Skip to content

5. 実装

5.1 アーキテクチャ

このセクションの定義

コントローラーは controller-runtime マネージャー(Deployment、replicas=2、リーダー選出)であり、NodePool ごとに reconcile し、各パスで governing RotationPolicy を解決する。

ポリシーと状態の分離

  • ポリシー = RotationPolicy spec(オペレーターが作成する望ましい設定)
  • 状態 = NodeClaim/NodePool 上のアノテーション + 一時的な Node/placeholder マーカー(§5.3)
  • CRD は権威的なランタイム状態を保持しない — status は観測用のみ

起動時 preflight

reconcile 開始前に、以下の場合コントローラーは即座に失敗:

  • クラスターが karpenter.sh/v1nodeclaims/nodepools リソースを提供しない
  • RBAC がそれらを読み取れない

互換性の契約は karpenter.sh/v1 グループ/バージョン であり、管理された Karpenter マイナーとは独立(EKS Auto Mode はそれを公開しない)。v1 型のデコード成功はワイヤー互換スキーマを確認。フィールドごとの CRD イントロスペクションは行わない。

5.2 Reconcile ループ

このセクションの定義

Reconcile 呼び出しは 正確に 1 つのノンブロッキングステップ を実行し Requeue を返す。ブロッキング待機なし — すべての状態はアノテーションから読み取られ、再起動を生き延びる。

Reconciler は NodePool をキーとし、以下を watch:

  • NodeClaim(所有 NodePool にマッピング)
  • placeholder PodRunning に到達
  • surge ホスト NodeReady に到達

定期的な 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-atNodePool 上に存在(削除された旧 NodeClaim ではない)。一時停止は完了境界とリーダー変更を跨いで永続的。

完全な擬似コード — クリックで展開
text
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 存在を再アサート
  • drainingdeletionTimestamp がない場合に冪等な delete を再発行(状態書き込みと delete 間のクラッシュ)
  • 完了はクリーンアップを再実行するが、ローテーションの完了は 条件付き書き込みで主張する: anchor の解放と success/expired の判定はどちらもその書き込みが検証される最新の読み取りから決まる
  • キャッシュ遅延したディスパッチが到達しうる書き込みは 4 つあり、いずれも遷移を主張するexpired へ入る 2 つの書き込み、pending の入口アサート、failedpending のリトライ。終端状態が既に書かれた claim をキャッシュ経由で見たパスも、クリーンアップと anchor 解放は行う
  • reconcile の残りの claim 状態書き込みは無条件のままだが、veto ではなく構造的に安全 — ただし同じ構造によるわけではない。pending ハンドラーが行う 2 つ(pendingdrainingpendingfailed)は、そのハンドラー自身のガードされた入口の後に続く。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 つの遷移abortPendingExpiryadvanceFailed の削除分岐。条件付き書き込みが受け付けるのはディスパッチ元ハンドラー自身の遷移前状態だけであり、既に終端状態の claim を再発行しない advanceExpired と整合する)、failure のロールバック(試行の発行と failure pause のスタンプは、それを記録する書き込みが成立したときにのみ行い、報告する retry count はその書き込みが生成した値を使う)、そして ウィンドウクローズ(喪失ウィンドウのカウンターと WindowMissed Event は window-opened-at スタンプをクリアした書き込みに従い、発生ごとに最大 1 回 — カウンターと Event の間で停止すれば片方だけが残りうる)
  • 終端書き込み前に消えた claim は anchor を残し、その結果は完了パス(expired、cooldown なし)が引き受ける

5.3 状態モデル

このセクションの定義

すべての状態は Kubernetes オブジェクト上に存在 — 外部データストアなし。NodePool の active-rotation anchor が どの ローテーションが進行中かを記録; 旧 NodeClaim の stateどこ にあるかを記録。

アノテーションリファレンス

キーターゲット目的
active-rotationNodePoolNodeClaim 名永続 anchor + シリアルゲート
active-rotation-stateNodePooldraining完了結果のフェーズミラー
draining-atNodePoolRFC3339ドレイン所要時間 anchor(§4.2)
surge-waitNodePoolGo duration完了ログの surge フェーズ所要時間
surge-pathNodePoolprovisioned/absorbedどちらの §3.3 パスがキャパシティを予約したか
rotation-modeNodePoolforceful-fallbacksurge なしパスマーカー
window-opened-atNodePoolRFC3339観測されたウィンドウの発生(§4.2)
state旧 NodeClaimpending/draining/failed/expired進捗状態
started-at旧 NodeClaimRFC3339readyTimeout 期限
failed-at旧 NodeClaimRFC3339バックオフ anchor
retry-count旧 NodeClaim整数バックオフをエスカレート
surge-claim旧 NodeClaimNodeClaim 名誘導された surge の特定
surge-forPod + freeze ノードNodeClaim 名ローテーションのペアリング
do-not-disrupt旧 + surge ノードtruevoluntary disruption をブロック
do-not-disrupt-owned旧 + surge ノードtrueコントローラーオーナーシップマーカー
cordoned旧ノードtrueコントローラーの cordon マーカー
last-failure-atNodePoolRFC3339試行間一時停止 anchor
freezeNodePoolRFC3339指定時刻までローテーション抑制
last-rotation-atNodePoolRFC3339cooldownAfter ゲート 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 → drainingsurge-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 に対して発生が判定される — スタンプが書かれたときのスケジュールをコントローラーは記録していない
  • state: expired は終端 — forceful drain 下でファイナライズ中の claim の再選定をブロック
  • started-at: 試行ごとに write-once。failed 書き込み時にクリア(state=failed と単一更新)。リトライ時に再スタンプ
  • failed-at: rotation attempt failed のログ行と RotationFailed Event が報告する次の試行の時刻は、その時点のスケジュールに基づくスナップショットであり、下限ではない: クランプは読み取りのたびに再評価される(§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)ウィンドウ内で選定pendinganchor 書き込み(最初); 旧ノード freeze; 旧ノード cordon; placeholder 作成
(none)forceful fallbackdraininganchor + rotation-mode + draining-at 書き込み; state=draining; 旧 NodeClaim 削除(surge なし)
pending各 reconcilependingnone/pending から state=pending主張(条件付き、他の一切より前); freeze + cordon 再アサート; surge-claim 永続化; placeholder 再作成(freeze 中は保留)
pendingsurge_readydrainingsurge ターゲット freeze; draining-at + surge-wait + surge-path 書き込み; 旧 NodeClaim 削除
pendingreadyTimeoutfailedsurge claim reap; placeholder 削除; unfreeze; state=failed + last-failure-at; anchor クリア。ロールバック中に消えた claim は何も書かないため、試行を発行せず pause もスタンプせず、anchor を残して完了パスに force-expiry を記録させる
pendingforce-expiringexpiredpending から state=expired主張(条件付き、クリーンアップより前); expired を 1 回発行; placeholder 削除; unfreeze; anchor クリア
drainingdeletionTimestamp なしdrainingdelete 再発行(クラッシュリカバリ)
drainingドレイン > tGP + bufferdrainingstuck-drain ゲージ; ゲート保持
drainingNodeClaim 消失(success)unfreeze; last-rotation-at; success 発行; anchor クリア
failedバックオフ + ゲート通過pendingfailed から state=pending主張(条件付き); 新試行で started-at 再スタンプ
faileddeletionTimestampexpiredfailed から 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 Podsurge-for の claim が不在/非 anchor → 削除
  • ノードマーカーsurge-for、コントローラーの do-not-disrupt(owned マーカーによる))→ 削除
  • cordoned マーカー — anchor なしのローテーション → uncordon して削除

ルール:

  • anchor がある NodePool は 陳腐化していない — ステップ 1 が通常通り再開
  • failed/expired claim はアノテーションを保持(バックオフ再入 / 終端マーカー)
  • anchor なしの pending/draining claim(クラッシュポイントからは不可能)→ 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

yaml
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: Accepted

status サブリソース

  • matchedNodePools: このポリシーがセレクタ specificity で勝利するプール数
  • rotatingNodePools: そのうち進行中のローテーションがある数
  • Ready condition:
    • Accepted — 有効かつ競合なし
    • Invalid — reconcile 時バリデーション失敗
    • Conflict — 同一 specificity タイ(§下記)
  • InvalidConflict に優先
  • status は観測用のみ — ローテーション判断の権威的ソースではない

専用の RotationPolicyStatusReconciler がこのビューを更新。楽観的並行制御の競合はサイレント requeue として扱う。

ターゲティングと競合解決

ルール動作
最も specific が勝利Specificity = ラベルキー制約数
同一 specificity タイハードエラー — その NodePool のローテーションを拒否
ポリシーなしローテーションしない(安全な no-op)
  • Specificity: matchLabels エントリ + matchExpressions エントリ。空(catch-all)セレクターはスコア 0 — 任意のキー付きセレクターに負ける
  • タイ: PolicyConflict Warning Event + noderotation_policy_conflict{nodepool} = 1 をセット
  • マッチなし: 暗黙のデフォルトなし; ブランケットカバレッジが必要ならオペレーターが catch-all を作成

ローテーション中のガバナンス喪失

ローテーションが anchor されている間にプールのガバナンスが失われた場合、コントローラーは以下の順で ロールバック:

  1. placeholder 削除
  2. ノードの unfreeze(オペレーター独自の保護は維持)
  3. anchor クリア — anchor がまだこのローテーションを指している場合のみ
  4. GovernanceLost Warning 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_activenoderotation_window_period_seconds がロードベアリングな nodepool ラベルを持つ理由(§4.2)。