Данные исчезли после docker compose down: виноват анонимный volume
Обычный редеплой — накатили новый тег образа, выполнили привычную пару команд docker compose down и docker compose up -d — и вместо рабочего приложения увидели чистую базу без единой таблицы с данными. Ни ошибок при остановке, ни предупреждений при запуске: контейнер поднялся штатно, просто заново, как будто это первый запуск. Разбираем по шагам, что на самом деле происходит с volume, к которому ни разу явно не обращались в compose-файле, и почему «пропажа» данных в такой ситуации — не мистика, а вполне объяснимое поведение Docker Compose.
Содержание
Что случилось
Стек — типовой: бэкенд-приложение и postgres в одном docker-compose.yml, поднятые на боевом VPS. Редеплой делали так же, как десятки раз до этого: остановить стек, подтянуть новый образ, поднять заново.
docker compose down
docker compose pull
docker compose up -d
Флаг -v (--volumes) никто не передавал — команда выглядела максимально безобидно. Через несколько секунд после up -d бэкенд начал сыпать в лог ошибками вида relation "users" does not exist, а при заходе в само приложение — форма первичной настройки, как будто это установка с нуля.
Первая реакция — проверить, не откатился ли случайно старый образ приложения без миграций. Но версия в docker compose images была той самой, новой, только что собранной. Проблема была не в приложении — база данных внутри контейнера db оказалась абсолютно пустой, будто её только что создали командой initdb.
Что показывали логи и метрики
Начали с самого контейнера db:
docker compose logs db --since 15m
В логе — ровно то, что видно при первом запуске чистого образа postgres: строки PostgreSQL init process complete; ready for start up, инициализация системного каталога, создание ролей по умолчанию. Никаких сообщений о повреждении данных, panic-логов WAL, ошибок восстановления после сбоя — Postgres не пытался ничего чинить, он честно стартовал с нуля, потому что каталог /var/lib/postgresql/data внутри контейнера был пуст.
Дальше — docker volume ls:
DRIVER VOLUME NAME
local a3f1c9e7b8d2... (создан только что)
local 8e02f6a1c4b9... (создан несколько недель назад)
Два volume с бессмысленными хеш-именами вместо одного ожидаемого. Один — свежий, привязанный к только что поднятому контейнеру db. Второй, более старый — не был примонтирован никуда. docker inspect по новому контейнеру подтвердил: mount есть, но это не тот volume, что использовался раньше.
Проверили место на диске (df -h) — свободного места было в достатке, диск не переполнялся. Проверили dmesg и системные логи на предмет OOM-киллера или падения хоста — ни одного события за последние сутки. Хост не перезагружался (uptime показывал недели без ребута). То есть ни диск, ни память, ни хост ни при чём — потеря произошла ровно в момент пересоздания контейнеров.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотеза первая: испорченный бэкап
Первым делом заподозрили ночной бэкап — может, в базе и так давно было пусто, а его просто никто не замечал до внепланового редеплоя. Подняли последний архив на тестовом сервере:
docker run --rm -v pgdata_test:/var/lib/postgresql/data postgres:16 true
pg_restore -d test_db /backups/latest.dump
Бэкап развернулся штатно, все таблицы и строки были на месте, с данными по вчерашний вечер включительно. Значит, ночью данные точно были — гипотеза не подтвердилась, но зато появилась гарантия, что откатиться будет чем.
Гипотеза вторая: кто-то вручную почистил таблицы
Вторая версия — не связан ли инцидент с человеческим фактором: может, кто-то из команды выполнил TRUNCATE или DROP TABLE по ошибке, зайдя не в тот терминал. Подняли историю подключений к базе через pg_stat_activity за последние минуты работы старого контейнера (благо он ещё не был удалён физически на диске, только не примонтирован) — путём временного монтирования старого volume в отдельный контейнер:
docker run --rm -it \
-v 8e02f6a1c4b9...:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=tmp \
--name pg-recover \
postgres:16
Подключились к поднявшейся базе — и обнаружили, что данные там целы. Таблицы, строки, индексы — всё на месте, ровно в том состоянии, в котором было перед остановкой стека. Это сразу сняло гипотезу про ручное удаление: данные никто не стирал, они просто оказались не там, где их искало приложение.
Реальная причина: анонимный volume, который никто не называл по имени
Разгадка нашлась в самом docker-compose.yml. Секция db выглядела так:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
Никакого блока volumes: для сервиса db не было вообще. Каталог /var/lib/postgresql/data внутри образа postgres объявлен как VOLUME ещё в самом Dockerfile образа — это встроенное поведение официального образа, а не что-то, что нужно включать отдельно. Из-за этого Docker при каждом создании контейнера db честно создаёт для этого пути volume — но, поскольку в compose-файле у него нет имени, Docker создаёт его как анонимный, с автогенерированным идентификатором вместо человекочитаемого имени.
Пока контейнер живёт — с анонимным volume всё в порядке, он примонтирован и работает неотличимо от именованного. Проблема начинается в момент, когда контейнер удаляется и создаётся заново — а именно это и делает связка docker compose down + docker compose up. down останавливает и удаляет контейнеры и сети проекта. Анонимный volume, который не был перечислен по имени в top-level секции volumes:, при этом никак не запоминается compose-конфигурацией — Compose просто не знает, что этот конкретный volume нужно переиспользовать при следующем запуске, потому что у него нет ссылки на него, только у самого (уже удалённого) контейнера была.
Когда следом отработал up -d, Compose создал новый контейнер db — и для его VOLUME /var/lib/postgresql/data создал новый анонимный volume, с нуля. Старый анонимный volume при этом физически не был стёрт (флаг -v не передавался, поэтому Docker его не удалил), он остался лежать на диске как «висячий» (dangling) — не привязанным ни к одному контейнеру. Именно поэтому данные не исчезли навсегда, а простой docker run с явным указанием старого имени volume их благополучно нашёл.
Итог: приложение не потеряло данные физически, но потеряло к ним доступ — потому что для Docker Compose «volume для этого сервиса» без явного имени — это не постоянная сущность, привязанная к сервису, а побочный эффект конкретного контейнера, который живёт и умирает вместе с ним.
Разница между docker compose down и docker compose down -v в этом контексте: без -v — данные физически остаются на диске в виде висячего volume, доступного для ручного восстановления; с -v — Compose явно удаляет и связанные с проектом volume, включая анонимные, и тогда без бэкапа это было бы уже безвозвратно. В нашем случае от полной потери спасло именно то, что -v не использовался — но рассчитывать на этот «зазор» как на страховку не стоит: это везение, а не архитектурное решение. Подробнее о том, чем анонимные, именованные и bind-mount тома отличаются друг от друга и когда какой уместен, — в статье про типы volume в Docker и когда какой использовать.
Как восстановили доступ к данным
Поскольку старый volume физически существовал, план восстановления обошёлся без разворачивания бэкапа (хотя он и был под рукой как план Б):
- Остановили новый (пустой) контейнер
db, чтобы не путать порты и не потерять свежие изменения (их и не было, но по правилам — сначала стоп). - Нашли точное имя старого volume через
docker volume lsи сверили дату создания сdocker volume inspect <id>— полеCreatedAtсовпало с моментом первого деплоя этого стека. - Переписали
docker-compose.yml, добавив явный именованный volume:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
external: true
name: 8e02f6a1c4b9...
Флаг external: true с указанием точного имени старого анонимного volume позволил подключить его к сервису явно, без создания нового. После docker compose up -d контейнер поднялся уже со старыми данными — приложение снова увидело все таблицы и записи.
- Как только всё заработало, старый volume пересоздали как обычный именованный (без
external), перелив данные через промежуточный контейнер иpg_dump/pg_restore, чтобы не зависеть от угадывания хеш-имён при следующих правках compose-файла:
docker run --rm \
-v 8e02f6a1c4b9...:/from \
-v pgdata:/to \
alpine sh -c "cp -a /from/. /to/"
- Дальше — обновили
docker-compose.ymlдо финального вида с обычным именемpgdataбезexternal, применили, проверили, что данные на месте, и только после этого удалили старый анонимный volume вручную.
Если бы этого «запасного» volume не было и требовалось поднимать всё из архива, порядок действий описан в статье про бэкап и восстановление Docker volume и частые ошибки при этом — там разобраны похожие ситуации, когда volume для восстановления выбирают не глядя на имя и накатывают дамп не туда.
Что изменили после инцидента
Первое и самое очевидное — во всех compose-файлах на сервере явно объявили volume для каждого сервиса, у которого есть постоянные данные: базы, очереди, файловые хранилища. Правило простое: если у образа в Dockerfile есть VOLUME, в вашем compose-файле для этого пути обязательно должен быть либо именованный volume, либо bind-mount — анонимных volume в проде быть не должно вообще.
Второе — пересмотрели сам процесс редеплоя. Связка down + up пересоздаёт вообще всё: контейнеры, сети, при необходимости и volume (если поймать -v), даже если реально поменялся только образ одного сервиса. Для обновления образов без пересоздания сети и лишних сущностей команда стала другой:
docker compose pull
docker compose up -d
Без предварительного down — Compose сам решает, какие контейнеры нужно пересоздать (если изменился образ или конфигурация сервиса), а какие оставить как есть. Именованные volume при этом переживают пересоздание контейнера штатно — Compose явно знает, что pgdata нужно подключить к новому контейнеру db, потому что имя есть в конфиге, а не только в метаданных старого контейнера. down теперь используют осознанно — только когда действительно нужно полностью снести стек, а не как привычный первый шаг любого обновления.
Третье — добавили простую проверку перед деплоем в CI: скрипт парсит docker-compose.yml проекта и для каждого сервиса, у образа которого объявлен VOLUME (это можно посмотреть через docker image inspect <image> --format '{{.Config.Volumes}}'), проверяет, что в самом compose-файле для этого сервиса есть соответствующий volumes:. Если нет — сборка падает с понятной ошибкой, а не тихо создаёт анонимный том где-то в проде.
Четвёртое — регулярный бэкап перестал быть «на всякий случай» и стал частью пайплайна: автоматический дамп базы по расписанию с ротацией и проверкой, что архив реально разворачивается (а не просто создаётся). Как это настроить с нуля на VPS, разобрано в статье как установить и настроить бэкап Docker volume на VPS. В этом инциденте бэкап не понадобился как основной путь восстановления, но именно его наличие сняло панику в первые минуты — было понятно, что худший сценарий закрыт заранее.
Отдельно пересмотрели и общий подход к продовым compose-файлам — не только к volume, но и к рестарт-политикам, лимитам ресурсов и порядку запуска сервисов. Частые грабли этого уровня собраны в статье про docker compose для продакшена и типичные ошибки в конфигурации — во многом пересекается с тем, что вскрылось в этом разборе.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Удаляет ли docker compose down volume по умолчанию?
Нет. Без флага -v/--volumes команда останавливает и удаляет только контейнеры и сети проекта. Volume, в том числе анонимные, физически остаются на диске — но перестают быть к чему-либо примонтированы, если ссылки на них не было в конфиге.
Чем анонимный volume отличается от именованного, если в обоих случаях данные хранятся на хосте?
Технически — почти ничем на уровне файловой системы. Разница в том, что Compose умеет сопоставлять именованный volume с сервисом между перезапусками (по имени в конфиге), а анонимный существует только в связке с конкретным контейнером и теряется для Compose, как только этот контейнер удалён.
Как понять, что volume анонимный, если он уже есть в системе?
Через docker volume ls — у анонимных volume в колонке имени длинный хеш вместо осмысленного имени вроде myproject_pgdata. Дополнительно можно посмотреть docker inspect <container> в секции Mounts и сверить, ссылается ли имя volume на что-то из вашего compose-файла.
Можно ли одной командой найти «потерянные» после пересоздания анонимные volume?
Да, через фильтр висячих томов: docker volume ls -f dangling=true. Он покажет volume, которые существуют, но не примонтированы ни к одному контейнеру — именно там может обнаружиться volume с данными после подобного инцидента.
Достаточно ли просто добавить volumes: в compose-файл, чтобы больше так не терять данные?
Это необходимый, но не единственный шаг. Дополнительно стоит держать регулярный внешний бэкап — именованный volume защищает от потери при пересоздании контейнера, но не от ошибки администратора, повреждения диска или случайного docker compose down -v.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →