Skip to content

4. 運用

4.1 キャパシティ / 可用性

このセクションの内容

surge がローテーション中の Pod 可用性にどう影響するか、および 1 ノード surge バジェットの適用方法。

懸念事項対処
Pod pending 時間surge によりほぼゼロ
readyReplicas のディップアプリケーション層での緩和
同時 surge ノードNodePool 内シリアル(v1)
  • Pod pending 時間: surge により Karpenter Graceful セマンティクス(make-before-break)と同等。
  • readyReplicas のディップ: Kubernetes の構造的制限 — surge があっても eviction 後に新しい Pod が即座に Ready にならない。緩和: レプリカのオーバープロビジョニング + PDB。スコープ外。
  • 同時 surge: v1 は NodePool あたり surge.maxUnavailable = 1(内部シリアル; 異なるプールは同時に surge 可能)。代替ノードは NodePool 所有(placeholder 経由で誘導、§3.3)。

1 ノード surge バジェットの適用方法

spec.limitsリソースバジェット{cpu, memory, …})であり、ノード数ではない。前提条件は placeholder のリクエストが NodePool の残りバジェット(limits − 現在プロビジョニング済み)に収まること。

コントローラーは 開始前にヘッドルームを事前チェック(§5.2 ステップ 3、候補選定後 — placeholder のリクエストは選定された候補に依存するため)。バジェットが 1 ノード分を収容できない場合は警告付きでスキップ。

4.2 オブザーバビリティ

Prometheus メトリクス

/metrics で公開:

メトリクスタイプラベル
noderotation_candidatesGaugenodepool
noderotation_in_backoffGaugenodepool
noderotation_in_progressGaugenodepool
noderotation_completed_totalCounternodepool, outcome
noderotation_forceful_fallback_totalCounternodepool
noderotation_window_missed_totalCounternodepool
noderotation_duration_secondsHistogramnodepool, phase
noderotation_window_activeGaugenodepool
noderotation_policy_conflictGaugenodepool
noderotation_freeze_until_timestampGaugenodepool
noderotation_age_threshold_secondsGaugenodepool
noderotation_rotation_chancesGaugenodepool
noderotation_throughput_capacityGaugenodepool
noderotation_t_rot_estimate_secondsGaugenodepool
noderotation_t_rot_bound_secondsGaugenodepool
noderotation_window_period_secondsGaugenodepool
noderotation_short_lead_nodesGaugenodepool
noderotation_drain_stuckGaugenodepool
noderotation_retry_countGaugenodepool
メトリクス詳細 — クリックで展開
  • noderotation_candidates: プールあたりの適格 NodeClaim 数
  • noderotation_in_backoff: 年齢トリガーを越えているうえで、失敗した試行によってエスカレートした retryBackoff 中にあるという理由だけで候補カウントから外れている NodeClaim 数。年齢の限定はロードベアリングである: 期限が来ていたときに失敗し、その後に年齢が期限外になった claim(明示指定した ageThreshold の引き上げ、リードタイムの短縮 — トリガーは age > expireAfter − leadTime なので、リードタイムを広げると期限は早く来る — 、expireAfter の延長)は、いま backoff に阻まれてはいるが何も負われておらず、このゲージも window_missed_total もそれを数えない。candidates + in_backoffwindow_missed_total が閉じた occurrence を判定する「年齢と状態による未処理」カウントそのものであり、in-window のアラートは同じ判定をライブに適用できる(§5.2)
  • ウィンドウを意識したバックオフクランプ(§3.2)は、発生の境界で claim を noderotation_candidatesnoderotation_in_backoff の間で移動させるが、固定された claim のスナップショットについて見れば、その合計——window_missed_total が判定に使う未処理作業カウント——は変化しない。しかしクランプはそれ以上のところでは observability に対して中立ではない。より多くの試行を生み出すことこそがクランプの目的だからである: noderotation_completed_total{outcome="failure"} はより頻繁に増加し、noderotation_retry_count はより速く上昇し、NodeRotationRetryCountHigh はより早く発火しうるようになり、失敗ログと Event は増え、追加された試行がクローズ時点で成功しているか、あるいはまだ進行中であるかによって、noderotation_window_missed_total がその発生で発火するかどうかも変わりうる。
  • 増えた試行はメトリクスだけの影響ではない:1回ごとに追加の surge NodeClaim が作成され、失敗すれば reap され、placeholder Pod が入れ替わり、対象の本番ノードの cordon/uncordon も繰り返される。クランプ前なら4回で済んでいた試行がクランプ後に6回になるプール——§3.2 のクランプを動機づけたインシデントの形——では、同じ発生でこのクラスタ側のチャーンがおよそ50%増える。
  • noderotation_in_progress: プールあたりのアクティブローテーション数
  • noderotation_completed_total: 累積完了数; outcome ∈ {success, failure, expired}。expired = graceful ローテーション完了前に force-expire(1回のみ発行、success としてカウントしない)
  • noderotation_forceful_fallback_total: surge なし forceful fallback ローテーション開始数(§3.6); 開始時にインクリメント
  • noderotation_window_missed_total: 年齢と状態から見て未処理の候補(eligible、または年齢トリガーを越えていて retryBackoff 中)が残ったまま、その発生に帰属するローテーションが 1 件も完了しないままメンテナンスウィンドウの発生が閉じた回数(§5.2)。ウィンドウがゲートするのはローテーションの開始だけなので、発生の中で始まり境界の後に成功した試行はその発生に帰属し、発生を settle させる。ここでの「残っている」は「コントローラーがローテーションできたはず」ではない: この評価は意図的にプールレベルのゲートより前段で走る(§5.2)ため、static なプールやスケジュールが致命的に実行不能なプールは、年齢と状態から見て未処理の claim を残したまま閉じた発生のたびに報告する。発生ごとに最大 1 回、window-opened-at スタンプをクリアしたパスがインクリメント — クリアが発行より先に着地するため、その間に停止したコントローラーはシグナルを捏造せず落とす
  • noderotation_duration_seconds: フェーズごと; phase ∈ {surge_wait, drain}。surge_wait = started-at → surge_ready; drain = draining-at → 旧 NodeClaim ファイナライズ。成功遷移ごとに最大 1 回観測(リトライ書き込みでの二重カウントなし)
  • noderotation_window_active: 0/1 ウィンドウメンバーシップ表示
  • noderotation_policy_conflict: 0/1 セレクタータイまたは無効ポリシーによりブロック(§5.4)
  • noderotation_freeze_until_timestamp: アクティブ freeze の Unix タイムスタンプ(0 = なし)
  • noderotation_age_threshold_seconds: 導出された ageThreshold(§3.2)
  • noderotation_rotation_chances: 保証回数 G
  • noderotation_throughput_capacity: layer-2 予測 C — 発生あたりの開始数(§3.2)
  • noderotation_t_rot_estimate_seconds: 予測サービス時間 t_rot_est = provisioningEstimate + drainEstimate
  • noderotation_t_rot_bound_seconds: 期限側上限 t_rot = readyTimeout + tGP + buffer
  • noderotation_window_period_seconds: 最悪ケース周期 P
  • noderotation_short_lead_nodes: 自身の spec.expireAfterK 回を保証できない NodeClaim 数(§3.2 layer 3)
  • noderotation_drain_stuck: 0/1 ドレインが tGP + buffer を超過(§5.2)
  • noderotation_retry_count: プール内 NodeClaim の最大 retry-count(0 = なし)

ラベルに関する注記

NodePool ごとのウィンドウ(各プールが自身の RotationPolicy を解決、§5.4)により、noderotation_window_period_secondsnoderotation_window_activeロードベアリングnodepool ラベルを持つ — P とメンバーシップはプール間で異なりうる。

  • expireAfter: Never: すべての導出ゲージが 0(導出スキップ)
  • ウィンドウ発生なし(P ≤ 0): bound/estimate は非ゼロ; throughput_capacity のみ 0

ライフサイクル

  • シリーズは NodePool 削除時にクリア — ゲージは各 reconcile で再計算
  • governing policy を失ったプールも同様にシリーズがドロップ(§5.4)

Kubernetes Events

Warning レベルの状態が kubectl describe で確認可能:

オブジェクトReasonタイミング
NodePoolKBelowTwo, AVeryAggressive, TGPUnset, HardCapExceeded, RetryBackoffShort, DrainEstimateAboveTGP, ProvisioningEstimateAboveReadyTimeout, ThroughputBelowArrival, ThroughputBurstShortfall, RotationSpansNextWindow, OverrideGBelowKスケジュール finding がアクティブ
NodeClaimShortLeadclaim が K 回を保証できない
NodePoolForcefulFallbacksurge なしローテーション開始
NodePoolStaticNodePoolspec.replicas が設定されており surge では絶対にローテーションできない(§3.3)
NodePoolInsufficientHeadroomspec.limits に placeholder の余地がなく、ローテーションを開始できない(§5.2 step 3)
NodePoolWindowMissed候補が未ローテーションのままウィンドウが閉じ、その発生に帰属するローテーションが 1 件もなかった(§4.2)
NodePoolPolicyConflict同一 specificity の RotationPolicy タイ — そのプールはローテーションしない(§5.4)
NodePoolGovernanceLostガバナンス喪失後に in-flight ローテーションをロールバック(§5.4)
NodePoolRotationStarted候補選定(Normal
NodePoolRotationCompleted旧 NodeClaim ファイナライズ(Normal
NodeClaimRotationFailedreadyTimeout 超過; ロールバック
NodeClaimSurgeUnschedulableplaceholder PodScheduled=False
NodeClaimSurgeClampedplaceholder クランプ済み(Normal
NodeClaimSurgeClampBandExceededクランプ shortfall > バンド(Warning
NodeClaimSurgeClampRefusedDaemonSet オーバーヘッドが候補自身の allocatable を消費(Warning
  • 重複排除: 状態への遷移時に発行; クリアして再発時に再発行
  • Fatal finding は Event ではない — ローテーション開始をブロックし §5.2 feasibility gate でログ

状態マシンのログ行

すべての状態遷移が 1 つの INFO ログ行を発行(永続アノテーション書き込み後):

主要フィールド
rotation candidate selectednodeclaim, age, deadline, surgeless
no rotation candidatereason、census カウント
surge placeholder createdplaceholder, requests、除外カウント、クランプ情報
surge placeholder is not schedulableplaceholder, reason, detail
insufficient limits headroom; cannot surgecandidate, resource, want, remaining, limit
surge node readysurgeNode, surgeWait, surgePath
drain startednode, mode ∈ {surge, forceful-fallback}
rotation attempt failedreason, readyTimeout, retryCount, backoffUntil
rotation completemode, drain, surgeNode, surgeWait, surgePath, total
maintenance window closed with candidates unrotatedwindowOpenedAt, eligible, inBackoffTriggered
  • surgePath ∈ {provisioned, absorbed} はどちらの §3.3 パスがキャパシティを予約したかを示し、surgeWait を解釈可能にする値である: absorbed の待機時間は既に存在したキャパシティへの bind を測っているだけなので、退避 Pod が稼働するまでの時間を上界しない。パスが確定しなかった場合 — surge なしのフォールバック、または surge ホストの NodeClaim を解決できなかった場合 — は推測せず 省略する。RotationCompleted Event も同じ値を持つ
  • レベルトリガー行no rotation candidatesurge placeholder is not schedulableinsufficient limits headroom; cannot surge)は遷移 dedup を使用 — reason/census/message が変化した場合のみ再発行
  • デバッグ冗長性V(1))で dedup なしの各パス findings とハートビートを追加
  • ライブネスシグナル: ログの沈黙ではなく controller_runtime_reconcile_total / workqueue メトリクスから読み取る

推奨アラート

アラート条件
失敗/expired`increase(noderotation_completed_total{outcome=~"failure
追いつけないnoderotation_candidates > 0 が 2 回連続ウィンドウ
ウィンドウ喪失increase(noderotation_window_missed_total[24h]) > 0
ウィンドウ詰まり(in-window)window_active == 1、freeze 中でない、成功完了ゼロ、かつ noderotation_candidates > 0 or noderotation_in_backoff > 0
ドレイン停滞noderotation_drain_stuck == 1
Short leadnoderotation_short_lead_nodes > 0
体系的失敗noderotation_retry_count >= 3
Forceful fallbackincrease(noderotation_forceful_fallback_total[1h]) > 0(severity: info)

in-window 条件の in_backoff 側はロードベアリングである: エスカレートした retryBackoff 中の NodeClaim は eligible ではないため、プールに未処理の作業が残っていても noderotation_candidates0 に落ちる。candidates だけに依存する条件は、ウィンドウ全体が失敗した試行だけで費やされた場合に沈黙する — これはまさに window_missed_total が報告するために追加されたケースである。

このアームが retry_count ではなく in_backoff であるのは、条件のこの半分をウィンドウ閉鎖時の評価と同一の判定にするためである。candidates + in_backoff はその未処理カウントそのものだが、retry_count はバケットに関係なくプールのすべての NodeClaim にわたる最大リトライ回数であり、カウンタが意図的に除外する claim — Node に運用者が karpenter.sh/do-not-disrupt を付与したもの、削除中のもの、既に期限切れのもの — でも高いまま残る。freeze の除外はその一致を広げる: freeze は運用者がプールにローテーション停止を指示するものなので、freeze 下で閉じたウィンドウは喪失ではなく辞退であり、カウンタはそれを記録しない(§5.2)。in-window 条件も同じ根拠でそれを辞退する。

ただし 2 つのシグナルはこのアームを超えては等価ではなく、アラートはカウンタの予測子ではない。設計上、両者は 3 点で異なる。

アラートはウィンドウが開いている間に繰り返しライブに評価し、カウンタは閉鎖時に一度だけ評価する — したがってプールが境界前に持ち直せば、アラートは発火してから解決する。

完了側のアームは completionRange のローリング参照だが、カウンタは last-rotation-at ≥ window-opened-at で成功を帰属させるため、両方向にずれる: occurrence の直の成功はその occurrence を settle できないのにアラートを抑止し、completionRange より長いウィンドウでは帰属する成功が参照範囲から抜け落ち、occurrence は settle されるのにアラートが発火しうる。どちらも小さく保つには completionRange をおよそ 1 ウィンドウ分に設定する。

意図的にアラートしないケースが 1 つある: backoff が明けて再選択された claim は InFlight であり、どちらのアームにも数えられないため、再選択から readyTimeout が試行をロールバックして retryBackoff に戻すまでこの条件は沈黙する。以前の retry_count アームはその間ずっと発火していた。この縮小は受け入れる — 実際に走っているローテーションは詰まったウィンドウではない — うえで、最終的なシグナルは失敗アラートと、occurrence がその claim を未処理のまま閉じた場合の window_missed_total である。

完了側を outcome="success" に限定することも、同じインシデントに対して同じ向きにロードベアリングである。noderotation_completed_totalfailureexpired も数え、readyTimeout のロールバックは failure を記録する。したがってあらゆる完了を進捗とみなす条件では、失敗した試行 1 件ごとにアラートが抑止され、ウィンドウ全体が失敗した試行だけで費やされたときにちょうど沈黙する。表明すべき条件は「ウィンドウが開いていて、作業が残っていて、何も成功していない」である。

Helm chart がオプションの PrometheusRule として提供(prometheusRule.enabled でゲート、デフォルト false)。チューニングは プロダクション Runbook を参照。

4.3 RBAC とクラウド権限

Kubernetes RBAC

yaml
- apiGroups: ["karpenter.sh"]
  resources: ["nodeclaims"]
  verbs: ["get", "list", "watch", "update", "patch", "delete"]
- apiGroups: ["karpenter.sh"]
  resources: ["nodepools"]
  verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
  resources: ["nodes"]
  verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
  resources: ["events"]
  verbs: ["create", "patch"]
- apiGroups: ["events.k8s.io"]
  resources: ["events"]
  verbs: ["create", "patch"]
- apiGroups: ["coordination.k8s.io"]
  resources: ["leases"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  • nodeclaims: create なし — v1 は NodeClaim を作成しない(§3.3)。update/patch は状態アノテーション; delete はローテーションと failure reap を駆動
  • nodepools: update/patch は active-rotation anchor、状態ミラー、完了アノテーション用
  • nodes: update/patchdo-not-disrupt/マーカー + spec.unschedulable(cordon)用
  • pods: placeholder Pod を直接管理
  • events: NodePool/NodeClaim の Warning Events + リーダー選出記録
  • leases: リーダー選出

placeholder の PriorityClassHelm chart により静的にインストールpriorityclasses 権限不要。

クラウド(例: AWS)IAM

  • v1: クラウド API への直接呼び出しなし。すべての操作は NodeClaim CRD 経由。
  • v2(pre-pull): Job が新ノード上の Pod として実行され、そのロールを継承。コントローラーレベルの追加クラウド権限なし。

4.4 コスト

要点

各ローテーションは ~10–20 分の重複課金を発生させる。1つのメンテナンスウィンドウの発生(occurrence)内では、試行の繰り返しのペースを制限するのは readyTimeout + failurePause である。ウィンドウを意識したバックオフクランプ(§3.2)がエスカレートする retryBackoff に発生を早期に終わらせないようにするのは、retryBackoff まで下げたステップがなお発生の内側に収まる間だけであり、retryBackoff すら収まらなくなった時点でエスカレートした待ち時間はそのまま確定し、その claim は次の発生へ持ち越される。retryBackoff はこのクランプの下限でもあるため、引き上げればその「何も収まらない」地点により早く到達し claim ごとの試行回数を減らせる——ただしこれは claim ごとに作用し、その claim については窓の残りを切り捨てうるので、プール全体を直接ペーシングするのは引き続き failurePause である。複数の claim が互いに独立して backoff とリトライを交互に行いうる。

通常のローテーションコスト

短時間の重複: surge 中に新旧ノードが同時に課金。

  • ローテーションあたり: 1 つの追加オンデマンドインスタンスの ~10–20 分
  • 月間(週次ローテーション、N ノード): ≈ N × 4 × hourly_rate × 0.25
  • ピーク重複: 同時にローテーションする NodePool 数に比例

whole-node 予約のコスト(§3.3、ADR-0005)

surge.wholeNodeReservation を有効にしたプールでは、予約が吸収されなかったローテーションが上記のオーバーラップ課金を支払う — 1 ノード分にサイズされた placeholder を吸収できるのは、cpu と memory に 1 ノード分の空きがあるホストだけだからである。その頻度はプールの形状に依存する: 空きキャパシティやより大きいインスタンスタイプを抱えるプールでは大半が吸収されうる一方、密に詰まった同種プールではほぼ毎回支払うことになる。ワークロードに対する相対コストが最も大きいのはほぼ空の候補で、どこにでも収まるドレインのために丸ごと 1 インスタンスを予約することになる。

完了後、ドレインされたノードの Pod だけを載せた surge ホストが do-not-disrupt を離れた時点で、consolidation が 1 回走ることがある。

失敗した surge のコスト

失敗した試行は readyTimeout まで surge ノードを課金する可能性あり(未使用時にその後 reap; 再利用されたノードは通常キャパシティとして残存)。

コスト制限メカニズム

メカニズム制限対象
readyTimeout + failurePause同一 claim への繰り返し試行、1つのメンテナンスウィンドウの発生(occurrence)内(§3.2)
プールレベル failurePause体系的失敗下の候補サイクリング
  • failurePause なし: 体系的原因が ~1 分以内に次の候補に移行し、候補ごとに readyTimeout 分の課金を消費
  • failurePause あり: readyTimeout + failurePause あたり最大 1 回の試行(デフォルトで ~25m)——これは引き続き 1 つの発生内で同一 claim がリトライする回数の上限である。ウィンドウを意識したクランプは retryBackoff まで下げたステップがなお収まる間はこのペースでリトライを許すが、収まらなくなった時点でエスカレートした待ち時間が確定し、その claim はその発生ではもう試行を得られない
  • failurePausecooldownAfter と分離 — スループット向上のための settle 短縮がコスト制限を弱めない
  • retryBackoff は claim ごとにエスカレートし続け、発生間の待ち時間を引き続き制限する。これはこのクランプの下限でもあるため、引き上げれば claim が「何も収まらない」地点により早く到達しその claim については窓の残りを切り捨てうる——鈍い、claim ごとの手段であって、プール全体の試行レートの信頼できる制限にはならない: 複数の claim が同時にそれぞれの backoff で独立にリトライしうる
  • noderotation_retry_count がパターンをアラート(§4.2)