CI/CD, инфраструктура и облака Continuous Delivery и стратегии релизов: blue-green, canary, feature flags, откаты
0%

Continuous Delivery и стратегии релизов: blue-green, canary, feature flags, откаты

Continuous Delivery и стратегии релизов: blue-green, canary, feature flags, откаты

Непрерывная интеграция (https://courses.digitable.life/post/devops/01-ci-fundamentals/) заканчивается там, где появляется зелёная сборка и артефакт в реестре. Дальше начинается вторая половина задачи — и именно она обычно болит. Артефакт собран, тесты прошли, а в проде он оказывается через две недели, ночью, руками, с чек-листом в Confluence и дежурным на телефоне. Continuous Delivery — это дисциплина, которая делает путь «коммит → продакшн» предсказуемым, повторяемым и настолько скучным, что его не страшно проходить двадцать раз в день.

Delivery, Deployment и Release — три разных слова

Их путают постоянно, а разница операционально важна.

  • Continuous Delivery — свойство системы: любой коммит, прошедший конвейер, технически готов к выкатке в прод. Решение «катить или нет» остаётся бизнесовым, но оно принимается за один клик, а не за спринт подготовки.
  • Continuous Deployment — более жёсткая версия: каждый прошедший конвейер коммит выкатывается автоматически, без человека. Это не «более правильный CD», это другой профиль риска. Платёжный процессинг с регуляторным аудитом вполне законно останавливается на Delivery.
  • Release — момент, когда функциональность становится доступна пользователю. Ключевая идея всей статьи: деплой и релиз можно и нужно разделить. Код физически стоит в проде, но выключен флагом — деплой состоялся, релиза не было.

Это разделение — центральная мысль книги Джеза Хамбла и Дэйва Фарли «Continuous Delivery» (2010). Пока деплой = релиз, любая выкатка это событие с риском, и организация естественным образом снижает частоту выкаток. Что, парадоксально, риск увеличивает: чем реже релиз, тем больше изменений в нём и тем труднее найти виновника инцидента.

Метрики, по которым проверяют себя

Программа исследований DORA (dora.dev) свела здоровье доставки к четырём числам:

Метрика Что измеряет Elite Low
Deployment Frequency как часто катим в прод по требованию, много раз в день реже раза в месяц
Lead Time for Changes от коммита до прода менее часа от месяца
Change Failure Rate доля выкаток, вызвавших деградацию 5–10% 40–60%
Failed Deployment Recovery Time время восстановления после плохой выкатки менее часа неделя и больше

Важный вывод «Accelerate» (Forsgren, Humble, Kim): скорость и стабильность не находятся в противоречии. Команды, катящие часто, ломают прод реже — потому что каждая выкатка маленькая и откатывается быстро. Если ваш Change Failure Rate растёт при росте частоты — проблема не в частоте, а в отсутствии стратегии релиза и автоматического отката.

Deployment pipeline: что происходит после CI

Конвейер доставки — это последовательность всё более дорогих и всё более реалистичных проверок одного и того же артефакта. Критическое правило: артефакт собирается ровно один раз. Никаких пересборок «под стенд» — иначе вы тестировали не то, что выкатили.

Несколько неочевидных требований к этой картинке:

  1. Идентичность окружений по конфигурации, а не по масштабу. Staging из трёх подов против прода из трёхсот — нормально. Staging на SQLite против прода на PostgreSQL — источник инцидентов.
  2. Конфигурация — снаружи артефакта. Один и тот же образ в staging и в проде, различия только в переменных окружения и секретах. Двенадцатифакторный принцип, который постоянно нарушают, вшивая URL в образ.
  3. Каждый этап может быть точкой отката. Не «пайплайн упал, разбирайтесь», а «пайплайн откатил и позвал дежурного».

Карта стратегий выкатки

Blue-green, canary и rolling update: как распределяется трафик

Прежде чем разбирать каждую, полезно увидеть их в координатах «во что обходится» и «как быстро откатывается».

Recreate: убить всё, поднять новое

spec:
  strategy:
    type: Recreate

Полная остановка старой версии, затем запуск новой. Даунтайм равен времени старта приложения. Единственные случаи, когда это правильный выбор: приложение не переживает одновременную работу двух версий (эксклюзивная блокировка на файле, singleton-воркер, несовместимая миграция схемы), либо это внутренний сервис с окном обслуживания. Стоимость нулевая, риск максимальный.

Rolling update: дефолт Kubernetes

Поды заменяются пачками, сервис всё время принимает трафик. Это то, что вы получаете по умолчанию — и то, что чаще всего настроено неправильно.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
spec:
  replicas: 10
  # сколько ревизий ReplicaSet хранить — это ваш запас для kubectl rollout undo
  revisionHistoryLimit: 10
  # под считается готовым только через 20 секунд стабильной готовности:
  # защита от "стартовал, прошёл probe, упал через 5 секунд"
  minReadySeconds: 20
  # если за 5 минут rollout не завершился — Deployment помечается Failed
  progressDeadlineSeconds: 300
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%          # можно поднять на 25% больше подов, чем replicas
      maxUnavailable: 0      # но НЕ гасить старые, пока новые не готовы
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: registry.example.com/checkout@sha256:9f2c...   # digest, не тег!
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 3
            failureThreshold: 2
          # startupProbe снимает конфликт "медленный старт vs быстрый liveness"
          startupProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 5
            failureThreshold: 30
          lifecycle:
            preStop:
              # даём балансировщику убрать под из эндпоинтов до SIGTERM
              exec: { command: ["sh", "-c", "sleep 10"] }

Три вещи, которые ломают rolling update в проде:

  • maxUnavailable: 25% по умолчанию. При десяти репликах вы сознательно разрешаете системе работать на семи. Если у вас нет запаса по ёмкости — получите каскадную деградацию под нагрузкой в момент выкатки. Ставьте maxUnavailable: 0 и платите за maxSurge.
  • Readiness probe, которая ничего не проверяет. path: / возвращающий 200 от фреймворка означает «процесс жив», а не «соединение с БД поднято и кэш прогрет». Проверяйте зависимости в /readyz.
  • Отсутствие preStop. Удаление пода из Endpoints и отправка SIGTERM происходят параллельно, а не последовательно. Без задержки вы гарантированно получите 502 на каждой выкатке.

Работа с откатом:

$ kubectl rollout status deploy/checkout --timeout=5m
Waiting for deployment "checkout" rollout to finish: 6 of 10 updated replicas are available...
error: deployment "checkout" exceeded its progress deadline

$ kubectl rollout history deploy/checkout
REVISION  CHANGE-CAUSE
7         image checkout@sha256:1a4f... (release 2026.7.11)
8         image checkout@sha256:9f2c... (release 2026.7.16)

$ kubectl rollout undo deploy/checkout --to-revision=7
deployment.apps/checkout rolled back

Обратите внимание: откат здесь занимает столько же, сколько выкатка — минуты. Для инцидента с 5xx на 30% трафика это очень долго.

Blue-Green: два полных окружения

Идея из блога Мартина Фаулера: держим две идентичные среды. Одна («blue») обслуживает прод, вторая («green») получает новую версию, прогревается, проходит smoke-тесты. Затем маршрутизатор одномоментно переключается на green. Blue остаётся горячей — это и есть механизм отката.

В Kubernetes самый простой вариант — переключение селектора Service:

apiVersion: v1
kind: Service
metadata:
  name: checkout
spec:
  selector:
    app: checkout
    slot: blue      # <- меняем на green и трафик перетекает за секунды
  ports:
    - port: 80
      targetPort: 8080
---
# отдельный Service для прогрева и smoke-тестов green без внешнего трафика
apiVersion: v1
kind: Service
metadata:
  name: checkout-preview
spec:
  selector:
    app: checkout
    slot: green
# smoke-тесты по preview-эндпоинту
$ kubectl run smoke --rm -it --image=curlimages/curl --restart=Never -- \
    curl -sf http://checkout-preview/readyz && echo OK

# переключение
$ kubectl patch svc checkout -p '{"spec":{"selector":{"slot":"green"}}}'
service/checkout patched

# откат — та же команда с blue, ~2 секунды
$ kubectl patch svc checkout -p '{"spec":{"selector":{"slot":"blue"}}}'

На AWS то же самое делается взвешенными target groups. Terraform (см. подробности в https://courses.digitable.life/post/devops/09-terraform-and-iac/):

resource "aws_lb_listener_rule" "checkout" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 100

  action {
    type = "forward"
    forward {
      target_group {
        arn    = aws_lb_target_group.blue.arn
        weight = var.blue_weight   # 100 → 0 при переключении
      }
      target_group {
        arn    = aws_lb_target_group.green.arn
        weight = var.green_weight  # 0 → 100
      }
      # без stickiness пользователь в рамках сессии будет прыгать между версиями
      stickiness {
        enabled  = true
        duration = 300
      }
    }
  }

  condition {
    path_pattern { values = ["/api/checkout/*"] }
  }
}

Честная цена blue-green. Вы платите за двойную ёмкость. Для сервиса на 40 инстансах m6i.large (~0.096 $/час в eu-central-1) полный дубль стоит около 2 760 $ в месяц. Если green живёт только час на выкатку — цена копеечная. Если вы держите blue горячей неделю «на всякий случай» — это уже реальные деньги, и стоит обсудить их в контексте https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/. Компромисс: green поднимается на 30–50% ёмкости, после переключения автоскейлер добирает остальное; blue гасится через 30 минут.

Главное ограничение blue-green — состояние. Обе среды ходят в одну БД. Это значит, что схема должна быть совместима с обеими версиями кода одновременно. Об этом — отдельный раздел ниже, и это та причина, по которой blue-green «не работает» у большинства команд, которые его пробовали.

Canary: маленький процент, большая информация

Канареечный релиз отправляет на новую версию небольшую долю трафика, измеряет её поведение и принимает решение. В отличие от blue-green, радиус поражения ограничен: 5% трафика видят ошибку вместо 100%.

Простейшая реализация на nginx ingress — без всяких операторов:

# основной ingress остаётся как есть, добавляем второй с аннотациями
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: checkout-canary
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "5"
    # приоритетнее веса: внутренние тестировщики всегда попадают на канарейку
    nginx.ingress.kubernetes.io/canary-by-header: "x-canary"
    nginx.ingress.kubernetes.io/canary-by-header-value: "always"
spec:
  ingressClassName: nginx
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /checkout
            pathType: Prefix
            backend:
              service:
                name: checkout-canary
                port: { number: 80 }

Ручное управление весом работает до масштаба «пара релизов в неделю». Дальше нужен автоматический прогрессивный релиз с анализом метрик. Два зрелых инструмента: Argo Rollouts (часть экосистемы Argo, см. https://courses.digitable.life/post/devops/08-helm-and-gitops/) и Flagger (проект Flux).

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
spec:
  replicas: 10
  strategy:
    canary:
      canaryService: checkout-canary
      stableService: checkout-stable
      trafficRouting:
        nginx:
          stableIngress: checkout
      # сколько ReplicaSet прошлых ревизий держать готовыми к откату
      scaleDownDelaySeconds: 600
      steps:
        - setWeight: 5
        - pause: { duration: 10m }         # baking time: даём метрикам накопиться
        - analysis:
            templates:
              - templateName: success-rate
              - templateName: latency-p99
            args:
              - name: service-name
                value: checkout-canary
        - setWeight: 25
        - pause: { duration: 10m }
        - setWeight: 50
        - pause: { duration: 5m }
        # финальный шаг до 100% выполняется автоматически
  selector:
    matchLabels: { app: checkout }
  template:
    metadata:
      labels: { app: checkout }
    spec:
      containers:
        - name: app
          image: registry.example.com/checkout@sha256:9f2c...

Анализ — отдельный ресурс, и это самая содержательная часть настройки:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  args:
    - name: service-name
  metrics:
    - name: success-rate
      interval: 1m
      count: 8                    # 8 замеров подряд
      # порог мягче, чем ваш SLO: канарейка на малом трафике шумит
      successCondition: result[0] >= 0.995
      failureLimit: 2             # два подряд плохих замера → abort
      # если метрики нет вообще (нет трафика) — не считаем это успехом
      inconclusiveLimit: 3
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service-name}}",code!~"5.."}[2m]))
            /
            sum(rate(http_requests_total{service="{{args.service-name}}"}[2m]))

Что здесь важно и что обычно упускают:

  • Метрики должны быть размечены по версии/сервису. Если ваш Prometheus агрегирует всё в один http_requests_total{app="checkout"}, канареечный анализ невозможен физически: 5% ошибок на 5% трафика — это 0.25% в общей метрике, статистически неотличимо от шума.
  • Малый трафик → нет статистики. На 5% от 100 rps это 5 rps; за 2 минуты — 600 запросов. Одна ошибка сдвигает success rate на 0.17%. Для низконагруженных сервисов канарейка бессмысленна как статистический инструмент, но осмысленна как «первый под с новой версией под реальным трафиком».
  • Смотреть надо не только на 5xx. Реальный набор: доля 5xx, p99 latency, rate ошибок в логах, бизнес-метрика (конверсия оформления заказа), потребление памяти. Самые дорогие инциденты не дают 500-х — они дают «кнопка не нажимается» при HTTP 200.

Что происходит во времени:

Стоимость canary: одна дополнительная реплика вместо полного дубля. Это её главное экономическое преимущество перед blue-green. Плата — сложность: нужен service mesh или ingress с поддержкой весов, нужен Prometheus с корректной разметкой, нужен оператор прогрессивной доставки. Порог входа заметно выше.

Shadow traffic (dark launch)

Копия боевого трафика зеркалируется на новую версию, ответы отбрасываются. Пользователь ничего не видит вообще. Идеально для проверки производительности нового движка поиска или переписанного сервиса на реальных запросах.

# Istio VirtualService: 100% трафика на v1, зеркало на v2
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: search
spec:
  hosts: ["search"]
  http:
    - route:
        - destination: { host: search, subset: v1 }
          weight: 100
      mirror:
        host: search
        subset: v2
      mirrorPercentage: { value: 10.0 }

Ловушка, на которой обжигаются все: зеркалированный трафик не должен иметь побочных эффектов. Если shadow-версия пишет в ту же БД, шлёт письма или списывает деньги — вы только что провели платёж дважды. Shadow требует отдельного хранилища или строгого read-only режима, и это делает его дорогим: вы дублируете не только вычисления, но и данные.

Feature flags: разделяем деплой и релиз

Все стратегии выше оперируют версией сервиса целиком. Feature flags работают на уровне отдельного изменения — и это качественно другой инструмент. Каноническая классификация — статья Пита Ходжсона на martinfowler.com:

Тип флага Живёт Меняется Пример
Release toggle дни–недели редко, при выкатке новая корзина скрыта до готовности
Experiment toggle недели по эксперименту A/B-тест цвета кнопки
Ops toggle месяцы–годы в инциденте отключить тяжёлые рекомендации при пиковой нагрузке
Permission toggle постоянно по пользователю бета-функции для платного тарифа

Смешивать типы в одном механизме — типичная ошибка: release toggle обязан умереть через две недели, permission toggle — часть доменной модели и не должен жить в конфиге деплоя.

Код с OpenFeature — вендор-нейтральным стандартом (CNCF), который избавляет от привязки к конкретному провайдеру:

package checkout

import (
    "context"
    "github.com/open-feature/go-sdk/openfeature"
)

type Service struct {
    flags *openfeature.Client
}

func (s *Service) Process(ctx context.Context, order Order) (Receipt, error) {
    // evaluation context — то, по чему провайдер таргетирует правило
    evalCtx := openfeature.NewEvaluationContext(
        order.UserID,
        map[string]any{
            "plan":    order.User.Plan,
            "country": order.User.Country,
            "beta":    order.User.OptedIntoBeta,
        },
    )

    // третий аргумент — значение по умолчанию.
    // Оно ОБЯЗАНО быть безопасным поведением: если провайдер недоступен,
    // мы падаем в старый проверенный путь, а не в новый непроверенный.
    useNewPricing, _ := s.flags.BooleanValue(ctx, "pricing-engine-v2", false, evalCtx)

    if useNewPricing {
        return s.processV2(ctx, order)
    }
    return s.processV1(ctx, order)
}

Ключевые правила эксплуатации:

  1. Default = старое поведение. Провайдер флагов — это внешняя зависимость, а значит она однажды упадёт. Падение должно означать «работаем как вчера», а не «половина фич выключилась».
  2. Флаг проверяется как можно ближе к точке ветвления, но не в горячем цикле. Вычисление флага на каждой итерации по 10 000 позиций заказа — реальный источник латентности.
  3. Флаг снимается по расписанию. Заводите тикет на удаление в момент создания флага. Flag debt — накопление мёртвых веток — превращает код в неразбираемое месиво: при 30 булевых флагах формальное число комбинаций поведения превышает миллиард, и ни один тест их не покрывает.
  4. Флаги — не замена стратегии выкатки, а дополнение. Флаг не спасёт от OOM в новом коде, который выполняется до проверки флага, и не спасёт от несовместимой миграции.

Чем платить за флаги: собственное решение против SaaS

Вариант Порог входа Стоимость Когда брать
Колонка в конфиге / env-переменная минуты 0 1–3 флага, выкатка = перезапуск
Таблица в БД + кэш в приложении 1–2 дня разработки ~0 до ~20 флагов, один сервис
Unleash OSS (self-hosted) ~день на разворот инфраструктура (~20 $–40/мес VPS + Postgres) десятки флагов, нужны таргетинг и UI для продакта
Flagsmith OSS ~день аналогично то же, альтернатива Unleash
LaunchDarkly / Statsig (SaaS) часы от нескольких сотен $/мес, растёт по seats и MAU нужны эксперименты, аудит, SLA, много команд

Практический совет: начинайте с самого дешёвого варианта и мигрируйте по боли. Внедрять LaunchDarkly ради трёх флагов — классическое переинженеривание. Писать свой сервис флагов с таргетингом, стриминговыми обновлениями и аудитом, когда Unleash уже это умеет, — тоже.

Откаты: почему «просто откатим» обычно неправда

Откат кажется тривиальной операцией, пока в системе нет состояния. Как только есть БД, очередь, кэш или внешний контракт — «просто откатим» превращается в набор условий.

Иерархия механизмов отката по скорости — от самого быстрого к самому медленному:

  1. Killswitch флага — секунды, не требует деплоя вообще. Самый быстрый механизм, который у вас есть.
  2. Смена весов трафика (canary abort, blue-green switch) — секунды–десятки секунд.
  3. Откат образа (kubectl rollout undo, helm rollback) — минуты, зависит от времени старта подов.
  4. Roll-forward с хотфиксом — от 20 минут до часов. Единственный вариант, когда назад дороги нет.

Важное следствие: тестируйте откат так же, как выкатку. helm rollback на релизе с изменёнными CRD или удалёнными ресурсами регулярно даёт неожиданные результаты. Проверяйте в staging регулярно, а не в момент инцидента.

Миграции БД — настоящая причина, почему откаты ломаются

Классический сценарий провала: релиз переименовал колонку name в first_name/last_name, миграция применилась, через 10 минут выяснилось, что новый код глючит. Откатываем образ — и старый код падает с column "name" does not exist. Прод лежит, а откат сделал только хуже.

Решение — Parallel Change, он же expand/contract (martinfowler.com/bliki/ParallelChange.html). Схема и код меняются разными релизами, и на каждом шаге они совместимы в обе стороны.

Expand / migrate / contract: пошаговая миграция схемы без потери возможности отката

Правила, которые из этого следуют:

  • Миграция никогда не едет в том же релизе, что и код, который её требует. Сначала схема (обратно совместимая), потом код.
  • Разрушающие операции (DROP COLUMN, DROP TABLE, NOT NULL на существующей колонке, переименования) откладываются минимум на один-два релизных цикла после того, как старый код гарантированно исчез из прода.
  • Backfill идёт батчами, а не одним UPDATE на 200 млн строк — иначе вы получите многочасовую блокировку и разбухший WAL.

Как это выглядит в SQL для PostgreSQL:

-- Шаг 2 (expand): безопасно, метаданная операция, без переписывания таблицы
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name  text;

-- Индекс — обязательно CONCURRENTLY, иначе блокировка записи на всю таблицу
CREATE INDEX CONCURRENTLY idx_users_last_name ON users (last_name);

-- Шаг 3 (backfill): батчами, с паузой между итерациями, вне транзакции миграции
UPDATE users
SET first_name = split_part(name, ' ', 1),
    last_name  = split_part(name, ' ', 2)
WHERE first_name IS NULL
  AND id IN (SELECT id FROM users WHERE first_name IS NULL LIMIT 10000);

-- Шаг 5 (contract): только когда версия кода, читающая name,
-- отсутствует в проде минимум неделю
ALTER TABLE users DROP COLUMN name;

Проверка совместимости автоматизируется. В пайплайн ставится линтер миграций, который блокирует опасные конструкции:

# .github/workflows/db-guard.yml — фрагмент
- name: Проверка миграций на разрушающие операции
  run: |
    # squawk — линтер для PostgreSQL-миграций
    npx squawk migrations/*.sql --exclude=prefer-text-field
- name: Совместимость схемы со старым кодом
  run: |
    # прогоняем тесты ПРЕДЫДУЩЕЙ версии приложения против НОВОЙ схемы
    docker run --rm --network=host \
      registry.example.com/checkout:${{ env.PREV_TAG }} pytest tests/contract/

Второй шаг здесь — самая недооценённая проверка во всей статье. Она ловит ровно тот класс поломок, который делает откат невозможным.

Собираем прод-пайплайн целиком: GitHub Actions

Конкретный рабочий workflow с ручным гейтом, защитой от параллельных выкаток и автоматическим откатом. Про сравнение самих платформ — https://courses.digitable.life/post/devops/03-modern-ci-platforms/.

name: deliver

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write        # для OIDC-аутентификации в облаке, без долгоживущих ключей

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - id: push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true          # SLSA-аттестация происхождения
          sbom: true

  staging:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Выкатка по digest, а не по тегу
        run: |
          # digest неизменен: гарантия, что в проде тот же байт-в-байт образ
          kubectl set image deploy/checkout \
            app=ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
          kubectl rollout status deploy/checkout --timeout=5m
      - name: Интеграционные тесты
        run: ./scripts/integration-test.sh https://staging.example.com

  production:
    needs: staging
    runs-on: ubuntu-latest
    # environment с required reviewers превращает job в ручной гейт:
    # workflow встаёт на паузу и ждёт approval в UI
    environment:
      name: production
      url: https://api.example.com
    # запрещаем параллельные выкатки в прод; cancel-in-progress: false —
    # чтобы не оборвать выкатку на середине и не оставить кластер в полусостоянии
    concurrency:
      group: production-deploy
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - name: Прогрессивная выкатка
        id: rollout
        run: |
          kubectl argo rollouts set image checkout \
            app=ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
          # блокируемся до промоута или abort; таймаут больше суммы всех pause
          kubectl argo rollouts status checkout --timeout=45m
      - name: Автоматический откат при неудаче
        if: failure() && steps.rollout.conclusion == 'failure'
        run: |
          kubectl argo rollouts undo checkout
          kubectl argo rollouts status checkout --timeout=10m
      - name: Уведомить дежурного
        if: failure()
        run: ./scripts/notify-oncall.sh "rollout ${{ github.sha }} aborted"

Три детали, которые отличают рабочий пайплайн от демонстрационного:

  • Деплой по digest. Тег :latest или даже :v1.2.3 может быть перезаписан. @sha256:... — не может. Это же требование к supply-chain безопасности, подробнее в https://courses.digitable.life/post/devops/17-security-in-pipeline/.
  • concurrency с cancel-in-progress: false. Две одновременные выкатки в один Deployment — надёжный способ получить кластер в состоянии, которое никто не понимает.
  • OIDC вместо статических ключей. id-token: write позволяет обменять токен GitHub на временные креды AWS/GCP. Долгоживущий AWS_SECRET_ACCESS_KEY в секретах репозитория — самая распространённая дыра в CI.

Сравнение стратегий: что выбрать под свой масштаб

Критерий Recreate Rolling Blue-Green Canary Feature flags
Даунтайм есть нет нет нет нет
Доп. инфраструктура 0 +maxSurge (~25%) +100% +5–15% 0 (нужен провайдер)
Время отката время старта минуты секунды секунды секунды
Радиус поражения 100% растёт по мере выкатки 100% после переключения 5–10% задаётся сегментом
Порог входа нулевой низкий средний высокий средний
Требует метрик нет нет желательно обязательно желательно
Совместимость версий БД не нужна нужна нужна нужна нужна
Эксплуатационная нагрузка минимальная низкая средняя (управление слотами) высокая (mesh + анализ) средняя (flag debt)
Работает на VPS без k8s да вручную да (два инстанса + nginx) да (веса в nginx) да

Практическая рекомендация по масштабу:

  • Один сервис, до 5 разработчиков, VPS или k3s (https://courses.digitable.life/post/devops/07-k3s-and-lightweight/): rolling update с maxUnavailable: 0 плюс два-три флага в конфиге. Canary здесь — оверинжиниринг: трафика не хватит на статистику, а поддержка mesh съест больше времени, чем сэкономит.
  • Десяток сервисов, 20–50 разработчиков, Kubernetes: rolling по умолчанию, blue-green для критичных сервисов с дорогим откатом, флаги через self-hosted Unleash. Прогрессивная доставка — для двух-трёх самых нагруженных сервисов.
  • Сотни сервисов, высокий трафик: canary с автоанализом как дефолт для всего, GitOps (https://courses.digitable.life/post/devops/08-helm-and-gitops/) как единственный способ изменить прод, платформенная команда, владеющая шаблонами Rollout.

Типичные ошибки

  1. Пересборка артефакта на каждом стенде. «В staging работало» — потому что там был другой образ. Собирайте один раз, продвигайте по стендам один и тот же digest.
  2. Канареечный анализ без разметки метрик по версии. Оператор честно смотрит на агрегированную метрику, не видит деградации на 5% трафика и промоутит сломанный релиз до 100%.
  3. Blue-green с необратимой миграцией. Green применил DROP COLUMN, blue мгновенно перестала работать. Механизм отката уничтожен той же выкаткой, которая должна была быть безопасной.
  4. Отсутствие baking time. setWeight: 5 → сразу setWeight: 100 без паузы — это не канарейка, это rolling update с лишними шагами. Утечки памяти и деградация под нагрузкой проявляются за десятки минут, а не за 30 секунд.
  5. Флаги без срока жизни. Через год у вас 200 флагов, из которых 180 всегда true, и никто не решается их удалить.
  6. Ручной approval как единственный контроль качества. Человек, нажимающий кнопку в 19:00 пятницы, не имеет информации, которой нет у автоматики. Гейт должен опираться на проверки, а не заменять их.
  7. Откат, который никогда не проверяли. Обнаружить, что helm rollback не работает из-за изменённых CRD, лучше в среду на staging, чем в субботу в проде.
  8. Выкатка без наблюдаемости. Если у вас нет SLI и алертов (https://courses.digitable.life/post/devops/16-observability-and-oncall/), любая стратегия деградирует до «катим и надеемся»: canary без метрик — это просто медленный rolling update.

Мини-итог

  • Continuous Delivery — это состояние готовности к выкатке в любой момент, а не автоматизация ради автоматизации. Мерить прогресс — четырьмя метриками DORA.
  • Разделение деплоя и релиза — самый сильный доступный приём: он превращает выкатку в рутину, а включение фичи — в обратимое решение.
  • Стратегия выбирается по трём осям: допустимый радиус поражения, требуемая скорость отката и бюджет на дублирующую инфраструктуру. Blue-green покупает мгновенный откат за 100% ёмкости; canary покупает ограниченный радиус за сложность и требование к метрикам.
  • Настоящее ограничение на откат почти всегда лежит в состоянии, а не в коде. Parallel Change для схемы БД — это то, что делает все остальные стратегии реально работающими.
  • Не начинайте с самого сложного. Rolling update с maxUnavailable: 0, корректными probes и парой флагов покрывает потребности подавляющего большинства команд.

Источники

Что дальше

Все стратегии выше оперируют образами: image@sha256:... — единица, которую мы двигаем по стендам, откатываем и подписываем. Пора разобраться, из чего эта единица состоит и как её сделать маленькой, воспроизводимой и безопасной.

Контейнеры в доставке: Docker, сборка образов, реестры, безопасность и размер

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов