Статья про архитектуру кластера заканчивалась строкой Successfully assigned … to k8s-volga-wk03: планировщик выбрал узел, дальше отвечал kubelet. Там этот шаг был принят как данность — здесь разбираем именно его. Как из шести узлов остаётся один, в каком порядке работают фильтры, что на самом деле означают числа в FailedScheduling, почему maxSkew: 1 иногда ведёт себя как жёсткий anti-affinity и что происходит с подом, которого вытеснили. Тема практическая: почти каждый залипший Pending объясняется одним из механизмов ниже, и почти каждый разбирается за минуту, если знать, куда смотреть.
Это третья статья серии «Kubernetes на практике». Она продолжает «Архитектуру Kubernetes» (там планировщик назначил узел — здесь разбираем, как он это решает) и опирается на «Ресурсы в Kubernetes»: планировщик считает по requests, а не по фактическому потреблению, и первый же раздел показывает, насколько это разные числа. Всё, что ниже, снято на стенде kubernetes/scheduling в репозитории digital-cookbook — Kubernetes v1.36.2 на кластере k8s-volga (шесть узлов под управлением Talos v1.13.6, среда исполнения containerd://2.2.5), клиент kubectl v1.36.1, демо-объекты в namespace cookbook-k8s, образ подов registry.k8s.io/pause:3.10. Кластер общий и живой, поэтому у демо вытеснения отдельный разбор — как оно сделано так, чтобы не задеть чужое.
В статье
- Три числа — три разных вопроса
allocatable— не вся ёмкость узла- Тейнт — первый фильтр, и это доказуемо
- Что означают счётчики в FailedScheduling
- Толераций у пода больше, чем написал автор
- Куда сядет под: affinity и цена жёсткого правила
- Topology spread, который ведёт себя как anti-affinity
- Вечный Pending
- Вытеснение живьём: пять секунд и одна жертва
- Замена выселенной реплики застревает в Pending
- Как сделать вытеснение безопасным на общем кластере
- Чего этот стенд не показывает
- Порядок разбора Pending
Три числа — три разных вопроса
Прежде чем разбирать фильтры, надо развести три величины, которые постоянно путают. Планировщик смотрит ровно на одну из них, а глазами администратора чаще видны две другие.
| Источник | Что показывает | На какой вопрос отвечает |
|---|---|---|
describe node → Allocated resources |
сумму requests подов узла |
сколько заявлено подами |
сумма kubectl top pod по подам узла |
working set самих подов | сколько поды едят |
kubectl top node |
working set узла целиком: поды плюс kubelet, containerd, Talos, сетевой стек ядра и page cache | сколько ест узел |
Сопоставима с requests только средняя строка. kubectl top node — величина из другой области измерения: в неё по определению входит то, чего в Allocated resources нет. Ставить их в одну таблицу «запрошено / факт» нельзя, вычитать одно из другого — тем более.
Технический нюанс, из-за которого среднее число приходится собирать вручную: kubectl top pods -A --field-selector spec.nodeName=… сервер отвергает — "spec.nodeName" is not a known field selector: only "metadata.name", "metadata.namespace". Список подов узла берётся из core API и джойнится с metrics API по паре namespace/имя.
Вот сопоставимая пара по всем трём воркерам, снятая в один проход:
| Узел | requests CPU |
Σ top pod |
во сколько раз меньше |
|---|---|---|---|
k8s-volga-wk01 |
780m (19%) |
21m |
≈ 37× |
k8s-volga-wk02 |
830m (21%) |
35m |
≈ 24× |
k8s-volga-wk03 |
630m (15%) |
33m |
≈ 19× |
Разрыв на порядок с лишним. Планировщик считает k8s-volga-wk02 занятым на 21% и оставит новому поду 3120m свободных из 3950m — при том что сами поды узла в тот же момент едят 35m. Это и есть механика Pending на кластере, где «всё простаивает»: место кончается в бухгалтерии заявок, а не в железе.
По памяти картина принципиально другая, и это важно проговорить, потому что вывод «поды всегда врут в меньшую сторону» напрашивается сам:
| Узел | requests памяти |
Σ top pod |
Σ / requests |
top node (для сравнения) |
|---|---|---|---|---|
k8s-volga-wk01 |
1030Mi (13%) |
488Mi |
≈ 0,47 | 1329Mi (17%) |
k8s-volga-wk02 |
1254Mi (16%) |
610Mi |
≈ 0,49 | 1214Mi (16%) |
k8s-volga-wk03 |
754Mi (10%) |
644Mi |
≈ 0,85 | 1293Mi (17%) |
Ни на одном воркере поды не потребляют больше, чем запросили: от 0,47 до 0,85 от заявки. Недооценки по памяти замер не показал вовсе — разрыв в разы здесь только по CPU.
Последняя колонка приведена именно для того, чтобы показать, чего с ней делать нельзя. top node больше суммы по подам в 2,0–2,7 раза (1329 против 488 на wk01, 1214 против 610 на wk02, 1293 против 644 на wk03), и это не «поды съели больше, чем видно». Это непододовый расход самого узла: kubelet, containerd, компоненты Talos, сетевой стек ядра, page cache образов и логов. Фраза «поды едят больше, чем запросили», сказанная на основании top node, — типовая ошибка чтения: сравниваются величины из разных областей.
Практический вывод, ради которого стенд и снимал третье число: у каждого вопроса свой источник. «Правильно ли выставлены requests» — это requests против Σ top pod. «Не пора ли добавить узел» — это top node против ёмкости. «Влезет ли ещё один под» — это сумма requests против allocatable, и только она, потому что планировщик не смотрит ни на один top.
И оговорка про сами замеры: kubectl top отдаёт скользящее среднее за последние секунды и дрейфует между вызовами. В фикстуре стенда по wk02 встречаются и 68m, и 72m — это живая нагрузка между двумя командами, а не расхождение методик. Ссылаться на «то самое число» нельзя; requests в этом смысле удобнее — они статичны, пока не создан или не удалён под.
Отдельно стоит уточнить ходовую формулировку «недооценённый requests — это отложенный OOM». Она смешивает три разных механизма, и requests участвует не во всех:
- OOM внутри cgroup контейнера — контейнер превысил свой
limits(cgroupmemory.max), ядро убивает процесс в этой cgroup. Здесь решает только лимит,requestsни при чём. - Вытеснение kubelet’ом при нехватке памяти на узле — kubelet заранее, по порогам, выселяет поды; в первую очередь тех, кто превышает свой
requests, с учётом приоритета. Заниженныйrequestsздесь означает «выселят раньше других». - OOM всего узла — память кончилась раньше, чем kubelet успел выселить, и срабатывает OOM killer ядра. Жертву он выбирает по
oom_score_adj, который kubelet выставляет по классу QoS: у Guaranteed −997, у BestEffort 1000, а у Burstable значение считается изmemory request— чем меньше запрос относительно ёмкости узла, тем выше шанс быть убитым. Так что в этом сценарии заниженныйrequestsдействительно приближает OOM-kill.
Помимо этого, requests определяют размещение — их складывает планировщик. Классы QoS и эта механика подробно разобраны в «Ресурсах в Kubernetes».
allocatable — не вся ёмкость узла
Второе число, которое планировщик берёт в расчёт, — потолок узла. И это не capacity. У k8s-volga-wk02 capacity — cpu=4 и mem=8109576Ki, а allocatable — 3950m и 7613960Ki. Разницу (50m CPU и около 484Mi памяти) kubelet резервирует под системные нужды, и планировщик её не раздаёт никому.
Обратная сторона — лимиты, которые планировщик как раз не проверяет. На том же wk02 сумма limits по CPU равна 4500m при allocatable 3950m, то есть 113%, и kubectl сам об этом предупреждает прямо в выводе describe node:
$ kubectl describe node k8s-volga-wk02 | grep -A8 'Allocated resources'
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 830m (21%) 4500m (113%)
memory 1254Mi (16%) 6784Mi (91%)
ephemeral-storage 0 (0%) 0 (0%)
hugepages-2Mi 0 (0%) 0 (0%)
Events: <none>
Переподписка по лимитам допустима именно потому, что решение о размещении принимается не по ним. limits — это потолок для рантайма на узле; requests — валюта, которой под платит планировщику за место.
Тейнт — первый фильтр, и это доказуемо
Теперь к самому отбору. Кластер k8s-volga — шесть узлов: три control-plane с тейнтом node-role.kubernetes.io/control-plane:NoSchedule и три воркера без тейнтов вовсе.
$ kubectl get nodes \
-o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints,CPU:.status.allocatable.cpu,MEM:.status.allocatable.memory
NAME TAINTS CPU MEM
k8s-volga-cp01 [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]] 1950m 3360936Ki
k8s-volga-cp02 [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]] 1950m 3360936Ki
k8s-volga-cp03 [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]] 1950m 3360936Ki
k8s-volga-wk01 <none> 3950m 7613952Ki
k8s-volga-wk02 <none> 3950m 7613960Ki
k8s-volga-wk03 <none> 3950m 7613956Ki
Тейнт — это свойство узла «отталкивать поды». Толерация — свойство пода «терпеть конкретный тейнт». Эксперимент устроен так, чтобы разница была единственной: два пода pause с одинаковыми requests (10m CPU / 16Mi памяти), оба прибиты nodeSelector‘ом kubernetes.io/hostname: k8s-volga-cp02 к одному и тому же узлу. Различие ровно одно:
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
operator: Exists здесь выбран не случайно: у тейнта на узлах нет значения ([map[effect:NoSchedule key:…]] — только ключ и эффект), поэтому сопоставление идёт по ключу. Единственной рабочей формой это его не делает — толерация с operator: Equal и не заданным value сопоставляется с пустым значением "" и такой тейнт тоже покрывает. Но Exists прямо выражает то, что проверяется на самом деле, и не сломается, если у тейнта однажды появится значение. Результат:
$ kubectl get pod -n cookbook-k8s no-toleration with-toleration \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName,SCHEDULED:.status.conditions[0].type
NAME PHASE NODE SCHEDULED
no-toleration Pending <none> PodScheduled
with-toleration Running k8s-volga-cp02 PodReadyToStartContainers
Один и тот же узел, два исхода. Под с толерацией размещён и запущен, под без неё не размещён вовсе.
Почему разбивка 3/3 доказывает порядок фильтров
Интересное — в тексте отказа:
$ kubectl -n cookbook-k8s describe pod no-toleration | awk '/^Events:/{f=1} f'
Warning FailedScheduling 58s default-scheduler 0/6 nodes are available: 3 node(s) didn't match Pod's node affinity/selector, 3 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/6 nodes are available: 6 Preemption is not helpful for scheduling.
Планировщик отчитывается по всем шести узлам: три отпали по селектору, три — по нетерпимому тейнту. Но эта арифметика говорит больше, чем кажется на первый взгляд.
Посчитаем, скольким узлам не подходит селектор kubernetes.io/hostname: k8s-volga-cp02. Хостнейм не совпадает у пяти узлов: три воркера плюс cp01 и cp03. Пять — а в корзине didn't match Pod's node affinity/selector их три.
Теперь посчитаем корзину тейнта. Тейнтованных узлов в кластере ровно три, других кандидатов в эту корзину физически не существует — контрольный kubectl get nodes после демо показывает те же три control-plane с NoSchedule и три воркера с <none>. Значит корзина тейнта целиком состоит из cp01, cp02, cp03.
Сводим: cp01 и cp03 проваливают обе проверки — и селектор, и тейнт, — но посчитаны только в корзине тейнта. Планировщик записывает на узел ровно одну причину, ту, что сработала первой. Раз для cp01 и cp03 ею оказался тейнт, значит TaintToleration отработал раньше NodeAffinity. Порядок плагинов виден прямо из одного события, без чтения исходников.
Оговорка к выводу: он про этот кластер и эту версию — k8s 1.36.2 со штатным default-scheduler. Порядок плагинов задаётся профилем планировщика и в принципе настраивается; стенд показывает фактический порядок, а не гарантию API.
И два места, где это же событие легко прочитать неправильно.
Тейнт не назван по имени. Сообщение ограничивается формулировкой 3 node(s) had untolerated taint(s) — без ключа, без значения, без эффекта. Какой именно тейнт помешал, по FailedScheduling на 1.36.2 не понять: придётся идти в kubectl get nodes или describe node.
Хвост про вытеснение порядок фильтров не доказывает. Строка preemption: 0/6 nodes are available: 6 Preemption is not helpful for scheduling. говорит ровно одно: планировщик отдельно проверил, спасёт ли выселение чужих подов, и ответил «нет» по всем шести узлам. Причина отказа такая, что освобождением места не лечится — выселяй хоть весь узел, тейнт и селектор от этого не изменятся. Про порядок запуска плагинов этот хвост не говорит ничего; порядок доказывает разбивка 3/3 выше, а не он.
Заметьте и то, чего в замере нет: он не показывает, считались ли вообще requests пода. Под просил 10m CPU при allocatable узла 1950m — на таком фоне ресурсы не были ограничением ни при каком порядке проверок.
Что означают счётчики в FailedScheduling
Из разбивки 3/3 следует правило, которое стоит запомнить отдельно, потому что на нём строится половина неверных диагнозов.
Числа в FailedScheduling — это «сколько узлов отпало по этой причине первой», а не «скольким узлам не подошло это правило». Узел попадает ровно в одну корзину, даже если проваливает три проверки подряд.
Следствие из этого неприятное. В стенде есть второй под, stuck-affinity, с совершенно другим правилом — жёстким nodeAffinity по метке cookbook.khorost.tech/scheduling со значением absent, которого нет ни на одном узле. Ни nodeSelector, ни привязки к control-plane у него нет. А событие получилось байт в байт то же самое, что у пода no-toleration из предыдущего раздела:
$ grep -h FailedScheduling fixtures/10-taint.txt fixtures/30-pending.txt | grep -v '^#' | sed 's/[0-9][0-9]*s /<age> /' | sort -u
# (grep -v '^#' отбрасывает строки '# cmd:', иначе команда нашла бы саму себя)
Warning FailedScheduling <age> default-scheduler 0/6 nodes are available: 3 node(s) didn't match Pod's node affinity/selector, 3 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/6 nodes are available: 6 Preemption is not helpful for scheduling.
Одна строка после нормализации возраста — два разных пода с двумя разными причинами. Внутри разбивка означает разное: у no-toleration три воркера действительно отсеялись по селектору, у stuck-affinity правилу не соответствуют все шесть узлов, просто три control-plane снова ушли в корзину тейнта.
Важно, где проходит граница этого совпадения. Оно не универсально: совпали два правила размещения, потому что nodeSelector и nodeAffinity обрабатывает один и тот же фильтр планировщика, и в сообщении он называется одинаково — didn't match Pod's node affinity/selector. Нехватка ресурса выглядит иначе и идёт отдельным слагаемым. Вот событие из демо вытеснения, где узлу не хватило места:
$ kubectl -n cookbook-k8s get events --field-selector involvedObject.name=preemptor,reason=FailedScheduling \
-o custom-columns=MSG:.message --no-headers
0/6 nodes are available: 1 Insufficient ephemeral-storage, 2 node(s) didn't match Pod's node affinity/selector, 3 node(s) had untolerated taint(s).
1 + 2 + 3 = 6, и первое слагаемое прямо называет ось, по которой не хватило. Так что «по тексту события причину не отличить» — утверждение неверное; верное звучит так: два разных невыполнимых правила размещения дают неразличимый текст, и что именно нарушено, отвечает только спека пода.
Толераций у пода больше, чем написал автор
Ещё одна деталь, которую видно на том же демо. В манифесте with-toleration толерация была одна, а в созданном объекте их три:
$ kubectl -n cookbook-k8s get pod with-toleration \
-o jsonpath='{range .spec.tolerations[*]}{.key}{" op="}{.operator}{" effect="}{.effect}{" tolerationSeconds="}{.tolerationSeconds}{"\n"}{end}'
node-role.kubernetes.io/control-plane op=Exists effect=NoSchedule tolerationSeconds=
node.kubernetes.io/not-ready op=Exists effect=NoExecute tolerationSeconds=300
node.kubernetes.io/unreachable op=Exists effect=NoExecute tolerationSeconds=300
Две нижние дописал admission-плагин DefaultTolerationSeconds. Смысл у них практический: под не выселяется с узла мгновенно, как только тот стал NotReady или Unreachable, — у него есть 300 секунд на то, чтобы узел вернулся. Проверить это на своём кластере просто: у соседнего пода demo-0, где толераций никто не писал вовсе, kubectl get pod demo-0 -o jsonpath='{.spec.tolerations}' возвращает ровно эту пару и больше ничего.
А вот распространённое продолжение «эта пара есть у любого пода кластера» — неверно, и разница содержательная. Плагин не трогает ключ, который под уже терпит сам. DaemonSet-поды несут зонтичную толерацию плюс явные толерации на те же ключи, но без tolerationSeconds:
$ kubectl -n kube-system get pod cilium-99lgh -o jsonpath='{.spec.tolerations}'
[{"operator":"Exists"},{"effect":"NoExecute","key":"node.kubernetes.io/not-ready","operator":"Exists"},{"effect":"NoExecute","key":"node.kubernetes.io/unreachable","operator":"Exists"}, …]
Ключи те же, срока нет — а отсутствие tolerationSeconds означает «терпеть бессрочно». Такой под не уедет с моргнувшего узла ни через 300 секунд, ни через час. Так и задумано: cilium — это сеть узла, vector — сбор логов, csi-nfs-node — монтирование томов. Если узел моргнул, эти поды нужны на нём особенно: они и есть то, что вернёт узел в строй и покажет, что произошло.
Масштаб исключения виден по одному проходу kubectl get pod -A по всему кластеру: из 82 подов пару с tolerationSeconds: 300 имеют 58, а у 24 её нет. Эти 24 — четыре набора DaemonSet (cilium, cilium-envoy, csi-nfs-node, vector), по шесть подов на шесть узлов. То есть «пара с 300 секундами» — про обычную рабочую нагрузку, а не про весь кластер.
И последнее про толерации, чтобы не переоценить их силу: толерация снимает запрет, но не притягивает. Она разрешает разместиться на узле с соответствующим тейнтом, а не заставляет планировщик его выбрать. В демо под попал именно на control-plane узел только потому, что был явно прибит nodeSelector.
Куда сядет под: affinity и цена жёсткого правила
Тейнт отвечает на вопрос «пустят ли под на узел». Следующие три инструмента отвечают на другой: где именно он окажется, если пустят везде.
Node affinity — притянуть к узлу по метке. На один воркер поставлена метка cookbook.khorost.tech/scheduling=demo, под просит её жёстким правилом:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cookbook.khorost.tech/scheduling
operator: In
values: [demo]
Результат ожидаемый — affinity-demo Running k8s-volga-wk01. Тот же эффект даёт однострочный nodeSelector, и вопрос «зачем тогда affinity» законный. nodeSelector умеет ровно одно: точное равенство «ключ = значение», несколько пар соединяются только по И. У nodeAffinity есть операторы In, NotIn, Exists, DoesNotExist, Gt, Lt, список значений внутри терма (это ИЛИ) и несколько альтернативных nodeSelectorTerms (между термами ИЛИ). А главное — мягкая форма preferredDuringSchedulingIgnoredDuringExecution с весами, «желательно, но не обязательно», которой у nodeSelector нет вовсе.
Вторая половина имени — IgnoredDuringExecution — про то, чего замер не показывает: правило проверяется при размещении. Снимете метку с узла позже — уже запущенный под никуда не уедет.
Pod anti-affinity — развести реплики. Deployment на три реплики с правилом «в одном домене kubernetes.io/hostname не больше одного пода с меткой app: spread-demo»:
$ kubectl -n cookbook-k8s get pod -l app=spread-demo --no-headers \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName
spread-demo-5644ff5cbb-4ps4w Running k8s-volga-wk02
spread-demo-5644ff5cbb-m9kj8 Running k8s-volga-wk03
spread-demo-5644ff5cbb-z9p9x Running k8s-volga-wk01
Три пода — три разных воркера. Это ровно та страховка, ради которой anti-affinity и ставят: падение узла уносит одну реплику, а не все.
А теперь цена. Масштабируем до четырёх реплик:
$ kubectl -n cookbook-k8s get pod -l app=spread-demo --no-headers \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName
spread-demo-5644ff5cbb-2skfx Pending <none>
spread-demo-5644ff5cbb-4ps4w Running k8s-volga-wk02
spread-demo-5644ff5cbb-m9kj8 Running k8s-volga-wk03
spread-demo-5644ff5cbb-z9p9x Running k8s-volga-wk01
$ kubectl -n cookbook-k8s describe pod spread-demo-5644ff5cbb-2skfx | awk '/^Events:/{f=1} f'
Warning FailedScheduling 49s default-scheduler 0/6 nodes are available: 3 node(s) didn't match pod anti-affinity rules, 3 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/6 nodes are available: 3 No preemption victims found for incoming pod, 3 Preemption is not helpful for scheduling.
required anti-affinity по hostname жёстко упирает число реплик в число узлов: три воркера — максимум три реплики, четвёртая висит Pending бессрочно, до появления нового узла.
Хвост про вытеснение здесь, кстати, не такой, как в разделе про тейнт. Там было 6 Preemption is not helpful на все шесть узлов, здесь — 3 No preemption victims found for incoming pod, 3 Preemption is not helpful. Разделение ровно по тем же двум группам: для трёх узлов с тейнтом вытеснение бесполезно в принципе, а для трёх воркеров планировщик всерьёз искал, кого выселить, чтобы правило выполнилось, и подходящей жертвы не нашёл. Оговорка: пофамильно событие эти группы не называет — раскладка «три жертв не нашлось = воркеры, три бесполезно = control-plane» здесь вывод, а не показанное фикстурой. Опирается он на то, что нетерпимый тейнт даёт неразрешимый статус (UnschedulableAndUnresolvable), при котором вытеснение не спасает ни при какой раскладке, а нарушенное anti-affinity — статус разрешимый, и именно на воркерах поиск жертвы вообще имел смысл. Разница формулировок не косметическая: она показывает, что anti-affinity, в отличие от тейнта, теоретически лечится освобождением домена.
Topology spread, который ведёт себя как anti-affinity
Topology spread — инструмент помягче: он не запрещает соседство, а ограничивает перекос. maxSkew: 1 по kubernetes.io/hostname для четырёх реплик на трёх воркерах должен давать 2+1+1 и четыре Running. Фактически:
$ kubectl -n cookbook-k8s get pod -l app=topo-demo --no-headers \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName
topo-demo-55b79976d7-6fm49 Running k8s-volga-wk02
topo-demo-55b79976d7-9kqrj Running k8s-volga-wk03
topo-demo-55b79976d7-dpx4d Pending <none>
topo-demo-55b79976d7-srk9l Running k8s-volga-wk01
1+1+1 и Pending — то есть ровно то же, что у жёсткого anti-affinity. Это главная находка стенда, и объяснение у неё точное.
Перекос считается как «число подов в домене минус минимум по доменам». Доменов kubernetes.io/hostname в кластере шесть, а не три: метка hostname есть у всех узлов, включая control-plane. Попадут ли тейнтованные узлы в расчёт — решает поле nodeTaintsPolicy. В объекте пода его нет: API-сервер умолчание не дописывает, объект хранится ровно таким, каким его подали:
[{"labelSelector":{"matchLabels":{"app":"topo-demo"}},"maxSkew":1,"topologyKey":"kubernetes.io/hostname","whenUnsatisfiable":"DoNotSchedule"}]
А умолчание документирует сам API кластера — kubectl explain pod.spec.topologySpreadConstraints.nodeTaintsPolicy отвечает дословно:
- Honor: nodes without taints, along with tainted nodes for which the incoming pod
has a toleration, are included. - Ignore: node taints are ignored. All nodes are included.
If this value is nil, the behavior is equivalent to the Ignore policy.
Отсюда всё и следует. Три control-plane узла входят в расчёт как домены с нулём подов, минимум по доменам равен 0, и воркер с двумя подами даёт перекос 2 − 0 = 2 при maxSkew: 1. Домен в расчёте участвует, а разместиться в нём нельзя: сам под на control-plane не пустит фильтр тейнтов. На control-plane узле перекос был бы 1 и в maxSkew укладывался бы — spread туда пода и не запрещает; режет его другой фильтр. Событие это подтверждает буквально:
$ kubectl -n cookbook-k8s describe pod topo-demo-55b79976d7-dpx4d | awk '/^Events:/{f=1} f'
Warning FailedScheduling 2m37s default-scheduler 0/6 nodes are available: 3 node(s) didn't match pod topology spread constraints, 3 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/6 nodes are available: 3 No preemption victims found for incoming pod, 3 Preemption is not helpful for scheduling.
Три воркера отпали по spread (на них перекос был бы 2), три control-plane — по тейнту, а не по spread.
Проверяется гипотеза одним полем. Тот же Deployment с добавленным nodeTaintsPolicy: Honor:
$ kubectl -n cookbook-k8s get pod -l app=topo-honor-demo --no-headers \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName
topo-honor-demo-84f74c99b5-6zmxw Running k8s-volga-wk02
topo-honor-demo-84f74c99b5-qhw6m Running k8s-volga-wk03
topo-honor-demo-84f74c99b5-s7xcs Running k8s-volga-wk01
topo-honor-demo-84f74c99b5-t5h6g Running k8s-volga-wk02
Все четыре Running, распределение — те самые 2+1+1. Honor исключает из расчёта узлы, тейнты которых под не терпит: доменов остаётся три, минимум становится 1, перекос воркера с двумя подами равен 1. Отличие между двумя Deployment’ами — ровно это поле (плюс переименования, чтобы объекты не подхватывали поды друг друга).
Практический вывод: на кластере, где часть узлов закрыта тейнтом — а это любой кластер со штатным тейнтом control-plane, — maxSkew: 1 по kubernetes.io/hostname с DoNotSchedule ведёт себя как жёсткий anti-affinity: числа реплик выше числа доступных узлов он не допустит. Лечится либо nodeTaintsPolicy: Honor, либо мягким whenUnsatisfiable: ScheduleAnyway, где перекос учитывается при скоринге, но размещение не блокирует.
Про версии: nodeTaintsPolicy и парное ему nodeAffinityPolicy появились не сразу, и на старом кластере поля может не быть вовсе — тогда Ignore является единственным поведением. Проверять надо не по памяти, а тем же kubectl explain на своём кластере.
И разница между двумя инструментами, которую эти два демо и разводят:
| anti-affinity | topology spread | |
|---|---|---|
| правило | не больше одного пода в домене | разница между доменами не больше maxSkew |
| про что вопрос | можно ли этим подам жить рядом | насколько неровно они лежат |
| потолок реплик | равен числу доменов | правилом не задан: ограничен перекос, а не соседство — но при maxSkew: 1 и nodeTaintsPolicy: Ignore он де-факто возникает и равен числу доступных узлов (разбор выше) |
Вечный Pending
Самая частая причина залипания — правило, которому не отвечает ни один узел. В стенде это под stuck-affinity с жёстким nodeAffinity по существующему ключу и несуществующему значению:
$ kubectl get nodes \
-o 'custom-columns=NODE:.metadata.name,SCHEDULING_LABEL:.metadata.labels.cookbook\.khorost\.tech/scheduling,TAINTS:.spec.taints[*].key'
NODE SCHEDULING_LABEL TAINTS
k8s-volga-cp01 <none> node-role.kubernetes.io/control-plane
k8s-volga-cp02 <none> node-role.kubernetes.io/control-plane
k8s-volga-cp03 <none> node-role.kubernetes.io/control-plane
k8s-volga-wk01 demo <none>
k8s-volga-wk02 <none> <none>
k8s-volga-wk03 <none> <none>
Метка есть на одном воркере со значением demo, под просит absent. Значения нет нигде, и появиться ему неоткуда.
Ключевое свойство такого Pending — он не рассосётся. Планировщик не ставит таймаут и не сдаётся: он возвращается к поду при каждом изменении кластера, каждый раз с тем же результатом. Под будет висеть, пока его не удалят или пока кто-то не поставит метку руками.
Отличать этот случай от временного — по спеке пода, а не по тексту события: как показано выше, у двух разных невыполнимых правил размещения текст события совпадает байт в байт. Нехватка ресурса, наоборот, называет ось прямо (1 Insufficient ephemeral-storage) — но и она не говорит, освободится ли место когда-нибудь. Первый вопрос при разборе — не «что пишет describe», а «существует ли в кластере узел, удовлетворяющий всем требованиям пода одновременно».
Вытеснение живьём: пять секунд и одна жертва
Обратная ситуация: место кончилось, а пришедший под важнее уже запущенных. Тогда планировщик не оставляет его в очереди, а выселяет менее приоритетные поды.
Расстановка на стенде: PriorityClass cookbook-low со значением −10 для четырёх подов-жертв и cookbook-high со значением 1000 для одного вытесняющего. Все пятеро прибиты nodeSelector‘ом к одному воркеру. Жертвы занимают узел по оси ephemeral-storage — по 12Gi каждая, четыре штуки из 51,75 GiB доступных:
$ kubectl describe node k8s-volga-wk01 | grep -A10 'Allocated resources'
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 820m (20%) 2500m (63%)
memory 1094Mi (14%) 4016Mi (54%)
ephemeral-storage 48Gi (92%) 0 (0%)
hugepages-2Mi 0 (0%) 0 (0%)
Events: <none>
Узел «полон» ровно по той оси, по которой его наполнили: CPU занят на 20%, память на 14%. Вытесняющий просит 8Gi — свободных ≈3,75 GiB не хватает, выселение одной жертвы даёт +12Gi, и этого достаточно.
Хроника с шагом в пять секунд (колонки после имени — READY, STATUS, RESTARTS, AGE; имена жертв сокращены до последнего сегмента, лишние пробелы сжаты):
11:41:07 создаём preemptor
11:41:07 preemptor 0/1 ContainerCreating 0 0s; victims-…-bsj4p 1/1 Running 0 53s; victims-…-mlxlt 1/1 Running 0 53s; victims-…-n4jtb 1/1 Running 0 53s; victims-…-t8nbq 0/1 Pending 0 0s; victims-…-vknnc 0/1 Completed 0 53s;
11:41:12 preemptor 1/1 Running 0 5s; victims-…-bsj4p 1/1 Running 0 58s; victims-…-mlxlt 1/1 Running 0 58s; victims-…-n4jtb 1/1 Running 0 58s; victims-…-t8nbq 0/1 Pending 0 5s;
Всё случилось внутри первой же секунды: жертва vknnc уже Completed, замена t8nbq уже создана и уже Pending, вытесняющий уже создаёт контейнер. Ещё через пять секунд он Running.
Вытеснена ровно одна жертва из четырёх. Единственное событие Preempted по всему кластеру:
$ kubectl get events -A --field-selector reason=Preempted --no-headers \
-o custom-columns=NS:.metadata.namespace,POD:.involvedObject.name,MSG:.message
cookbook-k8s victims-5d689d946d-vknnc Preempted by pod 9b43a2d1-591a-4ebe-80b8-1cc6b618fa89 on node k8s-volga-wk01
Планировщик выселяет минимально необходимое: одной жертвы (12Gi) хватило, чтобы 8Gi поместились, вторую он не тронул — три остальные так и остались Running.
И вот здесь — практическая боль дежурного. Событие называет вытеснителя по UID, а не по имени. Ни имени, ни namespace виновника в сообщении нет; чтобы понять, кто выселил под, UID приходится сопоставлять руками. В стенде сопоставление сделано явно — kubectl -n cookbook-k8s get pod preemptor -o jsonpath='{.metadata.uid}' даёт тот самый 9b43a2d1-591a-4ebe-80b8-1cc6b618fa89. В инциденте, где вытеснивший под уже мог завершиться, такой UID нередко оказывается единственной ниточкой.
У самого вытесняющего событий два подряд:
$ kubectl -n cookbook-k8s get events --field-selector involvedObject.name=preemptor \
--sort-by=.metadata.creationTimestamp | grep Schedul
Warning FailedScheduling pod/preemptor 0/6 nodes are available: 1 Insufficient ephemeral-storage, 2 node(s) didn't match Pod's node affinity/selector, 3 node(s) had untolerated taint(s).
Normal Scheduled pod/preemptor Successfully assigned cookbook-k8s/preemptor to k8s-volga-wk01
Сначала обычная фильтрация узла не нашла — и здесь снова разбивка по первой причине: 1 (целевой воркер, где не хватило места) + 2 (два других воркера, не подошедшие по nodeSelector) + 3 (control-plane с тейнтом) = 6. А теперь обратите внимание, чего в этом FailedScheduling нет: хвоста preemption: …. Во всех предыдущих демо он был и объяснял, почему выселение не поможет. Здесь оно помогло — и хвост не появился вовсе. По всем событиям этого стенда признак получается односторонний: где хвост preemption есть, там выселение проверили и отвергли; где его нет — как у этого пода, — планировщик наметил узел, выселил жертву и на следующем цикле разместил под.
Замена выселенной реплики застревает в Pending
Самое интересное началось после вытеснения. Выселение не уменьшает replicas — контроллер немедленно создал замену выселенной жертве:
$ kubectl -n cookbook-k8s get pod -l stand=cookbook-scheduling \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName,PRIO:.spec.priority,EPH:.spec.containers[0].resources.requests.ephemeral-storage
NAME PHASE NODE PRIO EPH
preemptor Running k8s-volga-wk01 1000 8Gi
victims-5d689d946d-bsj4p Running k8s-volga-wk01 -10 12Gi
victims-5d689d946d-mlxlt Running k8s-volga-wk01 -10 12Gi
victims-5d689d946d-n4jtb Running k8s-volga-wk01 -10 12Gi
victims-5d689d946d-t8nbq Pending <none> -10 12Gi
Замена встала в очередь на тот же узел, где места по-прежнему нет:
$ kubectl -n cookbook-k8s describe pod victims-5d689d946d-t8nbq | awk '/^Events:/{f=1} f' \
| grep FailedScheduling | tail -1
Warning FailedScheduling 2m23s default-scheduler 0/6 nodes are available: 1 Insufficient ephemeral-storage, 2 node(s) didn't match Pod's node affinity/selector, 3 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/6 nodes are available: 1 No preemption victims found for incoming pod, 5 Preemption is not helpful for scheduling.
No preemption victims found for incoming pod — у замены приоритет −10, и никого ниже себя в кластере она не находит. Выселить, чтобы освободить место себе, ей некого.
Механика здесь не «контроллер бесконечно пересоздаёт», а проще: ReplicaSet создал одну замену — четвёртую реплику вместо выселенной, в списке выше это t8nbq в Pending — и на этом его работа закончена, пока число подов совпадает с replicas. Замена висит в Pending, а повторяет попытки уже планировщик: под остаётся в очереди, и при каждом изменении в кластере (или по таймеру повторной попытки) его снова пробуют разместить — с тем же FailedScheduling. Вытеснение освободило место однократно; нагрузка, которую выселили, никуда не делась и ждёт в очереди планировщика. Узел после вытеснения показывает ephemeral-storage 44Gi (85%) — это 3 × 12Gi жертв плюс 8Gi вытеснившего.
Отсюда практический вывод, который стоит держать в голове при раздаче приоритетов: под с самым низким в кластере приоритетом — это не «под, которым можно пожертвовать», а под, который после первого же вытеснения не встанет, пока обстановка не изменится — пока не освободится ёмкость узла (capacity) и не сдвинутся ограничения: чужой приоритет, правило affinity, тейнт. При неизменных условиях замена так и останется в Pending, а планировщик будет раз за разом отказывать ей с тем же сообщением. Но это «пока», а не «никогда»: освободится место, поднимут приоритет, снимут правило — и она разместится. Если такая нагрузка должна пережить вытеснение, ей нужен запасной узел, куда замена может встать, или приоритет не в самом низу шкалы. Сам контроллер тут ничего не решит: он лишь поддерживает число реплик, а где замене жить — вопрос ёмкости и ограничений размещения. Автоматическое добавление узлов под такую очередь — тема соседняя, она разобрана в «Автоскейлинге: HPA, VPA и KEDA».
Как сделать вытеснение безопасным на общем кластере
Демо выше снималось на живом кластере, где на целевом узле крутились 20 чужих подов, включая coredns, cilium и vector. Выселение — операция, которую нельзя ставить «на авось», поэтому страховок было четыре. Важно, что они не равноправны, и различие тут не педантичное: две из них действительно защищают чужое, а две — нет.
Первая: приоритет жертв отрицательный. cookbook-low = −10, а поды без priorityClassName имеют приоритет 0 — то есть выше наших жертв. Планировщик рассматривает как кандидатов всех, кто ниже вытесняющего, а затем возвращает обратно тех, без чьего выселения место всё равно освободится, — по убыванию приоритета. Наши −10 пробуются последними, и в жертвах остаются именно они. Но защищает это чужие поды только при достаточности собственных жертв: если выселения всех подов с −10 не хватило бы, чтобы освободить нужное, планировщик продолжил бы набирать жертвы среди тех, чей приоритет ниже вытесняющего, — то есть и среди чужих подов с приоритетом 0. В этом демо собственных жертв заведомо хватало (одной 12Gi под запрос 8Gi), поэтому страховка сработала; безусловной она не является.
Вторая: ось выбрана так, что выселение чужого пода ничего бы не дало. Все 20 чужих подов на узле просят ephemeral-storage 0 — поимённая проверка перед демо показала пустое значение у каждого. Удаление любого из них освобождает ноль байт и не приближает вытесняющий под к размещению, а бесполезного кандидата планировщик из набора жертв отбрасывает. Эта страховка самодостаточна — она не зависит ни от первой, ни от того, хватит ли собственных жертв — правда, вывод «чужое осталось бы на месте и без отрицательных приоритетов» следует из замера (eph= пусто у всех двадцати), а не из отдельного опыта: чтобы поставить его честно, пришлось бы отключить первую страховку, чего на общем кластере не делают.
Третья: globalDefault: false у обоих классов. Это страховка не от вытеснения, а от того, чтобы стенд не переписал приоритеты всему кластеру. PriorityClass — объект кластерный, и globalDefault: true назначил бы класс всем подам, которые не указали priorityClassName явно. Контроль: kubectl get priorityclass -o json | grep -c '"globalDefault": true' → 0, ни одного класса по умолчанию в кластере нет. Само вытеснение это поле не предотвращает.
Четвёртая: все участники прибиты nodeSelector‘ом к одному узлу. Это ограничение радиуса, а не защита: чужие поды на самом целевом узле защищены страховками 1 и 2, а не этой — она лишь гарантирует, что остальные пять узлов демо не касается вообще. Тейнты при этом не ставились, drain/cordon/uncordon не вызывались.
Отдельно — потолок сверху: значение 1000 у вытесняющего выбрано заведомо ниже системных классов (system-cluster-critical = 2000000000, system-node-critical = 2000001000), так что системные поды узла демо неподсудны в принципе.
Проверка результата — не «вроде ничего не сломалось», а два независимых среза. Первый: снимок всех подов узла с временами старта, снятый до демо и после вытеснения, поды стенда исключены; diff между срезами пуст — все 20 чужих подов на месте, с теми же startTime. Совпадение времён старта здесь важнее списка имён: перезапущенный или пересозданный под получил бы новое startTime. Второй: счётчик перезапусков по каждому из 20 чужих подов — везде 0. Плюс единственное событие Preempted во всём кластере называет наш собственный под.
Что из этого переносится в прод. Механика — да, конкретные числа — нет: в проде приоритеты раздают положительными значениями по классам нагрузки, а не отрицательными. Переносится сам подход: перед тем как включать вытеснение на общем кластере, полезно знать, какая страховка у вас действительно защищает чужое, а какая только ограничивает радиус. И полезно иметь срез «до», с которым можно сравнить «после».
Чего этот стенд не показывает
Честный список границ — он же список мест, где чужие числа не стоит переносить на свой кластер.
Зон в кластере нет. topology.kubernetes.io/zone и topology.kubernetes.io/region пусты у всех шести узлов; больше того, в объектах узлов нет вообще ни одного ключа со словами topology, zone или region — проверка даёт 0 совпадений. Поэтому topology spread показан только по kubernetes.io/hostname. Это ограничение кластера, а не Kubernetes: механизм одинаково работает с любым ключом. Но практический смысл разный: spread по hostname защищает от падения узла, spread по zone — от падения стойки, ЦОДа или AZ, и на кластере с зонами обычно ставят оба ограничения сразу — по зонам жёстко, по хостам мягким ScheduleAnyway. Ни одно число этой статьи к зонам не относится. И ловушка с nodeTaintsPolicy: Ignore на кластере с зонами выглядела бы иначе: доменов-зон немного, и все они, как правило, содержат воркеры, так что «пустого домена, в который нельзя попасть» просто не возникает.
Демо вытеснения идёт по ephemeral-storage, а не по CPU, — из-за арифметики квоты. Это ограничение стенда, а не свойство Kubernetes. Чтобы вытеснение включилось, надо суметь занять узел почти целиком, а по CPU это арифметически недостижимо:
| доступно | сколько | |
|---|---|---|
квота namespace, requests.cpu |
4 минус занятые 950m |
3050m |
узел k8s-volga-wk01, CPU |
allocatable 3950m минус запрошенные 780m |
свободно 3170m |
Разрыв в 120m и решает дело: занять узел под завязку квота не даёт, а значит и «не поместиться» вытесняющему поду не даст. По памяти разрыв ещё больше — 3104Mi доступных против ≈6405Mi свободных на узле. Ось ephemeral-storage от этого свободна: квота её не ограничивает, LimitRange тоже, и на узле её никто не просит. Механика вытеснения от выбора оси не зависит — планировщик одинаково считает любой скалярный ресурс.
Порядок фильтров измерен, а не гарантирован. Вывод «TaintToleration раньше NodeAffinity» получен из фактического поведения default-scheduler на 1.36.2. Профиль планировщика настраивается, и на кластере с кастомным профилем порядок может быть другим.
Мягкие формы не замерялись. preferredDuringScheduling… у affinity и whenUnsatisfiable: ScheduleAnyway у spread в стенде не запускались — везде взяты жёсткие формы, чтобы результат был однозначным.
Порядок разбора Pending
Из всего разобранного складывается порядок, который экономит время на реальном Pending.
- Прочитать разбивку в
FailedScheduling— но как список первых причин, а не как статистику. Во всех замерах стенда сумма слагаемых равна числу узлов кластера — каждый узел учтён ровно один раз. СлагаемоеInsufficient <ресурс>называет ось прямо;didn't match Pod's node affinity/selectorне различаетnodeSelectorиnodeAffinity;untolerated taint(s)не называет тейнт по имени. - Сверить требования пода с реальностью кластера.
kubectl get nodes -o custom-columnsс нужными метками и.spec.taints— и вопрос «существует ли узел, удовлетворяющий всем требованиям одновременно». Если такого узла нет, ждать бессмысленно: это вечныйPending. - Если правило —
topologySpreadConstraints, посчитать домены, а не узлы. Домены считаются по всем узлам с нужной меткой, включая те, куда под всё равно не пустят: умолчаниеnodeTaintsPolicy—Ignore. Пустой недостижимый домен обнуляет минимум и раздувает перекос. - Если дело в ресурсах — сравнивать
requestsсallocatable, и только их. Ниkubectl top node, ниtop podна решение планировщика не влияют; узел может простаивать и при этом быть «полным». - Если под исчез, а не завис, — искать событие
Preemptedи сопоставлять UID вытеснителя вручную: имени в событии нет.
Отдельно держите в голове два ограничения из соседних тем. События живут час — если инцидент старше, describe pod будет пуст, и разбираться придётся по статусу объекта; это и остальная механика control plane разобраны в «Архитектуре Kubernetes». А Pending может вообще не дойти до планировщика: при отказе на admission (квота, LimitRange, PodSecurity) объекта пода просто не существует, и искать его в get pod бесполезно. Где виден сам отказ — зависит от того, кто создавал под: если напрямую kubectl apply, то только в ответе команды; если под создаёт контроллер, apply на Deployment проходит успешно, а отказ всплывает событием FailedCreate на ReplicaSet — смотреть надо туда.
Что читать дальше по смежным механизмам: «Ресурсы и QoS» — про requests/limits, квоты и классы качества, ту самую валюту, которой под платит за место; «StatefulSets и хранилище» — про то, как том привязывает под к узлу жёстче любого affinity; «Автоскейлинг» — про то, как число реплик меняется само и упирается в те же правила размещения; «Сеть в Kubernetes» — про DaemonSet-поды сети, которые терпят тейнты бессрочно; «Операторы и CRD»готовится, с 27 октября и «GitOps с ArgoCD»готовится, с 29 октября — про контроллеры, которые пересоздают вытесненные поды; «Секреты в Kubernetes» и «Безопасность подов»готовится, с 3 ноября — про admission, который отсекает под раньше планировщика. Устройство кластера, на котором всё это снято, — «Четыре отказа при bootstrap на Talos»; входящий трафик — статья про Gateway API; а вопрос, нужен ли вообще кластер под вашу задачу, разбирает «Kubernetes для домашней лаборатории».
Источники
- Kubernetes Documentation — Kubernetes Scheduler, Assigning Pods to Nodes, Taints and Tolerations
- Kubernetes Documentation — Pod Topology Spread Constraints, Pod Priority and Preemption, Node-pressure Eviction
- Kubernetes Documentation — Scheduling Framework, Scheduler Configuration, Reserve Compute Resources for System Daemons
- Kubernetes Documentation — Resource Quotas, Limit Ranges, Admission Controllers (
DefaultTolerationSeconds) - Стенд:
kubernetes/scheduling(Kubernetes 1.36.2, Talos v1.13.6, k8s-volga, namespacecookbook-k8s;containerd://2.2.5, образregistry.k8s.io/pause:3.10)
Комментарии