The Kubernetes Cluster Autoscaler works by scaling predefined node groups up or down. Karpenter throws that model out — it provisions exactly the right node, from scratch, in response to a specific unschedulable pod. That difference sounds small. In practice it changes both how fast your cluster scales and how much you pay for it.
The problem with node-group-based autoscaling
The Cluster Autoscaler operates on Auto Scaling Groups (or equivalent node pools). Each group is defined ahead of time with a fixed instance type, and the autoscaler simply adds or removes instances from whichever group matches an unschedulable pod's requirements. This means:
- You maintain multiple node groups per instance type/size combination you might need (a group for
m5.xlarge, another form5.2xlarge, another for GPU nodes, and so on). - A pod that needs slightly more CPU than any existing group provides either doesn't get scheduled efficiently, or forces you to pre-provision a bigger group "just in case."
- Scaling latency includes the Auto Scaling Group's own scaling activity, which typically takes minutes, not seconds.
- A working EKS cluster with
kubectlaccess and cluster-admin permissions. - Familiarity with Kubernetes scheduling concepts (requests, taints/tolerations, node affinity).
- An IAM OIDC provider already associated with the cluster (required for Karpenter's IAM role setup).
How Karpenter actually decides what to launch
Karpenter watches for pods stuck in Pending because nothing in the cluster satisfies their scheduling constraints. Instead of picking from a fixed menu of node groups, it looks at exactly what the pending pod(s) need — CPU, memory, architecture, GPU, zone — and asks the cloud provider's API directly for a matching instance type, right then.
This is why Karpenter is usually so much faster: it isn't waiting on an Auto Scaling Group's own scaling lifecycle. It calls the EC2 Fleet API directly to launch exactly the instance type the pending workload needs.
Setting up a NodePool and EC2NodeClass
Karpenter's configuration is two Kubernetes custom resources working together. The EC2NodeClass describes how nodes should be configured at the AWS level (AMI, subnets, security groups, IAM role). The NodePool describes what Karpenter is allowed to provision and under what constraints.
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
amiFamily: AL2023
role: "KarpenterNodeRole-my-cluster"
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "my-cluster"
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "my-cluster"apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
limits:
cpu: 1000
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30sThe subnet and security group karpenter.sh/discovery tags aren't cosmetic — Karpenter uses them at launch time to find where new nodes are allowed to live. Forgetting to tag your subnets is one of the most common reasons a fresh Karpenter install silently fails to provision anything.
Consolidation: the part that actually saves money
Provisioning the right node on demand is half the story. The other half is Karpenter continuously re-evaluating whether the current set of nodes is still the cheapest valid layout, and consolidating when it isn't.
consolidationPolicy: WhenEmptyOrUnderutilized tells Karpenter to actively look for opportunities to replace multiple underutilized nodes with fewer, better-packed ones, or terminate nodes that have gone empty — not just scale down when load drops, but actively repack. This is a meaningful behavioral difference from the Cluster Autoscaler, which only removes nodes that are already empty; it never proactively repacks a partially-utilized cluster onto fewer nodes.
Before consolidation: 4 nodes, each 30% utilized
After consolidation: 2 nodes, each ~60% utilized
Spot instances without the operational pain
Karpenter has first-class spot support built into the NodePool requirements shown above (capacity-type: spot). When AWS reclaims a spot instance, Karpenter receives the interruption notice, cordons and drains the node, and provisions a replacement — automatically, without a separate spot-handling controller like node-termination-handler (though many teams still run it for faster, more graceful draining on the 2-minute interruption warning).
Mixing spot and on-demand in the same NodePool (as in the example above) lets Karpenter default to whichever is available and cheapest for a given launch, while still falling back to on-demand under demand for capacity.
Setting real limits so Karpenter doesn't over-provision
limits.cpu in the NodePool spec is a hard ceiling on how much compute that pool can ever provision — without it, a runaway deployment or a misconfigured HPA can trigger unbounded node provisioning and an unbounded bill. Every production NodePool should have an explicit limit, sized to what you'd actually approve if someone asked for it manually.
- Set
limits.cpu(and optionallylimits.memory) on everyNodePool— an unbounded pool turns a bug in your workloads into an unbounded AWS bill. - Use multiple
NodePools to express real policy differences — a spot-only pool for fault-tolerant batch work, an on-demand-only pool for anything that can't tolerate interruption, gated by taints and tolerations. - Tag subnets and security groups with the exact
karpenter.sh/discoveryvalue yourEC2NodeClassselectors expect — this is the single most common first-install failure. - Start with
consolidateAfter: 30s–1mrather than disabling consolidation, then widen it if you see nodes churning too aggressively for genuinely bursty workloads.
Monitoring what Karpenter is actually doing
Karpenter emits Kubernetes events on the pods and nodes it acts on, which is the fastest way to understand a scaling decision without digging through controller logs:
kubectl get events --field-selector reason=Nominated -A
kubectl describe node <node-name> | grep -A5 "karpenter.sh"It also exposes Prometheus metrics on the controller's metrics endpoint — karpenter_nodes_created_total, karpenter_pods_state, and karpenter_nodeclaims_terminated_total are the ones worth wiring into a dashboard first. karpenter_nodeclaims_terminated_total broken down by reason is particularly useful for catching a consolidation loop early — nodes being created and terminated on a tight cycle usually points at a consolidateAfter value that's too aggressive for a genuinely bursty workload, not a bug in Karpenter itself.
For a slower-than-expected scale-up, the diagnostic order that actually finds the cause fastest:
- Check whether the pod is
Pendingfor a scheduling reason unrelated to capacity at all — an unsatisfiable node selector or a missing toleration produces a pod that will never schedule no matter how many nodes Karpenter launches. - Check
NodePoolrequirementsagainst what the pod actually needs — aNodePoolrestricted toarm64will never satisfy anamd64-only pod. - Check EC2 service quotas for the account/region — Karpenter can request an instance type successfully rejected by EC2 itself if a quota is exhausted, and that failure surfaces as an event on the
NodeClaim, not always somewhere obvious.
Interruption handling in more detail
When AWS reclaims a spot instance, it sends a two-minute interruption notice via the EC2 instance metadata service before actually terminating it. Karpenter's built-in interruption handling (enabled via an SQS queue configured on the controller) watches for this notice and immediately begins cordoning and draining the node — evicting pods so they reschedule elsewhere before the hard termination happens, rather than after.
apiVersion: karpenter.sh/v1
kind: NodePool
spec:
disruption:
expireAfter: 720h # force node replacement after 30 days regardlessexpireAfter is a separate mechanism from consolidation — it forces periodic node replacement even on a fully-utilized, otherwise-stable node, which matters for keeping AMIs and kernel patches current across a fleet that might otherwise never naturally churn.
Common mistakes
- Running Karpenter and the Cluster Autoscaler against the same node groups simultaneously. Both controllers try to make scaling decisions independently, and they can fight each other — migrate node groups to Karpenter-managed
NodePools deliberately, not incrementally on the same group. - Leaving
limits.cpuunset on aNodePool. A bug that spawns thousands of pods will happily be matched by thousands of new nodes unless there's an explicit ceiling. - Forgetting that consolidation actively terminates and replaces underutilized nodes — for workloads sensitive to any restart (some stateful sets, long-running batch jobs), that needs an explicit
do-not-disruptannotation on the pod, not just a hope Karpenter leaves it alone. - Assuming Karpenter handles pod disruption budgets automatically without a real PDB defined. Karpenter respects PodDisruptionBudgets during consolidation, but only if you've actually set one — without it, consolidation can terminate more replicas at once than your service can tolerate.
Karpenter vs. the Cluster Autoscaler, at a glance
| Aspect | Cluster Autoscaler | Karpenter |
|---|---|---|
| Provisioning unit | Scales predefined Auto Scaling Groups | Launches individual EC2 instances directly, no ASG required |
| Instance selection | Limited to whichever groups you pre-created | Picks from the full range of matching instance types at launch time |
| Typical scale-up latency | Several minutes (ASG scaling lifecycle) | Often under a minute |
| Bin-packing / consolidation | Removes empty nodes only | Actively repacks underutilized nodes onto fewer, cheaper ones |
| Spot handling | Requires separate tooling per node group | Native interruption handling built into the controller |
Is Karpenter worth adopting?
For any EKS cluster running variable or bursty workloads, the honest answer is close to yes by default now — AWS itself steers new EKS users toward Karpenter over the Cluster Autoscaler. The migration effort is real (rewriting node groups as NodePools, retagging subnets and security groups, testing consolidation behavior against your actual workloads before trusting it in production) but it's a one-time cost against an ongoing win in both scale-up latency and idle-capacity waste.
Related reading
- Kubernetes ConfigMaps and Secrets: A Practical Guide — shares tags: kubernetes, cloud (same category).
- How to Debug CrashLoopBackOff in Kubernetes — shares tags: kubernetes, devops.
- Understanding Cloud Cost Optimization Basics — shares tags: cloud, devops.
- Infrastructure as Code: Why Terraform Won — shares tags: cloud, devops.
- AWS vs. Azure vs. GCP: Choosing the Right Cloud for Your Project — shares tags: cloud.