Node autoscaling on EKS has two serious options: the venerable Cluster Autoscaler (CA) and Karpenter. They solve the same problem — "I have pending pods, give me capacity" — but with radically different models. CA reasons in terms of Auto Scaling Groups you pre-declared; Karpenter reasons in terms of pod requirements and picks EC2 instance types on the fly.
On real clusters, moving from CA to Karpenter typically cuts the EC2 bill by 40–60%, not because of magic, but because of three compounding effects: bin-packing on the right instance size, aggressive consolidation, and much easier Spot adoption. This article is a field comparison plus a step-by-step migration you can run on a production cluster without downtime.
Context: why Cluster Autoscaler hits a ceiling
Cluster Autoscaler works at the ASG level. For each node group it simulates "if I add one node of this shape, do the pending pods fit?". That design has structural consequences:
-
You own the shape catalog. Need
m6i.large,c6i.2xlarge,r6i.4xlargeand an ARM variant? That's 4+ node groups, each with its own launch template, labels and taints. Multiply by AZ when you need zonal affinity for EBS. - Instances within a group must be homogeneous. CA assumes all nodes in an ASG have the same CPU/memory. Mixed instance policies work only if the shapes are equivalent, which kills Spot diversification (the single biggest factor in Spot interruption rate).
-
Scale-down is timid. Default
--scale-down-unneeded-timeis 10 minutes and the utilization threshold is 50%. CA removes empty-ish nodes; it never says "these three half-emptym5.2xlargecould be onem6i.2xlarge". - Fragmentation is permanent. After a week of deploys, you end up with 30% unused reserved capacity spread across nodes. Nobody notices because every node looks "half used".
Karpenter removes the node group abstraction. It watches unschedulable pods, computes the cheapest instance type (across hundreds of candidates) that satisfies their combined requirements, and launches it directly via the EC2 Fleet API. Then it continuously re-evaluates whether the current fleet can be replaced by something cheaper.
| Cluster Autoscaler | Karpenter | |
|---|---|---|
| Unit of scaling | ASG / managed node group | NodeClaim → single EC2 instance |
| Instance selection | Pre-defined per group | Dynamic, price-aware, 100s of types |
| Typical node ready time | 2–4 min (ASG + LT) | 40–90 s |
| Scale-down | Empty/underused nodes | Consolidation (merge, replace, delete) |
| Spot | Mixed instance policy, manual diversification | Native, price-capacity-optimized, interruption queue |
| Upgrades | New node group + drain | Drift detection + automated rolling replacement |
| Config surface | Flags + N ASGs | 1 NodePool + 1 EC2NodeClass |
Installing Karpenter next to Cluster Autoscaler
The two can coexist, and that is exactly what makes the migration safe. The rule: Karpenter-provisioned nodes must not be part of any ASG CA manages, and the Karpenter controller itself must run on nodes it does not own (otherwise it can evict itself and deadlock). Keep a small managed node group (2 × m6i.large, across 2 AZs) for the controller and other system components.
Prerequisites: the controller IAM role (IRSA or EKS Pod Identity), the node IAM role, and the SQS interruption queue. If you use the official CloudFormation/Terraform modules, they create all of it.
export CLUSTER=prod-eks
export AWS_ACCOUNT=111122223333
helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
--version 1.1.1 \
--namespace kube-system \
--set "settings.clusterName=${CLUSTER}" \
--set "settings.interruptionQueue=karpenter-${CLUSTER}" \
--set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=arn:aws:iam::${AWS_ACCOUNT}:role/KarpenterController-${CLUSTER}" \
--set "nodeSelector.karpenter\.sh/controller=true" \
--set controller.resources.requests.cpu=1 \
--set controller.resources.requests.memory=1Gi \
--set controller.resources.limits.memory=1Gi \
--wait
Tag the subnets and security groups Karpenter must discover — this is the #1 cause of "no instance type satisfied the requirements" errors:
aws ec2 create-tags --resources subnet-0a1b2c3d subnet-0e4f5a6b \
--tags Key=karpenter.sh/discovery,Value=${CLUSTER}
aws ec2 create-tags --resources sg-0123456789abcdef0 \
--tags Key=karpenter.sh/discovery,Value=${CLUSTER}
Writing your EC2NodeClass and NodePool
The EC2NodeClass is the AWS-specific part: AMI, subnets, security groups, disks, IMDS, kubelet. The NodePool is the Kubernetes-facing part: scheduling constraints, limits, disruption policy.
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
role: KarpenterNodeRole-prod-eks
amiSelectorTerms:
# Pin the AMI version. "al2023@latest" is convenient but makes your
# fleet drift the day AWS publishes a new AMI.
- alias: al2023@v20241024
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: prod-eks
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: prod-eks
metadataOptions:
httpEndpoint: enabled
httpProtocolIPv6: disabled
httpTokens: required # IMDSv2 only
httpPutResponseHopLimit: 1 # containers cannot reach IMDS
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 80Gi
volumeType: gp3
iops: 3000
throughput: 125
encrypted: true
kmsKeyID: alias/eks-node-volumes
deleteOnTermination: true
kubelet:
maxPods: 110
systemReserved:
cpu: 100m
memory: 300Mi
ephemeral-storage: 2Gi
evictionHard:
memory.available: 5%
nodefs.available: 10%
tags:
Environment: production
ManagedBy: karpenter
CostCenter: platform
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general-purpose
spec:
weight: 50
limits:
cpu: "2000"
memory: 8000Gi
template:
metadata:
labels:
workload-class: general
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
expireAfter: 336h # force node rotation every 14 days
terminationGracePeriod: 2h # hard ceiling on drain time
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- 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"]
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 2m
budgets:
- nodes: "15%" # baseline: never churn more than 15%
- nodes: "0" # freeze voluntary disruption in business hours
schedule: "0 8 * * mon-fri"
duration: 10h
reasons: ["Underutilized", "Drifted"]
Two things matter here. First, be generous with instance types: the more candidates, the cheaper the result and the lower the Spot interruption rate. Restricting to one family is the most common self-inflicted cost problem. Second, weight lets you layer NodePools — a weight-100 pool with Reserved Instance-matching shapes, falling back to a weight-50 general pool.
For Spot-only workloads with a Spot/on-demand fallback, use two NodePools:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: spot-batch
spec:
weight: 100
template:
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
taints:
- key: workload
value: batch
effect: NoSchedule
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
- key: karpenter.k8s.aws/instance-cpu
operator: In
values: ["8", "16", "32"]
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30s
The progressive migration, step by step
1. Verify Karpenter provisions correctly
Deploy a throwaway workload with a nodeSelector that only Karpenter can satisfy:
kubectl create deployment inflate --image=public.ecr.aws/eks-distro/kubernetes/pause:3.7 --replicas=0
kubectl set resources deployment inflate --requests=cpu=1,memory=1.5Gi
kubectl patch deployment inflate -p '{"spec":{"template":{"spec":{"nodeSelector":{"workload-class":"general"}}}}}'
kubectl scale deployment inflate --replicas=12
kubectl get nodeclaims -w
kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter -f --tail=50
You should see a NodeClaim, then a node, in under 90 seconds. Scale back to 0 and confirm the node disappears after consolidateAfter.
2. Stop CA from growing
Do not delete CA yet. Just freeze the managed node groups by setting maxSize == desiredCapacity, so new capacity can only come from Karpenter:
aws eks update-nodegroup-config --cluster-name prod-eks \
--nodegroup-name workers-a \
--scaling-config minSize=0,maxSize=6,desiredSize=6
3. Drain node groups in waves
Never drain everything at once. Take 20–25% of the old nodes per wave, and check your PDBs first:
# List the legacy nodes (no Karpenter label)
kubectl get nodes -l '!karpenter.sh/nodepool' -o name
# Check PDBs that could block the drain
kubectl get pdb -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,ALLOWED:.status.disruptionsAllowed
for node in $(kubectl get nodes -l '!karpenter.sh/nodepool' -o name | head -n 4); do
kubectl cordon "$node"
done
for node in $(kubectl get nodes -l '!karpenter.sh/nodepool' --field-selector spec.unschedulable=true -o name | head -n 4); do
kubectl drain "$node" \
--ignore-daemonsets \
--delete-emptydir-data \
--grace-period=120 \
--timeout=10m
done
Pods become pending, Karpenter provisions new capacity, and you shrink the ASG:
aws eks update-nodegroup-config --cluster-name prod-eks \
--nodegroup-name workers-a \
--scaling-config minSize=0,maxSize=6,desiredSize=2
Repeat until only the controller node group remains. Then scale CA to zero replicas, observe for 48h, and remove it.
kubectl scale deployment cluster-autoscaler -n kube-system --replicas=0
Consolidation pitfalls
Consolidation is where the savings come from — and where incidents come from if you skip the guardrails.
Pods without a controller block consolidation. A bare Pod (no Deployment/Job owner) makes Karpenter refuse to disrupt the node. Good: it protects stateful one-offs. Bad: debug pods left running pin an entire node for days.
PodDisruptionBudgets are mandatory, not optional. Karpenter honours PDBs during drain. Without them, a two-replica Deployment on the same node can go to zero during a consolidation.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: checkout-api
namespace: shop
spec:
minAvailable: 70%
selector:
matchLabels:
app: checkout-api
Long-running jobs need an opt-out. Use the annotation on the pod (or the node for a manual freeze):
apiVersion: batch/v1
kind: Job
metadata:
name: nightly-reindex
spec:
template:
metadata:
annotations:
karpenter.sh/do-not-disrupt: "true"
spec:
restartPolicy: Never
containers:
- name: reindex
image: registry.example.com/reindex:1.4.2
Spot-to-Spot consolidation is conservative by design. Karpenter only replaces a Spot node with a cheaper Spot node when at least 15 cheaper instance types remain available, to avoid pushing the fleet into low-capacity pools. If your requirements are narrow, you will never see Spot consolidation — widen instance-category and instance-generation.
Spot interruptions are handled, not avoided. With settings.interruptionQueue set, Karpenter consumes EC2 Spot interruption notices, rebalance recommendations and scheduled maintenance events from SQS, cordons and drains the node immediately. You get ~110 seconds of drain — make sure terminationGracePeriod on your pods is below that for Spot workloads, and never put a database on Spot.
Topology spread still applies. Karpenter is topology-aware, but whenUnsatisfiable: DoNotSchedule on topology.kubernetes.io/zone combined with EBS zonal volumes can generate one node per AZ for 3 pods. Expect it, and size minDomains accordingly.
Measuring the real gain
Do not trust "it feels cheaper". Instrument it:
# Node count and capacity type distribution
kubectl get nodes -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type,karpenter.sh/nodepool
# Cluster-wide request vs allocatable ratio
kubectl describe nodes | grep -A5 "Allocated resources" | grep -E "cpu|memory"
Karpenter exposes Prometheus metrics on port 8080: karpenter_nodeclaims_created_total, karpenter_nodeclaims_disrupted_total, karpenter_voluntary_disruption_decisions_total, karpenter_pods_startup_duration_seconds. Track the last one closely: if p99 pod startup degrades after migration, your NodePool requirements are too narrow.
On the AWS side, activate karpenter.sh/nodepool and your own CostCenter tag as cost allocation tags, then compare EC2 spend week-over-week in Cost Explorer. The two numbers that actually move: average CPU request/allocatable ratio (expect 45% → 75%) and Spot share of vCPU hours (expect 0–20% → 60–80% on stateless clusters).
Security best practices
-
IMDSv2 with hop limit 1.
httpTokens: required+httpPutResponseHopLimit: 1prevents a compromised container from reaching the instance metadata endpoint and stealing node credentials. This is the single highest-value setting in the EC2NodeClass. -
Least-privilege node role. The node IAM role should carry only
AmazonEKSWorkerNodePolicy,AmazonEC2ContainerRegistryReadOnlyand the CNI policy. Application permissions go through IRSA or EKS Pod Identity, never through the node role. -
Scope the controller role. The Karpenter controller policy must restrict
ec2:RunInstances/ec2:CreateTagswith a condition onaws:RequestTag/kubernetes.io/cluster/<cluster>andiam:PassRoleto the single node role ARN. Otherwise anyone with NodePool write access can launch arbitrary instances with arbitrary roles. -
Treat NodePool/EC2NodeClass as privileged objects. They are cluster-scoped and define
userDataexecuted as root on every node. RBAC onkarpenter.shandkarpenter.k8s.awsAPI groups belongs to platform admins only; manage them through GitOps with mandatory review. -
Pin AMIs, use drift to roll them.
alias: al2023@latestmeans AWS controls when your fleet rotates. Pin a version, bump it in Git; Karpenter detects drift and replaces nodes within your disruption budgets. -
Encrypt volumes with a CMK and keep
deleteOnTermination: trueso ephemeral node disks never outlive the node. -
Cap node lifetime.
expireAfter: 336hguarantees no node runs an unpatched kernel for months — a free hardening win Cluster Autoscaler never gave you. -
Private subnets only. Select subnets by tag, and verify no public subnet carries the
karpenter.sh/discoverytag; a mis-tagged subnet silently puts workloads on public IPs.
Key takeaways
- Karpenter replaces N node groups with one or two NodePools and picks instance types per pod requirement set — that alone recovers the 25–30% of capacity fragmentation CA leaves behind.
- The migration is safe because both autoscalers coexist: freeze the managed node groups, drain in waves of 20–25% with
kubectl drain --ignore-daemonsets --delete-emptydir-data, shrink the ASG, repeat. - Savings come from breadth: wide
instance-category/instance-generationrequirements, Spot plus on-demand fallback, andconsolidationPolicy: WhenEmptyOrUnderutilized. - Consolidation needs guardrails: PDBs on every production Deployment,
karpenter.sh/do-not-disrupton long jobs, and disruption budgets with a business-hours freeze window. - Harden the EC2NodeClass on day one: IMDSv2 hop limit 1, encrypted gp3 with a CMK, pinned AMI, minimal node role, private subnets.
- Measure with
karpenter_pods_startup_duration_seconds, request/allocatable ratio and Spot vCPU share before claiming the win.

