Безопасность: non-root, минимум привилегий

Контейнер по умолчанию запускает процесс от root — и это лишний риск; учимся ужимать привилегии до необходимого минимума.

Принцип наименьших привилегий — процесс должен иметь ровно те права, что нужны для работы, и ни единого больше. В контейнерах это значит: не root, файловая система только для чтения, минимум capabilities.

Зачем это на практике

Контейнер — это не виртуалка, а процесс на хосте, изолированный ядром через namespaces и cgroups. Изоляция не абсолютна: если внутри образа запущен процесс от root (UID 0), то при удачном эксплойте в приложении или при опасной комбинации флагов (--privileged, проброс docker.sock) атакующий получает root на хосте, а не в песочнице. Поэтому первое правило прод-образа: процесс внутри работает от обычного пользователя.

По умолчанию базовые образы (например python, node, nginx) стартуют от root, потому что так проще установить пакеты при сборке. Но запускать приложение от root уже не нужно — установку делает слой сборки, а рантайму root ни к чему.

USER в Dockerfile

Инструкция USER переключает пользователя, от которого выполняются следующие команды и финальный CMD. Сначала создаём непривилегированного пользователя, потом переключаемся на него:

FROM python:3.12-slim

# создаём системного пользователя без shell и без home-каталога
RUN groupadd --system app && \
    useradd --system --gid app --no-create-home app

WORKDIR /app
COPY --chown=app:app . .
RUN pip install --no-cache-dir -r requirements.txt

# дальше всё работает от app, а не от root
USER app

CMD ["python", "main.py"]

Флаг --chown=app:app у COPY сразу отдаёт файлы нужному пользователю — иначе они останутся принадлежащими root, и процесс app не сможет их перезаписать (что как раз часто и нужно — см. read-only ниже). Многие официальные образы уже содержат готового непривилегированного пользователя: в node это node, в ряде образов — nobody.

Read-only файловая система

Здоровое приложение не должно писать в собственный код. Запускаем контейнер с файловой системой только для чтения — тогда даже при компрометации атакующий не подменит бинарники и не сбросит на диск вредонос:

docker run --read-only \
  --tmpfs /tmp \
  myapp:latest

Флаг --read-only делает корневую ФС неизменяемой. Реальным приложениям обычно нужно куда-то писать временные файлы — для этого монтируем --tmpfs /tmp (RAM-диск, исчезает вместе с контейнером) или именованный том для данных, которые должны пережить рестарт. В Compose то же самое:

services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
    volumes:
      - app-data:/var/lib/app
volumes:
  app-data:

Drop capabilities и no-new-privileges

Linux дробит всемогущество root на отдельные capabilities (право биндить порт < 1024, менять владельца файла, управлять сетью и т.д.). Docker по умолчанию уже отбирает большую часть, но прод-образу почти всегда не нужна ни одна. Снимаем все и добавляем точечно только необходимое:

docker run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  myapp:latest

--cap-drop ALL отбирает все capabilities; --cap-add NET_BIND_SERVICE возвращает единственную нужную, если приложению надо слушать порт ниже 1024 (хотя правильнее слушать высокий порт и пробрасывать его). Флаг --security-opt no-new-privileges запрещает процессу повышать привилегии через setuid-бинарники — даже если в образ затесался sudo или файл с setuid-битом, воспользоваться им не выйдет.

МераЧто закрывает
USER appпроцесс не root → эксплойт не даёт root на хосте
--read-onlyнельзя подменить код и сбросить вредонос на диск
--cap-drop ALLнет прав на низкоуровневые операции ядра
no-new-privilegesнельзя повысить привилегии через setuid

Как это работает под капотом

Когда вы пишете USER app, Docker не «создаёт» пользователя — он лишь записывает в конфиг образа UID/GID, под которым ядро запустит главный процесс. Сам пользователь должен уже существовать в /etc/passwd образа (его создал useradd на этапе сборки). Поэтому USER без предшествующего useradd либо упадёт, либо вы укажете числовой UID напрямую: USER 10001 — ядру имя не нужно, достаточно числа. Числовой UID даже надёжнее: он работает, даже если файла /etc/passwd в образе нет (как в scratch- и distroless-образах).

Capabilities и no-new-privileges — это флаги уже ядра, выставляемые в момент clone()/execve() при старте контейнера. Read-only достигается монтированием корня с флагом MS_RDONLY. Всё это — механизмы Linux, а Docker лишь удобный фронтенд к ним.

Частые ошибки

  • Поставить USER в начале Dockerfile. Тогда pip install/apt-get упадут на правах доступа. Переключайтесь на пользователя последней инструкцией, после установки зависимостей.
  • Забыть --chown при COPY. Файлы остаются root-овскими, и процесс не может их прочитать/перезаписать.
  • Включить --read-only без --tmpfs. Приложение упадёт на первой же записи лога или временного файла. Сначала найдите все каталоги, куда оно пишет.
  • Думать, что --privileged — это «просто чтобы заработало». Этот флаг снимает почти всю изоляцию и возвращает все capabilities; в проде он недопустим почти никогда.
  • Прокидывать /var/run/docker.sock внутрь контейнера. Это эквивалент выдачи root на хосте: через сокет можно запустить новый контейнер с любыми правами.

Итоги

  • Запускайте процесс от непривилегированного пользователя: useradd при сборке + USER последней инструкцией (или числовой UID).
  • Делайте корневую ФС --read-only, а для записи давайте tmpfs или том.
  • --cap-drop ALL и добавляйте capabilities точечно; включайте no-new-privileges.
  • Никогда не используйте --privileged и не пробрасывайте docker.sock без крайней нужды.
  • Принцип один: дайте контейнеру ровно столько прав, сколько ему нужно, и ни на грамм больше.
Проверьте себя
1. Почему USER в Dockerfile обычно ставят последней инструкцией, а не первой?
AТак требует синтаксис Dockerfile
BУстановка пакетов (apt/pip) требует root, поэтому переключаются на непривилегированного пользователя уже после неё
CUSER работает только в конце файла
DИначе образ не соберётся вообще никогда
2. Что делает флаг --security-opt no-new-privileges?
AЗапрещает контейнеру выходить в сеть
BДелает файловую систему только для чтения
CЗапрещает процессу повышать привилегии через setuid-бинарники
DУдаляет всех пользователей из образа
3. Зачем при --read-only обычно добавляют --tmpfs /tmp?
AЧтобы ускорить запуск контейнера
BПриложениям нужно куда-то писать временные файлы, а read-only корень это запрещает; tmpfs даёт записываемый RAM-диск
CБез tmpfs контейнер не получит IP-адрес
Dtmpfs шифрует данные на диске