How each autoscaler works: node groups vs direct provisioning

Both tools react to the same signal: pods the scheduler has marked unschedulable because no existing node has room for them. They differ in what they are allowed to change in response.

Cluster Autoscaler: resize a group you already defined

Cluster Autoscaler works through node groups, such as Auto Scaling groups on AWS or managed instance groups elsewhere. When pods are pending, it runs a scheduling simulation against a template node for each group and asks: "If I added one node shaped like this, would these pods fit?" An expander strategy chooses among the groups that pass. Cluster Autoscaler then raises that group's desired size, and the cloud provider launches the instance. Cluster Autoscaler never picks an instance type. It only chooses which predefined group to grow and by how much. If no group's template fits the pods, they stay pending.

Karpenter: launch the node the pods need

Karpenter skips the group layer. It batches pending pods over a short window and combines their constraints: resource requests, node selectors, affinities, tolerations and topology spread. It then calls the cloud provider's compute API directly, choosing from a broad set of allowed instance types the ones that fit the batch. The node's shape comes from the workload, not from a template you sized in advance.

What this means for choosing

This architectural difference is the real basis for choosing between them. Raw speed is not. Both tools still wait on the same steps: the instance launches, the node bootstraps and joins the cluster, and container images are pulled. No autoscaler can make those steps faster.

          +---------------------------------------------------+
          | Pending pods: no existing node has room for them  |
          +---------------------------------------------------+
                 v                                    v
+----------------------------------+ +----------------------------------+
| CLUSTER AUTOSCALER               | | KARPENTER                        |
| Simulate scheduling against each | | Batch pending pods over a short  |
| node group's template node       | | window; merge requests,          |
+----------------------------------+ | selectors, affinities,           |
                 v                   | tolerations, topology spread     |
+----------------------------------+ +----------------------------------+
| Expander picks a group that fits |                  v
| (none fits -> pods stay pending) | +----------------------------------+
+----------------------------------+ | Choose instance types that fit   |
                 v                   | from the allowed set             |
+----------------------------------+ +----------------------------------+
| Raise group desired size         |                  |
| (ASG / managed instance group)   |                  |
+----------------------------------+                  |
                 v                                    v
+----------------------------------+ +----------------------------------+
| Cloud provider launches instance | | Call compute API directly        |
| of the group's predefined type   | | to launch chosen instances       |
+----------------------------------+ +----------------------------------+
                 v                                    v
+-----------------------------------------------------------------------+
| Both wait on: instance launch -> node bootstrap/join -> image pull    |
+-----------------------------------------------------------------------+

Both autoscalers start from the same pending pods: Cluster Autoscaler grows a predefined node group whose template fits, Karpenter launches instance types chosen from the pods' combined constraints, and both are bounded by instance launch, node bootstrap and image pull time.

Describing capacity: node groups vs node pools and node classes

Cluster Autoscaler does not choose instance types. It works with node groups that you define in advance, such as managed node groups or Auto Scaling groups on AWS. To decide whether adding a node would let a pending pod schedule, it builds a template node from the group and simulates placement on it. That simulation only works when every instance in the group has the same CPU, memory and labels. If you mix a 4 vCPU and a 16 vCPU type in one group, the template no longer matches what actually launches, and scale-up decisions become unreliable. In practice, each new shape, architecture or capacity type gets its own group.

Karpenter replaces those groups with two custom resources:

  • NodePool sets constraints. Its requirements list uses well-known labels to restrict instance families, kubernetes.io/arch, topology.kubernetes.io/zone and karpenter.sh/capacity-type. For example, a pool might allow on-demand or spot capacity on arm64 or amd64 across three zones, with a CPU limit that caps its total size.
  • Node class holds cloud-specific settings. On AWS this is EC2NodeClass, which defines subnets, security groups, the AMI and the IAM role. Each NodePool references a node class.

Within those constraints, Karpenter picks the instance type for each launch.

The trade-off

Cluster Autoscaler pushes you toward node-group sprawl. You end up with dozens of groups to keep in sync, but you always know exactly which instance types can appear. Karpenter gives you a heterogeneous fleet with far less configuration, but you can no longer predict the exact shapes. That has three consequences:

  • Debugging: you have to check which type was actually chosen, rather than assume it.
  • Licensing: software licensed per instance type or per core needs explicit restrictions in the NodePool.
  • Capacity planning: reservations and quotas are harder to size when the instance mix keeps shifting.
# Illustrative Karpenter NodePool (karpenter.sh/v1 API).
# Replaces several Cluster Autoscaler node groups that would otherwise
# be split by architecture, capacity type and instance shape.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      # Cloud-specific settings (subnets, security groups, AMI, IAM role)
      # live in a separate EC2NodeClass that this pool references.
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        # Allow both spot and on-demand; Karpenter picks per launch.
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        # Mixed architectures: workloads need multi-arch images.
        - key: kubernetes.io/arch
          operator: In
          values: ["arm64", "amd64"]
        # Pin families explicitly if licensing or debugging needs
        # a predictable set of instance types.
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m7g", "m7i", "c7g", "c7i"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a", "us-east-1b", "us-east-1c"]
  # Caps the total vCPU this pool may provision across all its nodes.
  limits:
    cpu: "200"

A single NodePool constrains capacity type, architecture, instance family and zone, and leaves the choice of exact instance type to Karpenter. With Cluster Autoscaler, the same capacity would need a separate homogeneous node group for each combination.

Why pod resource requests matter more than the autoscaler

Both autoscalers make decisions from pod resource requests, not observed usage. A pending pod triggers scale-up because its requests don't fit on any existing node. A node counts as underutilized when the sum of requests on it is low. If requests are missing, nodes get packed until pods are throttled or OOM-killed. If requests are inflated, you pay for capacity nothing uses. Switching autoscalers fixes neither problem.

Illustrative estimate

Assumed inputs:

  • 30 replicas, each requesting 1 vCPU but using about 0.25 vCPU.
  • 4 vCPU nodes with roughly 3.5 vCPU allocatable.
  • $0.20 per node-hour.
  • Memory is not the limiting resource.

At 1 vCPU per pod, 3 pods fit per node, so 30 replicas need 10 nodes. If you right-size requests to 0.3 vCPU, 11 pods fit per node and 3 nodes are enough. The 7 extra nodes cost about 7 × $0.20 × 730 hours ≈ $1,020 per month, and they come entirely from inflated requests.

What the autoscaler doesn't do

Node autoscalers add capacity for pods that already exist. The Horizontal Pod Autoscaler changes replica counts, and the Vertical Pod Autoscaler (or its recommendations) corrects requests. You still need both.

Scheduling constraints

Both tools simulate the scheduler's rules: node selectors, required and preferred affinity, topology spread constraints, and taints with tolerations. A pod that no node group or NodePool can satisfy stays pending.

Cluster Autoscaler has a specific pitfall when a group is scaled to zero. With no live node to inspect, it builds a template from the group definition. On AWS, that means the Auto Scaling group must carry node-template tags that advertise the labels and taints its nodes will have. Without them, Cluster Autoscaler never selects that group for pods that depend on those labels or taints.

#!/usr/bin/env bash
# Scale-from-zero setup for a Cluster Autoscaler node group on AWS, plus a pod
# that depends on it. Names and values are placeholders.
set -euo pipefail

ASG="batch-workers-asg"

# With zero running nodes, Cluster Autoscaler builds a node template from ASG tags.
# Advertise the label and taint that the group's nodes will actually have.
aws autoscaling create-or-update-tags --tags \
  "ResourceId=${ASG},ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/node-template/label/workload,Value=batch,PropagateAtLaunch=true" \
  "ResourceId=${ASG},ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/node-template/taint/dedicated,Value=batch:NoSchedule,PropagateAtLaunch=true"

# The pod needs that label and tolerates that taint. Its request is right-sized,
# because both autoscalers count requests, not observed usage.
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: batch-worker
spec:
  replicas: 30
  selector:
    matchLabels: {app: batch-worker}
  template:
    metadata:
      labels: {app: batch-worker}
    spec:
      nodeSelector:
        workload: batch          # must match the advertised template label
      tolerations:
        - key: dedicated
          operator: Equal
          value: batch
          effect: NoSchedule     # must match the advertised template taint
      containers:
        - name: worker
          image: registry.example.com/batch-worker:1.0
          resources:
            requests: {cpu: 300m, memory: 256Mi}   # close to real use, not 1 vCPU
EOF

The node-template tags let Cluster Autoscaler match a zero-sized Auto Scaling group to a pod's nodeSelector and toleration, and the pod's right-sized CPU request determines how many nodes it adds.

Scale-down, consolidation and spot capacity

Scale-down is where the two tools differ most. Cluster Autoscaler removes a node only after its requested utilization stays below a configured threshold for a configured period, and only if every pod on it can be rescheduled elsewhere. It never replaces a node with a cheaper one, so a cluster can stay fragmented across half-empty nodes. Karpenter consolidates actively: it simulates whether several nodes' pods fit onto fewer nodes or onto a cheaper instance type, then launches the replacement and drains the originals.

Both tools respect the same blockers. A PodDisruptionBudget with no remaining disruptions, a pod using local storage such as emptyDir, or a pod annotated as not safe to evict will keep its node alive. These blockers protect you, but forgotten ones also silently stop savings.

Consolidation adds churn. Each move restarts pods, so slow-starting JVM services, caches that must warm up, and stateful workloads pay a real cost every time Karpenter finds a slightly cheaper layout. Use disruption budgets and the do-not-disrupt annotation for these workloads, or limit consolidation to empty nodes.

Concern Cluster Autoscaler Karpenter
Scale-down trigger Low utilization over time Empty, underused, or replaceable by cheaper nodes
Node replacement No Yes
Spot configuration Separate or mixed-instance groups Capacity type as one requirement in a node pool
Interruption response Delegated to cloud tooling Drains and replaces on interruption notice

Spreading capacity across many instance types and zones lowers the odds that no spot capacity is available. It does not remove that risk. Keep an on-demand fallback for workloads that cannot wait.

When to use Cluster Autoscaler, when to use Karpenter, and alternatives

Neither tool is better in general. The right choice depends on how varied your workloads are and how much the cluster needs to look the same across providers.

Factor Cluster Autoscaler Karpenter
Provider coverage Many clouds, maintained under SIG Autoscaling Originated on AWS; narrower coverage, so check current support
Capacity model Pre-defined node groups Instance choice made per pending pod
Best-fit workloads Stable, homogeneous Diverse shapes, heavy spot use
Change control Node groups are reviewable artifacts Fewer guardrails unless node pools are tightly constrained

Choose Cluster Autoscaler when you run clusters on more than one provider and want one scaling model everywhere. It also fits when your workloads change little, when existing Terraform or provider tooling already manages node groups, or when strict change control requires every instance type to be approved in advance. Its predictability comes from that rigidity: a node can only appear if a group already defines it.

Choose Karpenter when workload shapes vary widely, when spot instances carry a large share of capacity, or when node groups have multiplied to cover every GPU, memory and architecture combination. It picks instances per pod, which improves bin-packing and cost. Avoid it if you need the same tool on a provider it does not support.

Alternatives and complements:

  • Managed provider autoscaling suits teams that want the control plane to own scaling decisions.
  • Static capacity works for flat, predictable load where scaling adds risk without saving money.
  • Overprovisioning runs low-priority placeholder pods that real workloads preempt. This hides node startup latency, and it works with either autoscaler.

Running either autoscaler on EKS

Keep the controller off the capacity it manages

An autoscaler that runs on a node it can remove can evict itself during consolidation or scale-down, and then nothing is left to add capacity. Run Cluster Autoscaler or Karpenter on a small managed node group that the autoscaler does not control, or on Fargate. Give the controller a PodDisruptionBudget and more than one replica so that routine node maintenance cannot take it offline.

Scope the IAM permissions

Bind the controller's service account to an IAM role through IRSA or EKS Pod Identity. Do not attach broad permissions to the node instance role, because every pod on that node would inherit them.

  • Cluster Autoscaler needs permission to describe Auto Scaling groups and to change their desired capacity. Restrict it with tag conditions so it can only modify the groups it is meant to manage.
  • Karpenter needs EC2 permissions to launch and terminate instances, plus iam:PassRole for the node role it assigns to new instances. If you enable interruption handling, it also needs access to an SQS queue. This is a larger blast radius, so limit it to tagged resources where you can.

Observe it

Start with the scheduler's view of the problem:

  • kubectl get pods -A --field-selector=status.phase=Pending lists pods waiting for capacity.
  • kubectl describe pod <name> shows the FailedScheduling reason for a specific pod.
  • kubectl get events -A --sort-by=.lastTimestamp shows recent cluster events in order.

Then check the controller itself:

  • kubectl -n kube-system logs deploy/cluster-autoscaler shows Cluster Autoscaler's decisions. The cluster-autoscaler-status ConfigMap summarizes its current view of each node group.
  • For Karpenter, read kubectl -n <namespace> logs deploy/karpenter. Then run kubectl get nodeclaims to see each launch it attempted.

Both controllers expose Prometheus metrics. Alert on scale-up failures and on pods that stay pending.

Account for the upgrade burden

Cluster Autoscaler's main upgrade rule is to match its release to your Kubernetes minor version. Karpenter adds CRDs, such as NodePool, EC2NodeClass and NodeClaim, and these are API surface you now own. Its APIs have changed between releases before, so a Karpenter upgrade can involve converting manifests. Helm also does not upgrade CRDs automatically, so you need to manage them in a separate step.

#!/usr/bin/env bash
# Triage script for EKS node autoscaling. Read-only: it changes nothing in the cluster.
set -euo pipefail

# Namespace where Karpenter is installed. Adjust to match your install.
KARPENTER_NS="${KARPENTER_NS:-karpenter}"

# 1. Scheduler's view: which pods are waiting for capacity?
kubectl get pods -A --field-selector=status.phase=Pending -o wide

# 2. Why they are waiting: FailedScheduling events carry the scheduler's reason.
kubectl get events -A --field-selector=reason=FailedScheduling \
  --sort-by=.lastTimestamp

# 3. Confirm the controller runs on capacity it does not manage.
#    The NODE column should point at the static node group or Fargate.
kubectl -n kube-system get pods -l app.kubernetes.io/name=aws-cluster-autoscaler -o wide || true
kubectl -n "$KARPENTER_NS" get pods -o wide || true

# 4a. Cluster Autoscaler: recent decisions plus its per-node-group summary.
if kubectl -n kube-system get deploy/cluster-autoscaler >/dev/null 2>&1; then
  kubectl -n kube-system logs deploy/cluster-autoscaler --since=30m --tail=200
  kubectl -n kube-system get configmap cluster-autoscaler-status -o yaml
fi

# 4b. Karpenter: controller logs, then the custom resources it reconciles.
if kubectl -n "$KARPENTER_NS" get deploy/karpenter >/dev/null 2>&1; then
  kubectl -n "$KARPENTER_NS" logs deploy/karpenter --since=30m --tail=200
  # NodeClaims show each node Karpenter launched or is still trying to launch.
  kubectl get nodepools,nodeclaims -o wide
  # These CRDs are API surface you own: check them on every Karpenter upgrade.
  kubectl get crd -o name | grep karpenter
fi

A read-only triage script that walks from pending pods and FailedScheduling events to the logs, status ConfigMap or NodeClaims of whichever autoscaler is installed, and lists the Karpenter CRDs that must be reviewed on upgrades.

Migrating from Cluster Autoscaler to Karpenter safely

Install Karpenter while your existing node groups and Cluster Autoscaler keep running. Run the Karpenter controller on a small managed node group, or on Fargate on EKS, and never on nodes it provisions itself. If Karpenter is unhealthy, it cannot schedule its own replacement.

Move workloads deliberately

Make Karpenter capacity opt-in at first. Put a taint in the NodePool template, such as capacity=karpenter:NoSchedule, so ordinary pods stay on the existing node groups. Then add a matching toleration (key: capacity, value: karpenter, effect: NoSchedule) to one stateless, well-understood workload. A toleration only allows a pod onto those nodes. To steer the workload there, also add a node selector or node affinity on the karpenter.sh/nodepool label. Expand to more workloads one at a time. Once you trust the new capacity, remove the taint.

Throughout the migration, each node should have exactly one owner. Cluster Autoscaler only touches the node groups you configure for it, and Karpenter only manages nodes it launched. Do not add Karpenter-created instances to those groups, and do not point Cluster Autoscaler at them. If both controllers act on the same capacity, they will undo each other's scaling decisions.

Enable consolidation last

Consolidation evicts pods on purpose, so check the safeguards before you turn it on. Every critical workload needs a PodDisruptionBudget that actually permits some evictions, because a budget allowing zero disruptions blocks node drains indefinitely. Set NodePool disruption budgets to limit how many nodes can churn at once. After that, scale the old node groups to zero, remove Cluster Autoscaler, and delete the groups.

What it costs you

You get less node-group maintenance and tighter bin-packing. In exchange, your team has to learn a new API, tune disruption behavior, and debug a fleet of mixed instance types.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to cluster autoscaler vs karpenter, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.