Практику Kubernetes легко освоить по рецептам — applied манифест, поднялся под, — но без ментальной модели «что там крутится» отладка превращается в гадание. А модель простая и красивая: k8s — это декларативное состояние в etcd плюс рой контроллеров, каждый из которых бесконечно сверяет «как хочется» с «как есть» и подталкивает реальность к желаемому. Понять эту reconciliation loop и роли компонентов control plane важнее, чем помнить флаги kubectl: отсюда становится очевидно, почему под «висит в Pending», кто именно принимает решения и где искать причину.
Фундаментальная статья серии «Kubernetes на практике» — основа под все прикладные (ресурсыготовится, с 6 октября, сетьготовится, с 20 октября, операторыготовится, с 27 октября уже опираются на эти механизмы). Разбирать её будем не по схеме из документации, а по живому кластеру: всё, что ниже, снято на стенде kubernetes/architecture в репозитории digital-cookbook — Kubernetes v1.36.2 на кластере k8s-volga (шесть узлов под управлением Talos v1.13.6, среда исполнения containerd://2.2.5), клиент kubectl v1.36.1, демо-объекты в namespace cookbook-k8s. Половина интересного здесь — это места, где привычная схема расходится с тем, что реально показывает кластер.
В статье
- Что реально крутится в control plane
- Кто из трёх ведущий: аренда
- etcd, которого нет среди подов
- Reconciliation loop: сверка уровня, а не реакция на событие
- Один apply: семь запросов и одна запись
- Из одного объекта — четыре
- Четыре автора одной цепочки
- Как читать события и где они врут
- События живут час
- Три места залипания — три стадии одного пути
- Порядок диагностики
Что реально крутится в control plane
Начнём с состава. Кластер k8s-volga собран из шести узлов: три control-plane и три worker.
NAME VERSION OS RUNTIME TAINTS
k8s-volga-cp01 v1.36.2 Talos (v1.13.6) containerd://2.2.5 [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
k8s-volga-cp02 v1.36.2 Talos (v1.13.6) containerd://2.2.5 [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
k8s-volga-cp03 v1.36.2 Talos (v1.13.6) containerd://2.2.5 [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
k8s-volga-wk01 v1.36.2 Talos (v1.13.6) containerd://2.2.5 <none>
k8s-volga-wk02 v1.36.2 Talos (v1.13.6) containerd://2.2.5 <none>
k8s-volga-wk03 v1.36.2 Talos (v1.13.6) containerd://2.2.5 <none>
Уже здесь видна первая деталь, которая пригодится дальше: у всех трёх control-plane узлов стоит тейнт node-role.kubernetes.io/control-plane:NoSchedule, у воркеров тейнтов нет. Это не украшение — именно он отрежет три узла из шести в разборе Pending в конце статьи.
Сами компоненты control plane — это обычные поды в kube-system, и их ровно по три, по одному на каждый control-plane узел:
kube-apiserver-k8s-volga-cp01 k8s-volga-cp01 Running
kube-apiserver-k8s-volga-cp02 k8s-volga-cp02 Running
kube-apiserver-k8s-volga-cp03 k8s-volga-cp03 Running
kube-controller-manager-k8s-volga-cp01 k8s-volga-cp01 Running
kube-controller-manager-k8s-volga-cp02 k8s-volga-cp02 Running
kube-controller-manager-k8s-volga-cp03 k8s-volga-cp03 Running
kube-scheduler-k8s-volga-cp01 k8s-volga-cp01 Running
kube-scheduler-k8s-volga-cp02 k8s-volga-cp02 Running
kube-scheduler-k8s-volga-cp03 k8s-volga-cp03 Running
Раскладка 1:1:1 — так HA control plane и выглядит: девять подов, три роли, три узла. Но Running у всех девяти не означает, что все девять работают одинаково, и это следующий раздел.
API server — единственная дверь. Через него ходят все: и kubectl, и планировщик, и контроллеры, и kubelet на узлах. Клиенты и контроллеры не ходят в хранилище мимо API server: состояние кластера читается и пишется только через него. Прямые соединения между компонентами при этом существуют — сам API server, например, обращается к kubelet напрямую, когда нужны logs, exec или port-forward, — но состояние через них не проходит. На этом кластере API server отдаёт 44 группы/версии API — от базовых до пришедших с установленными операторами:
acme.cert-manager.io/v1
acme.selectel.ru/v1alpha1
admissionregistration.k8s.io/v1
apiextensions.k8s.io/v1
apiregistration.k8s.io/v1
apps/v1
argocd-image-updater.argoproj.io/v1alpha1
argoproj.io/v1alpha1
authentication.k8s.io/v1
authorization.k8s.io/v1
autoscaling.k8s.io/v1
autoscaling/v1
Это первые двенадцать из списка, и в них уже видно, что словарь кластера расширяем: acme.cert-manager.io, argoproj.io, autoscaling.k8s.io пришли не с Kubernetes, а с установленными в кластер операторами — механика такого расширения разбирается в «Операторы и CRD: расширяем Kubernetes под себя»готовится, с 27 октября. Для архитектуры важно другое: сколько бы типов ни появилось, дверь остаётся одна, и правила аутентификации, авторизации и admission одинаковы для всех.
Кто из трёх ведущий: аренда
Три экземпляра планировщика не раскладывают поды втроём — иначе два из них принимали бы решения, ничего не зная о решениях третьего. Работает один, остальные ждут; выбор ведущего оформлен объектом Lease в kube-system. Смотрим держателей аренды и время продления:
kube-controller-manager k8s-volga-cp01_45d5c755-126d-4f77-8f2b-5877c488a851 2026-07-24T07:55:03.810125Z
kube-scheduler k8s-volga-cp01_43df9b3b-94f2-457c-85db-791d6f6d2c4f 2026-07-24T07:55:03.373783Z
Обе аренды держит k8s-volga-cp01. Повторяем ту же выборку и сравниваем:
kube-controller-manager k8s-volga-cp01_45d5c755-126d-4f77-8f2b-5877c488a851 2026-07-24T07:55:35.992875Z
kube-scheduler k8s-volga-cp01_43df9b3b-94f2-457c-85db-791d6f6d2c4f 2026-07-24T07:55:35.522676Z
holderIdentity тот же, вплоть до UUID, а renewTime продвинулось: у kube-controller-manager на 32,18 с, у kube-scheduler на 32,15 с. Это и есть наблюдение: за интервал между снимками ведущий не менялся, аренду продлевал тот же держатель.
Здесь стоит задержаться на арифметике, потому что она — типовой источник неверных выводов. Между двумя выборками в команде стоит sleep 20, и соблазнительно написать «через 20 секунд». Но 20 — это параметр команды, а не измеренный интервал: между вызовами есть ещё сами вызовы kubectl, и по меткам времени разрыв получился 32 с с копейками. Правило простое: измеренным считается то, что стоит в метках, а не то, что вы просили у sleep. Разница в полтора раза здесь ни на что не влияет, но привычка брать интервал из параметра команды однажды влияет — и всегда в сторону красивого числа.
Практическое следствие HA-раскладки: у kube-scheduler и kube-controller-manager в каждый момент активен ровно один экземпляр, остальные два — горячий резерв, который держит соединение с API и ждёт, пока аренда протухнет. Падение ведущего означает не отказ, а паузу до перехвата аренды: новый лидер поднимет свои контроллеры и продолжит с текущего состояния в хранилище, потому что состояние не в памяти лидера, а в API. С kube-apiserver иначе: лидера он не выбирает вовсе — все три экземпляра равноправны и обслуживают запросы одновременно, а состояние держат не у себя, поэтому и масштабируются горизонтально. Это документированное свойство компонента, а не вывод из выборки выше: она показывает две аренды, но отсутствия других не доказывает.
etcd, которого нет среди подов
Теперь контринтуитивный факт этого кластера. Ищем поды etcd — во всех namespace, не только в kube-system:
--- а есть ли под etcd? ---
(подов с etcd в имени не найдено ни в одном namespace)
Ноль. При том что кластер прекрасно работает, состояние где-то хранится и кворум явно есть.
Разгадка — в операционной системе узлов. Это свойство Talos, а не Kubernetes: там etcd запускается как отдельный процесс операционной системы, не под управлением kubelet, и поэтому в списке подов ему взяться неоткуда. На кластере, поднятом kubeadm, привычное представление «etcd — статик-под в kube-system» как раз верно, и та же команда покажет три пода. Так что вывод из фикстуры — узкий и честный: kubelet этим etcd не управляет как подом. Где именно он работает, этой командой не показать, и утверждать «etcd тут нет» нельзя — он есть, просто живёт в другом слое.
Почему это стоит того, чтобы отдельно проговорить. Ментальная модель «control plane — это набор подов в kube-system» удобна и в большинстве кластеров работает, но она не часть контракта Kubernetes. Компоненты control plane могут быть подами, статик-подами, systemd-юнитами или процессами ОС дистрибутива — Kubernetes про это ничего не обещает. Единственное, на что можно опираться при отладке, — это API: он одинаков независимо от того, как упакованы компоненты за ним. Как выглядит такой кластер снаружи, когда к узлам нет даже SSH, разбирается в «Четыре отказа при bootstrap на Talos».
Reconciliation loop: сверка уровня, а не реакция на событие
Прежде чем разбирать путь apply, стоит зафиксировать модель, которая объясняет всё остальное.
Контроллер в Kubernetes — это процесс, который в цикле делает три вещи: читает желаемое состояние из объектов API, смотрит на фактическое состояние, устраняет разницу. Не «получает команду и выполняет её», а сверяет два уровня и подталкивает факт к желаемому. Разница между этими двумя формулировками — практическая, а не философская, и видна она в трёх местах.
Первое: идемпотентность вместо последовательности шагов. Reconcile вызывают неизвестное число раз, в том числе на объекте, который никто не менял. Правильный контроллер от повторного вызова не портится: если всё уже совпадает, он не делает ничего.
Второе: самолечение без единой команды. Удалите под из ReplicaSet — он вернётся, потому что контроллер увидит, что фактическое число реплик меньше желаемого. Никто не «поймал событие удаления и выполнил создание»; контроллер просто в очередной раз сравнил 2 и 1.
Третье: потеря события не фатальна. Контроллеры используют watch как быстрый вход, но не как единственный. Настоящая гарантия здесь — не какой-то обязательный обход, а сам level-based подход: при обрыве watch информер не просто ждёт следующего события, а переустанавливает соединение — делает relist (полный список из API) и заново открывает watch (re-watch), а контроллер работает от состояния, а не от дельт, поэтому пропущенные за время разрыва события ничего не ломают — расхождение всё равно найдётся при сверке уровней. Периодический полный resync — это опциональная надстройка сверху, а не всеобщий закон: его можно выключить, и в client-go нулевой resyncPeriod означает, что периодического resync просто нет. Когда resync включён, разницу между двумя входами в петлю — событием и плановым тиком resync — удобно разбирать на операторе, где интервал виден в коде и его влияние измеримо: в «Операторах и CRD»готовится, с 27 октября на живом стенде порча порождённого объекта чинилась не событием, а плановым resync, и задержка целиком объяснялась временем до ближайшего тика.
Ровно эта же петля лежит в основе GitOps: ArgoCDготовится, с 29 октября непрерывно сравнивает git с кластером тем же способом, что встроенные контроллеры — спецификацию объекта с реальностью. И ровно она объясняет, почему kubectl apply — это не «выполнить действие», а «записать желаемое». Что происходит после записи, дальше и посмотрим.
Один apply: семь запросов и одна запись
Применяем обычный Deployment на две реплики. Флаг -v=6 показывает HTTP-запросы, которые kubectl шлёт к API server (сокращённо — глагол, URL и статус):
GET .../openapi/v3?timeout=32s 200 OK
GET .../openapi/v3/apis/apps/v1?hash=E6837BF0…&timeout=32s 200 OK
GET .../api?timeout=32s 200 OK
GET .../apis?timeout=32s 200 OK
GET .../apis/apps/v1/namespaces/cookbook-k8s/deployments/trace-demo 404 Not Found
GET .../api/v1/namespaces/cookbook-k8s 200 OK
POST .../apis/apps/v1/namespaces/cookbook-k8s/deployments?fieldManager=kubectl-client-side-apply&fieldValidation=Strict 201 Created
Семь запросов, из них шесть — чтения и ровно один — запись. Чтения выясняют схему (/openapi/v3 и схема группы apps/v1), список доступных групп (/api, /apis), существует ли объект (404 Not Found — не существует, значит будет создание, а не патч) и жив ли namespace. Запись — единственный POST, который создаёт один объект: Deployment.
На этом участие kubectl заканчивается полностью. Ни ReplicaSet, ни подов он не создаёт и даже не знает про них. Всё остальное — работа контроллеров, каждый из которых увидит новый объект в API и сделает свою часть.
Важная оговорка про сам инструмент: -v=6 показывает запросы kubectl, и только их. Ни вызовов контроллеров, ни обращений планировщика, ни трафика kubelet в этот лог не попадает — это клиентский лог, а не трассировка кластера. Более подробные уровни (-v=8 и выше) добавят тела запросов того же клиента, но чужих вызовов не покажут. Цепочку дальше придётся восстанавливать другими средствами — ownerReferences и событиями.
Ещё одна деталь для воспроизведения: картина «шесть чтений и один POST» видна только на первом применении. При повторном apply того же манифеста объект уже существует, проверочный GET вернёт 200 OK, и client-side apply пойдёт другим путём — PATCH вместо POST. Это свойство самого механизма, а не замер этого прогона: повторного применения в фикстурах нет, и в README стенда оно записано как условие съёмки — перед новым снятием Deployment надо удалить.
Из одного объекта — четыре
Через пятнадцать секунд после применения в namespace лежит не один объект, а четыре:
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/trace-demo 2/2 2 2 15s
NAME DESIRED CURRENT READY AGE
replicaset.apps/trace-demo-58b97d487c 2 2 2 15s
NAME READY STATUS RESTARTS AGE
pod/trace-demo-58b97d487c-4lj98 1/1 Running 0 15s
pod/trace-demo-58b97d487c-zczbk 1/1 Running 0 15s
Связь между ними — не по совпадению имён (хотя имена и похожи), а по полю ownerReferences, которое проставляет создатель:
pod=trace-demo-58b97d487c-4lj98 owner=ReplicaSet/trace-demo-58b97d487c
rs=trace-demo-58b97d487c owner=Deployment/trace-demo
Это не косметика и не удобство kubectl tree. На ownerReferences держится каскадное удаление: снося Deployment, вы не удаляете ReplicaSet и поды руками — сборщик мусора находит их по ссылке на владельца. Отсутствие такой ссылки означает, что объект осиротеет и переживёт родителя, — сюжет, который в «Операторах и CRD»готовится, с 27 октября разобран на порождённом Secret, оставшемся в кластере после удаления своего Certificate.
Четыре автора одной цепочки
Теперь главное. Четыре объекта появились не сами и не по одной команде — их создали четыре разных источника, и каждый оставил след в событиях.
| Объект | Reason | Кто источник |
|---|---|---|
Deployment/trace-demo |
— (создан клиентом) | kubectl, POST … 201 Created |
Deployment/trace-demo |
ScalingReplicaSet (Scaled up replica set trace-demo-58b97d487c from 0 to 2) |
deployment-controller |
ReplicaSet/trace-demo-58b97d487c |
SuccessfulCreate ×2 (Created pod: …-4lj98, Created pod: …-zczbk) |
replicaset-controller |
Pod/…-4lj98, Pod/…-zczbk |
Scheduled |
default-scheduler, экземпляр default-scheduler-k8s-volga-cp01 |
Pod/…-4lj98, Pod/…-zczbk |
Pulled → Created → Started |
kubelet |
Ни один из этих источников не вызывает остальных напрямую. deployment-controller не дёргает replicaset-controller, планировщик не уведомляет kubelet — прямых вызовов между процессами нет. Координация идёт через объекты в API: один создаёт или меняет объект, другой его наблюдает. deployment-controller наблюдает ReplicaSet, replicaset-controller управляет подами через selectors и ownerReferences — то есть о связанных объектах они прекрасно осведомлены. Развязаны они по вызовам, но связаны через наблюдаемые объекты и владение: результат работы одного становится входом для другого, и из этого складывается цепочка Deployment → ReplicaSet → Pod.
Отдельно стоит заметить экземпляр планировщика: default-scheduler-k8s-volga-cp01 — тот самый узел, который держит аренду kube-scheduler. Ведущий из раздела про Lease здесь и работает; два его резервных близнеца в этой цепочке не участвовали.
И маленькая деталь, которая экономит время при отладке: событие Pulled в этом прогоне гласит Container image "registry.k8s.io/pause:3.10" already present on machine and can be accessed by the pod — образ уже лежал на узлах, скачивать ничего не потребовалось. Reason всё равно Pulled: он означает «образ готов», а не «образ скачан». Видеть Pulled и делать вывод о сетевом обращении к реестру нельзя.
Как читать события и где они врут
События — главный инструмент разбора этой цепочки, и у них есть две особенности, которые сбивают с толку регулярно.
Первая: у событий планировщика поле .source.component пусто. Вот выборка событий Scheduled с четырьмя колонками — старым .source.component, новыми .reportingComponent и .reportingInstance и сообщением:
trace-demo-58b97d487c-4lj98 <none> default-scheduler default-scheduler-k8s-volga-cp01 Successfully assigned cookbook-k8s/trace-demo-58b97d487c-4lj98 to k8s-volga-wk03
trace-demo-58b97d487c-zczbk <none> default-scheduler default-scheduler-k8s-volga-cp01 Successfully assigned cookbook-k8s/trace-demo-58b97d487c-zczbk to k8s-volga-wk02
<none> в первой колонке — это не «источник неизвестен», а следствие смены API: планировщик пишет события через events.k8s.io/v1, где источник лежит в .reportingComponent и .reportingInstance, а не в устаревшем .source. Контроллеры и kubelet на этом кластере ещё пишут по-старому, поэтому у них .source.component заполнен, и картина получается смешанная.
Практическое следствие прямое и неприятное: типовой совет «отфильтруй события по .source.component» решений планировщика не поймает. Событие есть, оно в API, но по такому фильтру или в такой колонке оно выглядит безымянным — и его легко счесть отсутствующим, а планировщик — не сработавшим. Фильтровать надо по reason или смотреть обе пары полей.
Вторая: сортировка по времени врёт внутри секунды. Просим kubectl отсортировать события по .metadata.creationTimestamp и получаем (сокращённо — время, объект, reason, .source.component, .reportingComponent):
2026-07-24T08:27:57Z Pod …-4lj98 Scheduled <none> default-scheduler
2026-07-24T08:27:57Z Pod …-4lj98 Pulled kubelet kubelet
2026-07-24T08:27:57Z Pod …-zczbk Scheduled <none> default-scheduler
2026-07-24T08:27:57Z ReplicaSet …-58b97d487c SuccessfulCreate replicaset-controller replicaset-controller
2026-07-24T08:27:57Z ReplicaSet …-58b97d487c SuccessfulCreate replicaset-controller replicaset-controller
2026-07-24T08:27:57Z Deployment trace-demo ScalingReplicaSet deployment-controller deployment-controller
Прочитанное буквально, это бессмыслица: под «назначен на узел» раньше, чем ReplicaSet сообщил о его создании, а масштабирование ReplicaSet — вообще последним из шести. Причина в разрешении: creationTimestamp у событий — с точностью до секунды, и все шесть уложились в одну и ту же секунду 08:27:57Z. Внутри секунды сортировка расставляет строки как придётся, и получается правдоподобный, но ложный причинный порядок.
Порядок записи событий можно прикинуть по имени события: оно строится как <объект>.<hex наносекунд>. Оговоримся сразу — hex-суффикс UnixNano в имени это деталь текущей реализации event recorder, а не контракт Kubernetes API: официально Events — best-effort вспомогательные данные, и их порядок API не гарантирует и строгим причинным порядком считать нельзя. Но как forensic-подсказка о порядке, в котором события были записаны в этой версии, суффикс работает. Сортируем по нему:
18c52c6dc5033ae7 ScalingReplicaSet Deployment/trace-demo
18c52c6dc606d9d9 SuccessfulCreate ReplicaSet/trace-demo-58b97d487c
18c52c6dc652668c Scheduled Pod/trace-demo-58b97d487c-4lj98
18c52c6dc697a3e4 SuccessfulCreate ReplicaSet/trace-demo-58b97d487c
18c52c6dc70842a2 Scheduled Pod/trace-demo-58b97d487c-zczbk
18c52c6deeff187a Pulled Pod/trace-demo-58b97d487c-4lj98
18c52c6defd8fa1b Created Pod/trace-demo-58b97d487c-4lj98
18c52c6df476a548 Pulled Pod/trace-demo-58b97d487c-zczbk
18c52c6df5014d8e Created Pod/trace-demo-58b97d487c-zczbk
18c52c6dfb415353 Started Pod/trace-demo-58b97d487c-4lj98
18c52c6e064a1300 Started Pod/trace-demo-58b97d487c-zczbk
Вот теперь картина осмысленная: сначала deployment-controller масштабирует ReplicaSet, затем replicaset-controller создаёт первый под, планировщик его назначает, дальше второй под — и только потом начинается работа kubelet. Суффикс проверяем: 18c52c6dc652668c — это 1784881677312616076 наносекунд, то есть 08:27:57.312616Z, а .eventTime того же события — 2026-07-24T08:27:57.312611Z.
К этому приёму нужны две оговорки, без которых он превращается в новый способ ошибиться.
Наносекунда в имени — момент записи события, а не момент самого действия. Событие пишется после того, как компонент что-то сделал, и с задержкой, которая нигде не зафиксирована. Порядок записей — хорошее приближение к порядку действий, но не доказательство.
Сравнивать наносекунды можно не везде — они с разных часов. Первые пять событий списка (ScalingReplicaSet, SuccessfulCreate ×2, Scheduled ×2) пишет один хост: обе аренды, и kube-controller-manager, и kube-scheduler, держит k8s-volga-cp01, а у планировщика это подтверждено прямо в событии — reportingInstance равен default-scheduler-k8s-volga-cp01. Часы одни, и порядок записи этих пяти восстановлен надёжно — насколько вообще позволяет реконструкция по одному источнику часов. Оговорка и к этому: снимок аренд сделан в 07:55:36Z, то есть за полчаса до самих событий (08:27:57Z), и прямо на момент события подтверждён только хост планировщика — через reportingInstance; у kube-controller-manager такого поля нет. Признаков смены лидера в промежутке тоже нет: обе выборки аренды показывают один и тот же holderIdentity с тем же UUID и штатно продвинувшимся renewTime. А события Pulled/Created/Started пишут kubelet’ы разных узлов — k8s-volga-wk03 для пода …-4lj98 и k8s-volga-wk02 для …-zczbk:
trace-demo-58b97d487c-4lj98 Pulled k8s-volga-wk03 k8s-volga-wk03
trace-demo-58b97d487c-zczbk Pulled k8s-volga-wk02 k8s-volga-wk02
Внутри одного пода эти три события сняты с одних часов, и их порядок корректен. А вот взаимный порядок между двумя подами опирается на синхронность часов двух разных узлов — этими данными он не доказан, и строить по нему рассказ «на одном узле получилось быстрее» нельзя. Это общее правило распределённой отладки: сравнение меток времени осмысленно ровно в пределах одного источника часов.
События живут час
Последняя особенность событий — та, из-за которой картину выше невозможно снять задним числом. Проверка сделана через 1 ч 37 мин после применения. Поды живы:
trace-demo-58b97d487c-4lj98 2026-07-24T08:27:57Z
trace-demo-58b97d487c-zczbk 2026-07-24T08:27:57Z
Событий про них — ровно ноль:
# kubectl -n cookbook-k8s get events --no-headers | grep -c trace-demo
0
Это не поломка. API server чистит события по --event-ttl, значение по умолчанию — час. Поды при этом совершенно здоровы, работают и обслуживают нагрузку, а describe pod покажет у них Events: <none>.
Вывод сугубо практический. Пустой список событий у здорового пода не значит ничего, кроме того, что событий больше нет. Диагностическая ценность здесь односторонняя: наличие событий — сигнал, отсутствие — не сигнал. И обратная сторона: если инцидент случился час назад и вы приходите разбираться сейчас, событий уже не будет — надо смотреть то, что живёт дольше: статус объекта, restartCount, lastState, метрики и логи. Отсюда же практика собирать события в долговременное хранилище, если разбор инцидентов — регулярная работа, а не разовая.
Три места залипания — три стадии одного пути
Теперь самое полезное следствие всей конструкции. Pending, ImagePullBackOff и CrashLoopBackOff — это не три разные болезни, а три разные точки остановки на одном и том же пути. Ломаем путь в трёх местах и смотрим на три пода:
NAME PHASE REASON RESTARTS NODE
stuck-pending Pending <none> <none> <none>
stuck-imagepull Pending ImagePullBackOff 0 k8s-volga-wk03
stuck-crashloop Running CrashLoopBackOff 4 k8s-volga-wk03
Первое, что здесь стоит заметить: PHASE стадию не показывает. У stuck-pending и stuck-imagepull фаза одна и та же — Pending, хотя остановились они в совершенно разных местах: у первого узла нет вовсе, у второго узел давно назначен и работа идёт на нём. А stuck-crashloop показывает Running — при CrashLoopBackOff в причине ожидания и четырёх перезапусках. Ориентироваться на колонку STATUS/PHASE при диагностике бессмысленно: она отвечает на другой вопрос.
Стадию показывает другой срез — условие PodScheduled, назначенный узел и готовность:
NAME SCHEDULED NODE READY
stuck-pending False <none> <none>
stuck-imagepull True k8s-volga-wk03 False
stuck-crashloop True k8s-volga-wk03 False
Вот это уже разделение стадий, и оно однозначное. PodScheduled=False с пустым nodeName — планировщик решения не принял, под до узла не доехал, и всё, что происходит дальше по пути, ещё не начиналось. PodScheduled=True с назначенным узлом — планировщик отработал, дальше отвечает kubelet узла. У stuck-pending условия Ready нет вовсе (<none>): оно появляется только после назначения на узел.
Второй, независимый срез той же картины — авторство событий: у stuck-pending единственное событие пишет default-scheduler, у двух других первое событие от планировщика успешное (Successfully assigned …), а всё остальное пишет kubelet. Два разных среза дают одинаковый ответ — значит стадия определена, а не угадана.
| Состояние | Стадия пути | Кто зафиксировал | Что смотреть |
|---|---|---|---|
Pending, узла нет |
отбор узла (планировщик) | default-scheduler, событие FailedScheduling |
разбор предикатов в событии: сколько узлов отпало и почему |
ImagePullBackOff / ErrImagePull |
получение образа на узле | kubelet, события Pulling → Failed |
текст ошибки реестра в событии Failed |
CrashLoopBackOff |
запуск контейнера | kubelet, события Started → BackOff |
restartCount и lastState.terminated, затем логи контейнера |
Стадия 1: планировщик не нашёл узла
Warning FailedScheduling 2m54s 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.
Событие — не «не получилось», а отчёт по всем узлам: из шести три отпали по несовпадению меток (под требует метки, которой нет ни на одном узле), три — по тейнту, который под не терпит. Это те самые control-plane узлы с node-role.kubernetes.io/control-plane:NoSchedule из первого раздела — но обратите внимание, что само событие тейнт по имени не называет: в 1.36 текст ограничивается формулировкой 3 node(s) had untolerated taint(s). Имя тейнта приходится доставать отдельно, из описания узлов.
Читать разбивку как «три узла селектор прошли» тоже нельзя: требуемой метки нет нигде, просто планировщик относит каждый узел к одной причине и складывает узлы в группы, а не перечисляет все провалившиеся предикаты по каждому. Вторая половина строки — про вытеснение: планировщик проверил, не поможет ли выселить кого-то менее приоритетного, и получил «не поможет» для всех шести узлов.
Почему этот Pending сделан несуществующей меткой, а не огромным requests.cpu. Хрестоматийная причина Pending в проде — Insufficient cpu, но на этом кластере её не воспроизвести, и мешает не то, о чём думается в первую очередь. Попытка запросить 5 CPU не доходит даже до планировщика:
Error from server (Forbidden): error when creating "STDIN": pods "stuck-pending-cpu" is forbidden: maximum cpu usage per Container is 2, but limit is 5
Это отказ на admission — принципиально другая стадия, ещё до записи в хранилище. Объекта не существует (kubectl get pod stuck-pending-cpu отвечает NotFound), событий нет, потому что событиям не о чем сообщать, и describe pod в такой ситуации бесполезен: смотреть надо на текст ответа kubectl apply.
Но LimitRange, который здесь сработал, потолок пода не задаёт: его max cpu: 2 — на контейнер, о чём прямо говорит и сам текст отказа (maximum cpu usage per Container is 2). Под из двух контейнеров по 1525m прошёл бы его без вопросов. Настоящий потолок ставит ResourceQuota namespace: requests.cpu — 4, занято 950m, остаток — 3050m. А свободного CPU на самом загруженном воркере (wk02: занято 830m из 3950m) — 3120m; на wk01 — 3170m, на wk03 — 3320m. Меньше 3120m свободного нет ни на одном воркере, а больше 3050m квота не пропустит: всё, что прошло квоту, помещается на любой воркер. Запас всего 70m, но знак от этого не меняется, и Insufficient cpu на этом кластере закрывает именно квота, а не LimitRange. Поэтому взят другой предикат той же стадии — заведомо невыполнимый nodeSelector, а requests у пода намеренно скромные, чтобы ресурсы в отборе не участвовали вовсе. Числа эти — снимок конкретного кластера в конкретный момент; на другом арифметика будет своя, и считать её надо заново по тем же двум величинам. Как устроены requests/limits и квоты, разбирается в «Ресурсах в Kubernetes»готовится, с 6 октября.
Стадия 2: узел выбран, образа нет
Normal Scheduled 2m54s default-scheduler Successfully assigned cookbook-k8s/stuck-imagepull to k8s-volga-wk03
Normal Pulling 79s (x4 over 2m53s) kubelet spec.containers{nope}: Pulling image "example.invalid/nope:0.0.1"
Warning Failed 79s (x4 over 2m53s) kubelet spec.containers{nope}: Failed to pull image "example.invalid/nope:0.0.1": failed to pull and unpack image "example.invalid/nope:0.0.1": failed to resolve reference "example.invalid/nope:0.0.1": failed to do request: Head "https://example.invalid/v2/nope/manifests/0.0.1": Service Unavailable
Warning Failed 79s (x4 over 2m53s) kubelet spec.containers{nope}: Error: ErrImagePull
Normal BackOff 0s (x11 over 2m53s) kubelet spec.containers{nope}: Back-off pulling image "example.invalid/nope:0.0.1"
Warning Failed 0s (x11 over 2m53s) kubelet spec.containers{nope}: Error: ImagePullBackOff
Первая же строка снимает подозрение с планировщика: Successfully assigned … to k8s-volga-wk03, своё дело он сделал. Дальше говорит только kubelet, и он называет конкретный шаг, на котором споткнулся, — HTTP-запрос HEAD https://example.invalid/v2/nope/manifests/0.0.1 к реестру, ответ Service Unavailable.
Это стоит прочитать внимательно, потому что тут легко перепутать слой. Сообщение говорит, что HTTP-запрос состоялся и получил ответ, — значит до реестра дошли; сбой резолва имени выглядел бы иначе, как dial tcp: lookup ...: no such host. Полезно, что в тексте видно и путь Registry API, и ответ: по нему сразу отличают недоступность реестра от «нет такого тега» и от отказа авторизации, а это три разных действия по исправлению.
ErrImagePull и ImagePullBackOff — не два диагноза, а одно состояние в развитии: первый означает очередную неудачную попытку, второй — паузу между попытками. Счётчики это и показывают: попыток скачивания было 4 (x4), а событий backoff — 11 (x11). Почему backoff-событий втрое больше, из этих данных не следует: x11 считает, сколько раз событие было выпущено на циклах синхронизации kubelet, а не длительность пауз. Из чисел надёжно читается только одно — состояние ожидания фиксировалось чаще, чем совершались сами попытки.
Стадия 3: контейнер запущен и сразу упал
Normal Scheduled 2m54s default-scheduler Successfully assigned cookbook-k8s/stuck-crashloop to k8s-volga-wk03
Normal Pulled 78s (x5 over 2m53s) kubelet spec.containers{crasher}: Container image "busybox:1.37" already present on machine and can be accessed by the pod
Normal Created 78s (x5 over 2m53s) kubelet spec.containers{crasher}: Container created
Normal Started 78s (x5 over 2m53s) kubelet spec.containers{crasher}: Container started
Warning BackOff 8s (x5 over 2m51s) kubelet spec.containers{crasher}: Back-off restarting failed container crasher in pod stuck-crashloop_cookbook-k8s(5a947fc5-f487-4581-a7d0-f56da80df9d1)
Этот под прошёл весь путь целиком: назначен на узел, образ получен, контейнер создан и запущен — Started в событиях есть, причём пять раз. То есть Kubernetes сделал ровно всё, что от него требовалось; сломалось приложение внутри. Что именно случилось, из событий уже не узнать, но статус пода отвечает прямо:
Error exitCode=1
lastState.terminated хранит исход прошлой попытки. Отсюда путь ведёт в логи контейнера: причина не в кластере, а внутри процесса. И это принципиальный водораздел — на стадиях 1 и 2 логов не существует вовсе, потому что контейнер ни разу не запускался.
Порядок диагностики
Из всего разобранного складывается порядок, который экономит больше всего времени, — и он обратный привычному.
- Определить стадию.
kubectl get pod -o custom-columns=NAME:.metadata.name,SCHEDULED:.status.conditions[?(@.type=='PodScheduled')].status,NODE:.spec.nodeName— два поля отвечают на вопрос «до какой стадии дошли». НеPHASE: как показано выше, она про другое. - Прочитать события той стадии.
describe pod— и читать не «есть ли ошибки», а кто источник события: планировщик или kubelet. Источник называет ответственный компонент, а текст события — конкретный отказавший шаг. - И только потом логи — если под дошёл до третьей стадии. На первых двух
kubectl logsне даст ничего.
Обратный порядок («сначала посмотрим логи») на стадиях 1 и 2 стоит нескольких минут и заканчивается недоумением: логов нет, а под «не работает».
Отдельно держите в голове две поправки из этой статьи. События живут час — если инцидент старше, разбирайте по статусу объекта, а не по пустому describe. И события планировщика с пустым .source.component — не отсутствующие, просто написанные по новому API.
И мостик дальше. На стадии отбора узла планировщик сказал Successfully assigned … to k8s-volga-wk03 — то есть выбрал узел. Здесь мы приняли это как факт: узел назначен, дальше отвечает kubelet. А вот как он этот узел выбрал — что за фильтры отсекли непригодные узлы, как оставшиеся ранжировались, что делают nodeAffinity, podAntiAffinity, тейнты, topologySpreadConstraints и приоритеты с вытеснением — целиком тема отдельной статьи серии: «Планирование подов вглубь: taints, affinity, topology и приоритеты»готовится, с 8 октября.
Остальные механизмы, которые в этой статье остались за скобками, разобраны рядом: как kubelet превращает манифест в процессы с namespaces и cgroups — в «Внутренностях контейнера»Скоро; как сеть подключается к поду через CNI и что делает eBPF-датаплейн — в «Сети в Kubernetes: Cilium и когда нужен service mesh»готовится, с 20 октября; как контроллеры ведут тома и упорядоченные реплики — в «StatefulSets и хранилище»готовится, с 15 октября; как желаемое состояние меняется автоматически по метрикам — в «Автоскейлинге: HPA, VPA и KEDA»готовится, с 13 октября; как секреты попадают в кластер, не попадая в git, — в «Секретах в Kubernetes»готовится, с 22 октября; как та же петля сведения состояний работает у вашего собственного контроллера — в «Операторах и CRD»готовится, с 27 октября. Входящий трафик — тема статьи про Gateway API; устройство кластера, на котором всё это снято, — «Четыре отказа при bootstrap на Talos»; а вопрос, нужен ли вообще кластер под вашу задачу, разбирает «Kubernetes для домашней лаборатории».
Источники
- Kubernetes Documentation — Kubernetes Components, Control Plane-Node Communication, Controllers, Leases
- Kubernetes Documentation — Pod Lifecycle, Owners and Dependents, Deployments, ReplicaSet
- Kubernetes Documentation — Kubernetes Scheduler, Images, Resource Quotas, Limit Ranges
- Kubernetes Reference — kube-apiserver (флаг
--event-ttl), Event v1 core, Event v1 events.k8s.io - Talos Linux — Architecture, etcd в Talos
- Стенд:
kubernetes/architecture(Kubernetes 1.36.2, Talos v1.13.6, k8s-volga, namespacecookbook-k8s;containerd://2.2.5, образыregistry.k8s.io/pause:3.10иbusybox:1.37)
Комментарии