宣言的調整ループとコントローラパターン
宣言的システムが「望ましい状態」へ自律収束する仕組みが原理からわかる。なぜ命令型でなく差分を埋め続ける設計が壊れにくいのか、informerとwork queueの効率化まで解説。
- 1.コントローラは望ましい状態(desired)と現在状態(observed)の差分を毎周回で計算し、APIを叩いて差を縮め続ける。1回の成功ではなく継続的な収束が本質。
- 2.level-triggered(状態を毎回観測し直す)はイベント取りこぼしに強く冪等。edge-triggered(変化イベントだけに反応)は速いが1つの欠落で状態が永久にずれる。
- 3.毎回の全件APIポーリングは重いので、informerがwatchでローカルキャッシュを同期し、変化をwork queueへ投入。重複排除・レート制限・再試行で効率と堅牢性を両立する。
命令型と宣言的の違いはどこにあるか
インフラ操作には2つの流儀があります。**命令型(imperative)**は「コンテナを3つ起動せよ」のように手順を直接実行します。**宣言的(declarative)は「コンテナは3つ存在すべき」という望ましい状態(desired state)**だけを宣言し、そこへ至る手順はシステムに委ねます。
命令型の弱点は、一度実行した後のずれ(ドリフト)に無力なことです。3つ起動した後で1つが落ちても、命令はもう過去のものなので誰も直しません。宣言的システムはこれを根本的に変えます。望ましい状態を事実の源(source of truth)として保持し続け、現在状態がそこからずれたら自動的に埋め戻す主体を常駐させる。この主体が**コントローラ(controller)であり、その心臓部が調整ループ(reconciliation loop / control loop)**です。
調整ループはサーモスタットと同型です。目標温度(desired)と室温(observed)の差を測り、差があればヒーターを動かし、また測る——この閉ループを止めずに回し続ける。コントローラがやるのは「設定温度に一度合わせる」ことではなく「合い続けるよう監視し補正する」ことです。
調整ループの不変の骨格
どんなコントローラも、本質的に次の3ステップを無限に繰り返します。
loop forever:
observe = read_current_state() # 現在の実際の状態を観測
desired = read_desired_state() # 望ましい状態を読む(宣言)
diff = compute_diff(desired, observe)
if diff is not empty:
act(diff) # APIを叩いて差を縮める
# そして必ず最初へ戻る
ここで決定的に重要な性質が冪等性(idempotency)です。act は「あるべき状態に近づける」操作であって「アクションを1回実行する」操作ではありません。同じ望ましい状態に対して何度ループを回しても、すでに収束していれば diff は空になり、何もしない。だからループを余分に回しても害がない。これが宣言的設計が壊れにくい根拠です。命令型なら「3つ起動」を2回実行すると6つになりますが、宣言的なら「3つ存在すべき」を何度評価しても3つに収束します。
コントローラはいつ再起動・クラッシュするか分かりません。だから1周回(reconcile)は、途中で中断されても次回の周回が正しく続きを埋められるよう書きます。「差分の一部だけ適用して落ちた」状態から再観測すれば、残りの差分が次に計算されるだけ——途中状態を覚えておく必要がない設計が要点です。状態は外部(API サーバー)にあり、コントローラ自身はほぼステートレスに保ちます。
level-triggered か edge-triggered か
ループを駆動する方式には2つの哲学があり、堅牢性の差はここで決まります。
- edge-triggered(エッジ駆動):状態が変化したイベントにだけ反応する。「Podが削除された」という通知を受けて補正を起動する。
- level-triggered(レベル駆動):今この瞬間の状態そのものを毎回観測し、あるべき姿との差を埋める。イベントは「見に行くきっかけ」にすぎず、判断は常に最新の観測に基づく。
電子回路の比喩で言えば、edge は信号の立ち上がり/立ち下がりの瞬間に反応し、level は信号が高いか低いかという現在の水準に反応します。
決定的な違いはイベントを取りこぼしたときの挙動です。edge-triggered は「削除された」という通知を1つ落とすと、それを永久に取り返せません。コントローラはずれたまま気づかない。対して level-triggered は、たとえ途中のイベントを全部落としても、次に状態を観測した瞬間に差分を再計算し、自力で正しい姿へ復帰します。
| 観点 | level-triggered | edge-triggered |
|---|---|---|
| 反応の根拠 | 現在の状態そのもの | 状態が変化したイベント |
| イベント欠落 | 次の観測で自動回復 | 差が永久に残りうる |
| 冪等性 | 本質的に冪等 | 重複/欠落で壊れやすい |
| 再起動後 | 観測し直すだけで整合 | 見逃した変化を取り返せない |
| コスト | 毎回観測する分やや重い | 変化時だけで軽い |
そのため堅牢なコントローラはlevel-triggered を基本に据えます。イベント(エッジ)は「いま観測し直せ」という効率化のトリガーとして使い、最終判断は必ず現在状態の全観測に基づく。これは「edge をトリガーに level で動く」ハイブリッドであり、Kubernetes のコントローラ設計の中核思想です。望ましい状態を Git で宣言する GitOps や、IaC のドリフト検出も、この「宣言を基準に現在を観測し直して収束させる」発想を共有しています。
なぜ素朴なポーリングでは破綻するのか
level-triggered の素直な実装は「毎秒、全リソースを API サーバーから一覧取得して差分を取る」ことです。しかしこれは規模が大きくなると破綻します。リソースが数万あれば、毎回の全件 LIST が API サーバーとネットワークを圧迫し、コントローラ自身も差分計算で飽和します。観測の正しさ(毎回見直す)と効率(毎回は重い)が衝突するのです。
この衝突を解くのが informer と work queue という2つの仕組みです。
informer:watch でキャッシュを同期し続ける
informer は、API サーバーへの負荷を抑えつつ level-triggered の正しさを保つ部品です。動作はこうです。
- 起動時に一度だけ全件 LIST し、結果をローカルキャッシュに載せる。
- 以降は watch ストリームを張り、変更(追加・更新・削除)の差分イベントだけを受け取ってキャッシュを更新し続ける。
- コントローラは API サーバーではなく、このローカルキャッシュを読む。
つまり「現在状態の全観測」は毎回ネットワークを叩くのではなく、watch で同期済みのメモリ上キャッシュを参照することで実現します。観測は安く、しかし最新です。
watch ストリームはネットワーク断や API サーバー再起動で切れ、その間のイベントを取りこぼしえます。これを放置すると edge-triggered と同じ欠陥に陥ります。だから informer は2つの安全網を持ちます。第一に、watch が切れたら最後に見たバージョン(resourceVersion)から張り直し、抜けを埋める。第二に、定期的な resync でキャッシュ全件を強制的に再 reconcile キューへ流し、「イベントを全部落としていても定期的に状態を見直す」level-triggered の保証を回復します。informer は edge(watch)で動きつつ level の正しさを resync で担保する、まさにハイブリッドの実装です。
work queue:重複排除・レート制限・再試行
informer がイベントを受け取るたびに reconcile を即実行すると、別の問題が出ます。同じリソースに更新が連続すると reconcile が何度も走り、また reconcile が失敗したときの再試行戦略もない。そこで間に work queue(作業キュー) を挟みます。
informer はイベントを受けると、変更のあったリソースのキー(名前空間/名前)だけをキューに入れます。ワーカーがキューからキーを取り出し、そのキーについて「キャッシュから現在状態を観測し、望ましい状態と比較し、差を埋める」reconcile を実行します。このキューが3つの重要な性質を与えます。
- 重複排除(dedup):同じキーが処理待ちの間に何度入れられても、キュー内では1つにまとまる。連続更新が来ても reconcile は1回で済む。しかもキューに入るのはキーだけで状態の中身ではないため、ワーカーは取り出し時に必ず最新のキャッシュを読み直す。これが level-triggered を保証します(古いイベントの中身に基づいて動かない)。
- レート制限(rate limiting):単位時間あたりの処理数を絞り、API サーバーへの過負荷やリトライ嵐を防ぐ。
- 再試行とバックオフ:reconcile が失敗したら、そのキーを指数バックオフで再投入する。一時的な障害は時間をおいて再挑戦し、収束をあきらめない。バックオフの設計思想は リトライとバックオフ/ジッタ と同じで、同時刻の再試行集中(サンダリングハード)を避けるためジッタを混ぜます。
informer: watch でキャッシュ更新 → 変化したリソースのキーを enqueue
queue: 同一キーは1つに統合(dedup)/レート制限/失敗キーをバックオフ再投入
worker: dequeue(key) → cache から現在状態を読み直す(最新観測)
→ desired と比較 → 差を埋める reconcile を実行
→ 失敗なら requeue(key, backoff)
この三段構え(informer + queue + worker)によって、「毎回最新の状態を観測して差を埋める」という level-triggered の正しさを保ちながら、全件ポーリングの非効率を回避できます。
単一の責務と楽観的並行制御
堅牢なコントローラ設計には、もう2つ原則があります。
第一に、1つのリソース種別の1つの側面だけを担うコントローラを多数並べること。巨大な万能コントローラより、小さな単機能コントローラの集合が望ましい。各々が独立して自分の差分だけを埋めるため、障害が局所化し、組み合わせで複雑な振る舞いを構成できます。
第二に、複数のコントローラやワーカーが同じリソースを同時に書き換える競合を、楽観的並行制御(optimistic concurrency)で裁きます。各リソースはバージョン番号(resourceVersion)を持ち、更新時に「自分が読んだバージョンと一致する場合だけ書き込む」compare-and-swap を行う。バージョンがずれていたら更新を拒否され、ワーカーは最新を読み直してやり直す。これも level-triggered の精神そのもので、古い観測に基づく書き込みを構造的に防ぎます。なお誰がリーダーとして reconcile するかの一意性は、リーダー選出とスプリットブレイン回避 と同じくクォーラムやリースで担保します。
コントローラの設計では、reconcile の失敗を例外的事態と捉えず、単に requeue して次に再試行する正常フローとして扱います。「いま完全に成功させる」ではなく「収束し続ける」がゴールだからです。だからエラーハンドリングはログを吐いて終わりではなく、必ずキーをキューへ戻して再収束の機会を作る形になります。
まとめ
- 宣言的システムは望ましい状態を事実の源として保持し、現在状態とのずれをコントローラの調整ループが継続的に埋める。一度の成功ではなく収束し続けることが本質。
- reconcile は冪等であり、何度回しても害がない。途中でクラッシュしても次の周回が状態を再観測して続きを埋めるため、コントローラはほぼステートレスでよい。
- level-triggered(現在状態を毎回観測)はイベント欠落・再起動に強く堅牢。edge-triggered(変化イベントだけに反応)は軽いが1つの取りこぼしで状態が永久にずれる。堅牢な設計はエッジをトリガーに level で動く。
- 全件ポーリングの非効率は、informer(watch でローカルキャッシュを同期し、resync で取りこぼしを回復)と work queue(重複排除・レート制限・バックオフ再試行)で解消する。
- 小さな単機能コントローラを多数並べ、楽観的並行制御で競合を裁く。失敗は例外でなく requeue による再収束の入力として扱う。
DevOps/インフラ Article
宣言的調整ループとコントローラパターンを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
コントローラパターン
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 5
導入後に効く点
level-triggered(状態を毎回観測し直す)はイベント取りこぼしに強く冪等。edge-triggered(変化イベントだけに反応)は速いが1つの欠落で状態が永久にずれる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「コントローラパターン / 宣言的設計」に近いか確認する。
- 強みである「コントローラは望ましい状態(desired)と現在状態(observed)の差分を毎周回で計算し、APIを叩いて差を縮め続ける。1回の成功ではなく継続的な収束が本質。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。