controller-runtime Cache Nasıl Çalışır?
Kubernetes için Go ile controller yazmak, kubebuilder ve controller-runtime sayesinde artık birkaç saatlik bir iş. Ama yük arttığında ya da controller beklenmedik biçimde davranmaya başladığında sorunların çoğu tek bir kök nedene iner: controller-runtime‘un iç işleyişine dair bulanık bir zihinsel modele. Bu yazı, cache mekanizmasının nasıl kurgulandığını ve reconciler içindeki okumaların neden API sunucusuna gitmediğini adım adım anlatıyor.
Not: Kaynak makalenin başında, bazı teknik ayrıntıların gözden geçirildiğine dair bir uyarı yer alıyor. Kesin doğrulama için
controller-runtimeresmi belgelerine başvurmanız öneriliyor.
Kısaca: reconciler okuma yapmıyor, cache’ten alıyor
Yazının özü tek cümleye sığar: Reconciler içindeki r.Get() ve r.List() çağrıları tipik olarak API sunucusuna gitmez. Bu çağrılar, manager tarafından önce list ile ısıtılan ve ardından watch ile güncel tutulan bellek içi yerel bir cache’i okur. Başka pek çok davranış da bu tek olgudan türer:
- Okumalar ucuzdur, ama bir yazma işleminin hemen ardından güçlü tutarlılık garantisi vermez.
- Yazmalar (
Create,Update,Patch,Delete) doğrudan API sunucusuna gider, cache üzerinden geçmez. - Yerel cache’in boyutu ve tanımlı index’ler bellek tüketimini doğrudan belirler.
- Yanlış yazılmış bir
List()çağrısı, on binlerce nesne üzerinde sessizce doğrusal taramaya dönüşebilir. APIReaderçoğu zaman gereksizdir, ama bazı özel durumlarda vazgeçilmezdir.
Reconciliation döngüsü ve cache’in var oluş nedeni
Bir Kubernetes controller’ı, bir nesnenin arzu edilen durumu ile fiili durumunu sürekli karşılaştırır ve ikisini hizalamaya çalışır. Kaynakta anlatıldığı üzere döngü kabaca şöyle işler: Kullanıcı ya da başka bir controller nesneyi değiştirir, kuyruğa bir olay düşer, Reconcile mevcut durumu okur, controller ne yaratılacağına, güncelleneceğine veya silineceğine karar verir; sistem yeni bir olay üretir ve döngü tekrar başlar.
Buradaki kritik nokta controller’ın “bir şey yapması” değil, değişiklikleri nereden öğrendiği ve durumu nereden okuduğu. Canlı bir cluster üzerinde kubectl get pods --watch komutu, controller’ların tükettiği olay akışını gözlemlemenin en pratik yoludur: Tek bir “nihai” nesne değil, scheduler’ın node atadığı, kubelet’in status güncellediği bir durum zinciri görürsünüz.
Basit bir controller düşünün:
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
}
//... anlamlı iş mantığı...
}
r.Get her çağrıda API sunucusuna HTTP isteği atsaydı, saniyede yüzlerce reconcile üreten onlarca controller API sunucusunu ve etcd‘yi kısa sürede boğardı. Bu nedenle Kubernetes daha ilk günden polling yerine watch modeli üzerine kuruldu: İstemci bir kez list yapar, ilgilendiği dilimin anlık görüntüsünü alır, ardından watch ile değişiklik akışına abone olur ve yerel kopyasını güncel tutar. Bütün bu iletişim tek bir uzun ömürlü HTTP bağlantısı üzerinden yürür.
controller-runtime, bu mekanizmayı client-go içindeki Reflector, DeltaFIFO ve Indexer gibi parçaları elle birleştirmek zorunda kalmadan kullanabilmeniz için sarmalar. Dolayısıyla “controller-runtime cache” bir optimizasyon değil, modelin tamamının temelidir: Bellekten okur, API sunucusuna yazar, watch üzerinden geri bildirim alırsınız.
Temel kavramlar
- GVK (GroupVersionKind): Kubernetes’te bir API tipini benzersiz tanımlayan üçlü; örneğin
apps/v1/Deployment.controller-runtimeAPI’lerinin neredeyse tamamı GVK üzerinden çalışır. - resourceVersion: API sunucusunun otomatik olarak izlediği ve her güncellemede artan monoton bir sayaç. Ondalık bir sayı olsa da string olarak taşınır. İki temel işlevi vardır: Optimistic concurrency control (örneğin
update‘de sağladığınız değeretcd‘dekiyle eşleşmezse409 Conflictdöner) ve birwatch‘un kaldığı yerden sürdürülmesi. - Manager:
main.goiçinde oluşturupmgr.Start(ctx)ile çalıştırdığınızctrl.Managernesnesi. Paylaşılan cache’e sahiptir, client’ı inşa eder, controller’ları, webhook’ları ve healthz endpoint’ini yönetir. Genellikle tek bir process içinde tek bir manager ve onun içinde birden çok controller bulunur. - Informer: Tek bir GVK için watch tutan, indexli yerel bir store barındıran ve olayları abonelere dağıtan
client-gobileşeni.Watches(...)kaydettiğinizde veya ilgili tip üzerinde ilkGet/List‘i çağırdığınızda otomatik oluşturulur. - Store: Informer’ın nesneleri tuttuğu bellek içi arka depo. Her informer’ın kendi store’u vardır.
- ResourceEventHandler:
OnAdd,OnUpdate,OnDeletemetodlarını içeren arayüz. DeltaFIFO üzerinden gelen her olay için informer bu metodları çağırır; store, handler çağrısıyla eş zamanlı güncellendiği için handler nesnenin en güncel halini görür. - workqueue: Deduplication ve rate limiting içeren,
namespace/nameanahtarlarından oluşan bir kuyruk. Her olayda controller bir anahtar ekler; worker’lar sırayla alır veReconcile‘actrl.Requestolarak iletir. - Predicate: Controller tarafındaki filtre. Bir olayın kuyruğa girip girmeyeceğine karar verir (örneğin yalnızca
specdeğişikliklerine tepki ver,status‘ü yok say).
Cache paketinin anatomisi
sigs.k8s.io/controller-runtime/pkg/cache, k8s.io/client-go/tools/cache üzerine ince bir sarmalayıcıdır. Altta Kubernetes’in geri kalanını da besleyen aynı ilkel yapılar bulunur:
- Reflector: API sunucusuna karşı
watch‘u açık tutar ve gelen değişiklikleri delta olarak kuyruğa yazar. Bir delta, “X nesnesiAdded/Updated/Deletedolayı aldı, işte yeni versiyonu” formundaki kayıttır. - DeltaFIFO: Deltaları tutan kuyruk. Her
namespace/nameanahtarı için o nesneye olanları sırayla biriktirir. - Indexer (Store): Bellek içi nesne deposu ve üzerine kurulmuş index’ler.
- SharedIndexInformer: Tüm bu parçaları bir araya getiren ve olayları abonelere dağıtan orkestratör.
Reflector ve resourceVersion
API sunucusuyla doğrudan konuşan tek bileşen Reflector’dır. İki işi vardır: Başlangıçta tek bir list, sonrasında sürekli açık bir watch. API sunucusu, nesne listesiyle birlikte anlık görüntünün alındığı sürümü de döner. Reflector API sunucusuna “X sürümünden itibaren watch aç” der ve o sürümden sonraki bütün olayları akış olarak alır. Böylece list ile watch arasında olay kaçırma riski oluşmaz.
Bağlantı koparsa Reflector bilinen son resourceVersion ile yeniden bağlanır. API sunucusu 410 Gone döndürürse (“bu sürüm artık tarihte yok, çok gerideyken”), Reflector yeni bir list yapar ve baştan başlar. Buna relist denir; zamanlanmış bir işlem değildir, yalnızca bu tür hata senaryolarında olur.
DeltaFIFO: delta kuyruğu
DeltaFIFO, Reflector ile informer’ın geri kalanı arasındaki tampondur. Girişi API sunucusundan gelen olay akışı, çıkışı aynı olayların anahtara göre gruplanmış ve sıralı halidir. Üç işi çözer:
- Sırayı korur:
default/my-deployiçin gelen akış, tüketiciye API sunucusunun teslim ettiği sırayla ulaşır. - Anahtara göre gruplar: Tek bir
namespace/nameiçin tüm deltalar tek bir yuvada birikir.Pop(), tek bir delta değil, o anahtar altında biriken deltaların dilimini döner. - Seçici deduplike eder: Yerleşik
dedupDeltasfonksiyonu, aynı anahtar için ardışıkDeleteddeltalarını birleştirir; böylece iki delete olayı iki ayrı işlem turuna dönüşmez.
Önemli bir uyarı: DeltaFIFO, ardışık Added veya Updated deltalarını birleştirmez. Ara durumları tek bir nihai duruma indirmek onun işi değildir.
Somut bir örnek: default/my-deploy için art arda üç olay gelsin — Added (replicas=1), Updated (replicas=2), Updated (replicas=3). DeltaFIFO üçünü de aynı yuvaya koyar. Pop() hepsini tek dilim olarak döndürür; sharedIndexInformer.HandleDeltas sırasıyla önce OnAdd, sonra iki OnUpdate çağırır. Handler üç kere çalışır, kısa yol yoktur.
Peki nesne başına gerçek dedup nerede? Bir katman yukarıda, controller’ın workqueue’sunda. Mekanik basittir: DeltaFIFO’dan gelen her delta için controller’ın event handler’ı nesneden namespace/name anahtarını çıkarır ve kuyruğa ekler. Aynı anahtarı yeniden eklemek mevcut girdiyle sessizce birleşir; workqueue nesneyle ilgilenmez.
Pratik yansıması şudur: Bir Pod yarattığınızda saniyeler içinde bir dizi Updated gelir; scheduler node atar, kubelet önce Pending, sonra ContainerCreating, Running, Ready döner. Event handler her defasında tetiklenir; ama tüm bu pencere boyunca workqueue default/my-pod anahtarını tek girdide tutar. Reconcile onu aldığında cache zaten nihai durumu barındırır ve Reconcile bir kez çalışır.
Böylece iki katmanın sorumlulukları net biçimde ayrılır: DeltaFIFO delta gerçeklerini doğru sırayla iletmekle, workqueue ise anahtar bazlı dedup ve rate limiting ile ilgilenir. Bu iki katmanlı resmi zihninizde tuttuğunuzda, tek nesneye yönelik olay selinin controller throughput’unu neden neredeyse hiç etkilemediği anlaşılır.
Indexer: cluster’ın yerel kopyası
Indexer (diğer adıyla ThreadSafeStore), cluster’ın yerel kopyasıdır. Altında namespace/name anahtarlı düz bir map[string]interface{}, bir mutex ve kayıtlı index’lerin sözlüğü vardır. Evet, özünde bellekteki bir map. B-tree veya LSM yok. r.Get‘in bir cache-hit’te mikrosaniyeler mertebesinde olmasının nedeni tam olarak budur: Map araması ve ardından bir Go struct kopyası.
SharedIndexInformer ve abonelikler
SharedIndexInformer; Reflector, DeltaFIFO ve Indexer’ı tek bir yapıya kaynaştırır ve dışarıya iki arayüz sunar: Nesneleri doğrudan indexer’dan okumak ve bir ResourceEventHandler kaydedip DeltaFIFO’dan çıkan her olay için (OnAdd, OnUpdate, OnDelete) bildirim almak. Store, handler çağrısıyla eş zamanlı güncellendiği için handler çalıştığında indexer yeni durumu yansıtır.
İsimdeki anahtar kelime Shared. Manager her GVK için tek bir informer oluşturur; o manager içindeki her controller, webhook ve olay kaynağı bu informer’a abone olur. API sunucusu tarafından bakıldığında process içinde kaç reconciler olursa olsun her GVK için tek bir list ve tek bir watch vardır.
Başlangıçta ve ilk r.Get‘te ne olur?
- Manager’ın
mgr.Start(ctx)‘i kayıtlı tüm informer’ları ayağa kaldırır. - Her GVK için Reflector, kapsamınıza giren tüm nesnelerin tam
list‘ini alır. listyanıtı informer’ın store’una yüklenir, kayıtlı index’ler yeniden inşa edilir ve informer’ınHasSynced()bayrağıtrueolur.- Bunun ardından
list‘in döndürdüğüresourceVersion‘dan başlayan birwatchaçılır. - Ancak bundan sonra controller
Reconcile‘ı çağırmaya başlar; yani sahip olduğu tüm kaynaklar içincache.WaitForCacheSynctruedöndüğünde. O ana kadar workqueue’ya olaylar birikse bile worker’lar kuyruğu boşaltmaz.
Yani controller-runtime‘da “reconciler çalışıyor ama cache hâlâ boş” gibi bir durum tasarım gereği gözlemlenemez. Isınma her zaman önden yapılır, tembel değil.
İlk r.Get sırasında ne olur? Reconciler’ınız şunu içeriyor olsun:
var obj appsv1.Deployment
err := r.Get(ctx, req.NamespacedName, &obj)
Altta kabaca şuna iner:
item, exists, err := indexer.GetByKey("default/my-deploy")
if !exists {
return apierrors.NewNotFound(...)
}
// obj içine DeepCopy
HTTP yok, TLS yok, protobuf serileştirmesi yok, etcd yok. Map araması, struct kopyası, dönüş. Mikrosaniyeler mertebesinde. Bir kez daha altını çizmek gerek: Controller’ın yaşam döngüsündeki ilk Get bile tamamen ısınmış, tamamen indexlenmiş bir anlık görüntüden okur. “İlkinde yavaş, sonrasında hızlı” gibi bir durum yoktur.
Not: Bu davranış özellikle mgr.GetClient() için geçerlidir. mgr.Start()‘tan önce nesneleri okumanız gerekiyorsa (örneğin başlatma sırasında), doğrudan API sunucusuna giden mgr.GetAPIReader()‘ı kullanabilirsiniz.
Client ≠ Cache: bellekten oku, API sunucusuna yaz
Sıklıkla gözden kaçan bir başka nokta: controller-runtime‘daki client.Client bileşik bir nesnedir.
- Okumalar (
Get,List) cache üzerinden yapılır. - Yazmalar (
Create,Update,Patch,Delete,DeleteAllOf) doğrudan API sunucusuna gider.
Bu bir kolaycılık değil, kasıtlı bir tasarım tercihidir: Okumalar sıktır, ucuz olmalıdır; yazmalar seyrektir, kesin olmalıdır. Bu ayrımı zihinsel modelinizin merkezine koyduğunuzda, cache tabanlı okumaların bir yazma sonrası neden geçici olarak “eski” görünebildiği ve APIReader‘ın hangi dar kesitlerde neden gerekli olduğu da netleşir.
Kaynaklar ve İleri Okuma
- How the controller-runtime Cache Actually Works, and Why Your Controller Does Not Crash the API Server — kubernetes.io
- sigs.k8s.io/controller-runtime paket referansı
- Kubernetes API Concepts (list, watch, resourceVersion)
- Kubernetes Server-Side Apply
- Kubernetes Architectural Principles
- Reconciliation loop pattern — görsel anlatım
- Kubernetes v1.36 Controller Staleness: Bayat Cache Sorunu Bitti mi?







Yorum gönder