Короткий ответ

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 iduid=0(root);
  • Dockerfile заканчивается CMD без USER;
  • файлы в overlay rw принадлежат root;
  • официальный postgres уже non-root, а ваш кастомный веб — нет.
ПроцессНорма?
nginx master 0, worker 101пакетный паттерн, смотрите workers
gunicorn 0нет
postgres 999да
sidecars 0 + sockинцидент

Возможные причины

  1. Образ ubuntu/python как база, забыли USER.
  2. Entrypoint gosu/su-exec сломан, остались 0.
  3. Нужен был bind :80.
  4. chown в entrypoint от root каждый старт.
  5. Копипаста user: "0:0" после проблем с volume.
  6. Редко: --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/health

uid ≠ 0 (исключение: мастер с drop — тогда workers ≠ 0, задокументируйте). Health ок. Том пишется.

Если не помогло

  • Permission denied на data — UID тома, не «верните root».
  • Нужен write в /var/cache — том или tmpfs, не rw rootfs.
  • Java пишет в /rootHOME= и 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.