Задача
Deployment на 3 реплики — казалось бы, отказоустойчиво. Во время планового обновления нод (администратор поочерёдно выполняет kubectl drain) сервис полностью недоступен около минуты.
Вопрос: почему три реплики не спасли и что нужно было настроить?
Что кажется очевидным
«Три реплики — значит, две всегда останутся. Наверное, drain выполняли слишком быстро».
Скорость здесь ни при чём. Проблем на самом деле две, и они независимы.
Как это работает на самом деле
Проблема первая: все реплики оказались на одной ноде
Планировщик по умолчанию не обязан разносить поды Deployment по разным нодам. Он ищет ноду, где под помещается, с учётом ресурсов и ограничений. Если на одной ноде есть место для всех трёх — вполне вероятно, что все три там и окажутся.
Проверить фактическое размещение:
kubectl get pods -l app=api -o wide
# NAME READY STATUS NODE
# api-abc-1 1/1 Running node-3
# api-abc-2 1/1 Running node-3
# api-abc-3 1/1 Running node-3 ← все на одной нодеdrain node-3 — и сервиса нет. Три реплики дали защиту от падения процесса, но не от отказа ноды.
Проблема вторая: drain никто не сдерживал
kubectl drain вытесняет поды через Eviction API, и этот API уважает PodDisruptionBudget (PDB). Если PDB не задан, ограничений нет: Kubernetes выселит столько подов, сколько потребуется, и одновременно.
PDB — это заявление «сколько экземпляров должно оставаться доступными при добровольных нарушениях работы».
Важно: PDB защищает только от добровольных нарушений (drain, вытеснение при обновлении узлов, autoscaler). От внезапного отказа ноды или kubectl delete pod он не спасает — там нечего сдерживать.
Ответ
Три реплики — это защита от падения процесса, но не от отказа ноды. Не хватало двух вещей:
- Распределения подов по разным нодам/зонам — чтобы одна нода не уносила весь сервис;
- PodDisruptionBudget — чтобы drain не выселил все реплики разом.
Что с этим делать
1. PodDisruptionBudget
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-pdb
spec:
minAvailable: 2 # или maxUnavailable: 1
selector:
matchLabels:
app: apiТеперь drain заблокируется, если выселение оставит меньше двух доступных подов, — и будет ждать, пока замена поднимется в другом месте.
Грабли, о которых стоит знать:
minAvailableравный числу реплик заблокирует drain навсегда. При 3 репликах иminAvailable: 3ни один под нельзя выселить — обслуживание нод встанет. Оставляйте хотя бы единицу запаса.- Для одной реплики PDB бесполезен.
minAvailable: 1приreplicas: 1намертво блокирует drain. Одна реплика — это осознанный простой при обслуживании. maxUnavailable: 1обычно удобнее: он не ломается при изменении числа реплик.
2. Распределение по нодам и зонам
Современный способ — topologySpreadConstraints:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: api
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: apimaxSkew: 1 означает: разница в числе подов между любыми двумя нодами (зонами) не больше одного.
Обратите внимание на разные значения whenUnsatisfiable:
DoNotSchedule— жёстко: под останетсяPending, если условие не выполнить. Правильно для распределения по хостам.ScheduleAnyway— мягко: планировщик постарается, но при невозможности всё равно разместит. Разумно для зон, чтобы недоступность одной зоны не блокировала выкатку.
Более старый эквивалент — podAntiAffinity. Он всё ещё работает, но topologySpreadConstraints выразительнее и не требует выбирать между «строго по одному на ноду» и «как получится».
3. Сложить вместе
Полный набор для по-настоящему отказоустойчивого сервиса:
- ≥ 3 реплики (при 2 репликах и
maxUnavailable: 1запаса почти нет); - распределение по хостам и зонам;
- PDB с запасом на выселение;
- корректное завершение —
preStopи grace period из задачи 2; - правильные probe из задачи 7, чтобы новый под входил в балансировку только когда готов;
requests/limits, чтобы вытеснение не начиналось с вашего сервиса (см. задачу 3).
Проверка до инцидента
Самый честный способ — прорепетировать:
# Кто где живёт
kubectl get pods -l app=api -o wide
# Проверить, что PDB реально считает
kubectl get pdb api-pdb
# NAME MIN AVAILABLE ALLOWED DISRUPTIONS AGE
# api-pdb 2 1 5m
# Учения: увести ноду под нагрузкой и смотреть на ошибки
kubectl drain node-3 --ignore-daemonsets --delete-emptydir-dataКолонка ALLOWED DISRUPTIONS — самая полезная: 0 означает, что drain сейчас заблокируется.
Дальше: Задача 9. Прожорливый etcd.