回到所有文章

Deployment 如何變成 Pod?從兩個 Controller 理解 Reconcile

從 Controller 的分工,理解 Watch、快取、工作佇列與滾動更新如何串起來。

執行 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 是三個,而且沒有既有物件可供採用:

  1. Deployment Controller 處理 hello-web,發現缺少對應的 ReplicaSet,透過 API 建立它,帶入 template 與副本目標。
  2. ReplicaSet Controller 的觀察機制接到這個 ReplicaSet,安排一次檢查。它看到目標是三個、目前沒有所屬 Pod,於是請求補建。
  3. 新 Pod 的變化被觀察到後,後續檢查就能看到建立結果。
Deployment Controller 與 ReplicaSet Controller 的觀察及修改責任
開啟完整圖表

圖中的虛線是 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 ControllerDeployment 新增、更新、刪除該 Deployment
Deployment ControllerReplicaSet 新增、更新、刪除相關的 Deployment
Deployment ControllerPod 刪除的特定處理路徑符合條件的 Deployment
ReplicaSet ControllerReplicaSet 新增、更新、刪除該 ReplicaSet
ReplicaSet ControllerPod 新增、更新、刪除相關的 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」這種待辦動作。

api-server、Informer、work queue 與 worker 的資料流
開啟完整圖表

所以,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 的目標:舊版這組先留兩個,新版這組先要一個。

Deployment Controller 設定新舊 ReplicaSet 的目標數量
開啟完整圖表

例如 replicas: 3、maxSurge: 1、maxUnavailable: 0,在沒有額外故障的前提下,可以這樣理解其中幾個階段:

階段v1 目標v2 目標為什麼這樣調?
更新前30目前由 v1 提供服務
先加一個 v231使用允許多出的一個副本,等待 v2 可用
v2 可用後21可以減少一個 v1,再繼續推進
更新完成03全部交給 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 變更經 api-server Watch 傳到 Controller 的流程
開啟完整圖表

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 內部運作機制。

繼續閱讀其他筆記