Деплоишь сервис в три реплики, радуешься отказоустойчивости. Ночью падает один узел - и все три реплики уезжают в перезапуск, потому что kube-scheduler по своей логике поставил их на один и тот же узел. HA на бумаге, ноль HA в реальности. Или другой классический случай: под висит в Pending часами, kubectl describe молчит загадками, а причина - taint на узлах, для которого ты забыл toleration. Или GPU-узел за большие деньги забивается обычными nginx-подами, потому что ничто не мешает им туда сесть.
Все эти истории - про одно: kubernetes планировщик (scheduler) принимает решение, куда поставить под, и это решение можно и нужно направлять. В этом уроке разберём механику до винтика: как работает kubernetes scheduler, что такое affinity kubernetes (node и pod), как устроены taints tolerations, зачем нужен topology spread, и как priorityClass с PodDisruptionBudget спасают тебя при вытеснении и обновлениях. Это не обзор - это то, что ты будешь руками крутить в проде.

Как kube-scheduler выбирает узел: filtering и scoring
Когда ты создаёшь под, он не появляется на узле магически. Сначала под попадает в очередь планировщика с пустым полем
Код: Выделить всё
spec.nodeNameРешение идёт в два этапа.
1. Filtering (фильтрация, она же predicates). Планировщик прогоняет все узлы через набор плагинов-фильтров и отсекает те, куда под поставить нельзя в принципе. Хватит ли на узле CPU и памяти под запросы пода (PodFitsResources)? Совпадает ли nodeSelector? Проходит ли nodeAffinity required-правила? Есть ли у пода toleration на taint узла? Свободен ли нужный порт? На выходе - список узлов-кандидатов. Если он пустой, под остаётся Pending, и это первое место, куда ты смотришь при разборе.
2. Scoring (оценка, она же priorities). Из кандидатов надо выбрать лучший. Каждый узел получает баллы от 0 до 100 по нескольким плагинам: насколько равномерно распределится нагрузка, выполняются ли preferred-правила affinity, как ложатся topology spread constraints, есть ли уже образ контейнера на узле (ImageLocality - чтобы не качать гигабайты заново). Баллы суммируются с весами, узел с максимумом побеждает. При равенстве выбор случайный среди лидеров.
Понимание этих двух фаз снимает 80 процентов вопросов. nodeSelector и required-affinity и taints - это про filtering: жёсткое да/нет. preferred-affinity и topology spread - в основном про scoring: мягкое предпочтение. Когда под Pending - сломалась фильтрация. Когда под сел не туда, куда ты хотел - не дожали scoring.
От простого к сложному: nodeSelector и node affinity
Самый примитивный инструмент - nodeSelector. Просто требуешь у пода метки на узле:
Код: Выделить всё
spec:
nodeSelector:
disktype: ssd
topology.kubernetes.io/zone: ru-central1-a
nodeAffinity - это nodeSelector на стероидах. Два режима, и разница принципиальная:
Код: Выделить всё
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["ru-central1-a", "ru-central1-b"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: node.kubernetes.io/instance-type
operator: In
values: ["standard-v3"]
preferred работает на scoring. Это пожелание с весом от 1 до 100. Нет узлов нужного типа - под всё равно сядет, просто на менее желанный. Вес влияет на итоговый балл узла.
Та самая длинная приписка IgnoredDuringExecution - не украшение. Она означает: правило проверяется только при планировании. Если узел уже после размещения пода поменял метки и перестал подходить - под не выселяется, продолжает работать. Гарантия только на момент посадки.
Pod affinity и anti-affinity: ставим поды рядом или врозь
nodeAffinity привязывает под к свойствам узла. А podAffinity и podAntiAffinity - к расположению других подов. Это ключевой инструмент для HA.
podAntiAffinity - "не ставь меня рядом с такими же". Главный рецепт против "все реплики на одном узле":
Код: Выделить всё
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: api
topologyKey: kubernetes.io/hostname
Грабли с required-anti-affinity: если узлов меньше, чем реплик, лишние реплики навсегда зависнут в Pending. Поэтому на практике для anti-affinity чаще берут preferred - "постарайся развести, но если некуда, ставь вместе":
Код: Выделить всё
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: api
topologyKey: kubernetes.io/hostname
Taints и tolerations: выделенные узлы и почему под висит в Pending
Affinity - это про притяжение со стороны пода. taints tolerations - механика отталкивания со стороны узла. taint вешается на узел и говорит: "не пускай сюда никого, кроме тех, кто явно согласен". Согласие пода - это toleration.
Ставим taint на узел:
Код: Выделить всё
kubectl taint nodes worker-gpu-1 dedicated=gpu:NoSchedule
- NoSchedule - новые поды без toleration не сядут. Уже запущенные остаются.
- PreferNoSchedule - мягкая версия, планировщик старается избегать, но может и поставить.
- NoExecute - не только не пускает новых, но и выселяет уже работающие поды без toleration. Это то, что Kubernetes делает сам, когда узел становится NotReady: вешает node.kubernetes.io/unreachable:NoExecute.
Код: Выделить всё
spec:
tolerations:
- key: "dedicated"
operator: "Equal"
value: "gpu"
effect: "NoSchedule"
Так же работают и control plane узлы: на них висит node-role.kubernetes.io/control-plane:NoSchedule, поэтому твои рабочие поды туда не лезут. А системные компоненты (kube-proxy, CNI-агенты Cilium/Calico) несут tolerations и спокойно там живут.
Диагностика Pending из-за taint. Самая частая загадка прода:
Код: Выделить всё
kubectl describe pod api-7d9f-xk2
...
Events:
Warning FailedScheduling default-scheduler
0/5 nodes are available: 3 node(s) had untolerated taint
{dedicated: gpu}, 2 Insufficient cpu.
Topology spread constraints: равномерно по зонам и узлам
podAntiAffinity умеет только "врозь" в режиме всё-или-ничего. А что если надо "размажь 6 реплик по 3 зонам так, чтобы максимум по 2 в зоне"? Для этого есть topology spread - более тонкий и современный инструмент.
Код: Выделить всё
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: api
minDomains: 3
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: api
- maxSkew - максимально допустимая разница в числе подов между самым загруженным и самым пустым доменом. maxSkew=1 значит "почти идеально ровно".
- topologyKey - по чему размазываем: zone или hostname.
- whenUnsatisfiable - DoNotSchedule (жёстко, как required, под уйдёт в Pending) или ScheduleAnyway (мягко, как preferred, влияет только на scoring).
- minDomains - минимальное число доменов, которое планировщик обязан учитывать. Поле стабильно (GA) с версии 1.30. Зачем оно: без minDomains, если eligible-зон по факту только 2, ограничение расслабляется и пустит 2 реплики в одну зону. С minDomains=3 планировщик считает, что зон должно быть как минимум три, и не даст скучковать поды, когда третья зона временно без узлов.
Есть ещё поле matchLabelKeys (бета, по умолчанию включено с 1.32) - оно добавляет в селектор значение метки самого пода, например pod-template-hash. Польза огромная: при rolling update новые поды считают skew только среди подов своей ревизии, а не вперемешку со старыми, которые вот-вот умрут. Без этого распределение во время выкатки временно перекашивает.
priorityClass, preemption и PodDisruptionBudget
Что делает планировщик, когда важный под некуда поставить - все узлы забиты? Тут включается preemption (вытеснение). Сначала задаём приоритеты:
Код: Выделить всё
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
description: "Критичные сервисы платежей"
Код: Выделить всё
spec.priorityClassName: high-priorityНо бесконтрольное выселение опасно. Тут страхует PodDisruptionBudget (PDB):
Код: Выделить всё
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: api
Типичные грабли и антипаттерны
- Все реплики на одном узле. Нет ни anti-affinity, ни topology spread - и планировщик по scoring вполне законно ставит их кучно. Лечится preferred podAntiAffinity по hostname или topology spread с maxSkew=1.
- required anti-affinity при нехватке узлов. Реплик 5, узлов 3 - две реплики навсегда Pending. На динамичных кластерах с автоскейлингом бери preferred либо topology spread с whenUnsatisfiable: ScheduleAnyway.
- toleration без affinity. Думаешь, что загнал поды на выделенные узлы, а они расползлись по обычным. taint отгоняет чужих, но не притягивает своих - нужна пара с nodeAffinity.
- Pending и непонятно почему. Всегда начинай с kubectl describe pod и строки "0/N nodes are available" - она разложит причину по фильтрам: taint, ресурсы, affinity, volume-зона.
- Забытый PDB. Апгрейд узлов сносит все реплики разом. minAvailable обязателен для любого сервиса с SLA.
- minAvailable равный числу реплик. PDB с minAvailable=3 при 3 репликах заблокирует drain полностью - узел не осушится никогда. Оставляй запас.
Нужен кластер с 3+ узлами (подойдёт kind/minikube с несколькими нодами, k3s или managed-кластер в облаке).
- Создай Deployment nginx на 3 реплики без всяких ограничений. Глянь - запиши, на скольких узлах они сели. Скорее всего скучковались.
Код: Выделить всё
kubectl get pods -o wide - Добавь в spec шаблона пода preferred podAntiAffinity по topologyKey kubernetes.io/hostname (селектор по своей метке app). Передеплой, снова смотри -o wide. Реплики должны разъехаться по узлам.
- Повесь taint на один узел: . Масштабируй деплой до 6 реплик. Убедись, что на этот узел поды не садятся.
Код: Выделить всё
kubectl taint nodes <node> role=infra:NoSchedule - Добавь поду toleration на этот taint - и убедись, что теперь он снова доступен для размещения.
- Замени anti-affinity на topologySpreadConstraints с maxSkew=1 по hostname, whenUnsatisfiable: DoNotSchedule. Масштабируй больше числа узлов и поймай под в Pending. Прочитай причину в kubectl describe.
- Создай PodDisruptionBudget с minAvailable=2, затем выполни kubectl drain на одном узле и понаблюдай, как drain соблюдает бюджет и выселяет поды по одному.
- Чем отличается этап filtering от scoring, и на каком из них работают required-affinity, preferred-affinity и taints?
- Почему под с нужным toleration всё равно может сесть на обычный узел без taint, и как заставить его садиться только на выделенные узлы?
- В каком случае required podAntiAffinity приведёт к вечному Pending, и чем тут лучше topologySpreadConstraints?
- Что произойдёт при kubectl drain, если PDB задан с minAvailable, равным числу реплик?
kube-scheduler фильтрует узлы-кандидаты, затем оценивает их баллами и сажает под на лучший. nodeSelector и nodeAffinity цепляют под к свойствам узла, podAffinity/anti-affinity - к соседям ради HA или локальности, taints с tolerations резервируют узлы под особые задачи, topology spread размазывает реплики ровно по зонам и нодам, а priorityClass с preemption и PodDisruptionBudget управляют тем, кого выселять и кого беречь при обновлениях. Освоишь эту связку - перестанешь ловить упавший сервис из-за того, что три реплики жили на одном узле.