Skip to content

Production runbook

Operational guidance for node-rotation-controller. Each section answers when does this apply, what to look at, and what to do.

For the design rationale, see the specification. Japanese translation: docs/ja/runbook.md.

Incident right now?

Jump to §7 Troubleshooting — a symptom-based index that maps what you see to what you do.


Contents

  1. Per-AZ surge headroom (zonal PV)
  2. Tuning throughput and tGP
  3. Metrics reference
  4. The freeze workflow
  5. Handling a stuck drain
  6. Alerting (PrometheusRule)
  7. Troubleshooting
  8. Upgrading and rolling back
  9. Sizing at scale

1. Per-AZ surge headroom (zonal PV)

When: your NodePool fronts workloads bound to zonal PersistentVolumes (EBS gp3/io2, or any PV with a topology.kubernetes.io/zone node affinity).

The constraint: the surge node is pinned to the candidate's AZ so the volume can re-attach. A same-AZ capacity shortage cannot fall back to another zone — the rotation rolls back after readyTimeout.

What to do:

  • Keep enough pool-wide spec.limits headroom for one additional node, and ensure the NodePool's requirements permit every in-use AZ. spec.limits cannot reserve capacity per AZ.
  • Separately confirm provider capacity in each AZ and enough regional EC2 vCPU quota for the surge.
  • Consider capacity reservations for the surge instance shape in each AZ.

How to detect a shortfall:

  • noderotation_completed_total{outcome="failure"} climbing
  • noderotation_retry_count >= 3 for the pool (alert: NodeRotationRetryCountHigh)

When that alert fires on a zonal-PV pool, suspect per-AZ capacity first.


2. Tuning throughput and tGP

Raising throughput (window capacity C)

When: ThroughputBelowArrival or ThroughputBurstShortfall warnings appear, or candidates don't clear within a window.

What controls throughput:

C = ceil(D / (provisioningEstimate + drainEstimate + cooldownAfter))
KnobWhat it isHow to set it
surge.provisioningEstimateExpected time for the surge node to reach ReadyRead from noderotation_duration_seconds{phase="surge_wait"}
surge.drainEstimateExpected time for a healthy drainRead from noderotation_duration_seconds{phase="drain"}
surge.cooldownAfterPause between consecutive rotationsMay be 0 if PDBs already serialize drains

What does NOT raise throughput: terminationGracePeriod. It no longer appears in C. Do not lower it for throughput warnings.

Choosing terminationGracePeriod

When: deciding how long Karpenter waits before force-killing pods on a draining node.

Pick it from: the downtime you can tolerate in an incident (a genuinely slow drain that hits the deadline), not from the drain times you observe in normal operation.

Reasons to lower tGP (none of them are throughput):

  • Lengthens ageThreshold → nodes rotate later, less churn
  • Relaxes the Auto Mode 21-day cap (expireAfter + tGP ≤ 21d)
  • Surfaces a stuck drain sooner (noderotation_drain_stuck fires at tGP + buffer)

For the full derivation, see spec §3.2.


3. Metrics reference

Exposed on /metrics. Full semantics in spec §4.2.

Per-NodePool series are cleared when the NodePool is deleted or loses its governing RotationPolicy.

Key operational metrics

MetricTypeWhat to watch for
noderotation_candidatesGaugeShould trend to 0 after each window. Stuck > 0 for two windows → falling behind
noderotation_in_progressGauge0 or 1 (serial per pool in v1)
noderotation_completed_total{outcome}Counteroutcome ∈ {success, failure, expired}. Any failure/expired → investigate
noderotation_forceful_fallback_totalCounterRising → graceful surges losing the race to deadlines
noderotation_duration_seconds{phase}Histogramphase ∈ {surge_wait, drain}. Use these to set your estimates
noderotation_drain_stuckGauge1 → operator action needed (§5)
noderotation_retry_countGauge≥ 3 → systematic failure (preemption or AZ shortage)
noderotation_short_lead_nodesGaugeNodes that can't get K chances before their own expiry

Schedule and policy metrics

MetricTypePurpose
noderotation_window_activeGauge0/1 per pool — is the window open now?
noderotation_window_period_secondsGaugeWorst-case gap P between windows (per pool)
noderotation_age_threshold_secondsGaugeDerived ageThreshold A (per pool)
noderotation_rotation_chancesGaugeGuaranteed chances G
noderotation_throughput_capacityGaugeForecast C — starts per window occurrence
noderotation_t_rot_estimate_secondsGauget_rot_est — expected healthy rotation time
noderotation_t_rot_bound_secondsGauget_rot — deadline-side bound (feeds lead time)
noderotation_freeze_until_timestampGaugeActive freeze timestamp (0 = none)
noderotation_policy_conflictGauge1 = pool blocked by a policy conflict

Judging liveness

The controller's warning logs are deduped — a healthy idle loop emits zero log lines. Do not treat log silence as a stall. Use:

  • controller_runtime_reconcile_total{controller="rotation"} — rising rate() = alive
  • workqueue_depth{name="rotation"} — should stay near 0

4. The freeze workflow

Purpose: suppress rotation for a NodePool during a business-critical period.

Set:

sh
kubectl annotate nodepool <name> \
  noderotation.io/freeze='2026-12-31T23:59:59Z' --overwrite

Lift:

sh
kubectl annotate nodepool <name> noderotation.io/freeze-

Behavior while frozen:

  • New rotations do not start
  • A pending rotation is held (drain hasn't begun, so it's safe to pause)
  • A draining rotation runs to completion (cannot abort a drain safely)
  • The expireAfter backstop still applies — freezing cannot make nodes live forever

Monitor: noderotation_freeze_until_timestamp{nodepool} (0 = no freeze).

Best practice: manage freezes via GitOps, not ad-hoc kubectl. A forgotten freeze silently disables graceful rotation until its timestamp passes.


5. Handling a stuck drain

Symptom: noderotation_drain_stuck == 1 (alert: NodeRotationDrainStuck).

What happened: the controller deleted the old NodeClaim, Karpenter is draining the node via the voluntary path, but the drain exceeds tGP + buffer.

Important: the stuck drain blocks all rotation for that pool on purpose (to respect maxUnavailable = 1). Other pools are unaffected.

Decision flow:

Commands:

sh
# Find the draining node
kubectl get nodeclaim -l karpenter.sh/nodepool=<pool> -o wide | grep -i terminating

# Pods still on it
kubectl get pods -A --field-selector spec.nodeName=<node> -o wide

# PDBs blocking eviction
kubectl get pdb -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,ALLOWED:.status.disruptionsAllowed

Do not delete the controller's annotations or placeholder to "unstick" it — the state machine is idempotent and will re-assert. Fix the underlying PDB or finalizer.


6. Alerting (PrometheusRule)

The Helm chart ships an optional PrometheusRule (off by default). Enable:

sh
helm upgrade --install node-rotation-controller charts/node-rotation-controller \
  --set prometheusRule.enabled=true
AlertFires whenAction
NodeRotationCompletedFailureOrExpiredA rotation failed or expired in the last hourCheck §1 (AZ capacity) and §5 (stuck drain)
NodeRotationCandidatesNotDrainingCandidates haven't cleared across two windowsCheck §2 (throughput)
NodeRotationStalledInWindowWindow open, candidates exist, zero completionsCheck §1 or §5
NodeRotationDrainStuckDrain exceeds tGP + bufferFollow §5
NodeRotationShortLeadNodesNodes can't get K chances before expiryRaise expireAfter or add windows
NodeRotationRetryCountHighSame rotation failing ≥ 3 timesSystematic cause — check §1
NodeRotationForcefulFallbackA rotation went surge-less (by design)Check §2 if rising; single occurrence is expected

Tune schedule-dependent ranges in your values:

  • prometheusRule.candidatesNotDraining.windowRange2·P (default 8d for {Wed, Sat})
  • prometheusRule.stalledInWindow.completionRange → window duration D (default 4h)

See values.yaml for all tunable fields.


7. Troubleshooting

Start from what you see, confirm with the signal, then jump to the fix.

SymptomSignalFix
Placeholder Pod stuck Pending, rotations failnoderotation_completed_total{outcome="failure"}; retry_count climbing§1 — same-AZ capacity
Candidates never clear across windowsNodeRotationCandidatesNotDraining alert§2 — throughput too low
ThroughputBurstShortfall warning every windowWarning Event on NodePool§2 — window too short for the batch
Drain hangs, no new completionsnoderotation_drain_stuck == 1§5 — PDB or finalizer
noderotation_in_progress stuck at 1NodeRotationStalledInWindowSurge not landing (→ §1) or drain stuck (→ §5)
NodePool never rotates, candidates accumulatenoderotation_policy_conflict == 1Fix the RotationPolicy selector overlap
NodePool stopped rotating quietlynoderotation_freeze_until_timestamp > 0Forgotten freeze — §4
Nodes reach expireAfter despite the controllernoderotation_completed_total{outcome="expired"}Lead time too tight — widen windows or lower tGP
noderotation_short_lead_nodes > 0ShortLead Warning EventRaise expireAfter on the NodePool or add window days
Rising forceful_fallback_totalForcefulFallback Warning EventExpected if throughput is tight; remediate via §2 if excessive
"Reconcile looks stalled" (no log output)Check controller_runtime_reconcile_total is risingNot a real problem — logs are deduped in steady state

8. Upgrading and rolling back

Upgrading the image

Safe mid-rotation. All state lives on Kubernetes objects — a rolling upgrade hands leadership to the new pod, which resumes any in-flight rotation from the active-rotation annotation. No external state, nothing lost in memory.

To quiesce first (optional): set a short freeze and wait for noderotation_in_progress to reach 0.

CRD schema changes

Helm does not upgrade CRDs. When upgrading into a release that adds a field, apply the CRD first:

sh
kubectl apply -f charts/node-rotation-controller/crds/
helm upgrade --install node-rotation-controller charts/node-rotation-controller ...
ReleaseSchema changeAction
v0.6.1NoneNone
v0.6.0surge.failurePause, surge.drainEstimate, surge.provisioningEstimate addedApply crds/ first
v0.5.0surge.forcefulFallback addedApply crds/ first
v0.4.0NoneNone
v0.3.0RotationPolicy CRD introducedFirst install

Behavioral change in v0.6.0

cooldownAfter no longer doubles as the post-failure pause. That is now surge.failurePause (defaults to max(10m, cooldownAfter)). If you had lowered cooldownAfter below 10m, the failure pause goes back up on upgrade. Set failurePause explicitly to keep your old value.

Values schema change in v0.6.0

The chart seals the rotationPolicies[].spec subtree — a typo that was silently dropped before now fails the upgrade. Dry-run first:

sh
helm template node-rotation-controller charts/node-rotation-controller -f your-values.yaml >/dev/null

Rolling back

Rolling the image back is safe (same on-object state, older controller resumes). Rolling back across a CRD change may cause the controller to treat the policy as invalid (noderotation_policy_conflict == 1). Rotation pauses for that pool; expireAfter backstop still applies. Fix by also reverting the RotationPolicy objects.


9. Sizing at scale

When: clusters with 10k+ Pods.

The controller caches every Pod in the cluster (it needs cross-namespace visibility to size the placeholder). Memory scales with Pod count:

PodsCache footprint (lower bound)
~1k~4 MB
~10k~37 MB
~50k~185 MB — budget 500 MB to 1 GB

CPU is not a concern — a 50k-Pod scan takes ~105 µs per call.

Size the Deployment's memory requests/limits accordingly. See the perf note for benchmark details.