Как я реализовал Blue-Green деплой с нулевым даунтаймом на Docker Compose

от автора

Недавно я внедрил blue-green деплой в проде. Реализация довольно простая и кастомная, но справляется со своей задачей на ура! Также сообщу, что используется обычный докер композ на виртуалке — возможно, кому-то такой подход будет полезен.

Для фоновых процессов (воркеров)

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

вот пример такого синглтона на ЯП python.

вот пример такого синглтона на ЯП python.
Пример обертки. Если is_accepting=False - отправляем задачи обратно в очередь.

Пример обертки. Если is_accepting=False — отправляем задачи обратно в очередь.

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

Переводим приложение в режим завершения(is_accepting=False), даём воркерам время закончить текущие задачи и затем корректно останавливаем остальные ресурсы. В примере используется фреймворк Faststream на rabbitmq.

Переводим приложение в режим завершения(is_accepting=False), даём воркерам время закончить текущие задачи и затем корректно останавливаем остальные ресурсы. В примере используется фреймворк Faststream на rabbitmq.

Можно сделать через хрупкий счетчик активных задач, но это лишено смысла — мы все равно упираемся в 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/