kube-controller-manager: reconcile loops
kube-controller-manager — это процесс, внутри которого живут ~30 разных controller-ов. Каждый controller следит за своим типом ресурсов и поддерживает инвариант: actual state = desired state. Когда вы создаёте Deployment, в дело включается цепочка: DeploymentController создаёт ReplicaSet, ReplicaSetController создаёт Pod-ы, scheduler привязывает их к нодам, kubelet запускает контейнеры.
В этом уроке разбираем reconcile loop pattern, informer caches (фундамент масштабируемости client-go SDK), leader election через Lease, и ownerReferences — механизм каскадного удаления. Это самая важная часть курса для тех, кто планирует писать operators.
Pipes и IPC: как процессы общаются через kernel
Reconcile loop: универсальный паттерн
Каждый controller построен по одной схеме:
Это level-triggered reconciliation. Controller не реагирует на отдельные events (“этот Pod упал — создать новый”). Он реагирует на состояние: “у этого ReplicaSet должно быть 3 Pod-а, сейчас 2 — создать ещё один”. Если упало 5 Pod-ов разом, controller увидит “должно быть 3, есть 0”, создаст 3 за раз.
Level-triggered подход даёт фундаментальную надёжность: даже если controller пропустил event (рестарт, network glitch, queue overflow) — следующий reconcile увидит правильный diff и исправит. Edge-triggered подход (реагируем на конкретный delta) такой устойчивости не даёт.
Informers: shared cache + watch
Если бы каждый controller делал свои watch-ы независимо и хранил свой кэш, мы бы получили N×M traffic на apiserver. Поэтому в client-go SDK есть shared informer — singleton-кэш per (resource type, namespace), который используют все controllers.
controller-manager process
├── SharedInformerFactory
│ ├── PodInformer (один на весь процесс, кэш всех Pod-ов)
│ │ ├── Watch: GET /api/v1/pods?watch=true&resourceVersion=...
│ │ ├── Local cache: thread-safe in-memory store
│ │ └── EventHandlers: ReplicaSetController, NodeController, ...
│ ├── ReplicaSetInformer (кэш всех RS)
│ ├── DeploymentInformer
│ ├── ...
│ └── (~30 информеров для основных типов)
│
├── DeploymentController подписан на DeploymentInformer + ReplicaSetInformer
├── ReplicaSetController подписан на ReplicaSetInformer + PodInformer
├── NodeController подписан на NodeInformer + PodInformer + LeaseInformer
├── JobController подписан на JobInformer + PodInformer
└── ...
Каждый informer:
- На старте делает LIST → набивает локальный cache.
- Открывает WATCH с последним resourceVersion → получает updates стримом.
- Применяет updates к cache, вызывает EventHandlers (Add/Update/Delete callbacks).
- EventHandlers обычно кладут ключи в work queue своего controller-а.
Это означает: когда controller хочет прочитать Pod — он не делает GET на apiserver, а читает из локального cache. Это microsecond-операция, не millisecond. И apiserver обслуживает не N×M GET-ов, а один WATCH на тип ресурса от controller-manager.
informer cache eventually consistent — данные могут быть устаревшими на сотни миллисекунд по сравнению с etcd. Это не баг, это feature. Controllers пишут idempotent reconcile — если выполнили действие на устаревшем state, следующий reconcile (после новых watch events) сделает правильный шаг.
Основные controllers внутри controller-manager
В одном процессе controller-manager работают многие controllers. Главные:
DeploymentController
Watch на Deployment-ы. Создаёт/обновляет ReplicaSet-ы. Управляет rollout-ом: создаёт новый RS при изменении podTemplate, плавно масштабирует старый вниз и новый вверх (RollingUpdate). НЕ создаёт Pod-ы напрямую.
ReplicaSetController
Watch на ReplicaSet-ы и Pod-ы. Поддерживает .spec.replicas Pod-ов с label selector. Создаёт новые Pod-ы через POST, удаляет лишние через DELETE.
DaemonSetController
Watch на DaemonSet-ы и Nodes. Для каждой ноды (соответствующей .spec.template.spec.nodeSelector и tolerations) обеспечивает один Pod. При добавлении ноды — создаёт Pod. При удалении ноды — удаляет.
StatefulSetController
Watch на StatefulSet-ы и Pod-ы. Создаёт Pod-ы строго упорядоченно (web-0, потом web-1, потом web-2). Управляет PVC-привязкой (каждый Pod имеет свой PVC через volumeClaimTemplate).
JobController
Watch на Job-ы и Pod-ы. Создаёт Pod-ы под параметры .spec.completions и .spec.parallelism. Когда .spec.completions Pod-ов завершились с success — Job помечается Complete.
CronJobController
Watch на CronJob-ы. Использует cron-expression в .spec.schedule. По наступлению времени создаёт Job-объект (дальше его обрабатывает JobController).
NodeController (часть NodeLifecycleController)
Watch на Node-ы и Lease-ы в kube-node-lease namespace. Если Lease не обновляется в течение --node-monitor-grace-period (50 сек по умолчанию в v1.32+, исторически 40 сек) — ставит taint node.kubernetes.io/not-ready:NoExecute. После таймера эвакуации (5 минут) — выгоняет Pod-ы с ноды.
EndpointSliceController
Watch на Service-ы и Pod-ы. Для каждого Service генерирует EndpointSlice-объекты — списки endpoint-ов (IP + port + ready/notReady) разбитые на shards по 100 endpoint-ов. Эти EndpointSlice-ы потом потребляются kube-proxy на каждой ноде.
NamespaceController
Watch на Namespace-ы. При delete (с finalizer-ом) удаляет все ресурсы в этом namespace (Pod-ы, ConfigMap-ы, Secret-ы, RBAC-объекты), потом снимает finalizer и Namespace удаляется окончательно.
ServiceAccountController + TokenController
SA controller автоматически создаёт ServiceAccount default в каждом namespace. TokenController исторически создавал legacy Secret-ы с токенами; в современных версиях используется bound projected tokens, генерируемые kubelet-ом на лету.
PersistentVolumeBinder
Watch на PVC и PV. Подбирает подходящий PV для каждого Pending PVC (по requested storage size, accessModes, storageClassName). Привязывает (Bound) PVC к PV. Для dynamic provisioning — вызывает CSI driver через external-provisioner.
GarbageCollector
Watch на ВСЕ ресурсы. Следит за ownerReferences. Когда удаляется owner — каскадно удаляет owned объекты. Это механизм за kubectl delete deployment — он удаляет Deployment, GC удаляет ReplicaSet (owned by Deployment), GC удаляет Pod-ы (owned by RS).
Killer-момент: Deployment не создаёт Pod-ы
Это самое непонимаемое место в Kubernetes для тех, кто только начал. Diagram cascade:
ownerReferences под микроскопом
В каждом дочернем объекте — ссылка на родителя:
apiVersion: v1
kind: Pod
metadata:
name: myapp-7d4b9f-abc
namespace: default
ownerReferences:
- apiVersion: apps/v1
kind: ReplicaSet
name: myapp-7d4b9f
uid: 12345-...
controller: true # это primary owner
blockOwnerDeletion: true # owner не удалится пока child живой (foreground delete)
GC следит за этими ссылками. Когда owner удаляется, у GC два режима:
- Background (default) — apiserver удаляет owner сразу, GC потом удаляет owned-объекты в фоне.
- Foreground — apiserver сначала удаляет owned-объекты (через finalizer
foregroundDeletionна owner), потом owner.kubectl delete --cascade=foreground. - Orphan — owner удаляется, owned-объекты остаются как сироты.
kubectl delete --cascade=orphan. Полезно когда хочется заменить контроллер не теряя Pod-ы.
# Default — background
kubectl delete deployment myapp
# Foreground — ждёт удаления Pod-ов и RS до удаления Deployment
kubectl delete deployment myapp --cascade=foreground
# Orphan — оставляет ReplicaSet и Pod-ы
kubectl delete deployment myapp --cascade=orphan
blockOwnerDeletion: true в ownerReferences важен только для foreground delete. Если false — owner может быть удалён даже если child ещё жив. Это используется в редких случаях, когда контроллер хочет управлять жизненным циклом независимо.
Leader election: один активный из N
В HA control plane controller-manager (и scheduler) работает в нескольких репликах — обычно 3, по одной на каждый control plane узел. Но активна только одна — лидер. Остальные на standby, ждут.
Это сделано так, чтобы не было гонок: если бы все три инстанса одновременно reconcile-или один Deployment, они могли бы создать дублирующие ReplicaSet-ы. Лидер один — гонок нет.
Lease как distributed lock
Механизм — обычный Lease объект в API server:
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: kube-controller-manager
namespace: kube-system
spec:
holderIdentity: ip-10-0-1-12_a1b2c3d4-5e6f-7890 # уникальный per-instance ID
leaseDurationSeconds: 15 # сколько lease живёт без renew
acquireTime: "2026-05-13T09:00:00.000000Z"
renewTime: "2026-05-13T09:42:30.000000Z" # обновляется ~каждые 10 сек
leaseTransitions: 3 # сколько раз лидер менялся
Алгоритм:
- На старте каждый инстанс пытается claim lease — попытка PUT через optimistic concurrency (с resourceVersion). Если объекта нет, или текущий holderIdentity не свой и leaseDuration истёк — claim проходит.
- Если claim удался → этот инстанс становится leader, начинает работать.
- Раз в
renewDeadline / 2(по умолчанию 10 сек при duration 15) лидер делает PUT, обновляяrenewTime. Если PUT не удался — лидер пытается заново, в течениеrenewDeadline. Если не получилось — лидер сдаётся, ставит себя в standby. - Standby инстансы периодически (
retryPeriod, ~2 сек) проверяют lease:renewTime + leaseDuration < now()→ пробуют claim.
# Идиомы из kube-controller-manager флагов
--leader-elect=true
--leader-elect-lease-duration=15s
--leader-elect-renew-deadline=10s
--leader-elect-retry-period=2s
--leader-elect-resource-name=kube-controller-manager
--leader-elect-resource-namespace=kube-system
Историческая нота: endpoints/configmap-based locks
До coordination.k8s.io/v1 Lease (added в v1.14) лидерство хранилось в:
- endpoints/configmap-based locks — Endpoints или ConfigMap объект с annotation
control-plane.alpha.kubernetes.io/leader. Безобразный hack, но работал. Сейчас deprecated. - В v1.20+ default стал Lease, в v1.24+ старые варианты помечены deprecated, в v1.27 убраны.
Если посмотрите на старые apiserver-ы в production — увидите configmap kube-controller-manager в kube-system namespace с annotation про leader. Это legacy lock, который должен мигрировать на Lease.
Cloud Controller Manager
Из kube-controller-manager выделили отдельный процесс — cloud-controller-manager (CCM). Он содержит controllers, которые делают cloud-specific вещи:
- NodeController (cloud variant) — общается с EC2/GCE/Azure API чтобы узнать, что нода реально удалена в cloud (и тогда удалить Node объект из K8s).
- ServiceController — для Service типа LoadBalancer создаёт реальный cloud LB (ELB/NLB в AWS, GCP LB, Azure LB), привязывает к Service.
- RouteController — настраивает routing tables в VPC чтобы Pod-IP были routable между нодами (используется в облаках без overlay-сетей).
Это позволило вытащить cloud-логику из main K8s codebase. K8s ядро остаётся cloud-agnostic, а каждый cloud provider предоставляет свой CCM binary с in-tree или out-of-tree implementation.
# Service с типом LoadBalancer
apiVersion: v1
kind: Service
metadata:
name: web
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080
Когда вы создаёте такой Service:
- apiserver сохраняет в etcd.
- EndpointSliceController создаёт EndpointSlice для backing pods.
- ServiceController (внутри CCM) видит новый Service, вызывает AWS API (
elbv2:CreateLoadBalancer), создаёт NLB, привязывает target group к Pod IP-ам. - После создания NLB его DNS-имя пишется в
.status.loadBalancer.ingress[0].hostname. - kube-proxy на нодах настраивает iptables для трафика от NLB target group до Pod-ов (DNAT по ClusterIP или прямо на NodePort).
В onPrem кластерах (kubeadm, kind, k3s) обычно нет CCM — Service типа LoadBalancer останется в Pending с <pending> external IP. Решение — MetalLB или kube-vip, которые имитируют поведение CCM в bare-metal окружении.
Killer-момент: что видит controller-manager на старте
Если запустить controller-manager с --v=4 и посмотреть логи в первые 10 секунд:
I0513 09:00:00 leaderelection.go:243] attempting to acquire leader lease kube-system/kube-controller-manager...
I0513 09:00:00 leaderelection.go:253] successfully acquired lease kube-system/kube-controller-manager
I0513 09:00:00 controllermanager.go:614] Starting "deployment"
I0513 09:00:00 controllermanager.go:614] Starting "replicaset"
I0513 09:00:00 controllermanager.go:614] Starting "daemonset"
I0513 09:00:00 controllermanager.go:614] Starting "statefulset"
I0513 09:00:00 controllermanager.go:614] Starting "job"
I0513 09:00:00 controllermanager.go:614] Starting "cronjob"
I0513 09:00:00 controllermanager.go:614] Starting "node-lifecycle"
I0513 09:00:00 controllermanager.go:614] Starting "endpoint-slice"
I0513 09:00:00 controllermanager.go:614] Starting "namespace"
I0513 09:00:00 controllermanager.go:614] Starting "service-account"
I0513 09:00:00 controllermanager.go:614] Starting "persistentvolume-binder"
I0513 09:00:00 controllermanager.go:614] Starting "garbagecollector"
... (всего ~30 controllers)
I0513 09:00:01 shared_informer.go:285] Waiting for caches to sync for deployment
I0513 09:00:01 shared_informer.go:295] Caches are synced for deployment
I0513 09:00:01 deployment_controller.go:165] Starting deployment controller workers
Видно: после acquire lease — старт всех controllers один за другим. Каждый ждёт sync своего informer (LIST → набивка cache), потом стартует workers. Через секунду-две всё работает.
Если бы lease acquire не удался — мы бы увидели repeated retries без старта controllers. Это и есть standby режим.