Недавно я внедрил 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 деплой с нулевым даунтаймом, и всё это на обычном докер композ.
Если есть идеи, как сделать проще и надежнее, или замечания по подводным камням, которые я мог не учесть — делитесь, буду рад почитать!
Автор: merra123
