Запуск от non-root user
Самое простое и самое важное правило безопасности контейнеров: приложение не должно работать от root. Если приложение получит RCE, атакующий ограничен правами того пользователя, под которым крутится процесс. Root в контейнере (даже без rootless) даёт капабилити менять системные файлы, ставить пакеты, читать чужие mount’ы.
Junior часто думает «у меня же контейнер изолирован, какая разница». Разница есть. Внутри контейнера root всё ещё может: писать в любые файлы, рут-only бинари (если есть), потенциально escape через ядерные баги. На default-Docker root в контейнере = root на хосте (см. прошлый урок).
Поэтому: USER в Dockerfile — обязательный шаг production-образа.
rwx-права — девять битов, которые управляют доступом к каждому файлу
Простой случай: USER N
В Dockerfile:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
USER 1000
CMD ["python", "main.py"]
USER 1000 означает: все последующие RUN, CMD, ENTRYPOINT выполняются от UID 1000. Это просто число — пользователь с таким UID может не существовать внутри контейнера (/etc/passwd его не знает), но Linux’у это всё равно — права работают на уровне UID, не имён.
Запускаем:
docker build -t my-app .
docker run --rm my-app
# Внутри id (если бы был USER 1000 + установлен бинарь id):
# uid=1000 gid=0(root) groups=0(root)
gid=0(root) — потому что мы указали только UID, без группы. По умолчанию GID = 0 (root group). Чтобы и группу сменить — USER 1000:1000.
Использовать UID 1000 — общая конвенция, потому что на большинстве хостов это первый пользовательский UID (на хосте 1000 обычно ты сам). Это удобно для bind mount’ов: владелец совпадает с твоим юзером, можно удалять файлы без sudo.
Лучший вариант: создать пользователя
UID без имени — это работает, но whoami внутри упадёт с whoami: cannot find name for user ID 1000. Лучше создать настоящего пользователя:
FROM python:3.13-slim
WORKDIR /app
RUN useradd -m -u 1000 -s /bin/bash app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN chown -R app:app /app
USER app
CMD ["python", "main.py"]
useradd -m -u 1000 -s /bin/bash app — создаёт пользователя app с UID 1000, home /home/app, shell bash. На alpine синтаксис другой:
FROM python:3.13-alpine
RUN adduser -D -u 1000 app
adduser -D (no password). На distroless заранее настроен пользователь nonroot (UID 65532):
FROM gcr.io/distroless/python3-debian12
USER nonroot
ВАЖНО: chown перед USER
Это самая частая ошибка. Дано:
FROM python:3.13-slim
WORKDIR /app
RUN useradd -u 1000 app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
USER app
CMD ["python", "main.py"]
Сборка проходит, контейнер запускается. Что не так. /app и всё внутри принадлежит UID 0 (root) — потому что COPY и RUN выполнялись от root. Когда USER app перешёл на 1000, приложение в /app стало только для чтения.
Если приложение пишет в свою директорию (logs, cache, временные файлы) — Permission denied:
# Внутри
with open("/app/logs/today.log", "w") as f: # PermissionError
f.write(...)
Решение — chown до USER:
FROM python:3.13-slim
WORKDIR /app
RUN useradd -u 1000 app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN chown -R app:app /app # <-- от root, меняем владельца
USER app # <-- теперь переключаемся
CMD ["python", "main.py"]
Альтернативно через COPY --chown:
FROM python:3.13-slim
WORKDIR /app
RUN useradd -u 1000 app
COPY --chown=app:app requirements.txt .
RUN pip install -r requirements.txt
COPY --chown=app:app . .
USER app
CMD ["python", "main.py"]
Это эффективнее: COPY --chown делает chown в момент копирования, без отдельного слоя RUN chown (который добавляет размер).
Compose-вариант через user
Если образ уже собран от root и нет возможности пересобрать — можно задать пользователя на уровне compose:
services:
app:
image: my-app:latest
user: "1000:1000"
volumes:
- ./data:/data
user: "1000:1000" переопределит USER из Dockerfile, контейнер запустится от UID 1000. Это работает, но с теми же caveats — /app должен быть доступен на чтение, директории, в которые приложение пишет, должны быть chown 1000.
Удобный паттерн для bind mount’ов — использовать UID хост-пользователя:
services:
app:
image: my-app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./data:/data
В .env или окружении: UID=$(id -u) GID=$(id -g). Тогда контейнер запускается от твоего UID, файлы в ./data создаются от твоего имени — на хосте удаляются без sudo.
Типичная ошибка: Permission denied
Сценарий. Запускаешь свой образ, в логах:
[ERROR] [Errno 13] Permission denied: '/app/logs/today.log'
Гипотеза 1: USER переключился, но /app/logs не существует:
docker run --rm -it my-app sh
$ ls -la /app
drwxr-xr-x 3 app app 4096 May 15 logs # OK
$ ls -la /app/logs
total 0 # пустая директория, но app может туда писать
Гипотеза 2: /app/logs существует, но принадлежит root:
$ ls -la /app
drwxr-xr-x 3 app app 4096 May 15 .
drwxr-xr-x 2 root root 4096 May 15 logs # <-- здесь проблема
Чиним: chown -R app:app /app/logs в Dockerfile перед USER.
Гипотеза 3: смонтирован bind mount, и хостовая директория принадлежит root:
docker run -v /var/log/app:/app/logs my-app
# хост: ls -la /var/log/app -> drwxr-xr-x 2 root root
# в контейнере /app/logs тоже принадлежит root, app писать не может
Чиним на хосте: sudo chown 1000:1000 /var/log/app. Или используем user: "0:0" (root в контейнере, плохое решение). Или меняем --user, чтобы UID совпадал.
Compose-стенд с non-root postgres
Postgres в официальном образе уже работает от UID 999 (postgres юзер внутри). Проверь:
docker exec pg id
# uid=999(postgres) gid=999(postgres) groups=999(postgres)
Это значит: для bind mount data dir тебе нужно chown 999:999, иначе Postgres не сможет писать. Часто решают через volume (Docker создаёт его с правильными правами автоматически), а не bind mount.
Не используй --user root для Postgres, если только не дебажишь — image сделан для UID 999, и --user root может ломать ожидания entrypoint’а.
Production-чек: посмотреть, кто внутри
Готовый паттерн для CI / production-проверки:
docker run --rm my-image id
Хотим увидеть:
uid=1000(app) gid=1000(app) groups=1000(app)
Если видим:
uid=0(root) gid=0(root) groups=0(root)
— образ запускается от root. Это fail в production-grade проверке. Возвращаемся в Dockerfile, добавляем USER.
Правило: предпочитай USER в Dockerfile. user: в compose — для случаев, когда нет контроля над образом.
Что делать с привилегированными портами
Контейнер от UID 1000 не может биндить порты <1024 (по умолчанию). Если приложение слушает 80 — проблема:
USER 1000
EXPOSE 80
CMD ["python", "-m", "http.server", "80"]
# При запуске: PermissionError: [Errno 13] Permission denied
Решения:
- Слушать порт >1024 внутри.
EXPOSE 8080,python -m http.server 8080. При запуске пробрасываем-p 80:8080— хост принимает на 80, проксирует на 8080 внутри. - Capability
CAP_NET_BIND_SERVICE.docker run --cap-add NET_BIND_SERVICE my-app. Но это даёт капабилити, что снижает выгоду от non-root. - Setcap на бинарь. В Dockerfile:
RUN setcap cap_net_bind_service=+ep /usr/local/bin/python3.13. После этого Python может биндить low ports от non-root.
Стандарт: слушать высокий порт, пробрасывать через -p.
Попробуй сам
- Создай простой
Dockerfile:
Собери и запусти:FROM python:3.13-slim WORKDIR /app RUN useradd -u 1000 -m app COPY --chown=app:app . . USER app CMD ["python", "-c", "print('hello from', __import__('os').getuid())"]docker build -t lab-app . && docker run --rm lab-app. Должно вывестиhello from 1000. - Намерено сломай: убери
--chown=app:app, добавь в кодopen("/app/test.txt", "w"). Пересобери и запусти. Получишь Permission denied. - Почини: верни
--chown=app:app. Или добавьRUN mkdir /app/data && chown app:app /app/dataперед USER. - Запусти postgres-образ и проверь, что он работает от не-root:
docker exec pg id. UID должен быть 999. - Сделай свой Python-сервер на порту 80 от non-root — поймай PermissionError. Перепиши на 8080 +
-p 80:8080. Работает.