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.
