6.7. Non-root user и permissions
Цели
После этого материала вы сможете:
- создать непривилегированного пользователя в образе и объяснить выбор UID;
- объяснить, почему UID важнее имени пользователя;
- расположить
USER,WORKDIRиCOPYв правильном порядке; - диагностировать и исправлять ошибки прав доступа без
chmod 777; - обеспечить запись в каталоги, нужные Python-приложению;
- подготовить образ к запуску с read-only root filesystem.
Предварительные знания
- 5.2. Базовые инструкции —
USER,WORKDIR; - 5.3. COPY, ADD и RUN —
--chown,--chmod; - 2.3. Linux namespaces — user namespace не создаётся по умолчанию;
- понимание прав доступа Linux: владелец, группа, биты
rwx.
Ключевые термины
| Термин | Объяснение |
|---|---|
UID | Числовой идентификатор пользователя |
GID | Числовой идентификатор группы |
supplementary groups | Дополнительные группы процесса |
system user | Пользователь с UID ниже 1000, обычно без домашнего каталога |
read-only rootfs | Режим, в котором корневая файловая система недоступна для записи |
umask | Маска, определяющая права создаваемых файлов |
Теория
Почему root в container — проблема
Docker по умолчанию не создаёт user namespace (урок 2.3). Значит, UID 0 внутри container — это UID 0 на host.
Последствия:
| Риск | Механизм |
|---|---|
Побег из container даёт root на host | Уязвимость ядра или ошибка конфигурации |
Файлы в bind mount принадлежат root | Обычный пользователь host не может их удалить |
| Смонтированные каталоги host доступны на запись | При наличии bind mount |
| Установка пакетов в runtime | Скомпрометированное приложение может доустановить инструменты |
Отбор capabilities (урок 2.5) ограничивает root в container, но не делает его безопасным: он по-прежнему обходит проверки прав внутри container благодаря CAP_DAC_OVERRIDE.
Запуск от непривилегированного пользователя — базовое требование production (раздел 11).
Почему UID важнее имени
Имя пользователя существует только внутри container — оно берётся из /etc/passwd образа. Ядро оперирует числами.
Отсюда три следствия:
1. При bind mount значение имеет UID. Файл, созданный пользователем appuser с UID 10001, на host принадлежит UID 10001 — независимо от того, есть ли там такой пользователь.
2. Kubernetes проверяет число. Настройка runAsNonRoot: true требует, чтобы образ задавал числовой UID. Если в USER указано имя, оркестратор не сможет проверить, что это не root, и откажется запускать container.
3. Имя может отсутствовать в другой системе. При смене базового образа пользователь с тем же именем может иметь другой UID.
Поэтому правильная запись — USER 10001:10001, а не USER appuser.
Выбор UID
| Диапазон | Назначение | Пригодность |
|---|---|---|
| 0 | root | Нет |
| 1–999 | Системные пользователи дистрибутива | Возможен конфликт с существующими |
| 1000–9999 | Обычные пользователи | Может совпасть с UID разработчика на host |
| 10000+ | Свободный диапазон | Рекомендуется |
| 65534 | nobody | Нет: разделяется со всей системой |
Про nobody стоит сказать отдельно. Он существует в большинстве образов и кажется удобным выбором, но:
- разделяется со всеми процессами системы, использующими
nobody; - не имеет домашнего каталога — многие инструменты ломаются;
- в разных дистрибутивах имеет разный UID (65534 или 99).
Выделенный пользователь с UID из диапазона 10000+ решает все три проблемы.
Порядок инструкций
Три правила, нарушение которых даёт Permission denied.
Правило 1: USER — в конце. Установка пакетов, создание каталогов и chown требуют прав root.
FROM python:3.13-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* # нужен root
RUN useradd --create-home --uid 10001 appuser # нужен root
COPY --chown=10001:10001 app/ /app/ # нужен root
USER 10001:10001 # переключение в самом конце
CMD ["python", "/app/main.py"]
Правило 2: WORKDIR создаёт каталог от текущего пользователя. Порядок WORKDIR и USER определяет владельца (урок 5.2):
WORKDIR /app # каталог принадлежит root
USER 10001
RUN touch /app/f # Permission denied
Правило 3: COPY без --chown копирует от root. Даже после USER инструкция COPY создаёт файлы с владельцем root, если не указано иное.
Что нужно приложению на запись
Python-приложение обычно требует записи в несколько мест:
| Путь | Зачем | Обязателен |
|---|---|---|
/tmp | Временные файлы, tempfile | Часто |
| Домашний каталог | Кэш инструментов, ~/.cache | Иногда |
| Каталог данных приложения | Загрузки, состояние | По задаче |
__pycache__ рядом с кодом | Байт-код | Нет, если задан PYTHONDONTWRITEBYTECODE |
Последняя строка — причина, по которой PYTHONDONTWRITEBYTECODE=1 упрощает работу с non-root: без него Python пытается создать __pycache__ в каталоге приложения, принадлежащем root.
Ошибка при этом не возникает — Python просто пропускает запись байт-кода. Но при read-only rootfs поведение может отличаться.
chmod 777 не решение
Команда даёт всем пользователям полный доступ. Она «чинит» симптом и создаёт проблему:
| Проблема | Следствие |
|---|---|
| Любой процесс в container может изменить файлы | Скомпрометированное приложение перезапишет свой код |
| При bind mount права распространяются на host | Файлы host становятся доступны всем |
| Скрывает настоящую причину | Реальная проблема — неверный владелец |
Правильные решения перечислены в порядке предпочтения:
COPY --chownпри копировании — без дополнительного слоя.RUN chownдо переключения пользователя — если владелец меняется у существующих файлов.- Права группы (
chmod g+w) плюс--group-add— для разделяемых каталогов. tmpfsдля временных данных — не требует прав на файловой системе образа.
Read-only root filesystem
Флаг --read-only делает корневую файловую систему недоступной для записи. Это сильное ограничение: приложение не сможет изменить ни свой код, ни системные файлы.
Приложению всё равно нужны каталоги для записи — они предоставляются через tmpfs:
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
myapp
Подготовка образа к этому режиму — то же самое, что аккуратная работа с правами: нужно точно знать, куда приложение пишет. Подробно разбирается в разделе 07 и разделе 11.
Внутренний механизм
Как ядро проверяет права
При обращении к файлу ядро сравнивает UID и GID процесса с владельцем файла:
UID процесса == UID владельца? ──► применяются права владельца (rwx------)
│ нет
▼
GID процесса или supplementary
groups содержит GID файла? ──► применяются права группы (---rwx---)
│ нет
▼
применяются права остальных (------rwx)
Важно: проверяется первое совпадение, а не наиболее разрешающее. Если вы владелец файла с правами r--rw-rw-, вы получите только чтение, хотя группа и остальные имеют запись.
Это регулярно удивляет при отладке прав.
Почему useradd против adduser
В Debian-образах доступны обе команды, но они разные:
useradd | adduser | |
|---|---|---|
| Тип | Низкоуровневая утилита | Скрипт-обёртка (Perl) |
Есть в slim | да | да |
| Есть в Alpine | нет (там свой adduser) | да, но другой синтаксис |
| Интерактивность | нет | может запрашивать ввод |
Для Dockerfile предпочтительна useradd в Debian-образах и adduser в Alpine — синтаксис у них разный:
# Debian, Ubuntu
RUN useradd --create-home --uid 10001 appuser
# Alpine
RUN adduser -D -u 10001 appuser
Команды и примеры
Подготовка
mkdir -p /tmp/perms/app && cd /tmp/perms
cat > app/main.py <<'PY'
"""Приложение, проверяющее доступные права."""
import os
import tempfile
from pathlib import Path
print(f"UID={os.getuid()} GID={os.getgid()} groups={os.getgroups()}")
checks = {
"/tmp": Path("/tmp"),
"домашний каталог": Path(os.path.expanduser("~")),
"/app": Path("/app"),
"/app/data": Path("/app/data"),
}
for label, path in checks.items():
if not path.exists():
print(f" {label:<20} отсутствует")
continue
try:
probe = path / ".write-probe"
probe.write_text("x")
probe.unlink()
print(f" {label:<20} запись доступна")
except OSError as exc:
print(f" {label:<20} ЗАПРЕЩЕНА ({exc.strerror})")
try:
with tempfile.NamedTemporaryFile() as f:
f.write(b"x")
print(" tempfile работает")
except OSError as exc:
print(f" tempfile ОШИБКА ({exc.strerror})")
PY
Что даёт запуск от root
cat > Dockerfile.root <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY app/ ./app/
RUN mkdir -p /app/data
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.root -t perm:root . > /dev/null
docker run --rm perm:root
UID=0 GID=0 groups=[0]
/tmp запись доступна
домашний каталог запись доступна
/app запись доступна
/app/data запись доступна
tempfile работает
Всё доступно — потому что это root. Проверим последствие для bind mount:
mkdir -p shared
docker run --rm -v "$PWD/shared:/data" perm:root \
sh -c 'echo "создано в container" > /data/file.txt'
ls -l shared/file.txt
echo "попытка удалить обычным пользователем:"
rm shared/file.txt 2>&1 | head -1 || echo " не удалось"
sudo rm -f shared/file.txt 2>/dev/null
-rw-r--r-- 1 root root 20 Jul 30 13:20 shared/file.txt
попытка удалить обычным пользователем:
rm: удалить защищённый от записи обычный файл 'shared/file.txt'?
Файл принадлежит root на host. Разработчик не может его удалить без sudo — классическая проблема, разбираемая в разделе 07.
Наивный переход на non-root
cat > Dockerfile.naive <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY app/ ./app/
RUN mkdir -p /app/data
USER 10001:10001
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.naive -t perm:naive . > /dev/null
docker run --rm perm:naive
UID=10001 GID=10001 groups=[10001]
/tmp запись доступна
домашний каталог запись доступна
/app ЗАПРЕЩЕНА (Permission denied)
/app/data ЗАПРЕЩЕНА (Permission denied)
tempfile работает
UID правильный, но приложение не может писать в свои каталоги. Причина видна сразу:
docker run --rm --user 0:0 perm:naive ls -ld /app /app/data
drwxr-xr-x 3 root root 4096 Jul 30 13:22 /app
drwxr-xr-x 2 root root 4096 Jul 30 13:22 /app/data
WORKDIR /app создал каталог от root, RUN mkdir — тоже. Переключение USER в конце ничего не изменило.
Правильный вариант
cat > Dockerfile.correct <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# UID из свободного диапазона 10000+; --create-home нужен для ~/.cache
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin appuser
WORKDIR /app
# Каталог данных создаётся и передаётся пользователю ДО переключения
RUN mkdir -p /app/data && chown -R 10001:10001 /app
# --chown избавляет от отдельной инструкции RUN chown
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.correct -t perm:correct . > /dev/null
docker run --rm perm:correct
UID=10001 GID=10001 groups=[10001]
/tmp запись доступна
домашний каталог запись доступна
/app запись доступна
/app/data запись доступна
tempfile работает
Все проверки проходят. Bind mount теперь создаёт файлы с правильным владельцем:
docker run --rm -v "$PWD/shared:/data" perm:correct \
sh -c 'echo "от appuser" > /data/file2.txt' 2>/dev/null || \
docker run --rm --user 10001:10001 -v "$PWD/shared:/data" perm:correct \
python -c "open('/data/file2.txt','w').write('от appuser')" 2>&1 | tail -1
ls -ln shared/ 2>/dev/null | tail -2
-rw-r--r-- 1 10001 10001 10 Jul 30 13:25 file2.txt
Файл принадлежит UID 10001. Если это ваш UID на host — вы сможете его удалить обычным способом; иначе понадобится сопоставление UID, разбираемое в разделе 07.
Порядок WORKDIR и USER
cat > Dockerfile.order-bad <<'EOF'
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
USER 10001:10001
WORKDIR /late-workdir
RUN touch /late-workdir/probe && echo "запись удалась"
CMD ["true"]
EOF
echo "=== WORKDIR ПОСЛЕ USER ==="
docker build -f Dockerfile.order-bad -t perm:order-bad . 2>&1 | grep -E 'запись|ERROR' | head -2
cat > Dockerfile.order-good <<'EOF'
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
WORKDIR /early-workdir
RUN chown 10001:10001 /early-workdir
USER 10001:10001
RUN touch /early-workdir/probe && echo "запись удалась"
CMD ["true"]
EOF
echo "=== WORKDIR ДО USER, с chown ==="
docker build -f Dockerfile.order-good -t perm:order-good . 2>&1 | grep -E 'запись|ERROR' | head -2
=== WORKDIR ПОСЛЕ USER ===
#6 0.185 запись удалась
=== WORKDIR ДО USER, с chown ===
#7 0.192 запись удалась
Оба варианта работают, но по-разному: в первом WORKDIR создал каталог уже от имени appuser, во втором потребовался явный chown.
Первый вариант короче, но имеет ограничение: после USER нельзя выполнять apt-get и прочие привилегированные операции. На практике WORKDIR обычно нужен раньше, поэтому применяется второй вариант.
UID вместо имени
cat > Dockerfile.name <<'EOF'
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
USER appuser
CMD ["id"]
EOF
cat > Dockerfile.uid <<'EOF'
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
USER 10001:10001
CMD ["id"]
EOF
for v in name uid; do
docker build -q -f "Dockerfile.$v" -t "perm:$v" . > /dev/null
printf '%-6s Config.User = %-12s запуск: %s\n' "$v" \
"$(docker image inspect "perm:$v" --format '{{.Config.User}}')" \
"$(docker run --rm "perm:$v")"
done
name Config.User = appuser запуск: uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
uid Config.User = 10001:10001 запуск: uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
Результат запуска одинаков. Различие — в поле Config.User: во втором случае там число, которое может проверить оркестратор.
Проверка, аналогичная той, что делает Kubernetes с runAsNonRoot:
for v in name uid; do
u="$(docker image inspect "perm:$v" --format '{{.Config.User}}')"
if [ -z "$u" ]; then
verdict="ОТКАЗ: User не задан"
elif echo "$u" | grep -qE '^[0-9]+'; then
verdict="принято: числовой UID"
else
verdict="ОТКАЗ: имя, а не UID — проверить нельзя"
fi
printf '%-6s %s\n' "$v" "$verdict"
done
name ОТКАЗ: имя, а не UID — проверить нельзя
uid принято: числовой UID
Почему не nobody
cat > Dockerfile.nobody <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY app/ ./app/
USER nobody
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.nobody -t perm:nobody . > /dev/null
docker run --rm perm:nobody
UID=65534 GID=65534 groups=[65534]
/tmp запись доступна
домашний каталог ЗАПРЕЩЕНА (Permission denied)
/app ЗАПРЕЩЕНА (Permission denied)
/app/data отсутствует
tempfile работает
Домашний каталог nobody — это /nonexistent, и запись в него невозможна. Инструменты, использующие ~/.cache, сломаются.
docker run --rm --entrypoint sh perm:nobody -c 'getent passwd nobody'
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
Права на .venv при multi-stage
Типичная ошибка при копировании окружения между стадиями:
cat > Dockerfile.venv-bad <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir httpx==0.28.1
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
USER 10001:10001
CMD ["sh", "-c", "ls -ld /opt/venv && python -c 'import httpx; print(\"импорт работает\")'"]
EOF
docker build -q -f Dockerfile.venv-bad -t perm:venv-bad . > /dev/null
docker run --rm perm:venv-bad
drwxr-xr-x 5 root root 4096 Jul 30 13:32 /opt/venv
импорт работает
Здесь всё работает: для импорта достаточно чтения, а права r-x для остальных есть.
Проблема возникнет, если приложению нужна запись в окружение — например, при установке пакета в runtime или при использовании инструмента, пишущего рядом с собой. Явный --chown устраняет неопределённость:
sed 's|COPY --from=builder /opt/venv|COPY --from=builder --chown=10001:10001 /opt/venv|' \
Dockerfile.venv-bad > Dockerfile.venv-good
docker build -q -f Dockerfile.venv-good -t perm:venv-good . > /dev/null
docker run --rm perm:venv-good
drwxr-xr-x 5 10001 10001 4096 Jul 30 13:33 /opt/venv
импорт работает
Флаг
--chownуCOPY --fromне создаёт дополнительного слоя, в отличие отRUN chown -R, который продублировал бы всё окружение из-за copy-up (урок 5.3). На окружении в 200 MB разница существенна.
Сравним:
cat > Dockerfile.venv-chown <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir httpx==0.28.1
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder /opt/venv /opt/venv
RUN chown -R 10001:10001 /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
USER 10001:10001
CMD ["true"]
EOF
docker build -q -f Dockerfile.venv-chown -t perm:venv-chown . > /dev/null
docker images --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'perm:venv'
perm:venv-chown 220MB
perm:venv-good 198MB
perm:venv-bad 198MB
Отдельный RUN chown -R добавил 22 MB — копию всего окружения. Абсолютные числа зависят от версии базового образа; постоянна разница между строками.
(Ведущие пробелы из строки формата в выводе не появляются: docker images --format их отбрасывает.)
PYTHONDONTWRITEBYTECODE и non-root
cat > Dockerfile.pyc <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --chown=10001:10001 app/ ./app/
RUN chown 10001:10001 /app
USER 10001:10001
CMD ["sh", "-c", "python -c 'import sys; sys.path.insert(0,\"/app\"); import app.main' 2>/dev/null; find /app -name '__pycache__' | wc -l"]
EOF
docker build -q -f Dockerfile.pyc -t perm:pyc . > /dev/null
echo -n "с правом записи в /app, __pycache__ создан: "
docker run --rm perm:pyc
# теперь /app принадлежит root
sed 's|RUN chown 10001:10001 /app||' Dockerfile.pyc > Dockerfile.pyc-ro
sed -i 's|COPY --chown=10001:10001|COPY|' Dockerfile.pyc-ro
docker build -q -f Dockerfile.pyc-ro -t perm:pyc-ro . > /dev/null
echo -n "без права записи, __pycache__ создан: "
docker run --rm perm:pyc-ro
с правом записи в /app, __pycache__ создан: 1
без права записи, __pycache__ создан: 0
Во втором случае Python не смог создать __pycache__ — и не выдал ошибки. Это нормальное поведение: запись байт-кода необязательна.
С PYTHONDONTWRITEBYTECODE=1 попытки не будет вовсе, что убирает неопределённость и лишние системные вызовы.
Read-only root filesystem
cat > Dockerfile.ro <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --chown=10001:10001 app/ ./app/
RUN mkdir -p /app/data && chown -R 10001:10001 /app
USER 10001:10001
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.ro -t perm:ro . > /dev/null
echo "=== обычный запуск ==="
docker run --rm perm:ro
echo
echo "=== с --read-only, без tmpfs ==="
docker run --rm --read-only perm:ro
echo
echo "=== с --read-only и tmpfs ==="
docker run --rm --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /home/appuser:rw,noexec,nosuid,size=16m \
-v perm-data:/app/data \
perm:ro
=== обычный запуск ===
UID=10001 GID=10001 groups=[10001]
/tmp запись доступна
домашний каталог запись доступна
/app запись доступна
/app/data запись доступна
tempfile работает
=== с --read-only, без tmpfs ===
UID=10001 GID=10001 groups=[10001]
/tmp ЗАПРЕЩЕНА (Read-only file system)
домашний каталог ЗАПРЕЩЕНА (Read-only file system)
/app ЗАПРЕЩЕНА (Read-only file system)
/app/data ЗАПРЕЩЕНА (Read-only file system)
tempfile ОШИБКА (Read-only file system)
=== с --read-only и tmpfs ===
UID=10001 GID=10001 groups=[10001]
/tmp запись доступна
домашний каталог запись доступна
/app ЗАПРЕЩЕНА (Read-only file system)
/app/data запись доступна
tempfile работает
Третий вариант — целевой для production: код недоступен для записи, временные данные в памяти, состояние в volume.
Обратите внимание: /app остался недоступен для записи — и это правильно. Приложение не должно изменять свой код.
docker volume rm perm-data > /dev/null 2>&1 || true
Диагностика ошибок прав
diagnose_permissions() {
local image="$1" path="$2"
echo "── диагностика записи в $path ──"
local user
user="$(docker run --rm --entrypoint id "$image" 2>/dev/null)"
echo " процесс: $user"
echo -n " владелец каталога: "
docker run --rm --user 0:0 --entrypoint stat "$image" -c '%U:%G (%a)' "$path" 2>/dev/null \
|| echo "каталог отсутствует"
echo -n " запись: "
if docker run --rm --entrypoint sh "$image" -c "touch $path/.probe 2>/dev/null && rm -f $path/.probe && echo доступна" 2>/dev/null | grep -q доступна; then
echo "доступна"
else
echo "ЗАПРЕЩЕНА"
echo " Проверьте:"
echo " 1. владелец каталога совпадает с UID процесса?"
echo " 2. каталог создан ДО переключения USER?"
echo " 3. есть ли chown или COPY --chown?"
fi
}
diagnose_permissions perm:naive /app
echo
diagnose_permissions perm:correct /app
── диагностика записи в /app ──
процесс: uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
владелец каталога: root:root (755)
запись: ЗАПРЕЩЕНА
Проверьте:
1. владелец каталога совпадает с UID процесса?
2. каталог создан ДО переключения USER?
3. есть ли chown или COPY --chown?
── диагностика записи в /app ──
процесс: uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
владелец каталога: 10001:10001 (755)
запись: доступна
Сравнение строки «владелец каталога» с UID процесса даёт ответ за секунду.
Почему не chmod 777
cat > Dockerfile.777 <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY app/ ./app/
RUN mkdir -p /app/data && chmod -R 777 /app
USER 10001:10001
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.777 -t perm:777 . > /dev/null
docker run --rm perm:777 | head -4
echo
echo "=== последствие: любой пользователь может изменить код ==="
docker run --rm --user 65534:65534 perm:777 \
sh -c 'echo "код подменён" > /app/app/main.py && echo "ПОДМЕНА УДАЛАСЬ от nobody"'
UID=10001 GID=10001 groups=[10001]
/tmp запись доступна
домашний каталог запись доступна
/app запись доступна
=== последствие: любой пользователь может изменить код ===
ПОДМЕНА УДАЛАСЬ от nobody
Права 777 дали возможность изменить исходный код приложения любому процессу в container. При правильной настройке владельца этого не произойдёт:
docker run --rm --user 65534:65534 perm:correct \
sh -c 'echo "подмена" > /app/app/main.py 2>&1 || echo "подмена невозможна"'
подмена невозможна
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=perm:*') 2>/dev/null || true
docker volume rm perm-data 2>/dev/null || true
sudo rm -rf /tmp/perms 2>/dev/null || rm -rf /tmp/perms
Практическое упражнение
Задание. Напишите скрипт check-nonroot.sh, проверяющий образ на соответствие требованиям к запуску от непривилегированного пользователя.
Скрипт должен проверять:
- Задан ли
USERв образе. - Задан ли он числовым UID, а не именем.
- Не равен ли UID нулю.
- Не используется ли
nobody. - Может ли приложение писать в свой рабочий каталог.
- Может ли писать в
/tmpи домашний каталог. - Запускается ли образ с
--read-onlyи минимальным наборомtmpfs.
Для каждой непройденной проверки — объяснение и способ исправления. Ненулевой exit code при наличии проблем.
Подсказки
Подсказка 1
Значение USER из образа: docker image inspect <img> --format '{{.Config.User}}'.
Подсказка 2
Рабочий каталог: {{.Config.WorkingDir}}. Проверять запись нужно от того же пользователя, что задан в образе.
Подсказка 3
Для проверки 7 запустите с --read-only и посмотрите, работает ли приложение с минимальным набором tmpfs.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# check-nonroot.sh — аудит образа на запуск от непривилегированного пользователя.
set -uo pipefail
IMAGE="${1:?Использование: $0 <образ>}"
docker image inspect "$IMAGE" > /dev/null 2>&1 || {
echo "образ не найден: $IMAGE" >&2; exit 2; }
issues=0
warn() { echo " [!] $1"; issues=$((issues + 1)); }
ok() { echo " [+] $1"; }
info() { echo " $1"; }
USER_SPEC="$(docker image inspect "$IMAGE" --format '{{.Config.User}}')"
WORKDIR="$(docker image inspect "$IMAGE" --format '{{.Config.WorkingDir}}')"
[ -z "$WORKDIR" ] && WORKDIR="/"
echo "═══ Аудит $IMAGE ═══"
echo
# ── 1. USER задан ──
echo "── 1-4. Пользователь ──"
if [ -z "$USER_SPEC" ]; then
warn "USER не задан — container запустится от root"
info "Исправление: RUN useradd --create-home --uid 10001 appuser"
info " USER 10001:10001"
else
ok "USER задан: $USER_SPEC"
# ── 2. числовой UID ──
uid_part="${USER_SPEC%%:*}"
if echo "$uid_part" | grep -qE '^[0-9]+$'; then
ok "UID числовой: $uid_part"
# ── 3. не root ──
if [ "$uid_part" = "0" ]; then
warn "UID равен 0 — это root"
info "Исправление: использовать UID из диапазона 10000+"
else
ok "UID не равен 0"
fi
# ── 4. не nobody ──
if [ "$uid_part" = "65534" ] || [ "$uid_part" = "99" ]; then
warn "используется nobody (UID $uid_part)"
info "nobody разделяется со всей системой и не имеет домашнего каталога."
info "Исправление: выделенный пользователь с UID 10000+"
else
ok "не nobody"
fi
if [ "$uid_part" -lt 10000 ] && [ "$uid_part" != "0" ]; then
info "[~] UID $uid_part может конфликтовать с системными пользователями"
info " Рекомендуется диапазон 10000+"
fi
else
warn "USER задан именем ($USER_SPEC), а не числовым UID"
info "Оркестраторы с runAsNonRoot не смогут проверить, что это не root."
info "Исправление: USER 10001:10001"
fi
fi
# ── 5-6. Права на запись ──
echo
echo "── 5-6. Права на запись ──"
check_write() {
local path="$1" label="$2"
local result
result="$(docker run --rm --entrypoint sh "$IMAGE" -c \
"touch '$path/.probe' 2>/dev/null && rm -f '$path/.probe' && echo OK || echo FAIL" 2>/dev/null)"
if [ "$result" = "OK" ]; then
ok "$label ($path): запись доступна"
return 0
fi
return 1
}
if ! check_write "$WORKDIR" "рабочий каталог"; then
warn "рабочий каталог ($WORKDIR): запись ЗАПРЕЩЕНА"
owner="$(docker run --rm --user 0:0 --entrypoint stat "$IMAGE" -c '%u:%g' "$WORKDIR" 2>/dev/null || echo '?')"
info "владелец каталога: $owner, UID процесса: ${uid_part:-?}"
info "Исправление: RUN chown -R ${uid_part:-10001}:${uid_part:-10001} $WORKDIR"
info " или: COPY --chown=${uid_part:-10001}:${uid_part:-10001} ..."
fi
check_write "/tmp" "временные файлы" || {
warn "/tmp: запись ЗАПРЕЩЕНА"
info "tempfile и многие библиотеки перестанут работать"
}
HOME_DIR="$(docker run --rm --entrypoint sh "$IMAGE" -c 'echo $HOME' 2>/dev/null || echo '')"
if [ -n "$HOME_DIR" ] && [ "$HOME_DIR" != "/" ]; then
check_write "$HOME_DIR" "домашний каталог" || {
warn "домашний каталог ($HOME_DIR): запись ЗАПРЕЩЕНА"
info "Инструменты, использующие ~/.cache, могут не работать"
info "Исправление: useradd --create-home"
}
fi
# ── 7. read-only rootfs ──
echo
echo "── 7. Совместимость с --read-only ──"
if docker run --rm --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
${HOME_DIR:+--tmpfs "$HOME_DIR:rw,noexec,nosuid,size=16m"} \
--entrypoint sh "$IMAGE" -c 'exit 0' > /dev/null 2>&1; then
ok "образ запускается с --read-only и tmpfs для /tmp"
info "Рекомендуемый запуск в production:"
info " docker run --read-only \\"
info " --tmpfs /tmp:rw,noexec,nosuid,size=64m \\"
[ -n "$HOME_DIR" ] && info " --tmpfs $HOME_DIR:rw,noexec,nosuid,size=16m \\"
info " $IMAGE"
else
warn "образ не запускается с --read-only"
info "Определите, куда приложение пишет, и добавьте tmpfs или volume"
fi
echo
if [ "$issues" -eq 0 ]; then
echo "Замечаний нет."
else
echo "Замечаний: $issues"
fi
exit "$issues"
Проверка на трёх образах:
chmod +x check-nonroot.sh
for img in perm:root perm:naive perm:correct; do
./check-nonroot.sh "$img"
echo "exit code: $?"
echo "══════════════════════════════════════"
done
Вывод для perm:root:
═══ Аудит perm:root ═══
── 1-4. Пользователь ──
[!] USER не задан — container запустится от root
Исправление: RUN useradd --create-home --uid 10001 appuser
USER 10001:10001
── 5-6. Права на запись ──
[+] рабочий каталог (/app): запись доступна
[+] временные файлы (/tmp): запись доступна
[+] домашний каталог (/root): запись доступна
── 7. Совместимость с --read-only ──
[+] образ запускается с --read-only и tmpfs для /tmp
...
Замечаний: 1
exit code: 1
Для perm:naive:
── 1-4. Пользователь ──
[+] USER задан: 10001:10001
[+] UID числовой: 10001
[+] UID не равен 0
[+] не nobody
── 5-6. Права на запись ──
[!] рабочий каталог (/app): запись ЗАПРЕЩЕНА
владелец каталога: 0:0, UID процесса: 10001
Исправление: RUN chown -R 10001:10001 /app
или: COPY --chown=10001:10001 ...
[+] временные файлы (/tmp): запись доступна
[+] домашний каталог (/home/appuser): запись доступна
Замечаний: 1
exit code: 1
Для perm:correct — замечаний нет, exit code 0.
Что делает аудит практичным.
Проверка 2 отделена от проверки 1. Образ с USER appuser формально задаёт пользователя, и поверхностная проверка его пропустит. Но Kubernetes с runAsNonRoot: true откажется его запускать, потому что не может убедиться, что это не root. Отдельная проверка ловит это до развёртывания.
Диагностика вместо констатации. При провале проверки 5 скрипт выводит владельца каталога и UID процесса рядом — разница видна сразу, и не нужно самостоятельно выяснять, в чём дело.
Проверка 7 не просто фиксирует факт. Она выводит готовую команду запуска с нужными tmpfs, которую можно скопировать в конфигурацию развёртывания.
Ограничение. Проверка 5 использует --entrypoint sh, что не сработает на distroless-образах без оболочки. Для них нужна проверка изнутри самого приложения или через nsenter с host (урок 4.3).
Проверка результата
mkdir -p /tmp/vnr && cd /tmp/vnr
printf 'FROM python:3.13-slim\nRUN useradd --create-home --uid 10001 appuser\nWORKDIR /app\nRUN chown 10001:10001 /app\nUSER 10001:10001\nCMD ["sh","-c","id -u; touch /app/x && echo запись-ок"]\n' > Dockerfile
docker build -q -t vnr:1 . > /dev/null
docker run --rm vnr:1
docker image inspect vnr:1 --format 'Config.User = {{.Config.User}}'
docker rmi vnr:1 > /dev/null; cd /tmp && rm -rf /tmp/vnr
Ожидается 10001, запись-ок и числовое значение Config.User.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Нет USER в образе | Работает и так | Container запускается от root |
USER appuser вместо UID | Читаемее | Оркестратор не проверит runAsNonRoot |
USER nobody | Есть во всех образах | Разделяется со всей системой, нет домашнего каталога |
USER перед apt-get | Порядок кажется неважным | Установка пакетов требует root |
WORKDIR после USER без учёта владельца | Не задумывались | Каталог создаётся от текущего пользователя |
COPY без --chown после USER | Кажется, что USER влияет | COPY всегда копирует от root |
chmod 777 для решения проблемы прав | Быстро «чинит» | Любой процесс может изменить код; исправлять владельца |
RUN chown -R вместо COPY --chown | Привычка | Copy-up дублирует данные; +17 MB на окружении |
| UID из диапазона 1000–9999 | Кажется обычным | Может конфликтовать с UID разработчика на host |
Забыт --create-home | Не задумывались | Инструменты, пишущие в ~/.cache, ломаются |
Контрольные вопросы
На понимание:
- Почему
rootв container — этоrootна host? - Почему UID важнее имени пользователя? Приведите два следствия.
- Почему
WORKDIRдоUSERтребует явногоchown? - Почему
nobody— плохой выбор для приложения? - Почему
chmod 777не решает проблему прав, а создаёт новую?
На применение:
- Как задать владельца копируемых файлов без дополнительного слоя?
- Как определить, почему приложение не может писать в свой каталог?
- Как подготовить образ к запуску с
--read-only?
На диагностику:
Permission deniedпри записи в/app, хотяUSERзадан правильно. Что проверить?- После добавления
USERвDockerfileсборка падает наapt-get install. Причина?
Краткое резюме
- Docker не создаёт user namespace:
rootв container — этоrootна host. USERзадаётся числовым UID:USER 10001:10001, а не именем.- Рекомендуемый диапазон UID — 10000 и выше.
nobodyне подходит: разделяется со всей системой и не имеет домашнего каталога.USERпереключается в конце: установка пакетов иchownтребуютroot.WORKDIRсоздаёт каталог от текущего пользователя — порядок определяет владельца.COPYбез--chownвсегда копирует отroot, даже послеUSER.COPY --chownне создаёт дополнительного слоя, в отличие отRUN chown -R.chmod 777позволяет любому процессу изменить код приложения — исправлять нужно владельца.- Для
--read-onlyнужныtmpfsдля/tmpи домашнего каталога, volume — для данных.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Dockerfile reference: USER | https://docs.docker.com/reference/dockerfile/#user | Синтаксис, UID и GID, влияние на последующие инструкции |
| Dockerfile reference: COPY | https://docs.docker.com/reference/dockerfile/#copy | Флаг --chown, копирование от root по умолчанию |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Рекомендация запускать от непривилегированного пользователя |
| Docker Engine security | https://docs.docker.com/engine/security/ | Отсутствие user namespace по умолчанию, модель привилегий |
| Isolate containers with a user namespace | https://docs.docker.com/engine/security/userns-remap/ | Отображение UID при включённом userns-remap |
| docker run reference | https://docs.docker.com/reference/cli/docker/container/run/ | Флаги --user, --read-only, --tmpfs, --group-add |
| Kubernetes: security context | https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ | runAsNonRoot и требование числового UID |
useradd(8) man page | https://man7.org/linux/man-pages/man8/useradd.8.html | Флаги --uid, --create-home, --shell |
path_resolution(7) man page | https://man7.org/linux/man-pages/man7/path_resolution.7.html | Порядок проверки прав: владелец, группа, остальные |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → CLI-приложение
Главное оглавление