7.5. Права доступа, UID и GID
Цели
После этого материала вы сможете:
- объяснить, почему файл, созданный в container, принадлежит
rootна host; - понять, что имена пользователей внутри и снаружи container'а не связаны между собой;
- диагностировать
Permission deniedпри bind mount за одну команду; - применить четыре рабочих решения и выбрать подходящее под задачу;
- объяснить, почему
chmod 777— маскировка проблемы, а не решение; - сказать, чем ситуация отличается в rootless Docker.
Предварительные знания
- 7.3. Bind mounts;
- 6.7. Non-root и права;
- 1.6. Rootless Docker;
- права доступа Linux: владелец, группа, биты
rwx.
Ключевые термины
| Термин | Объяснение |
|---|---|
UID | Числовой идентификатор пользователя — единственное, что знает ядро |
GID | Числовой идентификатор группы |
/etc/passwd | Файл соответствия имён и номеров; свой в каждом container'е |
setgid на каталоге | Новые файлы наследуют группу каталога |
userns-remap | Смещение диапазона UID для container'ов |
supplementary groups | Дополнительные группы процесса |
Теория
Ядро знает только числа
Это утверждение объясняет всё остальное в уроке.
Права доступа в Linux проверяются по числам: UID процесса сравнивается с UID владельца файла. Имена — root, appuser, postgres — существуют только в /etc/passwd, и этот файл свой в каждом container'е.
Отсюда:
| Наблюдение | Причина |
|---|---|
| Файл, созданный в container от root, принадлежит root на host | UID 0 внутри и снаружи — одно и то же число |
appuser в container и appuser на host — разные пользователи | Совпало имя, а не UID |
| Один UID может называться по-разному | Два разных /etc/passwd |
ls -l на host показывает имя вашего пользователя для файлов из container'а | Совпали номера, имя подставил host |
Практический вывод: при работе с правами и container'ами смотрите на числа, а не на имена:
ls -ln # числовые UID и GID вместо имён
Откуда берётся проблема
Схема, порождающая большинство обращений в поддержку:
host: каталог ./data принадлежит UID 1000 (вы), права 755
container: процесс работает от UID 10001 (appuser из образа)
монтируем: -v ./data:/data
результат: Permission denied при записи
Ни одна сторона не ошиблась: каталог host принадлежит вам, образ правильно использует non-root пользователя. Несовместимы именно числа.
Обратная ситуация встречается не реже:
container: процесс работает от UID 0 (root по умолчанию)
монтируем: -v ./output:/output
приложение создаёт ./output/result.txt
на host: файл принадлежит root, обычный пользователь не может его удалить
Здесь ошибка проявляется не сразу, а при попытке убрать за собой — и rm требует sudo в собственном каталоге проекта.
Почему у volumes этой проблемы нет
Named volume при первом монтировании наполняется содержимым образа вместе с владельцем и правами (урок 7.2). Если в образе /data принадлежит UID 10001, то и в volume он будет принадлежать ему.
| Volume | Bind 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 процесса:
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 задаётся при сборке:
ARG UID=10001
ARG GID=10001
RUN groupadd -g ${GID} app && useradd -u ${UID} -g ${GID} -m app
USER ${UID}:${GID}
docker build --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t dev .
Работает и сохраняет запись в /etc/passwd, но делает образ привязанным к конкретному разработчику — в реестр такой образ не публикуют. Отсюда практика: отдельный Dockerfile.dev или отдельная стадия для разработки.
Решение 3 подробнее. Со стороны host:
sudo chown -R 10001:10001 ./data
Просто и надёжно для сервера, где UID образа известен и постоянен. Для разработки неудобно: каталог перестаёт принадлежать вам.
Решение 4 подробнее. Общая группа плюс бит setgid:
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).
| Обычный Docker | Rootless | |
|---|---|---|
| UID 0 в container | UID 0 на host | Ваш UID на host |
| UID 1000 в container | UID 1000 на host | UID из вашего subuid-диапазона |
| Файл от root в bind mount | Принадлежит root | Принадлежит вам |
Нужен ли --user для разработки | Обычно да | Обычно нет |
Для разработки это заметно удобнее: файлы, созданные container'ом, сразу принадлежат вам.
Обратная сторона: файлы, созданные в container'е от не-root пользователя, получают на host UID из subuid-диапазона (например, 100000+10000) и не принадлежат вам — картина зеркальна привычной.
Промежуточный вариант для обычного Docker — userns-remap в настройках демона. Он смещает UID container'ов, но применяется ко всем container'ам сразу и ломает часть сценариев с bind mount.
Внутренний механизм
Что происходит при записи
- Процесс в container вызывает
open("/data/file", O_CREAT). - Ядро проверяет права каталога против UID и GID процесса — тех, что видны ядру, без всякого пересчёта.
- При успехе файл создаётся с владельцем, равным UID процесса.
- Тот же inode виден с host — файловая система одна.
Никакого преобразования на границе container'а нет (кроме user namespace в rootless и userns-remap). Именно поэтому «файл принадлежит root» — не ошибка Docker, а прямое следствие того, что процесс работал от UID 0.
Как проверить соответствие
stat -c '%u:%g %n' ./data # UID:GID на host
docker run --rm -v "$PWD/data:/d" img id -u # UID процесса в container
Совпадают — записи не будет проблем. Не совпадают — нужны права по группе или одно из четырёх решений.
Команды и примеры
Имена не значат ничего, числа — всё
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
Ожидаемый вывод:
═══ кто такой 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 показывает то, что происходит на самом деле. При разборе прав пользуйтесь ей.
Файл, который не удалить
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"
Ожидаемый вывод:
═══ container от root создаёт файл ═══
владелец: 0:0 файл: report.txt
═══ попытка удалить обычным пользователем ═══
rm: cannot remove 'output/report.txt': Permission denied
осталось: report.txt
═══ приходится звать sudo в собственном каталоге ═══
удалено через sudo
Это происходит в каталоге проекта, при обычной сборке, без каких-либо привилегированных флагов. Достаточно того, что процесс в container'е по умолчанию работает от root.
Обратная ситуация: non-root не может писать
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/^/ /'
Ожидаемый вывод:
каталог data принадлежит 1000:1000
═══ container от UID 10001 пытается писать ═══
sh: can't create /data/new.txt: Permission denied
═══ чтение при этом работает ═══
исходное
Чтение проходит (права 755 дают всем r-x), запись — нет. Типичная картина: приложение стартует, читает конфигурацию, а падает позже, при первой записи.
Диагностика за одну команду
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"
Ожидаемый вывод:
═══ образ с 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
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 " кэш создать удалось"
'
Ожидаемый вывод:
═══ с --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 образа при сборке
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
Ожидаемый вывод:
═══ образ собран под ваш 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
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
Ожидаемый вывод:
═══ права каталога ═══
drwxrwsr-x 0:60000
═══ запись из container с --group-add ═══
запись прошла
═══ владелец и группа созданного файла ═══
10001:60000 (группа унаследована благодаря setgid)
═══ без setgid группа была бы основной группой процесса ═══
10001:10001 ← группа НЕ унаследована
Буква s вместо x в правах группы (drwxrwsr-x) — это и есть setgid. Сравнение двух последних блоков показывает, зачем он нужен: без него каждый новый файл получает группу 10001, недоступную другим участникам, и согласование разваливается после первой записи.
Почему chmod 777 не работает
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
Ожидаемый вывод:
═══ запись из container от root ═══
владелец нового файла: 0:0 права: -rw-r--r--
═══ права каталога не наследуются файлом ═══
каталог: 777 файл: 644
═══ удалить его вы всё равно не можете... ═══
(удалилось: каталог доступен на запись всем)
Разберём внимательно, потому что результат неочевиден.
Файл создан с правами 644 и владельцем root — chmod 777 каталога на него не повлиял: права новых файлов определяет umask процесса, а не каталог.
Удалить файл удалось — но не потому, что права файла позволяют. Удаление зависит от прав каталога, а он доступен на запись всем. То есть chmod 777 дал возможность удалять чужие файлы любому пользователю системы — ровно то, чего не хотелось бы.
При этом изменить содержимое файла обычный пользователь по-прежнему не может. Проблема согласования UID никуда не делась, добавилась лишь дыра в правах каталога.
cd /tmp && sudo rm -rf /tmp/uid-demo
docker rmi -f nonroot devimg > /dev/null 2>&1
Что показывает rootless Docker
echo "═══ режим демона ═══"
docker info --format 'Rootless: {{.SecurityOptions}}' | grep -o 'rootless' || echo "обычный (rootful) режим"
Ожидаемый вывод в обычном режиме:
═══ режим демона ═══
обычный (rootful) режим
В rootless-режиме та же проверка выведет rootless, и поведение изменится:
| Действие | Rootful | Rootless |
|---|---|---|
docker run -v "$PWD:/d" alpine touch /d/f | Файл принадлежит root | Файл принадлежит вам |
docker run -u 10001 -v "$PWD:/d" ... | UID 10001 | UID из subuid-диапазона |
Удаление файла без sudo | Невозможно | Возможно |
Если основной сценарий — разработка с bind mount, rootless-режим устраняет проблему в корне (урок 1.6).
Практическое упражнение
Задание. Организуйте разработку с bind mount так, чтобы все файлы, созданные приложением, принадлежали текущему пользователю host.
Требования:
- Приложение внутри container'а работает не от root.
- Файлы, созданные приложением в смонтированном каталоге, принадлежат вашему UID.
- Созданные файлы удаляются с host без
sudo— доказать. HOMEвнутри container'а осмысленный, кэш библиотек создаётся без ошибок.- Тот же образ пригоден для production, где UID фиксирован и bind mount отсутствует.
- Решение работает у другого разработчика с иным UID без правки файлов в репозитории.
Подсказки
Подсказка 1
Требования 5 и 6 конфликтуют с «зашить UID в Dockerfile». Нужно разделить конфигурацию разработки и production.
Подсказка 2
Compose умеет подставлять переменные окружения; id -u можно вычислить в .env или в обёртке.
Подсказка 3
Требование 4 при --user требует явного HOME и каталога, куда этот UID может писать.
Решение
Показать решение
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
Ожидаемый вывод:
═══ Требования 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, где вопрос не возникает вовсе.
Проверка результата
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 mount | Volume наследует права из образа |
useradd без обработки существующего UID | Проверяли на своей машине | Сборка падает при совпадении номеров |
| Считают, что root в container = root на host всегда | Не знают про rootless | В rootless UID отображаются |
Контрольные вопросы
На понимание:
- Почему файл, созданный в container от root, принадлежит root на host?
- Почему имя пользователя в container ничего не значит для прав на host?
- Почему у named volume нет проблемы согласования UID?
- Что именно делает бит
setgidна каталоге и зачем он здесь? - Почему
chmod 777не решает задачу?
На применение:
- Как за одну команду понять, сможет ли container писать в каталог host?
- Как запустить container от своего UID и не потерять
HOME? - Как сделать образ, работающий и у вас, и у коллеги с другим UID?
На диагностику:
- Приложение стартует, читает конфигурацию, падает при первой записи. Причина?
- После сборки в каталоге проекта появились файлы, которые не удаляются. Что произошло?
Краткое резюме
- Ядро проверяет права по числам; имена существуют только в
/etc/passwd. - Файл
/etc/passwdсвой в каждом container'е — совпадение имён ничего не означает. - При разборе прав используют
ls -ln, а неls -l. - Файл, созданный процессом от UID 0, принадлежит root на host.
- Non-root container не может писать в каталог host, принадлежащий другому UID.
- Named volume наследует владельца из образа, поэтому проблемы не создаёт.
--user "$(id -u):$(id -g)"решает задачу для разработки, но требует явногоHOME.--build-arg UIDдаёт полноценного пользователя, но привязывает образ к разработчику.- Общая группа работает только с битом
setgidна каталоге. chmod 777не меняет владельца, не наследуется новыми файлами и открывает данные всем.- В rootless Docker файлы, созданные от root в container, принадлежат вам.
- Разработку и production разводят по стадиям одного Dockerfile, а UID берут из окружения.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Docker: --user | https://docs.docker.com/reference/cli/docker/container/run/#user | Подмена UID и GID процесса |
| Docker: bind mounts | https://docs.docker.com/engine/storage/bind-mounts/ | Владельцы файлов при монтировании |
| Docker: rootless mode | https://docs.docker.com/engine/security/rootless/ | Отображение UID, /etc/subuid |
| Docker: userns-remap | https://docs.docker.com/engine/security/userns-remap/ | Смещение UID для всех container'ов |
Dockerfile: USER | https://docs.docker.com/reference/dockerfile/#user | Числовая и именная форма |
Compose: user, build.args | https://docs.docker.com/reference/compose-file/services/ | Передача UID при сборке и запуске |
Linux: chmod и setgid | https://man7.org/linux/man-pages/man1/chmod.1.html | Бит s на каталоге |
Linux: credentials(7) | https://man7.org/linux/man-pages/man7/credentials.7.html | UID, GID, дополнительные группы |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Backup и restore
Главное оглавление