Реализация контроллеров с использованием SDK Kubebuilder
- среда, 12 августа 2026 г. в 00:00:20

Привет, Хабр! Я Захар Захаров, разработчик в команде LBaaS. Контроллеры (они же операторы) в Kubernetes — паттерн, который выглядит просто на схеме из документации и неожиданно обрастает деталями, когда садишься писать его руками: очередь, воркеры, кэш, индексы, манифесты, схема, веб-хуки.
В статье разберу, из чего на самом деле состоит контроллер, что из этой обвязки берет на себя kubebuilder и какие грабли мы собрали на работе — от бесконечной реконсиляции до finalizers, которые молча не срабатывают для ресурсов из разных неймспесов. Погнали.
Чтобы понять, с чем взаимодействует контроллер, начнем с устройства кластера. Кластер обычно состоит из control plane и одной или несколькизх worker node. И тех и других может быть как одна, так и несколько.

Различаются они в первую очередь тем, какие бинари на них развернуты. На control-plane-ноде в чистом Kubernetes обычно живут API-сервер, scheduler, etcd и controller manager. Плюс на каждой ноде, включая control plane, развернуты kubelet и kube-proxy. Worker node несут на себе только kubelet и kube-proxy.
Пройдемся по компонентам, которые нам сегодня важны.
etcd — key-value-база, написанная специально под Kubernetes. Через нее реализуются механизмы вроде выбора лидера, и в ней же хранится все состояние кластера.
API-сервер — внешняя сторона Kubernetes, именно к нему обращаются и клиенты, и внутренние поды, когда хотят поработать с кластером, используя get, list и прочие запросы.
Controller manager — набор готовых контроллеров, которые поставляются вместе с Kubernetes.
В документации Kubernetes дано классическое определение: контроллеры — это управляющие циклы (control loops), которые отслеживают состояние кластера и вносят или запрашивают изменения там, где это необходимо. Каждый контроллер пытается привести текущее состояние кластера ближе к желаемому.
Если смотреть шире, контроллер — классический паттерн, который применяется, когда разрабатывается какой-то свой сторонний control plane.
Из готовых контроллеров в controller manager есть, например, такая цепочка: мы задаем Deployment, готовый контроллер создает из него ReplicaSet, после чего другой контроллер создает нужное количество подов. Точно так же мы можем добавлять свои кастомные контроллеры, которые будут работать уже с другими ресурсами, — например, контроллер сертификатов, который по Certificate создает Issuer, или контроллер, который по Service управляет Endpoints.

Главное в контроллере: на вход всегда подается один или несколько Kubernetes-ресурсов, а вот на выходе возможны вариации. Это могут быть другие Kubernetes-ресурсы, а может быть и настройка какого-то data plane напрямую. Так делает, например, Istio, который через протокол xDS отправляет конфигурацию в Envoy Proxy. Дальше взаимодействие с Kubernetes уже не идет, но важно понимать: на входе всегда один или несколько кубовых ресурсов.
Ресурсов в кластере огромное множество, даже стандартных. Каждый Kubernetes-ресурс описывается тремя сущностями: GVK — Group, Version, Kind.

Возьмем для примера API/v1: здесь group пустая (core), version — v1, а kind — это, например, Pod или Node, которые в этой группе и содержатся. Еще у ресурсов есть неймспейсы — по сути, группировка ресурсов. Один и тот же Pod содержится в каком-то конкретном неймспейсе.
Нас особенно интересует CRD (Custom Resource Definition). Это ресурс, который позволяет генерировать новые ресурсы, чтобы строить взаимодействие в кластере так, как нам нужно. Именно вокруг кастомных ресурсов и крутится вся история с собственными контроллерами.
Посмотрим, как контроллер чувствует себя внутри кластера и как в среднем выглядит взаимодействие. Я разберу полный цикл по шагам — от создания ресурса до его удаления.
Создание и admission-контроль. Сначала клиент (или кто-то другой) отправляет запрос на создание или обновление манифеста. Например, мы создаем какой-то кастомный ресурс.

Дальше идут две опциональные части: mutating webhook и validating webhook.

Mutating webhook добавляет поля, которые пользователь мог не указать, но которые хотим проставить. Например, на основе других полей (patch defaults).
Validating webhook занимается валидацией ресурса: проверяет, соответствует ли он дополнительным правилам, которые мы не описали в самом CRD, но хотим наложить. В итоге API-сервер либо отклоняет запрос, либо разрешает создание или обновление ресурса.
Наблюдение и reconcile. Когда ресурс, за которым следит контроллер, создан, API-сервер сообщает контроллеру через watch: «Эй, я заметил, что ресурс, за которым ты следил, появился. Будешь что-то делать?»

Контроллер запускает reconcile loop. В нем он читает текущий стейт через get или list (в зависимости от того, сколько ресурсов нужно), а дальше из текущего состояния кластера делает желаемое: меняет ресурс или создает другие.

После этого контроллер делает create, update или, может, delete в зависимости от своей логики.

Далее контроллер получает от API-сервера allow или deny. Если не повезло, в зависимости от реализации могут быть разные действия.

Удаление через finalizer. Обсудим, как происходит удаление CRD.

Допустим, мы повесили finalizers. На контроллер приходит событие удаления.

Мы смотрим, есть ли у этого finalizer внешняя зависимость. Если есть, занимаемся тем, что удаляем все внешние зависимости. Например, если есть основной ресурс, а под ним дочерние, завязанные на него, мы удаляем их по цепочке. В обратном случае просто снимаем с себя finalizer и удаляемся.

После чистки мы получаем success, отправляем запрос API-серверу, при необходимости делаем ретраи, и в итоге ресурс удален.

Вот и все, что я хотел рассказать о жизненном цикле оператора (или контроллера) внутри Kubernetes.
Перейдем к тому, из чего контроллер состоит. Здесь удобно держать в голове три уровня, которые надстраиваются друг над другом.
На нижнем уровне — client-go. Это библиотека, где живут базовые Kubernetes-примитивы: REST-клиент, informers, listers, workqueue, cache store. Если писать контроллер совсем руками, большую часть инфраструктуры приходится собирать именно из этих деталей.
Выше — controller-runtime. Он не заменяет client-go, а оборачивает его в более удобные абстракции: Manager, Cache, Client, Controller, Reconciler, Predicate, Webhook Server.
Еще выше — kubebuilder. Это уже не runtime-библиотека, а фреймворк и CLI: он создает структуру проекта, Makefile, kustomize-манифесты, заготовки под CRD, RBAC, webhooks и тесты.
Если совсем грубо, client-go дает строительные блоки, controller-runtime собирает из них удобную модель контроллера, а kubebuilder генерирует проект вокруг этой модели.

Разберем, как работает client-go. Есть Kubernetes API, за которым следит Reflector — он watch'ит изменения в кластере: create, update, delete и прочее. Все пришедшие события он складывает в Delta FIFO. Контроллеру обычно не так важно, было это создание или обновление, — важен сам факт, что операция произошла.
Дальше событие передается в Informer и Indexer. Indexer индексирует данные, чтобы мы могли эффективнее работать с кэшем и делать get по определенным полям. Распространенный случай — индекс по owner: у нас есть ресурс-owner и его дети, и всем детям мы проставляем индекс owner. Так, имея родителя, сразу получаем всех его потомков. После этого Informer кладет текущее событие (например, create кастомного ресурса) в Workqueue, с которой уже взаимодействует контроллер.
Когда пишешь контроллер голыми руками, объектов, с которыми приходится возиться, оказывается неожиданно много.

Входные и выходные данные.
Кастомные типы (CRD). Нужно создать кастомный ресурс, как-то его задеплоить и при этом иметь схему, потому что контроллеру надо рассказать, с какой схемой он работает и какие там типы.
Очередь и обработка. Очередь обработки тоже пишется руками: создать нужное количество горутин, задать им retry/backoff-политику, настроить, как планировщик распределяет работу между воркерами. Это довольно большой объем работы.
Деплой. Нужно написать все Kubernetes-манифесты, чтобы задеплоить контроллер в кластер: выдать ему роли (RBAC), выбрать, через что деплоить, и так далее.
Reconcile loop — это цикл, который идемпотентно берет изменившийся ресурс и приводит кластер к желаемому состоянию. Все остальное из перечисленного — обвязка, которую как раз и упрощает kubebuilder.
На схеме разработки с kubebuilder желтым помечено то, что все еще надо писать руками, и этого заметно меньше.

Что пишем сами. reconcile loop: пишем свой класс и метод reconcile, который принимает контекст и request (там лежат имя и неймспейс изменившегося ресурса), а внутри — доменную логику. Плюс опциональные веб-хуки: validator и defaulter (мутирующий веб-хук в kubebuilder называется Default) — они настроены заранее, но логику прописываем сами.
Что предоставляет kubebuilder:
Manager — единый стейт для всех reconcile loops контроллера. В нем же из коробки доступен leader election: когда у вас несколько подов, работает только один под контроллера, а остальные стоят наготове и не делают лишнего.
Метрики, пробы — уже готовые, не нужно отдельно искать библиотеки.
Makefile с нужным набором таргетов и сгенерированные kustomize-файлы.
Заготовка под тесты через envtest — с удобными интеграционными библиотеками, которые поднимают локальный кластер, чтобы гонять в нем тесты.
controller-gen — очень полезный инструмент: вы описываете кастомный ресурс как Go-структуру, навешиваете на нее валидаторы, запускаете генерацию и получаете готовый CRD. В контроллере вы и так работаете не с YAML, а с Go-структурами, представляющими кастомные ресурсы. В голом контроллере для этого приходится искать библиотеки и изобретать велосипеды (написать руками — это полдела, потом из этого еще надо получать Go-типы). А здесь — запустили, расписали структуру, получили CRD и просто закинули его в кластер. Структуру при этом обычно держат в отдельном репозитории.
Соберем разницу в таблицу.
Тема | Контроллер своими руками | kubebuilder |
Кэш/информеры | Пишете сами или подбираете библиотеки | Shared cache и клиент из коробки |
Очередь/воркеры | Сами крутите workqueue, пишете воркеров и пул | Готовый контроллер с ретраями |
Индексы | Регистрируете indexer и ByIndex | IndexField + MatchingFields, индексы вешаются в метадате ресурса |
Watch-граф | Обрабатываете события Add/Update/Delete | For / Owns / Watches + Predicates |
CRD/валидация | code-generator / OpenAPI вручную | Генерация через маркеры из Go-структур → CRD |
Webhooks | Пишете сами | Defaulter/Validator |
Лидерство/пробы/метрики | Пишете сами | Все включено в Manager |
Тесты | Сами пишете структуру тестов | Шаблон на envtest |
Деплой | Ручной | Makefile + kustomize |
Watch-граф в kubebuilder устроен интереснее, чем просто «пришел Add/Update/Delete»:
For — основной ресурс, за которым следит контроллер. Reconcile всегда запускается именно для ресурса из For.
Owns — ресурсы, которым вешаем owner-индекс. Контроллер за ними следит и тоже запускает реконсиляцию (но все равно для ресурса из For).
Watches — любые ресурсы, которые не производим и которыми не владеем, но они влияют на стейт и за их изменениями хочется следить.
Predicates — фильтр. Например, не хотим запускать reconcile на изменение статусов или ненужной метадаты: это снижает общую нагрузку.
Под капотом эти методы различаются тем, какой EventHandler используется.
For(&MyApp{}) обычно означает: если изменился MyApp, положить в очередь request с именем и неймспейсом этого же MyApp.
Owns(&Deployment{}) работает иначе. Если изменился дочерний Deployment, controller-runtime смотрит на ownerReferences, находит владельца типа MyApp и кладет в очередь request уже для владельца. Поэтому reconcile все равно запускается для основного ресурса, а не для Deployment.
Watches(&ConfigMap{}, handler.EnqueueRequestsFromMapFunc(...)) нужен, когда прямого владения нет. Например, несколько MyApp ссылаются на один ConfigMap. Тогда сами пишем функцию: по изменившемуся ConfigMap найти все MyApp, которых это изменение касается, и вернуть список requests.
У Reconcile часто получается похожая структура:
Получить основной ресурс по req.NamespacedName.
Если ресурс не найден (IsNotFound), значит, его уже удалили. Завершаем без ошибки.
Если стоит deletionTimestamp, выполнить cleanup, снять finalizer и завершить reconcile.
Если finalizer нужен, но еще не добавлен, добавить его и обновить ресурс.
Собрать desired state дочерних ресурсов.
Сравнить desired state с actual state или применить идемпотентный patch.
Обновить status, если он изменился.
Вернуть ctrl.Result{} или запланировать повтор через RequeueAfter.
Ключевое здесь — идемпотентность. Один и тот же reconcile может выполниться несколько раз подряд, в произвольный момент, после частично успешной предыдущей попытки. Поэтому код должен быть написан так, чтобы повторный запуск не ломал состояние и не создавал лишние объекты.
Заглянем поближе в отдельные шаги. Допустим, приходит cluster event — создание ресурса. Informer детектит событие и кладет его в Workqueue. Дальше reconcile получает имя и неймспейс ресурса, за которым следит (For), и делает get.
Посмотрим саму доменную логику. Допустим, приходит cluster event — создание ресурса. Informer детектит событие и кладет его в Workqueue. Дальше reconcile получает имя и неймспейс ресурса, за которым следит (For), и делает get.

В controller-runtime обычный client использует cache для Get и List, а операции записи (Create, Update, Patch, Delete) отправляет напрямую в API Server. Это дает хорошую производительность, но означает eventual consistency: сразу после записи следующий Get через cache может еще вернуть старую версию объекта.
Если в конкретном месте нужна максимально свежая версия, можно читать напрямую через APIReader. Но это не должно становиться дефолтом.
apiReader := mgr.GetAPIReader() err := apiReader.Get(ctx, key, &obj)
Прямые чтения дороже для API Server. Обычно cache — правильный выбор, а APIReader нужен для редких мест, где задержка синхронизации действительно критична.
Дальше собираем из кэша остальные нужные ресурсы, делаем свою магию в reconcile loop (добавляем поля, создаем недостающие ресурсы) и сравниваем вход с выходом. Если есть различия, делаем update или create.
После этого у reconcile несколько вариантов возврата:
return ctrl.Result{} — все хорошо, контроллер заканчивает работу;
return ctrl.Result{Requeue: true} — перепланировать reconcile этого ресурса, он просто заново ляжет в Workqueue;
return ctrl.Result{RequeueAfter: $time} — перепланировать через заданное время (например, раз в секунду проверять стейт);
return error — например, пропала связь с API-сервером, событие тоже ляжет в Workqueue, и попытка повторится позже.
В заключение архитектура kubebuilder из официальной документации. Когда видишь ее впервые, непонятно вообще ничего, поэтому давайте пройдем ее по частям и из серого темного леса сделаем понятную схему.

Process — наша Go-программа, наш контроллер. Внутри процесса создаем Manager — единый стейт всех reconcile loops. У него общий клиент и общий Cache, куда перед запуском контроллера сохраняется текущий стейт отслеживаемых ресурсов и постепенно обновляется. Кэш нужен в том числе для того, чтобы Informer следил за актуальной версией ресурса: если в кэше resourceVersion один, а в кластере уже другой, значит, произошло событие, и Informer сообщит об этом контроллеру.
Webhook-часть — возможность добавить defaulter и validator.
Controller — я привык называть эту часть набором reconcile loops, потому что контроллером для меня является весь процесс. Событие сначала проходит Predicate (фильтр: может, триггернулось изменение поля, которое нас не интересует, тогда никакой работы не делаем), а дальше идет сам Reconciler.
Рекомендую использовать Predicate почаще, чтобы не запускать лишний reconcile loop.
Вместо выводов — то, с чем мы сталкивались на работе и что иногда стреляло в ногу.
Не делаем update просто так. Reconcile loops триггерятся довольно часто и могут даже триггерить сами себя, укатываясь в бесконечную реконсиляцию. Поэтому перед тем, как делать update в кластер, делаем локальный diff: сравниваем то, что получили (get), с тем, что собрали. Если различий нет, завершаем реконсиляцию.
Дело не только в лишнем Update — он еще и отправляет объект целиком. Если за это время объект поменял кто-то еще, получим conflict по resourceVersion: это нормально — надо перечитать объект и повторить позже. Для дочерних ресурсов удобнее Patch или controllerutil.CreateOrUpdate, а для управляемых полей — Server-Side Apply. И status обновляем отдельно, через r.Status().Update(...), а не обычным Update, так spec и status не затирают друг друга.
Если категорически нужны свежие данные, используем все на каждый reconcile. Если пришло обновление и критично иметь актуальный стейт, можно вместо get одного ресурса получать сразу все. Это затратно по производительности и подходит не для каждого ресурса, но в некоторых случаях дает дополнительную надежность и гарантию, что на любой чих будет нужный стейт.
Используем Predicate почаще. Часто бывает, что контроллер генерирует много дочерних ресурсов, из-за чего reconcile loop триггерится заново, даже когда мы этого не хотели. Это повышенная нагрузка на под по CPU и памяти и лишние события, которые мешают важным обработаться раньше.
Вешаем Finalizers почаще. Finalizers вешаются на ресурсы, которые хотим отследить перед удалением: через finalizer можно сказать «не удаляй основной ресурс, пока не удалены дочерние», — примерно как внешние ключи в базах данных. Сам finalizer — это просто строка в metadata, он неймспейсом не ограничен.
Важно не ошибиться с ownerReference. Owner-ссылка работает только в рамках одного неймспейса: дочерний ресурс может ссылаться на владельца лишь в том же неймспейсе (а на cluster-scoped владельца — из любого). Если основной ресурс создан в одном неймспейсе, а, например, сертификаты для него — в другом, связать их через ownerReference не выйдет: каскадное удаление и Owns-реконсиляция работать как надо не будут.
Короткий чеклист проблем, которые проверяем перед выкатыванием контроллера:
reconcile идемпотентный и не делает Update, если объект не изменился
status обновляется отдельно от spec
conflicts по resourceVersion обрабатываются как нормальный retry-сценарий
watch-граф не слишком широкий
На шумные события повешены predicates
cache не смотрит на весь кластер без необходимости
Есть RBAC на основной ресурс, /status, /finalizers и дочерние ресурсы
cleanup при удалении покрыт finalizer
cross-namespace зависимости удаляются явной логикой контроллера
В метриках видны ошибки reconcile, длительность и глубина workqueue
В тестах проверены create/update/delete-сценарии, а не только happy path