kube-scheduler: filtering, scoring, binding
kube-scheduler — отдельный процесс, чья единственная задача: назначить ноду каждому Pod-у, у которого её ещё нет. Это решение не делается жадно — для каждого Pod-а сначала прогоняется фильтр (какие ноды вообще подходят), затем скоринг (какая лучшая). Результат — POST на API server c полем .spec.nodeName. Дальше работает kubelet.
В этом уроке проходим scheduling cycle до уровня плагинов, разбираем разницу между nodeSelector, nodeAffinity и taints/tolerations, и понимаем почему DaemonSet теперь идёт через scheduler.
Что делает scheduler
Очень простая последовательность.
Scheduler работает по одному Pod-у за раз. Это не параллельный планировщик. Параллелизация была бы сложна из-за shared state (“сколько уже забронировано на ноде X в этом цикле”). Один поток процессит десятки Pod-ов в секунду — для большинства кластеров достаточно.
Планировщик ОС: preemptive vs cooperative, time slice
Filtering: какие ноды подходят
Фильтры (исторически они назывались predicates) — это серия плагинов, каждый из которых для пары (pod, node) возвращает true/false. Если хоть один сказал false — нода отбрасывается.
Главные built-in плагины фильтрации:
NodeResourcesFit
Проверяет, достаточно ли свободных CPU, memory, ephemeral-storage и других resources (включая GPU, hugepages) на ноде для requests Pod-а. Если Pod просит 2 CPU, а на ноде свободно 1.5 — нода фильтруется.
NodeAffinity
Проверяет nodeSelector (точное совпадение labels) и nodeAffinity (выражения через operator In, NotIn, Exists, …). Если есть requiredDuringSchedulingIgnoredDuringExecution — нода обязана совпасть, иначе фильтруется.
TaintToleration
Если нода имеет taint с эффектом NoSchedule, и у Pod-а нет соответствующего toleration — нода фильтруется. PreferNoSchedule не фильтрует, а только понижает score.
VolumeBinding / VolumeRestrictions
Проверяет, что все PersistentVolumeClaim Pod-а могут быть привязаны к volume-ам, доступным с этой ноды (учитывая topology — zone, region). Например, EBS volume в us-east-1a недоступен с ноды в us-east-1b.
PodTopologySpread
Проверяет topologySpreadConstraints Pod-а — например, “не более 1 Pod-а из этого Deployment в каждой AZ”. Если на ноде в зоне X уже есть один такой Pod, и max skew = 1 — нода фильтруется.
InterPodAffinity
Проверяет podAffinity и podAntiAffinity — должны ли определённые Pod-ы быть рядом (affinity) или вдали (anti-affinity) от других Pod-ов в определённой topology (например, hostname, zone).
Scoring: выбор лучшего из подходящих
После фильтрации остаётся набор подходящих нод. Scoring плагины присваивают каждой score от 0 до 100. Итоговый score — взвешенная сумма по всем плагинам. Выбирается нода с максимальным score (при равенстве — случайно).
Главные scoring плагины:
- NodeResourcesBalancedAllocation — поощряет ноды, где CPU и memory utilization будут сбалансированы после размещения. Не любит ноды, где CPU остаётся 5%, а memory 80%.
- NodeResourcesFit (scoring mode) — два режима:
LeastAllocated(выше score — меньше используется, classic spreading) илиMostAllocated(выше score — больше используется, bin-packing для cluster autoscaling). - ImageLocality — выше score, если на ноде уже скачан образ Pod-а. Экономит время на pull.
- NodeAffinity (scoring mode) —
preferredDuringSchedulingIgnoredDuringExecutionсуммирует веса всех matched preferences. - InterPodAffinity (scoring mode) —
preferredDuringSchedulingIgnoredDuringExecutionдля pod affinity/anti-affinity. - PodTopologySpread (scoring mode) — поощряет более равномерное распределение.
- TaintToleration (scoring mode) — учёт
PreferNoScheduletaints — нода с такими taints получает penalty.
В v1.35 scheduler-плагины конфигурируются через KubeSchedulerConfiguration. Можно создать профиль с custom набором/весами плагинов, или вообще написать свой scheduler (отдельный процесс с уникальным schedulerName). Pod-ы с .spec.schedulerName: custom будет планировать только этот scheduler.
nodeSelector, nodeAffinity, taints — три разных механизма
Все три влияют на размещение Pod-ов, но логика и направление разные. Запомните различия — на CKAD-экзамене это частая ловушка.
Пример nodeSelector vs nodeAffinity
# nodeSelector — простой, exact match всех labels
apiVersion: v1
kind: Pod
metadata:
name: gpu-job
spec:
nodeSelector:
accelerator: nvidia-a100
zone: us-east-1a
containers:
- name: trainer
image: tensorflow:gpu
---
# nodeAffinity — то же + soft preferences
apiVersion: v1
kind: Pod
metadata:
name: gpu-job-v2
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: accelerator
operator: In
values: ["nvidia-a100", "nvidia-h100"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
preference:
matchExpressions:
- key: zone
operator: In
values: ["us-east-1a"] # предпочтительно
containers:
- name: trainer
image: tensorflow:gpu
Семантика отличается: nodeSelector требует обе label-ы, nodeAffinity принимает любой из A100/H100 (operator In) и предпочитает (но не требует) зону.
Taints и tolerations
Taint — это label-подобная штука на ноде, но со специальной семантикой repulsion.
# Поставить taint на ноду
kubectl taint nodes node-1 dedicated=ml-team:NoSchedule
# Убрать (минус в конце)
kubectl taint nodes node-1 dedicated=ml-team:NoSchedule-
Три эффекта:
- NoSchedule — Pod без toleration не будет запланирован на эту ноду (но существующие Pod-ы остаются).
- PreferNoSchedule — soft вариант, понижает score, не блокирует полностью.
- NoExecute — самый строгий. Pod без toleration не только не планируется — существующие Pod-ы выгоняются с ноды. Используется для аварийных ситуаций (“эта нода нездорова, эвакуируйте всё”).
Built-in taints
Kubernetes сам ставит taints на ноды в специальных ситуациях:
| Taint | Когда |
|---|---|
node.kubernetes.io/not-ready | Нода NotReady (NodeCondition Ready != True) |
node.kubernetes.io/unreachable | NodeController не может связаться с нодой |
node.kubernetes.io/memory-pressure | Низко свободной памяти |
node.kubernetes.io/disk-pressure | Низко свободного диска |
node.kubernetes.io/pid-pressure | Низко свободных PID |
node.kubernetes.io/unschedulable | kubectl cordon поставил |
node.kubernetes.io/network-unavailable | network plugin сообщает проблему |
Все они с эффектом NoSchedule или NoExecute. Если хотите, чтобы ваш критичный Pod (мониторинг, log collector) запускался даже на нездоровых нодах — добавляйте соответствующие tolerations.
spec:
tolerations:
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 300 # терпеть до 5 минут, потом выгнать
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 300
tolerationSeconds — специальное поле для NoExecute: как долго Pod может оставаться на ноде после появления taint, прежде чем будет выгнан.
DaemonSet pods автоматически получают tolerations для большинства built-in taints — иначе log collector упал бы при первой проблеме на ноде. Но это не магия: посмотрите .spec.template.spec.tolerations любого DaemonSet — там целый список.
Pod affinity и anti-affinity
Эти конструкции выражают отношение Pod-а к другим Pod-ам, а не к нодам. Логика: «хочу быть рядом с Pod-ами label app=cache» или «не хочу быть в той же зоне с другим Pod-ом этого же Deployment».
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
template:
metadata:
labels:
app: web
spec:
affinity:
podAntiAffinity: # 3 реплики в РАЗНЫХ зонах
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web
topologyKey: topology.kubernetes.io/zone
podAffinity: # держаться рядом с кешем
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: redis
topologyKey: kubernetes.io/hostname
containers:
- name: web
image: nginx
topologyKey — фундаментальная штука. Это label на ноде, который определяет topology domain. kubernetes.io/hostname — каждая нода — отдельный домен (anti-affinity → разные ноды). topology.kubernetes.io/zone — каждая AZ — отдельный домен (anti-affinity → разные зоны).
topologySpreadConstraints — современный спред
Anti-affinity сложный и плохо масштабируется (O(n²) проверок). С v1.19 появился topologySpreadConstraints — более явный механизм:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # или ScheduleAnyway (soft)
labelSelector:
matchLabels:
app: web
maxSkew: 1 — максимальная разница между «зона с самым большим количеством Pod-ов с этим label» и «зона с самым маленьким». Если у вас 3 зоны и 6 реплик — будет 2/2/2. Если 7 — будет 3/2/2 (skew=1). Если 8 — будет 3/3/2 (skew=1). Если 9 — будет 3/3/3.
Это более предсказуемо, чем anti-affinity, и работает быстрее на больших кластерах.
Bind: финальный шаг
После выбора ноды scheduler делает специальный POST:
POST /api/v1/namespaces/default/pods/nginx-7d4-abc/binding HTTP/2
Content-Type: application/json
{
"apiVersion": "v1",
"kind": "Binding",
"metadata": {
"name": "nginx-7d4-abc",
"namespace": "default"
},
"target": {
"apiVersion": "v1",
"kind": "Node",
"name": "worker-3"
}
}
Apiserver получает Binding, обновляет .spec.nodeName в самом Pod-объекте до worker-3, делает Put в etcd. После этого:
- kubelet на
worker-3через watch-stream видит изменение Pod-а на свою ноду → начинает запускать. - scheduler видит, что Pod больше не unscheduled (есть nodeName), убирает его из своей очереди.
/binding — subresource Pod-а, специально для этого случая. У него своя RBAC verb — bind. Это позволяет custom scheduler-у иметь права делать только bind, без права редактировать Pod-ы вообще.
DaemonSet теперь через scheduler
Исторически (до v1.17) DaemonSet работал в обход scheduler-а: DaemonSet controller сам ставил .spec.nodeName на каждый Pod, как только видел новую ноду. Это было быстрее, но создавало проблемы:
- Не работали taints/tolerations через scheduler-плагины
- Не учитывались resource fits (DaemonSet pod мог попасть на ноду без ресурсов)
- Custom scheduler-логика не применялась
С v1.17 DaemonSet pods идут через обычный scheduler, но DaemonSet controller добавляет к каждому Pod-у specific nodeAffinity, который привязывает его именно к нужной ноде:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchFields:
- key: metadata.name
operator: In
values: ["worker-3"]
Это значит, что scheduler планирует именно эту ноду — но проходит весь pipeline: filtering (есть ли ресурсы?), scoring (плагины), binding. То же самое, что обычные Pod-ы, но с принуждением к конкретной ноде.
Static Pod-ы — другая история. Их kubelet запускает напрямую из манифестов в /etc/kubernetes/manifests/ без участия scheduler-а и api-server-а. Они потом регистрируются в apiserver как “mirror pods”, но scheduling им не нужен — нода уже известна (та, где они физически запущены).
Killer-момент: что видит scheduler в логах
Включите подробные логи scheduler-а (--v=4) и для одного Pod-а увидите что-то вроде:
I0513 09:00:01 scheduler.go:312] Attempting to schedule pod: default/nginx-7d4-abc
I0513 09:00:01 filter.go:64] Plugin NodeResourcesFit: node worker-1 result: passed
I0513 09:00:01 filter.go:64] Plugin NodeResourcesFit: node worker-2 result: rejected (insufficient cpu)
I0513 09:00:01 filter.go:64] Plugin NodeResourcesFit: node worker-3 result: passed
I0513 09:00:01 filter.go:64] Plugin TaintToleration: node worker-1 result: passed
I0513 09:00:01 filter.go:64] Plugin TaintToleration: node worker-3 result: passed
I0513 09:00:01 filter.go:64] Plugin NodeAffinity: node worker-1 result: passed
I0513 09:00:01 filter.go:64] Plugin NodeAffinity: node worker-3 result: passed
I0513 09:00:01 scorer.go:88] Pod nginx-7d4-abc — node worker-1: NodeResourcesBalancedAllocation=78, ImageLocality=0, NodeAffinity=0, total=78
I0513 09:00:01 scorer.go:88] Pod nginx-7d4-abc — node worker-3: NodeResourcesBalancedAllocation=82, ImageLocality=100, NodeAffinity=0, total=82*1+100*1=182
I0513 09:00:01 scheduler.go:421] Pod default/nginx-7d4-abc successfully bound to node worker-3
worker-3 выиграл из-за ImageLocality — образ nginx уже был там скачан, что дало +100. Это типичный кейс — scheduler любит ноды, на которых меньше работы для kubelet-а.