Zero-downtime deployments
“Rolling update без downtime” — стандартное требование production. На бумаге Kubernetes это делает out-of-the-box: Deployment → RollingUpdate → новые Pods постепенно заменяют старые. На практике 5xx в момент rollout — самая частая жалоба от пользователей.
Причина — zero-downtime требует семи правильно настроенных вещей одновременно, и достаточно нарушить одну, чтобы клиенты получали ошибки. В этом уроке — полный чек-лист и каждый его пункт, плюс особенности для long-lived connections (gRPC, WebSocket) и для координации с внешним load balancer.
Race-условие между SIGTERM и kube-proxy/EndpointSlice update разбирается детально в уроке «Graceful shutdown» (19/02). Здесь оно используется как один из пунктов чек-листа; за внутренней механикой EndpointSlice propagation идём в предыдущий урок.
Зачем нужен load balancer: scalability, redundancy, health checks
Чек-лист zero-downtime
1. readinessProbe — Pod не получает трафик, пока не готов
2. preStop hook (sleep 5-10s) — даёт kube-proxy обновить правила
3. terminationGracePeriodSeconds — бюджет на graceful shutdown
4. SIGTERM handler в приложении — действительно делает graceful shutdown
5. minReadySeconds — Pod проверяется стабильным до scaling старого
6. RollingUpdate maxUnavailable=0 — никогда не теряем Pod до того как новый Ready
7. PodDisruptionBudget — защита от parallel evictions
Без любого из пунктов rollout может уронить трафик. Разберём каждый.
1. readinessProbe — пропуск к Service
Без readinessProbe Pod считается Ready как только контейнер запустился (Phase=Running). Но Phase=Running не значит “готов принимать трафик” — приложение может ещё инициализироваться (warmup кешей, миграции, JIT). Деплоймент controller убьёт старый Pod, новый ещё не отвечает — клиенты получают 5xx.
containers:
- name: web
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
Pod добавляется в EndpointSlice (= получает трафик) только после первой успешной readiness probe.
Хороший /healthz для readiness — это не просто return 200. Endpoint должен проверять реальную готовность: DB connection, Redis connection, прогретость кешей. Это не liveness probe (которая просто “процесс жив”). Грубая readiness = ложно-готовые Pods в трафике.
Подробно — см. модуль “Probes & Health Checks”.
2. preStop sleep — race с kube-proxy
Когда Pod marked for deletion, EndpointSlice обновляется и kube-proxy на нодах должен убрать iptables/IPVS правило. Это distributed update, занимает 1-5 секунд. За это время kubelet уже шлёт SIGTERM и приложение закрывает listener.
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
preStop sleep 5-10s блокирует SIGTERM, давая kube-proxy distribute update. После sleep трафик уже не приходит — SIGTERM безопасен. Подробно — урок “Graceful shutdown”.
3. terminationGracePeriodSeconds — бюджет
spec:
terminationGracePeriodSeconds: 60 # включает preStop + grace после SIGTERM
Должно покрывать: preStop sleep + время на завершение in-flight requests. Для HTTP с быстрыми requests хватает 30-60s. Для long-lived (file uploads, gRPC streams) — может потребоваться больше.
4. SIGTERM handler — приложение готово
App должен handle SIGTERM:
- Stop accepting new connections.
- Wait for in-flight requests.
- Close DB pool, file handles.
- Exit с 0.
Если игнорирует SIGTERM — graceful shutdown превращается в SIGKILL через terminationGracePeriodSeconds → in-flight requests умирают.
5. minReadySeconds — wait для стабильного Pod
spec:
minReadySeconds: 15
Pod считается Available только если он Ready=True непрерывно дольше minReadySeconds. Если Ready перешёл в False — таймер сбрасывается.
Зачем: иногда Pod становится Ready, проходит несколько успешных probes, и потом падает (cold caches, lazy init issues, JIT failures). Без minReadySeconds — controller сразу после первого Ready считает Pod Available и убивает следующий старый. С minReadySeconds — controller ждёт стабильности.
spec:
minReadySeconds: 30
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
Production-grade: 15-60 секунд. Зависит от warmup time приложения.
6. RollingUpdate maxUnavailable=0
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0 # никогда не теряем Available Pod
maxUnavailable=0 — controller никогда не убивает старый Pod, пока не дождётся нового Ready+Available. Это медленнее (нет parallel scale-down), но гарантирует full capacity всегда.
Подробно про инварианты — модуль “Deployment Strategies”.
7. PodDisruptionBudget — защита от parallel disruptions
PDB отдельная проблема от rolling update. Защищает от voluntary disruptions:
kubectl drain node(cluster autoscaler removes node, admin maintenance).- Eviction по Pod priority, dischargeerror.
- Не от: node crash, OOMKill, Pod delete без drain.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 80% # или maxUnavailable: 1
selector:
matchLabels:
app: web
Сценарий без PDB:
- Cluster autoscaler решает удалить node A → drain.
- Drain пытается evict все Pods с node A.
- Pod web-1 и web-2 на node A → evict оба одновременно.
- Если они были part of
replicas=4, ныне Available count = 2. Если ещё параллельно идёт rolling update → может уйти ниже.
С PDB minAvailable=80%:
- Drain пытается evict web-1, web-2 на node A.
- API проверяет PDB: после evict web-1 минимально доступных всё ещё ≥ 80%? Если да — evict OK. Если нет (например, параллельно идёт rolling) — eviction блокирован, drain ждёт.
# Альтернативно
spec:
maxUnavailable: 1 # не более одного Pod недоступно
PDB только для voluntary disruptions. Если node физически крашится — все Pod-ы исчезают, PDB не помогает (это involuntary disruption). PDB это про координацию во время плановых операций.
Connection draining: long-lived connections
HTTP короткие requests — стандартный rolling update работает без особых усилий. Но long-lived connections требуют отдельной обработки:
HTTP/1.1 keep-alive
Если LB переиспользует connection, traffic продолжает идти на старый Pod даже после endpoint removal. Решение — в SIGTERM handler:
// pseudocode
srv.SetKeepAlivesEnabled(false)
// заставляет добавлять "Connection: close" header в next response
// клиент после этого закроет connection
После SIGTERM сервер должен respond Connection: close — LB не reuse этот connection.
gRPC streaming
gRPC streams могут жить минутами/часами. После SIGTERM:
- App шлёт
GOAWAYframe клиенту (HTTP/2 graceful disconnect signal). - Клиент должен reconnect на другой Pod через service mesh / DNS resolver.
- App ждёт active streams завершения (или timeout) до exit.
grpcServer.GracefulStop() // ждёт all RPCs done, потом stop
В production-grade gRPC: GRPC_GO_LOG_VERBOSITY_LEVEL=2, проверять GOAWAY обработку клиентами.
WebSocket
Похоже на gRPC. App должен:
- Шлёт frame “server going down” клиенту.
- Клиент reconnect.
- App закрывает socket после ack или timeout.
terminationGracePeriodSeconds для WebSocket-heavy apps часто 120-300s — чтобы дать всем клиентам reconnect.
Database connection pool
При SIGTERM:
# pseudocode
async def shutdown():
server.stop_accepting()
await server.wait_for_in_flight() # active requests завершатся
await db_pool.close() # ROLLBACK uncommitted, close sockets
await metrics_flush()
sys.exit(0)
Закрыть connection pool до exit. Иначе:
- Активные queries прерваны RST → some DB-серверы (PostgreSQL) logging это как error.
- Connections в pool остаются open на DB-стороне до timeout (60-120s) → лишний резерв БД.
Coordination с внешним Load Balancer
В managed K8s Service типа LoadBalancer создаёт cloud-resource (AWS ELB, GCP LB, Azure LB). Эти LB имеют свой health check, свой TTL, свою propagation latency.
Ловушка: K8s Pod removed instantly из EndpointSlice → kube-proxy убирает iptables → но внешний LB ещё ~30s держит target в healthy pool (он же независимо проверяет каждые 10-30s). Если ваш сервис принимает трафик напрямую от LB (NodePort или target Pod IP) — может быть drop.
Решения:
- AWS ALB ingress controller + IP-mode: target в ELB — Pod IP напрямую, ALB наблюдает за health через K8s readiness probe. Sync быстрее.
- Long terminationGracePeriodSeconds + preStop sleep, чтобы Pod продолжал отвечать пока ELB не deregister.
- Service mesh (Istio, Linkerd) — sidecar обрабатывает graceful shutdown, координирует с control plane.
Полный production-grade шаблон
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 4
minReadySeconds: 20
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
terminationGracePeriodSeconds: 60
containers:
- name: web
image: my-web:v1.2.3
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
periodSeconds: 10
failureThreshold: 5
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 75%
selector:
matchLabels:
app: web
И в коде:
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM)
go srv.ListenAndServe()
<-sigChan
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
srv.Shutdown(ctx)
db.Close()
Killer-моменты
- Zero-downtime = 7 пунктов одновременно. Один пропущенный — клиенты получают 5xx. Это не “one trick”, а system property.
maxUnavailable=0+maxSurge>0— гарантия full capacity всегда. Самый безопасный rolling update. Чуть медленнее.- PDB только для voluntary disruptions (drain). От crash/OOMKill не защищает.
minReadySeconds— страховка от Pod-ов, которые становятся Ready, но сразу падают. 15-30s типично.- External LB имеет свой TTL независимо от K8s. На AWS —
targetType: ip(ALB) обходит часть проблем. - HTTP/1.1 keep-alive требует
Connection: closeпосле SIGTERM, иначе LB reuse dead connection. - gRPC graceful:
GracefulStop()ждёт active RPCs, шлёт GOAWAY клиентам. Без него — abrupt termination.