JavaRush /Курсы /Docker for Spring /Права и mounts при non-root запуске

Права и mounts при non-root запуске

Docker for Spring
24 уровень , 1 лекция
Открыта

1. Права файлов после USER appuser

Переход на non-root обычно выглядит как маленькая косметика в Dockerfile: ну подумаешь, добавили USER appuser. Но у контейнеров есть суперспособность: превращать «косметику» в «почему экспорт в CSV упал в проде». Сейчас разберём, почему права процесса определяют вообще всё, что будет происходить с файлами, и почему non-root не ломает систему, а делает её честной.

Когда вы запускали Spring Boot сервис от root, он мог писать почти куда угодно внутри контейнера. Это похоже на ситуацию «я админ на своём ноутбуке» — всё открывается, всё создаётся, никаких вопросов. Но как только вы переключаетесь на обычного пользователя, контейнер перестаёт быть “песочницей без правил” и становится обычным Linux-окружением, где у файлов есть владелец, группа и права доступа.

Самая частая точка боли в нашем проекте — экспорт каталога в файл. Экспорт — это не «показать JSON», это реальная запись на диск: Files.writeString(...). И если путь экспорта не writable для пользователя процесса, вы увидите не “плохой CSV”, а банальную ошибку уровня ОС.

При этом важно помнить ещё одну вещь: по умолчанию данные, записанные в контейнер, оказываются в его writable layer. Этот слой эфемерный: удалили контейнер — удалили и его данные. Docker прямо объясняет, что данные в writable layer не сохраняются при уничтожении контейнера, и для persistence/обмена с хостом нужно использовать mounts. Поэтому мы и используем mount для exports. Но mount тоже не отменяет прав — он лишь меняет «где именно» лежат файлы.

2. Модель прав: владелец, группа, rwx, UID

Не хочется превращать курс в «Linux Admin 3000», но совсем без модели прав мы не сможем делать взрослый non-root baseline. Сейчас соберём минимальную картину: кто такой владелец файла, что такое UID, почему chmod 777 — это не жизненная философия, и как читать ls -l так, чтобы он перестал выглядеть как магическая руническая надпись.

В Linux у каждого файла и директории есть владелец (user/UID) и группа (group/GID), а также права доступа в трёх категориях: для владельца, для группы и для «остальных». Права — это три буквы: r (read), w (write), x (execute). Но важно: для директории x означает не “запуск”, а “можно зайти внутрь / traversing”, то есть без x на папке вы не сможете нормально работать с её содержимым, даже если r стоит.

Вот компактная таблица, которую полезно держать в голове:

Объект r w x
Файл читать содержимое менять содержимое запускать как программу/скрипт
Директория видеть список имён (ls) создавать/удалять/переименовывать внутри “заходить” внутрь (cd), читать метаданные файлов

А теперь — чуть практики «как это выглядит» внутри контейнера:

# Смотрим, под каким пользователем реально живёт процесс в контейнере
docker exec -it catalog-app sh -lc 'id'
# uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)

Команда id показывает, кем вы являетесь в контейнере (и да, это реально важно). Когда вы видите uid=10001, это значит: процесс работает от пользователя с числовым идентификатором 10001. И вот здесь начинается важная деталь: файловые права завязаны на числа, а не на красивые имена. И Docker в build-time тоже оперирует числами довольно прямолинейно.

Например, обычный COPY в Dockerfile создаёт файлы с владельцем UID/GID = 0 (то есть root). Docker это описывает прямо: без --chown файлы создаются с UID/GID 0, а COPY --chown позволяет задать владельца. Это важная подсказка: если вы копируете артефакт или создаёте директорию под root, а потом запускаете процесс под appuser, то писать в эту директорию он не сможет, пока вы явно не подготовите права.

3. Writable paths в сервисе

Когда впервые сталкиваются с permission-проблемой, руки тянутся к «ну давайте сделаем writable вообще всё». Обычно это заканчивается заклинанием chown -R appuser / (и где-то в мире грустит один security-инженер). Мы поступим умнее: найдём минимальный набор writable paths, который действительно нужен Container-Ready Catalog Service, и подготовим именно его.

Начнём от домена. Наш сервис — каталог, и у него есть файловый сценарий: экспорт каталога в CSV. Это означает, что сервис обязан уметь записать файл в директорию экспорта. В проекте эта директория должна быть внешне конфигурируемой (в курсе это делалось через env var APP_EXPORT_DIR, а внутри приложения — через конфиг-параметр). Концептуально всё просто: мы хотим писать только туда, куда нам разрешили.

Самое удобное решение для контейнера — договориться, что внутри контейнера экспорт лежит в одном понятном месте, например:

  • /app/application.jar — читаем (запуск),
  • /app/data/exports — пишем (экспорт).

И дальше уже mount’ом выводим /app/data/exports наружу. Это выглядит как нормальный “шлюз” из контейнера в хостовую файловую систему.

Небольшая схема (не для красоты, а чтобы не путаться, где у нас что живёт):

%% Процесс пишет в директорию внутри контейнера, а Docker "пробрасывает" её на host через bind mount
flowchart LR
    P["Spring Boot процесс (appuser)"]
    D["/app/data/exports внутри контейнера"]
    H["./data/exports на host-машине"]

    P -->|"Files.writeString(...)"| D
    D -->|"bind mount"| H

Смысл такой: приложение пишет в /app/data/exports, а Docker делает так, что это на самом деле “портал” в ./data/exports на вашей машине. И здесь вспоминаем важный факт из документации: данные, записанные в контейнерный writable layer, не предназначены для сохранения между уничтожениями контейнера; для этого есть mounts.

Плюс отдельная маленькая, но полезная мысль: не всё должно быть writable. Если вы сделаете writable весь /app, вы повышаете шанс того, что где-то “случайно” появится файл, который не должен был появиться, а значит вы усложните дебаг. Нам нужна управляемость: один путь writable, всё остальное — обычный runtime.

4. Файловая среда в Dockerfile: mkdir, chown и COPY --chown

Теперь перейдём к самой прикладной части. На этом шаге многие студенты делают одну и ту же ошибку: ставят USER appuser, а потом пытаются RUN mkdir /app/data/exports. Угадайте, чем это заканчивается. Правильно: “Permission denied” и тихий вопрос к себе “а зачем я вообще полез в non-root”. Сейчас соберём правильный шаблон: сначала готовим файловую среду под root, потом переключаемся на appuser.

Ключевая дисциплина такая: в runtime stage мы создаём и настраиваем директории до строки USER appuser. Потому что до USER команды выполняются от root (и могут создавать директории/менять владельца), а после USER — уже нет.

Пример минимальной подготовки экспортной директории (фрагмент runtime stage):

ARG UID=10001

# Создаём пользователя (UID важен для совпадения прав) и готовим writable-директорию под экспорт
RUN adduser --disabled-password --uid "${UID}" appuser \
  && mkdir -p /app/data/exports \
  && chown -R appuser:appuser /app

WORKDIR /app
# Переключаемся на non-root только после подготовки файловой системы
USER appuser

Здесь два момента особенно важны. Во-первых, mkdir -p создаёт нужный каталог (и все промежуточные), а затем chown делает владельцем appuser. Во-вторых, мы “чиним” не весь образ, а только /app, то есть область приложения.

Дальше — копирование артефакта. Если вы копируете jar-файл в /app обычным COPY, то владельцем станет root. Это не всегда критично (jar обычно нужно только читать), но в финальном шаблоне удобнее держать права аккуратно, чтобы не было неожиданностей. Docker даёт для этого прямую механику: COPY --chown.

WORKDIR /app

# Копируем артефакт сразу с правильным владельцем, чтобы не делать отдельный RUN chown (лишний слой)
COPY --chown=appuser:appuser build/libs/catalog-service.jar application.jar

USER appuser
# Запуск — чтение jar, поэтому writable тут не нужен (важнее, что читать можно)
ENTRYPOINT ["java", "-jar", "application.jar"]

Обратите внимание, что я не делаю RUN chown application.jar отдельной командой. Во-первых, это лишний слой. Во-вторых, это “постфактумная уборка”, а COPY --chown делает то же самое сразу и прозрачно.

Если вы копируете jar из builder stage (multi-stage), идея та же:

# То же самое, но источник — другой stage: права выставляем при COPY
COPY --from=builder --chown=appuser:appuser /build/app.jar /app/application.jar

И ещё один маленький нюанс: Dockerfile-инструкции вроде WORKDIR /app могут создавать директорию, если её нет. Но создаётся она от root. Поэтому даже если у вас “как-то само создалось”, лучше считать, что права нужно подготовить явно, а не надеяться на доброту вселенной.

5. Bind mount: host-права в контейнере

После того как вы подготовили директорию в образе, может быть соблазн сказать: “Ну всё, я сделал chown /app/data/exports, значит писать можно”. И вот тут bind mount делает неожиданный (но честный) трюк: он подменяет содержимое директории тем, что лежит на хосте. Это означает, что права на файлы и владельцы могут оказаться вообще другими. Сейчас разберёмся, почему так происходит и как это видеть.

Docker поддерживает разные типы mounts. Нас в контексте проекта интересуют bind mounts (когда мы хотим видеть файлы на хосте) и volumes (когда хотим persistence без привязки к конкретному пути). Docker прямо описывает, что данные по умолчанию лежат в writable layer, а для persistence и обмена с хостом используются volume mounts и bind mounts.

Bind mount — это буквально «взяли каталог на host и показали его внутри контейнера». У него есть две важные особенности:

Первая — по умолчанию bind mount writable, и процесс внутри контейнера может менять файлы на хосте. Это может быть полезно, но и опасно; Docker рекомендует использовать readonly/ro, если запись не нужна. В нашем случае экспорт — это запись, поэтому exports-директория должна быть writable. А вот, например, конфиг-файлы чаще всего лучше монтировать read-only (об этом чуть позже).

Вторая — bind mount “накрывает” существующие файлы. Если внутри образа в /app/data/exports что-то лежало, а вы смонтировали туда host-папку, содержимое из образа становится невидимым, пока mount не уберёте. Docker описывает это как “bind-mounting over existing data obscures pre-existing files”. Поэтому рассчитывать на “папка была подготовлена в образе” недостаточно. Фактически после bind mount вы работаете с тем, что на host.

Отсюда вытекает самая частая причина странных ошибок: на Linux у host-папки владелец, например, uid=1000, а внутри контейнера процесс идёт от uid=10001. И если на папке нет прав на запись для “others” или группы — приложение будет видеть путь, но не сможет создавать файлы.

Проверяется это очень быстро изнутри контейнера:

# Сравниваем uid процесса и владельца каталога, куда пытаемся писать
docker exec -it catalog-app sh -lc 'id; ls -ld /app/data/exports'
# uid=10001(appuser) gid=10001(appuser) ...
# drwxr-xr-x 2 1000 1000 4096 ... /app/data/exports

Здесь видно, что каталог принадлежит 1000:1000, а наш процесс — 10001:10001. Даже если вы идеально подготовили /app/data/exports в образе, bind mount его «перекрыл», и права теперь диктует host.

Ещё один неприятный сценарий: если host-папки вообще не было, Docker может создать её автоматически (как директорию) в некоторых режимах синтаксиса, а --mount наоборот упадёт ошибкой «path does not exist». Docker это описывает как различие между --volume и --mount. Поэтому в учебном репозитории обычно держат data/exports/ как реальную директорию (часто с .gitkeep), чтобы она существовала заранее и не создавалась “случайно”.

6. APP_EXPORT_DIR и точка монтирования

Очень неприятная категория багов выглядит так: “Я всё сделал по non-root, права выставил, но всё равно ошибка”. И дальше выясняется, что сервис пишет не туда, куда вы смонтировали. Это не совсем permission-проблема, это проблема согласованности конфигурации. Сейчас мы свяжем три слоя: Spring Boot property, env var и точку монтирования в Compose.

Давайте сначала договоримся о понятном контракте. Внутри контейнера мы считаем, что export directory — это /app/data/exports. Это физический путь, в который пишет Java-код. И именно его мы хотим связать с host-папкой.

Во-первых, в Spring Boot конфиге удобно держать свойство с fallback по умолчанию:

app:
  # Если переменная окружения не задана — пишем в дефолтный путь внутри контейнера
  export-dir: ${APP_EXPORT_DIR:/app/data/exports}

Это ровно та идея, которую мы уже использовали раньше: один и тот же image живёт в разных окружениях, а окружение подаёт значения через env vars.

Во-вторых, со стороны Java-кода нам важно не хардкодить путь /root/exports или что-то из серии “на моей машине так было”. Минимальный вариант (просто показать идею) может выглядеть так:

import java.nio.file.Path;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
class ExportDirHolder {

  // Path приходит из конфигурации: так мы не привязываемся к конкретному пути в коде
  final Path exportDir;

  ExportDirHolder(@Value("${app.export-dir}") Path exportDir) {
    this.exportDir = exportDir;
  }
}

В-третьих, в Compose мы должны сделать так, чтобы env var и mount указывали в одну и ту же точку:

services:
  app:
    environment:
      # Должно совпадать с точкой монтирования ниже
      APP_EXPORT_DIR: /app/data/exports
    volumes:
      # Host-папка <-> контейнерная директория, куда пишет приложение
      - ./data/exports:/app/data/exports

Смысл прост: приложение пишет в /app/data/exports, и этот каталог действительно является bind mount в ./data/exports. Если вы ошиблись в одном символе (например, /app/data/export без s), то mount будет один, а запись — в другой каталог, и дальше вы получите либо потерю файлов, либо AccessDeniedException, либо “где мои CSV”.

7. Read-only mount :ro: чтение vs запись

В предыдущих пунктах мы делали всё writable, потому что экспорт — это запись. Но контейнеру часто нужны и “файлы чтения”: конфиги, шаблоны, какие-то статические ресурсы. И вот там writable по умолчанию может быть даже вредным: контейнер начинает иметь право менять файлы на вашей машине. Поэтому здесь появляется простой, но полезный приём — read-only mount через :ro.

Docker прямо говорит, что bind mounts по умолчанию имеют write-доступ, и его можно отключить через readonly/ro. Это удобно не только “из соображений безопасности”, но и банально для самодисциплины: если файл должен только читаться — давайте зафиксируем это в инфраструктуре, а не в надежде на здравый смысл всех участников проекта.

Пример: пусть у вас есть внешний конфиг-файл (например, для dev-режима), и вы хотите, чтобы контейнер его только читал. В Compose это может выглядеть так:

services:
  app:
    volumes:
      # Конфиг монтируем read-only, чтобы контейнер не мог его менять на host
      - ./config/application.yml:/config/application.yml:ro
    environment:
      # Сообщаем Spring Boot, откуда подхватить внешний конфиг
      SPRING_CONFIG_IMPORT: optional:file:/config/application.yml

Здесь :ro говорит Docker: “монтируй, но писать туда нельзя”. А SPRING_CONFIG_IMPORT — это просто способ сказать Spring Boot, где искать конфиг (механика импорта конфигов у нас уже была в модуле про externalized configuration, поэтому сейчас важнее сама идея read-only mount).

И да, важно не перепутать: export directory — всегда read-write, иначе сервис не сможет создать CSV. А конфиг-файл или справочник — логично read-only.

8. Диагностика permission problem: права, путь, mount

Permission-проблемы коварны тем, что они выглядят «как будто сломалось приложение». В логах есть stacktrace, там что-то про Java, и мозг автоматически начинает искать баги в коде. Сейчас соберём короткий, инженерный способ: как быстро понять, что это права/путь/mount, а не “ошибка в бизнес-логике экспорта”.

Первый и самый честный симптом permission issue — это исключения вида java.nio.file.AccessDeniedException с конкретным путём. Если в ошибке написано /app/data/exports/..., это уже почти ответ: ОС запретила запись.

Дальше проверка обычно идёт в таком порядке: сначала мы смотрим, от какого пользователя запущен процесс, потом проверяем существование и права на каталог, затем выясняем, что это за каталог — “из образа” или “смонтированный”, и только потом уже возвращаемся к коду.

Команды простые, но их важно читать вместе. Например, чтобы увидеть user + права на директорию:

# Одной командой получаем и uid/gid, и права на директорию экспорта
docker exec -it catalog-app sh -lc 'id; ls -ld /app/data/exports'

Если директории нет, вы увидите понятное сообщение No such file or directory. Если директория есть, но права не подходят — вы увидите владельца и режим (те самые drwxr-xr-x). Сопоставляете это с uid, и становится ясно, может ли процесс писать туда.

Если вы подозреваете, что путь “перекрыт” bind mount’ом, полезно посмотреть mounts контейнера через inspect:

# Проверяем, что именно смонтировано, и куда (Source/Destination)
docker inspect catalog-app --format '{{ json .Mounts }}'

Это покажет, что именно смонтировано, и куда. Важно, что mounts нужны не только “чтобы работало”, но и для диагностики: вы должны уметь доказать себе, что /app/data/exports — это действительно mount, а не просто папка внутри контейнера.

И наконец, если вы хотите проверить поведение “руками”, можно создать тестовый файл прямо из контейнера (не в смысле “делать так в проде”, а чтобы понять права):

# Пробуем записать тестовый файл в export-dir: это быстрый тест прав на запись
docker exec -it catalog-app sh -lc 'echo test > /app/data/exports/_probe.txt'
# если прав нет — получите Permission denied

Если _probe.txt создался, а экспорт не работает — значит проблема не в правах на директорию, а где-то дальше (например, неверный путь, другое имя директории, попытка писать в подпапку, которую вы не создали, и так далее). Но в 80% случаев уже на шаге id + ls -ld всё становится очевидно.

Чтобы это было совсем “по шпаргалке”, вот маленькая таблица, которая помогает не прыгать хаотично:

Вопрос Быстрая команда Что хотим увидеть
Кто я внутри контейнера?
id
UID/GID, совпадают ли ожидания
Есть ли директория?
ls -ld /app/data/exports
Существование + права
Это mount или папка из образа?
docker inspect ... .Mounts
Source/Destination
Могу ли я реально писать?
echo test > ...
Permission denied или успех

9. Типичные ошибки при non-root запуске с mounts

Ошибка №1: директорию подготовили в образе, но bind mount её перекрыл.
Вы сделали mkdir -p /app/data/exports && chown ..., порадовались, а потом в Compose смонтировали ./data/exports:/app/data/exports. В итоге внутри контейнера вы видите не “папку из образа”, а host-папку, и её права/владелец могут быть совсем другими. Docker прямо предупреждает, что bind mount “obscures” содержимое, которое было в директории до монтирования.

Ошибка №2: поставили USER appuser слишком рано, а потом делаете RUN mkdir и RUN chown.
После USER appuser ваши RUN выполняются уже не от root. Поэтому попытка создать /app/data/exports может упасть просто потому, что /app root-owned. В Dockerfile порядок — это не эстетика, а сценарий выполнения.

Ошибка №3: APP_EXPORT_DIR указывает не туда, куда вы монтируете volume/bind mount.
Очень легко сделать так, что volumes: ./data/exports:/app/data/exports, а в env var случайно оставить /app/exports. Тогда вы будете смотреть в ./data/exports и удивляться пустоте, а приложение будет писать в контейнерный writable layer (или падать, если у него там нет прав). Docker mount тут ни при чём — это рассинхронизация конфигурации.

Ошибка №4: host-директория не существовала, Docker создал её автоматически, и права стали неожиданными.
В зависимости от способа монтирования Docker может создать директорию на хосте, если её не было (часто как директорию), а --mount в таком случае наоборот даст ошибку. Docker описывает это различие, и оно реально всплывает в жизни. Поэтому лучше, когда директория экспорта существует в репозитории заранее, а не появляется “магически”.

Ошибка №5: случайно сделали export-директорию read-only :ro.
Read-only mounts — отличная вещь для конфигов, но экспорт — это запись. Если вы добавили :ro по привычке, сервис будет честно падать при попытке создать файл. Снаружи это выглядит как “ошибка экспорта”, но по факту это просто запрет записи.

1
Задача
Docker for Spring, 24 уровень, 1 лекция
Недоступна
Writable export directory через bind mount
Writable export directory через bind mount
1
Задача
Docker for Spring, 24 уровень, 1 лекция
Недоступна
Сохранение счётчика в named volume
Сохранение счётчика в named volume
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ