::: tip What this section covers How surge affects pod availability during rotation, and how the one-node surge budget is enforced. :::
| Concern | Treatment |
|---|---|
| Pod pending time | Approaches zero (surge) |
readyReplicas dip |
Application-layer mitigation |
| Concurrent surge nodes | Serial per NodePool (v1) |
- Pod pending time: surge matches Karpenter Graceful semantics (make-before-break).
readyReplicasdip: a structural Kubernetes limitation — even with surge, the new Pod isn'tReadyinstantly after eviction. Mitigation: over-provision replicas + PDB. Not in scope.- Concurrent surge: v1 is
surge.maxUnavailable = 1per NodePool (serial within; distinct pools may surge concurrently). The replacement node is NodePool-owned (induced via placeholder, §3.3).
spec.limits is a resource budget ({cpu, memory, …}), not a node count. The precondition is that the placeholder's requests fit within the NodePool's remaining budget (limits − currently-provisioned).
The controller pre-checks headroom before starting (§5.2 step 3, after candidate selection — because the placeholder's requests depend on the selected candidate). Skips with a warning if budget cannot fit one more node's worth.
Exposed on /metrics:
| Metric | Type | Labels |
|---|---|---|
noderotation_candidates |
Gauge | nodepool |
noderotation_in_progress |
Gauge | nodepool |
noderotation_completed_total |
Counter | nodepool, outcome |
noderotation_forceful_fallback_total |
Counter | nodepool |
noderotation_duration_seconds |
Histogram | nodepool, phase |
noderotation_window_active |
Gauge | nodepool |
noderotation_policy_conflict |
Gauge | nodepool |
noderotation_freeze_until_timestamp |
Gauge | nodepool |
noderotation_age_threshold_seconds |
Gauge | nodepool |
noderotation_rotation_chances |
Gauge | nodepool |
noderotation_throughput_capacity |
Gauge | nodepool |
noderotation_t_rot_estimate_seconds |
Gauge | nodepool |
noderotation_t_rot_bound_seconds |
Gauge | nodepool |
noderotation_window_period_seconds |
Gauge | nodepool |
noderotation_short_lead_nodes |
Gauge | nodepool |
noderotation_drain_stuck |
Gauge | nodepool |
noderotation_retry_count |
Gauge | nodepool |
::: details Metric details — click to expand
noderotation_candidates: eligible NodeClaim count per poolnoderotation_in_progress: active rotation count per poolnoderotation_completed_total: cumulative completions;outcome∈ {success,failure,expired}.expired= force-expired before graceful rotation completed (emitted once, never counted as success)noderotation_forceful_fallback_total: surge-less forceful-fallback rotations initiated (§3.6); incremented at start, not completionnoderotation_duration_seconds: per-phase;phase∈ {surge_wait,drain}.surge_wait=started-at → surge_ready;drain=draining-at → old-NodeClaim finalization. Observed at most once per successful transition (no double-count on retried writes; dropped sample preferred over phantom sample)noderotation_window_active: 0/1 window membership indicatornoderotation_policy_conflict: 0/1 blocked by selector tie or invalid policy (§5.4)noderotation_freeze_until_timestamp: Unix timestamp of active freeze (0 = none)noderotation_age_threshold_seconds: derivedageThreshold(§3.2)noderotation_rotation_chances: guaranteed chancesGnoderotation_throughput_capacity: layer-2 forecastC— starts per occurrence (§3.2)noderotation_t_rot_estimate_seconds: forecast service timet_rot_est = provisioningEstimate + drainEstimatenoderotation_t_rot_bound_seconds: deadline-side boundt_rot = readyTimeout + tGP + buffernoderotation_window_period_seconds: worst-case periodPnoderotation_short_lead_nodes: NodeClaims whose ownspec.expireAftercan no longer guaranteeKchances (§3.2 layer 3)noderotation_drain_stuck: 0/1 drain exceededtGP + buffer(§5.2)noderotation_retry_count: highestretry-countacross pool's NodeClaims (0 = none)
:::
With per-NodePool windows (each pool resolves its own RotationPolicy, §5.4), noderotation_window_period_seconds and noderotation_window_active carry a load-bearing nodepool label — P and membership can differ across pools.
expireAfter: Never: all derived gauges read0(derivation skipped)- No window occurrence (
P ≤ 0): bound/estimate are non-zero; onlythroughput_capacityis0
- Series cleared when the NodePool is deleted — gauges are recomputed each reconcile
- A pool losing its governing policy has series dropped the same way (§5.4)
Warning-level conditions surfaced via kubectl describe:
| Object | Reason | When |
|---|---|---|
| NodePool | KBelowTwo, AVeryAggressive, TGPUnset, HardCapExceeded, RetryBackoffShort, DrainEstimateAboveTGP, ProvisioningEstimateAboveReadyTimeout, ThroughputBelowArrival, ThroughputBurstShortfall, RotationSpansNextWindow, OverrideGBelowK |
Schedule finding active |
| NodeClaim | ShortLead |
Claim can't guarantee K chances |
| NodePool | ForcefulFallback |
Surge-less rotation begins |
| NodePool | StaticNodePool |
spec.replicas set — surge can never rotate the pool (§3.3) |
| NodePool | PolicyConflict |
Equal-specificity RotationPolicy tie — the pool is not rotated (§5.4) |
| NodePool | GovernanceLost |
In-flight rotation rolled back after the pool left governance (§5.4) |
| NodePool | RotationStarted |
Candidate picked (Normal) |
| NodePool | RotationCompleted |
Old NodeClaim finalized (Normal) |
| NodeClaim | RotationFailed |
readyTimeout expired; rolled back |
| NodeClaim | SurgeUnschedulable |
Placeholder PodScheduled=False |
| NodeClaim | SurgeClamped |
Placeholder clamped (Normal) |
| NodeClaim | SurgeClampBandExceeded |
Clamp shortfall > band (Warning) |
| NodeClaim | SurgeClampRefused |
DaemonSet exhausts allocatable (Warning) |
- Deduplication: emitted on transition into the condition; clears and re-fires on return
- Fatal findings are not events — they block rotation start and are logged by the §5.2 feasibility gate
Every state transition emits one INFO log line (after the durable annotation write):
| Line | Key fields |
|---|---|
rotation candidate selected |
nodeclaim, age, deadline, surgeless |
no rotation candidate |
reason, census counts |
surge placeholder created |
placeholder, requests, exclusion counts, clamp info |
surge placeholder is not schedulable |
placeholder, reason, detail |
surge node ready |
surgeNode, surgeWait |
drain started |
node, mode ∈ {surge, forceful-fallback} |
rotation attempt failed |
reason, readyTimeout, retryCount, backoffUntil |
rotation complete |
mode, drain, surgeNode, surgeWait, total |
- Level-triggered lines (
no rotation candidate,surge placeholder is not schedulable) use transition dedup — re-fire only when reason/census/message changes - Debug verbosity (
V(1)) adds un-deduplicated per-pass findings and a heartbeat - Liveness signal: read from
controller_runtime_reconcile_total/ workqueue metrics, not from log silence
| Alert | Condition |
|---|---|
| Failure/expired | `increase(noderotation_completed_total{outcome=~"failure |
| Falling behind | noderotation_candidates > 0 for two consecutive windows |
| Window wasted | window_active == 1 full window, zero completions, non-zero candidates |
| Drain stuck | noderotation_drain_stuck == 1 |
| Short lead | noderotation_short_lead_nodes > 0 |
| Systematic failure | noderotation_retry_count >= 3 |
| Forceful fallback | increase(noderotation_forceful_fallback_total[1h]) > 0 (severity: info) |
The Helm chart ships these as an optional PrometheusRule (gated behind prometheusRule.enabled, default false). See the production runbook for tuning.
- 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: nocreate— v1 never creates aNodeClaim(§3.3).update/patchcarry state annotations;deletedrives rotation and failure reapnodepools:update/patchfor the active-rotation anchor, state mirror, and completion annotationsnodes:update/patchfordo-not-disrupt/markers +spec.unschedulable(cordon)pods: the placeholder Pod is directly managedevents: Warning Events on NodePool/NodeClaim + leader election recordsleases: leader election
The placeholder's PriorityClass is installed statically by the Helm chart — no priorityclasses permission needed.
- v1: no direct cloud API calls. All operations route through
NodeClaimCRD. - v2 (pre-pull): Jobs run as pods on the new node, inheriting its role. No extra controller-level cloud permissions.
::: tip Key point
Each rotation creates ~10–20 minutes of overlap billing. Two mechanisms bound failure-driven cost: escalating retryBackoff and pool-level failurePause.
:::
Brief overlap: old + new nodes billed simultaneously during surge.
- Per rotation: ~10–20 minutes of one extra on-demand instance
- Monthly (weekly rotation, N nodes):
≈ N × 4 × hourly_rate × 0.25 - Peak overlap: scales with the number of NodePools rotating concurrently
A failed attempt can bill a surge node up to readyTimeout (after which it is reaped when still unoccupied; a repurposed node stays as normal capacity).
| Mechanism | Bounds |
|---|---|
Escalating retryBackoff |
Retries of the same claim |
Pool-level failurePause |
Candidate cycling under systematic failure |
- Without
failurePause: a systematic cause would move to the next candidate within ~1 minute, burning areadyTimeout-worth of billing per candidate - With
failurePause: at most one attempt perreadyTimeout + failurePause(~25m at defaults) failurePauseis separate fromcooldownAfter— lowering settle for throughput never weakens cost boundsnoderotation_retry_countalerts on the pattern (§4.2)