當多個 controller 操作同一個 Custom Resource 時,並行性成為真正的問題:以下說明如何以穩健且可擴展的方式進行管理。
Operator 真正的問題:更新時的並行性
在真實世界中,很少只有單一 controller能以隔離方式管理「自己的」資源。一旦引入:
- 同一個專案(或同一個 manager)中有多個 controller,
- controller 寫入其他資源,
- 耗時操作(建立 VM、註冊節點、佈建…),
你就會開始遇到一個不可避免的主題:同一 Custom Resource (CR) 的並行更新。
要清楚理解發生什麼事,需要了解 Kubernetes 的兩個關鍵概念:
-
metadata.generation:僅在 spec 改變時才會增加。 -
metadata.resourceVersion:物件的每次修改(spec、status、label、annotation… 全部)都會改變。
這個區分至關重要,因為你在更新階段遇到的衝突幾乎總是來自 resourceVersion,而不是 generation。
Generation 與 resourceVersion 的實際應用
- 如果你修改
spec.targetNodes,則 generation 和 resourceVersion 都會改變。 - 如果你新增 label 或 annotation,generation 不會改變,但 resourceVersion 會改變。
換句話說:任何 write 都會產生新的 resourceVersion。Kubernetes 正是使用這個值來保護你,避免更新「舊副本」。
典型情境:NodePool + Autoscaler,兩個 controller 共用一個 CR
想像一個管理 NodePool CRD 的 operator:
-
spec.targetNodes:你想要多少個節點。 -
status.scaleOperation:使用inactive | active表示是否正在進行擴展。
NodePoolReconciler controller:
- 讀取
spec.targetNodes, - 在雲端建立 VM,
- 在叢集中註冊節點,
- 更新 status(從
active改為inactive)。
接著你再加入第二個 CRD Autoscaler,以及它自己的 controller,定期執行:
- 掃描特定命名空間,
- 尋找因 CPU/記憶體而 Pending 的 Pod,
-
增加 NodePool 的
spec.targetNodes以觸發建立新 VM。
因此你有:
- Controller A(NodePool):主要寫入
status,偶爾寫入 spec。 - Controller B(Autoscaler):寫入 NodePool 的
spec。
問題就在這裡開始。
典型衝突:對「過時」副本進行 update(resourceVersion 不符)
典型流程如下:
- NodePool controller 開始 reconciliation,讀取 resourceVersion = 1 的 NodePool 並快取在記憶體中。
- 同時 autoscaler 更新
spec.targetNodes(例如從 1 改為 2):etcd 中的物件變成 resourceVersion = 2。 - NodePool controller 完成佈建後,嘗試使用舊副本(rv=1)更新 status。
- Kubernetes 以 409 Conflict 拒絕寫入(「物件已被修改;請將你的變更套用到最新版本」)。
這不是「罕見」的例外:當操作耗時 + 多個 controller 時,這是正常現象。
最佳實踐 1:區分 spec 與 status(並使用 status 子資源)
第一條規則:如果你的 CRD 允許,請啟用並使用 status 子資源。
- Spec:使用者的期望(或其他像「使用者」一樣操作的 controller)。
- Status:controller 的觀察結果。
在 Kubebuilder 中,這意味著要先設計好 API,之後在 reconciler 中使用以下方式更新 status:
r.Status().Update(ctx, obj)
這樣就不會意外「觸及」spec。
這無法完全消除衝突,但能大幅減少 controller 之間互相干擾的情況。
最佳實踐 2:明確處理 Conflict(只在需要時重試)
當你寫入 Kubernetes 時,必須假設 Conflict 是正常情況。
穩健的策略:
- 嘗試 update,
- 如果收到
apierrors.IsConflict(err):- 對更新後的物件執行
Get(), - 僅重新套用你的修改(通常是 status),
- 再次嘗試 update。
- 對更新後的物件執行
重點:不要對任何錯誤都進行「盲目」重試。對永久性錯誤(例如驗證、權限、配額)進行無限重試只會讓情況更糟。
最佳實踐 3:避免無限 reconciliation 迴圈(status 引發事件)
每次更新物件都可能產生事件,進而重新觸發 reconciliation。
兩個常見陷阱:
- 每次 reconcile 都更新 status,即使沒有任何改變;
- 寫入「雜訊」欄位(時間戳、不必要的計數器),這些欄位總是在改變。
解決方法:
- 只有在 status 確實改變 時才更新(結構化比較或特定欄位);
- 避免將 status 當作「日誌」使用;請改用 event/metric。
這能降低 controller、apiserver 的負載,最重要的是避免你的邏輯變成一台持續 reconcile 的機器。
最佳實踐 4:多個 controller 可以,但要有明確的邊界(並控制並行性)
在同一個專案中使用多個 controller 通常是合理的(能提供並行性),但你必須釐清:
- 哪些欄位由誰擁有,
- controller 可以更新哪些資源,
- 如何處理「交接」(例如 autoscaler 更新 spec,nodepool controller 執行並更新 status)。
如果兩個 controller 經常更新同一個資源,副作用是不可避免的:更多 update → 更多 resourceVersion → 更多 conflict → 更多 reconcile。
在這種情況下,通常較好的做法是:
- 讓 autoscaler 寫入專用資源(例如
ScaleRequest),並讓 NodePool controller 成為唯一能修改NodePool.spec的角色;或者 - 引入獨立的「requested」欄位,並讓其中一個 controller 將它轉換為實際的「desired state」。
最佳實踐 5:使用 generation 來了解什麼改變了(而不是用來避免 conflict)
generation 非常適合用來區分:
- 因 spec 改變而觸發的 reconcile(新的期望),
- 以及因 metadata/status 改變而觸發的 reconcile。
常見模式:
- 在 status 中儲存
observedGeneration, - 當
metadata.generation>status.observedGeneration時,你就知道有新的期望需要處理。
這能幫助你避免在實際只改變其他內容(例如 label)時,重複執行昂貴的工作。
總結:可擴展的 operator = 有紀律的 update
當你開始讓多個 controller 協作時,operator 的品質主要取決於你如何管理更新:
-
resourceVersion總是會改變:conflict 是正常的,必須處理。 - 區分 spec 與 status,並以最小化的方式寫入 status。
- 避免觸發無意義 reconcile 的「雜訊」update。
- 在 controller 之間定義明確的邊界:誰寫什麼,以及為什麼。
真正的進步不是「寫一個能建立資源的 reconciler」,而是建立一個在負載與並行性下仍能保持穩定的系統。在 Kubernetes 中,這主要意味著:寫得更少、寫得更好,並只在有意義時才進行重試。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.