Главная/Python внутри Container/Урок

6.7. Non-root user и permissions

Цели

После этого материала вы сможете:

  • создать непривилегированного пользователя в образе и объяснить выбор UID;
  • объяснить, почему UID важнее имени пользователя;
  • расположить USER, WORKDIR и COPY в правильном порядке;
  • диагностировать и исправлять ошибки прав доступа без chmod 777;
  • обеспечить запись в каталоги, нужные Python-приложению;
  • подготовить образ к запуску с read-only root filesystem.

Предварительные знания

Ключевые термины

ТерминОбъяснение
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

ДиапазонНазначениеПригодность
0rootНет
1–999Системные пользователи дистрибутиваВозможен конфликт с существующими
1000–9999Обычные пользователиМожет совпасть с UID разработчика на host
10000+Свободный диапазонРекомендуется
65534nobodyНет: разделяется со всей системой

Про nobody стоит сказать отдельно. Он существует в большинстве образов и кажется удобным выбором, но:

  • разделяется со всеми процессами системы, использующими nobody;
  • не имеет домашнего каталога — многие инструменты ломаются;
  • в разных дистрибутивах имеет разный UID (65534 или 99).

Выделенный пользователь с UID из диапазона 10000+ решает все три проблемы.

Порядок инструкций

Три правила, нарушение которых даёт Permission denied.

Правило 1: USER — в конце. Установка пакетов, создание каталогов и chown требуют прав root.

dockerfile
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):

dockerfile
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 становятся доступны всем
Скрывает настоящую причинуРеальная проблема — неверный владелец

Правильные решения перечислены в порядке предпочтения:

  1. COPY --chown при копировании — без дополнительного слоя.
  2. RUN chown до переключения пользователя — если владелец меняется у существующих файлов.
  3. Права группы (chmod g+w) плюс --group-add — для разделяемых каталогов.
  4. tmpfs для временных данных — не требует прав на файловой системе образа.

Read-only root filesystem

Флаг --read-only делает корневую файловую систему недоступной для записи. Это сильное ограничение: приложение не сможет изменить ни свой код, ни системные файлы.

Приложению всё равно нужны каталоги для записи — они предоставляются через tmpfs:

bash
docker run --read-only \
    --tmpfs /tmp:rw,noexec,nosuid,size=64m \
    myapp

Подготовка образа к этому режиму — то же самое, что аккуратная работа с правами: нужно точно знать, куда приложение пишет. Подробно разбирается в разделе 07 и разделе 11.


Внутренний механизм

Как ядро проверяет права

При обращении к файлу ядро сравнивает UID и GID процесса с владельцем файла:

text
   UID процесса == UID владельца?  ──► применяются права владельца (rwx------)
                    │ нет
                    ▼
   GID процесса или supplementary
   groups содержит GID файла?      ──► применяются права группы (---rwx---)
                    │ нет
                    ▼
                                        применяются права остальных (------rwx)

Важно: проверяется первое совпадение, а не наиболее разрешающее. Если вы владелец файла с правами r--rw-rw-, вы получите только чтение, хотя группа и остальные имеют запись.

Это регулярно удивляет при отладке прав.

Почему useradd против adduser

В Debian-образах доступны обе команды, но они разные:

useraddadduser
ТипНизкоуровневая утилитаСкрипт-обёртка (Perl)
Есть в slimдада
Есть в Alpineнет (там свой adduser)да, но другой синтаксис
Интерактивностьнетможет запрашивать ввод

Для Dockerfile предпочтительна useradd в Debian-образах и adduser в Alpine — синтаксис у них разный:

dockerfile
# Debian, Ubuntu
RUN useradd --create-home --uid 10001 appuser

# Alpine
RUN adduser -D -u 10001 appuser

Команды и примеры

Подготовка

bash
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

bash
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
text
UID=0  GID=0  groups=[0]
  /tmp                 запись доступна
  домашний каталог     запись доступна
  /app                 запись доступна
  /app/data            запись доступна
  tempfile             работает

Всё доступно — потому что это root. Проверим последствие для bind mount:

bash
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
text
-rw-r--r-- 1 root root 20 Jul 30 13:20 shared/file.txt
попытка удалить обычным пользователем:
rm: удалить защищённый от записи обычный файл 'shared/file.txt'? 

Файл принадлежит root на host. Разработчик не может его удалить без sudo — классическая проблема, разбираемая в разделе 07.

Наивный переход на non-root

bash
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
text
UID=10001  GID=10001  groups=[10001]
  /tmp                 запись доступна
  домашний каталог     запись доступна
  /app                 ЗАПРЕЩЕНА (Permission denied)
  /app/data            ЗАПРЕЩЕНА (Permission denied)
  tempfile             работает

UID правильный, но приложение не может писать в свои каталоги. Причина видна сразу:

bash
docker run --rm --user 0:0 perm:naive ls -ld /app /app/data
text
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 в конце ничего не изменило.

Правильный вариант

bash
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
text
UID=10001  GID=10001  groups=[10001]
  /tmp                 запись доступна
  домашний каталог     запись доступна
  /app                 запись доступна
  /app/data            запись доступна
  tempfile             работает

Все проверки проходят. Bind mount теперь создаёт файлы с правильным владельцем:

bash
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
text
-rw-r--r-- 1 10001 10001 10 Jul 30 13:25 file2.txt

Файл принадлежит UID 10001. Если это ваш UID на host — вы сможете его удалить обычным способом; иначе понадобится сопоставление UID, разбираемое в разделе 07.

Порядок WORKDIR и USER

bash
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
text
=== WORKDIR ПОСЛЕ USER ===
#6 0.185 запись удалась
=== WORKDIR ДО USER, с chown ===
#7 0.192 запись удалась

Оба варианта работают, но по-разному: в первом WORKDIR создал каталог уже от имени appuser, во втором потребовался явный chown.

Первый вариант короче, но имеет ограничение: после USER нельзя выполнять apt-get и прочие привилегированные операции. На практике WORKDIR обычно нужен раньше, поэтому применяется второй вариант.

UID вместо имени

bash
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
text
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:

bash
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
text
name   ОТКАЗ: имя, а не UID — проверить нельзя
uid    принято: числовой UID

Почему не nobody

bash
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
text
UID=65534  GID=65534  groups=[65534]
  /tmp                 запись доступна
  домашний каталог     ЗАПРЕЩЕНА (Permission denied)
  /app                 ЗАПРЕЩЕНА (Permission denied)
  /app/data            отсутствует
  tempfile             работает

Домашний каталог nobody — это /nonexistent, и запись в него невозможна. Инструменты, использующие ~/.cache, сломаются.

bash
docker run --rm --entrypoint sh perm:nobody -c 'getent passwd nobody'
text
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

Права на .venv при multi-stage

Типичная ошибка при копировании окружения между стадиями:

bash
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
text
drwxr-xr-x 5 root root 4096 Jul 30 13:32 /opt/venv
импорт работает

Здесь всё работает: для импорта достаточно чтения, а права r-x для остальных есть.

Проблема возникнет, если приложению нужна запись в окружение — например, при установке пакета в runtime или при использовании инструмента, пишущего рядом с собой. Явный --chown устраняет неопределённость:

bash
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
text
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 разница существенна.

Сравним:

bash
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'
text
perm:venv-chown  220MB
perm:venv-good  198MB
perm:venv-bad  198MB

Отдельный RUN chown -R добавил 22 MB — копию всего окружения. Абсолютные числа зависят от версии базового образа; постоянна разница между строками.

(Ведущие пробелы из строки формата в выводе не появляются: docker images --format их отбрасывает.)

PYTHONDONTWRITEBYTECODE и non-root

bash
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
text
с правом записи в /app, __pycache__ создан: 1
без права записи, __pycache__ создан: 0

Во втором случае Python не смог создать __pycache__ — и не выдал ошибки. Это нормальное поведение: запись байт-кода необязательна.

С PYTHONDONTWRITEBYTECODE=1 попытки не будет вовсе, что убирает неопределённость и лишние системные вызовы.

Read-only root filesystem

bash
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
text
=== обычный запуск ===
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 остался недоступен для записи — и это правильно. Приложение не должно изменять свой код.

bash
docker volume rm perm-data > /dev/null 2>&1 || true

Диагностика ошибок прав

bash
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
text
── диагностика записи в /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

bash
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"'
text
UID=10001  GID=10001  groups=[10001]
  /tmp                 запись доступна
  домашний каталог     запись доступна
  /app                 запись доступна

=== последствие: любой пользователь может изменить код ===
ПОДМЕНА УДАЛАСЬ от nobody

Права 777 дали возможность изменить исходный код приложения любому процессу в container. При правильной настройке владельца этого не произойдёт:

bash
docker run --rm --user 65534:65534 perm:correct \
    sh -c 'echo "подмена" > /app/app/main.py 2>&1 || echo "подмена невозможна"'
text
подмена невозможна

Уборка

bash
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, проверяющий образ на соответствие требованиям к запуску от непривилегированного пользователя.

Скрипт должен проверять:

  1. Задан ли USER в образе.
  2. Задан ли он числовым UID, а не именем.
  3. Не равен ли UID нулю.
  4. Не используется ли nobody.
  5. Может ли приложение писать в свой рабочий каталог.
  6. Может ли писать в /tmp и домашний каталог.
  7. Запускается ли образ с --read-only и минимальным набором tmpfs.

Для каждой непройденной проверки — объяснение и способ исправления. Ненулевой exit code при наличии проблем.

Подсказки

Подсказка 1

Значение USER из образа: docker image inspect <img> --format '{{.Config.User}}'.

Подсказка 2

Рабочий каталог: {{.Config.WorkingDir}}. Проверять запись нужно от того же пользователя, что задан в образе.

Подсказка 3

Для проверки 7 запустите с --read-only и посмотрите, работает ли приложение с минимальным набором tmpfs.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/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"

Проверка на трёх образах:

bash
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:

text
═══ Аудит 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:

text
── 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).

Проверка результата

bash
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, ломаются

Контрольные вопросы

На понимание:

  1. Почему root в container — это root на host?
  2. Почему UID важнее имени пользователя? Приведите два следствия.
  3. Почему WORKDIR до USER требует явного chown?
  4. Почему nobody — плохой выбор для приложения?
  5. Почему chmod 777 не решает проблему прав, а создаёт новую?

На применение:

  1. Как задать владельца копируемых файлов без дополнительного слоя?
  2. Как определить, почему приложение не может писать в свой каталог?
  3. Как подготовить образ к запуску с --read-only?

На диагностику:

  1. Permission denied при записи в /app, хотя USER задан правильно. Что проверить?
  2. После добавления USER в Dockerfile сборка падает на apt-get install. Причина?

Краткое резюме

  1. Docker не создаёт user namespace: root в container — это root на host.
  2. USER задаётся числовым UID: USER 10001:10001, а не именем.
  3. Рекомендуемый диапазон UID — 10000 и выше.
  4. nobody не подходит: разделяется со всей системой и не имеет домашнего каталога.
  5. USER переключается в конце: установка пакетов и chown требуют root.
  6. WORKDIR создаёт каталог от текущего пользователя — порядок определяет владельца.
  7. COPY без --chown всегда копирует от root, даже после USER.
  8. COPY --chown не создаёт дополнительного слоя, в отличие от RUN chown -R.
  9. chmod 777 позволяет любому процессу изменить код приложения — исправлять нужно владельца.
  10. Для --read-only нужны tmpfs для /tmp и домашнего каталога, volume — для данных.

Официальные источники

ИсточникСсылкаЧто подтверждает
Dockerfile reference: USERhttps://docs.docker.com/reference/dockerfile/#userСинтаксис, UID и GID, влияние на последующие инструкции
Dockerfile reference: COPYhttps://docs.docker.com/reference/dockerfile/#copyФлаг --chown, копирование от root по умолчанию
Building best practiceshttps://docs.docker.com/build/building/best-practices/Рекомендация запускать от непривилегированного пользователя
Docker Engine securityhttps://docs.docker.com/engine/security/Отсутствие user namespace по умолчанию, модель привилегий
Isolate containers with a user namespacehttps://docs.docker.com/engine/security/userns-remap/Отображение UID при включённом userns-remap
docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/Флаги --user, --read-only, --tmpfs, --group-add
Kubernetes: security contexthttps://kubernetes.io/docs/tasks/configure-pod-container/security-context/runAsNonRoot и требование числового UID
useradd(8) man pagehttps://man7.org/linux/man-pages/man8/useradd.8.htmlФлаги --uid, --create-home, --shell
path_resolution(7) man pagehttps://man7.org/linux/man-pages/man7/path_resolution.7.htmlПорядок проверки прав: владелец, группа, остальные

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → CLI-приложение
Главное оглавление

Markdown на GitHub ↗