MAATRIX / Блог / Антипаттерн: docker compose down при каждом обновлении

Антипаттерн: docker compose down при каждом обновлении

MAATRIX

Деплой-скрипт, в котором каждое обновление начинается с docker compose down, а заканчивается docker compose up -d, — одна из самых распространённых привычек, которая переезжает из проекта в проект просто потому, что «так все делают» и «так точно сработает». На деле это означает, что ради обновления одного сервиса вы на несколько секунд (а иногда и дольше) кладёте весь стек целиком — включая базу данных, очередь и сервисы, к которым в этом релизе никто не прикасался. Разберём, почему down перед каждым up — лишний и местами опасный шаг, и что вместо этого умеет сам Docker Compose.

Как это выглядит в реальном деплой-скрипте

Паттерн почти всегда рождается из желания «начать с чистого листа» и не думать о частных случаях. Типичный deploy.sh, который можно найти в половине проектов на VPS:

#!/usr/bin/env bash
set -e

cd /opt/myapp
git pull origin main
docker compose down
docker compose pull
docker compose up -d

Выглядит логично: остановили всё, подтянули изменения, подняли заново — никаких «залипших» контейнеров со старым кодом, никаких сюрпризов с промежуточным состоянием. Проблема в том, что этот скрипт делает одно и то же независимо от того, поменялась ли одна строка в конфиге фронтенда или полностью переписан бэкенд. Если в docker-compose.yml пять сервисов — nginx, api, worker, postgres, redis — и обновился только образ api, скрипт всё равно останавливает и заново поднимает все пять.

Часто к этому добавляется ещё один слой: такой деплой запускают из CI по каждому пушу в main, то есть несколько раз в день, и каждый раз — полная остановка стека. На проекте с постоянным трафиком это уже не «пять секунд простоя раз в месяц при релизе», а регулярные проседания на графике доступности, которые никто не связывает с причиной, потому что «мы же просто передеплоили один сервис».

Простой сервисов, которые вообще не менялись

Самая очевидная и самая недооценённая проблема — docker compose down не смотрит, что именно изменилось. Команда останавливает и удаляет все контейнеры, описанные в файле, а заодно и сеть, которую Compose создал для стека. Дальше up -d поднимает всё заново, в порядке, заданном depends_on.

Для стейтфул-сервисов вроде postgres или redis это означает разрыв всех активных подключений от api и worker — даже если сами эти сервисы не обновлялись и продолжали бы работать без остановки. Клиенты приложения в этот момент получают обрывы соединений и таймауты, воркер теряет соединение с очередью и должен переподключаться и, возможно, повторно забирать задачи из middle-состояния.

Отдельно бьёт по времени восстановления depends_on без condition: service_healthy: если зависимость не настроена явно ждать готовности, api может подняться и начать принимать трафик раньше, чем postgres реально готов принимать соединения после холодного старта — и первые запросы после деплоя будут падать с ошибками подключения к базе. У нас есть отдельный разбор, как настроить healthcheck так, чтобы зависимые сервисы стартовали в правильном порядке, а не «примерно одновременно».

На проекте с одним сервисом разница между down+up и точечным обновлением почти не заметна. На проекте с пятью-десятью сервисами, где обновляется в среднем один-два за раз, полная остановка — это простой девяти сервисов ради обновления одного, каждый раз.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Потеря состояния: анонимные volumes и флаг -v

Вторая проблема серьёзнее простоя — потенциальная потеря данных. docker compose down без дополнительных флагов не удаляет именованные volumes, объявленные в секции volumes: верхнего уровня файла — они переживают остановку и переиспользуются при следующем up. Но у команды есть флаг --volumes (он же -v), который многие копируют в скрипт «для надёжности» или «чтобы точно всё пересоздалось при обновлении версии postgres», и который удаляет именно эти именованные volumes вместе с данными:

# опасно в продакшен-скрипте: удаляет именованные volumes,
# включая том с данными postgres
docker compose down --volumes

Отдельно — и это ловушка, о которую спотыкаются даже не используя -v явно, — анонимные volumes, то есть тома без явного имени, которые контейнер получает через VOLUME в Dockerfile или через маппинг вида - /var/lib/postgresql/data без указания имени тома слева. Такой volume физически привязан к конкретному контейнеру, а не к сервису в целом. Когда down удаляет контейнер, а следующий up создаёт контейнер заново, новый контейнер получает новый анонимный volume — пустой. Старый том при этом обычно не пропадает мгновенно, а становится висячим (dangling): он больше ни на что не смонтирован, занимает место на диске и рано или поздно исчезает при ручной или автоматической чистке (docker volume prune). С точки зрения приложения результат один: после обновления база данных или файлы приложения внезапно пустые, хотя команда для остановки и запуска ничем не отличалась от предыдущих ста раз.

Мы разбирали ровно такой инцидент отдельно, по шагам — что происходит с данными после обычного docker compose down на анонимном volume и как это выглядит в реальных логах. Если в вашем docker-compose.yml есть маппинг тома без явного имени слева от двоеточия — это стоит проверить до следующего релиза, а не после.

Более долгий и более рискованный деплой

Полный цикл down + up объективно дольше точечного обновления — и не только из-за времени на остановку лишних контейнеров:

  • Compose пересоздаёт сеть стека с нуля, даже если её конфигурация не менялась — это лишняя, хоть и небольшая, операция на каждый деплой.
  • Все сервисы проходят полный старт заново: приложения на JVM или с тяжёлой инициализацией (прогрев кэшей, миграции схемы, подключение пулов соединений) теряют время на холодный старт там, где точечное обновление вообще не потребовало бы рестарта.
  • Окно, в течение которого стек полностью недоступен, растягивается на время остановки всех контейнеров плюс время запуска самого медленного из них — а не на время запуска одного обновлённого сервиса.
  • Если в процессе up что-то пойдёт не так — например, новый образ не стартует из-за ошибки конфигурации — вы остаётесь с полностью лежащим стеком, а не с одним упавшим сервисом на фоне остальных работающих. Откатываться приходится под давлением, когда всё офлайн, а не спокойно поднимая версию назад для одного контейнера.

Это прямо противоречит идее деплоя без простоя — если для вас это актуально, у нас есть отдельный разбор, как устроен безопасный деплой без простоя на практике, включая варианты со сменой контейнеров без разрыва трафика.

Что на самом деле делает docker compose up -d

Ключевая вещь, которую упускают, копируя down в каждый скрипт: docker compose up -d и без предварительного down сама разбирается, что изменилось. При запуске Compose сравнивает конфигурацию каждого сервиса (образ, переменные окружения, команду, монтирования, лейблы и так далее) с тем, что реально запущено сейчас:

  • если конфигурация сервиса не изменилась и контейнер уже работает — Compose оставляет его как есть, ничего не трогая;
  • если что-то изменилось — например, обновился тег образа после docker compose pull — Compose останавливает старый контейнер этого сервиса, удаляет его и поднимает новый с актуальной конфигурацией;
  • сервисы, которые не изменились и не зависят напрямую от изменившегося (через depends_on с условиями, требующими рестарта), остаются нетронутыми и продолжают работать без разрыва соединений.

То есть вот этот скрипт делает практически то же самое, что скрипт с down в начале, — но только для сервисов, которые реально поменялись, и без остановки остальных:

#!/usr/bin/env bash
set -e

cd /opt/myapp
git pull origin main
docker compose pull
docker compose up -d

Проверить, что Compose считает нужным пересоздать, а что оставит без изменений, можно заранее, не трогая рабочий стек:

# показывает план: что будет создано, пересоздано, оставлено как есть
docker compose up -d --dry-run

Итоговое поведение зависит от версии Compose — в части релизов вывод --dry-run менее подробный, чем в других, — но сам факт, что план обновления можно посмотреть до применения, стоит взять в привычку перед деплоем в продакшен, а не полагаться на память о том, что вы правили в конфиге.

Точечное обновление конкретного сервиса и --no-deps

Если нужно обновить именно один сервис — например, выкатить новую версию api, не трогая postgres, redis и worker, — Compose позволяет указать его явно:

docker compose pull api
docker compose up -d api

По умолчанию up -d api всё равно проверит и при необходимости поднимет сервисы, от которых api зависит через depends_on, если они почему-то не запущены. Обычно это то, что нужно, но не всегда — например, когда зависимость должна быть поднята и настроена отдельным, более осторожным процессом, а деплой-скрипт не должен иметь возможности её случайно тронуть. Для этого есть --no-deps:

# поднимет/пересоздаст только api, не трогая зависимые сервисы,
# даже если их конфигурация формально тоже изменилась
docker compose up -d --no-deps api

Разница между up -d service и restart service тоже стоит того, чтобы её проговорить явно, потому что их путают в скриптах чаще всего:

КомандаПрименяет новый образ/конфигПересоздаёт контейнерОстанавливает другие сервисы
docker compose restart apiНет — просто перезапускает существующий контейнерНетНет
docker compose up -d apiДа, если конфигурация измениласьДа, только если измениласьНет
docker compose up -d --no-deps apiДа, если конфигурация измениласьДа, только если измениласьНет, и зависимости не проверяются
docker compose down && docker compose up -dДа, для всех сервисовДа, для всех сервисовДа, все сервисы стека

restart не подхватит новый образ, даже если вы только что сделали docker compose pull — контейнер просто перезапустится со старым образом, который уже был скачан на диск при предыдущем запуске. Это отдельная и тоже частая причина недоумения «я же обновил образ, а деплой как будто не применился» — команда была правильная по духу, но не та.

Как собрать деплой-скрипт, который не убивает лишнее

Рабочий вариант для типового VPS-стека выглядит примерно так — без ручных пауз, но и без полной остановки без необходимости:

#!/usr/bin/env bash
set -e

cd /opt/myapp
git pull origin main

# скачиваем актуальные образы заранее,
# чтобы up -d не тратил время деплоя на pull
docker compose pull

# Compose сам решит, какие сервисы пересоздать,
# а какие оставить работающими без разрыва
docker compose up -d --remove-orphans

# если хочется явно ограничиться конкретными сервисами —
# например, деплой только бэкенда из отдельного пайплайна:
# docker compose up -d --no-deps api worker

Флаг --remove-orphans здесь не про целевое обновление, а про гигиену: он убирает контейнеры, которые остались в стеке от сервисов, давно удалённых из docker-compose.yml, — без него такие контейнеры продолжают висеть и путать картину при разборе инцидентов. down в этом скрипте оставлен только для одного явного случая — полной остановки стека перед действительно разрушительными изменениями: сменой сети, миграцией на другой хост, откатом на другую мажорную версию compose-конфигурации. В обычном релизном цикле команда там не нужна.

Отдельная рекомендация — не запускать скрипт на слепую веру, что Compose «наверное разберётся»: перед первым использованием на боевом стеке стоит один раз прогнать --dry-run или протестировать сценарий на staging-копии тех же сервисов, чтобы увидеть реальное поведение конкретной версии Compose с конкретным вашим docker-compose.yml. Общий разбор частых ошибок компоуз-конфигурации на проде — в отдельном материале про типичные ошибки docker compose в продакшене, если хочется свериться по остальным местам, где обычно спотыкаются.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Разве down не нужен хотя бы иногда, для чистоты?

Иногда нужен — при смене сети стека, при удалении сервисов из compose-файла без --remove-orphans, при полной пересборке окружения с нуля. Но это не рутинная операция на каждый релиз, а осознанный шаг для конкретной задачи.

Если добавить в скрипт --no-deps ко всем сервисам, это безопасно всегда?

Нет, если зависимость реально должна быть поднята для работы сервиса — например, при первом деплое на пустой сервер, когда база ещё не запущена. --no-deps уместен в устоявшемся пайплайне, где вы точно знаете порядок запуска сервисов и управляете зависимостями отдельно.

Как понять, что Compose пересоздаст сервис, а что оставит нетронутым, не рискуя продом?

Прогнать docker compose up -d --dry-run перед реальным применением — команда покажет план без внесения изменений. Полезно взять в привычку перед релизом, особенно после правки самого docker-compose.yml, а не только образов.

Мы используем down --volumes специально, чтобы получить чистую базу для staging при каждом деплое. Это нормально?

Для staging/тестового окружения, где данные заведомо одноразовые, это осознанное и безопасное решение. Проблема возникает, когда та же команда копируется в продакшен-скрипт по инерции, без пересмотра под другие последствия.

Что делать, если данные уже пропали после down + up на проде?

Первым делом проверить, остались ли на диске висячие (dangling) volumes командой docker volume ls -f dangling=true — старые данные иногда ещё физически на месте и их можно смонтировать вручную и перенести. Дальше — разобраться в причине по шагам, как это устроено, в разборе инцидента с анонимным volume.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

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