We were migrating a fleet of production EKS clusters — some with hundreds of nodes and thousands of workloads — from Cluster Autoscaler (CAS) to Karpenter. The official migration guide got us started, but we needed something that could run hands-off across multiple clusters without anyone monitoring the process. This post describes the approach we built: a CronJob-driven, Infrastructure as Code (IaC) integrated migration that uses natural workload churn to move pods safely, with built-in rollback. Across the production fleet, the migrations completed without application downtime.
Background: CAS and Karpenter
Cluster Autoscaler (CAS) has been the standard Kubernetes node autoscaler for years. It watches for unschedulable pods and scales pre-defined Auto Scaling Groups (ASGs) up or down. It works well, but is constrained by ASG boundaries — instance types are chosen upfront, and CAS picks from what has been configured.
Karpenter takes a different approach. Instead of scaling ASGs, it provisions nodes directly, choosing instance types on the fly based on pending pod requirements. This gives better bin-packing, faster provisioning, and more flexibility — but it also means replacing a critical piece of cluster infrastructure.
The Challenge
Migrating node autoscaling on a production Kubernetes cluster is inherently risky. The autoscaler is the component that decides whether workloads have somewhere to run. A big-bang cutover risks capacity gaps, and running two autoscalers competing for the same nodes leads to unpredictable scaling behaviour.
The Official Migration Guide
The
Karpenter migration guide is the natural starting point. It covers the full setup — IAM roles, subnet tagging, deploying Karpenter, creating NodePools — and then describes a straightforward migration path:
- Deploy Karpenter alongside your existing cluster with a default NodePool
- Set node affinity so Karpenter itself runs on CAS-managed nodes
- Scale CAS to zero replicas:
kubectl scale deploy/cluster-autoscaler --replicas=0
- Shrink ASG desired counts to force nodes out:
aws eks update-nodegroup-config --scaling-config "minSize=2,maxSize=2,desiredSize=2"
- Watch Karpenter logs and verify new nodes appear as old ones are removed
For a small cluster or a greenfield setup, this works well — it's simple and direct.
On a large production cluster with hundreds of nodes and thousands of workloads, we found it was missing a few things we needed:
- Rollback. The guide doesn't describe a rollback path. Once CAS is at zero replicas and ASGs are shrunk, reversing course means manually scaling everything back up.
- Safety net during migration. Stopping CAS removes the autoscaler before the migration is complete. If Karpenter hits a capacity issue, there is nothing to fall back on.
- Automation. The guide recommends scaling down "a few instances at a time" and watching carefully. For a fleet of clusters, this requires significant manual effort.
- Non-disruptive migration. Shrinking ASGs terminates EC2 instances directly — workloads are evicted, and Pod Disruption Budgets (PDBs) are the only protection. The guide itself notes: "If your workloads do not have pod disruption budgets set, the following command will cause workloads to be unavailable."
We needed a migration path that was:
- Hands-off — change a config value, merge the PR, CI does the rest
- Observable — clear signal on progress at any point
- Reversible — hands-off, non-disruptive rollback
A note on portability: The examples below use EKS (the eks.amazonaws.com/nodegroup label to identify CAS-managed nodes, EC2NodeClass as the Karpenter cloud provider CRD). The pattern itself — cordon, label, wait for churn, restart stragglers, rollback via uncordon — is portable to any Kubernetes cluster with any node autoscaler. The node selector label should be replaced with whatever the autoscaler uses to tag its nodes.
The Approach: Cordon and Wait
The core idea is straightforward: set a config value, merge the PR, and let CI deploy it. A CronJob cordons the old nodes so nothing new lands on them, and the cluster migrates itself. New pods land on Karpenter because CAS nodes are unschedulable. CAS scales down underutilised nodes as they empty out. Deployments roll, CronJobs fire, Horizontal Pod Autoscaler (HPA) scales — all normal operations that naturally drain CAS nodes without anyone intervening.
A CronJob running every minute maintains the cordoned state and, after a configurable delay, begins restarting the long tail of eligible workloads that have not moved on their own.
The deployed automation fails closed: concurrencyPolicy: Forbid and a Lease serialize runs; a reviewed allowlist excludes unsupported workload types; and ready capacity, pending pods, rollout status, and application health must remain inside cluster-specific gates. The excerpts below show the migration logic, not the complete safety implementation.
Migration Modes
We defined five modes as a state machine, stored in our IaC config:
graph LR
A[cas-only] --> B[disabled]
B --> C[rollout]
C --> D[done]
C --> E[rollback]
E --> B
| Mode | Karpenter Limits | CAS | CAS Nodes |
|---|
cas-only | N/A (not deployed) | Enabled | Normal |
disabled | Bounded validation capacity | Enabled | Normal |
rollout | Full (from config) | Enabled but capped | Cordoned |
rollback | Bounded during handback | Enabled | Uncordoned |
done | Full (from config) | Removed | N/A |
Key details:
disabled deploys Karpenter with bounded validation capacity (32 CPU, 256Gi) while CAS remains primary. Scheduling constraints keep ordinary workloads away while NodePools and EC2NodeClasses are validated.
rollout lowers CAS --max-nodes-total to an environment-specific global ceiling, leaving controlled fallback capacity while Karpenter takes over.
done removes all CAS resources (NodeGroups, autoscaler deployment, IAM roles) automatically through IaC.
The CronJob: Rollout Script
The rollout script runs every minute and does three things in order:
1. Pre-flight Checks
Before making any changes, the script verifies Karpenter and application health. These excerpts show the queried state; the complete script evaluates the results as fail-closed predicates:
# Controller pods running?
kubectl get pods -n karpenter -l app.kubernetes.io/name=karpenter
# All NodePools ready?
kubectl get nodepools -o jsonpath='{range .items[*]}{.metadata.name}={.status.conditions[?(@.type=="Ready")].status} {end}'
# All EC2NodeClasses ready?
kubectl get ec2nodeclasses -o jsonpath='{range .items[*]}{.metadata.name}={.status.conditions[?(@.type=="Ready")].status} {end}'
If any predicate fails, the script exits with an error. The next serialized run retries.
2. Cordon CAS Nodes
Find any uncordoned CAS nodes and cordon them, adding a tracking label:
LABEL_KEY="example.com/cordoned-by"
LABEL_VALUE="karpenter-migration"
for node in $(kubectl get nodes -l 'eks.amazonaws.com/nodegroup' \
--field-selector spec.unschedulable!=true -o jsonpath='{.items[*].metadata.name}'); do
kubectl label node "$node" "$LABEL_KEY=$LABEL_VALUE" --overwrite
kubectl cordon "$node"
done
The label is critical — it lets the rollback script distinguish nodes we cordoned from nodes cordoned for other reasons (maintenance, debugging).
Completed pods (Succeeded/Failed) on CAS nodes are deleted immediately — they won't reschedule, but their presence on a node can block CAS from scaling it down.
3. Restart One Workload (After Delay)
After a configurable delay (default 8 hours), the script begins actively migrating eligible workloads that have not moved on their own. The delay gives long-running Jobs time to complete naturally before any forced restarts begin. The script iterates through CAS nodes, finds the first running pod owned by an allowlisted controller, and applies the approved action:
- StatefulSet with RollingUpdate →
kubectl rollout restart, where its update strategy and availability checks permit it
- StatefulSet with OnDelete → excluded from the unattended path and handled by an approved runbook
- Deployment →
kubectl rollout restart (via the pod's ReplicaSet owner), where its update strategy and availability checks permit it
The script then waits for the rollout and application-health gates to pass (timeout: 120 minutes) and exits. The Lease, recorded migration state, and idempotent actions prevent concurrent or duplicate restarts. Each run picks up the next eligible workload still on CAS until none remain.
Crucially, if a rollout or health gate fails, the CronJob does not skip to the next workload. It stops and continues retrying the same recorded workload on later runs. This is deliberate: it surfaces the problem for the owning team to fix rather than silently skipping it. Once the issue is resolved, the next run picks it up and moves on.
For eligible workloads, controller update strategies, replica counts, workload-specific safeguards, and capacity gates preserve availability. The automation treats controller and application-health checks—not a successful kubectl command—as the acceptance signal.
The CronJob: Rollback Script
Rollback reverses the order safely: restore and verify CAS capacity, uncordon only the nodes carrying the migration label, then reduce Karpenter limits and retire one NodeClaim per serialized run. The same capacity, pending-pod, rollout, and application-health gates run between each step. The excerpt below shows the ownership operations after those gates pass:
# Uncordon nodes we cordoned
for node in $(kubectl get nodes -l "eks.amazonaws.com/nodegroup,$MIGRATION_LABEL" \
--field-selector spec.unschedulable=true -o jsonpath='{.items[*].metadata.name}'); do
kubectl uncordon "$node"
kubectl label node "$node" "$LABEL_KEY-"
done
# Retire one Karpenter node after CAS capacity and health are verified
nc=$(kubectl get nodeclaims -o jsonpath='{.items[0].metadata.name}')
if [ -n "$nc" ]; then
kubectl delete nodeclaim "$nc" --wait=false
fi
Once the label is gone and no NodeClaims remain, subsequent CronJob runs are no-ops. Across these migrations, rollback remained hands-off and non-disruptive: set the mode, merge the PR, and CI works through the health-gated sequence.
IaC Integration
The pattern works with any IaC tool (we used Pulumi). The key pieces:
NodePool Limits Change Per Mode
The migration mode controls NodePool resource limits:
disabled / rollback → { cpu: "32", memory: "256Gi" } # bounded validation/handback capacity
rollout / done → limits from config # Karpenter is primary
This lets Karpenter be deployed in disabled mode for validation without becoming the primary autoscaler. Scheduling constraints isolate that bounded capacity from ordinary workloads. When the team is ready, switching to rollout begins the migration.
CAS Gets Capped During Rollout
When the mode is rollout, the Cluster Autoscaler's --max-nodes-total is reduced to an environment-specific global ceiling. The flag caps the total number of autoscaled nodes across all node groups, so CAS can still scale while below the ceiling. We choose a value that preserves system and rollback capacity while keeping Karpenter primary.
done Mode Removes CAS Entirely
Setting the mode to done causes IaC to:
- Skip creating EKS Managed NodeGroups and Launch Templates
- Skip deploying the Cluster Autoscaler Helm chart
- Skip creating CAS IAM roles
- Skip creating the migration CronJob
The Descheduler remains — it's still useful with Karpenter for rebalancing pods after node consolidation (
karpenter-provider-aws#6553).
Monitoring
Migration progress can be tracked with two signals; safety and completion are enforced separately through ready capacity, pending pods, rollout status, and application health:
- Node counts — CAS nodes should trend to zero, Karpenter nodes should grow:
echo "CAS: $(kubectl get nodes -l eks.amazonaws.com/nodegroup --no-headers | wc -l)"
echo "Karpenter: $(kubectl get nodes -l karpenter.sh/nodepool --no-headers | wc -l)"
- Pod placement — find non-DaemonSet pods still on CAS nodes:
CAS_NODES=$(kubectl get nodes -l eks.amazonaws.com/nodegroup \
-o jsonpath='{.items[*].metadata.name}' | tr ' ' '|')
kubectl get pods -A -o json | jq -r --arg nodes "$CAS_NODES" \
'.items[] | select(.spec.nodeName | test($nodes)) |
select(.metadata.ownerReferences[0].kind != "DaemonSet") |
"\(.metadata.namespace)/\(.metadata.name)"'
A Grafana dashboard tracking these over time is invaluable. We used the following PromQL queries:
Pods on CAS vs Karpenter (excluding DaemonSets):
# Pods on CAS nodes
count(
kube_pod_info
* on(pod, namespace) group_left() (max by(pod, namespace) (kube_pod_owner{owner_kind!="DaemonSet"}))
* on(node) group_left() (kube_node_labels{label_eks_amazonaws_com_nodegroup!=""})
) or vector(0)
# Pods on Karpenter nodes
count(
kube_pod_info
* on(pod, namespace) group_left() (max by(pod, namespace) (kube_pod_owner{owner_kind!="DaemonSet"}))
* on(node) group_left() (kube_node_labels{label_karpenter_sh_nodepool!=""})
) or vector(0)
Nodes by manager:
# CAS nodes
count(kube_node_labels{label_eks_amazonaws_com_nodegroup!=""}) or vector(0)
# Karpenter nodes
count(kube_node_labels{label_karpenter_sh_nodepool!=""}) or vector(0)
These rely on kube-state-metrics labels: EKS managed nodegroups get eks.amazonaws.com/nodegroup, Karpenter nodes get karpenter.sh/nodepool.
Real-World Results
On one production cluster (~6,000 non-DaemonSet pods, ~380 nodes) with the default 8-hour restart delay, rollout began at 11:10 and the Karpenter pod share reached 94% 30 minutes later, before any forced restarts. Here, share means pods on Karpenter divided by pods on CAS plus pods on Karpenter; the total changes as the live cluster scales:
| Time (UTC) | CAS Nodes | Karpenter Nodes | Pods on CAS | Pods on Karpenter | Karpenter pod share |
|---|
| 10:50 | 340 | 2 | 5,456 | 35 | 1% |
| 11:00 | 368 | 2 | 6,202 | 35 | 1% |
| 11:10 (rollout start) | 371 | 7 | 5,470 | 54 | 1% |
| 11:20 | 348 | 104 | 4,030 | 1,829 | 31% |
| 11:30 | 293 | 270 | 1,224 | 4,897 | 80% |
| 11:40 | 161 | 334 | 341 | 5,838 | 94% |
| 11:50 | 113 | 347 | 247 | 5,817 | 96% |
| 12:00 | 79 | 348 | 184 | 5,877 | 97% |
| 12:10 | 66 | 337 | 176 | 5,689 | 97% |
| 12:20 | 64 | 330 | 174 | 5,533 | 97% |
No engineer was driving this. CronJob and rollout logs confirmed that the CronJob cordoned the nodes but had not begun forced restarts; deployments already in progress, CronJobs scheduling new runs, HPA scaling events, and CAS draining underutilised nodes accounted for the movement. The forced restart mechanism was only needed for the remaining 3%—workloads that had not been deployed recently—and would not start for more than seven hours.
The workloadRestartDelayHours parameter (default: 8 hours) controls when the CronJob starts actively restarting that long tail. It gives long-running Jobs time to finish naturally before any forced restarts begin.
Lessons Learned
-
It is faster than expected. Natural churn does most of the work. Deployments, CronJobs, scaling events, and CAS draining underutilised nodes — all of this is already happening in any active cluster. Cordoning simply redirects it to Karpenter. The forced restart mechanism is only needed for the long tail.
-
One restart at a time contains failure. The CronJob selects one eligible controller per run, then waits for its rollout and application-health gates. Because PDBs do not govern controller-driven restarts, we enforce availability through update strategy, replica count, workload-specific safeguards, and health gates. Singleton and other unsupported workloads stay outside the unattended path. We did not experience a capacity incident or application downtime during these migrations.
-
Cap CAS; do not disable it. During rollout, CAS is still running under a global node ceiling. It can preserve a rollback path and may scale its existing ASGs while below that ceiling, so the chosen value must retain the capacity needed for system workloads and handback.
-
Label what is cordoned. The migration label (example.com/cordoned-by=karpenter-migration) is what makes rollback safe. Without it, the rollback script would uncordon nodes that were cordoned for maintenance or by other automation.
-
Keep the Descheduler. After migration completes, Karpenter consolidates underutilised nodes. The Descheduler helps rebalance pods that end up unevenly distributed after consolidation events.
Adapting This for Your Environment
The pattern is IaC-agnostic. You need:
- A mode flag in your cluster config that controls NodePool limits, CAS settings, and CronJob deployment
- Two shell scripts — rollout (cordon + delayed restart) and rollback (uncordon + delete NodeClaims)
- A serialized CronJob with
concurrencyPolicy: Forbid, a Lease, fail-closed health gates, and RBAC to read/patch nodes, read Karpenter CRDs, and restart allowlisted workloads
- A tracking label on cordoned nodes to distinguish migration cordons from manual ones
The scripts are ~130 lines of shell each. The CronJob needs a ServiceAccount with permissions for nodes (get/list/patch), pods (get/list/delete), deployments/statefulsets/replicasets (get/list/patch), and Karpenter CRDs (nodepools, ec2nodeclasses: get/list; nodeclaims: get/list/delete).
We recommend starting with a non-production cluster, using a short delay for fast iteration, and observing the CronJob logs. Before production, choose the delay and acceptance gates for that environment and test rollback under capacity pressure, unavailable CAS capacity, and disruption-blocked drains.
Conclusion
Migrating from Cluster Autoscaler to Karpenter doesn't have to be a high-risk, manually-driven operation. By cordoning CAS nodes instead of terminating them, capping CAS instead of killing it, and letting a CronJob handle both the forward migration and rollback, we turned it into a config change that runs through CI like any other infrastructure update.
The key insight was that most of the migration happens on its own. Normal cluster operations — deployments, scaling events, CronJobs, CAS draining underutilized nodes — naturally move workloads to Karpenter once CAS nodes are cordoned. The forced restart mechanism is only needed for the long tail of workloads that don't churn on their own.
For teams planning a CAS-to-Karpenter migration on production clusters, we recommend starting with the
official guide for the Karpenter setup, then adopting the cordon-and-wait pattern described here for the workload migration itself. The investment is modest — two shell scripts and a CronJob — and the result is a migration that can be triggered with a PR and left to run unattended.
This article is provided as a general guide for general information purposes only. It does not constitute advice. CECG disclaims liability for actions taken based on the materials.