Короткий ответ

Named volume и bind сохраняют числовые UID/GID. Официальный Postgres в Debian-образе часто 999, nginx 101/33, Node-образы — 1000 или node. Если в compose стоит user: "1000:1000", а файлы в app_data принадлежат 999, получите EACCES. Лечение: согласовать user: / USER образа с chown на томе, либо наоборот. Не chmod -R 777, не privileged, не root «навсегда» (контейнер от root).

Сначала убедитесь, что том вообще смонтирован: volume не монтируется.

Симптомы и как отличить

  • лог: Permission denied: '/var/lib/postgresql/data' или EACCES: permission denied, open '/var/lib/app/data.db';
  • контейнер сразу Exited с кодом 1;
  • ls из контейнера видит файлы, touch нет;
  • bind с хоста: файлы admin:admin 700, контейнер uid 999.
ls на томеПроцессДействие
root:root 755, пустоnon-rootchown под гостя
999:999user 1000выровнять user
777любойоткатить, это не фикс

Возможные причины

  1. user: в compose скопировали с Node на Postgres.
  2. Том создался от root при первом запуске без USER, потом добавили non-root.
  3. Bind /opt/app/data принадлежит admin 1000, гость 33 (www-data).
  4. read_only: true без tmpfs — выглядит как права.
  5. NFS root_squash: uid 0 внутри становится nobody.
  6. userns-remap на демоне (редко на Ubuntu по умолчанию) сдвигает все UID.
  7. Редко: immutable attr chattr +i на хосте.

Диагностика

Контейнер app-web-1, volume app_data, путь внутри /var/lib/app.

1. Кто процесс и что на файле

docker compose -f /opt/app/compose.yaml exec web id
docker compose exec web ls -lan /var/lib/app
docker inspect app-web-1 --format 'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}}'

-n обязателен: имена в контейнере не равны именам хоста.

2. Хостовый inode тома

mp=$(docker volume inspect app_data --format '{{.Mountpoint}}')
echo "$mp"
sudo ls -lan "$mp"

Сверьте числа с id гостя.

3. Bind

ls -lan /opt/app/data

4. userns

docker info --format '{{.SecurityOptions}}'
grep userns /etc/docker/daemon.json || true

Решение

Сценарий A. Первый запуск, том root:root, нужен uid 999

Окно:

cd /opt/app
docker compose stop web
mp=$(docker volume inspect app_data --format '{{.Mountpoint}}')
sudo chown -R 999:999 "$mp"
docker compose up -d web
docker compose exec web id
docker compose exec web touch /var/lib/app/.write-test && docker compose exec web rm /var/lib/app/.write-test

UID возьмите из образа (docker compose run --rm --entrypoint id web), не из форума.

Сценарий B. Выровнять compose user под существующие данные

Данные уже 1000:1000:

services:
  web:
    user: "1000:1000"

Если бинарь образа обязан быть 999 (postgres) — не меняйте user на 1000, меняйте chown на 999. Postgres не стартует от произвольного uid без подготовки.

Сценарий C. Bind и хостовый admin

sudo chown -R 33:33 /opt/app/data

Либо добавьте на хосте группу и ACL, если нужно писать и admin, и контейнеру. chmod 777 не используйте.

Сценарий D. read_only корень

read_only: true
tmpfs:
  - /tmp
  - /var/run

Данные — только на app_data. EACCES на /tmp без tmpfs — не UID тома.

Сценарий E. Миграция с root-процесса на non-root

  1. Бэкап volume.
  2. stop.
  3. chown.
  4. user: в YAML.
  5. start.
  6. Проверка записи.

Не оставляйте entrypoint chmod -R 777 как «совместимость».

На Engine 27.x с cgroup v2 смена user: не меняет владельца уже лежащих inode. Первый контейнер от root создаёт PGDATA 70 root, второй с user: 999 не стартует. Это не баг overlay: это POSIX. Окно: stop, chown -R 999:999 на Mountpoint, start. Не делайте chown из entrypoint на каждый запуск на терабайтном томе — это часы IO и риск гонки.

userns-remap в daemon.json сдвигает все UID на хосте (например +165536). Тогда ls на Mountpoint показывает 165535+999, а внутри контейнера по-прежнему 999. Если remap включили после создания app_data, получите кашу. Не включайте remap в ночь на проде с живыми томами. Проверка: docker info SecurityOptions содержит name=userns, плюс каталог /var/lib/docker/165536.165536 вместо обычного tree.

Для bind из домашнего каталога admin (uid 1000) и образа nginx (www-data 33) ACL setfacl -m u:33:rwx на хосте иногда удобнее массового chown, если людям нужен git-checkout в тот же путь. На named volume в /var/lib/docker/volumes ACL возможны, но хуже сопровождаются бэкапом. Запрет 777 остаётся: это маскирует ошибку uid и открывает запись всем процессам хоста, у которых есть traverse к Mountpoint (root всегда, плюс ошибочные bind).

Как проверить, что проблема устранена

docker compose exec web id
docker compose exec web sh -c 'touch /var/lib/app/.perm-ok && rm /var/lib/app/.perm-ok && echo write_ok'
docker compose logs --tail 30 web

Нет EACCES в новых логах, сервис Running. На хосте ls -lan Mountpoint показывает тот же uid, что id гостя.

Если не помогло

  • operation not permitted на chmod внутри — нет CAP_FOWNER, и не надо: chown с хоста.
  • Только некоторые файлы 600 root — entrypoint инициализации от root оставил хвост. stop, chown -R.
  • Overlay верхний слой, не volume: приложение пишет не в mount. Inspect Mounts vs путь в конфиге.
  • NFS squash: uid mapping на NAS, не docker user.
  • AppArmor DENIED path — не unix uid, журнал.

Профилактика

  • В образе и compose один и тот же числовой UID, задокументированный в README стека.
  • init-контейнер с известным chown хуже, чем сразу правильный USER; если init — только на пустой том.
  • Бэкап app_data до массового chown.
  • Запрет 777 в CI grep.
  • Non-root как цель hardening, но после согласования тома.

FAQ

Почему 999, а не postgres?

На хосте пользователя postgres может не быть. Числа портативны. Имена в ls с хоста обманывают.

user: "0:0" как быстрый фикс?

Работает и отменяет смысл non-root. Только аварийно, с тикетом на откат.

Можно ли ACL вместо chown?

Да на bind. На named volume в /var/lib/docker ACL возможны, но проще один uid процесса.

Docker Desktop file sharing vs Engine на Ubuntu

Эта статья про Engine на сервере. Права Desktop/WSL — другой UID-маппинг, не копируйте советы.

chown 1000 на postgres data?

Сломаете официальный entrypoint. Для postgres держите uid образа (часто 999).