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 не зберігаються після знищення контейнера, а для стійкого зберігання та обміну з хостом потрібно використовувати mounts. Тому ми й використовуємо mount для експорту. Але 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 взагалі все». Зазвичай це закінчується заклинанням chown -R appuser / (і десь у світі сумує один security-інженер). Ми вчинимо розумніше: знайдемо мінімальний набір записуваних шляхів, який справді потрібен Container-Ready Catalog Service, і підготуємо саме його.

Почнемо з домену. Наш сервіс — каталог, і в нього є файловий сценарій: експорт каталогу в CSV. Це означає, що сервіс мусить уміти записати файл у каталог експорту. У проєкті цей каталог має бути зовні конфігурованим (у курсі це робилося через env var APP_EXPORT_DIR, а всередині застосунку — через конфіг-параметр). Концептуально все просто: ми хочемо писати лише туди, куди нам дозволили.

Найзручніше рішення для контейнера — домовитися, що всередині контейнера export лежить в одному зрозумілому місці, наприклад:

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

І далі вже просто виводимо /app/data/exports назовні через mount. Це виглядає як нормальний «шлюз» із контейнера у файлову систему хоста.

Невелика схема (не для краси, а щоб не плутатися, де в нас що живе):

%% Процес записує у каталог всередині контейнера, а Docker «прокидає» його на хості через bind mount
flowchart LR
    P["Spring Boot процес (appuser)"]
    D["/app/data/exports всередині контейнера"]
    H["./data/exports на хості"]

    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: права хоста в контейнері

Після того як ви підготували каталог в образі, може виникнути спокуса сказати: «Ну все, я зробив chown /app/data/exports, отже писати можна». І ось тут bind mount робить неочікуваний, але чесний трюк: він підміняє вміст каталогу тим, що лежить на хості. Це означає, що права на файли і власники можуть виявитися зовсім іншими. Зараз розберемося, чому так відбувається і як це побачити.

Docker підтримує різні типи mounts. У контексті проєкту нас цікавлять bind mounts (коли ми хочемо бачити файли на хості) і volumes (коли хочемо стійке зберігання без прив’язки до конкретного шляху). Docker прямо описує, що дані за замовчуванням лежать у writable layer, а для стійкого зберігання й обміну з хостом використовуються volume mounts і bind mounts.

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

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

Друга — bind mount «накриває» наявні файли. Якщо всередині образу в /app/data/exports щось лежало, а ви змонтували туди папку з хоста, вміст з образу стає невидимим, доки ви не приберете mount. Docker описує це як «bind-mounting over existing data obscures pre-existing files». Тому розраховувати на «каталог був підготовлений в образі» недостатньо. Фактично після bind mount ви працюєте з тим, що на хості.

Звідси випливає найчастіша причина дивних помилок: на Linux у папки на хості власник, наприклад, uid=1000, а всередині контейнера процес іде від uid=10001. І якщо на папці немає прав на запис для «інших» або групи — застосунок бачитиме шлях, але не зможе створювати файли.

Перевіряється це дуже швидко зсередини контейнера:

# Порівнюємо 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-каталогу взагалі не було, Docker може створити його автоматично (як каталог) у деяких варіантах синтаксису, а --mount натомість упаде з помилкою «path does not exist». Docker описує це як різницю між --volume і --mount. Тому в навчальному репозиторії зазвичай тримають data/exports/ як реальний каталог (часто з .gitkeep), щоб він існував заздалегідь і не створювався «випадково».

6. APP_EXPORT_DIR і точка монтування

Дуже неприємна категорія багів виглядає так: «Я все зробив із non-root, права виставив, але помилка все одно є». І далі з’ясовується, що сервіс пише не туди, куди ви змонтували. Це вже не зовсім permission-проблема, а проблема узгодженості конфігурації. Зараз ми зв’яжемо три шари: властивість Spring Boot, 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:
      # Папка на хості <-> контейнерний каталог, куди пише застосунок
      - ./data/exports:/app/data/exports

Сенс простий: застосунок пише в /app/data/exports, і цей каталог справді є bind mount до ./data/exports. Якщо ви помилилися в одному символі (наприклад, /app/data/export без s), то mount буде один, а запис — в інший каталог, і далі ви отримаєте або втрату файлів, або AccessDeniedException, або «де мої CSV».

7. Монтування лише для читання :ro: читання vs запис

У попередніх пунктах ми робили все writable, тому що експорт — це запис. Але контейнеру часто потрібні й «файли для читання»: конфіги, шаблони, якісь статичні ресурси. І ось там writable за замовчуванням може бути навіть шкідливим: контейнер починає мати право змінювати файли на вашій машині. Тому тут з’являється простий, але корисний прийом — read-only mount через :ro.

Docker прямо говорить, що bind mounts за замовчуванням мають write-доступ, і його можна вимкнути через readonly/ro. Це зручно не лише з міркувань безпеки, а й просто для самодисципліни: якщо файл має лише читатися — давайте зафіксуємо це в інфраструктурі, а не покладатимемося на здоровий глузд усіх учасників проєкту.

Приклад: нехай у вас є зовнішній конфіг-файл (наприклад, для dev-режиму), і ви хочете, щоб контейнер його лише читав. У Compose це може виглядати так:

services:
  app:
    volumes:
      # Конфіг монтуємо read-only, щоб контейнер не міг змінювати його на хості
      - ./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. Діагностика проблем із правами: права, шлях, mount

Проблеми з правами підступні тим, що вони виглядають так, ніби зламався застосунок. У логах є 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. У результаті всередині контейнера ви бачите не «каталог з образу», а папку з хоста, і її права та власник можуть бути зовсім іншими. Docker прямо попереджає, що bind mount «obscures» вміст, який був у каталозі до монтування.

Помилка № 2: поставили USER appuser занадто рано, а потім робите RUN mkdir і RUN chown.
Після USER appuser ваші RUN виконуються вже не від root. Тому спроба створити /app/data/exports може впасти просто тому, що /app належить root. У Dockerfile порядок — це не естетика, а сценарій виконання.

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

Помилка № 4: host-каталог не існував, Docker створив його автоматично, і права стали неочікуваними.
Залежно від способу монтування Docker може створити каталог на хості, якщо його не було (часто як каталог), а --mount у такому разі навпаки дасть помилку. Docker описує цю різницю, і вона реально спливає в житті. Тому краще, коли каталог експорту існує в репозиторії заздалегідь, а не з’являється «магічно».

Помилка № 5: випадково зробили export-каталог read-only :ro.
Read-only mounts — чудова річ для конфігів, але експорт — це запис. Якщо ви додали :ro за звичкою, сервіс буде чесно падати під час спроби створити файл. Ззовні це виглядає як «помилка експорту», але фактично це просто заборона запису.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ