Learning Platform
Глоссарий Troubleshooting
Урок 16.02 · 22 мин
Начальный
dockersecuritydockerfile

Запуск от 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.

TIP

Использовать 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 (который добавляет размер).

Правильный порядок в Dockerfile
FROM pythonБазовый образ
useradd appСоздаём пользователя пока ещё root — useradd требует root
COPY/RUN под rootУстановка пакетов, копирование кода — это всё root-операции
chown -R app:app /appВАЖНЫЙ ШАГ — передать права на код пользователю app, иначе app не сможет писать в /app
USER appПереключаемся на пользователя app. Все последующие RUN/CMD/ENTRYPOINT идут от него
CMD python main.pyГлавный процесс контейнера запустится от app, не root

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.

Non-root в Dockerfile vs Compose
USER в DockerfileЗафиксировано в образе. Любой кто запустит этот образ — получит non-root. Best practice
лучше
Гарантия в productionНевозможно случайно запустить от root, кроме явного --user 0 при docker run
user: в composeOverride на уровне compose. Удобно для legacy-образов которые от root
хуже
Только в этом composeДругой человек запустит образ через docker run без флага — будет root. Защита неполная

Правило: предпочитай 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.


Попробуй сам

  1. Создай простой 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.
  2. Намерено сломай: убери --chown=app:app, добавь в код open("/app/test.txt", "w"). Пересобери и запусти. Получишь Permission denied.
  3. Почини: верни --chown=app:app. Или добавь RUN mkdir /app/data && chown app:app /app/data перед USER.
  4. Запусти postgres-образ и проверь, что он работает от не-root: docker exec pg id. UID должен быть 999.
  5. Сделай свой Python-сервер на порту 80 от non-root — поймай PermissionError. Перепиши на 8080 + -p 80:8080. Работает.

Проверка знанийKnowledge check
Почему недостаточно просто написать USER 1000 в Dockerfile, и какой шаг ОБЯЗАТЕЛЬНО делается перед USER чтобы избежать Permission denied при работе приложения?
ОтветAnswer
USER 1000 переключает контейнер на UID 1000 для всех последующих команд (RUN, CMD, ENTRYPOINT), но НЕ меняет владельца уже существующих файлов в /app. Эти файлы создавались командами COPY и RUN, которые выполнялись от root (UID 0). Поэтому /app, /app/main.py, /app/requirements.txt и все вложенные директории принадлежат root. UID 1000 имеет права только на чтение и выполнение root-файлов (если права 755), но не на запись. Если приложение пытается: - открыть файл на запись в /app (для логов, кеша, временных файлов) - создать новую директорию (например, /app/cache) - обновить существующий файл — получает PermissionError [Errno 13] Permission denied. Решение — chown ПЕРЕД USER: RUN chown -R app:app /app (или ещё лучше — COPY --chown=app:app) USER app Это передаёт владение от root к app. Теперь приложение может писать в свою директорию. Альтернативно: COPY --chown=app:app . . — делает chown в момент копирования, без отдельного слоя. Это самая частая ошибка Junior при первом написании production-Dockerfile. Запомни порядок: useradd -> COPY/RUN под root -> chown -> USER -> CMD.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. В Dockerfile написано: WORKDIR /app, COPY . ., RUN pip install -r requirements.txt, USER 1000, CMD python main.py. Приложение пишет лог в /app/logs/today.log. Что произойдёт при запуске?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5