調整 — 観測・比較・行動・報告の四拍子
一言でいうと
reconcileは「何が起きたのか」を問いません。ただ「今ほしい状態は何で、実際は何か」だけを問い、その差を冪等に縮めます。
なぜ必要なのか
命令型の自動化スクリプトは、「これを作って、あれを設定して、そのあとこれを実行する」の羅列です。途中で失敗するとシステムが中途半端な状態で残り、再実行すると「すでに存在します」というエラーが出たり、重複が生じたりします。そのため、人々は再実行を怖がるようになり、怖い自動化は結局使われなくなります。
reconcileはこの問題を逆転させます。どの時点で失敗しても、次の呼び出しが最初からやり直して、終わっていない部分だけを仕上げます。コントローラーが途中で落ちて復活してもかまいません。「どこまでやったか」をメモリに持っておく必要がないからです。本当の状態は、常にクラスターの中にあります。
どう動くのか
実際のコントローラーでリクエストが届く経路は、3つの段階です。
API Server --watch--> Informer --> WorkQueue --> Reconciler
(로컬 캐시) (중복 제거, (사용자 코드)
속도 제어)
このコードブロックの韓国語の3つの注記は、順に、ローカルキャッシュ、重複の除去と速度制御、ユーザーコード、という意味です。
ここで最も重要な洞察は、reconcileに渡されるのはオブジェクトではなく、オブジェクトのキー(ネームスペース/名前)だけだという点です。何が変わったのか、作成だったのか変更だったのかは渡されません。reconcileは、そのキーで最新の状態を読み直します。この設計が冪等性を強制します。
reconcile 1回は、6つの質問に要約されます。
- 今このオブジェクトの望ましい状態は何か(desired)
- 実際のクラスターの状態は何か(observed)
- 両者の差は何か(diff)
- その差を冪等に埋めるには(action)
- まだ埋められていない部分はあるか(requeueの判断)
- 観察した結果をstatusにどう反映するか(status)
4つ目は冪等性です。「作成する」ではなく、「望ましい姿で存在するか確認し、そうでなければ合わせる」と書く必要があります。すでに合っているなら何もしないのが正解で、そのとき下位オブジェクトのresourceVersionはそのままでなければなりません。内容が同じなのにもう一度書くと、新しいwatchイベントが生まれ、そのイベントがまたreconcileを呼びます。
5つ目は再キューです。「まだ準備ができていない」をすべてエラーとして処理してはいけません。エラーとして返すと、ワークキューが指数バックオフでリトライし、エラーログが溜まり、そのオブジェクトのバックオフが長くなって応答性が落ちます。依存先がまだないのは失敗ではなく、「少しあとでもう一度見よう」ということです。
| 区分 | トリガー | 待ち時間 | 用途 |
|---|---|---|---|
| エラーバックオフ | エラーを返す | 指数的に増加(自動) | 本当の失敗のリトライ |
| 遅延再キュー | 結果に明示 | 自分で指定した値 | 意図的な定期チェック |
バックオフが指数的なのは、1つのオブジェクトの繰り返しの失敗が、キュー全体を飢えさせないようにするためです。そして、成功すると、そのキーのバックオフカウンターはリセットされます。
6つ目はstatusと無限ループです。statusを書くと、それ自体が新しいwatchイベントとなって、reconcileをまた呼ぶことがあります。実際のコントローラーは、GenerationChangedPredicateでこれを防ぎます。generationはspecが変更されたときにしか上がらないため、このフィルターをかけると、自分のstatus書き込みによる再呼び出しが取り除かれます。ただし、定期チェックが必要なコントローラーなら、このフィルターがその定期チェックまで止めてしまわないよう注意が必要です。
削除の経路とファイナライザーについてです。所有者参照でつながったクラスター内の子は、ガベージコレクターが片付けますが、クラスターの外のリソース(クラウドのロードバランサー、外部DBのアカウント)は、Kubernetesが知りません。ファイナライザーは、その後片付けを保証する仕組みです。
삭제 요청 -> deletionTimestamp 설정 (오브젝트는 아직 존재)
-> 리컨사일 재호출: 정리 수행
-> 파이널라이저 제거
-> 그제서야 실제 삭제
このコードブロックの韓国語の4行は、順に、削除リクエストでdeletionTimestampが設定される(オブジェクトはまだ存在する)、reconcileが再び呼ばれてクリーンアップを行う、ファイナライザーを削除する、そこではじめて実際に削除される、という意味です。
核心は、「削除=すぐに消える」ではなく、「削除=削除時刻が刻まれて、reconcileがもう一度呼ばれる」ということです。その1回が、後片付けをする最後のチャンスです。そして、クリーンアップの関数も冪等でなければなりません。すでにないものを消そうとする試みがエラーを投げると、ファイナライザーが永遠に外れず、削除のデッドロックが生じます。
このラボ環境についての正直な説明
このラボには、コンパイル済みのGoコントローラーを実行する手段がありません。そのため、reconcileのループをシェルスクリプトのReconcilerとして作ります。informerキャッシュとワークキューはシミュレーションせず、その代わりに、reconcile関数の本質である、観測 → 比較 → 行動 → statusの報告と再キューの判断、ファイナライザーの流れを、手で実装します。採点されるのはコントローラーのバイナリではなく、判断の正確さと冪等性です。実際のコントローラーをGoに移しても、この4拍子はそのままです。
現場での姿
1つ目は、無限reconcileです。ほとんどすべてのOperator開発者が一度は経験する通過儀礼です。reconcileがstatusを書くと、その書き込みが新しいwatchイベントを生み、そのイベントがまたreconcileを呼びます。CPUが1コアを占有し、ログが毎秒数百行ずつ溜まります。解決策は2つです。specが変更されたときにだけ増加するmetadata.generationを見るpredicateをかけて、status変更による自己トリガーを断ち切るか、そもそも値が変わったときにだけstatusを書くことです。
2つ目は、statusの衝突です。キャッシュから読んだ古いオブジェクトでstatusを更新しようとして、conflictに当たります。コントローラーがリソースを同時に複数処理すると、確率が上がります。最新のオブジェクトを読み直して更新するか、サーバーサイドapplyでフィールド単位のマージに任せます。
3つ目は、作ったばかりのオブジェクトが見えないことです。読み取りはinformerキャッシュから、書き込みはAPIサーバーに対して行われます。そのため、Createの直後にGetすると、まだ「なし」が返ることがあります。ここでsleepを入れるのが、最もよくある誤答です。正しい答えは待たないことです。冪等に組んでおいて、次のreconcileで自然に見えるのに任せれば済みます。reconcileのループの設計思想が、そのまま表れる箇所です。
4つ目は、ファイナライザーに引っかかったオブジェクトです。コントローラーが落ちたままCRを削除すると、deletionTimestampだけが刻まれた状態で、永遠にTerminatingに残ります。ファイナライザーを外してくれる主体がいないからです。そのため、Operatorを削除するときは、コントローラーよりCRを先に片付ける順序を守る必要があり、急ぐ場合に備えて、ファイナライザーを手で外すエスケープハッチをランブックに書いておきます。
次のラボですること
/root/op/reconcile/reconcile.shを作成して、CRのspecをdesiredとして、下位のConfigMapをactualとして読んで比較し、なければ作り、すでに合っていれば何もしないことを、resourceVersionが変わらないことを根拠に証明します。statusをサブリソースのパスで書き戻し、依存先がないときの再キューの判断をJSONとして残し、ファイナライザーで後片付けをしてから削除される流れを作ったうえで、最後に3つのCRを1周させた調整レポートを作ります。