Karpenter v1.15.0 review: node repair grows up — from forced eviction to budgeted, rebootable disruption
Sources
- Karpenter v1.15.0 release notes (kubernetes-sigs/karpenter)
- Karpenter: Node Auto Repair & Disruption concepts (official docs)
- RFC: Making Node Repair Voluntary (design doc)
- RFC: Terminate-First Disruption (design doc)
- RFC: Reboot as a Node Action (design doc)
- Karpenter upgrade guide — Upgrading to 1.15.0
- aws/karpenter-provider-aws v1.15.0 release notes
Karpenter v1.15.0 landed on 10 October 2026 — core at 05:42 UTC and the AWS provider fifty-three minutes later — and the headline is architectural rather than a feature-list: node auto-repair stopped being an eviction hammer and became a first-class voluntary disruption method. If you run GPU fleets on Karpenter with the EKS Node Monitoring Agent, this is the release that changes how your unhealthy nodes die — and, for the first time, how some of them get to live.
We read the release notes, the three design docs that motivated the change, the v1.15.0 source of both kubernetes-sigs/karpenter and aws/karpenter-provider-aws, and the official disruption documentation before writing this. Everything below cites file paths and PR numbers from those sources. What follows is the skeptical version: what actually shipped, what it costs you, and who should not flip the gate.
The pain: a circuit breaker that failed in both directions
Node auto-repair shipped in PR #1768 as an alpha, opt-in mechanism that forcefully terminates nodes a cloud provider declares unhealthy. The Making Node Repair Voluntary RFC is unusually blunt about the old design: the standalone node.health controller deleted NodeClaims directly, gated only by a single hardcoded allowedUnhealthyPercent = "20%" circuit breaker. It did not pre-spin a replacement. It did not respect PodDisruptionBudgets. It did not honor terminationGracePeriod. It gave an operator no lever to stop it mid-incident.
The RFC calls the breaker "the wrong shape" because it fails in both directions. In a detector false-positive wave — the exact scenario a large accelerator fleet lives in — the breaker trips and all repair freezes cluster-wide, including the one genuinely broken node. When the flap settles and the fraction drops back under the line, the breaker re-opens and Karpenter terminates every still-flagged node at once. A single latched threshold cannot distinguish a bad rollout (where replacement re-inherits the fault), a flaky detector, a zonal partition, and genuine hardware failure. The design doc's failure table is the best argument for the rewrite we have read in any 2026 release notes.
The community evidence is pinned in the RFC itself: issue #2811 (an over-termination incident asking for "1 at a time, once per 5m" pacing), #2042 (repair ignoring PDBs — a graduation blocker), and #2134 / #2321 (single-node NodePools where 20% rounds up to "always repair" or "never repair"). We verified all four issue states at time of writing: #2042 is closed-completed by this release; #2134 and #2321 are closed-not-planned — the "configurable breaker" ask was superseded by the budget machinery; #2811 remains open for the per-NodePool repair-policy configuration follow-up.
The fix: repair as a fourth disruption reason
From v1.15.0, repair is a voluntary disruption method — the same class as consolidation and drift — and it rides the disruption-budget machinery as a new reason. In the code (pkg/apis/v1/nodepool.go), DisruptionReason gains Unhealthy alongside Underutilized, Empty, and Drifted, so your existing disruption budgets can now pace it per NodePool:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: gpu-train
spec:
disruption:
budgets:
# Repair at most one node at a time; never let it eat the
# budget that consolidation and drift also draw from.
- nodes: "1"
reasons: ["Unhealthy"]
# Everything else (Underutilized/Empty/Drifted) stays on the
# default pacing during business hours.
- nodes: "10%"
Three semantics from the source are worth pinning because they are not obvious from the YAML. First, budgets with reasons only apply to the listed reasons; a budget without reasons applies to all. Second, when multiple budgets are active, Karpenter uses the most restrictive value. Third — the detail that makes the whole design actually work — NotReady nodes do not count against the Unhealthy budget. The comment in pkg/controllers/disruption/helpers.go explains why: NotReady is precisely repair's trigger, so counting unhealthy nodes against the repair budget would let a health wave zero the very budget that fixes them — the freeze failure mode, resurrected. Only nodes already being deleted or actively rebooting consume repair budget. The 20% breaker still exists (repairUnhealthyThreshold = "20%" in pkg/controllers/disruption/repair.go), but it is now a self-clearing "correlated failure" tripwire — if more than 20% of a pool goes unhealthy, repair assumes the problem is systemic (bad AMI, AZ outage) rather than per-node, emits a NodeRepairBlocked warning event on the node, NodeClaim, and NodePool, and waits — it no longer latches until a human intervenes.
The full control flow, as documented and as implemented:
Node reports unhealthy condition
|
v
[Feature gate] NodeRepair=true? --no--> legacy behavior off; node left alone
|yes
v
Match RepairPolicy: condition type + status + reason regex
(toleration must have elapsed; e.g. Ready=False waits 30m,
AcceleratedHardwareReady=False XID-family waits 10m)
|
v
karpenter.sh/do-not-repair: "true" on node? --yes--> Blocked event; skip
|
v
Budget check: Unhealthy budget for this NodePool > 0?
(NotReady nodes do NOT consume the Unhealthy budget;
nodes being deleted or rebooting DO)
|
v
>20% of pool unhealthy? --yes--> NodeRepairBlocked event; defer
(correlated-failure tripwire, self-clears)
|
v
Action from policy: ReplaceNode or RebootNode
Replace: pre-spin replacement, verify reschedulability,
then cordon/drain (PDB-honoring, TGP-bounded), delete
Reboot: fence + bounded drain, then in-place instance reboot,
2 attempts per 24h per NodeClaim, then escalate to replace
The AWS policy table: reason regexes and XID families
The abstract RepairPolicy type grew from three fields in v1.14 (ConditionType, ConditionStatus, TolerationDuration) to eight in v1.15 — adding ReasonRegex, TerminationGracePeriod, Priority, and Action. The AWS provider's concrete set (pkg/cloudprovider/cloudprovider.go, landed in aws#9680) is where this becomes operationally real, because the Node Monitoring Agent embeds the NVIDIA XID code in the condition reason, and different XID families warrant different handling:
| Condition (status=False) | Reason match | Tolerate | Drain bound | Action |
|---|---|---|---|---|
| Ready | any (.*) | 30m | 10m | Replace |
| Ready (Unknown) | any | 30m | 10m | Replace |
| AcceleratedHardwareReady | XID 46/48/54/62/63/95/109/110/136/140/143/155/156/158 — reboot-clearable transient GPU faults | 10m | 5m | Reboot* or Replace |
| AcceleratedHardwareReady | XID 64/74/79/119/120 — fatal / uncorrectable | 10m | 5m | Replace |
| AcceleratedHardwareReady | fallback (no regex match) | 30m | 10m | Replace |
| StorageReady | any | 30m | 10m | Replace |
| NetworkingReady | any | 30m | 10m | Replace |
| KernelReady | any | 30m | 10m | Replace |
| ContainerRuntimeReady | any | 30m | 10m | Replace |
*Reboot requires the RebootForRepair AWS feature gate (alpha, off by default, landed in aws#9716); without it, the same policies replace the node on the same timing. Note the deliberate split: a transient GPU fault tolerates only 10 minutes and gets rebooted (preserving a scarce GPU instance), while an unrecognized fault waits 30 minutes before replacement — the policy set says "we don't know what this is, so give it time before we burn the instance." That is a mature policy table, not a marketing one. Note also what the docs warn explicitly: when a node matches several conditions at once, Karpenter takes the most disruptive action among them.
The per-condition drain bound matters more than it looks. TerminationGracePeriod on a policy bounds the drain even when neither NodePool nor NodeClaim set one — the RFC's words for the alternative are "the unbounded (~19-day) drain hang that a nil TerminationGracePeriod would otherwise permit." A zero value means forceful: skip the drain entirely, for conditions the kubelet cannot evict through (wedged kernel, lost heartbeat).
Reboot as a node action: the in-place path
PR #3345 adds reboot as a first-class node action, driven by a dedicated controller (pkg/controllers/nodeclaim/reboot/controller.go) that carries a committed reboot through drain → issue → observe to a terminal RebootSucceeded or RebootFailed. The lifecycle is durable in the NodeClaim's Rebooting condition, with phases as the condition reason: RebootRequested → RebootIssued → RebootSucceeded/RebootFailed. On the AWS side, aws#9676 implements CloudProvider.Reboot() via ec2:RebootInstances — a new IAM permission your controller role needs before the gate does anything.
The mechanics are careful in a way worth spelling out because they differ from what most people assume "reboot" means:
- Drain happens before issue. A reboot-owned
karpenter.sh/rebooting:NoScheduletaint fences new scheduling (the design rejectedspec.unschedulablecordoning because the boolean is shared with operators and does not encode ownership), then the node is drained reusing the termination controller's existingTerminator.Drain— PDBs honored, bounded by the policy's drain grace period. Residual pods that survive the bounded drain ride the reboot; Karpenter explicitly does not force-delete them, because deleting the API object does not prove the old process stopped and can create unsafe duplicate identity for stateful workloads. - Recovery is observed via bootID. The controller records the pre-boot
status.nodeInfo.bootIDin thekarpenter.sh/reboot-pre-boot-idannotation, and success is a new bootID on the same instance — not a provider API response. - Escalation is bounded.
pkg/controllers/disruption/repair_history.gokeeps a 24-hour sliding window per NodeClaim UID: afterrebootsBeforeReplacement = 2committed reboots, the next reboot decision escalates to replacement. A reboot that fails after escalation terminates the NodeClaim. The e2e timeout for the escalation path was raised to 35 minutes (#3396) — real reboot cycles are not instant. - Reboots consume budget. An in-progress reboot counts against every disruption budget, for every reason — otherwise consolidation could run concurrently with the reboot and exceed the NodePool budget.
Who is this for? The design motivation is scarce or slow-to-provision capacity: a p5-class instance that took hours to obtain and would take hours to replace. For everyone else, replacement remains the default action and reboot is the opt-in exception — which is the correct polarity for an alpha.
Terminate-first: disruption for fleets with no room to grow
The quieter structural change is PR #3342 (terminate-first repair) and its sibling #3312 (terminate-first drift), both behind alpha gates (TerminateFirstRepair requires NodeRepair; TerminateFirstDrift standalone). The RFC describes the deadlock: voluntary disruption replaces a node by launching its replacement first, which requires spare capacity. Fleets pinned to on-demand capacity reservations sized to exactly N accelerators have none — so drift stalls and repair cannot fire, and "a large reserved GPU fleet can sit un-rolled for weeks."
Terminate-first is the second strategy: delete first, then let ordinary reactive provisioning launch the replacement into the slot that freed. Karpenter derives the choice automatically from configuration you already supplied — a static NodePool at its limits, or a scheduling simulation that can only place the replacement back into the same full reservation. No new API. The karpenter_voluntary_disruption_terminate_first_decisions_total counter records why the replacement could not be staged: no-reserved-capacity or static-at-limit. The blast-radius story is honest: the budget bounds the delete side, PDBs bound how much of a workload is down at once, and if the slot is not free yet the launch retries on its normal cadence — there is no guarantee the replacement lands instantly, because none is possible on a structurally full fleet.
What else shipped: spread awareness, DRA, metrics
Three more changes deserve operator attention:
- Default pod topology spread awareness (#3205): Karpenter's scheduling simulation now mirrors the kube-scheduler's cluster-level
defaultConstraints(configured via the new--scheduler-configflag /SCHEDULER_CONFIGenv, empty by default). Previously a pod with notopologySpreadConstraintscould be packed onto a node in a domain the real scheduler would refuse, going Pending indefinitely because the node that would satisfy the default never gets created. The injector derives each pod's label selector the same way kube-scheduler's PodTopologySpread plugin does. If you run cluster-wide zone spread defaults, this closes a real gap. - DRA prioritized alternatives (#3142) plus NVIDIA/EFA DRA driver support in the AWS provider — the node provisioning path keeps tracking upstream Kubernetes DRA for GPU/interconnect fleets.
- Metrics discipline:
karpenter_pods_disruption_initiated_total(code-registered name; the release-notes headline sayskarpenter_pods_disrupted_total— trust the code and the metrics reference) counts reschedulable pods disrupted, labeled by reason, nodepool, capacity type, consolidation policy, and termination mode, with DaemonSet and mirror pods excluded. New reboot durations (karpenter_nodes_reboot_duration_seconds,karpenter_nodes_reboot_recovery_duration_seconds) and terminate-first counters complete the observability for the new paths.
Hidden costs, breaking changes, and the migration vector
The upgrade guide marks the repair change Breaking even though the gate is alpha and off by default. The full bill:
- CRDs must upgrade with the controller. The budget
reasonsenum addsUnhealthy; a stale CRD silently rejects or drops the new value. - Two metric label changes will break dashboards.
karpenter_nodepool_allowed_disruptionsandkarpenter_nodepool_nodes_consuming_budgetsnow emit thereasonlabel in lowercase (underutilized, notUnderutilized).karpenter_voluntary_disruption_queue_failures_total'sconsolidation_typelabel now carries the consolidation type (empty/single/multi) instead of the decision string, leaving the label empty for drift and repair — filter bydecisioninstead. Update alerts before you upgrade. - consolidateAfter is now optional, defaulting to "0s". v1.14 required the field; v1.15 defaults it (#3220). Existing NodePools that omit it and previously failed validation will now consolidate immediately — an API relaxation that is a behavior change for anyone relying on the validation error as a guardrail.
- The 1000+ node caveat. The upgrade guide's own warning: "Clusters with 1000+ nodes that enable node repair may see disruption slow down because node repair now runs in the disruption controller." The escape hatch is
--legacy-node-repair(#3400; Helmsettings.legacyNodeRepair, aws#9709) — but read its help text carefully: it runs the previous controller, which forcefully replaces and does not support terminate-first, reboot, policy priority, or grace periods. The flag's own doc says the legacy controller is planned for deprecation, with an explicit ask to open an issue if you need it. Treat it as a transition tool with an expiry date, not a supported configuration. - do-not-disrupt does not block repair. The annotation's help text is explicit: it "does not block … repair, or forceful termination once the terminationGracePeriod elapses." Repair has its own veto —
karpenter.sh/do-not-repair: "true"on the Node or NodeClaim — which is the correct escape hatch for "keep this unhealthy node around so I can debug it." If you were relying on do-not-disrupt to protect unhealthy nodes from repair, that assumption breaks. - New IAM permission for reboot.
ec2:RebootInstancesmust be in the controller role beforeRebootForRepairdoes anything but replace.
The migration vector for a typical EKS GPU fleet: upgrade CRDs and controller together, fix the two metric-label alerts, leave NodeRepair off until you have decided your per-pool Unhealthy budget, then enable the gate pool-by-pool with a tight budget (nodes: "1", reasons: ["Unhealthy"]) and watch karpenter_voluntary_disruption_decisions_total{reason="unhealthy"} plus the NodeRepairBlocked events for a week before loosening. If you run reserved-capacity fleets, evaluate TerminateFirstRepair only after NodeRepair itself has been sober for a while — it is the more aggressive primitive.
Who should skip this release's headline feature
- Clusters without a health-signal agent. Repair only acts on node conditions; without the NMA or Node Problem Detector, the gate does nothing. Enabling it is free but pointless.
- Anyone wanting repair-on for stateful single-node workloads without reading the drain-bound semantics. The whole point of this release is that repair now waits for PDBs and bounds; if your workload cannot tolerate the toleration windows (10m GPU, 30m everything else), fix the workload or the policy first — do not enable the gate and hope.
- Fleet-of-one NodePools. The default 10% budget rounds up to one node, fine; but the correlated-failure tripwire at 20% means a three-node pool with two unhealthy nodes freezes repair entirely. That is by design (two of three unhealthy is probably systemic) — budget and tripwire arithmetic should be planned per pool, not inherited.
Credits and what to watch next
The repair arc in this release is overwhelmingly @DerekFrank's work (the RFC, the voluntary-disruption integration, terminate-first repair and drift, the legacy flag, and the metric discipline), with @Sarthug99 on reboot-as-action, @Sebestien on reason-aware policy matching and candidate escalation, @ryan-mist on the disruption-queue integration, @jigisha620 on the VPA prediction groundwork, and @GaneshBannur on the repair-veto RFC. The AWS provider policy set came from @Sebestien (aws#9680) and @Sarthug99 (reboot, aws#9676). Watch issue #2811 for per-NodePool repair-policy configuration — the natural next step, and the one that makes the policy table operator-tunable rather than provider-fixed.
Our verdict: this is the most defensible 1.x minor Karpenter has shipped — a rewrite of its least-safe subsystem that closes a graduation blocker (#2042), replaces a latched breaker with pacing that cannot starve itself, and adds GPU-fleet capabilities (reason-regex policies, in-place reboot, terminate-first) that AWS's own managed node groups are only now matching. The alpha gating is conservative and the breaking surface is small and loudly documented. Upgrade for the CRD-and-metrics hygiene even if you leave every new gate off; turn the repair gate on only with a deliberate per-pool budget and a week of watching the unhealthy decision stream first.
References & Further Reading
- Karpenter v1.15.0 release notes — kubernetes-sigs/karpenter
- karpenter-provider-aws v1.15.0 release notes — aws/karpenter-provider-aws
- Karpenter Disruption & Node Auto Repair concepts — official documentation, v1.15
- Karpenter Upgrade Guide — Upgrading to 1.15.0 — breaking-change list
- RFC: Making Node Repair Voluntary — design doc
- RFC: Reboot as a Node Action — design doc
- RFC: Terminate-First Disruption for Capacity-Constrained NodePools — design doc
- aws#9680: reason-aware default repair policies — the AWS policy table
- EKS node health: Node Monitoring Agent & automatic node repair — AWS EKS User Guide
- Detect node health issues with the EKS node monitoring agent — XID condition reference
- NVIDIA XID error documentation — upstream GPU fault taxonomy
- Karpenter metrics reference — registered metric names and labels