frontendfacile.it

當多個 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,則 generationresourceVersion 都會改變。
  • 如果你新增 label 或 annotation,generation 不會改變,但 resourceVersion 會改變。

換句話說:任何 write 都會產生新的 resourceVersion。Kubernetes 正是使用這個值來保護你,避免更新「舊副本」。

典型情境:NodePool + Autoscaler,兩個 controller 共用一個 CR

想像一個管理 NodePool CRD 的 operator:

  • spec.targetNodes:你想要多少個節點。
  • status.scaleOperation:使用 inactive | active 表示是否正在進行擴展。

NodePoolReconciler controller:

  1. 讀取 spec.targetNodes
  2. 在雲端建立 VM,
  3. 在叢集中註冊節點,
  4. 更新 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 不符)

典型流程如下:

  1. NodePool controller 開始 reconciliation,讀取 resourceVersion = 1 的 NodePool 並快取在記憶體中。
  2. 同時 autoscaler 更新 spec.targetNodes(例如從 1 改為 2):etcd 中的物件變成 resourceVersion = 2。
  3. NodePool controller 完成佈建後,嘗試使用舊副本(rv=1)更新 status。
  4. 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 是正常情況

穩健的策略:

  1. 嘗試 update,
  2. 如果收到 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 中,這主要意味著:寫得更少、寫得更好,並只在有意義時才進行重試


原文:https://frontendfacile.it/blog/kubernetes-operator-con-kubebuilder-best-practice-per-evitare-conflitti-retry-in