Permissions: UID mismatch, chown в entrypoint, типичные ошибки
Если ты хоть раз видел сообщение «data directory "/var/lib/postgresql/data" has wrong ownership» — добро пожаловать в самый частый класс багов volume и bind mount. Корень проблемы — POSIX-права не зависят от Docker, а UID/GID процессов в контейнере не обязаны совпадать с UID хоста. Этот урок — про то, как этот mismatch возникает, как правильные образы его обходят через entrypoint-скрипт, и какие приёмы доступны тебе самому.
Пользователи и группы — UID, GID и две текстовые базы данных chmod: octal и symbolic notation, umask
Чем отличаются bind и volume по правам
| Аспект | bind mount | named volume |
|---|---|---|
| Owner/перм. при mount | Наследуются с host path | Создаются Docker, обычно root:root |
| Изменения прав в контейнере | Меняют host-файл напрямую | Меняют файл в Docker-managed папке |
| Готовность к owner-чистке | Хост должен подготовить заранее | Контейнер может chown при init |
| Поведение при пустом mount | Видишь содержимое host-папки | Пустой при первом mount |
| Особо: empty volume + образ-content | Bind перекрывает то, что было в образе | Empty named volume копирует содержимое из образа на первый mount |
Последний пункт критичен и часто удивляет. Если ты делаешь:
docker run -v pgdata:/var/lib/postgresql/data postgres:17
— и pgdata пустой и новый, Docker сначала скопирует содержимое директории /var/lib/postgresql/data из образа в volume. У Postgres-образа эта папка обычно пустая, но у других — нет. Например, у Airflow в /opt/airflow лежат файлы из образа. Если ты примонтируешь туда named volume — на первый mount туда скопируется содержимое.
С bind такого нет: bind просто перекрывает, host-папка показывается «как есть».
UID mismatch — анатомия
Сценарий:
# На Linux я — UID 1000.
mkdir -p ./pgdata-bind
docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-v $(pwd)/pgdata-bind:/var/lib/postgresql/data \
postgres:17
docker logs pg
# initdb: error: could not change permissions of directory "/var/lib/postgresql/data"
# ... data directory has wrong ownership
Что произошло:
- Host-папка
pgdata-bindпринадлежит мне (UID 1000:GID 1000). - Postgres-образ внутри запускает процесс от пользователя
postgres(UID 999). initdbхочет писать в/var/lib/postgresql/data, но папка —1000:1000, а процесс —999:999. Запись отказывает.
С named volume Postgres-образ сам исправляет — на пустой volume entrypoint делает chown postgres:postgres /var/lib/postgresql/data. С bind mount entrypoint не лезет в host-папку, потому что это потенциально опасно (что если ты примонтировал /?).
Решения:
-
Подготовить host-папку с нужным UID:
sudo chown -R 999:999 ./pgdata-bindМинус: на каждой машине надо помнить, плюс это «магическое число» 999.
-
Запустить контейнер с тем же UID, что хост-папка:
docker run -d -u $(id -u):$(id -g) \ -e POSTGRES_PASSWORD=secret \ -v $(pwd)/pgdata-bind:/var/lib/postgresql/data \ postgres:17Минус: Postgres ожидает UID
postgres, а тут будет другой. Иногда срабатывает, иногда —permission deniedна внутренние файлы. -
Использовать named volume вместо bind. Самый правильный путь для Postgres data: volume управляется Docker, entrypoint выставит нужный owner.
Не пытайся монтировать Postgres data через bind mount на macOS. Помимо UID-проблем там ещё и I/O через VM-FS — fsync медленный, durability гарантии слабее. Для Postgres data — named volume, всегда.
Стандартный паттерн: entrypoint + chown + gosu
Если ты строишь свой образ, в котором нужен mountable data path, классический рецепт:
FROM debian:12-slim
RUN apt-get update && apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r app && useradd -r -g app -u 1001 app
RUN mkdir -p /data && chown -R app:app /data
VOLUME /data
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["my-application"]
entrypoint.sh:
#!/bin/sh
set -e
# Если /data не принадлежит app — поправим. На bind mount этот chown реально перепишет host-папку.
chown -R app:app /data || true
# drop privileges и запустим основной процесс
exec gosu app "$@"
gosu — это утилита, которая делает setuid + exec в стиле su, но безопаснее и без TTY-шенаниганов su. Если gosu нет, можно su-exec (alpine) или runuser -u app -- "$@" (более новые ядра).
Логика:
- Контейнер стартует от root (потому что нужно сделать chown на mount path).
chownпоправит owner на mount path — на bind mount это перепишет host UID, на named volume — на volume UID.gosu app "$@"переключается на app user и стартует основное приложение.
Так делают официальные образы Postgres, Redis, Airflow.
Linux vs macOS поведение
| Сценарий | Linux | macOS Docker Desktop / OrbStack |
|---|---|---|
| Bind mount, файл создан в контейнере под UID 1000 | На хосте owner = 1000 | На хосте обычно owner = твой macOS-юзер (UID 501) |
| Bind mount, host-папка под UID 501, контейнер хочет writeable | Permission denied если процесс != 501 | Обычно works — VM сглаживает |
| Postgres data в bind mount | Требует точного UID 999 на host-папке | Часто работает «из коробки», но медленно |
| Named volume под Postgres data | Работает out-of-box | Работает out-of-box |
На macOS bind-mount-permissions менее строги — VM-driver сглаживает. На Linux всё буквально. Поэтому код, написанный на маке, может упасть на CI-Linux с UID-ошибкой. Тестируй на Linux прежде, чем считать pipeline production-ready.
Типичные ошибки и лечение
Ошибка 1: «data directory has wrong ownership»
initdb: error: could not change permissions of directory "/var/lib/postgresql/data" to 0700
Причина: bind mount с UID хоста, Postgres ждёт UID 999.
Лечение: перейти на named volume, либо chown -R 999:999 ./pgdata-bind перед запуском.
Ошибка 2: «permission denied» при попытке записать в /app
PermissionError: [Errno 13] Permission denied: '/app/output.csv'
Причина: контейнер запущен от user app (UID 1001), bind mount имеет host UID 1000, в host-папке нет write permission для UID 1001.
Лечение:
- быстро:
chmod 777 ./output-dir(быстро, но плохо для prod); - правильно:
chown 1001:1001 ./output-dir; - идеально: build-arg
UIDв образе совпадает с хостом,useradd -u $UIDсовпадает.
Ошибка 3: named volume содержит мусор после первого старта
ls /opt/airflow внутри контейнера показывает кучу всего из образа
Причина: ты примонтировал named volume на путь, где в образе уже что-то лежало. Docker скопировал содержимое образа в volume при первом mount, оно там осталось.
Лечение: удалить volume (docker volume rm) и пересоздать. Если нужно сохранить часть — выбраться внутрь volume через docker run --rm -v vol:/v alpine sh и удалить нужные файлы.
Ошибка 4: bind mount пустой, ожидался файл из образа
docker run -v $(pwd)/conf:/etc/app -- ну и где /etc/app/default.conf?
Причина: bind mount перекрывает то, что было в образе. Файл default.conf лежал в образе по пути /etc/app/default.conf, но host-папка ./conf пустая — после mount в /etc/app видна только пустая host-папка.
Лечение: сделать COPY на хост перед запуском, либо монтировать конкретный файл, а не папку:
docker run -v $(pwd)/conf/myapp.conf:/etc/app/myapp.conf:ro ...
Попробуй сам
# 1. Воспроизведи UID mismatch на bind mount (на Linux).
mkdir -p /tmp/uid-demo
docker run --rm -u 1500:1500 \
-v /tmp/uid-demo:/data \
alpine sh -c 'echo "from 1500" > /data/file.txt; ls -ln /data/'
ls -ln /tmp/uid-demo
# Файл принадлежит UID 1500. Если ты не 1500 на хосте, тронуть его без sudo не сможешь.
# Cleanup (нужен sudo на Linux):
sudo rm -rf /tmp/uid-demo
# 2. Чистый запуск Postgres с named volume — никаких UID-проблем.
docker run -d --name pg-clean \
-e POSTGRES_PASSWORD=secret \
-v pg-clean-data:/var/lib/postgresql/data \
postgres:17
sleep 5
docker logs pg-clean | head -20
# Видишь "database system is ready to accept connections" — никаких ошибок прав.
# Заглянем в volume — кто owner?
docker run --rm -v pg-clean-data:/d alpine ls -ln /d | head -5
# postgres = 999 внутри alpine. Postgres-entrypoint выставил.
docker rm -f pg-clean
docker volume rm pg-clean-data
# 3. Empty named volume копирует содержимое образа.
# Запустим nginx, у которого в /usr/share/nginx/html лежит index.html по умолчанию.
docker run -d --name nginx-vol \
-v nginx-html:/usr/share/nginx/html \
-p 8080:80 \
nginx:1.27-alpine
# Volume теперь содержит default index.html, скопированный из образа.
docker run --rm -v nginx-html:/v alpine ls /v
# 50x.html index.html
docker rm -f nginx-vol
docker volume rm nginx-html
Best practice: для своих образов всегда документируй ожидаемый UID. В README пиши «образ запускается от UID 1001, для bind mount на Linux подготовьте папку через chown 1001 …». Лучше — добавь build-arg для UID, чтобы пользователь мог собрать образ под свой хост.
На этом разбор volumes завершён. В следующем модуле — сети контейнеров: bridge, host, none, overlay; service discovery через DNS; почему Postgres внутри compose доступен как postgres:5432, а не localhost:5432.