Постановка задачи
Вам необходимо разворачивать новые версии приложения без прерывания обслуживания пользователей, безопасно выполнять миграции базы данных и иметь возможность быстро откатиться при возникновении проблем.
Сравнение стратегий деплоя
| Стратегия | Простой | Скорость отката | Стоимость ресурсов | Сложность |
|---|
| Rolling Update | Нулевой | Быстро | Низкая | Низкая |
| Blue-Green | Нулевой | Мгновенно | 2x | Средняя |
| Canary | Нулевой | Быстро | Низкая–средняя | Высокая |
| A/B Testing | Нулевой | Быстро | Низкая–средняя | Высокая |
Стратегия 1: Rolling Update (по умолчанию в Kubernetes)
Как это работает
Kubernetes постепенно заменяет старые pod’ы новыми, обеспечивая поддержание минимальной доступности на всём протяжении процесса.
Time 0: [v1] [v1] [v1] [v1] ← All running v1
Time 1: [v1] [v1] [v1] [v2] ← One v2 starting
Time 2: [v1] [v1] [v2] [v2] ← Two v2 ready
Time 3: [v1] [v2] [v2] [v2] ← Three v2 ready
Time 4: [v2] [v2] [v2] [v2] ← All running v2
Конфигурация
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # Не более 1 pod может быть недоступен
maxSurge: 1 # Не более 1 дополнительного pod во время rollout
template:
spec:
containers:
- name: app
image: myapp:v2
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
Корректное завершение работы
Убедитесь, что ваше приложение корректно обрабатывает SIGTERM:
// Пример для Node.js
const server = app.listen(8080);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
// Закрыть подключения к базе данных
db.close();
process.exit(0);
});
// Принудительное закрытие через 30 секунд
setTimeout(() => {
console.error('Forced shutdown');
process.exit(1);
}, 30000);
});
# Конфигурация Kubernetes
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
lifecycle:
preStop:
exec:
# Дать load balancer время исключить pod из ротации
command: ["/bin/sh", "-c", "sleep 10"]
Стратегия 2: Blue-Green Deployment
Как это работает
Запускаются два идентичных окружения. Весь трафик направляется в одно (blue), деплой выполняется во второе (green), затем выполняется переключение.
┌──────────────────┐
│ Load Balancer │
└────────┬─────────┘
│
┌───────────┼───────────┐
│ │ │
┌──────▼──────┐ │ ┌──────▼──────┐
│ Blue │ │ │ Green │
│ (v1.0) │◀───┘ │ (v1.1) │
│ ACTIVE │ │ STANDBY │
└─────────────┘ └─────────────┘
Реализация в Kubernetes
# blue-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-blue
labels:
app: myapp
version: blue
spec:
replicas: 3
selector:
matchLabels:
app: myapp
version: blue
template:
metadata:
labels:
app: myapp
version: blue
spec:
containers:
- name: app
image: myapp:v1.0
---
# green-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-green
labels:
app: myapp
version: green
spec:
replicas: 3
selector:
matchLabels:
app: myapp
version: green
template:
metadata:
labels:
app: myapp
version: green
spec:
containers:
- name: app
image: myapp:v1.1
---
# service.yaml — переключение через изменение selector
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
version: blue # Измените на "green" для переключения
ports:
- port: 80
targetPort: 8080
Скрипт переключения
#!/bin/bash
CURRENT=$(kubectl get service myapp -o jsonpath='{.spec.selector.version}')
if [ "$CURRENT" = "blue" ]; then
NEW="green"
else
NEW="blue"
fi
echo "Switching from $CURRENT to $NEW"
kubectl patch service myapp -p "{\"spec\":{\"selector\":{\"version\":\"$NEW\"}}}"
echo "Traffic now routing to $NEW"
Стратегия 3: Canary Deployment
Как это работает
Сначала новая версия разворачивается для небольшой доли пользователей, затем доля постепенно увеличивается.
Phase 1: [v1][v1][v1][v1][v1][v1][v1][v1][v1][v2] ← 10% canary
Phase 2: [v1][v1][v1][v1][v1][v2][v2][v2][v2][v2] ← 50% canary
Phase 3: [v2][v2][v2][v2][v2][v2][v2][v2][v2][v2] ← 100% promoted
Использование Nginx Ingress
# Основной деплой (stable)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-stable
spec:
replicas: 9
template:
spec:
containers:
- name: app
image: myapp:v1.0
---
# Canary-деплой
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-canary
spec:
replicas: 1
template:
spec:
containers:
- name: app
image: myapp:v1.1
---
# Stable Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-stable
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-stable
port:
number: 80
---
# Canary Ingress с весом
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-canary
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-canary
port:
number: 80
Canary с Istio
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: myapp
spec:
hosts:
- myapp
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: myapp
subset: canary
- route:
- destination:
host: myapp
subset: stable
weight: 90
- destination:
host: myapp
subset: canary
weight: 10
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: myapp
spec:
host: myapp
subsets:
- name: stable
labels:
version: stable
- name: canary
labels:
version: canary
Безопасные миграции базы данных
Принципы
- Backward Compatible: старый код должен работать с новой схемой
- Forward Compatible: новый код должен работать со старой схемой во время rollout
- Небольшие, инкрементальные изменения: никаких миграций «одним махом»
Паттерн: миграция Expand-Contract
Фаза 1: Expand (добавить новый столбец)
-- Миграция 1: добавить новый столбец, сохранить старый
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
// Код v2: писать в оба, читать из старого
const user = await db.query('SELECT first_name, last_name, full_name FROM users');
user.displayName = user.full_name || `${user.first_name} ${user.last_name}`;
// При сохранении писать в оба
await db.query(
'UPDATE users SET first_name = $1, last_name = $2, full_name = $3',
[firstName, lastName, fullName]
);
Фаза 2: Миграция данных
-- Миграция 2: backfill данных
UPDATE users SET full_name = CONCAT(first_name, ' ', last_name)
WHERE full_name IS NULL;
Фаза 3: Contract (удалить старые столбцы)
// Код v3: читать/писать только новый столбец
const user = await db.query('SELECT full_name FROM users');
-- Миграция 3: удалить старые столбцы (после перевода всех pod’ов на v3)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
Job для миграции в Kubernetes
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration-v1-2
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: wait-for-db
image: busybox
command: ['sh', '-c', 'until nc -z postgres 5432; do sleep 1; done']
containers:
- name: migrate
image: myapp:v1.2
command: ["npm", "run", "db:migrate"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
Процедуры отката
Откат в Kubernetes
# Просмотреть историю deployment
kubectl rollout history deployment/myapp
# Откат к предыдущей версии
kubectl rollout undo deployment/myapp
# Откат к конкретной ревизии
kubectl rollout undo deployment/myapp --to-revision=2
# Проверить статус отката
kubectl rollout status deployment/myapp
Мгновенный откат Blue-Green
# Просто переключите selector сервиса обратно
kubectl patch service myapp -p '{"spec":{"selector":{"version":"blue"}}}'
Откат базы данных
-- Всегда держите готовую rollback-миграцию
-- down-migration.sql
ALTER TABLE users ADD COLUMN first_name VARCHAR(255);
ALTER TABLE users ADD COLUMN last_name VARCHAR(255);
UPDATE users SET
first_name = SPLIT_PART(full_name, ' ', 1),
last_name = SPLIT_PART(full_name, ' ', 2);
Лучшие практики health checks
Реализуйте три endpoint’а
// /health — Liveness: жив ли процесс?
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// /ready — Readiness: может ли сервис обрабатывать запросы?
app.get('/ready', async (req, res) => {
try {
await db.query('SELECT 1');
await redis.ping();
res.status(200).json({ status: 'ready' });
} catch (error) {
res.status(503).json({ status: 'not ready', error: error.message });
}
});
// /startup — Startup: завершилась ли инициализация сервиса?
let isStarted = false;
app.get('/startup', (req, res) => {
if (isStarted) {
res.status(200).json({ status: 'started' });
} else {
res.status(503).json({ status: 'starting' });
}
});
Конфигурация probes в Kubernetes
spec:
containers:
- name: app
startupProbe:
httpGet:
path: /startup
port: 8080
failureThreshold: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 3
Подход Senior: дело в состоянии, а не в коде
«Maintenance Mode» неприемлем для систем уровня senior. Реальная сложность — не в замене кода, а в управлении состоянием.
Почему миграции базы данных — самая сложная часть
Правило: изменения базы данных должны быть backward compatible при одновременной работе старого и нового кода.
Сценарий: переименование столбца
- Наивно:
ALTER TABLE users RENAME COLUMN name TO full_name; - Результат: старый код (который всё ещё работает во время деплоя) делает запросы к
name. В DB теперь full_name. Падение.
Senior-паттерн: Expand and Contract 1. Деплой 1 (Expand): добавить столбец fullname. Код пишет в оба name и fullname. Читает из name. 2. Backfill: запустить скрипт, который копирует name в fullname для старых строк. 3. Деплой 2 (Switch): код читает из fullname. Пишет в оба. 4. Деплой 3 (Contract): код пишет только в full_name. Удалить столбец name.
Этот паттерн требует 3 деплоя вместо 1, но гарантирует ноль потерянных запросов.
Выбор правильной стратегии
| Сценарий | Рекомендуемая стратегия |
|---|
| Стандартное обновление приложения, важна стоимость | Rolling Update |
| Критический контур, нужен мгновенный откат | Blue-Green |
| Высокорисковое изменение, нужен постепенный rollout | Canary |
| A/B-эксперимент с сегментацией пользователей | Canary с маршрутизацией по заголовкам |
Pro Tips:
- Rolling Update: обязательно должен быть
readinessProbe. Иначе K8s начнёт отправлять трафик в новый pod до загрузки приложения, что приведёт к 502. - Blue-Green: стоимость вдвое выше (нужно 2x ресурсов), зато мгновенное переключение и мгновенный откат.
- Canary: самый безопасный вариант — выкатить на 5% пользователей, мониторить метрики, затем увеличить до 20%, 50%, 100%.
Istio VirtualService для Canary
При использовании Istio можно выполнять weighted routing:
# Istio VirtualService
route:
- destination:
host: my-service
subset: v1
weight: 90
- destination:
host: my-service
subset: v2
weight: 10
Чек-лист деплоя без простоя
- [ ] Приложение корректно обрабатывает SIGTERM
- [ ] PreStop hook даёт время на обновление load balancer
- [ ] Readiness probe проверяет все зависимости
- [ ] Стратегия rolling update настроена с подходящими значениями
- [ ] Миграции базы данных являются backward compatible
- [ ] Для изменений схемы используется паттерн Expand-Contract
- [ ] Feature flags для новой функциональности
- [ ] Алерты мониторинга на здоровье деплоя
- [ ] Runbook для процедур отката
- [ ] Перед крупными релизами выполнено нагрузочное тестирование
- [ ] Деплой в окна низкого трафика (если применимо)
Итог
Деплой без простоя в основном зависит от совместимости базы данных. Если изменения схемы ломают предыдущую версию кода, никакая магия Kubernetes не спасёт. Всегда сначала расширяйте (expand), затем сокращайте (contract).
Связанные статьи Wiki