5. 実装
5.1 アーキテクチャ
このセクションの定義
コントローラーは controller-runtime マネージャー(Deployment、replicas=2、リーダー選出)であり、NodePool ごとに reconcile し、各パスで governing RotationPolicy を解決する。
ポリシーと状態の分離
- ポリシー =
RotationPolicyspec(オペレーターが作成する望ましい設定) - 状態 =
NodeClaim/NodePool上のアノテーション + 一時的な Node/placeholder マーカー(§5.3) - CRD は権威的なランタイム状態を保持しない —
statusは観測用のみ
起動時 preflight
reconcile 開始前に、以下の場合コントローラーは即座に失敗:
- クラスターが
karpenter.sh/v1のnodeclaims/nodepoolsリソースを提供しない - RBAC がそれらを読み取れない
互換性の契約は karpenter.sh/v1 グループ/バージョン であり、管理された Karpenter マイナーとは独立(EKS Auto Mode はそれを公開しない)。v1 型のデコード成功はワイヤー互換スキーマを確認。フィールドごとの CRD イントロスペクションは行わない。
5.2 Reconcile ループ
このセクションの定義
各 Reconcile 呼び出しは 正確に 1 つのノンブロッキングステップ を実行し Requeue を返す。ブロッキング待機なし — すべての状態はアノテーションから読み取られ、再起動を生き延びる。
Reconciler は NodePool をキーとし、以下を watch:
NodeClaim(所有 NodePool にマッピング)- placeholder
PodがRunningに到達 - surge ホスト
NodeがReadyに到達
定期的な self-requeue がウィンドウエッジ、freeze 解除、ドレイン進捗、force-expiry の backstop として残る。
判断フロー
static capacity ゲート(ステップ 1a)
spec.replicas を設定した NodePool は surge を完了できない(§3.3)ため、ローテーションを開始しない。そのパスは 1 度だけ警告し(StaticNodePool、§4.3)、requeue する。
このゲートは in-flight の advance() の後、すべての開始ゲートの前に置かれる。このゲートが存在しなかった頃に書かれた anchor(旧バージョンのコントローラによるもの。Karpenter 自体は稼働中の NodePool への spec.replicas 追加を拒否する)でも、そのローテーションは完了まで進む — そうしないと cordon されたノードと placeholder が行き場を失って残る。advance() の失敗リトライ分岐は新しい試行にあたるため static プールでは別途閉じてあり、エスカレートするバックオフごとに無駄な試行を繰り返す代わりに anchor を解放する。
開始ゲート(ステップ 2)
新しいローテーションを開始するには以下のすべてが通過する必要がある:
in_window(now)— メンテナンスウィンドウがオープンnot frozen(np)— freeze アノテーションなしsince_last_rotation(np) >= cooldownAfter— gate A: 成功後の安定化待機since_last_failure(np) >= failurePause— gate B: 失敗後の一時停止(§4.4、ADR-0004)
候補選定(ステップ 3)
pick_earliest_deadline_eligible は以下の claim を選定:
deletionTimestampなしstateが空(新規)またはfailedでエスカレーティングバックオフ経過(retryBackoff · 2^(retry-count − 1)、8× で上限)。ただし失敗が発生したメンテナンスウィンドウの発生(occurrence)にクランプされる(§3.2)pending/drainingは再選定されない;expiredは終端
anchor のセマンティクス
active-rotation anchor は:
- 開始時にすべての他の副作用 より前 に書き込み
- 完了/失敗時に 最後 にクリア
- Conflict-checked, only-if-absent 書き込み(楽観的並行制御)
- Tick と NodeClaim イベントが同一 NodePool でレース可能 — 前提条件によりレースは無害
完了結果
NodePool 側の active-rotation-state ミラーにより決定:
drainingあり → success(cooldown 消費)drainingなし → expired(アラート、cooldown なし)
force-expiry の検出
2 つのパスで捕捉:
- 早期:
pendingのままdeletionTimestamp出現 — すべてに先行してチェック - 後期:
drainingミラーなしで旧 NodeClaim 消失
早期パスは anchor 解放前に state=expired を書き込む(Auto Mode の tGP = 24h 下でのライブロック防止)。
ドレイン停滞
tGP + buffer を超えるドレインは noderotation_drain_stuck を発生させるが シリアルゲートを保持 — draining のローテーションはロールバック不可(delete 済み)、ゲート解放は maxUnavailable = 1 に違反する。
cooldown anchor
last-rotation-at は NodePool 上に存在(削除された旧 NodeClaim ではない)。一時停止は完了境界とリーダー変更を跨いで永続的。
完全な擬似コード — クリックで展開
Reconcile(req):
if req is Tick:
for np in in_scope_nodepools():
reconcile_nodepool(np)
return Requeue(1m)
return reconcile_nodepool(nodepool(req.obj))
reconcile_nodepool(np):
# ── 0. ウィンドウクローズ評価(§4.2)。この関数内のすべてのゲートより前段: これは
# ウィンドウに何が起きたかを述べるものであり、コントローラーが動かなかった
# 理由ではない。Reconcile 自身のガバナンスゲート(ポリシー競合、ガバナンス
# ポリシーなし)は reconcile_nodepool が呼ばれる前にすでに return している。
# claim-then-announce(§5.2)を verdict 自身を条件として適用する: 書き込み
# ループの中で、権威的なアノテーションに対して(同じ census・同じ now で)
# verdict を再計算し、同じスタンプに同じ action が再び得られることを、
# どちら向きであっても要求する。
match window_edge(np, census(np), in_window(now)):
case stamp: annotate(np, window-opened-at=now) # only-if still `stamp`
case defer: pass # ローテーションがまだ成功しうる
case settled: clear(np, window-opened-at) # only-if still `settled`
case missed: won := clear(np, window-opened-at) # only-if still `missed`
if won: emit_metrics(window_missed); event(WindowMissed)
# ── 1. 進行中のローテーションを先に駆動(シリアル: NodePool あたり最大 1)
if name := np[active-rotation]:
return advance(np, name)
# ── 1a. static capacity ゲート(§3.3): replica 固定のプールに surge は使えない。
# advance() の後なので、in-flight のローテーションは完了まで進む。
if np.spec.replicas is set:
warn_once(np, StaticNodePool)
return Requeue(1m)
# ── 2. 開始ゲート
start_gates(np) :=
in_window(now) and not frozen(np)
and since_last_rotation(np) >= cooldownAfter # gate A
and since_last_failure(np) >= failurePause # gate B
if not start_gates(np): return Requeue(1m)
# ── 3. 候補選定、ヘッドルームチェック、anchor
cand := pick_earliest_deadline_eligible(np)
if cand == nil: return Requeue(1m)
surgeless := forceful_fallback(np, cand)
if not surgeless and not surge_headroom(np, cand):
warn(InsufficientHeadroom, resource, want, remaining, limit) # deduped
return Requeue(1m)
annotate(np, active-rotation=cand.name) # conflict-checked, only-if-absent
if surgeless:
annotate(np, rotation-mode=forceful-fallback,
active-rotation-state=draining, draining-at=now)
annotate(cand, state=draining)
emit_metrics(forceful_fallback); event
delete(cand)
return Requeue(30s)
return advance(np, cand.name)
advance(np, name):
cand := nodeclaim(name)
if cand == nil: # 旧 NodeClaim ファイナライズ完了
delete(placeholder(name))
for node in nodes_with(surge-for=name):
unfreeze(node)
# active-rotation == name のときだけ書く、conflict チェック付きの単一書き込み
# (§5.2)。検証対象と同じ最新コピーから結果を判定し、そのコピーが draining なら
# last-rotation-at を刻み、anchor をクリアし、解放したのが「このパス」か
# どうかを返す。
won, rotated := release_anchor(np, name)
if not won: # 先行パスが既に完了させている
return Requeue(1m)
if rotated:
emit_metrics(success, duration)
else:
emit_metrics(expired); alert
return Requeue(1m)
switch cand.state:
case (none) | pending:
if cand.deletionTimestamp != nil: # force-expiry 捕捉
# claim が「このハンドラー自身の遷移前状態」を保持している場合のみ書き込む、
# conflict チェック済みの単一書き込み(§5.2)。クリーンアップより前に
# 実行する: 遷移を所有しないパスが、進行中のドレインが依存している
# surge ノードを unfreeze してはならない。
out := mark_expired(cand, from=[none, pending],
clear=[started-at, surge-claim])
if out in {gone, raced}: # 何も書いていない = 何も所有しない
return Requeue(30s) # gone なら release_anchor が abort を数える
# 失敗しうるクリーンアップより前に発行する(§5.2)
if out == claimed: emit_metrics(expired); alert
delete(placeholder(name))
for node in nodes_with(surge-for=name): unfreeze(node)
clear(np, anchor)
return Requeue(1m)
# advance() がここへディスパッチする状態からのみ書く(§5.2): 永続状態が
# 先へ進んだ claim の `pending` ビューがロールバックを取り消してはならない —
# started-at の再スタンプは readyTimeout の期限をリセットする
wrote := annotate_if(cand, from=[none, pending],
state=pending, once(started-at=now))
if not wrote: return Requeue(30s) # このパスは何も所有しない
if elapsed(cand.started-at) > readyTimeout:
reap_surge_claim(cand[surge-claim])
delete(placeholder(name))
for node in nodes_with(surge-for=name): unfreeze(node)
wrote := annotate(cand, state=failed, failed-at=now, retry-count+=1,
clear=[started-at, surge-claim])
if not wrote: # ロールバック中に claim が finalize された
return Requeue(30s) # 失敗した試行ではなく force-expiry
# alert が報告する retry-count は、呼び出し元のキャッシュコピーではなく
# この書き込みが実際に生成した値
emit_metrics(failure); alert
annotate(np, last-failure-at=now, clear=anchor)
return Requeue(1m)
freeze(cand.node, surge-for=name)
cordon(cand.node)
if c := induced_claim(name):
annotate(cand, surge-claim=c.name)
if frozen(np): return Requeue(1m) # エスカレーション保留
if placeholder(name) is missing:
create_placeholder(np, cand)
return Requeue(30s)
if surge_ready(cand):
host := placeholder_node(name)
freeze(host, surge-for=name)
path := surge_path(host, cand.started-at) # provisioned | absorbed | unknown
annotate(np, active-rotation-state=draining, draining-at=now,
surge-wait=now − cand.started-at,
surge-path=path if known)
annotate(cand, state=draining)
delete(cand)
return Requeue(30s)
return Requeue(30s)
case draining:
annotate(np, active-rotation-state=draining)
if cand.deletionTimestamp == nil: # クラッシュリカバリ
delete(cand)
return Requeue(30s)
if elapsed(cand.deletionTimestamp) > drain_bound(np):
alert(stuck_drain)
return Requeue(30s)
case failed:
if cand.deletionTimestamp != nil:
out := mark_expired(cand, from=[failed]) # 上と同じ条件付き書き込み
if out in {gone, raced}: return Requeue(30s)
if out == claimed: emit_metrics(expired); alert
clear(np, anchor)
return Requeue(1m)
# リトライは新しい試行: このパスが上位にある step 1a の static ゲートと
# step 1b の fatal feasibility ゲート(anchor により先に advance() へ入る)も
# 通過する必要がある。
if start_gates(np) and np.spec.replicas is unset
and no fatal feasibility finding # step 1b の再主張: この経路はその上にいる
and elapsed(cand.failed-at) >= effective_backoff(cand) # エスカレート済み、発生(occurrence)にクランプ(§3.2)
and surge_headroom(np, cand):
# failed からのみ(§5.2)。同じガードが下の再入も抑える: advance() は
# キャッシュ経由で読み直すため、この書き込みにまだ遅れている読み取りは、
# すべてのゲートが開いたままここへ戻ってくる
wrote := annotate_if(cand, from=[failed], state=pending)
if not wrote: return Requeue(30s)
return advance(np, name)
annotate(np, last-failure-at=max(np[last-failure-at], cand.failed-at),
clear=anchor)
return Requeue(1m)
case expired: # 終端クリーンアップ
delete(placeholder(name))
for node in nodes_with(surge-for=name): unfreeze(node)
clear(np, anchor)
return Requeue(1m)主張してから発行する(claim-then-announce)
キャッシュ遅延したパスが到達しうる書き込みはすべて 条件付き である: 受け付けるのはそのハンドラーがディスパッチされる遷移前状態だけであり、その結果は書き込みループ自身が生成して試行ごとにリセットされる — したがって、最初の試行が conflict し、リトライでオブジェクトが finalize 済みだった場合の結果は成功ではなく gone になる。書き込みが成立したパスがその遷移を 所有 し、発行するのはそのパスだけである: メトリクス・ログ行・Event は「試行」ではなく「書き込み」に従う。
ここから 3 つの性質が導かれ、この順序を使うすべての箇所で成り立つ:
- at-most-once。 書き込みと発行の間で死んだコントローラーはシグナルを捏造せず落とし、自分が行っていない遷移を再発行するものは無い。
- 発行は書き込みの直後・クリーンアップより前に置く。 クリーンアップは失敗しうる。そこでエラーになると次の reconcile は、修復するだけで意図的に発行しないハンドラーに渡るため、クリーンアップの後ろに置いた発行は通常の一時的な API エラーでリトライされずに失われる。
- 何も所有しないパスは何もしない。 一切書き込まず、ローテーションのランタイムオブジェクトにも触れず、その遷移を所有するハンドラーに委ねる — 冪等なクリーンアップがそのパスの役割である場合を除く。
§5.3 の起動時 sweep と §5.4 のガバナンス喪失時の reap も同じ順序を使う。条件はいずれもハンドラーの遷移前状態ではなく、そのオブジェクトを選んだ述語である。
冪等リカバリ
各状態ハンドラーはフェーズの望ましい状態を 再アサート する(ワンショットアクションではない):
pendingは各パスで freeze、cordon、placeholder 存在を再アサートdrainingはdeletionTimestampがない場合に冪等なdeleteを再発行(状態書き込みと delete 間のクラッシュ)- 完了はクリーンアップを再実行するが、ローテーションの完了は 条件付き書き込みで主張する: anchor の解放と success/expired の判定はどちらもその書き込みが検証される最新の読み取りから決まる
- キャッシュ遅延したディスパッチが到達しうる書き込みは 4 つあり、いずれも遷移を主張する —
expiredへ入る 2 つの書き込み、pendingの入口アサート、failed→pendingのリトライ。終端状態が既に書かれた claim をキャッシュ経由で見たパスも、クリーンアップと anchor 解放は行う - reconcile の残りの claim 状態書き込みは無条件のままだが、veto ではなく構造的に安全 — ただし同じ構造によるわけではない。
pendingハンドラーが行う 2 つ(pending→draining、pending→failed)は、そのハンドラー自身のガードされた入口の後に続く。forceful fallback は候補選択から直接開始され、このハンドラーを通らないため、そのdraining書き込みを守るのは直前に獲得した only-if-absent の NodePool anchor である。3 つとも、所有するパスが実際に行った作業を記録し、3 つとも claim を前へ進める。§5.3 の起動時 sweep の書き込みはこのディスパッチの外側にある。そちらも条件付きだが、条件はハンドラーの遷移前状態ではなく、その claim を選んだ述語である - このガードが、遅れた
pendingビューによるロールバックの取り消し —pendingへ戻し、started-atを再スタンプしてreadyTimeoutの期限をリセットする一方、retry-countはエスカレーションの根拠となった値のまま残る — を止め、リトライ分岐からディスパッチャーへの再入も抑える(再入側のキャッシュ読み取りは、直前に行った書き込みにまだ遅れうる)
オブザーバビリティのスキュー(v1 で許容)
- ミラーから delete 間のギャップ: そこでのクラッシュ後に force-expiry が発生すると
successと記録(surge は確保済み — 実質的結果は一致) - claim-then-announce が適用される箇所。 発行点は 4 つあり、それぞれ発行物が異なる: 完了(anchor 解放の書き込み — カウンター・ヒストグラム・完了ログ・Event が、解放された anchor 1 つにつき 1 回発火する)、
expiredへ入る 2 つの遷移(abortPendingExpiryとadvanceFailedの削除分岐。条件付き書き込みが受け付けるのはディスパッチ元ハンドラー自身の遷移前状態だけであり、既に終端状態の claim を再発行しないadvanceExpiredと整合する)、failureのロールバック(試行の発行と failure pause のスタンプは、それを記録する書き込みが成立したときにのみ行い、報告する retry count はその書き込みが生成した値を使う)、そして ウィンドウクローズ(喪失ウィンドウのカウンターとWindowMissedEvent はwindow-opened-atスタンプをクリアした書き込みに従い、発生ごとに最大 1 回 — カウンターと Event の間で停止すれば片方だけが残りうる) - 終端書き込み前に消えた claim は anchor を残し、その結果は完了パス(
expired、cooldown なし)が引き受ける
5.3 状態モデル
このセクションの定義
すべての状態は Kubernetes オブジェクト上に存在 — 外部データストアなし。NodePool の active-rotation anchor が どの ローテーションが進行中かを記録; 旧 NodeClaim の state が どこ にあるかを記録。
アノテーションリファレンス
| キー | ターゲット | 値 | 目的 |
|---|---|---|---|
active-rotation | NodePool | NodeClaim 名 | 永続 anchor + シリアルゲート |
active-rotation-state | NodePool | draining | 完了結果のフェーズミラー |
draining-at | NodePool | RFC3339 | ドレイン所要時間 anchor(§4.2) |
surge-wait | NodePool | Go duration | 完了ログの surge フェーズ所要時間 |
surge-path | NodePool | provisioned/absorbed | どちらの §3.3 パスがキャパシティを予約したか |
rotation-mode | NodePool | forceful-fallback | surge なしパスマーカー |
window-opened-at | NodePool | RFC3339 | 観測されたウィンドウの発生(§4.2) |
state | 旧 NodeClaim | pending/draining/failed/expired | 進捗状態 |
started-at | 旧 NodeClaim | RFC3339 | readyTimeout 期限 |
failed-at | 旧 NodeClaim | RFC3339 | バックオフ anchor |
retry-count | 旧 NodeClaim | 整数 | バックオフをエスカレート |
surge-claim | 旧 NodeClaim | NodeClaim 名 | 誘導された surge の特定 |
surge-for | Pod + freeze ノード | NodeClaim 名 | ローテーションのペアリング |
do-not-disrupt | 旧 + surge ノード | true | voluntary disruption をブロック |
do-not-disrupt-owned | 旧 + surge ノード | true | コントローラーオーナーシップマーカー |
cordoned | 旧ノード | true | コントローラーの cordon マーカー |
last-failure-at | NodePool | RFC3339 | 試行間一時停止 anchor |
freeze | NodePool | RFC3339 | 指定時刻までローテーション抑制 |
last-rotation-at | NodePool | RFC3339 | cooldownAfter ゲート anchor |
すべてのキーは noderotation.io/ プレフィックスを使用(karpenter.sh/do-not-disrupt を除く)。
アノテーション詳細 — クリックで展開
active-rotation: すべての副作用に先行して書き込み、最後にクリア。旧 NodeClaim の削除を生き延びる(成功時に削除されるため)。maxUnavailable = 1のシリアルゲートも兼ねるactive-rotation-state:delete(cand)の直前に書き込み。不在 = ローテーションがpendingを離れなかった。旧 NodeClaim 消失後に完了ハンドラーが読み取りdraining-at:pending → drainingで write-once。旧 NodeClaim のdeletionTimestampは完了時に消失 — この anchor が必要surge-wait:pending → drainingで write-once。旧 NodeClaim(started-atのキャリア)がその遷移で削除されるsurge-path:pending → drainingでsurge-waitと同じ更新の中で write-once。理由も同じで、これを導出する述語がstarted-atを必要とするため。surge-waitを修飾する値である: 吸収パスでは予約は既に他の Pod が稼働するホスト上の集約キャパシティなので、この所要時間は退避 Pod が稼働するまでの時間を上界しない(§3.3)。パスが確定しなかった場合は不在 — surge なしのフォールバックには surge フェーズ自体がなく、surge ホストの NodeClaim を解決できない場合は推測値ではなく値なしとする。各行が報告するのは常にこのフィールドの値であり、後から再解決した値ではない — 遷移がリトライされた場合(pool 書き込みが成功した後に claim のstate書き込みが失敗した場合)、後続のパスはパスを再解決するが、その値を出力すると completion が持たないパスを名乗ることになるrotation-mode: forceful-fallback 開始時に anchor にスタンプ。不在 = デフォルト surge。すべての終了パスで anchor とともにクリアwindow-opened-at: in-window の reconcile でこのアノテーションが不在だと判明した最初の回にスタンプされ、window 外の reconcile で存在すると判明した最初の回にクリアされる。その存在が発生の識別子であり、スケジュールから発生の開始時刻を導出することはしない — 週次の投影は DST をアンカー週に固定しているため、復元した壁時計上の開始時刻は最大 1 時間ずれうる。in-flight のローテーションはクリアを遅延させる; 読み取れない値は in-window では再スタンプされ、window 外では黙ってクリアされる。発生を「観測」で識別することに由来する既知の限界(v1 で許容):- 2 つの発生が 1 件の報告にまとまる。 これは 2 通りの経路のいずれかで起きる: (a) 発生と発生の間の window 外のギャップを reconcile が 1 度も観測しない場合 — ギャップが reconcile 間隔より短い、その区間だけコントローラーが停止していた、API エラーがその区間を通じて続いた — か、(b) そのギャップを毎回観測してはいるものの、観測したすべてのパスでローテーションが in-flight のため
WindowDeferを返し続ける場合。ギャップをまたいで次の発生にまで及ぶ stuck な drain は、まさにこの経路で 2 つを 1 件にまとめる。どちらの経路でも先の発生のスタンプは次の発生まで残り、2 つはまとめて 1 回だけ判定・報告される(報告に載るのは先のwindow-opened-at)。その後の成功はこの先のスタンプに対して settle する — つまり、本来その成功が属していた発生ではなく、まとめられた期間全体に対して settle する。より狭い、すでに含意されているケースは 1 分の self-requeue より短いウィンドウそのもの: 観測されないためスタンプされず、報告もされない - スタンプ保持中にスケジュールを編集して現在時刻が window 外になれば、即座のクローズとして扱われる。 その時点で、その時点の census に対して発生が判定される — スタンプが書かれたときのスケジュールをコントローラーは記録していない
- 2 つの発生が 1 件の報告にまとまる。 これは 2 通りの経路のいずれかで起きる: (a) 発生と発生の間の window 外のギャップを reconcile が 1 度も観測しない場合 — ギャップが reconcile 間隔より短い、その区間だけコントローラーが停止していた、API エラーがその区間を通じて続いた — か、(b) そのギャップを毎回観測してはいるものの、観測したすべてのパスでローテーションが in-flight のため
state:expiredは終端 — forceful drain 下でファイナライズ中の claim の再選定をブロックstarted-at: 試行ごとに write-once。failed 書き込み時にクリア(state=failedと単一更新)。リトライ時に再スタンプfailed-at:rotation attempt failedのログ行とRotationFailedEvent が報告する次の試行の時刻は、その時点のスケジュールに基づくスナップショットであり、下限ではない: クランプは読み取りのたびに再評価される(§3.2)ため、発生の途中でmaintenanceWindowsのエントリを延長すると、すでに報告済みの時刻よりも早く claim が再開可能になることがある。この時刻は、失敗した書き込みが永続化したfailed-atから導出され、RFC3339 アノテーションが保持する秒単位に切り捨てられているため、再選定の述語がパースし直す時刻と同一の瞬間を指すsurge-claim: placeholder の bind ターゲット(spec.nodeName)が観測可能になり次第永続化。failed 書き込み時にクリアsurge-for: freeze ノード上で、freeze をこのローテーションに帰属。Pod 上で発見用にペアリングdo-not-disrupt-owned: コントローラーが実際にdo-not-disruptを適用した場合のみセット。オペレーターの既存アノテーション(マーカーなし)は変更しないcordoned: コントローラーがspec.unschedulableをフリップした場合のみセット。オペレーターの cordon(マーカーなし)は採用しないlast-failure-at: クラッシュリカバリブランチでmaxセマンティクスにより一時停止の無効化を防止
状態遷移
遷移の副作用 — クリックで展開
| From | イベント | To | 副作用 |
|---|---|---|---|
| (none) | ウィンドウ内で選定 | pending | anchor 書き込み(最初); 旧ノード freeze; 旧ノード cordon; placeholder 作成 |
| (none) | forceful fallback | draining | anchor + rotation-mode + draining-at 書き込み; state=draining; 旧 NodeClaim 削除(surge なし) |
pending | 各 reconcile | pending | none/pending から state=pending を主張(条件付き、他の一切より前); freeze + cordon 再アサート; surge-claim 永続化; placeholder 再作成(freeze 中は保留) |
pending | surge_ready | draining | surge ターゲット freeze; draining-at + surge-wait + surge-path 書き込み; 旧 NodeClaim 削除 |
pending | readyTimeout | failed | surge claim reap; placeholder 削除; unfreeze; state=failed + last-failure-at; anchor クリア。ロールバック中に消えた claim は何も書かないため、試行を発行せず pause もスタンプせず、anchor を残して完了パスに force-expiry を記録させる |
pending | force-expiring | expired | pending から state=expired を主張(条件付き、クリーンアップより前); expired を 1 回発行; placeholder 削除; unfreeze; anchor クリア |
draining | deletionTimestamp なし | draining | delete 再発行(クラッシュリカバリ) |
draining | ドレイン > tGP + buffer | draining | stuck-drain ゲージ; ゲート保持 |
draining | NodeClaim 消失 | (success) | unfreeze; last-rotation-at; success 発行; anchor クリア |
failed | バックオフ + ゲート通過 | pending | failed から state=pending を主張(条件付き); 新試行で started-at 再スタンプ |
failed | deletionTimestamp | expired | failed から state=expired を主張(条件付き); expired を 1 回発行; anchor クリア |
expired | まだ anchor あり | expired | 冪等クリーンアップ; anchor クリア(メトリクスは再発行しない) |
anchor のクリア
clear(np, anchor) はローテーションスコープのセット全体を削除する 単一更新:
active-rotation,active-rotation-state,draining-at,surge-wait,surge-path,rotation-mode
コンパニオンフィールドがローテーションを超えて存続することはない。失敗パスは同じ更新に last-failure-at も追加で書き込む。
起動時 sweep
最初の reconcile の前にゲートされ、1 回だけ 実行。anchor が参照しないマーカーのみをクリーン:
- Placeholder Pod —
surge-forの claim が不在/非 anchor → 削除 - ノードマーカー(
surge-for、コントローラーのdo-not-disrupt(owned マーカーによる))→ 削除 cordonedマーカー — anchor なしのローテーション → uncordon して削除
ルール:
- anchor がある NodePool は 陳腐化していない — ステップ 1 が通常通り再開
failed/expiredclaim はアノテーションを保持(バックオフ再入 / 終端マーカー)- anchor なしの
pending/drainingclaim(クラッシュポイントからは不可能)→pending/drainingからstate=failedを主張(条件付き)+ アラート。claim-then-announce(§5.2)が適用され、条件はハンドラーの遷移前状態ではなくその claim を選んだ述語である — reconcile の各経路と違い、結果を引き渡す anchor は存在しない。anchor を持たないことがこの claim を選んだ理由だからである。sweep は List から選択して書き込みはその後になるため、その窓で finalize された claim や、永続状態が既にその 2 状態を離れた claim は、ここでは何も修復されない - node 側も同じやり方で選択述語を再適用する — 書き込みが検証される読みに対して、sweep 開始時に取得した anchor 集合を用いる。その読みが「anchor されたローテーションのものだ」と示すマーカーは孤立ではなく現役であり、それを所有するローテーションに委ねる。何を解除したかも同じ読みから決まり、行はそれを名指す — surge で凍結されたノードなら unfroze、cordon のみのノード(凍結されたことがなく、どの claim にも属さない)なら uncordoned
- anchor なしの孤立
active-rotation-state→ 単純に削除 - ベストエフォート: アイテムごとのエラーはログ、fatal にしない
5.4 設定スキーマ
このセクションの定義
RotationPolicy CRD(cluster-scoped、v1alpha1)が NodePool ごとのローテーション設定を保持。コントローラーはセレクタの specificity で各 NodePool の governing policy を解決する。
RotationPolicy CRD(noderotation.io/v1alpha1)
apiVersion: noderotation.io/v1alpha1
kind: RotationPolicy
metadata:
name: api # cluster-scoped; ポリシーごとに 1 つ
spec:
nodePoolSelector: # governed NodePool を選択
matchLabels:
workload: api
ageThreshold: auto # "auto"(導出、§3.2)または Go duration オーバーライド
minRotationChances: 2 # K; 下限 1
maintenanceWindows: # ポリシーごと; 和集合セマンティクス(§3.1)
- timezone: Asia/Tokyo
days: [Wed, Sat]
start: "02:00"
end: "06:00"
surge:
maxUnavailable: 1 # v1 では 1 固定(OpenAPI が他を拒否)
readyTimeout: 15m # > 0 必須
cooldownAfter: 10m # gate A; 0 も可
# failurePause: 10m # gate B; 未設定 → max(10m, cooldownAfter)
# drainEstimate: 10m # layer-2 のみ; 未設定 → min(tGP, 10m)
# provisioningEstimate: 5m # layer-2 のみ; 未設定 → min(readyTimeout, 5m)
retryBackoff: 30m # > 0 必須
matchNodeRequirements: # placeholder 要件の複製(§3.7)
required:
- topology.kubernetes.io/zone
- kubernetes.io/arch
- karpenter.sh/capacity-type
preferred: []
forcefulFallback: # オプトイン surge なしフォールバック(§3.6)
enabled: false
wholeNodeReservation: # オプトイン whole-node 予約(§3.3、ADR-0005)
enabled: false
prePull: # v2(v1 では無効)
enabled: false
status:
observedGeneration: 3
matchedNodePools: 2
rotatingNodePools: 1
conditions:
- type: Ready
status: "True"
reason: Acceptedstatus サブリソース
matchedNodePools: このポリシーがセレクタ specificity で勝利するプール数rotatingNodePools: そのうち進行中のローテーションがある数Readycondition:Accepted— 有効かつ競合なしInvalid— reconcile 時バリデーション失敗Conflict— 同一 specificity タイ(§下記)
InvalidがConflictに優先- status は観測用のみ — ローテーション判断の権威的ソースではない
専用の RotationPolicyStatusReconciler がこのビューを更新。楽観的並行制御の競合はサイレント requeue として扱う。
ターゲティングと競合解決
| ルール | 動作 |
|---|---|
| 最も specific が勝利 | Specificity = ラベルキー制約数 |
| 同一 specificity タイ | ハードエラー — その NodePool のローテーションを拒否 |
| ポリシーなし | ローテーションしない(安全な no-op) |
- Specificity:
matchLabelsエントリ +matchExpressionsエントリ。空(catch-all)セレクターはスコア 0 — 任意のキー付きセレクターに負ける - タイ:
PolicyConflictWarning Event +noderotation_policy_conflict{nodepool} = 1をセット - マッチなし: 暗黙のデフォルトなし; ブランケットカバレッジが必要ならオペレーターが catch-all を作成
ローテーション中のガバナンス喪失
ローテーションが anchor されている間にプールのガバナンスが失われた場合、コントローラーは以下の順で ロールバック:
- placeholder 削除
- ノードの unfreeze(オペレーター独自の保護は維持)
- anchor クリア — anchor がまだこのローテーションを指している場合のみ
GovernanceLostWarning Event 発行
孤立した placeholder と陳腐化した do-not-disrupt マーカーが Karpenter の voluntary 操作を無期限にブロックするのを防止。
この順序は規範的であり、理由は 2 つ:
- ロールバックはクリアに先行する。 anchor は後続の reconcile をこのクリーンアップへ戻す唯一の手段である — anchor を持たないプールでは reap は即座に return し、そのプールを governing するポリシーはもはや存在しない。後続ステップが失敗しうる状態で先に anchor をクリアすると、成果物は恒久的に孤立する。
- 条件付きクリアが発行者を確定する。 reap は呼び出し元が受け取った anchor から entry するが、それはキャッシュ読み取りであり、先行パスが既にクリアした anchor をなお指していることがある。したがって anchor をクリアする書き込みこそが、そのローテーションを reap したパスを識別する — claim-then-announce(§5.2)であり、同じ at-most-once セマンティクスを持つ: reap されたローテーション 1 件につき Event 1 件、書き込みと発行の間でコントローラーが停止した場合は 0 件。
ポリシー変更の伝播
任意の RotationPolicy の create/update/delete は すべての NodePool を再解決のために再エンキュー(1 つの変更が任意のプールでどのポリシーが勝利するかを変更しうるため)。
NodePool ごとのメンテナンスウィンドウ
maintenanceWindows は各ポリシーに存在するため、ウィンドウは NodePool ごと。和集合セマンティクス(§3.1)は 1 つのポリシーのリスト内で適用。noderotation_window_active と noderotation_window_period_seconds がロードベアリングな nodepool ラベルを持つ理由(§4.2)。