Как на самом деле устроен кэш в controller-runtime, и почему ваш оператор не кладёт apiserver

Страницы:  1

Ответить
 

Professor Seleznov


pic
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). Нужна для двух вещей: для оптимистического контроля конкурентности (при 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. Именно их информер вызывает, когда в Store что-то меняется. Подписчики (ваши контроллеры) регистрируют такие обработчики и через них узнают об изменениях.
  • 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 — дирижёр, который склеивает всё это воедино и раздаёт события подписчикам — вашим контроллерам и прочим наблюдателям.
На пальцах конвейер выглядит примерно так:
pic
Пройдёмся по звеньям.
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 (OnAdd, OnUpdate, OnDelete).
«Наружу» — это как раз к вашим контроллерам. Контроллер в controller-runtime при регистрации Watches(...) под капотом просит информер: «добавь мне обработчик, при изменении объекта клади ключ вот в этот мой workqueue». Дальше воркеры контроллера по одному тянут ключи из очереди и зовут ваш Reconcile(ctx, ctrl.Request{NamespacedName: ...}).
Ключевое слово в названии — Shared. Manager создаёт один информер на GVK, и все контроллеры, вебхуки и источники событий в рамках этого менеджера подписываются на него:
pic
То есть информер — это то, что один раз подписалось на 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) тоже можно, но это отдельный разговор — про него чуть ниже.
Теперь цикл «запись → видимость» выглядит так:
pic
Между «выполнили 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
У информера есть параметр resyncPeriodcontroller-runtime по умолчанию 10 часов), и многие думают, что это «раз в N часов переливать всё из apiserver».
Нет. Resync не делаетLIST. Он просто пробегает по текущему содержимому Store и вызывает 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
CREATE INDEX idx_node ON pods(node_name) IndexField(&Pod{}, "spec.nodeName", fn)
SELECT * FROM pods WHERE node_name = 'node-1' List(&pods, MatchingFields{"spec.nodeName": "node-1"})
SELECT * FROM obj WHERE owner_uid = $1 List(&list, MatchingFields{"metadata.ownerReferences.uid": uid}) (нуженIndexFieldпо этому полю)

Обратите внимание на последнюю строчку: 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() в этот момент вернёт пустоту.
Цена — реальный сетевой запрос. Важно: не надо строить логику в духе «сначала посмотрим в кэш, если нет — сходим в API». Так вы собственноручно воссоздаёте ровно тот split-brain, от которого кэш и защищает.
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, распространённым ошибкам или неочевидным настройкам — с радостью почитаю в комментариях.-Источник
 
Loading...
Error