---
title: "Migrate from Kubernetes Cluster Autoscaler to Karpenter"
description: "Migrate Cluster Autoscaler to Karpenter: design the Karpenter disruption budget, map node groups to NodePools, and drain legacy nodes in waves."
canonical: "https://adamtheautomator.com/migrate-kubernetes-cluster-autoscaler-karpenter/"
---

# Migrate from Kubernetes Cluster Autoscaler to Karpenter

> Migrate Cluster Autoscaler to Karpenter: design the Karpenter disruption budget, map node groups to NodePools, and drain legacy nodes in waves.

Source: https://adamtheautomator.com/migrate-kubernetes-cluster-autoscaler-karpenter/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Migrate from Kubernetes Cluster Autoscaler to Karpenter](https://adamtheautomator.com/wp-content/uploads/publisher/3ec5d9c85b2b81b29048d1a79022bc78/f8fc0050abe42de0332a054b2c51b8a000144d26fcebb1a4a9aa57239a20f7af.webp)

# Migrate from Kubernetes Cluster Autoscaler to Karpenter

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)27 October 202614 min. read

Categories: [DevOps](/category/devops/)

Tags:[Karpenter](/tag/karpenter/)[Cluster Autoscaler](/tag/cluster-autoscaler/)[Kubernetes](/tag/kubernetes/)[AWS](/tag/aws/)[Helm](/tag/helm/)

Table of Contents

*   [What Actually Changes When the Autoscaler Stops](#what-actually-changes-when-the-autoscaler-stops)
*   [Where Each Cluster Autoscaler Knob Lands](#where-each-cluster-autoscaler-knob-lands)
*   [Prerequisites Before You Install a Controller](#prerequisites-before-you-install-a-controller)
*   [Installing Karpenter Beside the Autoscaler](#installing-karpenter-beside-the-autoscaler)
*   [Keeping the Two Autoscalers Apart](#keeping-the-two-autoscalers-apart)
*   [The Same Step on AKS Looks Different](#the-same-step-on-aks-looks-different)
*   [Translating Node Groups into NodePools and EC2NodeClasses](#translating-node-groups-into-nodepools-and-ec2nodeclasses)
*   [Writing the NodePool That Replaces One Node Group](#writing-the-nodepool-that-replaces-one-node-group)
*   [Creating the EC2NodeClass the NodePool Points At](#creating-the-ec2nodeclass-the-nodepool-points-at)
*   [Designing the Karpenter Disruption Budget Before You Drain Anything](#designing-the-karpenter-disruption-budget-before-you-drain-anything)
*   [Budget Arithmetic You Can Check by Hand](#budget-arithmetic-you-can-check-by-hand)
*   [Freezing Disruption for the Cutover Window](#freezing-disruption-for-the-cutover-window)
*   [Auditing safe-to-evict Annotations and Pod Disruption Budgets](#auditing-safe-to-evict-annotations-and-pod-disruption-budgets)
*   [Draining Legacy Nodes in Waves](#draining-legacy-nodes-in-waves)
*   [Draining and Gating a Wave](#draining-and-gating-a-wave)
*   [Rolling Back Without Losing Capacity](#rolling-back-without-losing-capacity)
*   [Troubleshooting the Failures That Stall a Migration](#troubleshooting-the-failures-that-stall-a-migration)
*   [What to Watch After the Cutover](#what-to-watch-after-the-cutover)
*   [Measure the Next Migration Against This One](#measure-the-next-migration-against-this-one)

Do not scale the Cluster Autoscaler deployment to zero until the NodePool that replaces it has placed a production workload and held its disruption budget through a full business day.

Skip that order and the failure looks the same every time: pending pods queue behind a pool that is still frozen, and the autoscaler that could have launched a node sits at zero replicas while the change record says the migration is finished. This walkthrough installs Karpenter beside the old controller, translates each node group into a NodePool and an EC2NodeClass, sizes the disruption budgets, and drains the legacy group in waves you can stop halfway. Both autoscalers answer for capacity at every step, and the Karpenter disruption budget is the artifact you design before the first drain.

## What Actually Changes When the Autoscaler Stops

Cluster Autoscaler makes one decision per node group: raise or lower that group’s desired count. Karpenter makes one decision per group of pending pods, choosing an instance type, a zone, and a capacity type before it launches the instance directly. The [Karpenter concepts documentation](https://karpenter.sh/docs/concepts/) frames that as a change in scope, and the [Amazon EKS best practices guide](https://docs.aws.amazon.com/eks/latest/best-practices/karpenter.html) states the practical result: “With Karpenter, you don’t need to create dozens of node groups to achieve the flexibility and diversity you get with Karpenter.”

### Where Each Cluster Autoscaler Knob Lands

Each row below is a setting you re-decide on purpose.

| Cluster Autoscaler | Karpenter | What you decide again |
| --- | --- | --- |
| Node group with one instance type | NodePool `requirements` | How wide the instance menu gets |
| Launch template, subnet and security group IDs | EC2NodeClass selectors | Which tags define the network |
| Node group min, max, and desired size | `spec.limits` (cpu, memory, nodes) | Whether the ceiling is nodes or resources |
| `--scale-down-unneeded-time` | `consolidateAfter` | How long a node must sit stable |
| `--scale-down-utilization-threshold` | `consolidationPolicy` | Which nodes count as waste |
| `cluster-autoscaler.kubernetes.io/safe-to-evict` | Pod disruption budget or `karpenter.sh/do-not-disrupt` | Who owns eviction safety |

Two rows cause most of the damage. Teams translate `maxSize` into `limits.nodes` and then wonder why a burst of pending pods is refused while the pool sits 40 percent idle on CPU, and they carry `scale-down-unneeded-time` across as `consolidateAfter` without noticing that the timer resets whenever a pod lands on a node.

## Prerequisites Before You Install a Controller

The [official migration guide](https://karpenter.sh/docs/getting-started/migrating-from-cas/) assumes an existing EKS cluster, a VPC with subnets and security groups, nodes in one or more node groups, an OIDC provider for service accounts, and workloads whose pod disruption budgets follow EKS best practice. The last two are the ones teams find false halfway through. Commands here match Karpenter 1.15.1 and the `v1` API, checked against current docs and kubectl 1.37.1.

*   An OIDC provider, because the controller role is assumed through IRSA and a missing provider returns a permissions-looking auth error.
    
*   [Helm 3](https://adamtheautomator.com/kubernetes-helm/) and kubectl 1.37.1 on your workstation, since the chart ships CRDs and a webhook that older clients apply badly.
    
*   One node group you keep permanently, at two On-Demand nodes across separate Availability Zones, because it hosts the controller and cluster DNS after cutover.
    
*   Subnet and security group tags of `karpenter.sh/discovery=<cluster-name>`, because networks resolve by tag, so a missing tag shows up as a failed launch.
    
*   AWS CLI v2 credentials that can create IAM roles, tag subnets, and read node groups, because the next steps span IAM and EC2 in one pass.
    
*   A pod disruption budget on every workload you keep running, because Karpenter evicts through the Eviction API and an unbudgeted workload gets no protection from consolidation.
    

None of the AWS-side commands ran against a disposable account for this post, and they create billable resources. So make the first pass somewhere a wrong tag costs an hour instead of a customer.

## Installing Karpenter Beside the Autoscaler

The controller has to live on infrastructure Karpenter doesn’t own. The EKS best practices guide is blunt: “Do not run Karpenter on a node that is managed by Karpenter.” If consolidation or a Spot reclaim takes the controller’s node, nothing remains to launch its replacement, so give the controller a small managed node group or a Fargate profile in the Karpenter namespace.

```bash
export KARPENTER_VERSION="1.15.1"
export CLUSTER_NAME="<your-cluster-name>"
export KARPENTER_NAMESPACE="kube-system"

helm registry logout public.ecr.aws

helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
  --version "${KARPENTER_VERSION}" \
  --namespace "${KARPENTER_NAMESPACE}" \
  --create-namespace \
  --set "settings.clusterName=${CLUSTER_NAME}" \
  --set "settings.interruptionQueue=${CLUSTER_NAME}" \
  --set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=arn:aws:iam::<account-id>:role/KarpenterControllerRole-${CLUSTER_NAME}" \
  --set controller.resources.requests.cpu=1 \
  --set controller.resources.requests.memory=1Gi \
  --wait
```

`settings.interruptionQueue` names the SQS queue that carries Spot interruption and rebalance events, and without it Karpenter never sees the two-minute warning AWS sends before reclaiming an instance. The `serviceAccount.annotations` key needs escaped dots because Helm reads a dot in `--set` as a path separator, and its value is the controller role ARN trusted through OIDC for the `karpenter` service account. `--wait` holds until the controller and webhook report Ready.

### Keeping the Two Autoscalers Apart

A taint on the NodePool is what stops a pending pod from being answered by whichever autoscaler sees it first. Add it under `spec.template.spec`, then give each workload you are moving the matching toleration plus a `nodeSelector` on the pool label:

```yaml
# this block also appears in the full NodePool manifest below
spec:
  template:
    spec:
      taints:
        - key: cutover
          value: karpenter
          effect: NoSchedule
---
# the workload you are migrating
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
spec:
  template:
    spec:
      tolerations:
        - key: cutover
          operator: Equal
          value: karpenter
          effect: NoSchedule
      nodeSelector:
        karpenter.sh/nodepool: general
```

The toleration lets a migrated pod run on Karpenter. The `nodeSelector` pins it there, so Cluster Autoscaler never counts it as demand and cannot launch a legacy node to satisfy it. A pod carrying neither field stays on the legacy group, so each controller sees only the pods it can actually place. Confirm the taint landed with `kubectl get nodes -l karpenter.sh/nodepool -o custom-columns=NODE:.metadata.name,TAINTS:.spec.taints`; a pod that is missing the toleration reports `node(s) had untolerated taint` instead of scheduling.

### The Same Step on AKS Looks Different

Azure ships Karpenter as a managed capability. Microsoft’s node auto-provisioning overview says it this way: “NAP automatically deploys, configures, and manages Karpenter on your AKS clusters and is based on the open-source Karpenter and AKS Karpenter provider projects.” There’s no controller node group to protect, because [node auto-provisioning](https://learn.microsoft.com/en-us/azure/aks/node-auto-provisioning) runs in the managed control plane.

Coexistence with the old autoscaler needs a subscription feature flag, and Microsoft names the risk in its [migration guide](https://learn.microsoft.com/en-us/azure/aks/migrate-from-autoscaler-to-node-auto-provisioning): “Using cluster autoscaler and NAP together in the same cluster indefinitely can cause unpredictable scaling behavior.”

```bash
az feature register --namespace Microsoft.ContainerService --name AllowClusterAutoscalerAndNAP
az aks update --name $CLUSTER_NAME --resource-group $RESOURCE_GROUP --node-provisioning-mode Auto
```

The feature registration unlocks the side-by-side path and takes a few minutes to report Registered. The `az aks update` call flips the provisioning mode, which is the Azure equivalent of the Helm install. Existing node pools keep their count until you change them.

In the diagram below, the controller and cluster DNS sit on nodes Karpenter can’t delete.

![Karpenter node architecture](https://adamtheautomator.com/wp-content/uploads/publisher/035a756c8958ee2ee8bcd13ceb758b13a970c9ebb3b7dd1b389d41f428a52279.jpg)

## Translating Node Groups into NodePools and EC2NodeClasses

Each legacy node group becomes one NodePool plus one EC2NodeClass. The NodePool carries constraints and the disruption policy, and the [EC2NodeClass](https://karpenter.sh/docs/concepts/nodeclasses/) holds the AWS settings that used to live in launch templates and bootstrap scripts.

### Writing the NodePool That Replaces One Node Group

One pool can cover what used to be several groups, so start with the widest instance menu the workload tolerates.

```yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: general
      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: ["on-demand"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["2"]
      expireAfter: 720h
      terminationGracePeriod: 24h
      taints:
        - key: cutover
          value: karpenter
          effect: NoSchedule
  limits:
    cpu: "400"
    memory: 800Gi
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 30m
    budgets:
      - nodes: "0"
```

`requirements` replaces the fixed instance type and the mixed instances policy, and the `Gt` operator on generation keeps old hardware out without listing every type. `expireAfter: 720h` rotates nodes on a 30-day cycle. `terminationGracePeriod: 24h` bounds a drain that a blocking budget would otherwise stall, and it must exceed the largest `terminationGracePeriodSeconds` in your workloads or Karpenter deletes a pod before cleanup finishes. The zero budget is deliberate and comes later.

But resource ceilings don’t behave like a node group maximum. The [NodePool reference](https://karpenter.sh/docs/concepts/nodepools/) warns that “Karpenter provisioning is highly parallel. Because of this, limit checking is eventually consistent, which can result in overrun during rapid scale outs.” Set `limits.cpu` from your spend ceiling and expect a brief overshoot during a burst.

### Creating the EC2NodeClass the NodePool Points At

The node class holds what the launch template held and applies to every pool that references it.

```yaml
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: general
spec:
  role: "KarpenterNodeRole-<your-cluster-name>"
  amiSelectorTerms:
    - alias: "al2023@<pinned-ami-version>"
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "<your-cluster-name>"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "<your-cluster-name>"
  metadataOptions:
    httpTokens: required
```

Set exactly one of `role` or `instanceProfile`. Tag selectors are ORed across terms while conditions inside a term are ANDed, which is why the discovery tag alone is enough. `httpTokens: required` is already the documented default for IMDSv2, and pinning it keeps a later edit from relaxing instance metadata by accident. The `kubelet` block lives here now, so pod density and eviction thresholds that sat in user data move with it, and editing them drifts every node the class covers.

The comparison below is the checklist for that translation: one pool, one class, and no leftover launch template references.

![Three node groups to one pool](https://adamtheautomator.com/wp-content/uploads/publisher/07483600295e4ae1c284590ccfed222a46645ab5c7d394c1584724a7f69a1b8a.jpg)

## Designing the Karpenter Disruption Budget Before You Drain Anything

A Karpenter disruption budget decides how fast Karpenter acts on its own cost-saving decisions, and the [disruption documentation](https://karpenter.sh/docs/concepts/disruption/) is explicit about the default: “If undefined, Karpenter will default to one budget with nodes: 10%.” Ten percent of a forty-node pool allows four nodes to go at once, fine on a Tuesday and wrong at 09:00 on a Monday.

Pick the shape before you pick the number.

| Configuration | Example | Use it when |
| --- | --- | --- |
| Percentage of pool | `nodes: "20%"` | Churn should scale with pool size |
| Absolute ceiling | `nodes: "5"` | You want a hard cap for a launch day |
| Reason filter | `reasons: ["Empty", "Drifted"]` | Consolidation runs free while drift stays paced |
| Time window | `schedule: "0 9 * * mon-fri"` with `duration: 8h` | Business hours need protection |
| Full freeze | `nodes: "0"` | You are validating or draining by hand |

The block below replaces the freeze entry from the NodePool step with two budgets, one filtered by reason and one by clock:

```yaml
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 30m
    budgets:
      - nodes: "5"
        reasons: ["Empty", "Drifted"]
      - nodes: "1"
        schedule: "0 9 * * mon-fri"
        duration: 8h
```

The reason-filtered entry caps empty and drifted disruptions at five at a time, whatever the clock says. The scheduled entry carries no `reasons` field, so it covers every disruption reason and drops the ceiling to one node for the eight hours that open at 09:00 UTC on a weekday. Four empty nodes that would have gone together now go one at a time, which is the trade you want on a Monday morning.

### Budget Arithmetic You Can Check by Hand

Karpenter counts the nodes a pool owns, subtracts those already deleting and those reporting NotReady, then compares the remainder against the budget. Percentages round up, so nineteen nodes at twenty percent allows four disruptions, since 3.8 rounds to 4. When several budgets apply to one reason, the most restrictive wins, and a budget with no `reasons` field applies to all of them.

Expiration sits outside that arithmetic: “NodePool Disruption Budgets do not prevent Karpenter from terminating expired nodes,” so an aggressive `expireAfter` can still take a node during a change freeze. Windows are also UTC, since the same page notes that “Timezones are not currently supported. Schedules are always in UTC.” A 09:00 freeze opens at 09:00 UTC whether or not that matches your morning.

### Freezing Disruption for the Cutover Window

Set the new pool’s Karpenter disruption budget to `nodes: "0"` for the week you spend validating workloads. Provisioning still works and pods still land on Karpenter nodes, but no consolidation or drift action can pull a node out from under a validation run. Raise it to a real percentage once the legacy group is gone.

* * *

_**Reality Check: A disruption budget only paces Karpenter’s own decisions. Running kubectl drain on a legacy node overrides that pacing with your own, and only the pod disruption budgets on those workloads slow it down.**_

* * *

## Auditing safe-to-evict Annotations and Pod Disruption Budgets

Cluster Autoscaler let application teams opt a pod out of scale-down with one annotation, and that annotation means nothing to Karpenter. The [Cluster Autoscaler FAQ](https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/FAQ.md) documents both values: `"false"` blocks scale-down of the pod’s node, and `"true"` overrides a default that would protect it.

```bash
kubectl get pods -A -o json | jq -r '
  .items[]
  | select(.metadata.annotations["cluster-autoscaler.kubernetes.io/safe-to-evict"] != null)
  | [.metadata.namespace, .metadata.name,
     .metadata.annotations["cluster-autoscaler.kubernetes.io/safe-to-evict"]] | @tsv'
```

The `select` clause keeps only pods that carry the annotation, and `@tsv` prints one pod per line so the output greps and sorts cleanly.

| Annotation value | What Cluster Autoscaler did | Replacement to write |
| --- | --- | --- |
| `"false"` | Blocked scale-down of the whole node | A [pod disruption budget](https://kubernetes.io/docs/concepts/workloads/pods/disruptions/) with `maxUnavailable: 0`, or `karpenter.sh/do-not-disrupt: "true"` on the pod |
| `"true"` | Allowed scale-down despite the pod default | Nothing, and remove it after the legacy node group is retired |

Then check what the budgets allow, because a budget on paper blocks nothing when its counter reads zero.

```bash
kubectl get pdb -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,MIN:.spec.minAvailable,MAX:.spec.maxUnavailable,ALLOWED:.status.disruptionsAllowed
```

A `0` in the `ALLOWED` column means that workload cannot be evicted right now, and Karpenter skips its node rather than forcing the eviction. The Karpenter migration guide names the consequence of skipping this check: with no budgets in place, scaling a node group down “will cause workloads to be unavailable.” One blocking budget on one singleton pod holds an entire node.

* * *

_**Pro Tip: Run both audit commands as a pre-flight step in the pipeline that deploys the NodePools, and fail the run when any `ALLOWED` value is 0.**_

* * *

## Draining Legacy Nodes in Waves

Idle the old autoscaler before the first cordon, because the drain itself creates the pending pods that attract it:

```bash
kubectl scale deploy/cluster-autoscaler -n kube-system --replicas=0
```

The legacy group keeps its `minSize` and `maxSize` untouched, so nothing replaces the nodes you cordon and Karpenter answers every pending pod for the rest of the wave window. Cordoning then separates two decisions that a direct drain makes at once: stop new pods from landing, then move the pods already there. Cordon the whole legacy group in one pass and let normal deployment churn do part of the work.

```bash
kubectl get nodes -l eks.amazonaws.com/nodegroup=<your-node-group> -o name | xargs kubectl cordon
```

The `-o name` form prints each node as `node/<name>`, which is what `cordon` accepts, so no shell loop is needed. Cordoning leaves every running pod where it is, which makes it safe to run across a group.

### Draining and Gating a Wave

Work a wave from the node list and pause between nodes so the scheduler has time to react.

* * *

_**Warning: The drain command below evicts every pod it can on the node you name, and it creates real downtime for anything without a budget or a second replica. Drain one node at a time and stop the wave if the pending count stops falling.**_

* * *

```bash
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data --timeout=10m
kubectl get pods -A --field-selector=status.phase=Pending
```

`--ignore-daemonsets` is required because the drain refuses to proceed while DaemonSet pods exist, and those pods stay in place. `--delete-emptydir-data` acknowledges that pods using `emptyDir` volumes lose their contents, which the command otherwise treats as a stop condition. `--timeout=10m` fails the drain instead of hanging when a budget holds an eviction, and a failed drain is the signal to read the `ALLOWED` column again.

Count the next wave’s gate with two commands before you start it:

```bash
kubectl get nodes -l karpenter.sh/nodepool
kubectl get nodeclaims
```

The `karpenter.sh/nodepool` label answers how many nodes Karpenter owns, and the [NodeClaim list](https://karpenter.sh/docs/concepts/nodeclaims/) answers whether those nodes finished launching. Hold a wave until [pending pods return to baseline](https://adamtheautomator.com/kubernetes-pending-pods-scheduling/) and every claim reports Ready.

The chart below shows the three waves against the node counts, with legacy capacity stepping down only after each gate clears.

![Three-wave drain plan](https://adamtheautomator.com/wp-content/uploads/publisher/32fd474543e4a14fbc0b1e8115445d699f389f0b0f9672227e3b7e2271c03c33.jpg)

## Rolling Back Without Losing Capacity

Rollback works because you idled the old autoscaler instead of removing it, and the three commands below only work in the order shown.

```bash
kubectl scale deploy/cluster-autoscaler -n kube-system --replicas=1
aws eks update-nodegroup-config --cluster-name "${CLUSTER_NAME}" \
  --nodegroup-name "<your-node-group>" \
  --scaling-config "minSize=2,maxSize=10,desiredSize=6"
kubectl delete nodepool general
```

Restoring the autoscaler first gives the cluster a working scale-up path before anything drains. Raising the node group’s minimum puts real capacity back under that autoscaler, and the desired count is how many nodes you want available for the workloads you’re about to move. Deleting the NodePool comes last, because the [owner reference](https://karpenter.sh/docs/concepts/disruption/) between a pool and its NodeClaims cascades a graceful termination: each node taints, drains through the Eviction API, then terminates. Delete the pool first and those pods have nowhere to land.

## Troubleshooting the Failures That Stall a Migration

Most stalled migrations produce one of five symptoms, and each has an artifact you can read before changing a manifest.

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Pods stay Pending and no nodes launch | NodePool tainted with `cutover`, workload tolerates nothing | Add the toleration or drop the `cutover` taint |
| `no instance type met the scheduling requirements or had a required offering` | Requirements too narrow for the pod’s resources | Widen `instance-category` or add a capacity type |
| NodeClaim exists, node never joins | Node IAM role missing from `aws-auth` | Map the role to `system:bootstrappers` and `system:nodes` |
| Launch fails on networking | Discovery tag missing on a subnet or security group | Tag the resource and let the class re-resolve |
| Bursts of `InsufficientInstanceCapacity` | Instance family exhausted in that Availability Zone | Add families or zones to the requirements |

Start with a describe on the claim when one exists, because it separates a launch failure from a registration failure. A pod with no claim at all never reached the provider, which points back at requirements or taints. The [Karpenter troubleshooting guide](https://karpenter.sh/docs/troubleshooting/) covers the provider errors behind each row.

```bash
kubectl describe nodeclaim <nodeclaim-name>
kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter -c controller --tail=100
```

Events on the NodeClaim show whether the provider call failed, the instance launched without registering, or initialization timed out, and the controller log adds the reason behind a rejected launch. When a provider error is unrecoverable, Karpenter deletes the claim, marks that instance type as temporarily unavailable, and tries another, which is why a Spot shortage shows up as a trickle of new claims.

## What to Watch After the Cutover

Keep the budgets for another two weeks and read the events they produce: a budget that blocks every consolidation attempt costs as much as one that allows too much. Karpenter reports blocked actions on the node objects; a typical event reads `Unconsolidatable` with the message `pdb default/inflate-pdb prevents pod evictions`, naming the budget blocking it. A rising count means a budget is miswritten; widen it.

Three settings deserve review once traffic is steady:

*   Raise the budget from its cutover value to what the pool can absorb.
    
*   Confirm `expireAfter` matches your patch cadence.
    
*   Check whether `WhenEmpty` leaves savings behind now that workloads are known.
    

Editing the EC2NodeClass has the same effect as editing a launch template on a live group, and those nodes rotate through the budgets you tuned.

### Measure the Next Migration Against This One

Before the next cluster’s cutover, capture three baselines on this one: node-ready time from the NodeClaim events, the requested-to-allocatable ratio from `kubectl describe node`, and spend per 1,000 pods from your cost export. Read the same three after cutover. Node count alone hides a pool that bought bigger instances than the workloads use, and the per-pod figure is the one that survives a change to replica counts.

On AKS the review happens against the managed control plane, where Microsoft publishes the target for [AKS Automatic, the tier where Azure manages cluster setup and nodes](https://learn.microsoft.com/en-us/azure/aks/intro-aks-automatic): “99.9% of qualifying pod readiness operations complete within 5 minutes.” The [settings reference](https://karpenter.sh/docs/reference/settings/) covers the controller flags behind these defaults, and the Salesforce writeup of their [1,000-cluster migration](https://aws.amazon.com/blogs/architecture/how-salesforce-migrated-from-cluster-autoscaler-to-karpenter-across-their-fleet-of-1000-eks-clusters/) shows the same sequence at fleet scale.

What keeps a Karpenter migration from becoming a second migration is unglamorous: budgets sized to the pool, pod disruption budgets on every workload that matters, and a drain you gate between nodes. The migration is finished when the legacy group carries only the controller and cluster DNS and a halted wave leaves pending pods flat.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fmigrate-kubernetes-cluster-autoscaler-karpenter%2F&text=Migrate%20from%20Kubernetes%20Cluster%20Autoscaler%20to%20Karpenter)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fmigrate-kubernetes-cluster-autoscaler-karpenter%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fmigrate-kubernetes-cluster-autoscaler-karpenter%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/2026/02/featured_image-23-scaled.webp)

### [Build Auto-Migration Systems for Azure Spot VMs in AKS](/azure-spot-vm-migration-aks/)

Build automated migration systems to handle Azure Spot VM evictions in AKS, keeping workloads running within the 30-second window.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3ec5d9c85b2b8140a365ef14a6b86f61/961d44c379185b8fc0fc222f19db075aa840e3336ede327b42a819523a3bdc25.webp)

### [Resolve Kubernetes CoreDNS Timeouts Like a Pro](/fix-coredns-timeouts-kubernetes/)

Trace CoreDNS timeouts through all five Kubernetes DNS hops: ndots:5 lookups, the 5-second conntrack race, cache sizing, and NodeLocal DNSCache.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3ec5d9c85b2b81d18bcdd4e82db4a47e/3655f2c9e9bdd72484d7c264e373fd1aaa68e5b72b9454cd575b1e0eb08f2063.png)

### [Kubernetes Pending Pods: Fix Scheduling and Resource Limits](/kubernetes-pending-pods-scheduling/)

Decode why Kubernetes pods stay Pending: read the FailedScheduling event, then fix requests, taints, node affinity, storage, or namespace quota.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
