はじめに
前の記事で Kubebuilder を使って動く Operator を作りました。そこでの reconcile 関数は「望ましい状態を作り冪等に合わせる」というシンプルな骨格でした。しかし実際にプロダクションで Operator を運用していると、reconcile ループの微妙な動作が安定性と性能を左右することに気づきます。
本記事は reconcile ループの内部動作を深く掘り下げます。リクエストがどのように informer から workqueue を経て reconcile に届くのか、Result でどう再試行を制御するのか、冪等性をどう堅牢に実装するのか、status の衝突をどう避けるのか、そして並行性とキャッシュをどうチューニングするのかを扱います。controller-runtime v0.24.x が基準です。
reconcile リクエストの流れ: informer → workqueue → reconcile
reconcile 関数が呼ばれるまでの経路を理解することが、すべての出発点です。controller-runtime は次のパイプラインを構成します。
API Server
| watch (変更ストリーム)
v
+-----------+ +-------------+ +------------+
| Informer |----->| WorkQueue |----->| Reconciler |
| (キャッシュ| | (レート制御/| | (ユーザー |
| 同期) | | 重複排除) | | コード) |
+-----------+ +-------------+ +-----+------+
^ | 再試行時
| ローカルキャッシュ読み | requeue
+----------------------------------------+
各段階の役割はこうです。
- **Informer**: API Server を watch しながらオブジェクトをローカルキャッシュに同期します。変更が生じるとイベントを発生させます。このキャッシュのおかげで reconcile は毎回 API Server を叩かずにオブジェクトを読めます。
- **WorkQueue**: イベントを受け取り「調整すべきオブジェクトのキー(namespace/name)」をキューに入れます。このキューは重複を排除し(同じオブジェクトが複数回入っても 1 回だけ処理)、速度を制御します。
- **Reconciler**: キューからキューを取り出し reconcile 関数を呼びます。reconcile はそのキーに対応するオブジェクトの望ましい状態に合わせます。
核心的な洞察は、**reconcile に渡されるのはオブジェクト自体ではなくオブジェクトのキーだけ** という点です。reconcile はそのキーで最新のオブジェクトを読み直さなければなりません。これは冪等性と深く結びつきます。イベントが何だったか(作成/修正/削除)は重要でなく、ただ「今の望ましい状態は何で、実際は何か」だけを見ます。
Result と requeue で再試行を制御する
reconcile 関数は `(Result, error)` を返します。この 2 つの値が次に何が起こるかを決めます。
return ctrl.Result{}, nil
-> 成功。再キューしない(次の watch イベントまで待機)
return ctrl.Result{}, err
-> エラー。workqueue が指数バックオフで自動再試行
return ctrl.Result{Requeue: true}, nil
-> エラーではないが再処理を要求(即時再キュー)
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
-> 30 秒後に再び reconcile (定期確認に有用)
この区別が重要なのは、あらゆる「まだできていない」状況をエラーで処理するとログがエラーで埋め尽くされるからです。たとえば外部依存がまだ準備できていないなら、それはエラーではなく「少しあとにまた見よう」という状況です。こういうときはエラーの代わりに `RequeueAfter` を使います。
誤ったパターン:
if 依存_未準備:
return Result{}, fmt.Errorf("まだ準備できていない") # エラーログ爆発
正しいパターン:
if 依存_未準備:
return Result{RequeueAfter: 10秒}, nil # 静かに再確認
`RequeueAfter` はまた「watch だけでは捉えられない変化」を定期的に点検するのにも有用です。たとえば証明書の有効期限のように時間によって変わる条件は、RequeueAfter で一定周期ごとに再確認します。
冪等実装パターン
冪等性は reconcile の絶対原則です。同じオブジェクトに対して reconcile が数十回呼ばれても結果が同じでなければなりません。これを堅牢に実装する核心パターンを 2 つ見ます。
1. サーバーサイド apply
従来の「get 後なければ create、あれば update」パターンは動作しますが、複数のコントローラーが同じオブジェクトを触るとき衝突が起きやすいです。**サーバーサイド apply(SSA)** は「これらのフィールドについて私が望む値はこれだ」と宣言すると、API Server がフィールド単位でマージしてくれる方式です。
サーバーサイド apply の利点:
- フィールド所有権(field ownership)を API Server が追跡
- 自分が管理するフィールドだけ更新し、他人が管理するフィールドは保存
- 「現在の状態をまず読んで比較」するボイラープレートが減る
- 冪等性が自然に保証される
SSA を使うと reconcile は「望ましいオブジェクト全体を宣言的に apply」する形になり、コードが単純になり、同時修正に強くなります。
2. owner reference で所有権を明確化
前の記事でも強調したように、コントローラーが作った下位リソースには owner reference を付けます。こうすると 2 つの利点があります。第一に、親削除時に子が自動で片付けられます(ガベージコレクション)。第二に、コントローラーは「自分が所有するもの」だけを管理するので reconcile の範囲が明確になります。owner reference がないとコントローラーは何を片付けるべきかを自分で追跡する必要があり、finalizer ロジックが複雑になります。
status 更新と conditions
spec と status は分けて扱う
Kubernetes API 規約では **spec はユーザー/コントローラーが望むもの**、**status はコントローラーが観測したもの** です。この 2 つは異なる更新経路を使います。status サブリソースが有効なら、status は `Status().Update()` でのみ更新すべきです。spec と status を 1 回の Update で一緒に書こうとすると衝突や無視が発生します。
誤り:
obj.Spec.X = ...
obj.Status.Y = ...
Update(obj) # status サブリソースが有効なら status は無視される
正しい:
obj.Status.Y = ...
Status().Update(obj) # status だけを別経路で更新
conditions で標準化された状態表現
status には単純な phase 文字列より **conditions** 配列を使うことが推奨されます。condition は `type`、`status`(True/False/Unknown)、`reason`、`message`、`lastTransitionTime` を持つ標準構造です。たとえば `Ready`、`Progressing`、`Degraded` のような type を置けば、観測ツールとユーザーが一貫した方法で状態を解釈できます。
conditions の例:
- type: Ready, status: "True", reason: AllReplicasReady
- type: Progressing, status: "False", reason: Stable
conditions は蓄積・更新され、同じ type の condition は status が変わるときだけ lastTransitionTime を更新します。この慣例を守れば Capability Level 4(Deep Insights)の土台になります。
イベントフィルタ: predicate
デフォルトではコントローラーは watch 対象のすべての変更に対して reconcile します。しかしすべての変更が意味あるわけではありません。たとえば status だけ変わったイベントや、関心のないフィールドだけ変わったイベントで reconcile を回すのは無駄です。**predicate** はどのイベントが reconcile をトリガーするかをフィルタリングします。
よく使う predicate:
- GenerationChangedPredicate
: metadata.generation が変わった場合だけ(= spec 変更だけ)処理。
status 変更による reconcile の自己トリガーを防ぐのに有用。
- LabelChangedPredicate / AnnotationChangedPredicate
: 特定のメタデータ変更だけに関心。
- ユーザー定義 predicate
: 任意の条件でフィルタ。
特に `GenerationChangedPredicate` は重要です。reconcile が status を更新すると、それ自体が新しい watch イベントを作り再び reconcile を呼ぶことがあります。generation は spec が変わるときだけ増えるので、この predicate を使えば status 更新による不要な reconcile の暴走を防げます。(ただし RequeueAfter で定期点検が必要なコントローラーなら、この predicate がその点検まで妨げないよう注意が必要です。)
レート制限と並行性
MaxConcurrentReconciles
デフォルトではコントローラーは一度に 1 つの reconcile だけ回します。処理量が足りなければ並行性を上げられます。
コントローラーオプション:
MaxConcurrentReconciles: 5
-> reconcile を最大 5 ワーカーで並列実行
注意すべきは、workqueue が **同じオブジェクトキーを同時に 2 つのワーカーに与えない** ことです。つまり同じオブジェクトに対する reconcile は直列化されるので、並行性を上げても同じオブジェクトに対する競合状態は起きません。ただし異なるオブジェクトは並列処理されるので処理量が上がります。
レートリミッター
workqueue はレートリミッターを通じて再試行速度を制御します。デフォルトは指数バックオフと全体処理率制限を組み合わせたものです。エラーが繰り返されるオブジェクトはだんだん長い間隔で再試行され、1 つのオブジェクトの失敗が全体のキューを飢えさせないようにします。暴走しうる外部 API を呼ぶコントローラーなら、レートリミッターを調整してダウンストリームを保護できます。
キャッシュとクライアント: get from cache 対 API
controller-runtime のデフォルトクライアントは **読みはキャッシュから、書きは API Server へ** 送ります。この区別を理解しないと微妙なバグに陥ります。
| 操作 | デフォルト動作 | 注意点 |
| --- | --- | --- |
| Get/List | informer キャッシュから読む | キャッシュは少し遅れることがある(結果整合) |
| Create/Update/Delete | API Server へ直接 | 即時反映だがキャッシュは少しあとに更新 |
ここでよくある落とし穴があります。今 Create したオブジェクトをすぐ Get すると、キャッシュがまだ更新されておらず「なし」が出ることがあります。したがって reconcile は「今作ったものがキャッシュに見えるまで待つ」ではなく、「次の reconcile で自然に見るようになる」という前提で冪等に書くべきです。本当に最新の値が必要な稀なケースでは、キャッシュを迂回する直接読みクライアントを使えますが、性能上の既定はキャッシュ読みです。
観測: metrics とロギング
プロダクションの Operator は自分の状態を公開しなければなりません。
- **メトリクス**: controller-runtime はデフォルトで reconcile 回数、処理時間、キュー深さ、エラー数のような Prometheus メトリクスを公開します。キュー深さがたまり続けると処理量不足のシグナルであり、reconcile 時間が長いと外部呼び出しがボトルネックというシグナルです。
- **構造化ロギング**: `log.FromContext(ctx)` で得たロガーはオブジェクトキーのようなコンテキストを自動で含みます。キー・バリュー形式の構造化ログを残せば、特定オブジェクトの reconcile の流れを追いやすいです。
- **イベント**: 重要な調整結果は Kubernetes Event として発行し、`kubectl describe` で見えるようにします。
2026 年基準でメトリクスエンドポイントは別途サイドカーなしに controller-runtime の認証・認可ミドルウェア(WithAuthenticationAndAuthorization)で保護するのが標準です。
よくあるバグ
reconcile ループで繰り返し現れるバグをまとめます。
| バグ | 原因 | 解決 |
| --- | --- | --- |
| 無限 reconcile | status 更新が新イベントを作り自己トリガー | GenerationChangedPredicate 適用、不要な status 書き込み除去 |
| status 衝突(conflict) | キャッシュの古いオブジェクトで Status().Update | 最新オブジェクトを Get し直して更新、または SSA |
| 「すでに存在」エラー | 非冪等な create | get 後に分岐、またはサーバーサイド apply |
| 今作ったオブジェクトが見えない | キャッシュ遅延 | 冪等設計で次の reconcile に任せる |
| 1 オブジェクトの失敗が全体を遅延 | レートリミッター/並行性不足 | MaxConcurrentReconciles 調整 |
| 外部 API 暴走 | requeue 過多 | RequeueAfter で間隔調整 |
特に **無限 reconcile** と **status 衝突** は、ほぼすべての Operator 開発者が一度は通る通過儀礼です。どちらも「status をどう扱うか」に由来するので、status 経路を慎重に設計するのが核心です。
性能チューニングチェックリスト
性能問題が生じたら次の順で点検します。
1. **まずメトリクスを見る**: キュー深さ、reconcile 時間、エラー率を確認しボトルネックがどこかを特定します。
2. **不要な reconcile を減らす**: predicate で意味のないイベントを濾します。特に status 自己トリガーを遮断します。
3. **並行性を上げる**: 処理量が足りなければ MaxConcurrentReconciles を上げます。同じオブジェクトは直列化されるので安全です。
4. **外部呼び出しを最小化**: reconcile ごとに遅い外部 API を呼ぶとそれがボトルネックです。キャッシュするか RequeueAfter で頻度を下げます。
5. **キャッシュを活用**: 読みはキャッシュから行い、必要なときだけ直接読みを使います。
6. **status 書き込みを最小化**: 実際に変わった場合だけ status を更新し、不要なイベントと衝突を減らします。
watch 対象の拡張: 何が reconcile をトリガーするのか
reconcile がいつ呼ばれるかは、コントローラーが何を watch するかで決まります。controller-runtime は watch 対象を宣言するいくつかの方法を提供します。
For(&MyKind{})
: 主リソース。この種類の変更が直接 reconcile をトリガー。
Owns(&appsv1.Deployment{})
: 自分が所有する(owner reference)下位リソース。
このリソースが変わるとその所有者(主リソース)の reconcile をトリガー。
Watches(&otherKind{}, handler)
: 所有関係ではない任意のリソースを watch。
ハンドラーで「この変更がどの主リソースの reconcile を呼ぶか」をマッピング。
`For` と `Owns` だけでほとんどのコントローラーがカバーされます。しかし所有関係ではないリソースの変更に反応すべきときがあります。たとえばある ConfigMap が変わったらそれを参照するすべての CR を reconcile すべきなら、`Watches` とマッピング関数を使います。
マッピング関数の動作
マッピング関数(EnqueueRequestsFromMapFunc)は「変更されたオブジェクト」を入力に受け取り「reconcile すべき主リソースのキーのリスト」を返します。つまり 1 つの外部変更が複数の主リソースの reconcile をキューに入れられます。
ConfigMap "shared-config" 変更
-> マッピング関数呼び出し
-> この ConfigMap を参照する CR のリストを返す: [cr-a, cr-b, cr-c]
-> 3 つの reconcile 要求がキューに入る
このパターンは強力ですが注意が必要です。マッピング関数が多すぎる主リソースを返すと、1 つの変更が reconcile の嵐を引き起こすことがあります。マッピング関数は軽く正確に保つべきです。
テストで reconcile を検証する
reconcile の微妙な動作はテストで捉えるのが最も確実です。envtest ベースのテストの典型的な流れを見ましょう。
テストシナリオ:
1. CR を作成する
2. しばらくポーリングし、期待する下位リソースができたか確認
3. CR の spec を変える
4. 下位リソースが新しい値に更新されたか確認
5. 下位リソースを手で変更する
6. reconcile が再び望ましい値に戻すか確認(自己修復検証)
7. CR を削除する
8. finalizer/owner reference の清掃が動作するか確認
核心は **ポーリング(eventually パターン)** です。reconcile は非同期で回るので、テストは「今すぐ」ではなく「しばらくのうちに」期待状態になるかを確認すべきです。キャッシュ遅延と非同期処理を考慮せず即座に断言すると、テストが不安定(flaky)になります。
単体テスト対統合テスト
| 種類 | ツール | 検証対象 |
| --- | --- | --- |
| 単体 | fake client | 純粋なロジック(ビルダー関数など) |
| 統合 | envtest | API Server レベルの動作(検証、status、GC) |
純粋関数(例: desired Deployment を作るビルダー)は fake client や通常の単体テストで素早く検証します。一方 status サブリソース、CRD 検証、owner reference のガベージコレクションのように API Server が介入する動作は envtest で検証してこそ現実的です。2 種類を適切に混ぜるのが良いテスト戦略です。
ワーカーキュー内部とバックオフ動作の詳細
先に workqueue を簡単に紹介しましたが、バックオフが実際にどう動くかを知っておくとデバッグに大いに役立ちます。controller-runtime のデフォルトのレートリミッターは 2 つのメカニズムを組み合わせます。
デフォルトレートリミッター = 項目別指数バックオフ + 全体バケット制限
項目別指数バックオフ:
同じキーが失敗するたびに待機時間が 2 倍ずつ増加
例: 5ms -> 10ms -> 20ms -> 40ms -> ... -> 最大上限
全体バケット制限(token bucket):
秒あたり処理可能な項目数にグローバル上限
暴走するキューがシステム全体を麻痺させないようにする
この 2 つのメカニズムが合わさって、特定のオブジェクトが失敗し続けてもそのオブジェクトだけがだんだんまばらに再試行され、全体の処理率は安定して保たれます。重要なのは、reconcile が成功(エラーなしで返却)するとそのキーのバックオフカウンターが **リセット** されることです。したがって一時的な失敗のあと成功すれば、次の失敗は再び短い待機から始まります。
バックオフと RequeueAfter の違い
ここで混同しやすい部分があります。エラー返却によるバックオフと RequeueAfter は異なるメカニズムです。
| 区分 | トリガー | 待機時間 | 用途 |
| --- | --- | --- | --- |
| エラーバックオフ | error 返却 | 指数増加(自動) | 本物の失敗の再試行 |
| RequeueAfter | Result に明示 | 自分が指定した固定値 | 意図的な周期点検 |
エラーバックオフは「何かが間違っているのでだんだんゆっくり再試行」、RequeueAfter は「正常だが一定時間後に再確認」です。この 2 つを混同して正常な状況をエラーとして処理すると、ログが汚染されバックオフがたまって応答性が落ちます。
実戦シナリオ: 段階的 reconcile の設計
複雑な Operator の reconcile は通常いくつかの段階に分かれます。一度にすべてを終わらせようとせず、各段階を冪等に分けるのが良い設計です。データベースクラスター Operator を例にしてみましょう。
reconcile(cluster):
1. finalizer を保証(なければ追加)
2. deletionTimestamp があれば -> 清掃分岐へ
3. Secret(パスワード)を保証
4. ConfigMap(設定)を保証
5. StatefulSet を保証
6. Service を保証
7. ブートストラップ完了の有無を確認
まだなら return RequeueAfter(10s)
8. プライマリ選出/確認
9. status.conditions を更新
return Result{}
各「保証(ensure)」段階は冪等です。すでにあれば比較後に必要なら更新、なければ作成します。核心は **段階が順序を持つ** ことです。Secret なしに StatefulSet を作れないので、前の段階が終わってから次へ進みます。まだ準備できていない段階に出会ったら、エラーではなく RequeueAfter で「少しあとに続けて」を表現します。
段階別の分岐と早期リターン
このパターンの利点は、reconcile が常に同じエントリーポイントから始まり「今どこまで進んだか」を毎回判断し直すことです。コントローラーが途中で死んで復活しても、次の reconcile が最初から回りながらすでに終わった段階を飛ばし、終わっていない段階から続けます。これがまさに reconcile が「どこまでやったか」の状態をメモリに持つ必要がない理由です。本当の状態は常にクラスター(observed state)にあります。
早期リターンの例:
if ブートストラップ_未完了:
return Result{RequeueAfter: 10s}, nil # ここで終わり、次に続ける
ここから下はブートストラップが終わったときだけ実行
このように設計すれば、reconcile 関数が長く見えても各部分が独立して冪等なので、推論とテストが容易になります。
reconcile メンタルモデルの整理
これまで扱った内容を一つのメンタルモデルに圧縮してみましょう。reconcile を書いたりデバッグしたりするとき、頭の中に常に浮かべるべき質問です。
reconcile を呼ばれたとき自問する:
1. 「今この オブジェクトの望ましい状態は何か?」 (desired)
2. 「実際のクラスターの状態は何か?」 (observed)
3. 「両者の差は何か?」 (diff)
4. 「その差を冪等に埋めるには?」 (action)
5. 「まだ埋められていない部分はあるか?」 (requeue 判断)
6. 「観測した結果を status にどう反映するか?」 (status)
この 6 つの質問が reconcile の骨格です。イベントが何だったか(作成/修正/削除)は意図的に問いません。reconcile は常に「今この瞬間の真実」から出発するからです。このメンタルモデルを体得すれば、新しい Operator コードを読んだりバグを追ったりするとき、どこを見るべきか自然と分かるようになります。
デバッグするときの思考の流れ
reconcile が変な動作をするときは、たいてい上の 6 段階のうち 1 つが壊れています。
| 症状 | 疑う段階 |
| --- | --- |
| 何も起きない | desired の計算または watch 設定 |
| 同じことを繰り返す | 冪等性(action)または status 自己トリガー |
| 差が埋まらない | diff 比較ロジックまたは RBAC 権限 |
| status が合わない | status 更新経路またはキャッシュ遅延 |
症状から疑う段階へまっすぐ絞り込むこの習慣が、漠然としたデバッグを体系的な追跡に変えてくれます。
リーダー選出と高可用性
プロダクションではコントローラーマネージャーを複数の複製で立てて可用性を確保します。しかし同じオブジェクトを 2 つの複製が同時に reconcile すると衝突します。これを防ぐのが **リーダー選出(leader election)** です。
leader election:
複数のマネージャー複製のうちただ 1 つだけが「リーダー」になり reconcile を実行
リーダーは Lease オブジェクトでリーダーシップを周期的に更新
リーダーが死ぬと Lease が期限切れになり別の複製がリーダーになる
controller-runtime はリーダー選出をオプション 1 つで有効化できます。有効にすると普段は 1 つの複製だけが実際に働き、残りは待機(standby)します。リーダーが障害で消えると、素早く別の複製が引き継いで reconcile を続けます。これにより単一障害点なしにコントローラーを運用できます。
注意すべきは、リーダー選出がオンでも reconcile 自体の冪等性は依然として重要だということです。リーダー切り替えの瞬間に同じオブジェクトが 2 回処理されうる境界ケースがあるからです。冪等性はすべての安全装置の土台です。
プロダクション運用チェックリスト
reconcile ループをプロダクションに載せる前に次を点検すると、よくある事故を予防できます。
[ ] reconcile は冪等か?(同じ入力の反復実行が安全か)
[ ] すべての下位リソースに owner reference が設定されているか?
[ ] 外部リソースを扱うなら finalizer があるか?
[ ] status は Status().Update でのみ更新するか?
[ ] status 自己トリガーを防ぐ predicate があるか?
[ ] エラーと「まだできていない」を区別して処理するか?(RequeueAfter 活用)
[ ] メトリクスとロギングで動作を観測できるか?
[ ] リーダー選出で多重複製の安全性を確保したか?
[ ] 外部 API 呼び出しにタイムアウトと再試行の上限があるか?
[ ] RBAC 権限が最小権限の原則に従っているか?
このチェックリストは、これまで扱ったすべての概念の要約でもあります。冪等性、owner reference、finalizer、status 経路、predicate、エラー処理、観測、高可用性、セキュリティ — 堅牢な Operator は、これらすべてを漏れなく押さえた成果物です。
おわりに
reconcile ループは一見シンプルな関数 1 つですが、その背後には informer、workqueue、レートリミッター、キャッシュが精緻に噛み合って回っています。堅牢な Operator を作るには、このパイプラインを理解し、reconcile を冪等に書き、status 経路を慎重に扱い、predicate で不要な仕事を減らし、メトリクスで動作を観測する必要があります。
核心原則を一行に要約するとこうです。**「reconcile はイベントが何だったかを問わない。ただ今の望ましい状態と実際の状態を比較して冪等に縮めるだけだ。」** この心構えを守れば、無限ループも status 衝突も性能の落とし穴も、ほとんど自然に避けられます。
参考資料
- [controller-runtime (pkg.go.dev)](https://pkg.go.dev/sigs.k8s.io/controller-runtime)
- [controller-runtime predicate パッケージ](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/predicate)
- [Kubebuilder Book — Reconcile の動作原理](https://book.kubebuilder.io/cronjob-tutorial/controller-implementation.html)
- [Kubernetes API 規約 (spec/status)](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md)
- [サーバーサイド apply(Kubernetes 公式ドキュメント)](https://kubernetes.io/docs/reference/using-api/server-side-apply/)
- [Operator パターン(Kubernetes 公式ドキュメント)](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
- [Operator SDK ドキュメント](https://sdk.operatorframework.io/)
- [kubernetes-sigs/controller-runtime (GitHub)](https://github.com/kubernetes-sigs/controller-runtime)
- [Operator Capability Levels](https://sdk.operatorframework.io/docs/overview/operator-capabilities/)
현재 단락 (1/215)
前の記事で Kubebuilder を使って動く Operator を作りました。そこでの reconcile 関数は「望ましい状態を作り冪等に合わせる」というシンプルな骨格でした。しかし実際にプロダ...