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
Конвейер доставки — это последовательность всё более дорогих и всё более реалистичных проверок одного и того же артефакта. Критическое правило: артефакт собирается ровно один раз. Никаких пересборок «под стенд» — иначе вы тестировали не то, что выкатили.
образ + SBOM + подпись] B --> U[Unit + линтеры
~3 мин] U --> R[(Registry
sha256:abc123)] R --> S1[Deploy: staging
автоматически] S1 --> IT[Интеграционные
и контрактные тесты] IT --> SM[Smoke + нагрузочный
срез] SM --> G{Gate:
ручной approval?} G -->|Delivery| M[Человек жмёт кнопку] G -->|Deployment| A[Авто] M --> P[Прод: canary 5%] A --> P P --> AN{Автоанализ
SLI 10 мин} AN -->|деградация| RB[Abort + rollback] AN -->|норма| PR[Progressive: 25% → 50% → 100%] PR --> POST[Post-deploy проверки
+ снятие флага] RB --> C style R fill:#0f766e,color:#fff style RB fill:#dc2626,color:#fff style PR fill:#16a34a,color:#fff
Несколько неочевидных требований к этой картинке:
- Идентичность окружений по конфигурации, а не по масштабу. Staging из трёх подов против прода из трёхсот — нормально. Staging на SQLite против прода на PostgreSQL — источник инцидентов.
- Конфигурация — снаружи артефакта. Один и тот же образ в staging и в проде, различия только в переменных окружения и секретах. Двенадцатифакторный принцип, который постоянно нарушают, вшивая URL в образ.
- Каждый этап может быть точкой отката. Не «пайплайн упал, разбирайтесь», а «пайплайн откатил и позвал дежурного».
Карта стратегий выкатки
Прежде чем разбирать каждую, полезно увидеть их в координатах «во что обходится» и «как быстро откатывается».
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.
Что происходит во времени:
пострадало 5% трафика на 3 минуты end
Стоимость 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)
}
Ключевые правила эксплуатации:
- Default = старое поведение. Провайдер флагов — это внешняя зависимость, а значит она однажды упадёт. Падение должно означать «работаем как вчера», а не «половина фич выключилась».
- Флаг проверяется как можно ближе к точке ветвления, но не в горячем цикле. Вычисление флага на каждой итерации по 10 000 позиций заказа — реальный источник латентности.
- Флаг снимается по расписанию. Заводите тикет на удаление в момент создания флага. Flag debt — накопление мёртвых веток — превращает код в неразбираемое месиво: при 30 булевых флагах формальное число комбинаций поведения превышает миллиард, и ни один тест их не покрывает.
- Флаги — не замена стратегии выкатки, а дополнение. Флаг не спасёт от 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 уже это умеет, — тоже.
Откаты: почему «просто откатим» обычно неправда
Откат кажется тривиальной операцией, пока в системе нет состояния. Как только есть БД, очередь, кэш или внешний контракт — «просто откатим» превращается в набор условий.
(необратимая миграция) RollForward --> Fix: hotfix через полный пайплайн Fix --> Released Released --> [*] note right of RollForward Сюда попадают, когда схема уже несовместима со старым кодом. Дороже отката в 10-50 раз. end note
Иерархия механизмов отката по скорости — от самого быстрого к самому медленному:
- Killswitch флага — секунды, не требует деплоя вообще. Самый быстрый механизм, который у вас есть.
- Смена весов трафика (canary abort, blue-green switch) — секунды–десятки секунд.
- Откат образа (
kubectl rollout undo,helm rollback) — минуты, зависит от времени старта подов. - 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). Схема и код меняются разными релизами, и на каждом шаге они совместимы в обе стороны.
Правила, которые из этого следуют:
- Миграция никогда не едет в том же релизе, что и код, который её требует. Сначала схема (обратно совместимая), потом код.
- Разрушающие операции (
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.
Типичные ошибки
- Пересборка артефакта на каждом стенде. «В staging работало» — потому что там был другой образ. Собирайте один раз, продвигайте по стендам один и тот же digest.
- Канареечный анализ без разметки метрик по версии. Оператор честно смотрит на агрегированную метрику, не видит деградации на 5% трафика и промоутит сломанный релиз до 100%.
- Blue-green с необратимой миграцией. Green применил
DROP COLUMN, blue мгновенно перестала работать. Механизм отката уничтожен той же выкаткой, которая должна была быть безопасной. - Отсутствие baking time.
setWeight: 5→ сразуsetWeight: 100без паузы — это не канарейка, это rolling update с лишними шагами. Утечки памяти и деградация под нагрузкой проявляются за десятки минут, а не за 30 секунд. - Флаги без срока жизни. Через год у вас 200 флагов, из которых 180 всегда
true, и никто не решается их удалить. - Ручной approval как единственный контроль качества. Человек, нажимающий кнопку в 19:00 пятницы, не имеет информации, которой нет у автоматики. Гейт должен опираться на проверки, а не заменять их.
- Откат, который никогда не проверяли. Обнаружить, что
helm rollbackне работает из-за изменённых CRD, лучше в среду на staging, чем в субботу в проде. - Выкатка без наблюдаемости. Если у вас нет 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 и парой флагов покрывает потребности подавляющего большинства команд.
Источники
- Jez Humble, David Farley. Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation. Addison-Wesley, 2010 — continuousdelivery.com
- Nicole Forsgren, Jez Humble, Gene Kim. Accelerate: The Science of Lean Software and DevOps. IT Revolution, 2018; актуальные отчёты — dora.dev
- Martin Fowler. BlueGreenDeployment, CanaryRelease, ParallelChange
- Pete Hodgson. Feature Toggles (aka Feature Flags), martinfowler.com
- Google SRE Book, гл. 8 — Release Engineering
- Argo Rollouts documentation — спецификация Rollout, AnalysisTemplate, стратегии
- Flagger documentation — прогрессивная доставка поверх Flux
- Kubernetes: Deployments — rolling update,
maxSurge/maxUnavailable, rollout undo - OpenFeature — вендор-нейтральный стандарт feature flags (CNCF)
- AWS CodeDeploy: deployment configurations — готовые canary/linear-конфигурации
- Squawk — линтер миграций PostgreSQL для CI
Что дальше
Все стратегии выше оперируют образами: image@sha256:... — единица, которую мы двигаем по стендам, откатываем и подписываем. Пора разобраться, из чего эта единица состоит и как её сделать маленькой, воспроизводимой и безопасной.
Контейнеры в доставке: Docker, сборка образов, реестры, безопасность и размер