
Kubernetes давно стал повсеместной платформой, а написать к нему собственный оператор сегодня — задача нескольких часов. Стандартный путь — kubebuilder на основе controller-runtime: scaffold проекта, типы, реконсайлер. В типовых сценариях этого вполне достаточно. Но как только нагрузка растёт или поведение оператора начинает расходиться с ожиданиями, всплывает целый класс edge-кейсов, причина которых — непонимание того, как controller-runtime устроен внутри. Если вы пишете контроллеры для Kubernetes, этот материал поможет собрать целостную mental model и заранее избежать дорогих сюрпризов в проде.
В этой статье разберём внутреннее устройство controller-runtime и на его примере увидим, какие архитектурные решения лежат в основе самого Kubernetes. Начнём с того, как контроллеры читают объекты из Kubernetes API.
Есть распространённое заблуждение, что r.Get() в Reconcile ходит прямо в kube-apiserver, List() каждый раз смотрит «живую» картину мира, а после Update() можно сразу перечитать объект и увидеть свежее состояние. На практике всё наоборот: controller-runtime живёт на локальной копии данных через LIST+WATCH. Благодаря этому чтение в реконсайле обходится почти бесплатно и не нагружает control plane даже при сотнях вызовов в секунду — но ценой этой модели становится то, что оператор может внезапно съедать гигабайты памяти, делать скрытые O(n)-сканы и регулярно упираться в stale reads.
Статья рассчитана на тех, кто уже писал операторы на Go с использованием controller-runtime, но хочет собрать целостную mental model, а не жить с набором частных наблюдений. Фокус будет на практических последствиях для production-кластеров: память, трафик, консистентность чтения и поведение реконсайла.
TLDR
Если хочется забрать из статьи одну мысль и уже с ней идти читать дальше, то она такая:
r.Get() и r.List() в реконсайле обычно читают не из apiserver, а из локального in-memory cache, который менеджер прогревает через LIST и затем поддерживает через WATCH.
Из этого следуют почти все остальные свойства системы:
чтение дешёвое, но не мгновенно консистентное после записи;
запись идёт напрямую в
apiserver, а не через cache;размер локального cache и набор индексов напрямую влияют на потребление памяти;
неправильный
List()легко превращается в линейный скан по десяткам тысяч объектов;APIReaderнужен редко, но в некоторых местах без него нельзя.
Дальше разберём, почему это так и как именно эта модель устроена под капотом.
Немного контекста: что такое reconciliation loop
Чтобы дальше не спорить о терминах, зафиксируем базовую модель.
Контроллер в Kubernetes живёт в цикле reconciliation: он постоянно сравнивает желаемое состояние объекта с фактическим и пытается привести одно к другому. Эта идея описана ещё в оригинальной архитектурной заметке про Kubernetes. Обычно это выглядит так:
пользователь или другой контроллер меняет объект;
событие попадает в очередь;
Reconcileчитает текущее состояние;контроллер решает, что нужно создать, обновить или удалить;
система получает новое событие и цикл повторяется.
Важно здесь не то, что контроллер «что-то делает», а то, откуда он узнаёт об изменениях и откуда читает состояние. Ровно в этом месте и начинается cache.
На живом кластере эту механику проще всего увидеть через:
kubectl get pod -w
kubectl в режиме -w подписывается на тот же событийный поток, на котором живут контроллеры. Вы создаёте или удаляете Pod и видите не один «финальный» объект, а цепочку состояний: scheduler назначает ноду, kubelet обновляет статус, другие контроллеры вносят свои изменения. Контроллеры Kubernetes работают не через постоянный polling, а через поток событий и локальное состояние, которое поддерживается в актуальном виде.
Я уже показывал этот процесс на живом демо — вот короткая вырезка из моего доклада, где я показывал, как reconciliation loop отрабатывает на реальном примере Pod и через какие состояния он проходит: https://t.me/ittales/661
Зачем вообще нужен кэш в controller-runtime
Давайте представим простейший контроллер:
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var pod corev1.Pod if err := r.Get(ctx, req.NamespacedName, &pod); err != nil { return ctrl.Result{}, err } // дальше какая-то осмысленная логика }
Вроде всё просто. Но возникает вопрос: что происходит, когда мы вызываем r.Get? Летит HTTP-запрос в apiserver? Если бы летел — представьте себе картину: десяток операторов, в каждом по паре-тройке контроллеров, каждый делает по Get и List на реконсайл, реконсайлов — сотни в секунду. apiserver с etcd в этот момент пишут друг другу прощальные письма.
Чтобы такого не случалось, Kubernetes с самого начала построен на watch-модели, а не на полинге. Стандартный механизм watch работает так: клиент один раз делает LIST, получает снимок интересующей его части мира, а затем подписывается на поток изменений через WATCH и держит у себя локально актуальную копию. Всё в одном долгоживущем HTTP-соединении, без циклического «а что там сейчас?».
Эта идея существует в client-go ещё со времён первых контроллеров в kube-controller-manager. А controller-runtime лишь упаковал её в удобный фреймворк, в котором не надо каждый раз вручную склеивать Reflector, DeltaFIFO и Indexer (про них — ниже).
То есть когда мы говорим «cache controller-runtime» — речь идёт не о хитрой оптимизации, а о фундаменте всей модели: читаете вы из памяти, пишете — в apiserver, обратную связь получаете через watch.
Дальше пройдёмся по тому, как именно это устроено.
Глоссарий: термины, которые пригодятся
Чтобы не перескакивать туда-сюда по тексту, соберу в одном месте понятия, которые встретятся ниже. Если что-то уже очевидно — пролистайте.
GVK (GroupVersionKind) — тройка, однозначно идентифицирующая тип ресурса в Kubernetes: группа, версия и kind. Например,
apps/v1/Deployment. Почти все API вcontroller-runtimeоперируют именно GVK, а не «именем ресурса как в kubectl».resourceVersion — непрозрачная строка, которую apiserver прикрепляет к каждому объекту (внутри — монотонно растущая позиция в
etcd). Нужна для двух вещей: для оптимистического контроля конкурентности (приUpdateapiserver проверит, чтоresourceVersionу вас тот же, что и вetcd, иначе вернёт409 Conflict) и для возобновления watch (приWATCH?resourceVersion=Xapiserver пришлёт все события, случившиеся после версии X).Manager — объект
ctrl.Managerвcontroller-runtime. Это то, что ваш оператор создаёт вmain.goи запускает черезmgr.Start(ctx). Он оркеструет всё вокруг: держит shared cache, создаёт клиент, запускает контроллеры, вебхуки, healthz-эндпоинты и прочие runnable’ы. В рамках одного процесса обычно один менеджер, внутри которого может жить много контроллеров.Informer — сущность из
client-go, которая держит watch на один GVK, поддерживает его локальный индексированный Store и раздаёт события подписчикам. Вcontroller-runtimeон создаётся автоматически, когда вы регистрируетеWatches(...)или делаете первыйGet/Listнужного типа.Store — in-memory хранилище информера, где лежат сами объекты. В
controller-runtimeу каждого информера свой Store.ResourceEventHandler — интерфейс с тремя методами:
OnAdd,OnUpdate,OnDelete. Информер вызывает их на каждое событие, пришедшее через DeltaFIFO; одновременно с этим обновляется Store, поэтому handler уже видит свежую версию объекта в Indexer. Подписчики (ваши контроллеры) регистрируют такие обработчики и через них узнают об изменениях.workqueue — очередь ключей (
namespace/nameобъектов) с дедупликацией и rate-limiting’ом. На каждое событие контроллер кладёт в неё ключ, а воркеры по одному вытаскивают и передают вReconcileкакctrl.Request.Predicate — фильтр в контроллере. Предикат решает, нужно ли вообще класть событие в очередь (например, «реагировать только на изменение
spec, status игнорировать»).
Теперь можно нырять.
Анатомия: что лежит под капотом пакета cache
Если заглянуть в sigs.k8s.io/controller-runtime/pkg/cache, видно, что сам controller-runtime — это тонкая обёртка поверх k8s.io/client-go/tools/cache. Под капотом живут ровно те же сущности, что и в ядре Kubernetes:
Reflector — держит
WATCHк apiserver и пишет приходящие изменения в очередь в виде дельт. Дельта — это запись вида «с объектомXпроизошло событиеAdded/Updated/Deleted, вот его новая версия». По сути, одна строчка журнала изменений.DeltaFIFO — очередь этих самых дельт. По каждому ключу
namespace/nameкопится список того, что с этим объектом происходило, причём порядок сохраняется.Indexer (Store) — in-memory хранилище объектов и индексов к ним.
SharedIndexInformer — дирижёр, который склеивает всё это воедино и раздаёт события подписчикам — вашим контроллерам и прочим наблюдателям.
На пальцах конвейер выглядит примерно так:

Пройдёмся по звеньям.
Reflector и resourceVersion
Reflector — это процесс, который непосредственно общается с apiserver. У него всего две задачи: при старте один раз сделать LIST и дальше держать открытым WATCH.
Тут пригодится тот самый resourceVersion. Отвечая на LIST, apiserver возвращает не только список объектов, но и версию, на которой этот снимок получен. Дальше Reflector говорит apiserver: «открой мне WATCH с версии X» — и получает поток событий обо всём, что произошло после этой версии. Это и есть основа консистентности: мы не рискуем пропустить событие между LIST и WATCH, потому что WATCH продолжает ровно с той точки, на которой закончился LIST.
Если соединение отваливается — Reflector переподключается с последним известным resourceVersion. Если apiserver отвечает 410 Gone («этой версии уже нет в истории, ты слишком отстал») — Reflector делает новый LIST и начинает заново. Это называется relist, и случается он не по расписанию, а именно в таких аварийных сценариях.
DeltaFIFO: очередь дельт
Это место, где стоит задержаться. DeltaFIFO — это буфер между Reflector и остальным информером. На входе — поток событий от apiserver, на выходе — те же события, но уже сгруппированные по ключу и в строгом порядке.
Если точнее, DeltaFIFO решает три задачи:
Сохраняет порядок. Что бы ни прилетело по объекту
default/my-deploy, на выходе вы увидите ровно ту последовательность изменений, в которой apiserver их присылал.Группирует по ключу. Все дельты по одному
namespace/nameкопятся в одном слоте.Pop()возвращает не одну дельту, а слайс всех накопленных дельт по этому ключу — консьюмер одним разом видит всё, что произошло с объектом с прошлого вызова.Выборочно дедуплицирует. Встроенная функция
dedupDeltasсхлопывает подряд идущиеDeletedпо одному ключу — чтобы два delete-события не превратились в две отдельные обработки.
Важный момент: ни Added подряд, ни Updated подряд DeltaFIFO не мержит. В общем случае «сжать все промежуточные состояния в одно финальное» — не её работа.
Давайте на примере. Допустим, по объекту default/my-deploy очень быстро произошло три события:
Added— создался Deployment (условно, сspec.replicas=1).Updated— кто-то поменялspec.replicasна2.Updated— и сразу же на3.
DeltaFIFO положит все три дельты в слот по ключу default/my-deploy. Pop() вернёт их единым слайсом, и дальше sharedIndexInformer.HandleDeltas пройдёт по ним по порядку — сначала OnAdd, потом два раза OnUpdate (с промежуточным состоянием 1→2 и финальным 2→3). То есть event handler честно отработает три раза.
Дедупликация по объекту при этом всё же есть, просто не в DeltaFIFO, а уровнем выше — в workqueue контроллера. Механика такая: на каждую дельту от DeltaFIFO event handler контроллера вытаскивает из объекта ключ namespace/name и кладёт его в очередь. Повторная вставка того же ключа молча сливается в ту же запись — сам объект workqueue не интересует.
Наглядно: вы создали Pod. За пару секунд по нему прилетает гирлянда Updated — scheduler назначил ноду, kubelet проставил Pending, потом ContainerCreating, Running, Ready. Пять дельт подряд, event handler сработает на каждой — но в workqueue всё это время висит одна запись с ключом default/my-pod. Когда Reconcile её заберёт, в кэше уже финальное состояние, и он отработает один раз.
Получается два уровня с чёткими ролями:
DeltaFIFO — упорядоченная очередь дельт, группировка по ключу, дедуп только для подряд идущих
Deleted. Её задача — доставить контроллерам факты изменений в правильном порядке.workqueue — очередь ключей с честным дедупом и rate-limit’ом. Именно она схлопывает «десять обновлений подряд → одна обработка».
Если держать эту двухслойную картинку в голове, сразу понятно, почему лишние события по одному объекту на производительность контроллера практически не влияют — их глушит workqueue.
Indexer: та самая «копия кластера»
Indexer (он же ThreadSafeStore) — это и есть локальная копия кластера. Под капотом — обычная map[string]interface{} с ключом namespace/name и мьютекс. Плюс словарь зарегистрированных индексов, про который поговорим в отдельном разделе.
То есть да, по сути это просто мапка в памяти. Никаких хитрых B-деревьев, никаких LSM. И именно поэтому r.Get из кэша стоит микросекунды — это банальный lookup по мапе и копирование Go-структуры.
SharedIndexInformer и подписки
Информер — это сущность, которая склеивает Reflector + DeltaFIFO + Indexer и даёт внешнему миру два интерфейса:
читать объекты напрямую из Indexer’а;
регистрировать
ResourceEventHandlerи получать уведомления на каждое событие из DeltaFIFO —OnAdd,OnUpdate,OnDelete. Store обновляется одновременно с вызовом handler’а, так что вы сразу видите в Indexer актуальное состояние объекта.
«Наружу» — это как раз к вашим контроллерам. Контроллер в controller-runtime при регистрации Watches(...) под капотом просит информер: «добавь мне обработчик, при изменении объекта клади ключ вот в этот мой workqueue». Дальше воркеры контроллера по одному тянут ключи из очереди и зовут ваш Reconcile(ctx, ctrl.Request{NamespacedName: ...}).
Ключевое слово в названии — Shared. Manager создаёт один информер на GVK, и все контроллеры, вебхуки и источники событий в рамках этого менеджера подписываются на него:

То есть информер — это то, что один раз подписалось на Pod’ы, держит их у себя, а все заинтересованные внутри процесса к нему обращаются. На apiserver это выглядит как один LIST и один WATCH на GVK, независимо от того, сколько у вас в процессе reconciler’ов.
Что происходит при старте и при самом первом r.Get
Разберём по шагам, что происходит между моментом запуска менеджера и первым вызовом r.Get в вашем реконсайле.
При старте менеджера вызывается
mgr.Start(ctx)— и он поднимает все зарегистрированные информеры.Для каждого GVK Reflector делает полный
LIST— всех объектов, которые попадают под ваш scope.Ответ
LISTраскладывается в Store информера, зарегистрированные индексы пересобираются, и у информера флагHasSynced()переключается вtrue.После этого запускается
WATCHс тем самымresourceVersion, полученным вLIST.И только теперь контроллер начинает дёргать
Reconcile— конкретно, когдаcache.WaitForCacheSyncвернётtrueдля всех его источников. До этого момента воркеры не разбираютworkqueue, даже если события в него уже капают.
То есть «ситуации, когда реконсайл уже работает, а кэш ещё пустой» в controller-runtime не бывает по построению. Прогрев всегда идёт заранее, не лениво.
Что происходит при первом r.Get? Представим, что у нас в реконсайле такой код:
var obj appsv1.Deployment err := r.Get(ctx, req.NamespacedName, &obj)
На самом деле под капотом примерно вот это:
item, exists, err := indexer.GetByKey("default/my-deploy") if !exists { return apierrors.NewNotFound(...) } // DeepCopy в obj
Никакого HTTP, TLS, сериализации protobuf, никакого etcd. Lookup по мапе, копия структуры, возврат. Микросекунды.
И — повторюсь, потому что это важно — даже самый первый Get в жизни контроллера читает уже прогретый и проиндексированный снапшот. Никакого «первый раз медленно, потом быстро» здесь нет.
Примечание. Это поведение касается именно
mgr.GetClient(). Если вам по какой-то причине понадобится читать объекты доmgr.Start()(например, на этапе инициализации) — используйтеmgr.GetAPIReader(), который ходит напрямую в apiserver. Про него ещё будет отдельный разговор.
Client ≠ Cache: чтение из памяти, запись в apiserver
Ещё один момент, который часто упускают. client.Client в controller-runtime — это составной объект:
Чтение (
Get,List) идёт через кэш.Запись (
Create,Update,Patch,Delete,DeleteAllOf) идёт напрямую в apiserver.
Это не хак, а сознательный дизайн:
Чтение частое, должно быть дешёвым.
Запись редкая, и должна быть точной.
Если писать через кэш — получите split-brain: локальная версия считает, что всё ок, а apiserver запрос уже отклонил.
На теме «должна быть точной» остановлюсь подробнее. Здесь нам снова нужен resourceVersion.
Когда вы читаете объект из кэша, вы получаете его не «как сейчас в etcd», а «как было, когда Reflector в последний раз видел это обновление». В этой версии прописан и resourceVersion. Дальше вы что-то меняете и делаете r.Update(ctx, &obj). Этот запрос уходит в apiserver прямо сейчас, и apiserver проверяет:
resourceVersionв вашем PUT =resourceVersionв etcd? → ок, пишем.Нет, в etcd уже новее? →
409 Conflict, «кто-то тебя опередил».
Это называется оптимистическая блокировка. Никаких реальных блокировок не берётся, все пишут параллельно, но только один из конкурирующих Update выиграет — тот, кто пришёл с актуальной версией. Остальным прилетит 409, и они должны перечитать объект и попытаться снова.
Почему это важно в контексте кэша: если вы наивно отправите в apiserver объект со «своим» resourceVersion из кэша, а с момента чтения его уже кто-то обновил — вы получите 409. Это не баг, это именно та защита, которая и должна быть. Писать в обход resourceVersion (через Patch без optimistic lock или через Server-Side Apply) тоже можно, но это отдельный разговор — про него чуть ниже.
Теперь цикл «запись → видимость» выглядит так:

Между «выполнили Update» и «кэш обновлён» — микроокно в единицы миллисекунд. В этом окне r.Get того же объекта вернёт старую версию. Отсюда растёт львиная доля проблем, которые я дальше перечислю.
Распространённые ошибки, на которые наступают все
Ошибка №1. Ожидание read-after-write
Довольно частая история:
obj.Spec.Replicas = ptr.To(int32(5)) if err := r.Update(ctx, &obj); err != nil { return ctrl.Result{}, err } // а давайте сразу перечитаем и убедимся, что там 5 var fresh appsv1.Deployment _ = r.Get(ctx, key, &fresh) fmt.Println(*fresh.Spec.Replicas) // внезапно 3
Это не баг controller-runtime. Это свойство eventual-consistent системы: кэш обновляется асинхронно, через watch.
Правильный паттерн — не полагаться на мгновенную свежесть. Reconcile должен быть идемпотентным и всегда смотреть на текущее состояние. Не совпало с желаемым — следующий реконсайл исправит. Не надо ни «подождать 100 мс», ни «дёрнуть ещё раз» — надо писать логику так, чтобы одно-два лишних срабатывания ничего не ломали.
Если всё-таки нужна гарантированная свежесть (например, в validating webhook’е, где вы не можете позволить себе работать на устаревшем состоянии) — для этого есть APIReader, который ходит мимо кэша. Про него — ниже.
Ошибка №2. DeepCopy и кто владеет памятью
Чтобы понять этот сюжет, сначала два слова про механику событий в контроллере. Когда вы регистрируете источник через Watches(...), между Indexer’ом и вашим Reconcile стоят два звена:
Predicate — фильтр. Смотрит на событие (
CreateEvent,UpdateEvent,DeleteEvent,GenericEvent) и решает, пускать его дальше или нет.EventHandler — преобразователь. Получает объект и превращает его в один или несколько
ctrl.Request, которые уходят вworkqueue(классическийEnqueueRequestForObjectпросто кладётnamespace/nameтекущего объекта).
И вот важный момент. В эти предикаты и хэндлеры приходят те же самые объекты, что лежат в общем Store информера. Один и тот же *corev1.Pod видят все контроллеры, которые подписаны на Pod’ы.
Это следствие Go-шной специфики: в Go нет иммутабельных структур, и ничто не мешает вам сделать pod.Labels["foo"] = "bar" прямо в обработчике. Исторически в Get/List тоже возвращался указатель на объект в Store, и это приводило к весёлому: кто-то в одном контроллере подправил статус «для удобства» — и у соседнего контроллера в кэше мир изменился.
Сейчас controller-runtime по умолчанию делает DeepCopy на Get и на List. Простое правило:
То, что вы получили из
r.Get/r.List— ваше, мутировать можно.То, что прилетело в
PredicateилиEventHandler— общее, чужое. Если зачем-то надо мутировать — рукамиobj.DeepCopy(), иначе получите скрытую коррапцию кэша в соседних контроллерах.
На что обращать внимание на ревью: если в predicate.Funcs{UpdateFunc: ...} или в handler.EnqueueRequestsFromMapFunc(...) есть вызовы вида e.ObjectNew.SetLabels(...), obj.Status.X = Y и так далее — это повод остановиться и спросить, точно ли здесь не нужен DeepCopy перед мутацией.
Ошибка №3. Resync — это не relist
У информера есть параметр resyncPeriod (в controller-runtime по умолчанию 10 часов), и многие думают, что это «раз в N часов переливать всё из apiserver».
Нет. Resync не делает LIST. Он перекладывает всё, что лежит в Indexer, обратно в DeltaFIFO как Sync-дельты — и информер обрабатывает их обычным образом, вызывая OnUpdate(old, old) на каждый объект. Так контроллер, который по какой-то причине пропустил своё реконсайл-окно (залип воркер, отвалился обработчик), получает шанс увидеть мир заново. Трафика на apiserver это не создаёт.
Настоящий relist случается только в двух случаях: когда WATCH отвалился с 410 Gone, и когда вы руками пересоздаёте информер.
Ошибка №4. Не путайте RequeueAfter с таймером
Маленькая ремарка, которая часто выручает. Иногда в реконсайле хочется подождать: «пошёл в API провайдера, запросил статус, если ещё не готово — попробую через минуту». Соблазн — запустить time.Sleep или собственную горутину.
Не надо. В controller-runtime для этого есть штатный механизм:
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
Контроллер поставит ваш req обратно в workqueue с отложенным срабатыванием через 30 секунд. При этом, если за это время придёт реальное событие по тому же объекту — реконсайл отработает сразу, не дожидаясь таймера (ключ в очереди дедуплицируется). Это и дешевле, и корректнее, чем собственные таймеры: вы не удерживаете воркер и не рискуете пропустить настоящее событие.
Есть и просто ctrl.Result{Requeue: true} — положить в очередь сразу, но с учётом rate-limiter’а.
cache + index = почти SQL
А теперь к самой, пожалуй, полезной возможности кэша, которую на практике использует далеко не каждый.
По умолчанию List из кэша выглядит так:
var pods corev1.PodList _ = r.List(ctx, &pods) for _, p := range pods.Items { if p.Spec.NodeName == "node-1" { // делаем что-то } }
Работает — пока объектов мало. Когда в кластере 50 тысяч Pod’ов, а реконсайлов сотни в секунду — контроллер, грубо говоря, перекладывает одни и те же полгигабайта указателей туда-сюда на каждый чих. O(n) на каждый реконсайл.
Indexer из client-go умеет гораздо лучше. Мы заранее объявляем, по какому полю нужно индексировать:
// Индекс по spec.nodeName для Pod'ов if err := mgr.GetFieldIndexer().IndexField( ctx, &corev1.Pod{}, "spec.nodeName", func(obj client.Object) []string { pod := obj.(*corev1.Pod) if pod.Spec.NodeName == "" { return nil } return []string{pod.Spec.NodeName} }, ); err != nil { return err }
Что такое inverted index? Термин пришёл из поисковых движков. Обычно у вас есть документы и у каждого документа — список слов в нём. «Inverted» значит «перевёрнутый»: теперь у вас словарь, где ключ — слово, а значение — список документов, в которых оно встречается. Здесь то же самое: ключ — значение поля (например, node-1), значение — список ключей объектов, у которых это поле такое:
map["node-1"] = {"default/pod-a", "kube-system/pod-b", ...} map["node-2"] = {"default/pod-c", ...}
Что происходит со стороны Indexer’а:
На каждое входящее событие (
ADDED,MODIFIED,DELETED) Indexer прогоняет объект через вашу индексирующую функцию, получает набор ключей индекса и обновляет inverted-словарь. Если Pod переехал сnode-1наnode-2, ключnode-1теряет ссылку на него, а ключnode-2её получает.Таким образом, к моменту, когда вы делаете
List, индекс уже актуален. Вы не платите за его перестройку в момент запроса — ни за какие проходы по всем объектам, ни за пересборку словаря. Вся работа сделана заранее, в момент изменения объекта.
И вот теперь можно писать так:
var pods corev1.PodList _ = r.List(ctx, &pods, client.MatchingFields{"spec.nodeName": "node-1"}, )
Это не то же самое, что «взять весь список и отфильтровать». Это lookup по inverted index → готовый набор ключей → выдача объектов. Совсем другой код-путь.
Сравнение с SQL, кстати, гораздо точнее, чем кажется:
SQL |
controller-runtime |
|---|---|
|
|
|
|
|
|
Обратите внимание на последнюю строчку: MatchingFields не делает магию из воздуха. Под каждое поле, по которому вы хотите искать через MatchingFields, нужен соответствующий IndexField, зарегистрированный при старте менеджера. Без него controller-runtime просто не даст вам такого искать и вернёт ошибку.
Несколько важных моментов, которые стоит держать в голове:
Только equality. Range-запросов,
LIKE, сортировок, агрегаций — нет. Если нужно «всё старше пяти минут» — либо делайте обычныйListи фильтруйте в коде, либо используйте трюк с бакетом времени: вместо точногоtime.Timeиндексируете округлённое значение (например,now.Truncate(5*time.Minute).Format(...)). Тогда можно выбирать объекты по конкретному окну.-
MatchingLabels— это не индекс. Многие думают, что раз по лейблам так часто ищут, для них точно есть оптимизация. Её нет: отдельного словаря по лейблам вThreadSafeStoreне существует.Когда вы пишете
List(..., MatchingLabels{...}), под капотом контроллер честно проходит по всем закэшированным объектам нужного типа и для каждого проверяет, подходит ли он под селектор. То естьO(n)— ровно то, от чего мы защищаемся черезIndexField.Сам apiserver позволяет настроить поток событий по конкретному label-селектору. Но чтобы это эффективно работало в вашем контроллере, оптимизировать надо на этапе формирования кэша — через
cache.ByObject{Label: ...}, — а не чтения из него. Об этом подробно — в следующем разделе про селективный кэш.А если нужен быстрый поиск по конкретному лейблу среди уже закэшированных — заводите
IndexFieldпо этому лейблу руками, это работает. Индекс — это память. Каждый индекс — это дополнительный словарь с ключами по каждому объекту. Не надо индексировать «на всякий случай» всё подряд.
Индексировать можно только то, что есть в самом объекте. Нельзя проиндексировать Pod по «наличию связанного PVC с таким-то флагом». Пишите это поле в сам объект либо индексируйте PVC, а не Pod.
Учтите. Индекс строится в момент регистрации, и на этапе
initial LISTон уже заполняется. То есть к моменту первогоReconcileиGet, иListсMatchingFieldsработают корректно — индекс не «достраивается лениво».
Селективный кэш: не тащите к себе весь кластер
По умолчанию информер тянет все объекты своего типа из всех namespace’ов. Для Pod, Secret, ConfigMap, Event в большом кластере это сюрприз на несколько гигабайт RAM, причём в первом же LIST при старте.
Особенно больно бывает с:
секретами, потому что Helm хранит в них состояние релизов (
helm.sh/release.v1.*), и эти секреты легко по сотне килобайт каждый;v1.Node, у которых в
status.imagesлежит список всех образов, когда-либо оседавших на узле — в нагруженных кластерах это десятки килобайт на узел;Event’ами, которых может быть очень много и которые вам, скорее всего, кэшировать не надо вообще никогда.
В controller-runtime политика кэширования задаётся в cache.Options, которые передаются при создании менеджера:
mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ // Кэшируем только Secret'ы из своего namespace, да и то по label'у &corev1.Secret{}: { Namespaces: map[string]cache.Config{ "my-operator": {}, }, Label: labels.SelectorFromSet(labels.Set{ "app.kubernetes.io/managed-by": "my-operator", }), }, // Pod'ы кэшируем все, но режем лишнее при попадании в Store &corev1.Pod{}: { Transform: func(obj any) (any, error) { pod := obj.(*corev1.Pod) pod.ManagedFields = nil return pod, nil }, }, }, }, })
Важный нюанс: это настройка уровня менеджера, и она аффектит все контроллеры в этом процессе, которые читают соответствующий тип. Если вы сузили кэш Secret’ов до одного namespace, а рядом в том же бинарнике живёт контроллер, которому нужны все секреты в кластере, — он их попросту не увидит. Так что, прежде чем резать scope, посмотрите, кто ещё пользуется этим типом.
Коротко, что даёт каждая опция:
Namespacesограничивает область видимости. Если оператор управляет только своим namespace — нечего держать в памяти чужие.Label/Fieldпревращаются в параметры самогоWATCH. То есть apiserver шлёт вам только подходящие объекты — экономия и в сети, и в памяти.Transformвызывается до того, как объект попадёт в Store. Идеальное место срезатьmanagedFields, гигантскиеannotations, бинарныеdataуConfigMap, всё, что вам не нужно.DefaultLabelSelector/DefaultNamespaces— то же самое, но глобально, если все типы нужно ограничить одинаково.
Учтите. Селектор ограничивает, что кэшируется, но не ограничивает, что существует. Если объект не подходит под ваш селектор — для оператора его не существует ни в
Get, ни вList. Это бывает больно, когда человек неправильно разметил один Secret и полдня выясняет, почему его контроллер его «не видит».
Metadata-only: когда Spec и Data не нужны
Отдельный паттерн — когда вам важно знать, что объект существует, но не нужны его spec и data. Типичные примеры: контроллер ждёт появления Secret с определённым именем, но сам его не читает. Или считает PersistentVolume’ы по label’у topology.kubernetes.io/zone. Или реагирует на ConfigMap’ы в namespace по имени, но содержимое ему безразлично.
Учтите.
PartialObjectMetadataпо понятной причине не даёт вам ничего изspecиstatus— толькоObjectMeta. Поэтому фильтровать через него по полям spec (типаstorageClassNameу PV илиnodeNameу Pod) нельзя — этих полей в локальной копии не существует. Всё, что попадает под metadata-only, — это labels, annotations, ownerReferences, finalizers, creationTimestamp и прочее изmetadata.
Для такого есть PartialObjectMetadata:
var list metav1.PartialObjectMetadataList // Обратите внимание: Kind указывается singular ("Secret"), а не "SecretList". // То, что это list, controller-runtime понимает по типу переменной. list.SetGroupVersionKind(schema.GroupVersionKind{ Group: "", Version: "v1", Kind: "Secret", }) if err := r.List(ctx, &list, client.InNamespace("my-ns")); err != nil { return err }
Под капотом это отдельный watch, который запрашивает у apiserver только метадату. В Store такие объекты хранятся без Data / Spec / Status — только ObjectMeta. Для Secret разница в памяти легко двухзначная кратность.
APIReader: когда кэша недостаточно
mgr.GetAPIReader() возвращает client.Reader, который ходит напрямую в apiserver, минуя кэш. Когда он действительно нужен:
Validating webhook, где вам критична свежая версия объекта. Кэш в другом процессе в этот момент может отставать, и вы заблокируете корректный
Update.Разовое чтение ресурса, для которого у вас не поднят информер. Поднимать watch ради одной операции — дорого.
Чтение до
mgr.Start(), например в инициализации. Обычныйmgr.GetClient()в этот момент вернёт пустоту.Постраничные обходы больших выборок через
client.Limit/client.Continue. Cache-backed клиент эти параметры игнорирует и всегда возвращает всю выборку из in-memory Store; чтобы реально читать из apiserver постранично, нуженAPIReader(или собственный прямой клиент).
Цена — реальный сетевой запрос. Важно: не надо строить логику в духе «сначала посмотрим в кэш, если нет — сходим в API». Так вы собственноручно воссоздаёте ровно тот split-brain, от которого кэш и защищает.
Полное отключение кэша для типа
Если для какого-то типа вам в принципе не нужен локальный кэш — например, тип «толстый», читается редко, и поднимать LIST+WATCH ради этого слишком дорого — можно сказать менеджеру не кэшировать его вообще. Это делается через client.Options.Cache.DisableFor:
mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Client: client.Options{ Cache: &client.CacheOptions{ DisableFor: []client.Object{ &corev1.Secret{}, }, }, }, })
С такой настройкой mgr.GetClient().Get(...) и List(...) для Secret будут ходить напрямую в apiserver, без кэша. Информер на этот тип не поднимается — а значит, нет ни LIST при старте, ни постоянной памяти под Store. Это более радикальная альтернатива APIReader: если APIReader нужен точечно, для отдельных запросов, то DisableFor отключает кэш для типа целиком.
На практике этот подход активно использует fluxcd: для редко читаемых, но потенциально больших объектов (тех же Secret) это и заметная экономия памяти, и снятие нагрузки на apiserver при старте.
Кстати. Если хочется вообще обойтись без watch на apiserver — события для контроллера можно подавать через собственный источник, минуя
LIST+WATCH. Вcontroller-runtimeдля этого естьWatchesRawSource/source.Channel: вы кормите контроллер событиями из любого места — внутренней очереди, kubelet’а, кастомного watch. Это нишевый, но вполне рабочий приём для случаев, когда apiserver лучше не трогать совсем.
Best practices
Под занавес — набор правил, которые стоит проверять по чек-листу, прежде чем выкатывать оператор в живой кластер:
Ограничьте scope кэша (
Namespaces,Label,Fieldселекторы), особенно для «тяжёлых» типов:Secret,ConfigMap,Event,Pod,Node.Добавьте
Transformдля объектов, у которых вам не нужны «толстые» поля —ManagedFieldsсами по себе съедают заметную долю памяти.Добавьте
IndexFieldпод каждыйListсMatchingFields. Нет индекса — у васO(n)скрытого скана на каждый реконсайл.Не мутируйте объекты, полученные в
EventHandlerиPredicate, без предварительногоDeepCopy. Мутации в Store ломают соседние контроллеры тихо и надолго.Делайте
Reconcileидемпотентным. Он должен корректно отработать, даже если его дёрнули пять раз подряд без реальных изменений.Не ждите read-after-write из кэша сразу после
Update. В этом окне кэш ещё отстаёт.Если нужна свежесть (вебхуки, инициализация, разовые чтения) — используйте
APIReader, а не обычныйClient.Используйте
PartialObjectMetadataдля типов, где нужна только метадата. Это может сэкономить гигабайты.Не дёргайте
mgr.GetClient()доmgr.Start(). Информер ещё не прогрет, Store пустой, и вы получите либоNotFound, либо пустойList, а потом полдня будете выяснять, почему объект «пропал».Для отложенных действий используйте
RequeueAfter, а неtime.Sleepи не свои горутины.
Итого
Если очень коротко:
Кэш в
controller-runtime— не оптимизация, а модель работы. Под капотом —Reflector+DeltaFIFO+Indexer, те же самые, что и в ядре Kubernetes.r.Get/r.Listидут в память,Create/Update/Patch/Delete— напрямую в apiserver. Обратная связь — через watch.IndexField+MatchingFieldsпревращают кэш в почти полноценный query engine с inverted-индексами.Namespaces, селекторы,PartialObjectMetadata,Transform— инструменты, чтобы контролировать, сколько памяти и трафика вы реально потребляете.APIReader— аварийный выход, когда нужна строго свежая версия объекта.
И главное, что стоит запомнить одной фразой: r.Get в реконсайле не ходит в apiserver. Никогда. Даже в самый первый раз. Как только это становится рефлексом — половина вопросов на ревью операторов отваливается сама.
Что дальше
За кадром этой статьи сознательно остались вопросы:
зачем нужны
managedFields;как работает Server-Side Apply;
как работает
Patchбез указанияresourceVersion.
На них мы постараемся ответить в следующей статье из цикла про то как работает Kubernetes изнутри. Подписывайтесь, чтобы не пропустить.
Если у вас есть свои кейсы по кэшу в controller-runtime, распространённым ошибкам или неочевидным настройкам — с радостью почитаю в комментариях.