執行
kubectl apply建立 Deployment 後,Kubernetes 會接著建立 ReplicaSet 與 Pod。之後手動刪掉其中一個 Pod,通常很快又會看到新的出現。 在背後持續推動這些變化的 Controller 是什麼?它又是怎麼做到的?
0. 什麼是 Controller?
Deployment 是 k8s 裡的資源物件,描述工作負載的目標,例如要使用哪個 image、維持幾個副本。Controller 則是持續觀察相關資源,依據目標與現況採取行動的控制程式。你透過 kubectl 修改 Deployment 後,Controller 負責判斷需要建立或調整哪些資源,再向 api-server 發出請求;api-server 則處理這些請求並儲存物件。
本文的 Deployment Controller 和 ReplicaSet Controller,通常都由控制平面的 kube-controller-manager 執行。建立十個 Deployment,不代表會啟動十個專屬的 Controller 程序;同一種 Controller 可以處理多個對應資源。
Controller 每次檢查時,會判斷現況距離目標還差什麼,再透過 API 發出必要的請求。這個反覆觀察與調整的過程,就是後面要介紹的 reconcile。官方 Controller 說明
Pod 少了一個,是誰先發現的?會是 Controller 一直呼叫 k8s 的 api-server 檢查嗎?如果同時改了 replicas 和 image,它怎麼決定要做什麼?既然 Deployment 已經知道我要幾個副本,為什麼還要多一層 ReplicaSet?
要理解這些行為,可以先看兩個 Controller 如何接力,再拆開它們每一輪做決定的方式。
1. 從 Deployment 到 Pod,先看兩個 Controller 的分工
Deployment → ReplicaSet → Pod 這層關係,背後由兩個 Controller 分別推動。
假設第一次建立 hello-web,replicas 是三個,而且沒有既有物件可供採用:
- Deployment Controller 處理
hello-web,發現缺少對應的 ReplicaSet,透過 API 建立它,帶入 template 與副本目標。 - ReplicaSet Controller 的觀察機制接到這個 ReplicaSet,安排一次檢查。它看到目標是三個、目前沒有所屬 Pod,於是請求補建。
- 新 Pod 的變化被觀察到後,後續檢查就能看到建立結果。
圖中的虛線是 Controller 觀察資源,實線是 Controller 透過 API 修改資源。兩種箭頭都從執行動作的 Controller 出發;這張圖表達責任關係,不是時間順序。
Deployment Controller 與 ReplicaSet Controller 透過 API 中的資源狀態協作,不直接呼叫彼此。它寫入的資源,會成為另一個 Controller 下一輪判斷的輸入。
反方向也一樣:ReplicaSet 的狀態變化,讓 Deployment Controller 得以重新判斷更新進度。它們透過共同的資源狀態接起來,各自完成自己負責的控制迴圈。Deployment Controller · ReplicaSet Controller
2. Reconcile:依這次看到的狀態決定動作
假設 hello-web 已經有三個 Pod。你手動刪掉一個,沒有重新 apply,卻很快又看到新 Pod 出現。
換句話說,apply 完成後,事情並沒有隨著那次 API 請求一起結束。目標留在叢集裡,Controller 會在後續的檢查中,判斷還需要做什麼才能滿足它。
這也是為什麼「建立時湊滿三個」還不夠。我們希望的是:過一段時間,即使現況改變了,仍然有人把它往目標拉回來。Controller 的控制迴圈
要維持目標,每一輪檢查都得根據當時看到的狀態做決定。
Pod 被刪掉後,最直覺的寫法似乎是:「收到刪除通知,就建一個新的。」
不過,假設通知還在等處理,你已經把 replicas 從三個改成兩個呢?這時照原本的通知補一個,反而做多了。
Reconcile 的做法是:輪到我處理時,重新看目標和現況,再決定這次需要做什麼。
| 為什麼回來檢查? | 這次看到的目標/現況 | 數量上需要做什麼? |
|---|---|---|
| replicas 從 2 改成 3 | 要 3 個,目前 2 個 | 補 1 個 |
| 原本 3 個,被刪掉 1 個 | 要 3 個,目前 2 個 | 補 1 個 |
| 上次補建失敗,這次重試 | 要 3 個,目前 2 個 | 再嘗試補 1 個 |
| Pod 被刪後,replicas 也改成 2,且已被觀察到 | 要 2 個,目前 2 個 | 不用補 |
前三種情況不需要各自發明一套補建流程。它們最後都落在同一個問題:現在還差幾個?
圖先只畫數量判斷;真實實作還有稍後會談的防重複操作等檢查。
這裡最值得記住的是:收到通知,代表值得再看一次;不代表某個動作已經被決定了。 Reconcile 也不必一輪完成所有事情。這次發出請求,之後觀察到結果,再決定下一步,就能逐步往目標靠近。
3. 透過 Watch 與快取掌握變化
實際上 Controller 是怎麼知道目前物件有哪些變化的呢?
最直覺會先想到的就是每隔幾秒問一次 api-server,比對 Pod 數量。但當物件多起來,反覆把沒變的資料查回來,並不划算。
本文這兩個 Controller 使用的觀察機制,可以先理解成:取得已有物件,再透過 Watch 接收後續變化。Watch 是客戶端向 api-server 建立的串流連線;有相關物件新增、更新或刪除時,變化就沿著這條連線傳回來,不必一直重新查全部物件。那 api-server 又是如何得知變化的呢? 見文末的Watch 從哪裡來。
收到變化後,先做的是把觀察到的資料更新到本地快取,讓後續判斷有資料可讀。
client-go 是 Kubernetes 官方提供的 Go 用戶端函式庫,讓 Go 程式與 Kubernetes API 溝通,也提供建立 Controller 常用的快取、Informer 與工作佇列工具。本文的兩個 Controller 就使用這些工具處理資源觀察與工作安排。client-go 官方說明
其中,Informer 負責維護觀察到的物件資料。Controller 之後要查物件,可以透過 Lister 讀這份快取。觀察資料的更新因此可以集中處理,每次 reconcile 不需要重新向 api-server 拉一整批資料。
代價也很直接:本地快取可能慢一拍。 api-server 已經接受建立請求,不代表 Controller 下一次讀快取就一定看得到新物件。這個時間差,後面會直接影響「到底還要不要補建」。Informer 的一致性說明
另外,這裡的變更通知,和 kubectl describe 下面的 Events 是不同的東西。Controller 不需要先看到「Pod 被刪除」的診斷紀錄,才知道該重新檢查。
4. Queue 記錄的是待檢查物件
Informer 更新快取後,會將通知交給 Controller 註冊的 event handler。這裡的 handler 是回呼函式,負責處理物件新增、更新或刪除的通知;它是 Controller 實作的一部分。
Handler 會判斷這次變化影響哪個物件,把需要檢查的物件排入 work queue。Worker 則是從 queue 取出工作並執行 reconcile 的處理迴圈。Handler 負責接收通知與安排檢查,worker 負責執行這輪判斷。
以本文核對的 v1.34.0 實作來看,兩個 Controller 註冊了以下通知處理:
| Controller | 接收哪些物件通知 | 安排檢查的對象 |
|---|---|---|
| Deployment Controller | Deployment 新增、更新、刪除 | 該 Deployment |
| Deployment Controller | ReplicaSet 新增、更新、刪除 | 相關的 Deployment |
| Deployment Controller | Pod 刪除的特定處理路徑 | 符合條件的 Deployment |
| ReplicaSet Controller | ReplicaSet 新增、更新、刪除 | 該 ReplicaSet |
| ReplicaSet Controller | Pod 新增、更新、刪除 | 相關的 ReplicaSet |
表中列的是註冊的通知類型;handler 還會檢查歸屬與條件,不代表每則通知都必定入列。例如 Pod 被刪除時,ReplicaSet Controller 的 handler 會根據控制歸屬找到對應的 ReplicaSet,安排它重新檢查。部分 handler 也會更新稍後介紹的 expectations,記錄預期的變化已被觀察到。Deployment handlers · ReplicaSet handlers
想像同一個 ReplicaSet 底下,幾個 Pod 短時間內接連更新。與其每來一次通知,就立刻啟動一次完整處理,通知的 handler 會先把相關 ReplicaSet 排入佇列,交給 worker 處理。
這兩個 Controller 放進佇列的,是像 default/hello-web-abc123 這樣的 namespace/name,不是「補一個 Pod」這種待辦動作。
所以,Pod 被刪掉時,排進去的是「再檢查這個 ReplicaSet」。等 worker 真的取出它,才從快取讀取目標和 Pod,判斷現在需不需要補。
同一個物件短時間內連續變更,也不需要逐次重播操作。client-go 的 queue 會合併尚未處理的重複 key;處理途中又收到相關變化,則可以在這輪結束後再排一次。通知次數和建立 Pod 次數沒有一對一關係。Work queue 實作
如果建立失敗,沒有新 Pod 的變化可供觀察,就需要靠重試讓處理繼續。失敗的處理可以重新入列,搭配退避,避免 API 出問題時又被密集請求打滿。有些判斷也會安排延後再看。Watch 很重要,但它不是再次 reconcile 的唯一來源。
5. 快取慢一拍,怎麼避免重複補建?
Queue 能合併重複 key,但 Controller 仍得處理另一個問題:已經送出的請求,可能還沒反映在快取裡。
假設 ReplicaSet 目標是三個,快取裡只有兩個。Controller 已經成功送出建立第三個的請求,但新 Pod 還沒出現在快取。另一輪檢查又開始了:它還是只看到兩個。
如果只有「三減二,所以補一個」,確實可能補過頭。
ReplicaSet Controller 因此有 expectations:先記下自己預期會觀察到的建立或刪除,等相關通知跟上,再繼續做數量調整。它也有失敗處理與預期逾時的檢查,避免這份紀錄永遠擋住進度;逾時本身不代表會精準啟動下一輪。
這就是為什麼「每次重新比對」只是控制邏輯的核心,還需要其他機制處理非同步帶來的時間差。副本差額與 expectations 實作
同樣值得留意的是,ReplicaSet 計算的有效 Pod 數量不等於 Ready 數量。新 Pod 還沒 Ready,不代表少一個副本、應該再生一個;而更新時能不能減少舊版,才會牽涉可用副本的判斷。
6. 滾動更新讓兩層分工更清楚
Controller 的行為仍需要開發者實作,只是不用替每一種事件組合各寫一份劇本。ReplicaSet 聚焦於單組副本維護;Deployment 則負責協調各組的目標與更新策略。
調整 Deployment 的 replicas,涉及各個 ReplicaSet 的目標數量;修改 Pod template,例如 image,則涉及 rollout 和對應版本的 ReplicaSet,不會直接把欄位差異套用到既有 Pod 上。Deployment 更新行為
如果只要求「維持三個 Pod」,確實可以想像讓 Deployment Controller 自己做完。
但換成一次滾動更新,問題就不只剩總數了:三個 v1 要換成 v2,現在該增加幾個 v2?v2 還沒可用,可以先刪 v1 嗎?
Deployment Controller 要協調的是這些更新步驟。它可以把決定寫成兩個 ReplicaSet 的目標:舊版這組先留兩個,新版這組先要一個。
例如 replicas: 3、maxSurge: 1、maxUnavailable: 0,在沒有額外故障的前提下,可以這樣理解其中幾個階段:
| 階段 | v1 目標 | v2 目標 | 為什麼這樣調? |
|---|---|---|---|
| 更新前 | 3 | 0 | 目前由 v1 提供服務 |
| 先加一個 v2 | 3 | 1 | 使用允許多出的一個副本,等待 v2 可用 |
| v2 可用後 | 2 | 1 | 可以減少一個 v1,再繼續推進 |
| 更新完成 | 0 | 3 | 全部交給 v2 |
這是幾個目標數量的概念快照,中間省略了後續步驟;實際觀察不保證剛好停在每一列,正在終止的 Pod 也可能暫時還在。
現在假設更新到一半,v1 的一個 Pod 被刪了。ReplicaSet Controller 不需要理解整次 rollout 的計畫,只要按照 v1 ReplicaSet 目前的目標 判斷是否補建。目標已經降成兩個,就不會還執著於最初的三個。
從這裡看,兩層分工就具體了:
- Deployment Controller 決定各版本現在應該有幾個。
- ReplicaSet Controller 負責讓各組的數量靠近自己的目標。
Deployment 當然仍然關心副本數,只是它透過調整下一層的目標來推動更新。這是對現有架構的理解,不是說技術上絕對不能把兩者寫在一起。Rolling update 實作
7. 刪掉 Pod,又立刻調低副本數,會發生什麼?
回到沒有版本更新的 hello-web。目標是三個,你刪掉一個 Pod,又立刻把 Deployment 的 replicas 改成兩個。
這時還會不會先補出第三個?
兩種結果都可能,取決於相關 Controller 處理時已經觀察到什麼。
如果 ReplicaSet Controller 處理刪除時,ReplicaSet 目標仍然是三個,它可能先補建。Deployment Controller 後續把 ReplicaSet 目標降到兩個,再由 ReplicaSet Controller 調整多出的數量。
如果新目標已經一路傳到 ReplicaSet,而且快取也跟上了,它看到目標兩個、現有兩個,就不需要補。
多個控制迴圈透過 API 與快取協作,過程中就可能出現這些中間狀態。只要後續請求能成功、變化能被觀察到,它們就能繼續往新的目標調整。
下次看到 Pod 刪掉又長回來,就能再往下問一層:哪個物件被排進 queue?這輪讀到的目標是什麼?先前的建立請求,是否已經反映在快取裡?
比起背住「少了就補」,這幾個問題更能幫我們看懂 Controller 到底在忙什麼。
延伸:api-server Watch、斷線恢復與 resync
主線只需要先記住:變化會更新快取,相關物件會排入 queue,worker 再依本輪讀到的狀態決定動作。若要繼續追 client-go 的觀察機制,還可以往下看通知的來源,以及斷線與重新檢查的處理。
api-server 的 Watch 通知從哪裡來?
以把 Deployment 的 replicas 從 3 改成 2 為例,一般啟用 watch cache 的內建資源路徑如下:
etcd 自己就提供 Watch。 api-server 的儲存層透過這個機制取得資料變更,再轉成 Kubernetes 資源的變更通知。它不需要先把兩份 YAML 逐欄比較,才知道資料更新了。etcd Watch 實作
對持續符合觀看條件的 Deployment,更新通知會是 MODIFIED,並帶有更新後的物件。以下省略其他欄位,只示意訊息結構:
{
"type": "MODIFIED",
"object": {
"metadata": {
"name": "hello-web",
"resourceVersion": "..."
},
"spec": {
"replicas": 2
}
}
}
訊息不會直接寫成「replicas 少了 1,請刪掉一個 Pod」。Controller 收到通知後,仍要依照資源語意與當時觀察到的狀態決定動作。
api-server 的 watch cache 保存目前的物件狀態與有限的近期變更紀錄,協助向多個觀看者提供資料,並在可用範圍內從指定的 resourceVersion 接續通知。它和 Controller 端 Informer 的本地快取位於不同位置:前者服務 API 的讀取與觀看請求,後者供 Controller 的判斷邏輯讀取。Watch cache 實作
分派時仍可能檢查新舊物件,例如 label 更新後是否進入或離開某個 watcher 的篩選範圍。這影響該 watcher 收到的事件類型;它和最初透過 etcd 得知資料變更,是不同步驟。Watcher 事件轉換
Watch 斷線後如何繼續
Watch 斷線後,客戶端會嘗試重連,透過 resourceVersion 接續;如果需要的歷史版本已經不可用,就重新取得現況,再繼續監看。一般常用 List/Watch 說明這個過程,初始化也可能走 streaming list;重點是能重新掌握狀態,而非保證所有通知永遠不遺漏。API 的變更追蹤機制
Resync 不等於重新 List
你還可能在文件裡看到 resync:啟用時,Informer 會把快取裡的物件重新交給 handler,提供再檢查的機會。它不表示 api-server 有新變更,也不等於重新向伺服器 List 全部資料。重試上限、延後多久、是否 resync,仍由實作與設定決定,不能把它們想成所有 Controller 共用的一個固定倒數計時器。
本文沒有執行叢集實驗;情境與數量表為概念推演,不代表實測時序。實作細節以 Kubernetes v1.34.0/client-go v0.34.0 核對。圖只呈現當節需要的關係,未列出所有 handler;各 Controller 的重試與 resync 策略也不完全相同。
本系列的閱讀順序與後續方向,整理在 Kubernetes 內部運作機制。