Tools Used in Kubernetes · Karpenter

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.

☺ Explain it like I'm 10

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.

🦥Your host for this topic: Sol the Sloth — the one on the Squad who refuses to eyeball a resource request and insists on the slow, correct arithmetic instead. Karpenter is a machine built entirely around that same instinct: never guess the shape of a node when you can compute the exact 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.

Cluster Autoscaler ASG · m5.large · fixed shape ASG · m5.2xlarge · fixed shape ASG · r5.xlarge · fixed shape Pending pod · 2 CPU / 3Gi grows nearest matching group New m5.2xlarge · 8 CPU total 6 CPU sits idle — wasted spend Karpenter Pending pod · 2 CPU / 3Gi bin-packs the ENTIRE catalogue New c6a.large · 2 CPU total ~0 CPU idle — exact fit Cluster Autoscaler grows the nearest pre-defined shape · Karpenter computes the cheapest shape that fits, from the whole catalogue, every time
◆ Key idea

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.

Pending Pods no existing node fit NodePool NodeClass the constraints 🦥 Karpenter 1 · bin-pack the batch 2 · pick type · zone · spot 3 · create NodeClaim 4 · disruption loop Cloud provider fleet API no node group in between Node joins · Pods bind typically 30–60 seconds Remove / replace when empty · underutilized drifted · expired repack / expire Brakes on disruption PDBs · do-not-disrupt · budgets Karpenter never binds a Pod itself — kube-scheduler still does that — it only changes what nodes exist to choose from

Three custom resources carry the whole model, and only two of them are yours to write.

Custom resourceWho writes itWhat it declares
NodePool (karpenter.sh/v1)YouCloud-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, …)YouCloud-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)KarpenterA 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 these

The 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 Pods

What 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.

ReasonTriggerBehavior
EmptyNode has no non-DaemonSet Pods left on itDeleted outright, after consolidateAfter
UnderutilizedThe Pods on it would fit onto fewer or cheaper nodesReplacement launched first, then the old node is drained and removed — capacity never dips
DriftedThe node no longer matches its NodePool or NodeClass — a new AMI, an edited requirement, a changed selectorReplaced so reality matches the declared spec again
ExpiredNode is older than expireAfterCordoned, 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.

⚠ Drift is a mass-replacement trigger

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.

🦥 Sol's-eye view

"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 -w
🦥 Sol's drill · 25 min

On 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.

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.

OptionModelBest whenCosts you
KarpenterGroupless, just-in-time provisioning from constraints, plus continuous consolidationVaried or bursty workloads on a well-supported cloud, where utilization and cost genuinely matter and workloads tolerate reschedulingCloud-specific NodeClass; real node churn; a high-blast-radius object in NodeClass edits; one more controller to run and upgrade
Cluster AutoscalerGrows and shrinks pre-defined, homogeneous node groupsUniform workloads, on-prem clusters, or any provider Karpenter doesn't support well — the boring, universally-understood optionNode-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 hoodYou want the outcome without operating the controller, and are comfortable inside one provider's opinionsLess control over the tuning surface; provider-defined limits; you inherit the provider's upgrade cadence
Fixed capacity, no autoscalerA static fleet sized for peak loadGenuinely flat traffic, strict placement rules, or on-prem hardware with nothing to scale intoYou 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.

🎬 At the Pod Squad
🦫

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.

🐢 Timmy's checkpoint

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
  1. 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.
  2. NodePool (cloud-agnostic constraints) and a provider-specific NodeClass such as EC2NodeClass (AMI, subnets, security groups, IAM role) are yours to write. NodeClaim is written by Karpenter itself — one per machine it requests, and the object to inspect when a launch fails.
  3. 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.
  4. Any two of: a PodDisruptionBudget whose minAvailable equals the replica count (no eviction is ever permitted); a very long terminationGracePeriodSeconds, which stalls every drain; karpenter.sh/do-not-disrupt applied broadly via a shared base template rather than deliberately on one workload.
  5. 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.
  6. 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.
  7. 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.