Чому "Rolling Update" недостатньо
Дефолтна стратегія деплою у Kubernetes — це RollingUpdate, і більшість команд на цьому зупиняються. Це краще, ніж повний простій, але це все ще поганий дефолт для продакшну. У нього немає автоматизованого health-check понад readiness probe, немає traffic-shaping, немає автоматизованого відкату і немає способу валідувати нову версію під реальним навантаженням до того, як перевести на неї всіх користувачів. Якщо новий под зламаний так, що це проявляється лише при 10% трафіку, ви дізнаєтесь це при 100% трафіку.
Реальні zero-downtime деплої вимагають двох речей, що працюють разом: стратегії трафіку (blue-green або canary) і стратегії здоров'я (автоматизованого аналізу, що може перервати rollout). Ця стаття — про те, як правильно побудувати обидві з Argo Rollouts, Kubernetes та service mesh чи ingress, який ви, ймовірно, вже запускаєте.
Blue-Green: перемикач
Blue-green запускає два повні середовища поряд. Blue обслуговує продакшн-трафік, а Green отримує нову версію. Ви деплоїте, робите smoke test проти внутрішнього service name, потім перемикаєте один label або router rule — і Green стає продакшном. Старе Blue тримається теплим для миттєвого rollback.
Коли Blue-Green виграє
- Потрібні stateful тести перед go-live. Ви можете запускати повні smoke/e2e набори проти Green, використовуючи реальну інфраструктуру.
- Миттєвий rollback — жорстка вимога. Перемикання label швидше за будь-який canary rewind.
- Вартість невеликого відсотка поганого rollout неприйнятна. Payment flows, auth, критичні внутрішні API.
Коли це боляче
- Ви не можете дозволити собі 2x capacity на час перемикання. Blue-green подвоює кількість подів під час переходу.
- У вас є міграції БД, що не є зворотно сумісними. І Blue, і Green розділяють ту саму БД. Якщо Green потребує зміни схеми, Blue може зламатися.
- Ваш сервіс великий. Провіження повного Green може займати хвилини scheduling time.
Blue-Green з Argo Rollouts
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: payments
spec:
replicas: 10
strategy:
blueGreen:
activeService: payments-active
previewService: payments-preview
autoPromotionEnabled: false
scaleDownDelaySeconds: 600
prePromotionAnalysis:
templates:
- templateName: smoke-tests
args:
- name: service-name
value: payments-preview
postPromotionAnalysis:
templates:
- templateName: error-rate
selector:
matchLabels:
app: payments
template:
metadata:
labels:
app: payments
spec:
containers:
- name: api
image: ghcr.io/example/payments:1.14.0
ports:
- containerPort: 8080
Дві важливі властивості тут. autoPromotionEnabled: false вимагає ручного promotion (або успішного AnalysisRun) перед тим, як відбудеться перемикання. scaleDownDelaySeconds: 600 тримає старий ReplicaSet живим 10 хвилин після promotion — це ваше вікно для rollback.
Canary: градієнт
Canary-релізи надсилають невеликий зріз трафіку на нову версію, спостерігають за метриками і поступово розширюють. Зроблено правильно — це найбезпечніша стратегія у більшості продакшн-середовищ. Зроблено неправильно (без метрик, без автоматизованого abort, з ручними стрибками ваг) — це гірше, ніж rolling update, бо інженер, що няньчить це, просто клікає "promote" на кожному кроці.
Коли Canary виграє
- High-traffic сервіси. 10% від мільйона запитів на хвилину — це багато сигналу, щоб виявити регрес.
- Чутливість до вартості. Ви запускаєте 110% capacity під час rollout, а не 200%.
- Поступова експозиція ризику. Чудово для user-facing змін, де "виглядає нормально внутрішньо" недостатньо.
Коли це боляче
- Low-traffic сервіси. 10% від 50 rpm — це 5 rpm. Цього недостатньо сигналу, щоб щось виявити за п'ять хвилин. Для них нормально blue-green або звичайний rolling update.
- Метрики, які важко ізолювати. Якщо ви не можете обчислити error rate per-version, canary-аналіз — це обман.
Canary з Argo Rollouts та Istio
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: api
spec:
replicas: 8
strategy:
canary:
canaryService: api-canary
stableService: api-stable
trafficRouting:
istio:
virtualService:
name: api
routes: [primary]
steps:
- setWeight: 5
- pause: { duration: 2m }
- analysis:
templates:
- templateName: success-rate
- templateName: latency-p99
args:
- name: service-name
value: api-canary
- setWeight: 25
- pause: { duration: 5m }
- analysis:
templates:
- templateName: success-rate
- templateName: latency-p99
args:
- name: service-name
value: api-canary
- setWeight: 50
- pause: { duration: 5m }
- setWeight: 100
Зверніть увагу, як паузи короткі на початку і довші пізніше. Перші хвилини canary — найінформативніші: якщо нова версія збирається впасти чи кидати 500-ки, ви хочете побачити це негайно.
Автоматизований аналіз: частина, що справді має значення
Жодна стратегія не є безпечною без автоматизованого, metric-driven abort. Argo Rollouts використовує AnalysisTemplate, щоб робити запити до Prometheus (або Datadog, New Relic, CloudWatch тощо) і провалити rollout, якщо сигнал поганий.
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
spec:
args:
- name: service-name
metrics:
- name: success-rate
interval: 30s
count: 6
successCondition: result[0] >= 0.99
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
sum(rate(http_requests_total{service="{{args.service-name}}",code!~"5.."}[1m]))
/
sum(rate(http_requests_total{service="{{args.service-name}}"}[1m]))
І шаблон latency за тим самим патерном:
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: latency-p99
spec:
args:
- name: service-name
metrics:
- name: latency-p99
interval: 30s
count: 6
successCondition: result[0] < 0.4
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
histogram_quantile(
0.99,
sum by (le) (
rate(http_request_duration_seconds_bucket{service="{{args.service-name}}"}[1m])
)
)
Якщо будь-який з них провалиться, Rollout перериває роботу і відновлює стабільний трафік без людини у циклі. У цьому весь сенс.
Міграції БД: тихий убивця
Найпоширеніша причина того, що "zero-downtime" перетворюється на "простій" — це міграція БД, що припустила, що старий код уже пішов. І blue-green, і canary обслуговують старий та новий код застосунку одночасно. Ваша схема має працювати для обох одночасно.
Патерн Expand-and-Contract
Ніколи не відправляйте ламаючу міграцію в одному релізі. Розділіть її на:
- Expand. Додайте новий стовпець/таблицю/індекс. Старий код його ігнорує. Новий код пише і в старий, і в новий.
- Backfill. Копіюйте дані зі старого в новий. Запускається як фоновий job.
- Migrate reads. Новий код читає з нового стовпця. Старий код усе ще читає старий.
- Contract. Видаліть старий стовпець у наступному релізі, коли жоден живий под його не використовує.
Так, це чотири релізи замість одного. Це також різниця між плановою зміною і інцидентом о 2 ночі.
-- Release N: expand
ALTER TABLE users ADD COLUMN email_normalized TEXT;
CREATE INDEX CONCURRENTLY idx_users_email_norm ON users(email_normalized);
-- Release N (app code): dual-write
INSERT INTO users (email, email_normalized) VALUES ($1, LOWER($1));
-- Release N+1: backfill
UPDATE users SET email_normalized = LOWER(email) WHERE email_normalized IS NULL;
-- Release N+2: read from new column
SELECT id FROM users WHERE email_normalized = $1;
-- Release N+3: contract
ALTER TABLE users DROP COLUMN email;
Connection draining та graceful shutdown
Навіть з ідеальним traffic shifting, под, що помирає посеред запиту, губить цей запит. Kubernetes надсилає SIGTERM, чекає terminationGracePeriodSeconds, потім надсилає SIGKILL. Вашому застосунку потрібно:
- Негайно провалити readiness probe (щоб його видалили з endpoints).
- Продовжувати обслуговувати in-flight запити.
- Закрити HTTP listener і злити з'єднання.
- Завершитись чисто до закінчення grace period.
Мінімальний Node.js shutdown handler:
import http from "node:http";
import express from "express";
const app = express();
let ready = true;
app.get("/ready", (_, res) => (ready ? res.sendStatus(200) : res.sendStatus(503)));
app.get("/live", (_, res) => res.sendStatus(200));
const server = http.createServer(app);
server.listen(8080);
const shutdown = async () => {
console.log("SIGTERM received, draining");
ready = false;
// Give load balancers time to observe unreadiness
await new Promise((r) => setTimeout(r, 5000));
server.close((err) => {
if (err) {
console.error("Shutdown error", err);
process.exit(1);
}
process.exit(0);
});
// Hard timeout
setTimeout(() => process.exit(1), 25000).unref();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
І відповідний pod spec:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 2
failureThreshold: 2
preStop hook дає service mesh мить, щоб поширити видалення до того, як процес почне завершуватись. Дрібниця, величезна різниця у продакшні.
Вибір між цими двома
| Критерій | Blue-Green | Canary |
|---|---|---|
| Модель трафіку | All-or-nothing | Градуальний відсоток |
| Швидкість rollback | Миттєво (label flip) | Секунди (traffic reset) |
| Додаткова capacity | 2x під час перемикання | 1.1x під час rollout |
| Валідація реальними користувачами | Лише після перемикання | Під час rollout |
| Мінімум корисного трафіку | Будь-який | ~100 rpm+ |
| Толерантність до міграцій БД | Важче (обидва env розділяють БД) | Так само |
| Складність | Нижча | Вища (потребує метрик) |
На практиці ми рекомендуємо canary за замовчуванням для будь-якого сервісу з достатнім трафіком, щоб аналіз був змістовним, і blue-green для low-traffic критичних сервісів (auth, payments, webhooks), де повні pre-promotion smoke-тести цінніші за поступову експозицію.
Наступні кроки
Відвантаження без страху вимагає трьох речей: стратегії трафіку, автоматизованого metric-driven abort і дисципліни БД, що припускає одночасне виконання двох версій коду. YAML — це легко. Дисципліна — складна частина. Почніть з прийняття expand-and-contract на вашій наступній зміні схеми, додайте AnalysisTemplate, що запитує ваш існуючий Prometheus, і запустіть ваш перший canary на некритичному сервісі. Якщо хочете допомоги з підняттям платформи progressive delivery на кількох сервісах, напишіть нам.