Karpenter Blueprint: Balanced Consolidation Policy¶
Purpose¶
Karpenter's consolidationPolicy decides which nodes are candidates for consolidation. The default WhenEmptyOrUnderutilized is aggressive — any node that could be removed or replaced to reduce cost is fair game — which produces two behaviors customers commonly hit:
- Marginal replaces. A node running a couple of pods that could technically fit on a same-or-similar-family instance gets replaced. Savings are near zero; several pods get evicted for no real benefit.
- Consolidation timing pressure. To contain this, customers reach for scheduled
disruption.budgetswithnodes: 0windows during business hours, pausing consolidation until off-hours. That controls the when but not the what — off-hours consolidation still churns marginal moves.
The Balanced policy (Karpenter v1.14+) addresses the what: it scores every consolidation action and only proceeds when the estimated savings outweigh the pod disruption. Because it filters out the marginal actions that made scheduled budgets attractive, teams can be less restrictive with their budget windows once Balanced is in play.
The score is a ratio computed per action:
score = savings_fraction / disruption_fraction
savings_fraction = savings / nodepool_total_cost
disruption_fraction = disruption_cost / nodepool_total_disruption_cost
An action is approved when score >= 1/k. Balanced uses k = 2, so the effective approval threshold is 0.5 — the savings must cover at least half the disruption in fractional terms. Both sides are dimensionless, so the threshold is scale-invariant across cluster sizes. Two levers change per-pod disruption weight in the numerator:
- Pod priority. Higher-priority pods count as more disruptive, making their host less consolidation-worthy.
controller.kubernetes.io/pod-deletion-costannotation. Per-pod override on disruption weight.
Reference: Karpenter Balanced consolidation docs and the design RFC (see "Why k=2" for the choice of threshold).
Requirements¶
- An EKS cluster running self-managed Karpenter v1.14 or later. The repo's
cluster/terraform/template installs a compatible version. - The cluster's default
EC2NodeClassexists. The repo's cluster template creates one nameddefault, and this blueprint references it.
EKS Auto Mode is not currently supported. Auto Mode ships a bundled Karpenter build that is behind OSS v1.14, so the
NodePoolCRD in Auto Mode does not yet listBalancedas a validconsolidationPolicy. Applying this blueprint on Auto Mode fails withUnsupported value: "Balanced": supported values: "WhenEmpty", "WhenEmptyOrUnderutilized". Once Auto Mode's Karpenter reaches v1.14+, the same NodePool works on Auto Mode by swappingnodeClassRef.grouptoeks.amazonaws.com.
Deploy¶
kubectl apply -f balanced-consolidation.yaml
This creates a NodePool named balanced-consolidation that references the cluster's default EC2NodeClass. No separate NodeClass is created — the blueprint adds only what's specific to it (a NodePool + workloads), leaving default free for other blueprints or workloads.
The NodePool pins to on-demand, generation-3+, and excludes small instance sizes (nano/micro/small/medium/large) so instance-type transitions during consolidation are visible in kubectl get nodes. Categories are limited to c/m/r.
Apply the sample workload — three deployments (baseline, low-priority, high-priority) and two PriorityClasses:
kubectl apply -f workload.yaml
All deployments start at replicas: 0 and set nodeSelector: blueprint=balanced-consolidation so they land only on this blueprint's NodePool.
Walkthrough¶
The rest of this document runs the same scale sequence twice — once under WhenEmptyOrUnderutilized, once under Balanced — and observes what Karpenter does at each step. The sequence is designed so that instance-type changes are visible: watch kubectl get nodes between steps.
Start the NodePool on WhenEmptyOrUnderutilized so we have a baseline:
kubectl patch nodepool balanced-consolidation --type=merge \
-p '{"spec":{"disruption":{"consolidationPolicy":"WhenEmptyOrUnderutilized"}}}'
Open a second terminal to watch node changes:
kubectl get nodes -l karpenter.sh/nodepool=balanced-consolidation \
-L node.kubernetes.io/instance-type,karpenter.sh/capacity-type -w
Step 1 — Scale up: 0 → 5¶
kubectl scale deployment balanced-inflate --replicas=5
Each pod requests 1 vCPU and 1Gi memory, so 5 pods need ~5 vCPU. Karpenter provisions a single instance that fits — under the c/m/r filter with small sizes excluded, the cheapest option that holds 5 vCPU worth of pods is a 2xlarge (8 vCPU).
Expected (from a real run on us-west-2):
NAME STATUS INSTANCE-TYPE CAPACITY-TYPE
ip-10-1-7-254.us-west-2.compute.internal Ready c6a.2xlarge on-demand
Karpenter emits a chain of NodeClaim events you can follow — Nominated → Launched → Registered → Initialized → Ready:
kubectl describe nodeclaim -l karpenter.sh/nodepool=balanced-consolidation | grep -A20 Events
Step 2 — Scale up further: 5 → 10¶
kubectl scale deployment balanced-inflate --replicas=10
Now 10 vCPU worth of pods. The existing 2xlarge (8 vCPU) can't hold them all, so Karpenter adds a second node rather than replacing the existing one.
Expected:
NAME STATUS INSTANCE-TYPE
ip-10-1-29-19.us-west-2.compute.internal Ready c6a.xlarge <- new
ip-10-1-7-254.us-west-2.compute.internal Ready c6a.2xlarge
Pod placement should show 7 pods on the 2xlarge and 3 on the new xlarge:
kubectl get pods -l app=balanced-inflate -o wide \
| awk 'NR>1 {print $7}' | sort | uniq -c
Step 3 — Scale down under WhenEmptyOrUnderutilized: 10 → 3¶
kubectl scale deployment balanced-inflate --replicas=3
Under WhenEmptyOrUnderutilized, Karpenter sees the 2xlarge is now oversized for the remaining pods. It launches a new xlarge, drains pods onto it (and the existing xlarge), then removes the empty 2xlarge. The replace happens automatically — no human action needed.
Wait about 2 minutes and watch:
kubectl get nodes -l karpenter.sh/nodepool=balanced-consolidation -L node.kubernetes.io/instance-type
Expected (both nodes are now xlarge; the 2xlarge is gone):
NAME STATUS INSTANCE-TYPE
ip-10-1-24-42.us-west-2.compute.internal Ready c6a.xlarge <- new, from the replace
ip-10-1-29-19.us-west-2.compute.internal Ready c6a.xlarge
Trace what happened via the disruption events:
kubectl get events --sort-by=.lastTimestamp \
| grep -iE "disrupting|nominated|launched|registered|ready.*NodeClaim"
You'll see the new NodeClaim come up (Launched → Ready) and then the old 2xlarge NodeClaim emit DisruptionTerminating: Disrupting NodeClaim: Empty once its pods drained onto the new capacity.
Step 4 — Scale to zero: 3 → 0¶
kubectl scale deployment balanced-inflate --replicas=0
Both nodes become empty. Empty-node consolidation is a fast path in Karpenter and fires under any consolidationPolicy (including Balanced). After ~60 seconds:
Expected:
No resources found # no nodes tagged with karpenter.sh/nodepool=balanced-consolidation
Events:
kubectl get events --field-selector reason=DisruptionTerminating --sort-by=.lastTimestamp
Should show both NodeClaims with Disrupting NodeClaim: Empty.
Step 5 — Switch to Balanced and repeat the same sequence¶
kubectl patch nodepool balanced-consolidation --type=merge \
-p '{"spec":{"disruption":{"consolidationPolicy":"Balanced"}}}'
Now re-run steps 1–4 exactly the same way:
kubectl scale deployment balanced-inflate --replicas=5 # -> 1x c6a.2xlarge
# wait, verify
kubectl scale deployment balanced-inflate --replicas=10 # -> +1x c6a.xlarge (2 nodes)
# wait, verify
kubectl scale deployment balanced-inflate --replicas=3 # -> replace 2xlarge with xlarge -> 2x c6a.xlarge
# wait, verify
kubectl scale deployment balanced-inflate --replicas=0 # -> both nodes removed (empty)
Under Balanced you should see the identical node states at each step. Balanced doesn't break beneficial consolidation — the replace in step 3 has real cost savings and passes the score gate (score >= 0.5), so it's approved just like it was under WhenEmptyOrUnderutilized.
Where Balanced diverges is on marginal moves — cases where the replace would produce near-zero savings for meaningful pod eviction. Those get rejected with score < 0.5. In a small demo cluster with a simple workload, it's hard to construct such a case deterministically (the 2xlarge → xlarge savings in this walkthrough are substantial, so it always passes), but Karpenter emits an Unconsolidatable: Can't replace with a cheaper node event once the pool has reached its cheapest feasible shape:
kubectl get events --field-selector reason=Unconsolidatable --sort-by=.lastTimestamp
That event tells you Karpenter's feasibility check ran but couldn't find a cheaper replacement — under Balanced this is the point where the score gate has settled the pool into a stable low-cost configuration.
Step 6 — Priority-weighted disruption¶
The clearest observable difference Balanced brings over WhenEmptyOrUnderutilized is that pod priority now feeds into consolidation ranking. When Karpenter has to pick which of several similar nodes to consolidate, Balanced prefers to disrupt nodes hosting lower-priority pods.
The two priority deployments (balanced-inflate-lowpri, balanced-inflate-highpri) carry mutual podAntiAffinity on topologyKey: kubernetes.io/hostname, so each tier lands on its own node. That makes the outcome deterministic: we can point at "the low-pri node" and "the high-pri node" without reasoning about pod packing on a shared instance.
Ensure the pool is on Balanced and the baseline deployment is at 0. Scale up both priority-classed deployments:
kubectl scale deployment balanced-inflate-highpri --replicas=3
kubectl scale deployment balanced-inflate-lowpri --replicas=3
The pods carry:
# balanced-inflate-highpri
spec:
template:
spec:
priorityClassName: balanced-blueprint-high # value 1000
nodeSelector: {blueprint: balanced-consolidation}
containers:
- name: inflate
image: public.ecr.aws/eks-distro/kubernetes/pause:3.7
resources: {requests: {cpu: "1", memory: "1Gi"}}
# balanced-inflate-lowpri — identical shape, priorityClassName: balanced-blueprint-low (value 100)
Karpenter provisions two xlarge nodes — one holding the high-priority pods, one the low-priority pods. Verify placement:
kubectl get pods -l blueprint=balanced-consolidation \
-o custom-columns=NAME:.metadata.name,PRIORITY:.spec.priorityClassName,NODE:.spec.nodeName
Now scale the low-priority deployment down:
kubectl scale deployment balanced-inflate-lowpri --replicas=0
Because Balanced weights disruption by pod priority, the low-priority node's disruption_fraction is smaller — its consolidation score clears the threshold and it's removed. The high-priority node stays. If the pool were on WhenEmptyOrUnderutilized, the outcome is the same in this case (the low-pri node became empty). The differentiator becomes visible when both nodes are underutilized but not empty; Balanced picks the low-priority host to consolidate first, WhenEmptyOrUnderutilized picks by scheduling simulation alone.
Automated verification¶
test.sh runs the four scenarios from this walkthrough end-to-end and asserts on specific NodeClaim identities (not just counts or cluster-wide events), so unrelated activity elsewhere in the cluster can't make it pass or fail:
- Test 1 — Provisioning + empty-node consolidation (Steps 1 and 4). Scales the baseline to 5 then back to 0; asserts a NodeClaim appears then disappears.
- Test 2 — Replace transition (Steps 1–3). Scales 5 → 10 → 3; captures the original NodeClaim's name after step 1, then asserts that specific name is gone from the pool after Balanced replaces the oversized node.
- Test 3 — Priority-weighted consolidation (Step 6). Scales high-pri first, records its NodeClaim, then scales low-pri; asserts the low-pri NodeClaim (identified by which node hosts the low-pri pods) is the one consolidated when low-pri drops to 0.
- Test 4 — Score gate / feasibility with a PDB. Provisions a NodeClaim, pins its pods with
minAvailable: 100%, and looks forUnconsolidatable/ConsolidationRejected/DisruptionBlockedevents against that specific NodeClaim via--field-selector involvedObject.name=<claim>.
Run it against a cluster that meets the Requirements:
./test.sh
Cleanup¶
kubectl delete -f workload.yaml
kubectl delete -f balanced-consolidation.yaml
Takeaways¶
- Same beneficial consolidation. For clearly-beneficial actions (empty-node deletes, replaces with substantial savings), Balanced approves them at the same score threshold and produces the same node states as
WhenEmptyOrUnderutilized. - Score gate for marginal moves. The score gate protects against churny replaces where savings barely cover disruption. In this demo the sequence's actions clear the gate; on real workloads with more nodes and tighter cost ratios, the gate rejects moves that
WhenEmptyOrUnderutilizedwould have taken. - Priority is now a first-class signal.
PriorityClassvalues you probably already use for scheduling now also protect their pods' host nodes from being picked first when consolidation has multiple candidates. - Budget windows can relax. If you had scheduled
disruption.budgetswithnodes: 0windows to contain churn, Balanced removes much of the reason to. Windows still matter for high-blast-radius operations (drift, expiration), but consolidation-driven churn is now bounded by the score gate.
References¶
- Karpenter release notes: v1.14.0
- Karpenter disruption docs: karpenter.sh/docs/concepts/disruption
- Balanced consolidation RFC: designs/balanced-consolidation.md
- Consolidation lab in the AWS workshop: Karpenter consolidation