最近在讀 Kubernetes controller,以下是我的摘要筆記:
Operator Pattern 的核心只有一句話:
持續觀察某個資源的「期望狀態」(desired state)與「實際狀態」(actual state),並不斷讓實際狀態趨近期望狀態。
這個持續比對、修正的迴圈,叫做 reconcile loop。要實作這件事,通常需要兩塊拼圖:
kubectl apply 描述「我想要什麼」Operator = Controller + CRD
KubeRay 就是最典型的例子:它定義了 RayCluster、RayJob、RayService 這些 CRD,controller 則負責根據 CR 的 spec 去建立/管理對應的 Pod。
Argo CD 也是同樣的模式,只是它的「期望狀態」不是寫在 CR 的欄位裡,而是存在 Git repo 的 manifests 中,這就是 GitOps 的核心:把 Git 當作唯一真相來源(single source of truth),而 Argo CD 的 controller 持續比對 Git 內容跟 cluster 實際狀態,發現不一致就同步。可以說 GitOps = Operator Pattern + Git 作為期望狀態的來源。
要實作一個 Go 語言的 controller,幾乎都會用到官方的 client-go library,它封裝了「怎麼跟 kube-apiserver 溝通」這件事。值得注意的是,client-go 本身不是 cluster 內建的東西,而是你程式裡引用的一個 library;apiserver 對外暴露的其實是標準 REST API,任何語言只要能發對應格式的 HTTP request 都能操作 K8s 資源(Python 有 kubernetes client、Java 有對應 SDK),client-go 只是 Go 語言的官方實作。
下圖是理解 client-go controller 機制最經典的一張圖,整體可以分成上下兩個區塊:

(Source: Writing Kubernetes Custom Controllers)
Reflector 是整條流程的起點,負責跟 Kubernetes API Server 要資料。它做兩件事:
這個機制讓 controller 能即時得知資源變化,同時不會對 apiserver 造成過大壓力。
Reflector 收到的每個變化會被包裝成一個 Delta,放進 Delta FIFO Queue。Delta FIFO 做的事情包含:
Informer 從 Delta FIFO 把物件取出來後,會交給 Indexer 處理,同時把事件分派給 Resource Event Handlers。
為什麼 Informer 跟 Indexer 要拆成兩個獨立的元件?
因為兩者處理的是完全不同性質的問題:
| Informer | Indexer | |
|---|---|---|
| 職責 | 事件分派(Event Distribution) | 儲存與索引(Storage & Indexing) |
| 關心的問題 | 發生了什麼事、要通知誰 | 資料現在長怎樣、怎麼快速查到 |
| 存取模式 | 依序處理事件流(write path) | 隨機查詢(read path) |
把兩者拆開的好處:
Process Item 階段,可以直接透過 Indexer reference 查資料,完全不需要透過 Informer,彈性更高Indexer 把物件存進一個 thread-safe 的本地快取(in-memory cache),並建立索引。這一步是整個機制最省資源的關鍵:之後 Controller 查資料是查這份本地快取,不需要每次都打 API Server。
Informer 分派事件時,會呼叫你註冊的 AddFunc / UpdateFunc / DeleteFunc。這幾個 handler 有個重要限制:它們是在 Informer 的主 goroutine 裡被同步呼叫的,絕對不能在裡面做重活。如果在 handler 裡直接處理業務邏輯(例如呼叫外部 API、等待回應),會卡住整個事件分派流程,導致後續事件全部塞車。
所以正確的做法是:handler 只做一件事:把物件的 key 丟進 Workqueue,然後立刻返回。這裡故意只丟 key、不丟整個物件,是因為物件之後可能又變了,用 key 去查最新狀態(透過 Indexer)才準確。
Workqueue 存在的理由:
Worker 從 Workqueue 取出 key 後,進入實際的 reconcile 邏輯:
Ready: true)Indexer 是唯讀快取,不能拿來寫入資源。Informer/Indexer 這整套機制的用途只有「讓 controller 知道現在是什麼狀態」,完全不負責「怎麼改變狀態」。
真正修改資源時,Controller 會用 client-go 提供的 Clientset,直接對 apiserver 發 REST request:
clientset.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{})
clientset.CoreV1().Pods(namespace).Update(ctx, pod, metav1.UpdateOptions{})
// 針對 CRD,例如 kuberay 的 RayCluster
rayClientset.RayV1().RayClusters(namespace).Update(ctx, rc, metav1.UpdateOptions{})
所以整條資料流其實是兩條平行路徑:
寫入生效後,這個改動最終會經由 Reflector 的 Watch 機制重新流回 Delta FIFO → Informer → Indexer,讓快取更新成最新狀態——形成一個完整的閉環,這正是 reconcile loop 能持續、可靠運作的原因。
理解這套架構之後,再去讀任何 operator 的程式碼,不管是 kuberay、Argo CD,還是你自己想寫的 controller,骨架幾乎都大同小異: 一個 controller struct 裡通常同時持有 Lister/Indexer(唯讀查詢用)跟 Clientset(讀寫用)兩種引用,而所有的客製化邏輯,幾乎都濃縮在 Handle Object 這一小塊「比對期望與實際狀態」的程式碼裡。client-go 把跟 apiserver 溝通、快取管理、事件分派、重試機制都封裝起來了,讓開發者可以專注在業務邏輯的處理。