On this page
For years, Cluster Autoscaler was the default way to scale nodes on Kubernetes, and on EKS it works well enough. But it scales node groups, not nodes, and that one design decision shapes everything: how fast you scale, which instance types you can use, and how much capacity sits idle.
Over the past year we moved several client clusters from Cluster Autoscaler to Karpenter. This post is a write-up of one of those migrations, for a Series B e-commerce platform running about 60 services on EKS with a spiky, sale-driven traffic pattern. We cover how the two tools differ, the configuration we ended up with on Karpenter v1, the numbers before and after, and what went wrong along the way.
How the two tools think
Cluster Autoscaler watches for pods that cannot be scheduled, then picks a node group (an EC2 Auto Scaling group behind an EKS managed node group) that could fit them, and increases its desired count. Each node group has a fixed instance type or a small set of similar types. To support a new shape of workload, you create a new node group. Scale-down happens when a node has been underused for a period (10 minutes by default) and its pods can fit elsewhere.
Karpenter skips node groups entirely. It looks at the pending pods' requirements (CPU, memory, architecture, zone, capacity type, tolerations, topology spread) and calls the EC2 Fleet API directly to launch whichever instance fits best from the set you allow. It also runs consolidation continuously: it looks for nodes that can be removed or replaced with something cheaper, and does so while respecting PodDisruptionBudgets.
| Cluster Autoscaler | Karpenter | |
|---|---|---|
| Unit of scaling | Node group (ASG) | Individual EC2 instance |
| Instance choice | Fixed per node group | Chosen per launch from allowed set |
| Typical time to Ready node | 3 to 4 minutes | 45 to 70 seconds |
| Bin packing | Limited to group shapes | Picks size to fit pending pods |
| Scale down | Remove underused nodes | Remove or replace with cheaper nodes |
| Spot handling | Via ASG mixed instances policy | Native, with interruption queue |
| Config surface | Node groups plus flags | NodePool and EC2NodeClass resources |
The starting point
The client had seven managed node groups: general purpose on-demand, general purpose Spot, memory-optimised, two GPU groups for image processing, one for CI runners and one for a legacy service with a pinned instance type. Cluster Autoscaler ran with --expander=least-waste.
Three problems drove the move:
- Slow scale-up during sales. Flash sales were announced with little notice. Traffic went from baseline to 6x in under two minutes, and new nodes took three to four minutes to join. The HPA did its job, but pods sat pending.
- Idle capacity. Average CPU allocation across nodes was 48%. Node groups were sized for peak shapes that rarely coexisted.
- Node group sprawl. Every new workload shape meant a Terraform change, a new ASG and a new set of Cluster Autoscaler tags.
The Karpenter configuration
Karpenter v1 uses two custom resources. A NodePool describes what kind of nodes may be launched and how they can be disrupted. An EC2NodeClass describes AWS-specific details: AMI, subnets, security groups, IAM role, block devices.
Here is the general-purpose pool, which ended up running around 85% of workloads.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general
spec:
template:
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: kubernetes.io/arch
operator: In
values: ["arm64", "amd64"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["5"]
- key: karpenter.k8s.aws/instance-size
operator: NotIn
values: ["nano", "micro", "small", "metal"]
expireAfter: 720h
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "10%"
- nodes: "0"
schedule: "0 9 * * 1-5"
duration: 10h
limits:
cpu: "800"
memory: 3200GiSome choices worth explaining:
- Wide instance selection. Allowing categories c, m and r across generation 6 and newer gives Karpenter dozens of instance types to choose from. More choice means better bin packing and, for Spot, a lower interruption rate because it can pick from deeper pools.
- Both architectures. Most of the client's images were already multi-arch. Services that were not got a
kubernetes.io/arch: amd64node selector. Karpenter then launched Graviton wherever it could, since it is usually cheaper. expireAfter: 720h. Nodes are replaced after 30 days, which keeps AMIs current without a separate patching process.- Disruption budgets. At most 10% of nodes can be disrupted at once, and during business hours on weekdays voluntary disruption (consolidation and drift) is paused entirely. Budget schedules are evaluated in UTC, so convert your local business hours. Spot interruptions and expiry still happen, because they are not optional.
- Limits. A hard ceiling on the pool's total CPU and memory, so a runaway deployment cannot launch unlimited nodes.
The node class is short:
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
role: KarpenterNodeRole-prod
amiSelectorTerms:
- alias: al2023@latest
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: prod
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: prod
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 80Gi
volumeType: gp3
encrypted: trueIn production we pin the AMI alias to a specific version (for example al2023@v20251215) and bump it through a pull request, rather than using @latest, so AMI changes go through the same review as everything else. Karpenter detects the change as drift and rolls nodes within the disruption budget.
GPU workloads got their own NodePool with a taint, and the CI runners got a Spot-only pool with aggressive consolidation (consolidateAfter: 30s).
Installing it without a chicken-and-egg problem
Karpenter should not run on nodes it manages. If it consolidates the node it is running on, there is nothing left to launch a replacement. We run the Karpenter controller on a small managed node group (two m7g.large instances across two zones) that Karpenter does not touch. Fargate is another option.
Spot interruption handling needs an SQS queue that receives EC2 interruption warnings, rebalance recommendations and instance state changes through EventBridge rules. The Karpenter Helm chart takes the queue name as a setting:
helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
--version "1.6.0" \
--namespace kube-system \
--set settings.clusterName=prod \
--set settings.interruptionQueue=Karpenter-prod \
--set controller.resources.requests.cpu=1 \
--set controller.resources.requests.memory=1Gi \
--waitWith the queue in place, Karpenter cordons and drains a Spot node as soon as the two-minute warning arrives and launches a replacement in parallel.
Migrating workloads
We ran both autoscalers side by side for two weeks. Karpenter only launches nodes for pods that cannot be scheduled on existing capacity, so the approach was:
- Install Karpenter and the NodePools with conservative limits.
- Scale one managed node group's minimum to zero and cordon its nodes.
- Drain the nodes one at a time with
kubectl drain --ignore-daemonsets --delete-emptydir-data, watching pods reschedule onto Karpenter nodes. - Once stable for a few days, delete the node group from Terraform. Repeat.
Cluster Autoscaler was removed last, after the final node group was gone.
Results
We compared four weeks before and four weeks after, including one flash sale in each period.
| Metric | Cluster Autoscaler | Karpenter |
|---|---|---|
| Median pending-pod time during scale-up | 3m 40s | 58s |
| Average CPU allocation across nodes | 48% | 74% |
| Node count at baseline traffic | 41 | 27 |
| Share of vCPU on Spot | 22% | 61% |
| Share of vCPU on Graviton | 0% | 68% |
| Monthly EC2 compute cost | $31,400 | $19,900 |
| Managed node groups in Terraform | 7 | 1 (for Karpenter itself) |
The cost reduction came from three places in roughly equal parts: better bin packing, more Spot, and Graviton. The last two were possible with Cluster Autoscaler in theory, but each would have meant more node groups.
What went wrong
Consolidation churn. In the first week, consolidation moved pods every few minutes as load shifted. Services without PodDisruptionBudgets lost too many replicas at once. We added PDBs to every deployment with more than one replica (usually maxUnavailable: 1), raised consolidateAfter from 1 minute to 5, and added the business-hours budget above.
Workloads that should not move. A handful of long-running batch jobs were killed mid-run by consolidation. Pods can opt out with the karpenter.sh/do-not-disrupt: "true" annotation; we added it to the job templates.
Zone skew. Karpenter picked the cheapest zone for Spot, and for a day most nodes ended up in one availability zone. Pods need topologySpreadConstraints with whenUnsatisfiable: DoNotSchedule on topology.kubernetes.io/zone if zone spread matters. Karpenter honours those constraints when choosing where to launch.
Missing requests. Karpenter sizes nodes from pod requests. A few pods had no CPU or memory requests at all, so Karpenter packed them tightly onto small nodes and they were throttled. A LimitRange with default requests per namespace fixed it.
Should you use EKS Auto Mode instead?
EKS Auto Mode runs a managed Karpenter for you, along with other components such as the VPC CNI and load balancer controller. If you are starting a new cluster and are happy with its defaults, it removes a meaningful amount of operational work. We chose self-managed Karpenter here because the client needed custom AMI settings and fine control over disruption budgets, and already ran the add-ons Auto Mode would replace. Check the per-instance management fee against your fleet size before deciding.
Checklist
- Every workload has CPU and memory requests, enforced by a LimitRange.
- Every multi-replica deployment has a PodDisruptionBudget.
- Karpenter runs on capacity it does not manage.
- The Spot interruption queue and EventBridge rules are in place.
- NodePools allow a wide set of instance types and have CPU and memory limits.
- Disruption budgets pause voluntary disruption when you need stability.
- AMIs are pinned and upgraded through pull requests.
- Zone spread is expressed with topology spread constraints, not assumed.
- Long-running jobs carry the
do-not-disruptannotation.
The honest summary: Karpenter is faster and cheaper for almost every cluster we have moved, but it assumes your workloads are well-behaved Kubernetes citizens. Most of the migration effort went into fixing requests, PDBs and spread constraints, which were worth fixing anyway.



