Karpenter vs. Cluster Autoscaler: Node Provisioning for the Bin-Packing Era
Sources
- Karpenter NodePools concepts (official docs)
- Karpenter Disruption: consolidation and budgets (official docs)
- Karpenter Scheduling and kube-scheduler interaction (official docs)
- Migrating from Cluster Autoscaler (official Karpenter guide)
- Cluster Autoscaler FAQ (kubernetes/autoscaler)
- Cluster Autoscaler AWS cloud provider README (kubernetes/autoscaler)
- Cluster Autoscaler 1.37.0 release notes
- Karpenter v1.15.1 release notes (kubernetes-sigs/karpenter)
The question used to be "which node autoscaler should I install?" That question is over. What is actually being decided in 2026 is which control-loop model owns your fleet's economics: a demand counter that adjusts pre-designed node groups, or a placement engine that computes every node launch from the full instance catalog. The two releases that frame this are fresh — Cluster Autoscaler 1.37.0 shipped November 6, 2026, and Karpenter v1.15.1 landed October 23, 2026 (the AWS provider, aws/karpenter-provider-aws, releases in lockstep). This guide compares the two systems at the level that matters: their control loops, their failure modes, and the exact manifests and flags each one needs to behave in production — including the scheduler-scoring problem that quietly undoes most Karpenter cost-saving projections and the ASG constraints that quietly cap what Cluster Autoscaler can ever do.
The Structural Shift: Demand Counting vs. Placement Simulation
Cluster Autoscaler is a demand counter bolted onto an inventory system you already built. You design node groups — on AWS, Auto Scaling Groups with a launch template and a mixed-instances policy — and CA adjusts one number per group: desired capacity. Its scale-up loop watches for pods the scheduler marked unschedulable, builds a template node per node group, and checks whether pending pods would fit on that template. If yes, it picks a group via an expander and bumps the group's desired size. The instance you get is whatever the group's configuration produces. CA's own FAQ describes this template-node check as "quite simplified" compared to the real scheduler — it may take multiple iterations before everything schedules.
The model carries three structural consequences that the marketing material never prints:
- Node groups must be pre-designed shapes. The AWS provider README is explicit that each ASG "should be composed of instance types that provide approximately equal capacity." Your fleet's heterogeneity is frozen into IaC templates the day you write them.
- Capacity math is approximate. When a mixed-instances policy has multiple instance types, CA uses only the first instance type found for resource calculations. Every other type in the list is invisible to its simulation.
- Cluster knowledge lives outside the cluster. To scale a group up from zero, CA reads node metadata — labels, taints, resources — from ASG tags like
k8s.io/cluster-autoscaler/node-template/label/<name>. The description of your cluster's capacity lives in EC2 tags, not in any Kubernetes API object.
Karpenter is a placement engine. A NodePool is not a capacity shape — it is a constraint set (requirements, taints, limits) evaluated against the cloud provider's entire instance catalog at every launch. When pods go pending, Karpenter batches them and simulates a tight bin-packing — using real kube-scheduler semantics, including affinity, anti-affinity, and topology spreads — then calls ec2:CreateFleet for exactly the instance types that fit. Each launched node gets its own NodeClaim object: a first-class, per-node API resource with its own lifecycle, drift hash, and status. There is no group to size, no template to maintain, and no first-instance-type-only approximation.
The second Karpenter loop has no CA equivalent at all: a disruption controller that runs node repair, drift replacement, and consolidation — deleting or replacing nodes when a cheaper shape fits the workload — rate-limited by disruption budgets. CA's scale-down is binary by comparison: a node is unneeded or it is not, with a fixed 50%-of-requests utilization threshold.
The convergence story nobody is telling. The two tools are now growing toward each other at the API level. Karpenter's alpha CapacityBuffers implement the SIG Autoscaling CapacityBuffer API (autoscaling.x-k8s.io/v1alpha1) — the same API Cluster Autoscaler consumes — and CA 1.37.0 moved the companion CapacityQuota CRD to v1beta1 as the storage version, plus a readyReplicas status field on CapacityBuffer. The headroom APIs are shared; the engines underneath remain completely different animals.
Architectural Blueprint
The two control loops, side by side:
CLUSTER AUTOSCALER — demand counting over pre-designed groups
Pending pod
(kube-scheduler marks it unschedulable)
|
v
CA scan loop (every 10s, --scan-interval)
|
v
For each node group: build TEMPLATE NODE ----+
(fixed shape from ASG launch template; | expander picks
mixed-instances policy = FIRST type only) | the group:
| | least-waste (default)
| would pending pods fit | most-pods / price /
| on a new template node? | least-nodes / priority
v v
ASG API: desired = desired + N ---> EC2 launches
(CA does NOT register nodes; your whatever the launch
node bootstrap does that) template produces
Scale-down (every 10s): node is "unneeded" when requests < 50% of
allocatable AND all pods fit elsewhere AND 10 min stable.
One non-empty node at a time. Empty nodes: bulk, up to 10.
---------------------------------------------------------------------
KARPENTER — placement simulation over the instance catalog
Pending pod batch
|
v
Scheduling simulation with kube-scheduler semantics
(affinity, anti-affinity, topology spread, taints, PDBs)
|
v
NodePool requirements INTERSECT full EC2 catalog
(instance type, size, family, generation, capacity type,
GPU count/architecture -- all computed per launch)
|
v
ec2:CreateFleet (spot/on-demand/capacity-block, fallbacks
across types within milliseconds when offerings vanish)
|
v
NodeClaim (per-node API object: drift hash, budgets, status)
Second loop, CA has no equivalent:
DISRUPTION CONTROLLER, in order:
1. Node auto-repair (unhealthy conditions, XID-aware on GPUs)
2. Drift (NodePool/NodeClass hash changed -> replace node)
3. Consolidation (delete/replace when a cheaper shape fits),
rate-limited by disruption budgets, e.g. "20% at once"
sequenceDiagram
participant P as Pending pod
participant S as kube-scheduler
participant CA as Cluster Autoscaler
participant K as Karpenter
participant C as Cloud API (ASG / EC2)
Note over P,S: pod cannot fit anywhere
P-->>S: Pending / unschedulable
rect rgb(30,30,30)
Note over CA,C: Cluster Autoscaler path
S-->>CA: unschedulable pods list (10s scan)
CA->>CA: template-node fit check per ASG
CA->>CA: expander picks group (least-waste)
CA->>C: ASG desired +N
C-->>P: instance from ASG template
end
rect rgb(24,24,48)
Note over K,C: Karpenter path
S-->>K: pod binding events
K->>K: bin-pack simulation (scheduler semantics)
K->>K: pick types from full catalog
K->>C: CreateFleet (exact instance types)
C-->>P: right-sized instance + NodeClaim
K-->>K: later: drift / consolidation loop
end
The Catch Nobody Budgets For: kube-scheduler Fights Your Bin-Packing
Here is the finding that separates a real Karpenter deployment from a slide deck. Karpenter provisions capacity, but it does not place pods — kube-scheduler does. Karpenter simulates a tight bin-packing to decide which nodes to launch, then hands the pods to the real scheduler. The default NodeResourcesFit scoring strategy in kube-scheduler is LeastAllocated, which spreads pods to maximize free resources on every node — the exact opposite of bin-packing. The Karpenter docs are unusually blunt about the consequence: when scheduler placement diverges from the simulation, Karpenter-launched nodes end up under-packed, and consolidation churns as it repeatedly re-packs pods and removes the excess nodes. You pay for the saving twice: once in wasted headroom, once in disruption.
The official recommendation is to flip the scheduler to kube-scheduler's MostAllocated scoring strategy so placement aligns with Karpenter's packing. On Amazon EKS clusters running Kubernetes 1.31 or later, this is a supported control-plane configuration — no self-managed scheduler needed. The exact CLI shape, verified against the EKS control plane configuration docs and the AWS CLI reference:
aws eks update-cluster-config \
--name my-prod-cluster \
--kube-scheduler-config '{
"nodeResourcesFit": {
"scoringStrategy": {
"type": "MostAllocated",
"resources": [
{ "name": "cpu", "weight": 1 },
{ "name": "memory", "weight": 1 },
{ "name": "nvidia.com/gpu", "weight": 1 }
]
}
}
}'
Three caveats the EKS docs themselves call out, and they matter: changing scoring affects only future scheduling decisions (running pods are never moved); the filter phase is unchanged (a pod that does not fit still does not fit); and MostAllocated concentrates blast radius — more pods feel one unhealthy node at once. EKS supports weights for cpu, memory, nvidia.com/gpu, aws.amazon.com/neuron, and aws.amazon.com/neuroncore (1–100, defaults cpu: 1, memory: 1), but not the upstream RequestedToCapacityRatio strategy. If your pods routinely set limits well above requests and burst into the difference, tight packing removes the room they burst into — that is an OOM-throttling trade, not a free win.
Cluster Autoscaler has no equivalent decision to make because it never claims to pack anything: with CA, whatever the scheduler does after scale-up is post-hoc. That is a weakness in utilization and, occasionally, a mercy in blast radius.
The Code: Both Stacks, Runnable
Karpenter: NodePool and EC2NodeClass
The core objects, on the v1 APIs (stable since Karpenter 1.0). This pool uses the Balanced consolidation policy introduced in the 1.15 series — it scores each consolidation action by savings-versus-disruption instead of grabbing every possible saving — plus disruption budgets, a weight for pool preference, and hard limits including a node-count ceiling:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general
spec:
weight: 100
template:
metadata:
labels:
workload-class: general
spec:
taints: []
startupTaints:
- key: node.cilium.io/agent-not-ready
effect: NoSchedule
expireAfter: 720h
terminationGracePeriod: 48h
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: al2023
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
- 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: Gte
values: ["3"]
limits:
cpu: "2000"
memory: 4000Gi
nodes: 60
disruption:
consolidationPolicy: Balanced
consolidateAfter: 1m
budgets:
- nodes: "20%"
- schedule: "0 9 * * mon-fri"
duration: 8h
nodes: "0"
reasons: ["Underutilized"]
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: al2023
spec:
role: "KarpenterNodeRole-my-prod-cluster"
amiSelectorTerms:
- alias: "al2023@v20261102"
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "my-prod-cluster"
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "my-prod-cluster"
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 100Gi
volumeType: gp3
encrypted: true
deleteOnTermination: true
Details that are easy to miss and verified against the docs: budgets stack multiplicatively — Karpenter computes roundup(total x percentage) - total_deleting - total_notready for percentage budgets and takes the minimum across all active budgets; the default when you define none is a single nodes: 10% budget; budget schedules are UTC-only cron with @daily-style macros; and reasons restrict a budget to Drifted, Underutilized, Empty, or Unhealthy. The weight field ranks NodePools against each other (no weight = 0), and pool limits are a hard stop on provisioning — including the newer nodes count limit.
A GPU pool for training and inference fleets, where the per-launch type selection matters most:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: gpu-training
spec:
weight: 10
template:
spec:
taints:
- key: nvidia.com/gpu
effect: NoSchedule
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: al2023
requirements:
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["g", "p"]
- key: karpenter.k8s.aws/instance-generation
operator: Gte
values: ["5"]
- key: karpenter.k8s.aws/gpu-count
operator: In
values: ["1", "2", "4", "8"]
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "10%"
For multi-GPU nodes you usually want minValues on instance-family requirements (an alpha field) so Karpenter keeps enough type diversity in each launch decision for spot fallback — and note that spot-to-spot replacement consolidation is off by default; it requires the SpotToSpotConsolidation feature gate. For workloads that use DRA (Dynamic Resource Allocation) instead of the classic nvidia.com/gpu extended resource, Karpenter 1.15 supports it via the DRANVIDIAGPU and DRAEFA feature gates, with the full DRA setup in the docs — but it does not support dynamic provisioning for NVIDIA ComputeDomains, so claims against those DeviceClasses will not trigger node launches. The NodeOverlays concept (also new) lets you fine-tune the scheduling simulation itself — custom device capacities, price adjustments, per-overlay requirements — which is where heterogeneous GPU fleets are heading.
Cluster Autoscaler: deployment and the ASG contract
The CA side of the contract. The controller is a plain deployment with auto-discovery over ASG tags; the node fleet's shape lives in your IaC:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cluster-autoscaler
namespace: kube-system
labels:
app: cluster-autoscaler
spec:
replicas: 1
selector:
matchLabels:
app: cluster-autoscaler
template:
metadata:
labels:
app: cluster-autoscaler
spec:
priorityClassName: system-cluster-critical
serviceAccountName: cluster-autoscaler
containers:
- image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.37.0
name: cluster-autoscaler
resources:
requests:
cpu: 100m
memory: 300Mi
limits:
cpu: 100m
memory: 300Mi
command:
- ./cluster-autoscaler
- --v=4
- --cloud-provider=aws
- --cluster-name=my-prod-cluster
- --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/my-prod-cluster
- --expander=least-waste
- --max-node-provision-time=15m
- --scale-down-unneeded-time=10m
- --scale-down-utilization-threshold=0.5
- --balance-similar-node-groups=false
- --ok-total-unready-count=3
- --max-total-unready-percentage=0.45
- --skip-nodes-with-system-pods=false
ports:
- containerPort: 8085
name: metrics
And the ASG side — discovery tags, plus the tags CA needs to simulate nodes when scaling a group up from zero. This is the part Karpenter does not need at all, because in Karpenter the equivalent information lives in the NodePool spec inside the cluster:
aws autoscaling create-or-update-tags \
--auto-scaling-group-name my-prod-gpu-asg \
--tags \
ResourceId=my-prod-gpu-asg,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/enabled,Value=true,PropagateAtLaunch=false \
ResourceId=my-prod-gpu-asg,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/my-prod-cluster,Value=true,PropagateAtLaunch=false \
ResourceId=my-prod-gpu-asg,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/node-template/label/nvidia.com/gpu.present,Value=true,PropagateAtLaunch=false \
ResourceId=my-prod-gpu-asg,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/node-template/taint/nvidia.com/gpu,Value=true:NoSchedule,PropagateAtLaunch=false \
ResourceId=my-prod-gpu-asg,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/node-template/resources/nvidia.com/gpu,Value=8,PropagateAtLaunch=false
Two verified gotchas on this path: the taint tag's value format is <taint-value>:<taint-effect>, and with --enable-csi-node-aware-scheduling the ASG must explicitly declare which CSI drivers its nodes run — CA will not infer them, because EBSVolumeLimit is EC2 hardware capacity, not proof that ebs.csi.aws.com is installed. CA 1.37.0 also fixed a scale-from-zero bug for AWS node groups using CSI-backed volumes with topology constraints — if you run an older CA with CSI-aware scheduling, that failure mode has a patch release answer.
PDBs and do-not-disrupt: the workload-side contract
Both engines ultimately evict pods through the eviction API, so the workload contract is shared — but the annotations differ per tool:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-server-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: api-server
---
# Karpenter only: block voluntary disruption per pod.
# On a Node, blocks consolidation + drift.
# On a Pod, blocks consolidation of the pod's node.
# The value can be a duration: protection ends 5m after pod start.
apiVersion: v1
kind: Pod
metadata:
name: critical-job
annotations:
karpenter.sh/do-not-disrupt: "5m"
spec:
containers:
- name: work
image: registry.example.com/worker:1.4.2
Note the asymmetry the docs are specific about: karpenter.sh/do-not-disrupt does not block expiration, repair, or forceful termination once the NodeClaim's terminationGracePeriod elapses. It is a brake, not a wall — and a fleet of always-annotated pods will starve consolidation silently.
Comparison: Mechanics That Decide Your Bill
| Dimension | Cluster Autoscaler 1.37.0 | Karpenter v1.15.1 |
|---|---|---|
| Unit of scaling | Node group (ASG/MIG) desired count | Individual node, via ec2:CreateFleet |
| Instance selection | Whatever the group's launch template yields; mixed-instances capacity read from the first type only | Computed per launch from the full catalog against NodePool requirements |
| Scale-up trigger | Unschedulable pod scan, every 10s (--scan-interval) | Pod binding events, batched |
| Scale-up fidelity | Simplified template-node fit; "may require multiple iterations" | kube-scheduler-semantics simulation incl. affinity and topology spread |
| Scale-down logic | Binary: unneeded = requests < 50% of allocatable AND pods fit elsewhere AND stable 10m; one non-empty node at a time | Consolidation (delete/replace/multi-node), Balanced policy scores savings vs. disruption |
| Disruption rate limiting | None (PDBs only, plus unready-node breaker) | Disruption budgets: percent or absolute, cron schedules, per-reason, min across active budgets (default 10%) |
| Node metadata for scale-from-zero | ASG tags: label/taint/resource tags live outside the cluster | NodePool spec — a namespaced API object, versioned with your GitOps flow |
| Spot handling | Delegate to ASG mixed-instances allocation strategy | Native: spot capacity types with per-launch fallback across types in milliseconds |
| Headroom / overprovisioning | Low-priority pause pods (preempted by real work), or the new CapacityBuffer API | CapacityBuffers (alpha, same autoscaling.x-k8s.io/v1alpha1 API) as virtual pods in the simulation |
| Drift / config roll-out | Not CA's job; you rebuild node groups out-of-band | Drift: NodePool/NodeClass hash changes trigger replacement, budgeted |
| GPU story | Resource counted from ASG template; scale-from-zero needs tags; DRA scale-from-zero per provider (1.37: CoreWeave GPULabel, DO AMD, Cluster API DRA fixes) | First-class requirements (gpu-count, categories) plus DRA gates (DRANVIDIAGPU, DRAEFA); no ComputeDomains dynamic provisioning |
| Maturity surface | In-tree providers for 25+ clouds (GCE, Azure, AWS, Hetzner, CoreWeave, Cluster API…) | AWS first-class; other clouds via Karpenter core + community providers (Azure is the strong second) |
Day-2 Operational Warnings
Karpenter side
- Watch the budget math, not the budget count. A
20%budget on a 19-node pool allowsroundup(19 x 0.2) - deleting - notready= 4 disruptions, and nodes being deleted by anything (including your own drain tooling) count against it. Several budgets stack to the minimum. Alert onkarpenter_nodepools_allowed_disruptionspinned at zero for hours — that means consolidation, drift, and repair are all wedged, usually by PDBs ordo-not-disruptannotations. - Expiration is not budgeted. Disruption budgets rate drift, emptiness, consolidation, and repair —
expireAftertermination is a forceful method that ignores budgets and bypasses PDBs onceterminationGracePeriodelapses. If you setexpireAfter: 720hon a 60-node pool, plan for the wave. - Read the
Unconsolidatableevents. Karpenter emits per-node events explaining why a node that looks consolidatable is not —pdb default/inflate-pdb prevents pod evictions,can't replace with a lower-priced node, or, with CapacityBuffers active,Node has buffer pods. These events are the fastest triage path in the product; scrape them. - Spot-to-spot consolidation is opt-in and flexibility-gated. By default Karpenter only deletes spot nodes in consolidation, never replaces them with cheaper spot. Enable
SpotToSpotConsolidationand it still requires 15 candidate instance types for single-node replacements, specifically to avoid racing to the cheapest, most-interrupted type. - Preferred constraints blunt consolidation. Preferred anti-affinity and topology spreads are respected by Karpenter's launch simulation to avoid immediately re-consolidating what it just launched — so pods with heavy
preferredDuringSchedulingrules produce nodes that look consolidatable but are not. The logs name the pods ("has a preferred Anti-Affinity which can prevent consolidation"). - Neuron (Trainium/Inferentia) needs power-of-2 requests. Karpenter is not aware of the Neuron scheduler extension's decisions, so containers must use like-sized, power-of-2 requests (1, 2, 4…) — otherwise bin-packing diverges from reality and you get permanently pending pods.
- Restricted labels.
karpenter.sh/registeredandkarpenter.sh/initializedare stamped on nodes after join and never trigger provisioning — a NodePool withExistson them is rejected by CEL. Pin workloads withkarpenter.sh/nodepool. - EC2 API throttling is a real ceiling. Every launch calls
CreateFleet/pricing/SSM; the docs have a dedicated task page on monitoring EC2 API call volume. On large fleets, this — not the controller — is usually the scale ceiling.
rate(karpenter_nodes_created_total[5m])
rate(karpenter_nodes_terminated_total[5m])
karpenter_nodepools_allowed_disruptions
karpenter_consolidation_moves_total
Cluster Autoscaler side
- Scale-down is structurally slow. One non-empty node at a time, 10 minutes of "unneeded" stability each, and during a node's eviction CA stops all other activity for up to 2 minutes retrying. Empty nodes go in bulk (up to 10 by default,
--max-empty-bulk-delete). If your fleet scales in and out hourly, that ladder is your latency. - The unready-node breaker halts everything. More than 45% unready nodes (or 3, whichever is larger —
--max-total-unready-percentage,--ok-total-unready-count) stops CA operations entirely until the cluster heals. During a bad rollout this looks exactly like "the autoscaler is broken." - 15 minutes is the whole startup budget.
--max-node-provision-time(default 15m) must cover cloud launch, kubelet TLS bootstrapping, GPU driver install, and device plugin registration. GPU nodes with big driver stacks can blow through it — CA then drops the node from simulation and may scale up elsewhere. New in 1.37.0: the external gRPC interface grewmaxNodeStartupDurationandallowNonAtomicScaleUpToMaxso providers can classify not-yet-registered nodes as NotStarted rather than Unready. - The price expander does not exist on AWS.
--expander=priceworks for GCE, GKE and Equinix Metal only. On AWS your realistic set isleast-waste(default),most-pods,least-nodes,random,priority— chainable since 1.23 (e.g.--expander=priority,least-waste). - Know the 1.37.0 regression that produced a zero-memory template node. AWS ASGs using attribute-based
InstanceRequirementswithout instance-type overrides hit an integer overflow:MemoryMiB.Min >= 2048could produce a template node with zero memory and silently block scale-up. Fixed in 1.37.0 — it is a precise illustration of how many translations sit between an ASG config and CA's simulation. - New observability in 1.37.0 worth adopting immediately:
node_removal_latency_secondsnow carries adelay_reasonlabel (BlockedByPod,AtomicScaleDownFailed, …) — previously you only had logs for "why won't scale-down happen." And--enable-graceful-degradationstops the main loop from stalling indefinitely on large unschedulable-pod backlogs (--pending-pods-batching-timeout), trading decision optimality for progress under heavy load. - Metrics endpoint is on :8085 by default (
--address), Prometheus format, with cluster-state gauges documented in the metrics proposal —cluster_autoscaler_unschedulable_pods_countandcluster_autoscaler_cluster_safe_to_autoscalebeing the two that catch most incidents early.
cluster_autoscaler_unschedulable_pods_count
cluster_autoscaler_cluster_safe_to_autoscale
Verdict: Honest Strengths, Honest Weaknesses
Run Karpenter when: your fleet is EKS-first and high-churn (batch, CI, training bursts, spot-heavy services), your instance-type needs are heterogeneous or change often, or cost per pod is a metric someone owns. The per-launch placement simulation, disruption budgets, drift handling, and budgeted node repair are genuinely a generation ahead, and 1.15's Balanced consolidation fixes the worst old complaint — churny consolidation for marginal savings. We covered the 1.15 disruption rework in the Karpenter v1.15.0 release review.
Run Cluster Autoscaler when: you operate multi-cloud or on a cloud where Karpenter has no first-class provider; your fleet is built from a handful of stable, pre-designed shapes that rarely change; your organization's IaC and compliance story is already built around ASGs/MIGs; or you need one autoscaler across 25+ in-tree providers, including niche ones (Hetzner, CoreWeave, Cluster API). CA is also the lighter conceptual load for teams without a platform engineering function: groups scale up, groups scale down, few surprises.
Who should skip Karpenter: GCP-centric shops — GKE Autopilot and its own autoscaling paths make CA-vs-Karpenter a marginal debate there; and fleets that genuinely run one fixed instance type with steady utilization — you would adopt a placement engine to solve problems you do not have, and pay for it in controller complexity, EC2 API volume, and a much longer list of concepts your on-call must learn (NodePool, NodeClass, NodeClaim, budgets, drift, overlays).
Who should skip Cluster Autoscaler: cost-sensitive EKS fleets with heterogeneous or GPU workloads, and anyone building around DRA and GPU heterogeneity. On AWS, CA's per-group model forces you to pre-enumerate every shape a workload might ever need, and the mixed-instances capacity read (first type only) plus ASG-tag metadata for scale-from-zero are permanent tax. The 1.37.0 stream of per-provider GPU/DRA fixes shows the project investing hard in exactly this gap — and also how far the ASG abstraction has to stretch to cover it.
The migration is a solved problem if you take it: the official Migrating from Cluster Autoscaler guide is a runbook, not a blog post — IAM roles with scoped termination permissions (ec2:TerminateInstances conditioned on the karpenter.sh/nodepool tag), subnet/SG discovery tags, and a cutover that lands Karpenter first, then scales CA to zero replicas, then walks node groups to their floor (minimum 2 instances for a multi-AZ group) so drains proceed safely. The PDB prerequisite is not decorative: the guide warns in bold that scaling node groups down without PDBs will cause workload unavailability. If you want the drain-side mechanics in depth, see our node drain automation guide; for the cost-visibility loop that tells you whether any of this paid off, start with cost visibility and right-sizing and the cost optimization tool landscape.
One last engineering judgment: the 2026 releases make both tools safer than their reputations, and both teams are clearly watching each other — CA absorbed graceful degradation, DRA scale-from-zero, and buffer APIs; Karpenter absorbed CA's buffer semantics and added a cost-aware consolidation policy. The differentiator is not feature count. It is whether your fleet's shape should be designed once (groups) or computed every time (placement). Pick the model that matches your workload churn, not the one with the better launch post — and whichever you pick, fix the scheduler scoring: spreading defaults silently eat bin-packing economics in one case, and mask under-provisioning in the other.
References & Further Reading
- Karpenter NodePools — official concepts (v1 API fields, requirements, limits, budgets, weight)
- Karpenter Disruption — consolidation, budgets, node repair (policies, budget math, forceful methods)
- Karpenter Scheduling — kube-scheduler settings (the MostAllocated recommendation and its caveats)
- Karpenter: Migrating from Cluster Autoscaler (the official cutover runbook, v1.15.1)
- Karpenter CapacityBuffers and the shared SIG Autoscaling CapacityBuffer API
- Karpenter: GPUs and EFAs with Dynamic Resource Allocation (feature gates, ComputeDomains limitation, NVIDIA DRA driver install)
- Karpenter well-known annotations (
karpenter.sh/do-not-disruptsemantics) and the metrics reference - Cluster Autoscaler FAQ (scale-up/scale-down mechanics, expanders, PDB behavior, parameters table)
- Cluster Autoscaler AWS cloud provider README (discovery tags, node-template tags, mixed-instances caveats)
- Cluster Autoscaler 1.37.0 release notes (CapacityQuota v1beta1, DRA scale-from-zero, graceful degradation, AWS fixes)
- Cluster Autoscaler metrics proposal (cluster-state gauges)
- Amazon EKS advanced control plane configuration (kube-scheduler
nodeResourcesFit.scoringStrategy) - AWS EKS Best Practices: Karpenter (the PDB guidance the migration guide assumes)
- kube-scheduler documentation and PodDisruptionBudgets; pod priority mechanics in the priority and preemption docs
- Internal context: Kubernetes 1.37.0 release analysis for the platform baseline CA 1.37 targets, and our Karpenter v1.15.0 review for the disruption rework this guide builds on.