3. 設計
3.1 メンテナンスウィンドウ
このセクションの定義
メンテナンスウィンドウはローテーションの 開始 を制御する。進行中のローテーションは境界を超えて完了する。最悪ケースのウィンドウ周期 P が ageThreshold の導出に使用される(§3.2)。
maintenanceWindows: # リスト; 有効なウィンドウ = すべてのエントリの和集合
- timezone: Asia/Tokyo # IANA tz データベース名
days: [Wed, Sat] # Mon/Tue/Wed/Thu/Fri/Sat/Sun
start: "02:00"
end: "06:00"セマンティクス
- Reconciler は 常時稼働; ウィンドウのメンバーシップは各 tick(1分)で評価。
maintenanceWindowsは リスト; 有効なウィンドウはすべてのエントリの 和集合。- 和集合の外では reconcile ループは no-op。
- ウィンドウは ローテーション開始 のみを制御。進行中のローテーションは境界を超えて継続。
- freeze アノテーション(
noderotation.io/freeze=<RFC3339 タイムスタンプ>)が指定時刻までローテーションを抑制。
freeze の動作
ウィンドウ(開始のみゲート)とは異なり、freeze は進行中の pending ローテーションも 保留 する:
- エスカレーションのみ を停止 — placeholder の(再)作成と
drainingへの遷移 - パッシブなブックキーピングは継続 —
do-not-disrupt/cordon マーカーの再アサート、surge-claimの識別を永続化 - freeze が
readyTimeoutを超えた場合 → 通常の失敗パスでロールバック draining状態のローテーションは完了まで継続(ドレイン中の中止は危険)
最悪ケースのウィンドウ周期 P
P は繰り返しサイクルにおける、ある発生の開始から次の発生の開始までの最大ギャップ。
- 例: 和集合
{Wed, Sat}02:00–06:00 → ギャップ3dと4d→P = 4d - 24/7 連続オープンの和集合:
Pは reconcile tick の粒度に縮退(7dではない) - DST に関する注記:
Pは繰り返し壁時計サイクルで計算。DST 遷移により個別ギャップが ±1h シフトする可能性; v1 ではこれを既知の近似として扱う
3.2 候補選定
このセクションの定義
3つの問い: (1) ノードはいつ候補になるか? (2) ageThreshold はどう導出され expireAfter の前にコントローラーが完了することを保証するか? (3) どのバリデーションが実行可能性を確認するか? 中心公式: A = E − (K·P + t_rot)。
選定条件
NodeClaim が候補になるには すべて が成立する必要がある:
| 条件 | 備考 |
|---|---|
now() > deadline − leadTime | 各 NodeClaim 自身 の spec.expireAfter に基づく |
| governed な NodePool に所属 | RotationPolicy にマッチ(§5.4) |
Ready == True | NotReady → Auto Repair / backstop に委ねる |
deletionTimestamp 未設定 | 削除中 → 除外 |
state が空、または failed でバックオフ経過 | pending/draining は進行中; expired は終端 |
オペレーター do-not-disrupt なし | オペレーター独自のオプトアウトを尊重 |
- 期限の計算:
deadline = creationTimestamp + NodeClaim.spec.expireAfter - ソート順: 期限が早い順、同一の場合は
creationTimestampが古い順、次に名前順 - 異種の
expireAfter: 短いexpireAfterを持つ若いノードが早い期限を持つ可能性があり、先にローテーションされる - オペレーターのオプトアウト: Node 上の
karpenter.sh/do-not-disrupt: "true"(do-not-disrupt-ownedマーカーなし)がプロアクティブなローテーションから除外。expireAfterbackstop は維持
エスカレートしたバックオフは、失敗が発生したメンテナンスウィンドウの発生(occurrence)にクランプされる。その発生の残り時間を過ぎると、エスカレーションのラダーは区別する力を失う——どのステップも「この発生の残りをスキップする」という同じ意味になる——ため、早い段階で2回失敗したプールは、適格な候補が1つもないままウィンドウの残りを過ごすことになる。エスカレートしたリトライがクローズ時刻以降に着地する場合、ラダーはクローズより厳密に内側に着地する最大のステップまで引き下げられ、設定された retryBackoff を下回ることはない。retryBackoff すら収まらない場合は、エスカレートした値がそのまま使われ、claim は次の発生を待つ。
このクランプが適用されるのは、失敗そのものが、評価時点を含む発生の内側で起きていた場合に限られる。しかもそれは評価時点でのスケジュールに基づく判定であり、履歴的な同一性ではない。2つの発生を統合するようなオペレーターによるスケジュール編集は、以前の発生で起きた失敗を統合後の発生としてクランプさせることになるし、逆に分割すればその逆が起きる。retry-count は影響を受けない——エスカレーションはどの失敗でも変わらず上がり続け、短縮されるのは1つの発生内での待ち時間だけである。
ageThreshold の導出
手動調整(エラーを起こしやすい — 緩すぎると Forceful Expiration が発動)ではなく、コントローラーはスケジュールと目標ローテーション回数から NodePool ごとに ageThreshold を導出 する。
中心的なレース: Forceful Expiration はウィンドウや PDB に関係なく各ノードの deadline で発動する。コントローラーはその瞬間 より前に graceful surge ローテーションを完了しなければならない。
公式:
ageThreshold (A) = E − (K·P + t_rot)leadTime = K·P + t_rot を左から右に読む:
K回の最悪ケースウィンドウサイクル(K·P)でウィンドウを 捕捉- プラス 1 回の完了時間(
t_rot)でその中で 完了 expireAfterの前にK回のメンテナンスウィンドウを十分なヘッドルームで保証
シンボル(権威的ソース)
| シンボル | ソース |
|---|---|
E | ノードごと: NodeClaim.spec.expireAfter(権威的)。テンプレートはバリデーション用の代表値のみ |
tGP | ノードごと: NodeClaim.spec.terminationGracePeriod; テンプレートは代表値 |
P | maintenanceWindows 和集合から導出(§3.1) |
t_rot | readyTimeout + tGP + buffer。tGP 未設定時 → 固定フォールバック 1h |
t_rot_est | provisioningEstimate + drainEstimate。Layer-2 のみ、期限項なし |
buffer | 固定 4·shortRequeue = 2m。期限側のみ、t_rot_est には含まれない |
権威的な有効期限ソース
ノードごとのトリガーは NodeClaim.spec.expireAfter から読み取り、その NodeClaim の creationTimestamp に基づく。NodePool テンプレートへの後の編集は既存の NodeClaim に伝播 しない(drift のみをトリガー)。テンプレート E は NodePool ごとのバリデーションとメトリクスの代表値としてのみ使用。
マージンと cooldownAfter
- 限界は タイト — 正確に
K回の機会、組み込みスラックなし - 安全マージンは
K自体から —K ≥ 2を推奨 cooldownAfterは連続ローテーション間の成功後の安定化待機 —t_rotの一部 ではないfailurePause(gate B、§4.4、ADR-0004)はスループット項に影響しない別フィールド
バリデーション詳細(レイヤー 1–3)— クリックで展開
レイヤー 1 — スケジューリングの実行可能性
| 条件 | 結果 |
|---|---|
P = 0(ウィンドウ発生なし) | fatal(NoWindows) |
K < 1 | fatal — 無効な設定 |
K < 2(つまり K = 1) | warn — ウィンドウを逃すとリトライなし |
A ≤ 0(E ≤ K·P + t_rot) | fatal — E を上げる、ウィンドウを追加、または K を下げる |
0 < A < P | warn — 極めて攻撃的なローテーション |
明示的オーバーライドで G < 1 | fatal — オーバーライド拒否 |
明示的オーバーライドで 1 ≤ G < K | warn — 保証回数が弱化 |
E + tGP > 21d | warn(HardCapExceeded)— Auto Mode 違反 |
tGP 未設定 | warn — ドレインフェーズが無制限 |
retryBackoff < readyTimeout | warn — リトライが試行より速い |
spec.limits にヘッドルームなし | warn — surge 不可 |
レイヤー 2 — スループット
発生あたりの容量: C = m · ceil(D / (t_rot_est + cooldownAfter))。
Cはt_rot_est(期待サービス時間)を使用、t_rot(期限上限)ではない- 正の長さの発生は少なくとも 1 回の開始を許容(
C ≥ 1) - 到着が容量を超えた場合(
C < N · P / A): warn — ウィンドウを拡大または発生を追加
同期バッチ: 同一期限の N ノード。graceful に完了するには K · C ≥ N が必要。それ以外は warn(ThroughputBurstShortfall)。
スピルオーバー: t_rot_est + cooldownAfter > gap の場合、遅く開始されたローテーションが次の発生に持ち越される。warn(RotationSpansNextWindow)— 隣接する発生がそれぞれフル C を提供しない。
レイヤー 3 — ノードごと(ランタイム)
テンプレートレベルのチェックはすべての既存 claim が満たされることを証明しない。各 reconcile でコントローラーはすべてのスコープ内 NodeClaim を 自身の spec.expireAfter に対してチェック:
E_node ≤ K·P + t_rot(ノードごとA ≤ 0)→noderotation_short_lead_nodesにカウント、ShortLeadEvent で警告- 最初の機会にベストエフォート でローテーション
deletionTimestampがセットされたら → 選定から除外、abort パスが適用(§5.2)
計算例 — クリックで展開
設定: Auto Mode、tGP を 1h に引き下げ、E = 14d、和集合 {Wed, Sat} 02:00–06:00。
P = 4dt_rot = 15m + 1h + 2m ≈ 1h17mK = 2A = 14d − (2·4d + 1h17m) ≈ 5.9d
ノードは ~5.9d で候補になり、14d の前に 2 回のウィンドウが保証される。
スループット:
provisioningEstimate = min(15m, 5m) = 5mdrainEstimate = min(1h, 10m) = 10mt_rot_est = 15mC = ceil(4h / (15m + 10m)) = 10(発生あたり)
fatal の例: 週1ウィンドウ {Sat} → P = 7d → A = 14d − (14d + 1h17m) ≈ −1h17m ≤ 0 → fatal。修正: E を ~20d に上げるかウィンドウ日を追加。
キャリブレーション注記: ストック tGP = 24h では t_rot ≈ 24h17m だが t_rot_est は 15m のまま(同じ C = 10)。tGP を下げる利点: (1) A が長くなる、(2) 21日キャップが緩和。tGP はリスク許容度から選択; drainEstimate/provisioningEstimate は観測された所要時間から選択。
3.3 surge シーケンス
このセクションの定義
単一の reconcile サイクルで 1つ のノードを処理: NodePool 内はシリアル(maxUnavailable = 1)、NodePool 間はコンカレント。placeholder Pod が NodePool 所有の代替キャパシティを誘導する。
同一 NodePool である理由(スタンドアロン NodeClaim ではない)
スタンドアロン NodeClaim は NodePool のアカウンティング、expiry、drift、disruption budgets の外にある 非所有 ノードを生成し、意図的な NodePool 分離を破壊する。
static capacity NodePool は対象外
spec.replicas を設定した NodePool(Karpenter の static capacity)はノード数を固定に保ち、Pod が pending になっても provisioner の候補に入らない。placeholder は構造的な不変条件として karpenter.sh/nodepool を候補自身のプールに固定するため、static プールでは他プールに吸収されることもプロビジョニングされることもなく、試行のたびに readyTimeout まで滞留してノードの保証チャンスを 1 回消費するだけになる。
したがってコントローラは static NodePool でのローテーション開始を拒否し、StaticNodePool Warning Event(§4.3、ゲートは §5.2)で 1 度だけ通知する。それらのノードは Karpenter の forceful expiration の対象のまま残る。
Karpenter は既存 NodePool の static と dynamic の相互移行を拒否する(spec.replicas に対する CEL ルール)。したがって対処はフィールドをその場で編集することではなく、ワークロードを dynamic な NodePool へ移すか、この NodePool を RotationPolicy のセレクタから外すことである。
replica 収束に置換を任せる surge-less なローテーション — surge のために spec.replicas を増やす案と、NodeClaim を削除して replica 収束に補充させる案 — は、先送りではなく不採用として決着した。Karpenter の static deprovisioning は空のノードを最初に選ぶため、replicas を増やして立てたノードは、続く scale-down で選ばれうる候補の 1 つではなく最初の候補になる。また、削除して補充させる案がドレイン全体の停止ではなく 1 ノード分の目減りで済むという並走性は、limits.nodes が replicas を上回っている間しか成立せず、これはモードの性質ではなくオペレーターの設定である。surge.forcefulFallback があることは、それでも出荷してよい前例にはならない。あれは締切をまるごと逃すプールのための opt-in かつウィンドウに限定された脱出路(ADR-0001)であって、プールの常態の機構ではない。どちらの制約も Karpenter の現在のノードアカウンティング(v1.14)に由来するので、それが変われば決定は再検討に値する — static プールのローテーションが望ましくないからではない。
placeholder Pod
| プロパティ | 値 |
|---|---|
| Kind | Bare Pod(コントローラーなし) |
| Priority | 専用の負の PriorityClass |
| Preemption | preemptionPolicy: Never |
| Requests | 再スケジュール可能 Pod 合計(クランプ済み)。wholeNodeReservation 有効時は 1 ノード分 |
| Node selector | karpenter.sh/nodepool = <pool> |
| Node affinity | Soft: 候補 + 期限近いノードを回避 |
| Tolerations | NodePool spec.template.spec.taints から |
リクエスト合計からの除外
Karpenter が再配置不要な Pod:
- DaemonSet Pod — Karpenter が各新ノードにオーバーヘッドを追加(二重カウント)
- Mirror / static Pod
- 完了した Pod(
Succeeded/Failed) - ノード固定 Pod(hostname affinity)
ホスト名除外(soft、hard ではない)
- 候補の除外は cordon で強制(この preference ではない)
- 期限近いノードの除外はベストエフォート
- required にしない理由: Karpenter は required
kubernetes.io/hostnameaffinity(制限付きラベル)を拒否 - 除外リストは各(再)作成時に再計算; 陳腐化寿命 ≤
readyTimeout
2つのプロビジョニングパス
- 新規プロビジョニング: Karpenter が同一 AZ に NodePool 所有ノードをプロビジョニング
- キャパシティ吸収: スケジューラーが既存の空きキャパシティに placeholder を配置
いずれの場合も、ホストが surge ターゲット になり、ローテーション中 freeze される。
2 つのパスは所要時間も予約するものも大きく異なるため、コントローラーは通ったパスを明示する: surge node ready と rotation complete の各行、および RotationCompleted Event に surgePath ∈ {provisioned, absorbed} を出力し、両者の間は surge-path アンカーフィールドで引き継ぐ(§5.3)。
パスは 1 つの問い — surge ホストの NodeClaim はこの試行の中で生まれたか — で決まり、ロールバックの reap ガードと同じ述語で評価する。そのため両者が同じホストについて食い違うことはない。報告するのは観測できる事実であって因果ではない: この試行の期間中に Karpenter が別の pending Pod のために立てたノードが placeholder を吸収した場合も provisioned と読める。NodeClaim を解決できないホストは、推測値ではなく値なしとする。
placeholder のサイジングクランプ
問題: Karpenter はインスタンスタイプごとに 1 つの allocatable 推定値をキャッシュするが、実際の allocatable は AZ ごとに高い可能性がある。キャッシュ推定値を超えて満たされたノードは、プロビジョニング不可能な placeholder を生成する。
解決策:
limit = NodeClaim.status.allocatable − DaemonSet オーバーヘッド(リソースごと、下限 0)
requests = min(再スケジュール可能合計, limit) (リソースごと)status.allocatableを使用 — インスタンスタイプやキャッシュの知識不要status.allocatableが存在しない場合 → クランプは no-opsurge_headroom(§5.2)は クランプ済み フットプリントをテスト- 両項とも推定値である: タイプごとにキャッシュされた
allocatableと、候補ノード上で観測された DaemonSet オーバーヘッド(Karpenter が新規ノードに見積もる集合と一致するとは限らない)。クランプは placeholder をこの天井に合わせにいくのであって、収まることを保証はしない
エッジケース:
- Refused(ドレインが要求するあるリソースで
limit ≤ 0): 候補ノード上で観測された DaemonSet オーバーヘッドにより、そのリソースのキャッシュ済み天井が非正になる → その天井の下ではどのクランプ値もそのリソースを正の量だけ予約することができず、そのディメンションのドレインを予約しないままsurge_readyを満たしてしまいうる。したがって placeholder はフルドレインを維持する。これが問題になるのに placeholder が空である必要はない: cpu の天井が正で memory の天井が 0 なら placeholder は依然 cpu を担いでおり、裏付けが無いのは memory だけである(limit = 0では、そのリソース 1 つについてゼロ要求はオーバーヘッドと並んで算術的に成立する。排除する理由は「収まらない」ことではなく「その需要を一切保持しない」ことである)。この refusal はリソース単位であり、クランプは最初に見つけた 1 つで返るため、ドレインの他のリソースは正の天井を持ち予約可能でありうる。そしてスケジュール可能性については何も決定しない。 両項とも候補ノード由来であり、しかもこの天井は Karpenter のキャッシュされたインスタンスタイプ単位の推定値 — 実ノードがそれを超えうるからこそこのクランプが存在し、その差分をバンドが計測している。したがって同じタイプで同じ DaemonSet を載せたノードにも空きがありうるし、より大きい許可インスタンスタイプや、適用される DaemonSet オーバーヘッドがより小さいノードでも同様である。これらは網羅列挙ではなく例示である。どのノードも受け入れられない場合に placeholder はスケジュール不可のままとなりreadyTimeoutでローテーションがロールバックするが、それは結果であって列挙の補集合ではない。SurgeClampRefusedWarning Event(§4.3)はこの形のまま通知する - Band-exceeded(shortfall > 計測バンド):
SurgeClampBandExceededWarning Event; ローテーションは続行 - 通常ケース(limit 内に収まる): サイレント
whole-node 予約(ADR-0005)
問題: placeholder はドレインの合計を 1 個の Pod として予約する。キャパシティ吸収パスでは、その集約的な穴は既に他の Pod が稼働しているホスト上に置かれ、そのホストが全員を受け入れられる場合にのみ個別の退避 Pod の配置と交換可能になる。穴の大きさは足りていても、個々の Pod 自身の podAntiAffinity や hostPort がそのホストを拒否することがあり、その場合 Karpenter はドレイン開始後にその Pod のためにプロビジョニングする — surge の前ではなく後ろで。
解決(オプトイン、surge.wholeNodeReservation、デフォルト off):
requests = max(requests, limit) (limit は上記と同じ)候補に再スケジュール対象の Pod がある限り cpu と memory に適用し、加えてドレイン自身が要求している他のリソースにも適用する。
ハードルを上げるが、保証ではない
空き cpu または memory が候補サイズのフットプリントに満たないホストはすべて除外される。それがフリートのどれだけを取り除くかはプールの形状に依存する。完全に空のホスト(DaemonSet のみ)は引き続き placeholder を受け入れるが、これは正しい: 空のホストには hostname トポロジの anti-affinity が噛みつく相手が居ないからである。
ただしホストが空であることを証明はしない。予約サイズは候補ノードの allocatable から決まるため、次の 3 つのごく普通の状況では占有されたホストでも吸収できる: より大きいホスト(placeholder が固定するのは NodePool と複製された requirements であってインスタンスタイプではないため、異種混在の NodePool では 8 CPU のホストが 2 CPU 使用中でも 4 CPU の候補 1 台分の空きがある — これは例外ではなく通常ケース)、候補より DaemonSet オーバーヘッドが小さいホスト、そして cpu も memory も要求しない Pod(アクセラレータや ephemeral storage だけを要求する Pod を含む。引き上げるのはこの 2 次元だけなので、それらの Pod は予約が測っている資源を一切占有せず、かつ退避 Pod の anti-affinity や hostPort に拒否される相手になりうる)。
「他の Pod が居ないホスト」を表現する手段は無い: required な kubernetes.io/hostname NotIn term は Karpenter の provisioner にプロビジョニング自体を拒否させ、全 Pod にマッチする required な podAntiAffinity はどのノードにも居る DaemonSet まで排除してしまう。1 つ目の残余は surge.matchNodeRequirements.required に node.kubernetes.io/instance-type を追加して候補自身の型を固定すれば狭められるが、キャパシティが逼迫したときに Karpenter が型を代替する自由を失うという代償がある。
- ドレインの宣言内容によらず bin-packing の 2 次元を引き上げる — cpu のみのドレインが cpu だけを予約すると、memory だけを使う Pod で埋まったホストに吸収されうる。
podsは決して要求しない(コンテナ要求ではない)。ephemeral storage とアクセラレータは、ドレインが要求している場合のみ引き上げる - 予約するかどうかは Pod 数で決める(合計値ではない): ゼロ要求の Pod だけを載せた候補はドレイン合計が空でも実ワークロードを抱えている。再スケジュール対象が 0 なら何も予約しない
- より大きいインスタンスを強制はしない:
limit + DaemonSet = allocatableなので、リソース適合の観点で候補自身のクラスを超える要求にはならない。ただし placeholder はインスタンスタイプを固定しないので、Karpenter が可用性・価格・複製された requirements に従って別の型を選ぶことはある - 非正の limit はクランプの refusal に委ねる — そこへ引き上げるとそのリソースを一切予約せず、当該ディメンションのドレインを予約しないまま
surge_readyを満たしてしまう(ドレインが他に何も要求していなければ、何も予約しない空の placeholder になる)。いずれにせよ暗黙の break-before-make である。既に limit を超えているドレインも同様に、クランプが下げて報告する surge_headroom(§5.2)は引き上げ後の footprint を検査するので、spec.limitsがほぼ尽きたプールはローテーションを開始しなくなり、そのことを通知する(InsufficientHeadroom、§4.3)- コスト: 予約が吸収されなかったローテーションごとに追加のインスタンス 1 台、および surge ホストが
do-not-disruptを離れた後の consolidation が 1 回発生しうる。その頻度はプールの形状に依存する(§4.4)— ローテーションごとに必ず発生する固定コストではない
ノードより粗い粒度の制約 — ゾーンレベルの podAntiAffinity、topologySpreadConstraints — は対象外である: それらはホスト上に何があるかではなく、ゾーン内に何が居るかで決まる。Pod を忠実にモデル化してもこれらが解決しない理由は ADR-0005 を参照。
placeholder の優先度とプリエンプション
- 設計上の犠牲者: 負の priority +
preemptionPolicy: Never→ ワークロードがプリエンプト; placeholder は何もプリエンプトしない - Bare Pod: プリエンプトされると消失 — 状態マシンが検出し再作成(
readyTimeoutで制限) - 敵対的プリエンプション: 繰り返しプリエンプションは
readyTimeoutで自己終了 → クリーンなロールバック
シーケンス図(ハッピーパス)
surge_ready の条件
placeholder が以下を満たす必要がある:
- Running かつ terminating でない(
deletionTimestamp未設定) - 候補ノード ≠ の Ready ホスト上
- ホストの
karpenter.sh/nodepoolラベル == プール名
プリエンプトされた placeholder は deletionTimestamp 付きで Running のまま → ready ではない(予約が解除中)。消失後に再作成、readyTimeout で制限。
キャパシティ予約のセマンティクス
placeholder は 1 ノード分のキャパシティを予約する。保護は 物理的予約: surge_ready は placeholder が候補以外の Ready ノード上で Running であることを要求する。新規でも既存でも、そのホストの承認は再スケジュール可能キャパシティが物理的に保持されていることを意味する。
吸収パスの制限
吸収パスでは、予約は集約的 — 既に他の Pod が稼働するホスト上に 1 ノード分のリクエストが保持される。個別の退避 Pod がそれを利用できない場合があり(Pod anti-affinity、hostPort 衝突)、その場合 Karpenter はドレイン開始後にその Pod のためにプロビジョニングする。コントローラーはノードレベルのキャパシティを保証; Pod ごとの配置はスケジューラーと PDB の領域(§3.5)。
したがってこのパスでは surge_wait は退避 Pod が稼働するまでの時間を上界しない — 4 秒の待機の後ろに、ドレインの背後へ移動しただけのノード起動が隠れうる。所要時間の隣にパスを明示する理由がこれである(§4.2): そうしなければ 2 つのパスはログ上で区別できない。
3.4 surge 中の保護
このセクションの定義
新旧ノードが共存する間、コントローラーは Karpenter のオプティマイザが構築途中の surge ペアを consolidate/drift するのを防止する。
メカニズム: do-not-disrupt アノテーション
ローテーション中、旧ノードと surge ターゲットの 両方 に適用。
ブロック対象
| Disruption タイプ | ブロック? |
|---|---|
| Consolidation | はい |
| Drift | はい |
| Emptiness | はい |
| Forceful Expiration | いいえ |
| Interruption | いいえ |
| Node Repair | いいえ |
expireAfterとのレースに勝つのはleadTimeの仕事(§3.2)であり、このアノテーションの役割ではない- アノテーションの役割: オプティマイザが構築途中のペアを disrupt するのを防止
オーナーシップ追跡
noderotation.io/do-not-disrupt-owned=trueはコントローラーがdo-not-disruptを実際に適用した場合のみ書き込み- オペレーターの既存
do-not-disrupt: true(マーカーなし)は 決して変更しない - 非
true値はオペレーター保護ではない — 上書きして own - クリーンアップは owned マーカーがある場合のみ
do-not-disruptを削除
surge-for マーカー
freeze された各ノードが noderotation.io/surge-for=<旧 NodeClaim 名> を保持:
- freeze をこのローテーションに帰属
- 旧 NodeClaim 削除後に surge ターゲットを発見
do-not-disruptのオーナーシップセマンティクスは 持たない
メカニズム: cordon
pending に入る際、コントローラーが 候補ノードを cordon:
- 目的: surge 待機中に新しい Pod が候補に着地するのを防止(placeholder リクエストはスナップショット)
- マーカー:
noderotation.io/cordoned=true— ロールバック/sweep はコントローラーの cordon のみを解除 - 条件付き: マーカーなしで既に
unschedulable→ no-op(オペレーターの cordon は採用しない) - ローテーション拒否ではない: cordon されたノードは引き続き選定される。ローテーションを抑制するには
freezeを使用
残存リスク
surge がまだ代替ノードの Ready 待ちの間に旧ノードの deadline が到来した場合 → Karpenter がスケジュール通りに force-expire。これはタイト leadTime / 最終ウィンドウのエッジケースであり、ネイティブベースラインに 退化 する(§3.9)。防止されるシナリオではない。
3.5 Pod レベルの動作
要点
Make-before-break は ノード レベルであり、Pod レベルではない。Pod レベルの安全性は PDB + レプリカヘッドルームに委譲。
コントローラーは Pod のローリングアップデートを 行わない。surge ノードは 空のキャパシティ として追加される。
旧 NodeClaim が削除されると:
- Karpenter の termination controller が Eviction API でドレイン(PDB 尊重)
- 各退避 Pod が削除される
- 所有するワークロードコントローラーが代替 Pod を作成
- スケジューラーが利用可能なキャパシティ(通常は surge ノード)に配置
これは evict-then-reschedule — 代替 Pod が旧 Pod 終了前に Ready であることは保証されない(§4.1)。
surge ノードの役割: PDB ゲート付き eviction が長い pending ウィンドウなしで進行するようにランディングゾーンを事前配置。
- 厳格な PDB(
minAvailable= 希望カウント): 代替がReadyになるまで eviction ブロック → 実質的に Pod レベル make-before-break(surge ノードのキャパシティにより実現) - 緩い/なしの PDB: eviction が一括で進行、
readyReplicasがディップ(§4.1)
まとめ: コントローラーはノードレベルの surge を保証; Pod レベルの make-before-break は PDB + レプリカヘッドルームで達成され、surge ノードのキャパシティがそれを 可能にする — G4 と一致。
3.6 強制フォールバック(オプトイン)
このセクションの定義
surge.forcefulFallback.enabled: true で、graceful surge が候補の期限前に完了できない場合、コントローラーは NodeClaim を surge なし、ウィンドウ内 で voluntary パス経由で削除する(PDB 適用)。
トリガー
deadline − now < t_rotここで deadline = creationTimestamp + spec.expireAfter、t_rot = readyTimeout + tGP + buffer。
動作
- 旧
NodeClaimをウィンドウ内で make-before-break なし で削除(break-before-make) - 引き続き voluntary パス — PDB 尊重(
terminationGracePeriodまで) - ノードレベル surge プロパティのみ緩和。「Karpenter をバイパスしない」や G4 は維持
- 制御不能な expiration をウィンドウ内に引き込む
readyTimeoutとプロビジョニング待機をドロップ → スループット向上
制約
- NodePool 内シリアル(
maxUnavailable = 1) expireAfter: Never(nil)の候補は期限なし → 該当しない- デフォルト off — off の場合、余剰ノードはネイティブ
expireAfterベースラインに退化(§3.9) noderotation.io/rotation-mode = forceful-fallbackで anchor に記録(§5.3)ForcefulFallbackWarning Event +noderotation_forceful_fallback_totalカウンター(§4.2)を発行
3.7 ゾーンワークロード
このセクションの定義
ゾーン PV Pod は同一 AZ でのみ再スケジュール可能。placeholder は候補のゾーン(および他の設定可能な要件)を複製し、surge ノードが正しい AZ に着地する。
問題
ゾーン PersistentVolume(EBS gp3/io2)にバインドされた Pod は 同一 AZ でのみ再スケジュール可能。surge ノードが異なる AZ に着地すると、その Pod は Pending のまま。
解決策: matchNodeRequirements
placeholder が候補ノードから設定可能なスケジューリング要件を複製:
| カテゴリ | デフォルトキー | 目的 |
|---|---|---|
required | topology.kubernetes.io/zone, kubernetes.io/arch, karpenter.sh/capacity-type | 同一 AZ、アーキテクチャ、キャパシティタイプに固定 |
preferred | (空) | キャパシティ圧力下で緩和 |
- オペレーターがより厳密なパリティのためにキーを追加(インスタンスタイプ、カスタムラベル)
- キーを
preferredに移動して厳密性とスケジュール可能性をトレード
要件の解決
キーは候補 NodeClaim.spec.requirements とノードラベルから読み取り、NodePool の許可された要件と交差:
- 交差により placeholder がプール内でスケジュール可能に維持
- ノードラベルが 権威的(実際の配置)でコンフリクト時に優先
- ラベルとして表面化しないキー →
NodeClaim.spec.requirementsのIn値を使用 - 両ソースに存在しない → スキップ
- 数値演算子(
Gt/Lt/Gte/Lte)を正しく処理 — サイレントに弱化しない topology.kubernetes.io/zoneをrequiredから除去 → warn(ゾーン PV Pod がストランドする可能性)
制限事項
- 同一 AZ ランディングゾーンの再作成のみ; ストレージの 移動はしない
- CSI ドライバーが代替 Pod のスケジュール後に既存ボリュームを再アタッチ
- AZ にスケジュール可能なキャパシティがない場合 →
readyTimeoutでロールバック; backstop 適用 - ゾーン PV ワークロードを持つ NodePool は AZ ごとの surge ヘッドルームを確保すべき(R3)
3.8 ロールバック動作
| 失敗 | アクション |
|---|---|
| 新ノードがタイムアウト内に Ready にならない | surge claim を reap、placeholder 削除、unfreeze、失敗を記録 |
| 旧削除後に新ノードが NotReady | ドレイン進行中、逆転不可; Karpenter がキャパシティを調整 |
| Karpenter API 利用不可 | スキップ; 次の reconcile でリトライ |
| コントローラーが surge 中にクラッシュ | active-rotation anchor から再開(§5.2); 冪等な再アサート |
タイムアウトロールバックの詳細
readyTimeout が超過した場合:
- 誘導された surge NodeClaim を reap —
noderotation.io/surge-claimから特定(placeholder の bind ターゲットが観測可能になり次第永続化) - ノードを unfreeze — コントローラーの
do-not-disrupt(owned マーカーによる)と cordon(cordonedマーカーによる)を削除 - 失敗を記録 — NodePool に
last-failure-atを書き込み、anchor をクリア、失敗メトリクス + アラートを発行
surge-claim の特定
surge-claim アノテーションは pending ハンドラーで placeholder の bind ターゲットが観測可能になり次第 永続化 — spec.nodeName(non-preempting Pod の唯一のスケジューラー可視シグナル)。失敗パスでのフォールバック解決順:
- まだ存在する placeholder から再解決
- プールの
started-at以降に作成された、登録済み Node のないNodeClaim
reap ガード
誤った claim を reap しないための 2 つのガード:
started-at以降に作成 されていなければならない(既存のキャパシティ吸収ホストは surge デブリではない)- ノードが placeholder のみ + DaemonSet をホスト(実際の Pod なし)
WARNING
v1 はサイクルごとに 1 ノードを処理。未処理候補は次のウィンドウに繰り越される。expireAfter backstop が最終的なローテーションを保証。
3.9 バックストップ動作
要点
すべての失敗モードは Karpenter のネイティブ Forceful Expiration に退化する — 本コントローラーなしの現状より悪くならない。
コントローラーが利用不可の場合、安全ネットが順に作動:
- Consolidation / Drift — voluntary パスで一部ノードをローテーションする可能性
expireAfter— 超過ノードで Forceful drain を発動terminationGracePeriod— ドレインを制限- Auto Mode 21日ハードキャップ — 最終上限
陳腐化した do-not-disrupt はリスクではない
クラッシュしたコントローラーが残した karpenter.sh/do-not-disrupt:
- voluntary disruption(パス 1)のみを抑制
expireAfter(パス 2)を ブロックしない — ノードは期限を超えて存続できない- 起動時 sweep がクリアするが、そもそもノード寿命を延長していなかった
graceful な退化保証
コントローラーはローテーションをより 早い段階 に移動し graceful にするのみ。以下は行わない:
- 安全ネットの除去
expireAfterを超えたノード寿命の延長(ノードレベルdo-not-disruptは効果なし)
最悪ケースは現在のベースラインと同等 — forceful だが制限付き。段階的に安全に導入可能。
graceful な保証が不可能な場合
キャパシティが需要を下回る(C · A < N · P またはバッチ N > K·C)と、一部の forceful disruption は避けられない:
- デフォルト: ネイティブの制御不能な
expireAfter期限で発生 forcefulFallbackあり: コントローラーがウィンドウ内で制御された surge なしローテーションを実行(§3.6)NodeClaim.specは不変 — コントローラーはexpireAfterのタイミングを変更できない; 唯一のレバーは置換