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. Информер вызывает их на каждое событие, пришедшее через 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 решает три задачи:

  1. Сохраняет порядок. Что бы ни прилетело по объекту default/my-deploy, на выходе вы увидите ровно ту последовательность изменений, в которой apiserver их присылал.

  2. Группирует по ключу. Все дельты по одному namespace/name копятся в одном слоте. Pop() возвращает не одну дельту, а слайс всех накопленных дельт по этому ключу — консьюмер одним разом видит всё, что произошло с объектом с прошлого вызова.

  3. Выборочно дедуплицирует. Встроенная функция dedupDeltas схлопывает подряд идущие Deleted по одному ключу — чтобы два delete-события не превратились в две отдельные обработки.

Важный момент: ни Added подряд, ни Updated подряд DeltaFIFO не мержит. В общем случае «сжать все промежуточные состояния в одно финальное» — не её работа.

Давайте на примере. Допустим, по объекту default/my-deploy очень быстро произошло три события:

  1. Added — создался Deployment (условно, с spec.replicas=1).

  2. Updated — кто-то поменял spec.replicas на 2.

  3. 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 в вашем реконсайле.

  1. При старте менеджера вызывается mgr.Start(ctx) — и он поднимает все зарегистрированные информеры.

  2. Для каждого GVK Reflector делает полный LIST — всех объектов, которые попадают под ваш scope.

  3. Ответ LIST раскладывается в Store информера, зарегистрированные индексы пересобираются, и у информера флаг HasSynced() переключается в true.

  4. После этого запускается WATCH с тем самым resourceVersion, полученным в LIST.

  5. И только теперь контроллер начинает дёргать 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

У информера есть параметр resyncPeriodcontroller-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

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() в этот момент вернёт пустоту.

  • Постраничные обходы больших выборок через 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, распространённым ошибкам или неочевидным настройкам — с радостью почитаю в комментариях.

Комментарии (0)