Как на самом деле устроен кэш в controller-runtime, и почему ваш оператор не кладёт apiserver
- четверг, 7 мая 2026 г. в 00:00:11

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-кластеров: память, трафик, консистентность чтения и поведение реконсайла.
Если хочется забрать из статьи одну мысль и уже с ней идти читать дальше, то она такая:
r.Get() и r.List() в реконсайле обычно читают не из apiserver, а из локального in-memory cache, который менеджер прогревает через LIST и затем поддерживает через WATCH.
Из этого следуют почти все остальные свойства системы:
чтение дешёвое, но не мгновенно консистентное после записи;
запись идёт напрямую в apiserver, а не через cache;
размер локального cache и набор индексов напрямую влияют на потребление памяти;
неправильный List() легко превращается в линейный скан по десяткам тысяч объектов;
APIReader нужен редко, но в некоторых местах без него нельзя.
Дальше разберём, почему это так и как именно эта модель устроена под капотом.
Чтобы дальше не спорить о терминах, зафиксируем базовую модель.
Контроллер в Kubernetes живёт в цикле reconciliation: он постоянно сравнивает желаемое состояние объекта с фактическим и пытается привести одно к другому. Эта идея описана ещё в оригинальной архитектурной заметке про Kubernetes. Обычно это выглядит так:
пользователь или другой контроллер меняет объект;
событие попадает в очередь;
Reconcile читает текущее состояние;
контроллер решает, что нужно создать, обновить или удалить;
система получает новое событие и цикл повторяется.
Важно здесь не то, что контроллер «что-то делает», а то, откуда он узнаёт об изменениях и откуда читает состояние. Ровно в этом месте и начинается cache.
На живом кластере эту механику проще всего увидеть через:
kubectl get pod -w
kubectl в режиме -w подписывается на тот же событийный поток, на котором живут контроллеры. Вы создаёте или удаляете Pod и видите не один «финальный» объект, а цепочку состояний: scheduler назначает ноду, kubelet обновляет статус, другие контроллеры вносят свои изменения. Контроллеры Kubernetes работают не через постоянный polling, а через поток событий и локальное состояние, которое поддерживается в актуальном виде.
Я уже показывал этот процесс на живом демо — вот короткая вырезка из моего доклада, где я показывал, как reconciliation loop отрабатывает на реальном примере Pod и через какие состояния он проходит: https://t.me/ittales/661
Давайте представим простейший контроллер:
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). Нужна для двух вещей: для оптимистического контроля конкурентности (при Update apiserver проверит, что resourceVersion у вас тот же, что и в etcd, иначе вернёт 409 Conflict) и для возобновления watch (при WATCH?resourceVersion=X apiserver пришлёт все события, случившиеся после версии 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 игнорировать»).
Теперь можно нырять.
Если заглянуть в 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 — это процесс, который непосредственно общается с apiserver. У него всего две задачи: при старте один раз сделать LIST и дальше держать открытым WATCH.
Тут пригодится тот самый resourceVersion. Отвечая на LIST, apiserver возвращает не только список объектов, но и версию, на которой этот снимок получен. Дальше Reflector говорит apiserver: «открой мне WATCH с версии X» — и получает поток событий обо всём, что произошло после этой версии. Это и есть основа консистентности: мы не рискуем пропустить событие между LIST и WATCH, потому что WATCH продолжает ровно с той точки, на которой закончился LIST.
Если соединение отваливается — Reflector переподключается с последним известным resourceVersion. Если apiserver отвечает 410 Gone («этой версии уже нет в истории, ты слишком отстал») — Reflector делает новый LIST и начинает заново. Это называется relist, и случается он не по расписанию, а именно в таких аварийных сценариях.
Это место, где стоит задержаться. 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 (он же ThreadSafeStore) — это и есть локальная копия кластера. Под капотом — обычная map[string]interface{} с ключом namespace/name и мьютекс. Плюс словарь зарегистрированных индексов, про который поговорим в отдельном разделе.
То есть да, по сути это просто мапка в памяти. Никаких хитрых B-деревьев, никаких LSM. И именно поэтому r.Get из кэша стоит микросекунды — это банальный lookup по мапе и копирование Go-структуры.
Информер — это сущность, которая склеивает 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 в вашем реконсайле.
При старте менеджера вызывается 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.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 того же объекта вернёт старую версию. Отсюда растёт львиная доля проблем, которые я дальше перечислю.
Довольно частая история:
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, который ходит мимо кэша. Про него — ниже.
Чтобы понять этот сюжет, сначала два слова про механику событий в контроллере. Когда вы регистрируете источник через 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 перед мутацией.
У информера есть параметр resyncPeriod (в controller-runtime по умолчанию 10 часов), и многие думают, что это «раз в N часов переливать всё из apiserver».
Нет. Resync не делает LIST. Он перекладывает всё, что лежит в Indexer, обратно в DeltaFIFO как Sync-дельты — и информер обрабатывает их обычным образом, вызывая OnUpdate(old, old) на каждый объект. Так контроллер, который по какой-то причине пропустил своё реконсайл-окно (залип воркер, отвалился обработчик), получает шанс увидеть мир заново. Трафика на apiserver это не создаёт.
Настоящий relist случается только в двух случаях: когда WATCH отвалился с 410 Gone, и когда вы руками пересоздаёте информер.
Маленькая ремарка, которая часто выручает. Иногда в реконсайле хочется подождать: «пошёл в API провайдера, запросил статус, если ещё не готово — попробую через минуту». Соблазн — запустить time.Sleep или собственную горутину.
Не надо. В controller-runtime для этого есть штатный механизм:
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
Контроллер поставит ваш req обратно в workqueue с отложенным срабатыванием через 30 секунд. При этом, если за это время придёт реальное событие по тому же объекту — реконсайл отработает сразу, не дожидаясь таймера (ключ в очереди дедуплицируется). Это и дешевле, и корректнее, чем собственные таймеры: вы не удерживаете воркер и не рискуете пропустить настоящее событие.
Есть и просто ctrl.Result{Requeue: true} — положить в очередь сразу, но с учётом rate-limiter’а.
А теперь к самой, пожалуй, полезной возможности кэша, которую на практике использует далеко не каждый.
По умолчанию 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 и полдня выясняет, почему его контроллер его «не видит».
Отдельный паттерн — когда вам важно знать, что объект существует, но не нужны его 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 разница в памяти легко двухзначная кратность.
mgr.GetAPIReader() возвращает client.Reader, который ходит напрямую в apiserver, минуя кэш. Когда он действительно нужен:
Validating webhook, где вам критична свежая версия объекта. Кэш в другом процессе в этот момент может отставать, и вы заблокируете корректный Update.
Разовое чтение ресурса, для которого у вас не поднят информер. Поднимать watch ради одной операции — дорого.
Чтение до mgr.Start(), например в инициализации. Обычный mgr.GetClient() в этот момент вернёт пустоту.
Цена — реальный сетевой запрос. Важно: не надо строить логику в духе «сначала посмотрим в кэш, если нет — сходим в API». Так вы собственноручно воссоздаёте ровно тот split-brain, от которого кэш и защищает.
Под занавес — набор правил, которые стоит проверять по чек-листу, прежде чем выкатывать оператор в живой кластер:
Ограничьте 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, распространённым ошибкам или неочевидным настройкам — с радостью почитаю в комментариях.