Перешли на образ postgres:18, а приложение видит пустую базу: проверяем PGDATA и тома по шагам
Приложение видит пустую базу не потому, что Docker потерял данные, а потому, что PostgreSQL 18 в контейнере стартовал на другом каталоге или на другом томе. Старый том почти всегда цел. Ниже — порядок проверки PGDATA и монтирований за десять минут, четыре типовые причины и безопасный способ вытащить данные.
Приложение видит пустую базу: данные пропали или оно смотрит не туда?
Меня зовут на такие истории со словами «Docker съел базу». За пятнадцать с лишним лет я видел, как базы съедали диски, обновления и стажёры с командой prune, но сам Docker данные не ест. Он аккуратно делает то, что написано в compose-файле, и именно в этом обычно причина. Если вы занимаетесь внедрением Docker-сервисов в офисе до 50 рабочих мест, этот сценарий рано или поздно придёт и к вам: поменяли тег образа, перенесли каталог со стеком, подняли, а в приложении пусто.
Первым делом я не лезу в тома. Сначала отвечаю на три вопроса. Первый: приложение подключается к тому контейнеру, который я сейчас вижу? После переноса на соседний каталог или другой сервер легко остаться со вторым, забытым postgres, у которого другой адрес, а в строке подключения вшито старое имя хоста. Второй: контейнер с базой стартовал на пустом каталоге данных? Третий: база не пуста, но приложение зашло под другой ролью или в другую базу данных, и таблицы лежат рядом?
Прежде чем что-либо делать, остановите приложение, не базу. Современные приложения при старте сами накатывают миграции на любую подключённую пустую базу, и через час в «новой» базе уже лежат свежие записи, которые потом придётся аккуратно сливать со старыми. Каждый час работы на пустой базе удорожает восстановление, это я проверял на практике.
И железное правило первого часа: ничего не удалять. Ни docker volume prune, ни docker compose down -v, ни «давайте наведём порядок в лишних томах». Фраза «лишний том» в такой ситуации самая дорогая из всех, что я слышал в серверных. Старый том, который сейчас не подключён, как раз и есть ваша база.
Что изменилось в образе postgres:18: PGDATA и VOLUME
В официальном образе PostgreSQL для версии 17 и ниже каталог данных лежит в /var/lib/postgresql/data, и именно там объявлен том. Начиная с 18 переменная PGDATA в образе стала версионной: /var/lib/postgresql/18/docker, а объявленный VOLUME переехал на уровень выше, в /var/lib/postgresql. В описании образа на Docker Hub это прямо названо важным изменением. Причина практическая: когда том один на весь /var/lib/postgresql, каталоги разных мажорных версий лежат рядом, и pg_upgrade можно запускать с быстрым ключом --link. Это правка в образе (изменение влито в июне 2025 года), а не в самом PostgreSQL: на «голом» Debian пути у вас всё те же.
Вот что это значит для вашего compose-файла: | Параметр | Образ до 17 включительно | Образ 18 и новее | |---|---|---| | PGDATA по умолчанию | /var/lib/postgresql/data | /var/lib/postgresql/18/docker | | Объявленный VOLUME | /var/lib/postgresql/data | /var/lib/postgresql | | Куда монтировать том | /var/lib/postgresql/data | /var/lib/postgresql | | Что бывает при неверном пути | данные не переживут пересоздание контейнера | данные окажутся в анонимном томе, а ваш именованный том простаивает | Документация образа предупреждает про старые версии: монтирование в /var/lib/postgresql там не сохранит данные при пересоздании контейнера. Симметричный сюрприз получается в 18: монтируем по-старому в /data и получаем тот же эффект, только наоборот.
Теперь о том, откуда берётся «пустая база». Скрипт запуска образа проверяет, есть ли в PGDATA файл PG_VERSION. Если есть, он пишет, что каталог уже содержит базу, и пропускает инициализацию. Если нет, он создаёт новый кластер, и только тогда срабатывают переменные POSTGRES_USER, POSTGRES_DB и скрипты из /docker-entrypoint-initdb.d: по документации образа они действуют лишь на пустом каталоге данных. То есть PostgreSQL не сломался и не потерял данные, он честно инициализировал новый кластер на пустом месте, а всё остальное доделало ваше приложение своими миграциями.
В свежих сборках образа 18 есть и страховка. Если PGDATA равен значению по умолчанию, скрипт заглядывает в /var/lib/postgresql, /var/lib/postgresql/data и /var/lib/postgresql/*/docker и ищет там PG_VERSION. Нашёл старые данные или обнаружил, что /var/lib/postgresql/data смонтирован и не используется, — контейнер завершается с ошибкой и перечисляет найденное. Оговорка про Alpine: проверку неиспользуемого монтирования /var/lib/postgresql/data в alpine-вариантах чинили отдельным коммитом в апреле 2026 года, поэтому на более ранних тегах вида 18.x-alpine образ мог молча создать новый кластер в анонимном томе. Громкая ошибка здесь полезнее тишины, поэтому я фиксирую в проектах тег с точной версией, а не «просто 18», и читаю лог первого запуска. Механику и сценарий обновления с 17 я подробно разобрал в отдельной статье про PGDATA и том, тут нас интересует диагностика.
Как проверить PGDATA и подключённые тома: пять команд
Начинаю со стека целиком. Здесь и далее служба с базой в моём примере называется db, у вас имя может быть другим. Команды выполняю в каталоге с compose-файлом:
docker compose ps
docker compose config --volumes
docker volume lsПервая показывает, какие контейнеры реально запущены этим проектом. Вторая выводит имена томов, которые Compose считает своими, построчно. Третья показывает все тома на хосте. Если в третьей есть том, похожий на ваш, но с другим префиксом, вы уже, скорее всего, нашли виновника.
Дальше смотрим внутрь контейнера: что считает каталогом данных образ и что в нём лежит. Команда читает переменную PGDATA и файл версии кластера:
docker compose exec db sh -c 'echo $PGDATA; cat $PGDATA/PG_VERSION; ls -la /var/lib/postgresql'Для образа 18 без своих настроек ожидаю PGDATA /var/lib/postgresql/18/docker и число 18 в PG_VERSION. Теперь подключённые тома. Параметр --format у docker inspect принимает шаблон Go, поле Mounts содержит тип, имя, источник и точку назначения каждого монтирования:
docker inspect --format '{{json .Mounts}}' $(docker compose ps -q db)Читаю вывод одним правилом: каталог PGDATA обязан лежать внутри точки назначения (Destination) именованного тома или bind-каталога, про который вы знаете, где он и сколько весит. Если Destination у вас /var/lib/postgresql/data, а PGDATA равна /var/lib/postgresql/18/docker, то именованный том к кластеру не относится. Если в Mounts есть строка с типом volume и именем из 64 шестнадцатеричных символов, это анонимный том, и ваш кластер живёт именно в нём.
Последний шаг — спросить саму базу. Подключаюсь теми же переменными, что заданы в контейнере (если вы их не задавали, подставьте пользователя postgres):
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SHOW data_directory" -c "\l+" -c "\dt"'SHOW data_directory подтверждает путь, \l+ показывает базы с размерами (размер виден, если у роли есть право CONNECT или она суперпользователь), \dt — таблицы текущей базы. База в десятки мегабайт при ожидаемых гигабайтах и таблицы, созданные только вашими миграциями, — классическая картина свежей инициализации. Я называю это «пустой базой с чистой совестью».
Осталось найти старый том. Тома, на которые не ссылается ни один контейнер, показывает фильтр dangling, а дату создания и точку монтирования на хосте отдаёт docker volume inspect:
docker volume ls -f dangling=true
docker volume inspect --format '{{ .Name }} {{ .CreatedAt }} {{ .Mountpoint }}' crm_pgdataРазмер каталога смотрите на хосте из-под root командой du -sh по пути Mountpoint. Три признака нужного тома: подходящая дата создания, размер, сопоставимый с ожидаемой базой, и файл PG_VERSION либо в корне тома (раскладка до 18), либо в подкаталоге вида 17/docker.
Четыре причины тихой пустоты и как отличить одну от другой
За все разборы у меня сложился короткий список причин. Чтобы не гадать, держите таблицу «симптом — причина — проверка»: | Что видите | Вероятная причина | Чем проверить | |---|---|---| | Образ 18, том смонтирован в /var/lib/postgresql/data, кластер создан заново | Монтирование «по-старому»: данные в анонимном томе, именованный простаивает | docker inspect: в Mounts анонимный том, PGDATA вне вашего тома | | Образ 18, монтирование в /var/lib/postgresql, но в томе лежит раскладка 17 | Файлы старого кластера в корне тома, а образ ждёт 18/docker | ls тома: PG_VERSION в корне, нет каталога 18 | | Стек переехал в другой каталог или запущен с другим -p | Сменилось имя проекта, Compose создал новый пустой том | docker volume ls: два тома с разными префиксами | | Всё работало, после down/up база пуста | Данные были в анонимном томе, а следующий up его не подхватывает | dangling-тома, дата создания старого тома |
Про имя проекта скажу отдельно, потому что его недооценивают. По документации Docker, имя проекта берётся по убыванию приоритета из флага -p, переменной COMPOSE_PROJECT_NAME, верхнеуровневого атрибута name в compose-файле и, наконец, из имени каталога, где лежит файл. Именованные тома без своего атрибута name получают имя вида проект_ключ. Перенесли каталог из /srv/crm в /srv/crm-new, и вместо тома crm_pgdata Compose спокойно создаёт crm-new_pgdata, пустой. Образ тут вообще ни при чём: переход на 18 просто совпал по времени с переездом, и все стали искать причину в образе.
Анонимные тома — вторая ловушка. Docker объявляет VOLUME в образе, и если вы ничего не смонтировали по этому пути, создаётся безымянный том. Документация compose down говорит прямо: анонимные тома по умолчанию не удаляются, но и следующий up автоматически их не подключит, потому что у них нет устойчивого имени. Обычный docker compose up при пересоздании контейнера анонимный том ещё переносит, поэтому всё работает неделями, а обнуляется после down и нового up. Для данных, которые должны жить, нужны именованные тома или bind-каталоги, и именно с точным путём. Похожая история с переездом тома на другую раскладку случилась и в обновлении netbox-docker, где база выглядела пустой: там пустоту породило само обновление поставки.
Четвёртый вариант самый обидный: кто-то после неудачного запуска «почистил» тома. Команды docker volume prune и docker compose down -v снесут то, что вам нужно, и не переспросят. Если вы читаете это до чистки, поставьте себе запрет на эти команды на хосте с базами. Если после, ищите снимки гипервизора и бэкап и не лечите нервы повторными запусками.
Если вы дочитали до этого места и поняли, что пытаться разобраться самостоятельно на боевой базе страшновато, это нормальная реакция. Мы в АйТи-Фреш можем подключиться удалённо, снять копию тома и вытащить данные, не трогая рабочий стек, пока вы продолжаете жить своей жизнью. Об условиях ниже, в конце статьи.
Кейс: «Ключи столицы», 43 рабочих места, CRM на Docker Compose
Риэлторская компания «Ключи столицы», 43 рабочих места. Внутренняя CRM (объекты, сделки, показы) крутится на одном сервере в Docker Compose: приложение, веб-сервер и postgres:16-alpine. База около 6,4 ГБ. Админ в один присест решил два дела: разложить стеки на том же сервере по новой схеме каталогов и заодно поднять образ до postgres:18-alpine. Каталог стека в процессе переехал из /srv/crm в /srv/crm-new. Монтирование он по документации исправил на /var/lib/postgresql, а том назвал в файле тем же ключом pgdata.
В понедельник утром менеджеры открыли CRM и увидели пустой список объектов. Приложение работало, вход проходил, интерфейс не ругался, потому что при старте оно само накатило миграции на новую базу. Три часа в ней успели создать 17 карточек, прежде чем кто-то сказал, что клиентов «кажется, стало меньше». Меня позвали около 11:50. Остановил приложение, оставил базу, выполнил проверку из предыдущего раздела.
Картина получилась однозначная. Образ 18, PGDATA в значении по умолчанию, Mounts показывал именованный том crm-new_pgdata размером в несколько десятков мегабайт. В списке томов рядом лежал crm_pgdata на 6,4 ГБ, созданный полтора года назад, и ни один контейнер на него не ссылался. Причиной было имя проекта: Compose взял его из имени нового каталога. Ошибка в монтировании к делу не относилась. Но в корне старого тома лежали файлы кластера 16, а образ 18 ждёт подкаталог 18/docker, так что даже при правильном имени тома он бы отказался стартовать и честно перечислил найденное. Тут сработали сразу две ловушки, и каждая в одиночку отнимала бы час.
Результат: от моего звонка до работающей CRM со старой базой прошло около двух часов. 25 минут ушло на диагностику, 11 на дамп из спасательного контейнера, 14 на восстановление в чистую базу 18, остальное — проверка и запуск. 17 карточек из «новой» пустой базы мы выгрузили отдельно и добавили руками, менеджеры повторили их за пять минут. Потерянных записей нет. Админа никто не ругал: переезд и обновление в один день — решение типичное, виноват был процесс, где не было чек-листа «сравнить тома до и после».
Как вытащить данные из старого тома и вернуть их в postgres:18
Принцип такой: не пытаться подключить старый том к образу 18 и не заставлять 18 прочитать файлы 16. Файлы данных разных основных версий между собой не читаются, и спорить с этим бесполезно. Поднимаем отдельный контейнер на той же основной версии, на которой том был создан, монтируем в него старый том по старой раскладке, берём логический дамп и переносим его в чистую 18. Для 16 (и вообще до 17) раскладка такая:
docker run -d --name pg-rescue -e POSTGRES_PASSWORD=temp-pass -v crm_pgdata:/var/lib/postgresql/data postgres:16-alpine
docker logs pg-rescueПорты наружу не публикуем. Переменная пароля здесь формальная: по документации образа переменные POSTGRES_* действуют только на пустом каталоге данных, на готовую базу пароль не влияет. В логе ждём сообщение о том, что каталог уже содержит базу и инициализация пропущена. Если сообщения нет, значит, том не тот, останавливаем контейнер и ищем дальше.
Если вы боитесь, что старая база после аварийного останова потребует восстановления журнала и запишет что-то в том, сначала снимите копию тома (снимок хранилища или копирование из остановленного контейнера на соседний носитель). Для дампа:
docker exec pg-rescue pg_dump -U postgres -Fc crm > crm.dump
docker exec pg-rescue pg_dumpall -U postgres -g > globals.sqlПервая команда пишет дамп одной базы в пользовательском формате, он сжат и восстанавливается через pg_restore, а не psql. Вторая забирает глобальные объекты, роли и табличные пространства, которые pg_dump не сохраняет. Имя пользователя и базы подставьте свои: я использую crm и postgres только как пример.
Теперь восстанавливаем. Приложение остаётся остановленным. В новой базе уже есть таблицы, созданные миграциями, поэтому лишнее удаляем и создаём базу заново, затем заливаем дамп:
docker compose exec -T db psql -U postgres -d postgres -c 'DROP DATABASE crm' -c 'CREATE DATABASE crm'
docker compose exec -T db pg_restore -U postgres -d crm < crm.dumpКлюч -T отключает выделение псевдотерминала (по умолчанию compose exec его выделяет), а при перенаправлении файла терминал только мешает. Роли из globals.sql заливаем через psql -f до восстановления базы, если приложение работает под отдельной ролью. После этого запускаем приложение и сравниваем контрольные цифры: число объектов, сделок, самая свежая дата. Это занимает пять минут и закрывает вопрос «а точно всё».
Если вы хотите не восстанавливать дамп, а перевести тот же том на новую раскладку, это отдельная операция обновления версий: надо привести каталоги в порядок и пройти pg_upgrade, в том числе с учётом контрольных сумм данных в 18, о чём я писал в разборе контрольных сумм при pg_upgrade. Для небольшой CRM дамп и восстановление проще, и откатываться есть куда: старый том остаётся нетронутым. Его я удаляю только через неделю-две работы, не раньше.
Что я делаю по умолчанию, чтобы пустая база больше не пугала
Первое: на каждую базу в контейнере один том, имя которого задано явно атрибутом name, чтобы переезд каталога не создавал новый. Это единственная настройка, которая в кейсе выше сэкономила бы два часа. Второе: тег образа фиксируется с точной минорной версией, а не «18» и не latest, и обновление образа идёт отдельным окном, не вместе с переездом стека. Совмещать переезд и смену мажорной версии — всё равно что менять колёса и двигатель на ходу, да ещё и с фразой «сейчас быстренько». Остальные практики для production-стеков я собрал в заметках про Docker Compose в production.
Третье: в чек-лист обновления добавляю три команды из этой статьи: docker volume ls до и после, docker inspect по Mounts и размер базы через \l+. Если размер упал на два порядка, мы узнаём об этом за минуту, а не в понедельник от менеджеров. Четвёртое: логи первого запуска нового контейнера читает человек, а не только мониторинг. Строка о том, что инициализация пропущена, или, наоборот, о создании новой базы, — это ответ на главный вопрос ещё до того, как кто-то откроет приложение.
Что можно не делать. Не надо вручную задавать PGDATA, чтобы «оставить всё как было»: это работает, но отключает часть защитных проверок образа и закрывает дорогу к быстрому pg_upgrade. Не надо переписывать работающий стек на 17 только ради новой раскладки: окно для этого выбирают спокойно, поддержка 17 есть. И бэкапы: бэкап делают все, проверяет его почти никто. Раз в месяц восстановите дамп в отдельный контейнер и посмотрите цифры, как мы описывали в материале о проверке резервных копий, и тогда любой «пустой понедельник» превращается в рядовую задачу.
Частые вопросы
Почему после перехода на postgres:18 приложение видит пустую базу, если старый том на месте?
Потому что контейнер стартовал на другом каталоге или другом томе: PGDATA образа 18 — /var/lib/postgresql/18/docker, и если там нет файла PG_VERSION, образ создаёт новый кластер. Старый том при этом цел, он просто не подключён к контейнеру.
Как быстро проверить, где лежит каталог данных в контейнере?
Выполните docker compose exec db sh -c 'echo $PGDATA; cat $PGDATA/PG_VERSION' и сравните путь с точками назначения из docker inspect --format '{{json .Mounts}}'. PGDATA должен лежать внутри тома, про который вы знаете, где он и сколько весит.
Могла ли причиной стать смена имени каталога со стеком?
Да. По документации Docker Compose имя проекта по умолчанию берётся из имени каталога с compose-файлом, а именованные тома получают имя проект_ключ, если не задан атрибут name. Другой каталог или флаг -p дают новый пустой том.
Можно ли просто подключить старый том 16 или 17 к контейнеру postgres:18?
Нет. Файлы кластера привязаны к основной версии, 18 их не прочитает. Поднимите контейнер на той же основной версии, снимите дамп и восстановите его в 18, либо планово пройдите pg_upgrade.
Что делать с записями, которые приложение успело создать в пустой базе?
Остановите приложение сразу, выгрузите новые записи отдельным дампом или выпишите их, затем восстановите старую базу и добавьте новые руками или скриптом. Не заливайте дамп поверх базы с уже накатанными миграциями — пересоздайте базу.
Источники
- Docker Hub: официальный образ postgres — Проверено: PGDATA в 18+ — /var/lib/postgresql/18/docker, VOLUME — /var/lib/postgresql; для 17 и ниже монтировать в /var/lib/postgresql/data; POSTGRES_* и docker-entrypoint-initdb.d действуют только на пустом каталоге данных. https://hub.docker.com/_/postgres
- docker-library/postgres: pull request 1259 — Проверено: смена PGDATA на /var/lib/postgresql/MAJOR/docker для 18+ (влито 6 июня 2025), цель — pg_upgrade --link в одном томе; проверка старых данных в entrypoint. https://github.com/docker-library/postgres/pull/1259
- docker-library/postgres: docker-entrypoint.sh — Проверено: поиск PG_VERSION в /var/lib/postgresql, /var/lib/postgresql/data и /var/lib/postgresql/*/docker, ошибка при найденных старых данных или неиспользуемом монтировании, сообщение о пропуске инициализации. https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh
- Docker Docs: имя проекта Compose, compose down, тома — Проверено: приоритет определения имени проекта, префикс проект_ключ для именованных томов, поведение анонимных томов при down, -v, compose config --volumes, compose exec -T. https://docs.docker.com/compose/how-tos/project-name/ и https://docs.docker.com/reference/cli/docker/compose/down/
- Docker Docs: docker volume ls и docker volume inspect — Проверено: фильтр dangling=true, поля CreatedAt, Mountpoint, Name и шаблон --format. https://docs.docker.com/reference/cli/docker/volume/ls/ и https://docs.docker.com/reference/cli/docker/volume/inspect/
- PostgreSQL 18 Documentation: pg_dump, pg_dumpall, psql, pg_upgrade — Проверено: pg_dump -Fc и восстановление через pg_restore, pg_dumpall -g для глобальных объектов, мета-команды \l+ и \dt, режим --link и его ограничения. https://www.postgresql.org/docs/18/app-pgdump.html и https://www.postgresql.org/docs/18/app-pg-dumpall.html



