複数の controller が同じ Custom Resource に触れる場合、競合は現実的な問題になります:堅牢かつスケーラブルに扱う方法を紹介します。
Operator の本当の問題:更新時の競合
現実の世界では、1 つの controller が自分のリソースを孤立して扱うことはほとんどありません。次のような状況になると:
- 同じプロジェクト(または同じ manager)内に複数の controller を導入する
- 他のリソースに書き込む controller を作る
- 処理が重いアクション(VM の作成、ノードの登録、プロビジョニングなど)
避けられない問題にぶつかります:同じ Custom Resource (CR) に対する同時更新です。
何が起きているかを理解するには、Kubernetes の 2 つの重要な概念が必要です:
-
metadata.generation: spec が変更されたときのみ増加します。 -
metadata.resourceVersion:オブジェクトのあらゆる変更(spec、status、ラベル、アノテーションなど)で毎回変わります。
この違いは重要です。更新時に発生する競合は、ほぼ常に resourceVersion に起因し、generation ではありません。
実践での generation と resourceVersion
spec.targetNodesを変更すると、generation と resourceVersion の両方が変わります。- ラベルやアノテーションを追加すると、generation は変わりませんが resourceVersion は変わります。
つまり、オブジェクトへのあらゆる書き込みが新しい resourceVersion を生み出します。Kubernetes はこの値を使って「古いコピーの更新」を防いでいます。
典型的なシナリオ:NodePool + Autoscaler、2 つの controller と 1 つの共有 CR
NodePool CRD を扱う operator を考えてみましょう:
-
spec.targetNodes:必要なノード数。 -
status.scaleOperation:スケール中かどうかを示すinactive | active。
NodePoolReconciler は以下のように動作します:
spec.targetNodesを読み取る- クラウド上に VM を作成する
- クラスタにノードを登録する
- status を更新する(
active→inactive)
次に、別の CRD Autoscaler とその controller を追加します。この controller は定期的に以下を行います:
- いくつかの namespace をスキャン
- CPU/メモリ不足で Pending の Pod を探す
- NodePool の
spec.targetNodesを増やすことで新しい VM 作成をトリガーする
つまり:
- Controller A(NodePool):主に
status、時々 spec に書き込む - Controller B(Autoscaler):NodePool の
specに書き込む
ここから問題が発生します。
典型的な競合:古いコピー(resourceVersion 不一致)での更新
典型的な流れは次の通りです:
- NodePool controller が
resourceVersion = 1の NodePool を読み込んでメモリにキャッシュする - その間に autoscaler が
spec.targetNodesを更新(例: 1 → 2)し、etcd 内のオブジェクトがresourceVersion = 2になる - NodePool controller がプロビジョニングを終え、古いコピー(rv=1)で status を更新しようとする
- Kubernetes が 409 Conflict(“the object has been modified; please apply your changes to the latest version”)で書き込みを拒否する
これは稀な例外ではなく、重い処理+複数 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 を引き起こします。
よくある 2 つの落とし穴:
- 何も変わっていないのに毎回 status を更新する
- 常に変わる「ノイズの多い」フィールド(タイムスタンプ、不必要なカウンター)を書く
対策:
- status は本当に変更があったときだけ更新する(構造比較または特定フィールド)
- status を「ログ」として使わず、event/metric を利用する
これにより controller、apiserver への負荷を減らし、reconcile が無限に続く事態を防げます。
ベストプラクティス 4:複数 controller は可、ただし明確な境界と制御された並列処理を
同じプロジェクト内に複数 controller を持つことは正しい選択(並列処理が可能)ですが、以下を明確にする必要があります:
- どのフィールドのオーナーか
- どのリソースを更新できるか
- 「引き継ぎ」の方法(例: autoscaler が spec を更新し、nodepool controller が実行して status を更新)
2 つの controller が頻繁に同じリソースを更新すると、必然的に副作用が生じます:更新増加 → 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のとき、新しい希望を処理する必要があると判断する
これにより、ラベル変更など他の部分だけが変わった場合に高コストな処理を繰り返さずに済みます。
まとめ:スケーラブルな operator = 規律ある更新
複数 controller を連携させるとき、operator の品質は更新の扱い方で決まります:
resourceVersionは常に変わる:conflict は普通に発生し、扱う必要がある- spec と status を分離し、status への書き込みは最小限に
- 無意味な reconcile を引き起こす「ノイズの多い」更新を避ける
- controller 間の境界を明確に:誰が何を、なぜ書くのか
必要なのは「リソースを作成する reconciler を書く」ことではなく、負荷と競合下でも安定するシステムを構築することです。Kubernetes では、それは主により少なく、より良く書き、意味があるときだけリトライすることを意味します。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.