Skip to content

Karpenter vs Cluster Autoscaler: what changed when we switched on EKS

Scale-up time dropped from 3m 40s to under a minute and compute cost fell by a third. Here is the Karpenter v1 configuration we used, the migration path and what went wrong.

Written by

Sarah Chen

Head of DevOps

Published
Reading time
7 min
Share
Image: Venti Views on Unsplash · Source
On this page
  1. How the two tools think
  2. The starting point
  3. The Karpenter configuration
  4. Installing it without a chicken-and-egg problem
  5. Migrating workloads
  6. Results
  7. What went wrong
  8. Should you use EKS Auto Mode instead?
  9. Checklist

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 AutoscalerKarpenter
Unit of scalingNode group (ASG)Individual EC2 instance
Instance choiceFixed per node groupChosen per launch from allowed set
Typical time to Ready node3 to 4 minutes45 to 70 seconds
Bin packingLimited to group shapesPicks size to fit pending pods
Scale downRemove underused nodesRemove or replace with cheaper nodes
Spot handlingVia ASG mixed instances policyNative, with interruption queue
Config surfaceNode groups plus flagsNodePool 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:

  1. 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.
  2. Idle capacity. Average CPU allocation across nodes was 48%. Node groups were sized for peak shapes that rarely coexisted.
  3. 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.

yaml
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: 3200Gi

Some 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: amd64 node 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:

yaml
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: true

In 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:

bash
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 \
  --wait

With 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:

  1. Install Karpenter and the NodePools with conservative limits.
  2. Scale one managed node group's minimum to zero and cordon its nodes.
  3. Drain the nodes one at a time with kubectl drain --ignore-daemonsets --delete-emptydir-data, watching pods reschedule onto Karpenter nodes.
  4. 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.

MetricCluster AutoscalerKarpenter
Median pending-pod time during scale-up3m 40s58s
Average CPU allocation across nodes48%74%
Node count at baseline traffic4127
Share of vCPU on Spot22%61%
Share of vCPU on Graviton0%68%
Monthly EC2 compute cost$31,400$19,900
Managed node groups in Terraform71 (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

  1. Every workload has CPU and memory requests, enforced by a LimitRange.
  2. Every multi-replica deployment has a PodDisruptionBudget.
  3. Karpenter runs on capacity it does not manage.
  4. The Spot interruption queue and EventBridge rules are in place.
  5. NodePools allow a wide set of instance types and have CPU and memory limits.
  6. Disruption budgets pause voluntary disruption when you need stability.
  7. AMIs are pinned and upgraded through pull requests.
  8. Zone spread is expressed with topology spread constraints, not assumed.
  9. Long-running jobs carry the do-not-disrupt annotation.

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.

More in DevOps

DevOps

Mastering Docker for Scalable Development and Deployment

A complete guide to mastering Docker for scalable development and deployment—covering architecture, image optimization, networking, orchestration, and best practices for production environments.

Muhammad Rizwan

20 min read

Need help putting this into practice?

Talk to the engineers who write these posts. A 30-minute call gets you a rough plan, a timeline and a price range for your setup, whether or not we work together.

  • Reply within one business day
  • NDA on request
  • Senior engineer on every call