Недавно я внедрил blue-green деплой в проде. Реализация довольно простая и кастомная, но справляется со своей задачей на ура! Также сообщу, что используется обычный докер композ на виртуалке - возможно, кому-то такой подход будет полезен.
Для фоновых процессов (воркеров)
В приложение добавляется специальный инфрастуктурный singleton класс с флагом is_accepting, и обертка на consumers. В каждом консьюмере перед обработкой проверяем этот флаг: если True - обрабатываем задачу, если False - переносим задачу на повторную обработку (например, в rabbitmq делаем сразу nack(requeue=true))


Когда сервис получает sigterm сигнал, этот singleton переключает is_accepting в False. После переключения добавляем ожидаение на время максимального выполнения задачи(+5-10 секунд), и в контейнере обязательно указываем graceful timeout на 5-10 секунд больше этого значения.

Можно сделать через хрупкий счетчик активных задач, но это лишено смысла - мы все равно упираемся в graceful timeout контейнера. Простое ожидание надежнее в этом случае.
По сути получается так: активный процесс после сигнала перестает обрабатывать новые задачи, дорабатывает текущие и корректно завершается.
Также уточню, что пример был приведен для однопоточного асинхронного воркера. Реализация может зависеть от архитектуры приложения и способа организации обработки задач. Например, если под капотом фреймворка используется Process Pool, важно учитывать, что механизм переключения состояния (через is_accepting) должен быть инициализирован в каждом дочернем процессе! Иначе консумеры в дочерних процессах будут игнорировать сигнал завершения и продолжать обработку новых задач. Возможно, что стоит подумать о распределенном ключе, который определяет активный деплой, но в нашем случае это усложнение.
В деплой скрипте логика такая: определяем активный инстанс (через регулярку/сопоставление/etc), сразу поднимаем inactive инстанс. В итоге на короткое время у нас работают оба инстанса и вместе обрабатывают задачи. Дальше отправляем sigterm активному инстансу - он переключается в режим неактивного (is_accepting=False), перестает обрабатывать новые задачи и спокойно дожидается завершения текущих.
Вот пример скрипта деплоя:
set -e DEPLOY_PATH="$1" COMPOSE="docker compose -f ${DEPLOY_PATH}/docker-compose.prod.yml" if docker ps --format '{{.Names}}' | grep -q "^notifier-blue$"; then ACTIVE="blue" INACTIVE="green" else ACTIVE="green" INACTIVE="blue" fi echo "[notifier] active=${ACTIVE}, deploying to=${INACTIVE}" $COMPOSE up -d notifier-${INACTIVE} $COMPOSE stop notifier-${ACTIVE} echo "[notifier] done"
P.S. Для идемпотентныхконсьюмеров все еще проще — можно почти ничего не делать =) достаточно рейзить специальную ошибку с requeue=True, и условный RabbitMQ сам отправит сообщение обратно в очередь. Но проблема возникает с заполнением очереди, т.к во время деплоя мы не будем обрабытвать сообщения. С неидемпотентными сообщениями такой подход уже проблемный — при повторной обработке мы получим неконсистентное состояние(например, упадем при проверке id сообщения). А неидемпотентных сообщений, как правило, большинство.
Для веб сервисов
В конфиге у нас всегда есть 2 инстанса приложения — blue и green. Также нужен реверс‑прокси, например nginx.
Логика переключения реализована в деплой скрипте. Сначала определяем, какой инстанс сейчас активный. Это можно сделать через if логику: если активен blue - Active = blue, Inactive = green, в любом другом случае - наоборот Active=green, Inactive = blue. Определить это можно по регулярке/активному порту/etc. После этого запускаем inactive инстанс и проверяем его через healthcheck.
Дальше переключаем nginx на новый инстанс, что-то типа: echo “server ${HOST}${INACTIVE_PORT};” > “$UPSTREAM_CONF” и делаем мягкий бесшовный reload(nginx -s reload). После этого устанавливаем время ожидания, равное максимальному времени выполнения http запроса в вашем сервисе(с запасом). Затем старому инстансу посылаем sigterm сигнал.
Вот пример скрипта деплоя:
set -e IMAGE="$1" DEPLOY_PATH="$2" BLUE_PORT=${BLUE_PORT:-8002} GREEN_PORT=${GREEN_PORT:-8003} UPSTREAM_CONF="/etc/nginx/snippets/web-upstream.conf" COMPOSE="docker compose -f ${DEPLOY_PATH}/docker-compose.prod.yml" if grep -q "${BLUE_PORT}" "$UPSTREAM_CONF"; then ACTIVE="blue" INACTIVE="green" INACTIVE_PORT=$GREEN_PORT else ACTIVE="green" INACTIVE="blue" INACTIVE_PORT=$BLUE_PORT fi echo "[blue-green] active=${ACTIVE}, deploying to=${INACTIVE} (port ${INACTIVE_PORT})" $COMPOSE up -d web-${INACTIVE} echo "[blue-green] waiting for health..." for i in $(seq 1 30); do if curl -sf "http://127.0.0.1:${INACTIVE_PORT}/v1/health" > /dev/null 2>&1; then echo "[blue-green] healthy after ${i} attempts" break fi if [ "$i" -eq 30 ]; then echo "[blue-green] health check failed, rolling back" $COMPOSE stop web-${INACTIVE} exit 1 fi sleep 2 done echo "server 127.0.0.1:${INACTIVE_PORT};" > "$UPSTREAM_CONF" nginx -t && nginx -s reload echo "[blue-green] nginx switched to ${INACTIVE}" sleep 5 $COMPOSE stop web-${ACTIVE} echo "[blue-green] stopped web-${ACTIVE}, deploy complete"
Немного о миграциях
В blue green деплое важно, чтобы новая и старая версии приложения могли одновременно работать с одной схемой бд. Поэтому используем backward compatible подход: Например, нам нужно удалить атрибут или целую таблицу - сначала убираем её использование в коде и делаем деплой. После переключения трафика и завершения работы старых инстансов выполняем второй деплой с миграциями на удаление. Да, больше работы - но это того стоит.
Пример конфигурации Docker Compose
Вот фрагмент докер композ файла:
services: ... web-blue: image: ${CI_REGISTRY_IMAGE}/web:${IMAGE_TAG:-latest} container_name: web-blue restart: always env_file: - .env.prod ports: - "127.0.0.1:8002:8000" depends_on: migrations: condition: service_completed_successfully postgres: condition: service_healthy rabbitmq: condition: service_healthy web-green: image: ${CI_REGISTRY_IMAGE}/web:${IMAGE_TAG:-latest} container_name: web-green restart: always env_file: - .env.prod ports: - "127.0.0.1:8003:8000" depends_on: migrations: condition: service_completed_successfully postgres: condition: service_healthy rabbitmq: condition: service_healthy worker-blue: image: ${CI_REGISTRY_IMAGE}/worker:${IMAGE_TAG:-latest} container_name: worker-blue restart: always stop_grace_period: 200s env_file: - .env.prod depends_on: migrations: condition: service_completed_successfully postgres: condition: service_healthy rabbitmq: condition: service_healthy redis: condition: service_healthy worker-green: image: ${CI_REGISTRY_IMAGE}/worker:${IMAGE_TAG:-latest} container_name: worker-green restart: always stop_grace_period: 200s env_file: - .env.prod depends_on: migrations: condition: service_completed_successfully postgres: condition: service_healthy rabbitmq: condition: service_healthy redis: condition: service_healthy notifier-blue: image: ${CI_REGISTRY_IMAGE}/notifier:${IMAGE_TAG:-latest} container_name: notifier-blue restart: always stop_grace_period: 15s env_file: - .env.prod depends_on: migrations: condition: service_completed_successfully postgres: condition: service_healthy rabbitmq: condition: service_healthy redis: condition: service_healthy notifier-green: image: ${CI_REGISTRY_IMAGE}/notifier:${IMAGE_TAG:-latest} container_name: notifier-green restart: always stop_grace_period: 15s env_file: - .env.prod depends_on: migrations: condition: service_completed_successfully postgres: condition: service_healthy rabbitmq: condition: service_healthy redis: condition: service_healthy ...
Заключение
В итоге получился вполне рабочий blue‑green деплой с нулевым даунтаймом, и всё это на обычном докер композ.
Если есть идеи, как сделать проще и надежнее, или замечания по подводным камням, которые я мог не учесть — делитесь, буду рад почитать!
Комментарии (8)

pashuk
20.04.2026 19:16K3s поставьте поэкспериментируйте, там совсем другой уровень.
Там хоть сине зеленое, хоть канареечное развертывание можно сделать просто меняя labels у service.
Расписывать не буду, инфы море. Но для старта ваш проект хороший конечно

propell-ant
20.04.2026 19:16получился вполне рабочий blue‑green деплой с нулевым даунтаймом
Но есть побочка: принудительный NACK посреди обработки может порождать непредвиденые и нетестированные состояния системы.

merra123 Автор
20.04.2026 19:16мы не прерываем уже выполняющиеся задачи, мы просто не принимаем новые, т.е отправляем их обратно в очередь на обработку нашему новому активному фоновому процессу
baldr
Как я понимаю, продакшен у вас - это один сервер? Даже в таком случае можно включить Docker Swarm на сервере и тогда вам будет доступен deploy режим с его фичами по плавному апдейту и роллбеку. Есть и минусы - вы сразу теряете
depends_on.merra123 Автор
Да, на одном сервере. Спасибо за ссылку, посмотрю