Cluster Autoscaler answers one question: given this set of node groups, which one should I make bigger? Every node group is a pre-declared shape — an ASG or MIG with a fixed instance type and a fixed set of labels and taints. The autoscaler simulates whether adding one node of each candidate group would let the pending pods schedule, picks a winner by an expander policy, and increments a desired count. Someone had to define those groups by hand, and someone has to keep defining them every time a workload needs a shape that does not exist yet.
Karpenter answers a different question: given these pending pods, what node should exist? It reads the pods' actual requirements — resource requests, node selectors, affinities, topology spread, taints tolerated — and asks the cloud provider's API directly for an instance that satisfies them, from a set that can span a hundred-plus instance types. No node groups. The node is a consequence of the workload rather than a prerequisite for it.
The numbers that matter
Provisioning latency is where the difference is most visible. Cluster Autoscaler's loop runs on a scan interval (10 seconds by default), then calls the cloud API to change an ASG's desired capacity, then waits for the ASG to launch, then waits for the kubelet to register and become Ready. On EKS with a warm AMI you are typically looking at 60 to 120 seconds from unschedulable pod to running pod, and it can stretch well past that when the first group it tries has no capacity and it has to back off and try another.
Karpenter skips the ASG entirely and calls CreateFleet. Typical time-to-Ready lands in the 35 to 60 second band, with the bulk of it being instance boot and kubelet registration rather than control-plane round trips. On a burst of 200 pending pods, the gap widens further, because Karpenter batches the pending set and provisions a small number of large nodes in one call, while Cluster Autoscaler increments group counts iteratively across multiple scan cycles.
| Cluster Autoscaler | Karpenter | |
|---|---|---|
| Provisioning model | Scale a predefined group | Synthesise a node per pending set |
| Typical pod-to-Ready | 60–120s | 35–60s |
| Instance shapes reachable | What you declared | Whatever matches, ~600 types on AWS |
| Bin-packing quality | Bounded by group shapes | Chooses shape to fit the workload |
| Consolidation | Scale-down of underused nodes | Active repacking onto cheaper nodes |
| Node inventory | Deterministic | Nondeterministic |
| Cloud support | Broad, mature | AWS mature; Azure GA; others behind |
That last-but-one row is the trade nobody puts in the comparison table, and it is the one that generates the incidents.
Nondeterministic inventory breaks assumptions you didn't write down
With node groups, you know that your general-purpose pool is m6i.4xlarge, forever. Things quietly depend on that. A DaemonSet sized at 200m CPU per node is fine at 16 cores and wasteful at 4. A log shipper that ships per-node buffers assumes a certain node-to-pod ratio. A licence keyed to physical cores costs a different amount on c6i than on c7g. Local NVMe exists on i3en and not on m6i, so a CSI driver that assumed ephemeral scratch space silently starts failing on some nodes and not others. ARM instances are in the default Karpenter instance family list, and a container image built only for linux/amd64 will land on a Graviton node and CrashLoop with an exec format error.
None of these are Karpenter bugs. They are latent assumptions that node groups were accidentally enforcing. The fix is to make the requirements explicit in the NodePool rather than implicit in the ASG:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"] # until images are multi-arch
- 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: Gt
values: ["5"]
- key: karpenter.k8s.aws/instance-cpu
operator: In
values: ["8", "16", "32"] # floor avoids DaemonSet overhead ratio
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
expireAfter: 336h # forced recycle every 14 days
limits:
cpu: "4000"
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "10%"
- nodes: "0"
schedule: "0 13 * * mon-fri" # freeze during peak
duration: 5h
The instance-cpu floor deserves a comment. DaemonSet overhead is fixed per node, not per pod. If your DaemonSets consume 900m CPU and 1.5 GiB, that is 22 percent of a 4-core node and 5.6 percent of a 16-core node. Letting Karpenter pick tiny instances because they bin-pack a small pending set nicely can make your effective utilisation worse. Setting a floor of 8 vCPU on general-purpose pools is one of the highest-leverage single lines in a Karpenter config, and it is the same reasoning that drives node shape selection generally.
Consolidation is the feature and the hazard
Consolidation is Karpenter actively deleting nodes it believes are unnecessary and rescheduling their pods elsewhere — either onto existing capacity, or onto a new cheaper node it provisions for the purpose. It is the mechanism that produces the utilisation improvements people quote, typically 20 to 40 percent off a cluster that was running on hand-tuned node groups.
It also means your pods get moved. Continuously. On a cluster with variable load, a pod's median lifetime can drop from days to a few hours. Everything that was tolerable at low churn becomes visible:
- Services with slow startup take a latency hit on every consolidation event. Fix with
terminationGracePeriodSecondsthat matches reality and a readiness probe that does not lie about warm-up. - Pods without PodDisruptionBudgets get evicted freely. Karpenter respects PDBs; the absence of one is read as consent.
- StatefulSets with attached EBS volumes pay a volume detach/attach cycle, which is 30 to 90 seconds of unavailability per move. Use
karpenter.sh/do-not-disrupt: "true"on pods where that is not acceptable. - Long-running batch jobs get killed mid-flight and restart from zero. Same annotation, or a dedicated NodePool with
consolidationPolicy: WhenEmpty.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: clickhouse
spec:
template:
metadata:
annotations:
karpenter.sh/do-not-disrupt: "true"
The disruption budget block in the NodePool above is the other half of the control. A flat 10% cap means Karpenter will never voluntarily disrupt more than a tenth of the nodes in that pool at once, and the scheduled zero-budget window freezes all voluntary disruption during business peak. Both are cheap insurance. Note that budgets apply only to voluntary disruption — a spot interruption notice will still take the node regardless, which is a separate design problem covered in the piece on spot for stateful services.
Which one to run
Stay on Cluster Autoscaler if: you are not on AWS or Azure and want a mature implementation; your workload mix is homogeneous enough that three or four node groups genuinely cover it; you have hard compliance requirements about which instance types may run production, and expressing those as a requirements list is more risk than maintaining the groups; or your cluster is small enough that the absolute savings from better packing are under a few thousand dollars a year and the migration is not worth the change budget.
Move to Karpenter if: you are managing more than roughly eight node groups, which is the point where the combinatorics of shape × zone × capacity-type × taint start eating a person's week; your workloads are heterogeneous, especially if you have anything with unusual memory-to-CPU ratios; you want serious spot adoption, since Karpenter's ability to diversify across many instance types is the single biggest lever on interruption rate; or your scale-up latency is user-visible.
A migration path that works: install Karpenter alongside the existing autoscaler, create one NodePool with a taint, move a single non-critical workload to tolerate it, and run both for two to four weeks. Then progressively drain node groups by setting their ASG max to zero, one at a time, watching for pods that fail to reschedule — those are exactly the ones with an undeclared dependency on a node property. Delete the node groups last, after a full peak cycle has passed with zero on them.
The honest summary: Karpenter is better at the job and demands more of your workload manifests. It converts node management toil into workload specification rigour. If your Deployments already declare their real requirements, that is a trade worth making. If they are full of copy-pasted requests and no PDBs, Karpenter will find every one of those problems for you, in production, at 40 percent off.