MAATRIX / Блог / Данные исчезли после docker compose down: виноват анонимный volume

Данные исчезли после docker compose down: виноват анонимный volume

MAATRIX

Обычный редеплой — накатили новый тег образа, выполнили привычную пару команд 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 физически существовал, план восстановления обошёлся без разворачивания бэкапа (хотя он и был под рукой как план Б):

  1. Остановили новый (пустой) контейнер db, чтобы не путать порты и не потерять свежие изменения (их и не было, но по правилам — сначала стоп).
  2. Нашли точное имя старого volume через docker volume ls и сверили дату создания с docker volume inspect <id> — поле CreatedAt совпало с моментом первого деплоя этого стека.
  3. Переписали 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 контейнер поднялся уже со старыми данными — приложение снова увидело все таблицы и записи.

  1. Как только всё заработало, старый volume пересоздали как обычный именованный (без external), перелив данные через промежуточный контейнер и pg_dump/pg_restore, чтобы не зависеть от угадывания хеш-имён при следующих правках compose-файла:
docker run --rm \
  -v 8e02f6a1c4b9...:/from \
  -v pgdata:/to \
  alpine sh -c "cp -a /from/. /to/"
  1. Дальше — обновили 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →