Главная/Storage/Урок

7.5. Права доступа, UID и GID

Цели

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

  • объяснить, почему файл, созданный в container, принадлежит root на host;
  • понять, что имена пользователей внутри и снаружи container'а не связаны между собой;
  • диагностировать Permission denied при bind mount за одну команду;
  • применить четыре рабочих решения и выбрать подходящее под задачу;
  • объяснить, почему chmod 777 — маскировка проблемы, а не решение;
  • сказать, чем ситуация отличается в rootless Docker.

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

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

ТерминОбъяснение
UIDЧисловой идентификатор пользователя — единственное, что знает ядро
GIDЧисловой идентификатор группы
/etc/passwdФайл соответствия имён и номеров; свой в каждом container'е
setgid на каталогеНовые файлы наследуют группу каталога
userns-remapСмещение диапазона UID для container'ов
supplementary groupsДополнительные группы процесса

Теория

Ядро знает только числа

Это утверждение объясняет всё остальное в уроке.

Права доступа в Linux проверяются по числам: UID процесса сравнивается с UID владельца файла. Имена — root, appuser, postgres — существуют только в /etc/passwd, и этот файл свой в каждом container'е.

Отсюда:

НаблюдениеПричина
Файл, созданный в container от root, принадлежит root на hostUID 0 внутри и снаружи — одно и то же число
appuser в container и appuser на host — разные пользователиСовпало имя, а не UID
Один UID может называться по-разномуДва разных /etc/passwd
ls -l на host показывает имя вашего пользователя для файлов из container'аСовпали номера, имя подставил host

Практический вывод: при работе с правами и container'ами смотрите на числа, а не на имена:

bash
ls -ln    # числовые UID и GID вместо имён

Откуда берётся проблема

Схема, порождающая большинство обращений в поддержку:

text
host:      каталог ./data принадлежит UID 1000 (вы), права 755
container: процесс работает от UID 10001 (appuser из образа)
монтируем: -v ./data:/data
результат: Permission denied при записи

Ни одна сторона не ошиблась: каталог host принадлежит вам, образ правильно использует non-root пользователя. Несовместимы именно числа.

Обратная ситуация встречается не реже:

text
container: процесс работает от UID 0 (root по умолчанию)
монтируем: -v ./output:/output
приложение создаёт ./output/result.txt
на host:   файл принадлежит root, обычный пользователь не может его удалить

Здесь ошибка проявляется не сразу, а при попытке убрать за собой — и rm требует sudo в собственном каталоге проекта.

Почему у volumes этой проблемы нет

Named volume при первом монтировании наполняется содержимым образа вместе с владельцем и правами (урок 7.2). Если в образе /data принадлежит UID 10001, то и в volume он будет принадлежать ему.

VolumeBind mount
Владелец точки монтированияИз образаС host, как есть
Нужно согласовывать UIDНетДа

Это ещё один довод в пользу volumes для данных приложения. Проблема UID возникает там, где данные должны быть видны и с host, — то есть при разработке.

Четыре рабочих решения

РешениеСутьКогда подходит
1. --user $(id -u):$(id -g)Процесс в container работает от вашего UIDРазработка, разовые запуски
2. UID образа = UID разработчика--build-arg UID=$(id -u) при сборкеРазработка в команде с одинаковыми UID
3. chown каталога host под UID образаСогласование со стороны hostСервер, фиксированный UID
4. Общая группа плюс setgidДоступ по группе, а не по владельцуНесколько пользователей и сервисов

Плюс системное решение — rootless Docker, где вопрос снимается на другом уровне.

Решение 1 подробнее. Флаг --user подменяет UID процесса:

bash
docker run --user "$(id -u):$(id -g)" -v "$PWD:/app" myimage

Побочный эффект: такого UID нет в /etc/passwd образа. Последствия:

Что ломаетсяПочемуОбход
whoamiНет записи в /etc/passwdОбычно не нужен
$HOME пуст или /Определяется по /etc/passwd-e HOME=/tmp
~/.cache библиотекЗависит от HOMEЗадать HOME явно
os.getlogin()Требует записиИспользовать os.getuid()

Поэтому --user почти всегда идёт в паре с -e HOME=....

Решение 2 подробнее. UID задаётся при сборке:

dockerfile
ARG UID=10001
ARG GID=10001
RUN groupadd -g ${GID} app && useradd -u ${UID} -g ${GID} -m app
USER ${UID}:${GID}
bash
docker build --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t dev .

Работает и сохраняет запись в /etc/passwd, но делает образ привязанным к конкретному разработчику — в реестр такой образ не публикуют. Отсюда практика: отдельный Dockerfile.dev или отдельная стадия для разработки.

Решение 3 подробнее. Со стороны host:

bash
sudo chown -R 10001:10001 ./data

Просто и надёжно для сервера, где UID образа известен и постоянен. Для разработки неудобно: каталог перестаёт принадлежать вам.

Решение 4 подробнее. Общая группа плюс бит setgid:

bash
sudo groupadd -g 2000 appdata
sudo chgrp -R 2000 ./data
sudo chmod -R g+rwX ./data
sudo find ./data -type d -exec chmod g+s {} +   # новые файлы наследуют группу
docker run --group-add 2000 --user "$(id -u)" ...

Бит setgid на каталоге — ключевая часть: без него файлы, созданные в каталоге, получат основную группу создателя, и согласование развалится после первой же записи.

Почему chmod 777 — не решение

Команда встречается в каждом втором ответе на форуме. Её проблемы:

ПроблемаПояснение
Не меняет владельцаФайлы, созданные container'ом, всё равно будут чужими
Разрешает запись всемЛюбой процесс на host может изменить данные
Не наследуетсяНовые файлы получат права по umask, а не 777
Скрывает причинуПроблема согласования UID остаётся
Не работает при SELinuxМетка важнее битов (урок 7.3)

Третий пункт объясняет, почему chmod 777 часто «помогает на пять минут»: существующие файлы стали доступны, а созданные после — нет.

Единственное приемлемое применение — одноразовая диагностика: если после chmod 777 заработало, причина точно в правах, и теперь её надо устранить по-настоящему.

Rootless Docker меняет картину

В rootless-режиме демон работает от вашего пользователя, а UID внутри container'а отображаются в диапазон, выделенный вам в /etc/subuid (урок 1.6).

Обычный DockerRootless
UID 0 в containerUID 0 на hostВаш UID на host
UID 1000 в containerUID 1000 на hostUID из вашего subuid-диапазона
Файл от root в bind mountПринадлежит rootПринадлежит вам
Нужен ли --user для разработкиОбычно даОбычно нет

Для разработки это заметно удобнее: файлы, созданные container'ом, сразу принадлежат вам.

Обратная сторона: файлы, созданные в container'е от не-root пользователя, получают на host UID из subuid-диапазона (например, 100000+10000) и не принадлежат вам — картина зеркальна привычной.

Промежуточный вариант для обычного Docker — userns-remap в настройках демона. Он смещает UID container'ов, но применяется ко всем container'ам сразу и ломает часть сценариев с bind mount.


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

Что происходит при записи

  1. Процесс в container вызывает open("/data/file", O_CREAT).
  2. Ядро проверяет права каталога против UID и GID процесса — тех, что видны ядру, без всякого пересчёта.
  3. При успехе файл создаётся с владельцем, равным UID процесса.
  4. Тот же inode виден с host — файловая система одна.

Никакого преобразования на границе container'а нет (кроме user namespace в rootless и userns-remap). Именно поэтому «файл принадлежит root» — не ошибка Docker, а прямое следствие того, что процесс работал от UID 0.

Как проверить соответствие

bash
stat -c '%u:%g %n' ./data          # UID:GID на host
docker run --rm -v "$PWD/data:/d" img id -u   # UID процесса в container

Совпадают — записи не будет проблем. Не совпадают — нужны права по группе или одно из четырёх решений.


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

Имена не значат ничего, числа — всё

bash
mkdir -p /tmp/uid-demo && cd /tmp/uid-demo

docker build -q -t names - <<'EOF' > /dev/null
FROM alpine:3.21
# Тот же UID 1000, но под другим именем
RUN adduser -D -u 1000 совсем-другое-имя 2>/dev/null || adduser -D -u 1000 othername
EOF

echo "═══ кто такой UID 1000 на host ═══"
getent passwd 1000 | cut -d: -f1,3 | sed 's/^/  /'

echo "═══ кто такой UID 1000 в container ═══"
docker run --rm names sh -c 'getent passwd 1000 | cut -d: -f1,3' | sed 's/^/  /'

echo "═══ файл, созданный этим пользователем в container ═══"
docker run --rm -u 1000 -v "$PWD:/out" names sh -c 'echo данные > /out/f.txt'
echo "  ls -l  (имена):"
ls -l f.txt | awk '{print "    " $3 ":" $4 "  " $9}'
echo "  ls -ln (числа):"
ls -ln f.txt | awk '{print "    " $3 ":" $4 "  " $9}'

rm -f f.txt; docker rmi -f names > /dev/null

Ожидаемый вывод:

text
═══ кто такой UID 1000 на host ═══
  evg:1000
═══ кто такой UID 1000 в container ═══
  othername:1000
═══ файл, созданный этим пользователем в container ═══
  ls -l  (имена):
    evg:evg  f.txt
  ls -ln (числа):
    1000:1000  f.txt

Один и тот же UID 1000 называется evg на host и othername в container'е. Файл, созданный othername, на host принадлежит evg — потому что имена никогда не участвовали в проверке, участвовало число.

Команда ls -ln показывает то, что происходит на самом деле. При разборе прав пользуйтесь ей.

Файл, который не удалить

bash
mkdir -p /tmp/uid-demo/output && cd /tmp/uid-demo

echo "═══ container от root создаёт файл ═══"
docker run --rm -v "$PWD/output:/output" alpine:3.21 \
    sh -c 'echo "результат работы" > /output/report.txt'

ls -ln output/ | tail -1 | awk '{print "  владелец: " $3 ":" $4 "  файл: " $9}'

echo "═══ попытка удалить обычным пользователем ═══"
rm -f output/report.txt 2>&1 | sed 's/^/  /' || true
ls output/ | sed 's/^/  осталось: /'

echo "═══ приходится звать sudo в собственном каталоге ═══"
sudo rm -f output/report.txt && echo "  удалено через sudo"

Ожидаемый вывод:

text
═══ container от root создаёт файл ═══
  владелец: 0:0  файл: report.txt
═══ попытка удалить обычным пользователем ═══
  rm: cannot remove 'output/report.txt': Permission denied
  осталось: report.txt
═══ приходится звать sudo в собственном каталоге ═══
  удалено через sudo

Это происходит в каталоге проекта, при обычной сборке, без каких-либо привилегированных флагов. Достаточно того, что процесс в container'е по умолчанию работает от root.

Обратная ситуация: non-root не может писать

bash
cd /tmp/uid-demo
docker build -q -t nonroot - <<'EOF' > /dev/null
FROM alpine:3.21
RUN adduser -D -u 10001 appuser
USER 10001:10001
EOF

mkdir -p data && echo "исходное" > data/existing.txt
ls -ldn data | awk '{print "  каталог data принадлежит " $3 ":" $4}'

echo "═══ container от UID 10001 пытается писать ═══"
docker run --rm -v "$PWD/data:/data" nonroot sh -c 'echo новое > /data/new.txt' 2>&1 | sed 's/^/  /'

echo "═══ чтение при этом работает ═══"
docker run --rm -v "$PWD/data:/data" nonroot cat /data/existing.txt | sed 's/^/  /'

Ожидаемый вывод:

text
  каталог data принадлежит 1000:1000
═══ container от UID 10001 пытается писать ═══
  sh: can't create /data/new.txt: Permission denied
═══ чтение при этом работает ═══
  исходное

Чтение проходит (права 755 дают всем r-x), запись — нет. Типичная картина: приложение стартует, читает конфигурацию, а падает позже, при первой записи.

Диагностика за одну команду

bash
cd /tmp/uid-demo
cat > diag.sh <<'SH'
#!/usr/bin/env bash
# Сравнивает UID процесса в container с владельцем каталога host.
set -uo pipefail
IMAGE="${1:?образ}"; HOSTDIR="${2:?каталог host}"

c_uid="$(docker run --rm "$IMAGE" id -u)"
c_gid="$(docker run --rm "$IMAGE" id -g)"
c_groups="$(docker run --rm "$IMAGE" id -G)"
h_uid="$(stat -c '%u' "$HOSTDIR")"
h_gid="$(stat -c '%g' "$HOSTDIR")"
h_mode="$(stat -c '%a' "$HOSTDIR")"

printf '  container: UID=%s GID=%s группы=[%s]\n' "$c_uid" "$c_gid" "$c_groups"
printf '  host:      UID=%s GID=%s права=%s\n' "$h_uid" "$h_gid" "$h_mode"

if [ "$c_uid" = "$h_uid" ]; then
    echo "  ✓ UID совпадают — запись будет работать"
elif [ "$c_uid" = "0" ]; then
    echo "  ⚠ container работает от root: запись пройдёт,"
    echo "    но созданные файлы будут принадлежать root на host"
elif echo " $c_groups " | grep -q " $h_gid "; then
    perm_g="$(( (8#$h_mode / 8) % 8 ))"
    if [ "$(( perm_g & 2 ))" -ne 0 ]; then
        echo "  ✓ совпадает группа и есть право записи для группы"
    else
        echo "  ✗ группа совпадает, но у группы нет права записи (chmod g+w)"
    fi
else
    echo "  ✗ UID и группы не совпадают — Permission denied при записи"
fi
SH
chmod +x diag.sh

echo "═══ образ с UID 10001 против каталога UID 1000 ═══"
./diag.sh nonroot "$PWD/data"

echo
echo "═══ образ по умолчанию (root) ═══"
./diag.sh alpine:3.21 "$PWD/data"

Ожидаемый вывод:

text
═══ образ с UID 10001 против каталога UID 1000 ═══
  container: UID=10001 GID=10001 группы=[10001]
  host:      UID=1000 GID=1000 права=755
  ✗ UID и группы не совпадают — Permission denied при записи

═══ образ по умолчанию (root) ═══
  container: UID=0 GID=0 группы=[0 1 2 3 4 6 10 11 20 26 27]
  host:      UID=1000 GID=1000 права=755
  ⚠ container работает от root: запись пройдёт,
    но созданные файлы будут принадлежать root на host

Скрипт разбирает обе неприятности сразу: невозможность записи и загрязнение каталога root-овыми файлами.

Решение 1: --user

bash
cd /tmp/uid-demo
echo "═══ с --user \$(id -u):\$(id -g) ═══"
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/data:/data" \
    nonroot sh -c 'echo от текущего пользователя > /data/via-user.txt && echo "  запись прошла"'
ls -ln data/via-user.txt | awk '{print "  владелец файла: " $3 ":" $4}'
rm -f data/via-user.txt

echo
echo "═══ побочный эффект: пользователя нет в /etc/passwd ═══"
docker run --rm --user "$(id -u):$(id -g)" nonroot sh -c '
    echo -n "  whoami: "; whoami 2>&1 | head -1
    echo "  HOME:   ${HOME:-(пусто)}"
'

echo
echo "═══ исправление: задать HOME явно ═══"
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp nonroot sh -c '
    echo "  HOME:   $HOME"
    mkdir -p "$HOME/.cache" && echo "  кэш создать удалось"
'

Ожидаемый вывод:

text
═══ с --user $(id -u):$(id -g) ═══
  запись прошла
  владелец файла: 1000:1000
═══ побочный эффект: пользователя нет в /etc/passwd ═══
  whoami: whoami: unknown uid 1000
  HOME:   /
═══ исправление: задать HOME явно ═══
  HOME:   /tmp
  кэш создать удалось

Запись работает, файл принадлежит вам — задача решена. Но whoami не работает, а HOME указывает на /, куда non-root писать не может. Отсюда пара --user плюс -e HOME=....

Решение 2: UID образа при сборке

bash
cd /tmp/uid-demo
cat > Dockerfile.dev <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim

ARG UID=10001
ARG GID=10001

RUN groupadd -g ${GID} app 2>/dev/null || true && \
    useradd -u ${UID} -g ${GID} -m -s /bin/bash app 2>/dev/null || true

USER ${UID}:${GID}
WORKDIR /app
CMD ["sh", "-c", "id; echo HOME=$HOME; whoami"]
EOF

docker build -q -f Dockerfile.dev \
    --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" \
    -t devimg . > /dev/null

echo "═══ образ собран под ваш UID ═══"
docker run --rm devimg

echo
echo "═══ запись в bind mount ═══"
docker run --rm -v "$PWD/data:/data" devimg sh -c 'echo ok > /data/built.txt && echo "  запись прошла"'
ls -ln data/built.txt | awk '{print "  владелец: " $3 ":" $4}'
rm -f data/built.txt

Ожидаемый вывод:

text
═══ образ собран под ваш UID ═══
uid=1000(app) gid=1000(app) groups=1000(app)
HOME=/home/app
whoami: app
═══ запись в bind mount ═══
  запись прошла
  владелец: 1000:1000

В отличие от --user, здесь пользователь есть в /etc/passwd: whoami работает, HOME осмысленный. Цена — образ привязан к конкретному UID и в общий реестр не годится.

Обратите внимание на || true в Dockerfile.dev: при UID=1000 группа или пользователь могут уже существовать в базовом образе, и без этого сборка упадёт.

Решение 4: общая группа и setgid

bash
cd /tmp/uid-demo
sudo mkdir -p shared
sudo groupadd -g 60000 dockerdata 2>/dev/null || true
sudo chgrp -R 60000 shared
sudo chmod 2775 shared        # 2 — бит setgid

echo "═══ права каталога ═══"
ls -ldn shared | awk '{print "  " $1 "  " $3 ":" $4}'

echo "═══ запись из container с --group-add ═══"
docker run --rm --user "10001:10001" --group-add 60000 \
    -v "$PWD/shared:/shared" nonroot \
    sh -c 'echo из container > /shared/from-container.txt && echo "  запись прошла"'

echo "═══ владелец и группа созданного файла ═══"
ls -ln shared/from-container.txt | awk '{print "  " $3 ":" $4 "  (группа унаследована благодаря setgid)"}'

echo "═══ без setgid группа была бы основной группой процесса ═══"
sudo chmod 0775 shared
docker run --rm --user "10001:10001" --group-add 60000 \
    -v "$PWD/shared:/shared" nonroot \
    sh -c 'echo x > /shared/no-setgid.txt' 2>/dev/null
ls -ln shared/no-setgid.txt 2>/dev/null | awk '{print "  " $3 ":" $4 "  ← группа НЕ унаследована"}'

sudo rm -rf shared
sudo groupdel dockerdata 2>/dev/null || true

Ожидаемый вывод:

text
═══ права каталога ═══
  drwxrwsr-x  0:60000
═══ запись из container с --group-add ═══
  запись прошла
═══ владелец и группа созданного файла ═══
  10001:60000  (группа унаследована благодаря setgid)
═══ без setgid группа была бы основной группой процесса ═══
  10001:10001  ← группа НЕ унаследована

Буква s вместо x в правах группы (drwxrwsr-x) — это и есть setgid. Сравнение двух последних блоков показывает, зачем он нужен: без него каждый новый файл получает группу 10001, недоступную другим участникам, и согласование разваливается после первой записи.

Почему chmod 777 не работает

bash
cd /tmp/uid-demo
mkdir -p wide && chmod 777 wide

echo "═══ запись из container от root ═══"
docker run --rm -v "$PWD/wide:/w" alpine:3.21 sh -c 'echo x > /w/created.txt'
ls -ln wide/created.txt | awk '{print "  владелец нового файла: " $3 ":" $4 "  права: " $1}'

echo "═══ права каталога не наследуются файлом ═══"
stat -c '  каталог: %a   файл: %a' wide wide/created.txt

echo "═══ удалить его вы всё равно не можете... ═══"
rm -f wide/created.txt 2>&1 | sed 's/^/  /' || echo "  (удалилось: каталог доступен на запись всем)"

sudo rm -rf wide

Ожидаемый вывод:

text
═══ запись из container от root ═══
  владелец нового файла: 0:0  права: -rw-r--r--
═══ права каталога не наследуются файлом ═══
  каталог: 777   файл: 644
═══ удалить его вы всё равно не можете... ═══
  (удалилось: каталог доступен на запись всем)

Разберём внимательно, потому что результат неочевиден.

Файл создан с правами 644 и владельцем rootchmod 777 каталога на него не повлиял: права новых файлов определяет umask процесса, а не каталог.

Удалить файл удалось — но не потому, что права файла позволяют. Удаление зависит от прав каталога, а он доступен на запись всем. То есть chmod 777 дал возможность удалять чужие файлы любому пользователю системы — ровно то, чего не хотелось бы.

При этом изменить содержимое файла обычный пользователь по-прежнему не может. Проблема согласования UID никуда не делась, добавилась лишь дыра в правах каталога.

bash
cd /tmp && sudo rm -rf /tmp/uid-demo
docker rmi -f nonroot devimg > /dev/null 2>&1

Что показывает rootless Docker

bash
echo "═══ режим демона ═══"
docker info --format 'Rootless: {{.SecurityOptions}}' | grep -o 'rootless' || echo "обычный (rootful) режим"

Ожидаемый вывод в обычном режиме:

text
═══ режим демона ═══
обычный (rootful) режим

В rootless-режиме та же проверка выведет rootless, и поведение изменится:

ДействиеRootfulRootless
docker run -v "$PWD:/d" alpine touch /d/fФайл принадлежит rootФайл принадлежит вам
docker run -u 10001 -v "$PWD:/d" ...UID 10001UID из subuid-диапазона
Удаление файла без sudoНевозможноВозможно

Если основной сценарий — разработка с bind mount, rootless-режим устраняет проблему в корне (урок 1.6).


Практическое упражнение

Задание. Организуйте разработку с bind mount так, чтобы все файлы, созданные приложением, принадлежали текущему пользователю host.

Требования:

  1. Приложение внутри container'а работает не от root.
  2. Файлы, созданные приложением в смонтированном каталоге, принадлежат вашему UID.
  3. Созданные файлы удаляются с host без sudo — доказать.
  4. HOME внутри container'а осмысленный, кэш библиотек создаётся без ошибок.
  5. Тот же образ пригоден для production, где UID фиксирован и bind mount отсутствует.
  6. Решение работает у другого разработчика с иным UID без правки файлов в репозитории.

Подсказки

Подсказка 1

Требования 5 и 6 конфликтуют с «зашить UID в Dockerfile». Нужно разделить конфигурацию разработки и production.

Подсказка 2

Compose умеет подставлять переменные окружения; id -u можно вычислить в .env или в обёртке.

Подсказка 3

Требование 4 при --user требует явного HOME и каталога, куда этот UID может писать.

Решение

Показать решение
bash
mkdir -p /tmp/uidfix/src/app /tmp/uidfix/output && cd /tmp/uidfix

cat > src/app/__init__.py <<'PY'
"""Приложение, пишущее в смонтированный каталог."""
PY

cat > src/app/main.py <<'PY'
"""Создаёт файлы в /output и сообщает, от кого работает."""
from __future__ import annotations

import os
import sys
from pathlib import Path

OUTPUT = Path(os.environ.get("OUTPUT_DIR", "/output"))


def main() -> int:
    print(f"  UID процесса:  {os.getuid()}:{os.getgid()}")
    print(f"  HOME:          {os.environ.get('HOME', '(не задан)')}")

    # Требование 4: кэш библиотек
    cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "app"
    try:
        cache.mkdir(parents=True, exist_ok=True)
        (cache / "state").write_text("ok\n")
        print(f"  кэш:           {cache} (записан)")
    except OSError as exc:
        print(f"  кэш:           ОШИБКА {exc}")
        return 1

    # Требование 2: файлы в смонтированном каталоге
    try:
        OUTPUT.mkdir(parents=True, exist_ok=True)
        (OUTPUT / "result.txt").write_text("результат работы\n")
        (OUTPUT / "nested").mkdir(exist_ok=True)
        (OUTPUT / "nested" / "deep.txt").write_text("вложенный файл\n")
        print(f"  файлы:         {OUTPUT}/result.txt, {OUTPUT}/nested/deep.txt")
    except OSError as exc:
        print(f"  файлы:         ОШИБКА {exc}")
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
output
.env
EOF

# ── Один Dockerfile, две стадии ──
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONPATH=/app/src
WORKDIR /app
COPY src/ ./src/

# ── Production: фиксированный UID, bind mount не предполагается ──
FROM base AS runtime
RUN groupadd -g 10001 app && \
    useradd -u 10001 -g 10001 -m -d /home/app app && \
    mkdir -p /output && chown 10001:10001 /output
ENV HOME=/home/app
USER 10001:10001
CMD ["python", "-m", "app.main"]

# ── Разработка: UID подставляется при сборке ──
FROM base AS dev
ARG UID=1000
ARG GID=1000
# Группа или пользователь с таким номером могут уже существовать в образе
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
    useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
    mkdir -p /home/dev && chown -R ${UID}:${GID} /home/dev
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["python", "-m", "app.main"]
EOF

# Требование 6: UID вычисляется, а не записан в репозиторий
cat > .env <<EOF
UID=$(id -u)
GID=$(id -g)
EOF
echo ".env" >> .dockerignore

cat > compose.yaml <<'EOF'
services:
  # Разработка: UID из окружения, bind mount
  dev:
    build:
      context: .
      target: dev
      args:
        UID: ${UID:-1000}
        GID: ${GID:-1000}
    environment:
      OUTPUT_DIR: /output
    volumes:
      - ./output:/output
      - ./src:/app/src

  # Production: фиксированный UID, данные в volume
  prod:
    build:
      context: .
      target: runtime
    environment:
      OUTPUT_DIR: /output
    volumes:
      - prod-output:/output

volumes:
  prod-output:
EOF

# ── Проверка ──
echo "═══ Требования 1, 2, 4: разработка ═══"
docker compose run --rm --build dev 2>/dev/null

echo
echo "═══ Требование 2: владельцы файлов на host ═══"
find output -type f -o -type d | sort | while read -r p; do
    printf '  %-28s %s\n' "$p" "$(stat -c '%u:%g' "$p")"
done
printf '  ваш UID:GID                  %s:%s\n' "$(id -u)" "$(id -g)"

echo
echo "═══ Требование 3: удаление без sudo ═══"
if rm -rf output/result.txt output/nested 2>/dev/null; then
    echo "  ✓ удалено без sudo"
else
    echo "  ✗ понадобился sudo"
fi

echo
echo "═══ Требование 1: точно не root ═══"
uid_in_container="$(docker compose run --rm -T dev id -u 2>/dev/null | tr -d '\r')"
[ "$uid_in_container" != "0" ] && echo "  ✓ UID=$uid_in_container, не root" || echo "  ✗ работает от root"

echo
echo "═══ Требование 5: production-стадия ═══"
docker compose run --rm --build prod 2>/dev/null
echo "  данные в volume, bind mount не используется"

echo
echo "═══ Требование 6: другой разработчик, другой UID ═══"
UID=4242 GID=4242 docker compose build dev > /dev/null 2>&1
UID=4242 GID=4242 docker compose run --rm -T dev id 2>/dev/null | sed 's/^/  /'
echo "  файлов репозитория менять не пришлось — UID пришёл из окружения"

docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/uidfix

Ожидаемый вывод:

text
═══ Требования 1, 2, 4: разработка ═══
  UID процесса:  1000:1000
  HOME:          /home/dev
  кэш:           /home/dev/.cache/app (записан)
  файлы:         /output/result.txt, /output/nested/deep.txt

═══ Требование 2: владельцы файлов на host ═══
  output                       1000:1000
  output/nested                1000:1000
  output/nested/deep.txt       1000:1000
  output/result.txt            1000:1000
  ваш UID:GID                  1000:1000

═══ Требование 3: удаление без sudo ═══
  ✓ удалено без sudo

═══ Требование 1: точно не root ═══
  ✓ UID=1000, не root

═══ Требование 5: production-стадия ═══
  UID процесса:  10001:10001
  HOME:          /home/app
  кэш:           /home/app/.cache/app (записан)
  файлы:         /output/result.txt, /output/nested/deep.txt
  данные в volume, bind mount не используется

═══ Требование 6: другой разработчик, другой UID ═══
  uid=4242(dev) gid=4242(dev) groups=4242(dev)
  файлов репозитория менять не пришлось — UID пришёл из окружения

Все шесть требований выполнены.

Три решения, определяющие качество.

Две стадии в одном Dockerfile вместо двух файлов. Требования 5 и 6 тянут в разные стороны: production хочет фиксированный UID и воспроизводимый образ, разработка — UID текущего пользователя. Стадии runtime и dev наследуют общую base, поэтому код и зависимости описаны один раз, а различается только пользователь. Два отдельных Dockerfile разошлись бы через месяц.

UID приходит из .env, который не в репозитории. Записать UID=1000 в compose.yaml означало бы сломать работу у любого, у кого UID другой, — а в командах это обычное дело. Файл .env генерируется локально и добавлен в .dockerignore; в репозитории лежит только ${UID:-1000} со значением по умолчанию.

useradd с || true и последующим chown домашнего каталога. При UID=1000 пользователь или группа могут уже существовать в базовом образе, и useradd завершится ошибкой — сборка упадёт на ровном месте у части разработчиков. Подавление ошибки решает это, но тогда домашний каталог может не создаться, поэтому chown -R идёт отдельной командой. Оба шага нужны; без второго требование 4 не выполняется.

Чего решение не делает. Оно не помогает, если каталог ./output уже существует и принадлежит другому пользователю — например, остался от прежних запусков от root. Bind mount не меняет владельца существующего каталога, и первая же запись упрётся в отказ. Промышленная обёртка проверяет это перед запуском и предлагает sudo chown. Радикальная альтернатива — rootless Docker, где вопрос не возникает вовсе.

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

bash
mkdir -p /tmp/perm-check
docker run --rm -v /tmp/perm-check:/d alpine:3.21 touch /d/root-file
docker run --rm --user "$(id -u):$(id -g)" -v /tmp/perm-check:/d alpine:3.21 touch /d/user-file
ls -ln /tmp/perm-check
sudo rm -rf /tmp/perm-check

Ожидается 0:0 для первого файла и ваш UID для второго.

Типичные ошибки

ОшибкаПричинаИсправление
chmod 777 при Permission deniedСовет с форумаНе меняет владельца; открывает данные всем
Сравнивают имена пользователейls -l показывает именаСмотреть ls -ln: значение имеют числа
--user без HOMEНе подумали о побочных эффектахHOME=/, библиотеки не создают кэш
Зашитый UID в общем DockerfileРаботает у автораЛомается у коллег; выносить в ARG
chown каталога проекта под UID образаПростое решениеКаталог перестаёт принадлежать вам
Общая группа без setgidНе знают про битНовые файлы получают другую группу
sudo rm -rf для уборки за container'омБыстрее всегоСимптом проблемы; настроить UID
Ожидают, что volume даст те же проблемыОбобщают опыт bind mountVolume наследует права из образа
useradd без обработки существующего UIDПроверяли на своей машинеСборка падает при совпадении номеров
Считают, что root в container = root на host всегдаНе знают про rootlessВ rootless UID отображаются

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

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

  1. Почему файл, созданный в container от root, принадлежит root на host?
  2. Почему имя пользователя в container ничего не значит для прав на host?
  3. Почему у named volume нет проблемы согласования UID?
  4. Что именно делает бит setgid на каталоге и зачем он здесь?
  5. Почему chmod 777 не решает задачу?

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

  1. Как за одну команду понять, сможет ли container писать в каталог host?
  2. Как запустить container от своего UID и не потерять HOME?
  3. Как сделать образ, работающий и у вас, и у коллеги с другим UID?

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

  1. Приложение стартует, читает конфигурацию, падает при первой записи. Причина?
  2. После сборки в каталоге проекта появились файлы, которые не удаляются. Что произошло?

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

  1. Ядро проверяет права по числам; имена существуют только в /etc/passwd.
  2. Файл /etc/passwd свой в каждом container'е — совпадение имён ничего не означает.
  3. При разборе прав используют ls -ln, а не ls -l.
  4. Файл, созданный процессом от UID 0, принадлежит root на host.
  5. Non-root container не может писать в каталог host, принадлежащий другому UID.
  6. Named volume наследует владельца из образа, поэтому проблемы не создаёт.
  7. --user "$(id -u):$(id -g)" решает задачу для разработки, но требует явного HOME.
  8. --build-arg UID даёт полноценного пользователя, но привязывает образ к разработчику.
  9. Общая группа работает только с битом setgid на каталоге.
  10. chmod 777 не меняет владельца, не наследуется новыми файлами и открывает данные всем.
  11. В rootless Docker файлы, созданные от root в container, принадлежат вам.
  12. Разработку и production разводят по стадиям одного Dockerfile, а UID берут из окружения.

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

ИсточникСсылкаЧто подтверждает
Docker: --userhttps://docs.docker.com/reference/cli/docker/container/run/#userПодмена UID и GID процесса
Docker: bind mountshttps://docs.docker.com/engine/storage/bind-mounts/Владельцы файлов при монтировании
Docker: rootless modehttps://docs.docker.com/engine/security/rootless/Отображение UID, /etc/subuid
Docker: userns-remaphttps://docs.docker.com/engine/security/userns-remap/Смещение UID для всех container'ов
Dockerfile: USERhttps://docs.docker.com/reference/dockerfile/#userЧисловая и именная форма
Compose: user, build.argshttps://docs.docker.com/reference/compose-file/services/Передача UID при сборке и запуске
Linux: chmod и setgidhttps://man7.org/linux/man-pages/man1/chmod.1.htmlБит s на каталоге
Linux: credentials(7)https://man7.org/linux/man-pages/man7/credentials.7.htmlUID, GID, дополнительные группы

Навигация

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

Markdown на GitHub ↗