Короткий ответ
Root в контейнере — не root хоста сам по себе, но с bind /, capabilities или sock становится им. Для сервиса web проекта app задайте USER в образе и/или user: "1000:1000" (или uid образа) в compose, read_only: true + tmpfs, no-new-privileges. Сначала выровняйте владельца app_data, иначе получите EACCES: права volume. Не чините права privileged.
Симптомы и как отличить
docker compose exec web id→uid=0(root);- Dockerfile заканчивается
CMDбезUSER; - файлы в overlay rw принадлежат root;
- официальный postgres уже non-root, а ваш кастомный веб — нет.
| Процесс | Норма? |
|---|---|
| nginx master 0, worker 101 | пакетный паттерн, смотрите workers |
| gunicorn 0 | нет |
| postgres 999 | да |
| sidecars 0 + sock | инцидент |
Возможные причины
- Образ
ubuntu/pythonкак база, забыли USER. - Entrypoint
gosu/su-execсломан, остались 0. - Нужен был bind :80.
- chown в entrypoint от root каждый старт.
- Копипаста
user: "0:0"после проблем с volume. - Редко:
--user rootв systemd wrapper.
Диагностика
docker inspect app-web-1 --format 'configUser={{.Config.User}} pid1={{.State.Pid}}'
ps -o user,uid,pid,cmd -p $(docker inspect -f '{{.State.Pid}}' app-web-1)
docker compose exec web id
docker inspect app-web-1 --format 'ro={{.HostConfig.ReadonlyRootfs}} nnp={{.HostConfig.SecurityOpt}}'Слои и кто владеет приложением:
docker compose exec web ls -lan /app /var/lib/app
grep -n USER /opt/app/DockerfileРешение
Сценарий A. Свой образ
Dockerfile:
RUN useradd --system --uid 10001 --no-create-home app \
&& chown -R app:app /app
USER 10001:10001Числовой USER предпочтителен (нет /etc/passwd сюрпризов). Сборка, push в registry.example:5000/app/web:1.2.4, recreate. chown app_data под 10001 на остановленном контейнере.
Сценарий B. Нельзя пересобрать образ сразу
services:
web:
user: "10001:10001"
read_only: true
tmpfs:
- /tmp
- /var/run
security_opt:
- no-new-privileges:true
cap_drop:
- ALLБинарник должен быть исполняем этим uid. Если файлы образа 700 root — без rebuild не взлетит.
Сценарий C. Порт 80
Публикуйте 8080:8080, в приложении bind 8080. Не root ради CAP_NET_BIND, если можно не слушать 80 внутри.
Сценарий D. Официальный образ с существующим USER
Не перебивайте user: "0:0". Оставьте uid образа, chown тома под него.
Проверка drop:
docker compose exec web id
docker compose exec web touch /tmp/x && echo tmp_ok
docker compose exec web sh -c 'touch /etc/shadow' && echo BAD || echo rootfs_protectedПри read_only запись в /etc должна падать.
Root в user namespace хоста (rootful Engine) совпадает с uid 0 хоста на bind-монтированных файлах: файл, созданный контейнером на bind /opt/app/data, будет root:root на host.example. На named volume это тоже uid 0 на Mountpoint. Дальнейший non-root старт сломается, пока не chown. Поэтому переход «сначала USER, потом данные» требует окна и бэкапа.
read_only: true закрывает запись в overlay. Приложение, которое пишет PID-файл в /var/run и кэш в /tmp, получит EROFS. Это не повод вернуть root: добавьте tmpfs. Кто пишет в /app на overlay — вынесите в volume или соберите файлы в образе заранее. SQLite на корневом слое при read_only умрёт; держите файл на app_data.
no-new-privileges блокирует переход к setuid-бинарям. Если в образе случайно sudo и entrypoint вызывает его — сломается, и правильно. Удалите sudo из runtime-образа. cap_drop: ALL у root всё ещё оставляет uid 0 для DAC (чтение чужих файлов в том же namespace, кроме user ns). Non-root нужен именно из-за DAC, не только из-за capabilities.
Официальные образы: postgres, redis, nginx workers уже не 0. Не ставьте user: "0:0" «для отладки» в YAML на неделю. Отладка: compose run --user 0 --rm --entrypoint sh web разово, не сервис.
Проверка в CI: docker inspect --format '{{.Config.User}}' не пусто; compose exec web id -u не 0. Исключения списком в репозитории, не устные.
Как проверить, что проблема устранена
docker compose exec web id
docker inspect app-web-1 --format '{{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}}'
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/healthuid ≠ 0 (исключение: мастер с drop — тогда workers ≠ 0, задокументируйте). Health ок. Том пишется.
Если не помогло
- Permission denied на data — UID тома, не «верните root».
- Нужен write в
/var/cache— том или tmpfs, не rw rootfs. - Java пишет в
/root—HOME=и user home в образе. nnpломает дочерний setuid — цель; уберите setuid из приложения.
Контрольный ps с хоста по PID1 контейнера (inspect .State.Pid) надёжнее exec id, если entrypoint делает drop после старта. Смотрите дочерние процессы: master uid 0 при workers ≠ 0 допустим только для nginx/apache пакетного типа и должен быть записан в исключение baseline.
Профилактика
- Шаблон Dockerfile с USER.
- CI:
docker inspect --format '{{.Config.User}}'≠ пусто. - grep compose на
user: \"0. - read_only по умолчанию для stateless.
- Baseline.
Смена USER ломает процессы, которые пишут в /root/.cache или слушают только от uid 0 из-за кода, не ядра. Задайте HOME=/var/lib/app, XDG_CACHE_HOME на volume/tmpfs. Для интерпретаторов проверьте, что site-packages в образе world-executable, не 700 root. Multi-stage: COPY --from=build --chown=10001:10001. Без --chown файлы останутся root и non-root не стартует — тогда «придётся» вернуть uid 0, это ловушка сборки, не требование runtime.
Не используйте gosu root в скриптах «только для миграции» на каждом старте: окно uid 0 каждый reboot. Вынесите migrate в отдельный compose run --rm --user 0 с явным тикетом или, лучше, migrate от того же uid, что владеет app_data.
FAQ
user: "1000:1000" на все сервисы?
Нет. Postgres/redis имеют свои uid. Один uid на сервис данных.
Rootless Engine vs USER в контейнере
Ортогонально. Делайте оба по возможности. USER обязателен и на rootful.
Можно ли USER в конце multi-stage забыть во runtime stage?
Да, классика. Проверяйте финальный inspect.
gosu vs USER
gosu в entrypoint ок, если финальный процесс не 0. Проверяйте ps не только Dockerfile.
read_only сломает sqlite на корне
Sqlite держите на app_data, не на overlay.