Недавно я внедрил 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 -eDEPLOY_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"fiecho "[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 -eIMAGE="$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_PORTelse ACTIVE="green" INACTIVE="blue" INACTIVE_PORT=$BLUE_PORTfiecho "[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 2doneecho "server 127.0.0.1:${INACTIVE_PORT};" > "$UPSTREAM_CONF"nginx -t && nginx -s reloadecho "[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 деплой с нулевым даунтаймом, и всё это на обычном докер композ.
Если есть идеи, как сделать проще и надежнее, или замечания по подводным камням, которые я мог не учесть — делитесь, буду рад почитать!
ссылка на оригинал статьи https://habr.com/ru/articles/1025776/