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 всё становится очевидно.
Чтобы это было совсем “по шпаргалке”, вот маленькая таблица, которая помогает не прыгать хаотично:
| Вопрос | Быстрая команда | Что хотим увидеть |
|---|---|---|
| Кто я внутри контейнера? | |
UID/GID, совпадают ли ожидания |
| Есть ли директория? | |
Существование + права |
| Это mount или папка из образа? | |
Source/Destination |
| Могу ли я реально писать? | |
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 по привычке, сервис будет честно падать при попытке создать файл. Снаружи это выглядит как “ошибка экспорта”, но по факту это просто запрет записи.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ