Learning Platform
Глоссарий Troubleshooting
Урок 09.03 · 22 мин
Средний
dockersecurityuserrootlesspermissions

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.

DANGER

Запуск приложения от 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"]

Что здесь происходит:

  1. groupadd --gid 1000 — создаём группу с фиксированным GID 1000.
  2. useradd --uid 1000 --gid 1000 --create-home — пользователя с UID 1000 (default user uid в Linux), создаём home directory /home/appuser.
  3. Pip install от root — pip может требовать прав на установку в системные пути; делается до USER.
  4. COPY --chown=appuser:appgroup — копируем сразу с правильным владельцем, иначе файлы будут owned by root.
  5. 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
TIP

В современных 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

Проверка знанийKnowledge check
Команда добавила USER 1000 в Dockerfile production-сервиса. При деплое в k8s контейнер падает: 'PermissionError: [Errno 13] Permission denied: /app/.cache/...' при попытке Python создать кэш. Что не учтено в Dockerfile?
ОтветAnswer
WORKDIR /app был создан до USER 1000, поэтому /app owned by root (UID 0). Когда python запускается от UID 1000, он не может писать в /app (и его поддиректории, типа /app/.cache, которое Python пытается создать). Решение: chown WORKDIR перед USER. Полный fix: RUN useradd -m -u 1000 appuser && mkdir -p /app && chown appuser:appuser /app, потом WORKDIR /app, потом USER appuser. Также все COPY должны быть с --chown=appuser:appuser, иначе скопированные файлы будут owned by root и appuser не сможет их модифицировать.

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

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Почему запуск процесса от root внутри контейнера считается плохой практикой для production?

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

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

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

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