Архитектура Kubernetes: control plane и reconciliation loop

Что крутится, когда вы делаете kubectl apply: API server как единственная дверь к etcd, scheduler раскладывает поды по узлам, controller manager бесконечно сверяет желаемое с фактическим (reconciliation loop), kubelet на узле воплощает поды через CRI, а CNI/CSI дают им сеть и хранилище. Ментальная модель k8s как набора контроллеров вокруг декларативного состояния — фундамент под всю практику

Практику 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. Половина интересного здесь — это места, где привычная схема расходится с тем, что реально показывает кластер.

Ретрофутуристский разрез машинного зала кластера: вверху три одинаковых латунных пульта-apiserver, к каждому подходит один и тот же пучок труб, а за ними единственная бронированная дверь в хранилище-etcd, вынесенное за стену зала и подписанное как отдельный агрегат, не стоящий на общем стеллаже подов; ниже двумя рядами по три стоят пульты планировщика и контроллеров, и лишь у одного в каждом ряду горит лампа «ведущий», а его песочные часы аренды переворачивает часовой механизм, остальные четыре стоят под чехлами в горячем резерве; от двери вниз уходит конвейер с четырьмя постами, у каждого свой мастер со своим клеймом — на первом посту из карточки Deployment штампуется карточка ReplicaSet, на втором из неё две карточки подов, на третьем каждой карточке присваивается номер узла, на четвёртом карточка превращается в работающий котёл-контейнер; вдоль конвейера три красные стрелочные заслонки на местах возможной остановки, а над постами висит пневмопочта событий с табличкой «письма хранятся один час»

В статье

Что реально крутится в 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 и делать вывод о сетевом обращении к реестру нельзя.

Путь одного apply: кто автор каждого шагаkubectl apply7 запросов: 6 чтений + 1 POSTkube-apiserver ×3единственная дверь, 44 группы APIetcdна Talos — вне Kubernetesобъект Deployment trace-demoавтор: kubectl — единственная запись, 201 CreatedReplicaSet trace-demo-58b97d487cавтор: deployment-controller · ScalingReplicaSetдва объекта Pod, узел ещё не назначенавтор: replicaset-controller · SuccessfulCreateузлы назначены: wk03 и wk02автор: default-scheduler-k8s-volga-cp01 · Scheduledобраз получен, контейнер запущенавтор: kubelet узла · Pulled → Created → Startedсвязь шагов —ownerReferences:Pod → ReplicaSet→ Deploymentзалипание здесь:PendingImagePullBackOffCrashLoopBackOffkubectl создал один объект — остальные три родили контроллерыавторы не вызывают друг друга — координация через объекты в API

Как читать события и где они врут

События — главный инструмент разбора этой цепочки, и у них есть две особенности, которые сбивают с толку регулярно.

Первая: у событий планировщика поле .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 логов не существует вовсе, потому что контейнер ни разу не запускался.

Порядок диагностики

Из всего разобранного складывается порядок, который экономит больше всего времени, — и он обратный привычному.

  1. Определить стадию. kubectl get pod -o custom-columns=NAME:.metadata.name,SCHEDULED:.status.conditions[?(@.type=='PodScheduled')].status,NODE:.spec.nodeName — два поля отвечают на вопрос «до какой стадии дошли». Не PHASE: как показано выше, она про другое.
  2. Прочитать события той стадии. describe pod — и читать не «есть ли ошибки», а кто источник события: планировщик или kubelet. Источник называет ответственный компонент, а текст события — конкретный отказавший шаг.
  3. И только потом логи — если под дошёл до третьей стадии. На первых двух 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 для домашней лаборатории».

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии