Karpenter
Karpenter is a node autoscaler that answers a question the Cluster Autoscaler never asks: not "which of my pre-built node groups should I grow," but "what is the single cheapest machine, chosen from the entire cloud catalogue, that fits the Pods stuck Pending right now?" It watches for unschedulable Pods, works out an exact-fit instance in seconds, and calls the cloud provider's API directly to launch it — no Auto Scaling Group or VM scale set standing in between. Then it keeps working after the Pods land: a continuous consolidation loop keeps asking whether the cluster's existing Pods would fit on fewer or cheaper nodes, and repacks them when they would. This page covers what changes when you adopt that model: the philosophy shift itself, the architecture behind it, the NodePool and NodeClass objects you write by hand, the four reasons Karpenter will ever touch a running node, day-to-day commands, the gotchas that bite in production, and how it stacks up against the Cluster Autoscaler and the alternatives around it.
Imagine you need extra storage for a pile of boxes. The old way is to pre-rent three fixed-size lockers — small, medium, large — and guess which one to use, paying rent on all three whether they're full or not, and if your pile doesn't match any locker size exactly, you overpay for the leftover empty space. Karpenter is a different kind of storage clerk: it actually looks at your boxes, walks to a warehouse that stocks every locker size that exists, and rents you the one locker that's the closest possible fit — nothing pre-reserved, nothing guessed. And it doesn't stop there — every so often it checks your current pile again, and if you've taken enough boxes out that a smaller, cheaper locker would now hold everything, it quietly moves you into that one and hands back the big one.
The philosophy shift: pre-provisioned groups vs. just-in-time capacity
☺ Like you're 10: One approach keeps a shelf of ready-made box sizes and picks the closest; the other custom-orders the exact size every single time.
Autoscaling: HPA, VPA & Cluster Autoscaler and the Cluster Autoscaler tool guide cover the older model in depth, so only the shape of it belongs here: the Cluster Autoscaler scales node groups — an AWS Auto Scaling Group, a GCP managed instance group, an Azure VM scale set — each one homogeneous by construction, one instance type per group. To cover a real workload mix, platform teams end up hand-maintaining a small zoo of groups (on-demand-small, spot-compute, spot-memory, arm64-general, one set per zone, times every environment), and because the autoscaler can only grow whichever group's shape happens to fit, a Pod asking for 2 CPU and 3 GiB routinely lands a node with a dozen spare cores nobody will ever use.
Karpenter, donated to the Kubernetes project by AWS and now a provider-neutral core under kubernetes-sigs/karpenter with separate cloud providers layered on top (AWS is the most mature; Azure's provider is what powers AKS Node Auto Provisioning), removes the intermediate group entirely. You declare constraints — a NodePool — and for each batch of unschedulable Pods, Karpenter evaluates the whole permitted instance-type catalogue and launches whichever specific machine (or small set of machines) fits that batch most cheaply. It reached a stable v1 API in 2024.
Karpenter turns node provisioning from "pick from a list I built in advance" into "solve for the cheapest fit, right now." Every capability further down this page — consolidation, spot-first NodePools, drift replacement — falls out of that one inversion. It also explains the cost of admission: because the answer can legitimately change on every decision, your workloads have to tolerate occasionally being moved.
Karpenter deliberately stays out of two neighboring jobs. It does not decide how many Pods you need — that's the HPA and VPA's territory, both covered on the autoscaling deep-dive — Karpenter only reacts once those decisions leave a Pod stuck Pending. And it does not bind Pods to nodes either; kube-scheduler still does that. Karpenter's entire job is changing what capacity exists for the scheduler to choose from.
Architecture: one controller, two loops
☺ Like you're 10: One program runs two errands forever — "does anyone need a locker?" and "can I return a locker nobody's using anymore?"
Karpenter runs as a Deployment — conventionally in kube-system, two or three replicas with leader election — watching Pods, Nodes, and its own custom resources, and talking directly to the cloud provider's fleet API. There's a genuine chicken-and-egg problem worth knowing up front: Karpenter cannot provision the node it itself runs on, so it normally sits on a small, statically-sized node group (or on Fargate, where that's available) that exists outside anything Karpenter manages.
Three custom resources carry the whole model, and only two of them are yours to write.
| Custom resource | Who writes it | What it declares |
|---|---|---|
NodePool (karpenter.sh/v1) | You | Cloud-agnostic constraints — allowed instance families, sizes, architectures, zones and capacity types; labels and taints to stamp on new nodes; a total limits ceiling; the disruption policy and budgets; a reference to a NodeClass |
NodeClass (provider-specific — EC2NodeClass, AKSNodeClass, …) | You | Cloud-specific details — AMI selection, subnet and security-group selectors, the node's IAM role or identity, user data, disk layout, metadata options |
NodeClaim (karpenter.sh/v1) | Karpenter | A request for one specific machine, and the audit trail for it — what was asked for, what instance was actually chosen, and why a launch failed if it did. Deleting one terminates the node it represents |
The split is deliberate: a NodePool reads the same way on every cloud, while a NodeClass is where the provider-specific mess is quarantined — swap the cloud, swap the NodeClass, keep the NodePool. And because a NodeClaim is a real, inspectable object rather than an opaque cloud-console entry, debugging a failed launch means kubectl describe, not a support ticket.
NodePool and NodeClass: the objects you actually write
☺ Like you're 10: One file says "here's what kinds of computers you're allowed to rent," a second says "and here's exactly how to set one up on this particular cloud."
Treat a NodePool's requirements as a fence, not a preference — they say what Karpenter may choose, and inside that fence it always picks the cheapest fit. Excluding tiny sizes and old instance generations is standard practice: a nano or micro node wastes most of its capacity on DaemonSets alone, and older generations are almost always worse price-for-performance than their replacement.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general-purpose
spec:
weight: 10 # higher weight wins when several NodePools could serve a Pod
limits:
cpu: "1000" # hard ceiling for this pool — your blast radius on cost
memory: 4000Gi
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized # the setting that actually saves money
consolidateAfter: 1m # wait this long before acting on a consolidation opportunity
budgets:
- nodes: "10%" # never disrupt more than 10% of this pool at once
- nodes: "0" # ...and nothing at all during the weekly deploy window
schedule: "0 14 * * tue"
duration: 2h
reasons: [Drifted, Underutilized]
template:
metadata:
labels:
team.example.com/pool: general
spec:
expireAfter: 720h # rotate every 30 days so nodes keep getting patched
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"] # spot preferred; on-demand is the fallback
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["4"] # nothing older than generation 5
- key: karpenter.k8s.aws/instance-size
operator: NotIn
values: ["nano", "micro", "small"] # DaemonSets alone would eat theseThe NodeClass is where the AWS-specific (or Azure-, or GCP-specific) plumbing lives — three selectors do the heavy lifting, and all three are the usual cause of a launch that never happens.
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
role: "KarpenterNodeRole-example" # node IAM role; must already be mapped into the cluster
amiSelectorTerms:
- alias: al2023@latest # pin a published version to opt out of automatic drift
subnetSelectorTerms:
- tags: { karpenter.sh/discovery: "example-prod" } # subnets MUST carry this tag
securityGroupSelectorTerms:
- tags: { karpenter.sh/discovery: "example-prod" }
metadataOptions:
httpTokens: required # IMDSv2 only
httpPutResponseHopLimit: 1 # keeps the instance metadata service out of reach of PodsWhat your own workloads write is smaller: nothing about capacity, only how much disruption they can tolerate. A PodDisruptionBudget is the brake Karpenter always honors during consolidation, and karpenter.sh/do-not-disrupt: "true" on a Pod's template stops Karpenter from voluntarily touching the node it's on at all — reach for it on a singleton job or a long migration, never as a blanket default, because a fleet where every Pod opts out is a fleet that has quietly stopped consolidating.
Consolidation and the four disruption reasons
☺ Like you're 10: Karpenter never touches a running node for no reason — it always has exactly one of four named excuses, and each behaves a little differently.
| Reason | Trigger | Behavior |
|---|---|---|
| Empty | Node has no non-DaemonSet Pods left on it | Deleted outright, after consolidateAfter |
| Underutilized | The Pods on it would fit onto fewer or cheaper nodes | Replacement launched first, then the old node is drained and removed — capacity never dips |
| Drifted | The node no longer matches its NodePool or NodeClass — a new AMI, an edited requirement, a changed selector | Replaced so reality matches the declared spec again |
| Expired | Node is older than expireAfter | Cordoned, drained, and removed — forced rotation for patching hygiene |
Alongside those four sits interruption handling: given a stream of provider events (on AWS, typically an SQS queue fed by EventBridge), Karpenter receives a spot two-minute warning, a rebalance recommendation, or a scheduled-maintenance notice, and proactively cordons and drains the doomed node before the machine actually disappears — without that plumbing, a spot reclaim is an abrupt kill instead of a graceful one.
Change one field on a NodeClass — a new AMI alias, an added security group, a tweak to user data — and every node Karpenter manages becomes drifted, and every one of them gets rolled. That's the correct behavior, and it's how a fleet actually gets patched, but it means an innocuous-looking pull request can end up replacing the whole cluster. Always pair a NodeClass change with a tight disruption budget and review it the way you'd review any other GitOps change to production, not as a routine tag bump.
"Everyone gets excited about the thirty-second provisioning number and stops there. That's not where the money is. The money is in the boring second loop — the one that runs while nothing looks broken. A cluster that grows fast but never consolidates properly is a cluster that ratchets: every traffic spike leaves behind one more half-empty node, and by the end of the quarter you're paying for the busiest hour you ever had, permanently. So when I evaluate whether consolidation is actually working, I don't time how fast a node appears. I measure the gap between what Pods request and what they actually use, over a full month, before and after. That gap is the real number."
Day-to-day commands
☺ Like you're 10: Mostly you're asking "what did it decide, and why?" — and the honest answer is always in one log stream and one list of NodeClaims.
# what exists, and what Karpenter actually chose
$ kubectl get nodepool
$ kubectl get nodeclaim -o wide # the real audit trail: type, zone, capacity, node
$ kubectl describe nodeclaim <name> # launch errors live in conditions and events
$ kubectl get nodes -L karpenter.sh/nodepool,karpenter.sh/capacity-type,node.kubernetes.io/instance-type
# is a NodePool near its cost ceiling?
$ kubectl get nodepool general-purpose -o jsonpath='{.status.resources}{"\n"}'
# read the decisions — the controller log states in plain text what it considered and why
$ kubectl -n kube-system logs deploy/karpenter -f
$ kubectl -n kube-system logs deploy/karpenter | grep -i 'launched\|disrupt\|drift'
# why is this Pod still Pending? Karpenter writes events onto the Pod itself
$ kubectl describe pod <pod> | grep -A5 Events
$ kubectl get events --field-selector reason=FailedScheduling -A
# the correct way to force a node out — drains, respects PDBs, then terminates the instance
$ kubectl delete nodeclaim <name> # never terminate it from the cloud console
# prove a NodePool actually works
$ kubectl create deploy inflate --image=registry.k8s.io/pause:3.9
$ kubectl set resources deploy inflate --requests=cpu=1
$ kubectl scale deploy inflate --replicas=12 && kubectl get nodeclaim -wOn a scratch cluster, install Karpenter and apply the general-purpose NodePool and default EC2NodeClass above. Deploy the inflate Deployment from the last command block, scale it to 12, and watch kubectl get nodeclaim -w while tailing the controller log — read the exact sentence explaining which instance type it picked and why. Now scale inflate back to 1 and time how long the now-empty nodes take to disappear: that's consolidateAfter plus a drain. Then add a PodDisruptionBudget with minAvailable equal to the replica count and scale down again — consolidation stalls, and the log names the exact Pod that blocked it. Finally, edit one harmless field on the EC2NodeClass (add a tag) and watch every node go Drifted and roll. That last thirty seconds of alarm is the most useful part of the exercise.
Gotchas and failure modes
☺ Like you're 10: Most Karpenter surprises aren't bugs — they're the fence you drew, or forgot to draw, doing exactly what it says.
- Node sizing follows
requests, never usage. A fleet of Pods requesting four cores and actually using half of one will make Karpenter faithfully buy an enormous, expensive, idle cluster. Right-sizing requests — the subject of Scheduling & Resource Management — is a prerequisite for Karpenter saving you anything, not an optional follow-up; Cost & FinOps on Kubernetes covers exactly this gap. - An unsatisfiable PDB, a long
terminationGracePeriodSeconds, or a blanketdo-not-disruptsilently stops consolidation. AminAvailableequal to the replica count means no eviction is ever permitted; a workload with a one-hour grace period turns every repack into an hour-long stall; anddo-not-disruptcopy-pasted into a shared base template propagates to every Deployment built from it. The controller log names the blocking Pod every time — read it before assuming Karpenter is broken. - Missing discovery tags make a whole zone invisible. Subnet and security-group selectors match on tags, not on names — a new subnet added without the
karpenter.sh/discoverytag is simply never considered, and Karpenter keeps packing the zones it can see while one sits permanently empty. - Over-broad requirements pick surprising instances. A NodePool that permits every family and every size is "maximum flexibility" on paper and a trap in practice — Karpenter optimizes purely for price-per-fit, so it will happily launch a burstable instance whose CPU credits run out mid-load, or a network-optimized shape your CNI has never been tested against. Fence the categories, exclude the sizes you don't want, and keep specialized hardware in its own tainted NodePool.
- It cannot bootstrap itself. Karpenter can't provision the node its own controller runs on — that node has to come from somewhere static and outside its management, or the very first failure mode you hit is Karpenter itself stuck
Pending.
Karpenter vs. the alternatives
☺ Like you're 10: A few other ways exist to make sure there's always a computer waiting — some simpler, some that hand the whole job to someone else entirely.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Karpenter | Groupless, just-in-time provisioning from constraints, plus continuous consolidation | Varied or bursty workloads on a well-supported cloud, where utilization and cost genuinely matter and workloads tolerate rescheduling | Cloud-specific NodeClass; real node churn; a high-blast-radius object in NodeClass edits; one more controller to run and upgrade |
| Cluster Autoscaler | Grows and shrinks pre-defined, homogeneous node groups | Uniform workloads, on-prem clusters, or any provider Karpenter doesn't support well — the boring, universally-understood option | Node-group sprawl to maintain by hand; minutes rather than seconds to provision; poor bin-packing; no cheaper-instance swaps |
| Managed node auto-provisioning (GKE Node Auto-Provisioning, AKS Node Auto Provisioning) | The cloud runs a Karpenter-like autoscaler for you — AKS's version is Karpenter under the hood | You want the outcome without operating the controller, and are comfortable inside one provider's opinions | Less control over the tuning surface; provider-defined limits; you inherit the provider's upgrade cadence |
| Fixed capacity, no autoscaler | A static fleet sized for peak load | Genuinely flat traffic, strict placement rules, or on-prem hardware with nothing to scale into | You pay for peak capacity every hour of every day; a real spike still leaves Pods Pending |
A practical rule: if your cluster runs on AWS or AKS, your workloads are mostly stateless, and anyone has ever questioned the compute bill, Karpenter is the reasonable default — start with a spot-first pool and a conservative on-demand pool, fence the requirements tightly, set limits, and only then turn on WhenEmptyOrUnderutilized. If you're on-prem or on a provider Karpenter doesn't support well, the Cluster Autoscaler remains a perfectly respectable choice. Karpenter itself doesn't sit on the core CKA Workloads & Scheduling blueprint, but the concepts underneath it — Pending Pods, bin-packing, spot capacity, request-driven sizing — do; and if you're studying toward Platform Engineering's own certification track, its Karpenter page goes to CNPE implementation depth this page deliberately stops short of, with the wider ladder of certifications mapped on the sibling Golden Astronaut course.
Benny the Beaver: NodePool's applied, EC2NodeClass looks right, and I scaled the batch job to fifty replicas ten minutes ago. Still Pending. Nothing in the events.
Sol the Sloth: Slow down and check the arithmetic instead of the vibes. What does the Pod actually request, and what's the largest instance size your requirements permit?
Benny the Beaver: ...400 GiB of memory. My NodePool caps instance-size at xlarge. That never fits anywhere.
Gizmo the Gremlin: Easy — delete the requirements entirely. Let it pick from literally anything. Maximum flexibility! 🤑
Timmy the Turtle: And on Tuesday it launches a bare-metal instance because spot happened to be cheap that hour. "Maximum flexibility" is only safe inside a fence you actually drew.
Sol the Sloth: Widen the size ceiling on this one NodePool instead — deliberately, for exactly this workload. Leave the general pool's fence exactly where it was.
Recon the Robot: Once it launches, I'll keep reconciling the NodeClaim against the NodePool same as everything else — no special case needed on my end.
Ellie the Elephant: Logging the instance type it picked and the minutes it sat Pending first. Next time someone asks why the batch pool has its own NodePool, the record answers it.
1. In one sentence, what question does Karpenter ask that the Cluster Autoscaler's node-group model never asks? 2. Name the three custom resources involved and say which one Karpenter writes rather than you. 3. List the four disruption reasons, and say which one is triggered by editing a NodeClass. 4. Name two things that can silently stop consolidation from ever happening. 5. Karpenter decides how big a node to launch from which field on a Pod — and why does that make right-sizing requests a prerequisite rather than a nice-to-have? 6. Why can't Karpenter provision the very first node its own controller runs on? 7. If your cluster runs on-prem, is Karpenter still the right choice — why or why not?
Check your answers
- Not "which pre-built node group should grow," but "what is the single cheapest instance, from the entire cloud catalogue, that fits the Pods stuck Pending right now" — a decision made fresh per batch rather than chosen from a pre-built shelf of shapes.
NodePool(cloud-agnostic constraints) and a provider-specificNodeClasssuch asEC2NodeClass(AMI, subnets, security groups, IAM role) are yours to write.NodeClaimis written by Karpenter itself — one per machine it requests, and the object to inspect when a launch fails.- Empty, Underutilized, Drifted, Expired. Editing a NodeClass (a new AMI, a changed selector, an edited tag) triggers Drifted, and rolls every node that NodeClass touches.
- Any two of: a PodDisruptionBudget whose
minAvailableequals the replica count (no eviction is ever permitted); a very longterminationGracePeriodSeconds, which stalls every drain;karpenter.sh/do-not-disruptapplied broadly via a shared base template rather than deliberately on one workload. - From the Pod's resource
requests, never from observed usage — so a fleet of Pods that over-request makes Karpenter efficiently buy a large, expensive, idle cluster. Right-sizing requests has to come first, or consolidation is just optimizing waste. - Because Karpenter's controller is itself a workload that needs somewhere to run before it can start watching for Pending Pods and calling the cloud API — it normally sits on a small, statically-sized node group (or Fargate) that exists entirely outside anything Karpenter manages.
- Generally no, or at least not directly — Karpenter needs a cloud provider implementation that can launch and terminate individual instances on demand, which is why the Cluster Autoscaler's node-group model remains the standard, well-supported choice for on-prem and for providers Karpenter doesn't cover well.