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 (именно это покажет Deployment anti в разделе про 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.

Приоритет работает в двух местах:

  1. Очередь. Под с большим приоритетом выбирается из activeQ раньше.
  2. Вытеснение (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 и taint node.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: почему у каждого пода свой адрес и под достаёт до пода без NAT, что такое CNI и как containerd вызывает Calico, откуда берётся veth cali… и маршрут /32 на узле и как пакет идёт между подами на одном узле и на разных.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.