USER и безопасность: запуск без root
По умолчанию процесс внутри контейнера запускается от root (UID 0). Это удобно: можно ставить пакеты, писать в любую директорию, биндить порты ниже 1024. Это и опасно: при container escape (через kernel CVE, мисконфигурацию privileged-флага, или Docker socket binding) атакующий получает root на хосте. Запуск от непривилегированного пользователя — фундаментальная практика production security, и DE-команды это применяют для каждого образа, который идёт в k8s или экспонируется в интернет.
В этом уроке: почему root опасен, как добавить непривилегированного пользователя, как работать с правами на bind mounts.
Почему root внутри контейнера это проблема
Распространённый миф: «контейнер изолирован, root внутри не страшен». Это частично правда — namespaces изолируют PID/network/mount/IPC. Но есть пути эскалации:
1. Container escape через kernel CVE. Иногда находят CVE в ядерных подсистемах (cgroups, overlayfs, namespaces). С root внутри контейнера эксплуатация проще — больше capabilities, больше attack surface. С non-root user CVE часто становится non-issue.
2. Privileged-флаг. docker run --privileged отключает почти всю изоляцию. Если по ошибке поднять контейнер privileged и быть root внутри — это full root на хосте.
3. Docker socket exposed. Если в контейнере смонтирован /var/run/docker.sock (бывает у CI tools, инструментов мониторинга), root внутри может через сокет запустить новый privileged контейнер с volume /:/host — и получить весь хост.
4. Bind mount + chown. Root в контейнере может chown -R 0 /mnt/host на bind-mount, что меняет owner’а файлов на хосте. С UID 1000 (non-root) такое не работает — UID не имеет CAP_CHOWN на чужие файлы.
5. Compliance. Любой security audit (SOC2, PCI-DSS, GDPR) требует non-root containers. Kubernetes PodSecurityStandards (Baseline / Restricted) блокируют root.
Запуск приложения от root в контейнере НЕ эквивалентен запуску от root на хосте, но это значительно увеличивает attack surface. Все production-pipelines требуют USER non-root, и это must-have практика, не «nice to have».
Capabilities и namespaces — разрезаем 'всемогущество root' на кусочки
Создание пользователя в Dockerfile
FROM python:3.13-slim
# Создать группу и пользователя
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash appuser
# Установить зависимости от root
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копировать код с правильным владельцем
COPY --chown=appuser:appgroup . /app
# Переключиться на непривилегированного пользователя
USER appuser
CMD ["python", "main.py"]
Что здесь происходит:
groupadd --gid 1000— создаём группу с фиксированным GID 1000.useradd --uid 1000 --gid 1000 --create-home— пользователя с UID 1000 (default user uid в Linux), создаём home directory/home/appuser.- Pip install от root — pip может требовать прав на установку в системные пути; делается до USER.
COPY --chown=appuser:appgroup— копируем сразу с правильным владельцем, иначе файлы будут owned by root.USER appuser— переключаемся; всё последующее выполняется от UID 1000.
USER можно указывать по имени или по UID:
USER appuser # по имени (требует пользователя в /etc/passwd)
USER 1000 # по UID (работает даже если /etc/passwd удалён)
USER 1000:1000 # UID:GID
USER appuser:appgroup # имя:имя
В distroless / scratch образах нет /etc/passwd — там нужно использовать UID числом.
Фиксированный UID 1000
Почему именно 1000?
- Это default first non-root user на большинстве Linux-систем (ubuntu, debian — первый созданный user получает UID 1000).
- В Kubernetes удобно: если bind-mount или PV содержит данные, owner’ом которых на хосте является UID 1000, контейнер с тем же UID может читать/писать без chmod 777.
- 1000 узнаваем — security-engineers сразу видят «не root, не системный пользователь, обычный app user».
В некоторых проектах используют --uid 10001 для дополнительной идентификации именно как «контейнерный пользователь». Это нормально.
Права на WORKDIR
Распространённый pitfall:
FROM python:3.13-slim
RUN useradd -m -u 1000 appuser
WORKDIR /app # создаёт /app owned by root
USER appuser
COPY . . # COPY от appuser, но /app owned by root -> permission denied (...)
Решение: chown WORKDIR перед USER:
FROM python:3.13-slim
RUN useradd -m -u 1000 appuser
WORKDIR /app
RUN chown appuser:appuser /app
USER appuser
COPY --chown=appuser:appuser . .
Или одной командой:
RUN useradd -m -u 1000 appuser && \
mkdir -p /app && \
chown appuser:appuser /app
WORKDIR /app
USER appuser
В современных Dockerfile можно вообще не делать WORKDIR /app до USER. Сделать USER первым, потом WORKDIR — но это работает только если USER уже имеет права на родительскую директорию. Безопаснее: mkdir + chown + WORKDIR + USER.
Bind mounts и UID-совпадение
Когда монтируешь host-директорию через docker run -v $PWD/data:/data, UID/GID файлов внутри контейнера = UID/GID на хосте. Если на хосте файлы owned by UID 501 (typical macOS user), а внутри контейнера USER 1000 — будет permission issue.
$ docker run -v $PWD/data:/data --user 1000 app
# python writes to /data/output.csv -- PermissionError: [Errno 13] Permission denied
Решения:
1. Использовать тот же UID на хосте. На Linux dev-машинах с user UID 1000 это работает «из коробки». На macOS UID хоста 501, что не совпадает с 1000.
2. Параметризовать UID через ARG.
ARG UID=1000
ARG GID=1000
RUN groupadd --gid ${GID} appgroup && \
useradd --uid ${UID} --gid ${GID} -m appuser
# При build на macOS
docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) -t app .
# Теперь UID внутри контейнера = UID на хосте, bind mount работает
docker run -v $PWD/data:/data app
3. На Docker Desktop / OrbStack: автоматический UID mapping. Docker Desktop делает автоматическое UID-mapping между хостом и контейнером для bind mounts (sort of works, but with edge cases).
4. К Kubernetes: использовать fsGroup в SecurityContext.
spec:
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000 # chown'ит PV в этот GID при mount
Capabilities: меньше прав
Docker по умолчанию даёт контейнеру частичный набор Linux capabilities (CAP_CHOWN, CAP_NET_BIND_SERVICE, CAP_SETUID, CAP_SETGID, CAP_SYS_CHROOT и др.) даже для root. Это меньше, чем root на хосте, но всё ещё значительно.
Для production-сервиса лучше drop’нуть всё и добавить только нужные:
docker run --cap-drop=ALL app
В compose:
services:
app:
image: my-app
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # если нужен listen на :80
С USER non-root + cap_drop ALL — даже container escape даёт атакующему минимум прав.
Read-only filesystem
Ещё один уровень: запретить запись в файловую систему контейнера. Все изменяемые директории явно объявляются как tmpfs или volume:
docker run --read-only --tmpfs /tmp --tmpfs /var/run app
В compose:
services:
app:
image: my-app
read_only: true
tmpfs:
- /tmp
- /var/run
volumes:
- app-data:/data # persistent data в named volume
Это блокирует:
- Atакующему создать новые бинарники в /usr/local/bin
- Tampering с конфигами в /etc
- Установку malicious python-пакетов через pip
Приложение должно быть готово: писать ТОЛЬКО в /tmp, /data, и т.д.
Hadolint правила
Hadolint проверяет много security-related правил для Dockerfile:
- DL3002 — Last USER should not be root
- DL3007 — Using latest tag (не security напрямую, но связано)
- DL3008 — Pin versions in apt-get install
- DL3013 — Pin versions in pip
- DL3045 — COPY to relative WORKDIR
В CI блокировка по этим правилам — стандарт.
# Запустить hadolint локально
docker run --rm -i hadolint/hadolint < Dockerfile
# С конфигом
docker run --rm -v $PWD:/work -w /work hadolint/hadolint hadolint Dockerfile
Тонкости: HEALTHCHECK работает от USER
HEALTHCHECK CMD curl -f http://localhost:8080/health выполняется тем USER, который активен в финальном stage. Если HEALTHCHECK требует curl/wget, а ты put USER 1000 без установки этих утилит — healthcheck сломается.
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# ...
USER appuser
HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:8080/health || exit 1
Подробнее про HEALTHCHECK — в следующем уроке.
Попробуй сам
Создай Dockerfile с непривилегированным пользователем:
mkdir non-root && cd non-root
cat > app.py <<'EOF'
import os
print(f"Running as UID={os.getuid()}, GID={os.getgid()}")
print(f"Home directory: {os.path.expanduser('~')}")
print(f"Can write to /etc?")
try:
with open('/etc/test', 'w') as f:
f.write('hello')
print(" YES (this is bad -- you're root)")
except PermissionError as e:
print(f" NO (good): {e}")
EOF
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
EOF
docker build -t non-root-demo .
docker run --rm non-root-demo
# Running as UID=1000, GID=1000
# Home directory: /home/appuser
# Can write to /etc?
# NO (good): [Errno 13] Permission denied: '/etc/test'
# Сравни с root-вариантом
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY . .
CMD ["python", "app.py"]
EOF
docker build -t root-demo .
docker run --rm root-demo
# Running as UID=0, GID=0
# Home directory: /root
# Can write to /etc?
# YES (this is bad -- you're root)
cd .. && rm -rf non-root
docker rmi non-root-demo root-demo