# How to Migrate from Cluster Autoscaler to Karpenter

> Source: <https://cast.ai/blog/cluster-autoscaler-to-karpenter-migration/>
> Published: 2026-08-05 10:42:44+00:00

8% average CPU utilization across autoscaled EKS clusters. That is the median, per the Cast AI 2026 State of Kubernetes Optimization report covering 23,000+ clusters. Fixed node groups drive the gap: provisioned for worst-case instance size, not actual pod demand. Karpenter replaces that model with just-in-time provisioning, sizes nodes to actual resource requests, and consolidates continuously. This guide covers the full migration: install Karpenter alongside Cluster Autoscaler, map node groups to NodePools, shift workloads incrementally, then remove the old stack. A rollback path exists at every step.Key takeaways

## Key takeaways

- Karpenter provisions nodes in 45-90 seconds versus 3-5 minutes for Cluster Autoscaler (varies by AMI caching, region, and instance type; AL2023 with warm pools can provision faster; times reflect typical EKS deployment without prewarming)
- You can run both tools simultaneously during cutover with proper workload isolation to prevent double-provisioning
- Each node group maps to a NodePool plus EC2NodeClass pair
- Rollback is possible at every step: keep Cluster Autoscaler installed throughout
- Large enterprises including major SaaS companies have migrated to Karpenter at scale — AWS has documented multiple large-scale EKS migrations in their Architecture Blog

## Why teams migrate from Cluster Autoscaler to Karpenter

The case for migrating comes down to three things: speed, flexibility, and waste reduction. Each has a direct production impact, and together they add up to a meaningful operational difference.

### Speed

Karpenter provisions new nodes in 45-90 seconds (varies by AMI caching, region, and instance type; AL2023 with warm pools can provision faster; times reflect typical EKS deployment without prewarming). Cluster Autoscaler typically takes 3-5 minutes. That gap compounds during traffic spikes when your cluster needs capacity quickly.

Cluster Autoscaler works by scaling existing Auto Scaling Groups. It polls the Kubernetes API, identifies pending pods, and triggers ASG scale-out. The EC2 instance then needs time to register and join the cluster. Karpenter bypasses the ASG layer entirely. Instead, it calls the EC2 API directly and watches pod events in real time.

### Flexibility

Cluster Autoscaler requires a separate node group for each combination of instance type and capacity type. Managing multiple groups with overlapping configurations adds operational overhead. Karpenter handles spot and on-demand in a single NodePool and selects across hundreds of instance types based on availability and cost.

For a full comparison of both approaches and their trade-offs, see [Karpenter vs Cluster Autoscaler](https://cast.ai/blog/karpenter-vs-cluster-autoscaler/).

### Waste reduction

The Cast AI 2026 State of Kubernetes Optimization report, based on data from 23,000+ clusters, found that 69% of Kubernetes clusters over-provision CPU. Average CPU utilization across autoscaled clusters sits at 8%. Fixed node groups contribute to this problem because you provision for the largest expected node size, not for actual pod bin-packing needs.

Karpenter’s consolidation policy actively removes underutilized nodes and repacks pods onto fewer, better-sized instances. This reduces the idle capacity you would otherwise pay for. For background on how Cluster Autoscaler handles scaling before you compare the two, see the [Cluster Autoscaler guide](https://cast.ai/blog/kubernetes-cluster-autoscaler/).

## Prerequisites

Before starting the migration, confirm you have everything below. Missing any one item causes silent provisioning failures that are frustrating to debug.

### EKS cluster requirements

- EKS cluster running Kubernetes 1.27 or later
- AWS CLI and kubectl configured with appropriate permissions
- Helm 3.x installed locally

### IAM requirements

- Karpenter controller IAM role using IRSA or EKS Pod Identity
- Node IAM role:
`KarpenterNodeRole-${CLUSTER_NAME}`

- Required managed policies: AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryReadOnly, AmazonSSMManagedInstanceCore

### Infrastructure tagging

Tag your VPC subnets and security groups before installing Karpenter. Karpenter uses these tags to auto-discover networking resources through your EC2NodeClass configuration.

```
karpenter.sh/discovery: ${CLUSTER_NAME}
```

Also check whether existing node groups use custom AMIs, GPU instances, or arm64 workloads. Each of these requires explicit configuration in your EC2NodeClass. For a broader introduction to how Karpenter selects instances and manages node lifecycles, see [What is Karpenter](https://cast.ai/blog/what-is-karpenter/).

## Step by step: migrating from Cluster Autoscaler to Karpenter

### Install Karpenter

**Parallel-run isolation required.** Cluster Autoscaler and Karpenter do not conflict by design. CA only manages nodes in ASGs it is explicitly configured to watch. Karpenter calls the EC2 API directly and registers nodes outside any CA-managed ASG, so CA cannot scale down or manage Karpenter nodes.

The real isolation risk is at the pod level: pending pods without explicit routing may be scheduled to either autoscaler’s capacity. To guarantee which autoscaler handles which workloads:

**Option A — Taints + Tolerations (recommended):** Add a taint to your NodePool template:

```
spec:
  template:
    spec:
      taints:
        - key: karpenter-managed
          value: "true"
          effect: NoSchedule
```

Then add a toleration to pods that should run on Karpenter nodes:

```
tolerations:
  - key: karpenter-managed
    value: "true"
    effect: NoSchedule
```

Pods without this toleration will not schedule onto Karpenter nodes.

**Option B — NodeSelector:** Add nodeSelector to your Karpenter NodePool template labels, then target that label in pod specs.

Install Karpenter using Helm without removing Cluster Autoscaler first. Cluster Autoscaler continues managing its existing node groups. Karpenter only provisions nodes for pods that match its NodePool selectors.

```
export CLUSTER_NAME=<your-cluster>
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export KARPENTER_VERSION="1.4.0"  # Check https://github.com/aws/karpenter/releases for latest

helm registry logout public.ecr.aws || true
helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
  --version "${KARPENTER_VERSION}" \
  --namespace kube-system \
  --set "settings.clusterName=${CLUSTER_NAME}" \
  --set "settings.interruptionQueue=${CLUSTER_NAME}" \
  --set controller.resources.requests.cpu=1 \
  --set controller.resources.requests.memory=1Gi \
  --wait
```

Verify the controller pod is running before proceeding.

```
kubectl get pods -n kube-system -l app.kubernetes.io/name=karpenter
```

Annotate the Karpenter controller deployment so Cluster Autoscaler does not evict its pods during the parallel-run phase:

```
kubectl patch deployment karpenter -n kube-system \
  -p '{"spec":{"template":{"metadata":{"annotations":{"cluster-autoscaler.kubernetes.io/safe-to-evict": "false"}}}}}'
```

This annotation tells Cluster Autoscaler not to evict Karpenter controller pods when it considers scaling down a node. Without it, CA may disrupt the Karpenter controller during the parallel-run phase.

Set `settings.interruptionQueue`

to the name of your SQS queue for spot interruption handling. Without this, interrupted spot instances drain without graceful pod eviction.

### Map node groups to NodePools

This is the core translation step. Each existing managed node group maps to a NodePool and EC2NodeClass pair. The NodePool defines what Karpenter can provision. The EC2NodeClass defines how it provisions those nodes.

#### Cluster Autoscaler to Karpenter concept mapping

| Cluster Autoscaler concept | Karpenter equivalent | Notes |
|---|---|---|
| Auto Scaling Group (ASG) | NodePool | Defines instance selection rules, capacity type, limits, disruption policy |
| Launch Template / AMI | EC2NodeClass (amiSelectorTerms) | Use alias al2023@latest or specify custom AMIs |
| Node group instance types (fixed) | NodePool requirements (instance-category, family, generation) | Karpenter selects optimally from hundreds of types |
| Node group labels | NodePool template.metadata.labels | Applied to all provisioned nodes |
| Node group taints | NodePool template.spec.taints | Pods must tolerate them |
| ASG min/max size | NodePool spec.limits (cpu, memory) | Expressed as total resources, not node count |
| Spot/On-demand (separate ASGs) | karpenter.sh/capacity-type: [spot, on-demand] | Native multi-type in one NodePool |
| Subnet selection | EC2NodeClass subnetSelectorTerms | Tag subnets with karpenter.sh/discovery= ${CLUSTER_NAME} |
| Security groups | EC2NodeClass securityGroupSelectorTerms | Tag SGs with karpenter.sh/discovery= ${CLUSTER_NAME} |
| Node IAM role | EC2NodeClass spec.role | KarpenterNodeRole- ${CLUSTER_NAME} |

ASG min/max does not translate directly to NodePool limits. Karpenter counts total CPU and memory across all provisioned nodes, not node count. Set `spec.limits.cpu`

based on your peak resource demand, not a maximum node count.

Below are the NodePool and EC2NodeClass YAML examples for a general-purpose workload node group. For a full reference on all available NodePool fields, see [NodePool configuration](https://cast.ai/blog/karpenter-nodepools/).

```
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      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"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["2"]
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      expireAfter: 720h
  limits:
    cpu: 1000
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 15m
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  role: "KarpenterNodeRole-${CLUSTER_NAME}"
  amiSelectorTerms:
    - alias: "al2023@latest"
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "${CLUSTER_NAME}"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "${CLUSTER_NAME}"
```

### Shift workloads and verify

Start with non-critical workloads. Add a label to your NodePool template, then use a matching `nodeSelector`

on the pods you want Karpenter to manage.

In the NodePool template metadata:

```
template:
  metadata:
    labels:
      provisioner: karpenter
```

On the target deployment spec:

```
nodeSelector:
  provisioner: karpenter
```

Redeploy the workload. Karpenter provisions a new node sized to the pod’s resource request. The old Cluster Autoscaler node stays until it becomes underutilized and scales down normally.

Watch these signals during verification:

`kubectl get nodeclaims`

– confirms Karpenter provisioned nodes and their status`kubectl describe nodeclaim <name>`

– shows instance selection and scheduling details- Pod events: confirm pods scheduled on Karpenter nodes, not Cluster Autoscaler nodes

After shifting each workload batch, verify node and pod state before proceeding to the next batch:

```
# Verify no critical workloads remain on old nodes
kubectl get pods -A -o wide | grep 
# Verify new Karpenter nodes are healthy
kubectl get nodes -l karpenter.sh/nodepool
# Verify pods are running on new nodes
kubectl get pods -A -o wide | grep
```

Run batch-one workloads on Karpenter for 24-48 hours before expanding. Then progressively increase the set of workloads targeting Karpenter NodePools. Continue until all Cluster Autoscaler node groups are idle.

### Remove old node groups

Before deleting a node group, confirm no pods still schedule there.

```
kubectl get pods -o wide | grep <node-name>
```

Then cordon and drain each node.

```
kubectl cordon <node-name>
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
```

After the nodes drain, delete the node group from the EKS console or via eksctl. Once all node groups are removed, uninstall Cluster Autoscaler.

```
helm uninstall cluster-autoscaler -n kube-system
```

## Rollback and safety

Because Cluster Autoscaler stays installed throughout the migration, you have a fallback at every step. How you roll back depends on how far you have progressed.

To roll back a specific workload: remove the Karpenter `nodeSelector`

from the deployment. Pods reschedule onto available nodes, including Cluster Autoscaler-managed ones if capacity exists.

To roll back completely, follow these steps in order:

- Scale Cluster Autoscaler back up:
`kubectl scale deployment cluster-autoscaler -n kube-system --replicas=1`

- Delete Karpenter NodePools to trigger graceful node draining:
`kubectl delete nodepool default`

(Karpenter drains nodes before deleting them; pods are evicted and rescheduled automatically) - Allow the scheduler to reschedule pods onto CA-managed node groups as those scale up to meet demand
- Verify all pods are running:
`kubectl get pods -A | grep -v Running`

Keep Cluster Autoscaler running for at least one week after you consider migration complete. This ensures you have observed the cluster under your full load pattern before removing the fallback.

## Common migration pitfalls

Most migration failures come from a small, repeatable set of mistakes. Here are the ones that appear most often.

### Missing subnet and security group tags

Karpenter cannot discover networking resources without the `karpenter.sh/discovery`

tag. NodePool provisioning fails silently. Check Karpenter controller logs first for “no subnets found” or “no security groups found” errors. This single issue accounts for the majority of first-time installation failures.

### Over-restrictive instance requirements

Pinning a NodePool to specific instance types recreates the same constraint you had with fixed node groups. Use `instance-category`

and `instance-generation`

requirements instead. Karpenter then selects the most cost-effective available type that satisfies the pod’s resource request.

### Missing node labels on NodePool templates

Labels from node groups do not carry over to Karpenter nodes automatically. Define all required labels in `NodePool.spec.template.metadata.labels`

. Also audit any PodAffinity or NodeSelector rules in your workloads. If they reference labels from old node groups, those rules will not match Karpenter-provisioned nodes.

### Aggressive consolidation on stateful workloads

`WhenEmptyOrUnderutilized`

replaces nodes frequently. This works well for stateless workloads but causes unnecessary disruption for stateful ones. For stateful workloads, switch to `WhenEmpty`

. Also consider increasing the `consolidateAfter`

interval to give workloads time to stabilize before Karpenter triggers consolidation.

### Missing PodDisruptionBudgets during cutover

When draining nodes, pods without PDBs can be evicted simultaneously, causing downtime. Before cutover, verify every latency-sensitive deployment has a PDB:

```
kubectl get pdb -A
```

Set `minAvailable`

or `maxUnavailable`

to match your SLO requirements. Without PDBs, draining a node during cutover can evict all replicas of a deployment at once.

## After migration: closing the CPU waste gap

Completing the migration moves node provisioning to Karpenter. Provisioning efficiency and resource efficiency are still separate problems.

Despite Karpenter’s bin-packing, most clusters continue to over-provision CPU after migration. The root cause is inflated pod resource requests. When pods request more CPU and memory than they actually consume, Karpenter provisions nodes sized to those requests rather than actual usage. The provisioning model improves; the request accuracy problem remains.

Cast AI operates alongside Karpenter to close this gap:

**Continuous workload rightsizing**: adjusts CPU and memory requests based on actual usage, so Karpenter bins pods against real consumption rather than padded estimates**Reliable Spot management**: predicts spot interruption events and proactively moves workloads before instances terminate, so you stay on Spot without the reliability risk**Zero-downtime container live migration (CRIU)**: moves stateful workloads across nodes without restarts, enabling consolidation that would otherwise be blocked by stateful pods

## Frequently Asked Questions

**How do I migrate from Cluster Autoscaler to Karpenter?** Install Karpenter alongside Cluster Autoscaler using Helm. Create a NodePool and EC2NodeClass for each existing node group. Migrate workloads incrementally using nodeSelector rules that target Karpenter-provisioned nodes. Remove Cluster Autoscaler and old node groups only after all workloads are stable on Karpenter nodes for at least 24-48 hours each.

**Can I run both during migration?** Yes, with proper isolation. Cluster Autoscaler manages pods that schedule to its node groups. Karpenter manages pods that match its NodePool requirements. Without workload isolation, both controllers may provision nodes for the same pending pods. Use nodeSelector or taints to separate workloads during the transition period — and annotate the Karpenter controller pods with `cluster-autoscaler.kubernetes.io/safe-to-evict: "false"`

to prevent CA from evicting them.

**How do I map node groups to NodePools?** Each node group maps to one NodePool plus one EC2NodeClass. The NodePool defines instance requirements, capacity type (spot or on-demand), resource limits, and disruption policy. The EC2NodeClass defines AMI selection, subnets, security groups, and the node IAM role. The full mapping table in this guide lists every concept-to-concept translation.

**What can go wrong during migration?** The most common issues are missing karpenter.sh/discovery tags on subnets and security groups, over-restrictive instance type requirements, a missing SQS interruption queue for spot nodes, missing PodDisruptionBudgets on latency-sensitive workloads, and node labels that do not carry over from old node groups. Check Karpenter controller logs early; they surface most of these problems as actionable error messages.

**Is the migration reversible?** Yes, at every step. Cluster Autoscaler stays installed throughout the migration. To reverse a workload, remove its Karpenter nodeSelector. To reverse completely: scale Cluster Autoscaler back up (`kubectl scale deployment cluster-autoscaler -n kube-system --replicas=1`

), delete Karpenter NodePools to trigger graceful draining (`kubectl delete nodepool default`

), then verify all pods are running (`kubectl get pods -A | grep -v Running`

). Pods reschedule onto CA node groups as those scale up.
