Kubernetes для самых маленьких. Часть 13: Как работает планировщик?
Все части серии «Kubernetes для самых маленьких»Под, созданный Deployment'ом, в первые миллисекунды своей жизни никуда не привязан: в его spec.nodeName пусто, и ни один kubelet его не запускает. Кто-то должен решить, на каком узле ему жить, — это работа kube-scheduler, одного из компонентов control plane из части 2. Сегодня разберём, как он принимает решение: очередь, фильтрация, оценка, привязка; какими полями пода и узла на это решение влиять (nodeSelector, affinity, taints и tolerations, topology spread); что делает планировщик, когда места нет (приоритеты и вытеснение), и как выводить узел на обслуживание, не уронив приложение (cordon, drain и PodDisruptionBudget). Запросы ресурсов, по которым планировщик считает свободное место, — из части 10, метки и селекторы — из части 11. В конце разнесём реплики hello (Deployment из части 7, с части 11 он живёт в неймспейсе demo) по двум воркерам так, чтобы потеря одного узла не уносила всё приложение.
Что делает планировщик и чего не делает
Планировщик — обычный клиент API. На стенде kubeadm он работает статическим подом из /etc/kubernetes/manifests/kube-scheduler.yaml на k8s-master-1, ходит в kube-apiserver с kubeconfig /etc/kubernetes/scheduler.conf и через watch следит за двумя множествами объектов: подами, у которых spec.nodeName пуст, а spec.schedulerName равен его имени (default-scheduler по умолчанию), и всем, что влияет на решение, — узлами, подами на узлах, PVC, PV и StorageClass.
Решение планировщика — одна запись в API: подресурс pods/binding, объект Binding с именем узла. kube-apiserver в ответ записывает spec.nodeName в под. Больше планировщик ничего не делает: он не запускает контейнеры, не связывается с узлами и не перепроверяет решение потом. Дальше работает kubelet на выбранном узле: он видит в watch под со своим именем в spec.nodeName, повторно проверяет, хватает ли ресурсов (это admission на узле: если места на самом деле нет, под получит фазу Failed с причиной OutOfcpu или OutOfmemory), и запускает его через CRI (интерфейс, по которому kubelet управляет containerd).
Отсюда два следствия, которые стоит запомнить сразу:
- решение принимается один раз. Если узел потом перегрузится или появится более подходящий, планировщик под не переселит. Переселение — это удаление пода и создание нового, его делает контроллер (Deployment, вытеснение,
drain), и новый под проходит планирование заново; - если в
spec.nodeNameуже что-то написано, планировщик под не трогает. Этим пользовался DaemonSet в старых версиях и пользуется любой, кто хочет обойти планировщик (подробнее ниже).
В кластере может работать несколько планировщиков одновременно, и под выбирает свой полем schedulerName. Обычно хватает одного с несколькими профилями — о них в конце следующего раздела.
Планировщик в кластере kubeadm один процесс, но control plane может быть отказоустойчивым (серия про Kubernetes на BGP), и тогда экземпляров три. Чтобы два из них не планировали один и тот же под, работает выбор лидера: экземпляры соревнуются за объект Lease kube-system/kube-scheduler, и работает только тот, чьё имя записано в holderIdentity; остальные ждут, когда лидер перестанет продлевать аренду (срок аренды — 15 секунд).
Цикл планирования
┌──────────────────────── очередь ─────────────────────────┐
новый под → │ activeQ готовые к попытке, по приоритету │
│ backoffQ неудачная попытка, ждут 1с, 2с … до 10с │
│ unschedulable ждут события, которое может помочь │
│ gated ждут снятия schedulingGates │
└──────────────────────────────┬───────────────────────────┘
▼
цикл планирования (по одному поду)
PreFilter → Filter → PostFilter какие узлы вообще подходят;
(вытеснение) если ни один — попробовать освободить
PreScore → Score → NormalizeScore какой из подходящих лучше
Reserve → Permit занять ресурсы в своём кэше
│
▼
цикл привязки (параллельно для разных подов)
PreBind → Bind → PostBind дождаться тома, записать Binding
Очередь. Под попадает в activeQ и оттуда выбирается по приоритету (о приоритетах ниже), при равных — по времени создания. Если попытка не удалась, под не крутится в цикле вхолостую: он уходит в unschedulable и ждёт события, которое может изменить результат, — появился узел, удалили под, сняли taint. Какие события полезны конкретному поду, решают сами плагины через QueueingHint (стабильно с 1.34): плагин, отказавший из-за нехватки CPU, скажет, что удаление пода на узле — повод попробовать снова, а изменение меток узла — нет. После такого события под идёт в backoffQ с растущей задержкой и затем снова в activeQ. Раз в пять минут все застрявшие поды в любом случае получают повторную попытку.
Под можно поставить в очередь и запретить планировать полем schedulingGates (стабильно с 1.30): пока список ворот не пуст, под висит в Pending (в kubectl get pods — статус SchedulingGated), лежит в отдельной очереди gated и в цикл не попадает. Ворота задаются только при создании пода, потом их можно лишь снимать; пока они стоят, размещение пода разрешено только сужать — добавить nodeSelector или условия node affinity. Этим пользуются внешние контроллеры, которым нужно что-то подготовить до размещения, например выделить квоту.
Фильтрация (Filter). Для каждого узла плагины отвечают «годится» или «нет, потому что…». Подходит узел, который одобрили все. Основные фильтры:
| Плагин | Что проверяет |
|---|---|
NodeUnschedulable |
узел не помечен spec.unschedulable (kubectl cordon) |
NodeName |
если в поде задан nodeName, это он |
NodeResourcesFit |
сумма requests подов на узле плюс новый под не больше allocatable узла |
NodePorts |
свободен hostPort, если под его просит |
NodeAffinity |
nodeSelector и обязательная node affinity |
TaintToleration |
у пода есть tolerations на все taints узла с эффектами NoSchedule и NoExecute |
InterPodAffinity |
обязательные pod affinity и anti-affinity |
PodTopologySpread |
ограничения распределения с DoNotSchedule |
VolumeBinding, VolumeZone, NodeVolumeLimits |
тома пода можно подключить на этом узле (подробно — в части 21) |
Важно: NodeResourcesFit считает запросы, а не реальное потребление. Узел, на котором поды запросили 1.9 CPU из 2, для планировщика заполнен, даже если загрузка процессора 5%. И наоборот: узел с подами без requests для фильтра выглядит пустым, хотя может задыхаться. Поэтому requests у каждого контейнера — первое условие предсказуемого планирования (часть 10).
На большом кластере проверять все узлы дорого, и планировщик останавливается, набрав достаточно подходящих: доля percentageOfNodesToScore по умолчанию вычисляется от размера кластера — 50% на сотне узлов, 10% на пяти тысячах и не меньше 5%, — но проверяется не меньше 100 узлов. На кластерах до сотни узлов и тем более на трёх узлах стенда проверяются все.
PostFilter. Если подходящих узлов ноль, вызывается PostFilter — у планировщика по умолчанию это плагин DefaultPreemption, вытеснение. О нём ниже.
Оценка (Score). Каждый плагин оценивает подходящие узлы от 0 до 100, оценки умножаются на вес плагина и складываются. Побеждает узел с наибольшей суммой, при равенстве — случайный из лучших. Примеры критериев:
| Плагин | Предпочитает |
|---|---|
NodeResourcesFit |
по умолчанию стратегия LeastAllocated — узел с наибольшей долей свободных запрашиваемых ресурсов; MostAllocated — наоборот, плотная упаковка |
NodeResourcesBalancedAllocation |
узел, где после размещения CPU и память будут заняты в равной доле |
ImageLocality |
узел, где образ уже скачан |
NodeAffinity, InterPodAffinity |
узлы, подходящие под мягкие (preferred) правила, с учётом их весов |
PodTopologySpread |
узлы, где размещение меньше нарушит равномерность (ScheduleAnyway) |
TaintToleration |
узлы с меньшим числом taints PreferNoSchedule, которые под не терпит |
Веса по умолчанию в 1.36: TaintToleration — 3; NodeAffinity, PodTopologySpread, InterPodAffinity и DynamicResources — 2; NodeResourcesFit, NodeResourcesBalancedAllocation и ImageLocality — 1. DynamicResources отвечает за поды, которые просят устройства через ResourceClaim (DRA, динамическое выделение устройств вроде GPU), остальным подам он ставит всем узлам ноль. И одна тонкость оценки: в отличие от фильтра, при оценке NodeResourcesFit контейнер без requests считается так, будто запросил 100m CPU и 200 MiB памяти, — иначе узел, набитый подами без запросов, выглядел бы самым свободным.
Reserve и Permit. Выбрав узел, планировщик сразу учитывает под в своём кэше как размещённый (assume) — чтобы следующий под из очереди уже видел занятые ресурсы, не дожидаясь, пока запись дойдёт до API и вернётся через watch. Permit может придержать под (используется для группового планирования, когда несколько подов должны стартовать вместе или не стартовать вовсе).
Привязка. Цикл привязки идёт асинхронно: пока для одного пода VolumeBinding ждёт, пока PV привяжется к PVC, планировщик уже обрабатывает следующий. Плагин DefaultBinder создаёт Binding. Если привязка не удалась, резерв в кэше откатывается и под возвращается в очередь.
Плагины и профили
Всё перечисленное — плагины фреймворка планирования: точки расширения (PreFilter, Filter, Score и т. д.) фиксированы, а реализация каждой — набор плагинов, вкомпилированных в kube-scheduler или в собственную сборку. Набор, веса и параметры плагинов задаются конфигурацией:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
clientConnection:
kubeconfig: /etc/kubernetes/scheduler.conf
profiles:
- schedulerName: default-scheduler
- schedulerName: bin-packing
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: MostAllocated
resources:
- name: cpu
weight: 1
- name: memory
weight: 1
- name: PodTopologySpread
args:
defaultingType: List
Это два профиля в одном процессе: поды с schedulerName: bin-packing планируются со стратегией плотной упаковки (удобно для пакетных задач, чтобы освобождать целые узлы), остальные — как обычно. Вторая запись в pluginConfig нужна, чтобы упаковка действительно работала: без неё в профиле остаются мягкие ограничения распределения по умолчанию (о них в разделе про topology spread), у PodTopologySpread вес 2 против 1 у NodeResourcesFit, и на стенде три пода bin-packing легли два на один узел и один на другой. defaultingType: List без списка ограничений выключает эти умолчания для профиля, и тогда все три пода ложатся на один узел (вывод — в «Смотрим руками»).
Файл передаётся kube-scheduler флагом --config; на kubeadm это правка статического манифеста и монтирование файла в под. Чтобы kubeadm upgrade такую правку не потерял, её надо внести и в ClusterConfiguration: в kubeadm.k8s.io/v1beta4 это scheduler.extraArgs — список пар name/value, например name: config, value: /etc/kubernetes/scheduler-config.yaml, — и scheduler.extraVolumes с самим файлом. На уже созданном кластере ClusterConfiguration хранится в ConfigMap kubeadm-config неймспейса kube-system; у стенда там scheduler: {}.
Менять профиль по умолчанию нужно редко. Почти всегда достаточно полей в самом поде, к которым и переходим.
nodeName и nodeSelector
nodeName — прямое указание узла:
spec:
nodeName: k8s-node-2
Под с заполненным nodeName планировщик не видит вовсе: kubelet на k8s-node-2 подхватит его сразу. Не проверяются ни taints, ни affinity, а ресурсы — только kubelet'ом при запуске, и при нехватке под сразу получит фазу Failed с причиной OutOfcpu вместо того, чтобы ждать места в Pending (вывод — в «Смотрим руками»). Для приложений это плохой инструмент: имя узла зашито в манифест, и при замене узла манифест сломается. Его место — отладка и ситуации, когда планировщик сам недоступен.
nodeSelector — словарь меток, которые должны быть у узла все сразу:
spec:
nodeSelector:
kubernetes.io/os: linux
disktype: ssd
Метки узлам ставит kubelet (kubernetes.io/hostname, kubernetes.io/os, kubernetes.io/arch, на облаке — topology.kubernetes.io/zone и region) и администратор:
$ kubectl label node k8s-node-1 disktype=ssd
node/k8s-node-1 labeled
Метки с префиксом node-role.kubernetes.io/ kubelet ставить самому себе не может. Это ограничение плагина допуска (admission plugin — модуль kube-apiserver, который проверяет или дополняет каждый объект перед записью в etcd) NodeRestriction, который не даёт скомпрометированному узлу объявить себя control plane и притянуть чувствительные поды. Но остальные метки вне префиксов kubernetes.io/ и k8s.io/ — и disktype, и example.com/gpu — kubelet на своём узле менять может. Поэтому метки, по которым на узел попадают особые или чувствительные нагрузки, лучше заводить с префиксом node-restriction.kubernetes.io/, который NodeRestriction отдаёт только администратору, например example.com.node-restriction.kubernetes.io/gpu=true.
Node affinity
nodeSelector умеет только «все метки равны». Node affinity — то же самое, но с операторами и с мягкими правилами:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: disktype
operator: In
values: ["ssd"]
required…— фильтр: узел без подходящих меток исключается. НесколькоnodeSelectorTermsобъединяются через ИЛИ, выражения внутри одного терма — через И.preferred…— оценка: узлы, подходящие под правило, получают вес (1–100) к сумме. Под может оказаться и на узле без SSD, если на SSD-узлах нет места.- Операторы:
In,NotIn,Exists,DoesNotExist,Gt,Lt(два последних сравнивают значение метки как целое число).NotInиDoesNotExistдают анти-привязку к узлам. IgnoredDuringExecutionв названии — честное предупреждение: если метку с узла снять, уже работающие поды останутся там. Правило действует только в момент планирования.
Если в поде заданы и nodeSelector, и nodeAffinity, должны выполниться оба.
Pod affinity и anti-affinity
Иногда важно не то, какой узел, а кто на нём уже живёт: кэш ставить рядом с приложением, две реплики базы — подальше друг от друга. Для этого правила формулируются относительно других подов:
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: hello
topologyKey: kubernetes.io/hostname
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
podAffinityTerm:
labelSelector:
matchLabels:
app: redis
topologyKey: kubernetes.io/hostname
Ключевое поле — topologyKey: метка узла, которая делит узлы на домены топологии. Узлы с одинаковым значением метки считаются одним доменом. С kubernetes.io/hostname каждый узел — свой домен, и правило читается «не на том же узле, где уже есть под app.kubernetes.io/name=hello». С topology.kubernetes.io/zone домен — зона, и то же правило значит «не в той же зоне». На стенде зон нет, но их можно смоделировать собственной меткой, например стойками: kubectl label node k8s-node-1 topology.kubernetes.io/zone=rack-a.
Как это работает при проверке узла-кандидата: планировщик берёт значение topologyKey у кандидата, находит все узлы с тем же значением и смотрит, есть ли на них поды, подходящие под labelSelector (по умолчанию — в том же неймспейсе, иначе — namespaces или namespaceSelector). Для affinity такие поды нужны, для anti-affinity — недопустимы.
Три предупреждения:
- Обязательная anti-affinity по hostname ограничивает число реплик числом узлов. Третья реплика с правилом выше на двух воркерах навсегда останется в
Pending(именно это покажет Deploymentantiв разделе проPending). Для веб-приложений обычно правильнее мягкое правило или topology spread (следующий раздел). - Это дорого. Чтобы проверить один узел, планировщику приходится смотреть на поды всех узлов того же домена, а при изменении любого пода пересчитывать правила. Документация не рекомендует pod affinity и anti-affinity в кластерах больше нескольких сотен узлов.
- Селектор должен различать версии, если это важно. При rolling update старые и новые поды Deployment'а имеют одну метку
app.kubernetes.io/name: hello. ПолеmatchLabelKeys: ["pod-template-hash"](стабильно с 1.33) добавляет к селектору значение метки самого пода, и правило будет применяться только к подам той же ревизии.
Taints и tolerations
Affinity — это под, который тянется к узлам. Taint — наоборот, узел, который отталкивает поды. Taint — запись в spec.taints узла из ключа, значения и эффекта; toleration в поде говорит «этот taint меня не смущает».
$ kubectl taint node k8s-node-2 dedicated=batch:NoSchedule
node/k8s-node-2 tainted
spec:
tolerations:
- key: dedicated
operator: Equal
value: batch
effect: NoSchedule
| Эффект | Новые поды без toleration | Уже работающие поды без toleration |
|---|---|---|
NoSchedule |
не размещаются | остаются |
PreferNoSchedule |
размещаются, только если больше некуда | остаются |
NoExecute |
не размещаются | выселяются (сразу или через tolerationSeconds) |
Оператор Equal требует совпадения значения, Exists — только ключа. Toleration с пустым ключом и operator: Exists терпит вообще всё — так делают системные DaemonSet'ы (CNI, сбор логов), которые обязаны работать на каждом узле. Для приложений это плохая практика: такой под окажется и на control plane, и на узле, который выводят из строя. (В 1.35 появились числовые операторы Gt и Lt для tolerations, но это альфа-функция, выключенная по умолчанию и в 1.36.)
Главное, что надо понимать: toleration разрешает, но не притягивает. Под с toleration на dedicated=batch может оказаться на любом узле: на стенде с этим taint на k8s-node-2 четыре пода без toleration встали на k8s-node-1, а четыре пода с toleration разошлись по обоим узлам. Чтобы выделить узлы под нагрузку, нужны оба механизма: taint не пускает чужих, node affinity (или nodeSelector) приводит своих.
Taint control plane
kubeadm при init ставит на мастер taint:
$ kubectl describe node k8s-master-1 | grep -A1 Taints
Taints: node-role.kubernetes.io/control-plane:NoSchedule
Unschedulable: false
Поэтому поды приложений на k8s-master-1 не попадают, а CoreDNS, kube-proxy и calico-node — попадают: у них есть соответствующие tolerations. Снимать этот taint (kubectl taint node k8s-master-1 node-role.kubernetes.io/control-plane:NoSchedule-, с минусом на конце) стоит только в однонодовом учебном кластере. В остальных случаях control plane должен оставаться без приложений: под, съевший память, может довести до OOM kube-apiserver или etcd, и пострадает весь кластер.
Taints от node lifecycle controller
Часть taints ставит сам Kubernetes. Node lifecycle controller в kube-controller-manager раз в 5 секунд (--node-monitor-period) проверяет, когда узел последний раз отчитывался. kubelet отчитывается двумя способами: продлевает свой Lease в неймспейсе kube-node-lease (каждые 10 секунд) и обновляет status.conditions узла. Если отчётов нет дольше --node-monitor-grace-period (по умолчанию 50 секунд), контроллер выставляет условию Ready значение Unknown и вешает на узел taints:
| Taint | Когда |
|---|---|
node.kubernetes.io/not-ready |
kubelet сообщает Ready=False (например, не работает сетевой плагин CNI или containerd) |
node.kubernetes.io/unreachable |
от kubelet нет вестей, Ready=Unknown |
node.kubernetes.io/unschedulable |
узел закордонен |
node.kubernetes.io/memory-pressure, disk-pressure, pid-pressure |
kubelet сообщает о нехватке ресурса |
node.kubernetes.io/network-unavailable |
сеть узла не настроена |
Taint unschedulable тоже ставит контроллер, а не kubectl: сразу после kubectl cordon поле spec.taints ещё пустое, taint с отметкой timeAdded появляется мгновением позже и так же с задержкой исчезает после uncordon.
not-ready и unreachable ставятся с двумя эффектами: NoSchedule (новые поды не идут) и NoExecute (старые выселяются). А чтобы кратковременное пропадание узла — перезагрузка, моргнувший коммутатор — не вызывало массового переезда, плагин допуска DefaultTolerationSeconds добавляет каждому поду, у которого нет своих, две tolerations:
$ kubectl -n demo get pod hello-6d5c6db6b4-247xb -o jsonpath='{.spec.tolerations}' | jq
[
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
]
Итого от падения узла до удаления его подов проходит около 50 + 300 секунд — почти шесть минут. Только после этого ReplicaSet увидит, что подов меньше, чем нужно, и создаст замену на живых узлах. Это значение по умолчанию (флаги kube-apiserver --default-not-ready-toleration-seconds и --default-unreachable-toleration-seconds) — компромисс; для stateless-приложения с несколькими репликами его можно уменьшить в шаблоне пода (например, до 30–60 секунд), для пода со своим томом на узле — увеличить. DaemonSet получает эти tolerations без tolerationSeconds: его поды с узла не выселяются никогда, потому что им некуда переезжать.
Отдельно о StatefulSet: под на недоступном узле контроллер не пересоздаст автоматически, пока старый не удалён окончательно, иначе можно получить два экземпляра с одним томом (часть 8).
Topology spread constraints
Anti-affinity говорит «не больше одного на домен». Чаще нужно другое: «примерно поровну по доменам». Для этого есть topologySpreadConstraints:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: hello
matchLabelKeys:
- pod-template-hash
maxSkew— допустимый перекос: разница между числом подходящих подов в самом заполненном и самом пустом домене после размещения. С 1 на двух узлах четыре реплики лягут 2 + 2, пять — 3 + 2.topologyKey— как и в affinity, метка, задающая домены.whenUnsatisfiable—DoNotSchedule(фильтр: если размещение нарушит перекос, узел не подходит) илиScheduleAnyway(оценка: предпочесть узлы, где перекос меньше).labelSelector— какие поды считать. Обычно — поды того же приложения.matchLabelKeys— как в affinity, ключи меток, значения которых берутся из самого пода (здесь — бета, включена по умолчанию). Сpod-template-hashстарая и новая ревизии считаются отдельно, и rolling update не упирается в перекос, созданный уходящими подами.minDomains— минимальное число доменов (стабильно с 1.30, только сDoNotSchedule): если доменов с подходящими узлами меньше, недостающие считаются пустыми, и поды не набьются в имеющиеся.nodeAffinityPolicyиnodeTaintsPolicy(стабильно с 1.33) — учитывать ли при подсчёте доменов узлы, куда под всё равно не может попасть из-за affinity (Honorпо умолчанию) или taints (Ignoreпо умолчанию). ЗначениеHonorдля taints полезно: иначе control plane с его taint считается доменом с нулём подов, и перекос, посчитанный от него, не даст разместить реплики на воркерах — на стенде это видно ниже.
Если у пода нет своих ограничений, а сам он принадлежит Service, ReplicaSet, StatefulSet или ReplicationController, планировщик применяет к нему ограничения по умолчанию: мягкое распределение по kubernetes.io/hostname с maxSkew: 3 и по topology.kubernetes.io/zone с maxSkew: 5, с селектором, взятым из этого сервиса или контроллера. Это объясняет, почему реплики Deployment'а обычно и так расходятся по узлам, — но именно «обычно»: ограничение мягкое, при maxSkew: 3 расклад 4 + 0 для него допустим, а узел с уже скачанным образом и свободными ресурсами может перевесить.
Приоритеты и вытеснение
Когда кластер заполнен, кто-то должен остаться без места. PriorityClass задаёт, кто важнее:
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: business-critical
value: 100000
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: "Пользовательские сервисы, ради которых можно вытеснить фоновые задачи"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: batch-low
value: 1000
preemptionPolicy: Never
description: "Фоновые задачи: ждут места, никого не вытесняют"
Под ссылается на класс полем priorityClassName, и при создании плагин допуска Priority записывает число в spec.priority (под с несуществующим классом отклоняется). Пользовательские классы — до одного миллиарда; выше лежат два встроенных: system-cluster-critical (2 000 000 000, у CoreDNS и calico-typha) и system-node-critical (2 000 001 000, у kube-proxy, calico-node и статических подов control plane). Класс с globalDefault: true применяется к подам без priorityClassName, остальные получают 0.
Приоритет работает в двух местах:
- Очередь. Под с большим приоритетом выбирается из
activeQраньше. - Вытеснение (preemption). Если для пода не нашлось узла, PostFilter-плагин
DefaultPreemptionищет узел, на котором удаление подов с меньшим приоритетом освободит достаточно места. Из кандидатов выбирается тот, где жертв меньше и их приоритет ниже, и где по возможности не нарушается PodDisruptionBudget. Жертвы удаляются штатно (сterminationGracePeriodSecondsи событиемPreempted), а вытесняющему поду записываетсяstatus.nominatedNodeName— узел зарезервирован за ним, и планировщик учитывает это при размещении других подов. Освободится место — под пройдёт обычный цикл и, скорее всего, встанет туда;nominatedNodeNameпри этом очищается.
preemptionPolicy: Never — класс, которому приоритет нужен только для порядка в очереди: фоновая задача пойдёт перед другими фоновыми, но работающих не тронет.
Вытеснение учитывает PDB по возможности, но не гарантирует: если другого способа нет, бюджет будет нарушен. И ещё одна ловушка: если раздать высокий приоритет «на всякий случай» всем приложениям, вытеснение перестанет что-либо значить. Классов должно быть мало, два-три, и каждый — с внятным описанием, кому он положен; выдачу высоких классов можно ограничить квотой — ResourceQuota из части 10 со scopeSelector по PriorityClass. Как это выглядит на стенде — в «Смотрим руками».
Обслуживание узла: cordon, drain, PodDisruptionBudget
Узлы приходится выводить из работы: обновить ядро, поменять диск, обновить Kubernetes (часть 35). Три команды:
kubectl cordon— ставит узлуspec.unschedulable: true. Новые поды туда не планируются (фильтрNodeUnschedulableи taintnode.kubernetes.io/unschedulable:NoSchedule), работающие продолжают работать.kubectl drain— cordon плюс выселение всех подов с узла.kubectl uncordon— снимаетunschedulable. Обратно поды сами не вернутся: планировщик ничего не переселяет, новые поды просто снова смогут попасть на узел.
drain не удаляет поды напрямую, а для каждого вызывает Eviction API — создаёт подресурс pods/eviction. Разница принципиальная: перед тем как удалить под, kube-apiserver проверяет все PodDisruptionBudget, которые этот под выбирают. Если выселение нарушит бюджет, API отвечает 429 Too Many Requests, и drain повторяет попытку каждые 5 секунд, пока контроллер не поднимет замену где-то ещё.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: hello
namespace: demo
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: hello
unhealthyPodEvictionPolicy: AlwaysAllow
minAvailableилиmaxUnavailable(одно из двух, число или процент; проценты округляются вверх) — сколько подов должно оставаться готовыми или сколько может отсутствовать.selector— какие поды защищает бюджет. Должен совпадать с подами одного контроллера; если на один под смотрят несколько бюджетов, Eviction API отвечает ошибкой 500, и выселить его невозможно.unhealthyPodEvictionPolicy(стабильно с 1.31):IfHealthyBudget(по умолчанию) разрешает выселять неготовые поды, только если бюджет и так соблюдён;AlwaysAllow— выселять неготовые поды всегда. Второе почти всегда правильнее: под вCrashLoopBackOffне обслуживает трафик, а со значением по умолчанию он может навсегда заблокироватьdrain.
Контроллер disruption в kube-controller-manager постоянно пересчитывает статус бюджета:
$ kubectl -n demo get pdb hello
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
hello N/A 1 1 3s
ALLOWED DISRUPTIONS — сколько подов можно выселить прямо сейчас (status.disruptionsAllowed). Ноль означает, что drain будет ждать. maxUnavailable для Deployment'а удобнее minAvailable: бюджет не надо пересчитывать при изменении числа реплик, и при масштабировании до одной реплики он не заблокирует обслуживание навсегда.
PDB защищает только от добровольных нарушений — тех, что идут через Eviction API: drain, автомасштабирование узлов, вытеснение. От падения узла, OOM, kubectl delete pod и от обновления самого Deployment'а (у него свой maxUnavailable) PDB не защищает.
Типичные ошибки:
minAvailable: 100%илиmaxUnavailable: 0—drainникогда не завершится. Для одиночного экземпляра так делают сознательно, если перед выключением нужна ручная процедура, но тогда это часть регламента, а не значение «на всякий случай».- PDB на Deployment с одной репликой и
minAvailable: 1— то же самое. Либо две реплики, либо без PDB. - Бюджет, который выполняется только при всех репликах на одном узле.
minAvailable: 2при трёх репликах, лежащих на одном узле, —drainвыселит одну, вторая будет ждать, пока первая поднимется на другом узле. Это корректно, но медленно; распределение по узлам (следующий раздел) делает drain быстрым.
Разносим hello по двум воркерам
К этому моменту в demo работают три реплики hello из части 11. Увеличим их до четырёх и посмотрим, куда они легли:
$ kubectl -n demo scale deployment hello --replicas=4
deployment.apps/hello scaled
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-6d5c6db6b4-247xb 1/1 Running 0 8s 172.20.211.12 k8s-node-1 <none> <none>
hello-6d5c6db6b4-2czsx 1/1 Running 0 25s 172.20.172.34 k8s-node-2 <none> <none>
hello-6d5c6db6b4-ntb9b 1/1 Running 0 25s 172.20.211.7 k8s-node-1 <none> <none>
hello-6d5c6db6b4-ssg42 1/1 Running 0 25s 172.20.172.29 k8s-node-2 <none> <none>
Два и два: на пустых воркерах LeastAllocated и мягкое распределение по умолчанию разводят поды поровну. Но это мягкие правила и решение один раз. Посмотрим, что останется после обычного обслуживания: на время перезапуска подов k8s-node-1 закрыт для планирования.
$ kubectl cordon k8s-node-1
node/k8s-node-1 cordoned
$ kubectl -n demo rollout restart deployment hello
deployment.apps/hello restarted
$ kubectl -n demo rollout status deployment hello
...
deployment "hello" successfully rolled out
$ kubectl uncordon k8s-node-1
node/k8s-node-1 uncordoned
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-6d5c6db6b4-ntb9b 1/1 Terminating 0 67s 172.20.211.7 k8s-node-1 <none> <none>
hello-6d95689dbb-gfld8 1/1 Running 0 13s 172.20.172.38 k8s-node-2 <none> <none>
hello-6d95689dbb-ng8hq 1/1 Running 0 22s 172.20.172.33 k8s-node-2 <none> <none>
hello-6d95689dbb-p86qx 1/1 Running 0 23s 172.20.172.35 k8s-node-2 <none> <none>
hello-6d95689dbb-z9jv5 1/1 Running 0 9s 172.20.172.36 k8s-node-2 <none> <none>
Все четыре новых пода на k8s-node-2, последний старый дозавершается, и после uncordon ничего не изменится: maxSkew: 3 из умолчаний такой расклад разрешает. Потеря k8s-node-2 теперь унесёт всё приложение. Добавим в шаблон пода жёсткое распределение по узлам, мягкую anti-affinity как дополнительный аргумент и уменьшенное время ожидания недоступного узла (метки — те же, что в части 11):
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello
namespace: demo
labels: &labels
app.kubernetes.io/name: hello
app.kubernetes.io/instance: hello
app.kubernetes.io/version: "1.0"
app.kubernetes.io/component: web
app.kubernetes.io/part-of: k8s-for-beginners
app.kubernetes.io/managed-by: kubectl
annotations:
example.com/source: https://git.example.com/demo/hello
spec:
replicas: 4
selector:
matchLabels:
app.kubernetes.io/name: hello
app.kubernetes.io/instance: hello
template:
metadata:
labels: *labels
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
nodeTaintsPolicy: Honor
labelSelector:
matchLabels:
app.kubernetes.io/name: hello
matchLabelKeys:
- pod-template-hash
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: hello
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 60
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 60
containers:
- name: hello
image: ghcr.io/example/hello:1.0
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
memory: 64Mi
readinessProbe:
httpGet:
path: /readyz
port: http
nodeTaintsPolicy: Honor здесь не украшение. Сначала применим тот же манифест без этой строки (файл hello-ignore.yaml):
$ kubectl apply -f hello-ignore.yaml
deployment.apps/hello configured
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-6d95689dbb-p86qx 1/1 Running 0 68s 172.20.172.35 k8s-node-2 <none> <none>
hello-7c4f947b68-5xngf 0/1 Pending 0 14s <none> <none> <none> <none>
hello-7c4f947b68-j5dq7 1/1 Running 0 24s 172.20.172.45 k8s-node-2 <none> <none>
hello-7c4f947b68-t2hlc 1/1 Running 0 25s 172.20.211.13 k8s-node-1 <none> <none>
hello-7c4f947b68-xslgl 0/1 Pending 0 17s <none> <none> <none> <none>
$ kubectl -n demo describe pod hello-7c4f947b68-5xngf
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 14s default-scheduler 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match pod topology spread constraints. no new claims to deallocate, preemption: 0/3 nodes are available: 1 Preemption is not helpful for scheduling, 2 No preemption victims found for incoming pod.
Выкатка застряла: со значением по умолчанию Ignore k8s-master-1 считается третьим доменом с нулём подов hello, и при maxSkew: 1 на каждый воркер можно положить только по одному поду новой ревизии. Два пода новой ревизии ждут в Pending, а последний старый остаётся, потому что maxUnavailable Deployment'а не даёт опуститься ниже трёх готовых реплик. Применяем манифест целиком:
$ kubectl apply -f hello-deployment.yaml
deployment.apps/hello configured
$ kubectl -n demo rollout status deployment hello
...
deployment "hello" successfully rolled out
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-67479df8df-9dbfx 1/1 Running 0 36s 172.20.211.14 k8s-node-1 <none> <none>
hello-67479df8df-9qg9b 1/1 Running 0 36s 172.20.172.46 k8s-node-2 <none> <none>
hello-67479df8df-c2l78 1/1 Running 0 28s 172.20.211.3 k8s-node-1 <none> <none>
hello-67479df8df-q4c8v 1/1 Running 0 25s 172.20.172.39 k8s-node-2 <none> <none>
Два и два, и теперь это правило, а не случай. Добавим PDB из предыдущего раздела и выведем k8s-node-1 на обслуживание:
$ kubectl apply -f hello-pdb.yaml
poddisruptionbudget.policy/hello created
$ kubectl drain k8s-node-1 --ignore-daemonsets --delete-emptydir-data
node/k8s-node-1 cordoned
Warning: ignoring DaemonSet-managed Pods: calico-system/calico-node-6d2qb, calico-system/csi-node-driver-q6t25, kube-system/kube-proxy-xmjz2
evicting pod demo/hello-67479df8df-c2l78
evicting pod calico-system/calico-typha-55d594d885-hxzs8
evicting pod demo/hello-67479df8df-9dbfx
error when evicting pods/"hello-67479df8df-9dbfx" -n "demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/calico-typha-55d594d885-hxzs8 evicted
evicting pod demo/hello-67479df8df-9dbfx
error when evicting pods/"hello-67479df8df-9dbfx" -n "demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/hello-67479df8df-c2l78 evicted
evicting pod demo/hello-67479df8df-9dbfx
pod/hello-67479df8df-9dbfx evicted
node/k8s-node-1 drained
Ход событий: drain закордонил узел и начал выселять все поды разом. Первое выселение hello прошло (бюджет разрешал одно), второе в тот же момент — нет. Через 5 секунд — снова нет: замена первого пода ещё не прошла readiness-пробу, и без второго готовых подов осталось бы два из четырёх. Строка pod/… evicted печатается, когда под действительно исчез, то есть после его штатного завершения. Третья попытка прошла, и приложение всё это время обслуживало запросы минимум тремя репликами. Заодно с узла уехала реплика calico-typha — у неё свой PDB, а Tigera operator на время cordon ещё и уменьшает число её реплик (часть 8).
Флаги: --ignore-daemonsets — поды DaemonSet не выселять (контроллер тут же создал бы их снова на том же узле, потому что они терпят unschedulable); --delete-emptydir-data — согласие потерять содержимое томов emptyDir. Поды, которыми не управляет ни один контроллер, drain без --force выселять откажется: их никто не пересоздаст.
А как же распределение DoNotSchedule с maxSkew: 1, если на k8s-node-1 ставить нельзя? Здесь снова помогает nodeTaintsPolicy: Honor: закордоненный узел несёт taint node.kubernetes.io/unschedulable, которого поды hello не терпят, поэтому он исключается из подсчёта доменов, остаётся один домен, и все четыре пода законно оказываются на k8s-node-2. Со значением Ignore замены застряли бы в Pending, как в опыте выше, а drain ждал бы вечно. Возвращаем узел:
$ kubectl uncordon k8s-node-1
node/k8s-node-1 uncordoned
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-67479df8df-9qg9b 1/1 Running 0 65s 172.20.172.46 k8s-node-2 <none> <none>
hello-67479df8df-c7n4b 1/1 Running 0 13s 172.20.172.37 k8s-node-2 <none> <none>
hello-67479df8df-q4c8v 1/1 Running 0 54s 172.20.172.39 k8s-node-2 <none> <none>
hello-67479df8df-z5r8f 1/1 Running 0 24s 172.20.172.42 k8s-node-2 <none> <none>
Поды сами не вернулись. Чтобы распределить их снова, достаточно перезапустить Deployment: новые поды лягут по правилу.
$ kubectl -n demo rollout restart deployment hello
deployment.apps/hello restarted
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-6df567dbbb-7vqns 1/1 Running 0 20s 172.20.211.16 k8s-node-1 <none> <none>
hello-6df567dbbb-jvgh5 1/1 Running 0 17s 172.20.172.44 k8s-node-2 <none> <none>
hello-6df567dbbb-p4rc5 1/1 Running 0 29s 172.20.211.15 k8s-node-1 <none> <none>
hello-6df567dbbb-s2mf9 1/1 Running 0 28s 172.20.172.40 k8s-node-2 <none> <none>
Для автоматического перебалансирования существует отдельный проект descheduler — контроллер, который по заданным политикам выселяет поды, нарушающие распределение или affinity, чтобы планировщик разместил их заново; в стандартный кластер он не входит.
Pending и события
Когда разместить под невозможно, планировщик не молчит. Он пишет причину в условие пода и событие с разбором по узлам. Создадим отдельный Deployment anti из трёх реплик nginx:1.29 с обязательной anti-affinity по kubernetes.io/hostname на собственные поды (метка app: anti): две реплики встанут на воркеры, третьей места нет.
$ kubectl -n demo get pods -l app=anti -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
anti-79d5d64c98-9rmgd 0/1 Pending 0 20s <none> <none> <none> <none>
anti-79d5d64c98-jszc8 1/1 Running 0 20s 172.20.172.47 k8s-node-2 <none> <none>
anti-79d5d64c98-zlmnb 1/1 Running 0 20s 172.20.211.18 k8s-node-1 <none> <none>
$ kubectl -n demo describe pod anti-79d5d64c98-9rmgd
...
Conditions:
Type Status
PodScheduled False
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 20s default-scheduler 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match pod anti-affinity rules. no new claims to deallocate, preemption: 0/3 nodes are available: 1 Preemption is not helpful for scheduling, 2 No preemption victims found for incoming pod.
Сообщение читается как сумма отказов фильтров по всем узлам: мастер отказал из-за taint (имя taint в 1.36 в сообщении не печатается), оба воркера — из-за anti-affinity. Фраза no new claims to deallocate относится к DRA: освобождать нечего. Вторая часть — результат попытки вытеснения: на мастере оно бесполезно (taint никуда не денется), на воркерах нет жертв, удаление которых помогло бы (под с тем же приоритетом вытеснять нельзя). Тот же текст лежит в условии пода PodScheduled с причиной Unschedulable. Типичные формулировки:
| Фрагмент сообщения | Причина |
|---|---|
Insufficient cpu, Insufficient memory |
requests не помещаются в allocatable за вычетом запросов других подов |
had untolerated taint(s) |
нет toleration на taint узла |
didn't match Pod's node affinity/selector |
nodeSelector или required node affinity |
didn't match pod anti-affinity rules, didn't match pod affinity rules |
правила pod affinity |
didn't match pod topology spread constraints |
DoNotSchedule с нарушением maxSkew |
node(s) were unschedulable |
узел закордонен |
didn't have free ports for the requested pod ports |
занят hostPort |
pod has unbound immediate PersistentVolumeClaims |
PVC ещё не привязан (часть 21) |
Узел может отказать по нескольким причинам сразу, но в сводке он считается один раз — по первому отказавшему фильтру. Поэтому мастер всегда попадает в untolerated taint(s), даже если под не подошёл бы ему и по селектору.
Сколько ресурсов на узле уже запрошено, видно в describe node:
$ kubectl describe node k8s-node-2
...
Allocatable:
cpu: 2
ephemeral-storage: 29271151363
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 3377252Ki
pods: 110
...
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 300m (15%) 0 (0%)
memory 256Mi (7%) 256Mi (7%)
ephemeral-storage 0 (0%) 0 (0%)
hugepages-1Gi 0 (0%) 0 (0%)
hugepages-2Mi 0 (0%) 0 (0%)
Два пода hello по 100m и 64 Mi и один anti со 100m и 128 Mi из LimitRange неймспейса demo (часть 11); поды Calico и kube-proxy ресурсов не запрашивают.
Смотрим руками
Кто сейчас лидер планировщика и как часто он продлевает аренду:
$ kubectl -n kube-system get lease kube-scheduler -o yaml | grep -E 'holderIdentity|renewTime|leaseDurationSeconds'
holderIdentity: k8s-master-1_f994e4e0-ad31-4daa-abc4-db19947249d2
leaseDurationSeconds: 15
renewTime: "2026-10-28T15:43:11.754706Z"
Привязка изнутри: если создать под с schedulerName, которого нет в кластере, он будет висеть в Pending без событий — его просто никто не планирует. Привяжем его руками тем же запросом, что делает планировщик:
$ kubectl -n demo run manual --image=nginx:1.29 --overrides='{"spec":{"schedulerName":"nobody"}}'
pod/manual created
$ kubectl -n demo get pod manual
NAME READY STATUS RESTARTS AGE
manual 0/1 Pending 0 6s
$ kubectl -n demo describe pod manual
...
Events: <none>
$ kubectl create --raw /api/v1/namespaces/demo/pods/manual/binding -f - <<'EOF'
{"apiVersion":"v1","kind":"Binding","metadata":{"name":"manual"},
"target":{"apiVersion":"v1","kind":"Node","name":"k8s-node-2"}}
EOF
{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success","code":201}
$ kubectl -n demo get pod manual -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
manual 1/1 Running 0 14s 172.20.172.60 k8s-node-2 <none> <none>
Это и есть весь «выход» планировщика: Binding. Всё остальное — вычисления внутри него. Событие Scheduled у такого пода не появится — его пишет планировщик, а он здесь не участвовал. Второй раз привязать под нельзя:
$ kubectl create --raw /api/v1/namespaces/demo/pods/manual/binding -f - <<'EOF'
{"apiVersion":"v1","kind":"Binding","metadata":{"name":"manual"},
"target":{"apiVersion":"v1","kind":"Node","name":"k8s-node-1"}}
EOF
Error from server (Conflict): Operation cannot be fulfilled on pods/binding "manual": pod manual is already assigned to node "k8s-node-2"
$ kubectl -n demo delete pod manual
pod "manual" deleted from demo namespace
Селекторы и affinity. Четыре пода на образе pause (он есть на каждом узле — это pause-контейнер пода): ssd с nodeSelector из раздела выше, nvme с nodeSelector: {disktype: nvme}, которого нет ни у одного узла, aff с node affinity из раздела про неё и near-redis с обязательной pod affinity к подам app: redis, которых в кластере нет. Метка disktype=ssd уже стоит на k8s-node-1:
$ kubectl apply -f sel.yaml
pod/ssd created
pod/nvme created
pod/aff created
pod/near-redis created
$ kubectl -n demo get pods ssd nvme aff near-redis -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
ssd 1/1 Running 0 9s 172.20.211.17 k8s-node-1 <none> <none>
nvme 0/1 Pending 0 9s <none> <none> <none> <none>
aff 1/1 Running 0 9s 172.20.211.20 k8s-node-1 <none> <none>
near-redis 0/1 Pending 0 9s <none> <none> <none> <none>
$ kubectl -n demo get events --field-selector involvedObject.name=nvme -o custom-columns=R:.reason,M:.message
R M
FailedScheduling 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.
$ kubectl -n demo get events --field-selector involvedObject.name=near-redis -o custom-columns=R:.reason,M:.message
R M
FailedScheduling 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match pod affinity rules. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.
aff попал на k8s-node-1 по мягкому правилу: после нормализации узел с SSD получает у NodeAffinity высшую оценку, а вес плагина 2 удваивает её, и это перевешивает остальное. Для nvme и near-redis вытеснение бесполезно на всех трёх узлах: удаление чужих подов не добавит узлу метку и не создаст под redis.
Taints на живых узлах. Два Deployment'а по четыре пода: plain без tolerations и batch с toleration на dedicated=batch:NoSchedule:
$ kubectl taint node k8s-node-2 dedicated=batch:NoSchedule
node/k8s-node-2 tainted
$ kubectl apply -f taint.yaml
deployment.apps/plain created
deployment.apps/batch created
$ kubectl -n demo get pods -l 'app in (plain,batch)' -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
batch-5cb5d7476d-cxjpx 0/1 ContainerCreating 0 10s <none> k8s-node-1 <none> <none>
batch-5cb5d7476d-fnnzh 1/1 Running 0 10s 172.20.172.43 k8s-node-2 <none> <none>
batch-5cb5d7476d-rr2gm 0/1 ContainerCreating 0 10s <none> k8s-node-1 <none> <none>
batch-5cb5d7476d-slht7 0/1 ContainerCreating 0 11s <none> k8s-node-2 <none> <none>
plain-7b575454-76mnm 0/1 ContainerCreating 0 11s <none> k8s-node-1 <none> <none>
plain-7b575454-gkqxw 0/1 ContainerCreating 0 11s <none> k8s-node-1 <none> <none>
plain-7b575454-rk8m6 0/1 ContainerCreating 0 11s <none> k8s-node-1 <none> <none>
plain-7b575454-xn4zp 0/1 ContainerCreating 0 11s <none> k8s-node-1 <none> <none>
$ kubectl delete -f taint.yaml
deployment.apps "plain" deleted from demo namespace
deployment.apps "batch" deleted from demo namespace
$ kubectl taint node k8s-node-2 dedicated=batch:NoSchedule-
node/k8s-node-2 untainted
Все plain на k8s-node-1, batch — на обоих: toleration разрешает, но не притягивает. Теперь мягкий taint на k8s-node-1 и четыре пода без tolerations:
$ kubectl taint node k8s-node-1 maintenance=soon:PreferNoSchedule
node/k8s-node-1 tainted
$ kubectl -n demo create deployment soft --image=registry.k8s.io/pause:3.10.2 --replicas=4
deployment.apps/soft created
$ kubectl -n demo get pods -l app=soft -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
soft-7ddd9f8d46-57c9h 0/1 ContainerCreating 0 8s <none> k8s-node-2 <none> <none>
soft-7ddd9f8d46-9prdb 0/1 ContainerCreating 0 8s <none> k8s-node-2 <none> <none>
soft-7ddd9f8d46-lchnp 0/1 ContainerCreating 0 8s <none> k8s-node-2 <none> <none>
soft-7ddd9f8d46-r64bt 0/1 ContainerCreating 0 10s <none> k8s-node-2 <none> <none>
$ kubectl -n demo delete deployment soft
deployment.apps "soft" deleted from demo namespace
$ kubectl taint node k8s-node-1 maintenance=soon:PreferNoSchedule-
node/k8s-node-1 untainted
Все четыре на k8s-node-2: место на k8s-node-1 было, но TaintToleration с весом 3 перевесил равномерность.
Ворота планирования. Под с schedulingGates и попытки его изменить:
apiVersion: v1
kind: Pod
metadata:
name: gated
namespace: demo
spec:
schedulingGates:
- name: example.com/quota-check
containers:
- name: pause
image: registry.k8s.io/pause:3.10.2
$ kubectl apply -f gated.yaml
pod/gated created
$ kubectl -n demo get pod gated
NAME READY STATUS RESTARTS AGE
gated 0/1 SchedulingGated 0 3s
$ kubectl -n demo get pod gated -o jsonpath='{.status.conditions}{"\n"}'
[{"lastProbeTime":null,"lastTransitionTime":"2026-10-28T15:43:40Z","message":"Scheduling is blocked due to non-empty scheduling gates","reason":"SchedulingGated","status":"False","type":"PodScheduled"}]
$ kubectl -n demo patch pod gated --type=merge -p '{"spec":{"nodeSelector":{"disktype":"ssd"}}}'
pod/gated patched
$ kubectl -n demo patch pod gated --type=json -p '[{"op":"add","path":"/spec/schedulingGates/-","value":{"name":"x"}}]'
The Pod "gated" is invalid: spec.schedulingGates[1].name: Forbidden: only deletion is allowed, but found new scheduling gate 'x'
$ kubectl -n demo patch pod gated --type=json -p '[{"op":"remove","path":"/spec/schedulingGates"}]'
pod/gated patched
$ kubectl -n demo get pod gated -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
gated 1/1 Running 0 7s 172.20.211.31 k8s-node-1 <none> <none>
$ kubectl -n demo delete pod gated
pod "gated" deleted from demo namespace
nodeSelector в работающем поде менять нельзя, а в поде за воротами — можно: он ещё не размещён. Новые ворота добавить нельзя, только снять. Сняв ворота, под сразу ушёл на k8s-node-1 — туда, куда его теперь направляет добавленный селектор.
Приоритеты и вытеснение — на запросах, без настоящей нагрузки. Создаём классы business-critical и batch-low из раздела выше, Deployment filler из двух подов pause с priorityClassName: batch-low и запросом cpu: 1500m — по одному на воркер, после чего на каждом свободно меньше 300m — и под important с priorityClassName: business-critical и запросом cpu: "1":
$ kubectl apply -f priority.yaml
priorityclass.scheduling.k8s.io/business-critical created
priorityclass.scheduling.k8s.io/batch-low created
$ kubectl get priorityclass
NAME VALUE GLOBAL-DEFAULT AGE PREEMPTIONPOLICY
batch-low 1000 false 0s Never
business-critical 100000 false 0s PreemptLowerPriority
system-cluster-critical 2000000000 false 4d5h PreemptLowerPriority
system-node-critical 2000001000 false 4d5h PreemptLowerPriority
$ kubectl apply -f filler.yaml
deployment.apps/filler created
$ kubectl -n demo get pods -l app=filler -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
filler-5775b46cdb-jdl6m 1/1 Running 0 6s 172.20.172.57 k8s-node-2 <none> <none>
filler-5775b46cdb-kpt2p 1/1 Running 0 6s 172.20.211.33 k8s-node-1 <none> <none>
$ kubectl apply -f important.yaml
pod/important created
$ kubectl -n demo get pods -l app=filler -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
filler-5775b46cdb-c78g8 0/1 Pending 0 9s <none> <none> <none> <none>
filler-5775b46cdb-kpt2p 1/1 Running 0 16s 172.20.211.33 k8s-node-1 <none> <none>
$ kubectl -n demo get pod important -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
important 1/1 Running 0 10s 172.20.172.55 k8s-node-2 <none> <none>
$ kubectl -n demo get events --sort-by=.lastTimestamp | grep -E 'important|filler|Preempt'
...
10s Warning FailedScheduling pod/important 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient cpu.
9s Normal Preempted pod/filler-5775b46cdb-jdl6m Preempted by pod 28de96fc-8832-40ee-85db-ab7e77867e42 on node k8s-node-2
9s Normal Killing pod/filler-5775b46cdb-jdl6m Stopping container pause
7s Warning FailedScheduling pod/filler-5775b46cdb-c78g8 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient cpu. no new claims to deallocate, preemption: not eligible due to preemptionPolicy=Never.
7s Normal Scheduled pod/important Successfully assigned demo/important to k8s-node-2
...
Всё по схеме: important не поместился (в его FailedScheduling нет части preemption: — вытеснение удалось), планировщик выбрал жертву на k8s-node-2, событие Preempted называет вытесняющий под по UID, жертва завершилась, и через две секунды important встал на освободившееся место. Замена filler от ReplicaSet ждёт в Pending и сама никого не вытесняет — у её класса preemptionPolicy: Never. Зарезервированный узел виден в колонке NOMINATED NODE, но недолго, пока жертва завершается. Повторив опыт (удалили important, filler вернул вторую реплику, снова создали important), успеваем его застать:
$ kubectl -n demo get pod important -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
important 0/1 Pending 0 2s <none> <none> k8s-node-2 <none>
Как планировщик рассуждал. На уровне подробности 10 он пишет оценки плагинов для каждого узла; на продуктивном кластере так оставлять нельзя (очень много вывода), но на стенде можно временно добавить - --v=10 в command в /etc/kubernetes/manifests/kube-scheduler.yaml, предварительно сохранив копию манифеста вне каталога manifests. Заодно проверим профили: в тот же манифест добавляем - --config=/etc/kubernetes/scheduler-config.yaml с файлом из раздела про профили, а в volumeMounts и volumes — сам файл. kubelet пересоздаёт статический под, как только файл манифеста изменится, а с ним пропадают и логи прежнего контейнера. Перезапустим hello и посмотрим, как оценивался один из новых подов:
$ kubectl -n kube-system logs kube-scheduler-k8s-master-1 | grep -E 'Plugin scored node|Calculated node' | grep 99dfn | grep -v 'score=0$'
I1028 15:46:48.335751 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="TaintToleration" node="k8s-node-1" score=300
I1028 15:46:48.335755 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="NodeResourcesFit" node="k8s-node-1" score=54
I1028 15:46:48.335760 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="NodeResourcesBalancedAllocation" node="k8s-node-1" score=74
I1028 15:46:48.335763 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="TaintToleration" node="k8s-node-2" score=300
I1028 15:46:48.335764 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="NodeResourcesFit" node="k8s-node-2" score=63
I1028 15:46:48.335765 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="InterPodAffinity" node="k8s-node-2" score=200
I1028 15:46:48.335768 1 schedule_one.go:982] "Plugin scored node for pod" pod="demo/hello-55ddc6bb64-99dfn" plugin="NodeResourcesBalancedAllocation" node="k8s-node-2" score=74
I1028 15:46:48.335772 1 schedule_one.go:1050] "Calculated node's final score for pod" pod="demo/hello-55ddc6bb64-99dfn" node="k8s-node-1" score=428
I1028 15:46:48.335774 1 schedule_one.go:1050] "Calculated node's final score for pod" pod="demo/hello-55ddc6bb64-99dfn" node="k8s-node-2" score=637
Оценки уже умножены на вес плагина. TaintToleration дал обоим воркерам по 300 (100 × 3): taints PreferNoSchedule на них нет. NodeResourcesFit предпочёл менее занятый k8s-node-2 (63 против 54), а решила дело мягкая anti-affinity: на k8s-node-2 в тот момент было меньше подов hello, и InterPodAffinity дал ему 200 (100 × 2). PodTopologySpread в оценке не участвует: у пода только ограничение DoNotSchedule, которое работает фильтром, а умолчания к поду со своими ограничениями не применяются. Строки с score=0 (ImageLocality, DynamicResources) отфильтрованы. Рядом в журнале видно, с какими числами считал NodeResourcesFit: у k8s-node-2 requestedResource=[800,1174405120] — 800m CPU и 1120 MiB, хотя поды там вместе с новым запросили 400m и 320 MiB (это число видно в строке NodeResourcesBalancedAllocation): четыре контейнера calico-node, csi-node-driver и kube-proxy без запросов посчитаны по 100m и 200 MiB.
Профиль bin-packing с конфигурацией из раздела про профили — три пода pause по 300m CPU с schedulerName: bin-packing:
$ kubectl -n demo create deployment packed --image=registry.k8s.io/pause:3.10.2 --replicas=3 --dry-run=client -o json | jq '.spec.template.spec.schedulerName="bin-packing" | .spec.template.spec.containers[0].resources={requests:{cpu:"300m"}}' | kubectl apply -f -
deployment.apps/packed created
$ kubectl -n demo get pods -l app=packed -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
packed-f474646d7-q249j 1/1 Running 0 8s 172.20.211.37 k8s-node-1 <none> <none>
packed-f474646d7-rttx8 1/1 Running 0 8s 172.20.211.36 k8s-node-1 <none> <none>
packed-f474646d7-sqnr4 0/1 ContainerCreating 0 8s <none> k8s-node-1 <none> <none>
Все три на одном узле; в событиях Scheduled у этих подов в колонке From стоит bin-packing — имя профиля. После опыта манифест возвращаем из копии (сверив sha256sum), Deployment packed удаляем.
Taints и выселение по NoExecute. Кроме подов hello и anti, на k8s-node-2 есть под patient, который терпит taint maintenance 30 секунд:
apiVersion: v1
kind: Pod
metadata:
name: patient
namespace: demo
spec:
nodeSelector:
kubernetes.io/hostname: k8s-node-2
tolerations:
- key: maintenance
operator: Exists
effect: NoExecute
tolerationSeconds: 30
containers:
- name: pause
image: registry.k8s.io/pause:3.10.2
$ kubectl taint node k8s-node-2 maintenance=now:NoExecute
node/k8s-node-2 tainted
$ kubectl get events -n demo --field-selector reason=TaintManagerEviction
LAST SEEN TYPE REASON OBJECT MESSAGE
...
38s Normal TaintManagerEviction pod/anti-79d5d64c98-z6d9h Marking for deletion Pod demo/anti-79d5d64c98-z6d9h
38s Normal TaintManagerEviction pod/hello-55ddc6bb64-99dfn Marking for deletion Pod demo/hello-55ddc6bb64-99dfn
38s Normal TaintManagerEviction pod/hello-55ddc6bb64-w56nb Marking for deletion Pod demo/hello-55ddc6bb64-w56nb
8s Normal TaintManagerEviction pod/patient Marking for deletion Pod demo/patient
$ kubectl get pods -A -o wide --field-selector spec.nodeName=k8s-node-2
NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
calico-system calico-node-7mx4w 1/1 Running 2 (138m ago) 4d4h 192.168.10.21 k8s-node-2 <none> <none>
calico-system csi-node-driver-6nzck 2/2 Running 4 (138m ago) 4d4h 172.20.172.16 k8s-node-2 <none> <none>
kube-system kube-proxy-xjhjp 1/1 Running 2 (138m ago) 4d4h 192.168.10.21 k8s-node-2 <none> <none>
$ kubectl taint node k8s-node-2 maintenance=now:NoExecute-
node/k8s-node-2 untainted
Поды hello и anti помечены на удаление в ту же секунду, patient — ровно через 30 секунд. Удаляет их контроллер taint-eviction-controller в kube-controller-manager (так написано в поле source событий), и удаляет в обход PDB: выселение по taint — это удаление, а не Eviction API. Поэтому для планового обслуживания — drain, а NoExecute руками — только когда узел действительно нужно освободить немедленно. Замены hello встали на k8s-node-1 (с nodeTaintsPolicy: Honor узел с чужим taint из подсчёта исключён), а поды calico-node, csi-node-driver и kube-proxy на узле остались: у DaemonSet'ов tolerations на всё.
Под в обход планировщика и admission на узле: под с nodeName: k8s-node-2, который просит больше CPU, чем есть на узле:
$ kubectl -n demo run direct --image=registry.k8s.io/pause:3.10.2 --overrides='{"spec":{"nodeName":"k8s-node-2","containers":[{"name":"direct","image":"registry.k8s.io/pause:3.10.2","resources":{"requests":{"cpu":"3"}}}]}}'
pod/direct created
$ kubectl -n demo get pod direct
NAME READY STATUS RESTARTS AGE
direct 0/1 OutOfcpu 0 5s
$ kubectl -n demo get pod direct -o jsonpath='{.status.phase} {.status.reason}: {.status.message}{"\n"}'
Failed OutOfcpu: Pod was rejected: Node didn't have enough resource: cpu, requested: 3000, used: 300, capacity: 2000
$ kubectl -n demo delete pod direct
pod "direct" deleted from demo namespace
Под не ждёт места, а сразу проваливается: kubelet отказал ему при приёме, и больше его никто не перепланирует. В сообщении capacity — это allocatable узла.
Метрики планировщика. kube-scheduler отдаёт их на https://127.0.0.1:10259/metrics только авторизованным клиентам — анонимный запрос получает forbidden: User "system:anonymous" cannot get path "/metrics". На мастере проще всего взять сертификат администратора из admin.conf, не сохраняя его в файлы:
user@k8s-master-1:~$ sudo -i
root@k8s-master-1:~# curl -sk --cert <(grep client-certificate-data /etc/kubernetes/admin.conf | awk '{print $2}' | base64 -d) \
--key <(grep client-key-data /etc/kubernetes/admin.conf | awk '{print $2}' | base64 -d) \
https://127.0.0.1:10259/metrics | grep -E '^scheduler_(pending_pods|schedule_attempts_total)'
scheduler_pending_pods{queue="active"} 0
scheduler_pending_pods{queue="backoff"} 0
scheduler_pending_pods{queue="gated"} 0
scheduler_pending_pods{queue="unschedulable"} 1
scheduler_schedule_attempts_total{profile="default-scheduler",result="scheduled"} 8
scheduler_schedule_attempts_total{profile="default-scheduler",result="unschedulable"} 6
Очереди из схемы в начале статьи: в unschedulable лежит третья реплика anti. Счётчики попыток считаются с последнего перезапуска kube-scheduler и разделены по профилям.
Наконец, полный список того, что мешает или помогает размещению на узле, одной командой:
$ kubectl get node k8s-node-1 -o json | jq '{unschedulable: .spec.unschedulable, taints: .spec.taints, labels: .metadata.labels}'
{
"unschedulable": null,
"taints": null,
"labels": {
"beta.kubernetes.io/arch": "amd64",
"beta.kubernetes.io/os": "linux",
"disktype": "ssd",
"kubernetes.io/arch": "amd64",
"kubernetes.io/hostname": "k8s-node-1",
"kubernetes.io/os": "linux"
}
}
Убрать за собой: kubectl -n demo delete deployment anti, kubectl -n demo delete pod ssd nvme aff near-redis, kubectl -n demo delete deployment filler, kubectl -n demo delete pod important, kubectl delete priorityclass business-critical batch-low и kubectl label node k8s-node-1 disktype-. PDB hello и распределение в Deployment hello можно оставить.
Источники
- Kubernetes Scheduler — фильтрация и оценка узлов.
- Scheduling Framework — точки расширения, QueueingHint, цикл планирования и цикл привязки.
- Scheduler Configuration —
KubeSchedulerConfiguration, плагины по умолчанию, профили. - Scheduler Performance Tuning —
percentageOfNodesToScoreи его значение по умолчанию. - Resource Bin Packing — стратегии
MostAllocatedиRequestedToCapacityRatio. - Assigning Pods to Nodes —
nodeName,nodeSelector, node affinity, pod affinity и anti-affinity,matchLabelKeys. - Taints and Tolerations — эффекты,
tolerationSeconds, taints по состоянию узла, операторыGtиLt. - Pod Topology Spread Constraints — поля ограничений,
nodeTaintsPolicy, ограничения по умолчанию. - Pod Priority and Preemption — PriorityClass, встроенные классы,
nominatedNodeName, PDB при вытеснении. - Pod Scheduling Readiness —
schedulingGatesи какие поля можно менять за воротами. - Nodes — пульс узлов (Lease и статус), node controller.
- Admission Control in Kubernetes — NodeRestriction, DefaultTolerationSeconds, Priority.
- Disruptions — добровольные и недобровольные нарушения, что защищает PDB.
- Specifying a Disruption Budget for your Application —
minAvailable,maxUnavailable, округление процентов,unhealthyPodEvictionPolicy. - API-initiated Eviction — Eviction API и коды ответа 200, 429 и 500.
- Safely Drain a Node —
kubectl drainи PDB. - Customizing components with the kubeadm API —
scheduler.extraArgsиextraVolumesвClusterConfiguration. - Descheduler — перебалансирование уже размещённых подов.
Дальше — сетевая модель Kubernetes: почему у каждого пода свой адрес и под достаёт до пода без NAT, что такое CNI и как containerd вызывает Calico, откуда берётся veth cali… и маршрут /32 на узле и как пакет идёт между подами на одном узле и на разных.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.