Docker postgres:18: приложение видит пустую базу
АйТи Фреш
1С и базы данных

Перешли на образ postgres:18, а приложение видит пустую базу: проверяем PGDATA и тома по шагам

Автор: , директор ООО «АйТи-Фреш» · · ~19 мин чтения
Подключённый пустой том базы и отсоединённый полный том рядом: приложение видит пустую базу postgres в Docker
Данные не пропали: они лежат в старом томе, к которому контейнер просто не подключён.

Приложение видит пустую базу не потому, что 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.

Не копируйте файлы из каталога данных запущенной базы и не запускайте на старом томе вторую базу «посмотреть». Перед любыми экспериментами сделайте копию тома или логический дамп из отдельного контейнера, о нём в разделе про спасение данных.
Чек-лист из пяти команд для проверки PGDATA и подключённых томов Docker при пустой базе postgres:18
Пять команд отвечают на главный вопрос: лежит ли PGDATA в томе, который вы считаете своим.

Четыре причины тихой пустоты и как отличить одну от другой

За все разборы у меня сложился короткий список причин. Чтобы не гадать, держите таблицу «симптом — причина — проверка»: | Что видите | Вероятная причина | Чем проверить | |---|---|---| | Образ 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 снесут то, что вам нужно, и не переспросят. Если вы читаете это до чистки, поставьте себе запрет на эти команды на хосте с базами. Если после, ищите снимки гипервизора и бэкап и не лечите нервы повторными запусками.

Если вы дочитали до этого места и поняли, что пытаться разобраться самостоятельно на боевой базе страшновато, это нормальная реакция. Мы в АйТи-Фреш можем подключиться удалённо, снять копию тома и вытащить данные, не трогая рабочий стек, пока вы продолжаете жить своей жизнью. Об условиях ниже, в конце статьи.

Дерево решений: симптомы пустой базы postgres:18 в Docker, причины и действия, включая имя проекта Compose
Три типовых симптома ведут к трём разным причинам, и ни в одном случае данные не нужно удалять.

Кейс: «Ключи столицы», 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 карточек из «новой» пустой базы мы выгрузили отдельно и добавили руками, менеджеры повторили их за пять минут. Потерянных записей нет. Админа никто не ругал: переезд и обновление в один день — решение типичное, виноват был процесс, где не было чек-листа «сравнить тома до и после».

Цифры кейса: восстановление CRM риэлторской компании после перехода на postgres:18 за два часа без потери данных
Дамп из старого тома вернул 6,4 ГБ базы за два часа, потому что том никто не трогал.

Как вытащить данные из старого тома и вернуть их в 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.

Что делать с записями, которые приложение успело создать в пустой базе?

Остановите приложение сразу, выгрузите новые записи отдельным дампом или выпишите их, затем восстановите старую базу и добавьте новые руками или скриптом. Не заливайте дамп поверх базы с уже накатанными миграциями — пересоздайте базу.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи