Karpenter vs. Cluster Autoscaler: Node Provisioning for the Bin-Packing Era

Sources

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:

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

DimensionCluster Autoscaler 1.37.0Karpenter v1.15.1
Unit of scalingNode group (ASG/MIG) desired countIndividual node, via ec2:CreateFleet
Instance selectionWhatever the group's launch template yields; mixed-instances capacity read from the first type onlyComputed per launch from the full catalog against NodePool requirements
Scale-up triggerUnschedulable pod scan, every 10s (--scan-interval)Pod binding events, batched
Scale-up fidelitySimplified template-node fit; "may require multiple iterations"kube-scheduler-semantics simulation incl. affinity and topology spread
Scale-down logicBinary: unneeded = requests < 50% of allocatable AND pods fit elsewhere AND stable 10m; one non-empty node at a timeConsolidation (delete/replace/multi-node), Balanced policy scores savings vs. disruption
Disruption rate limitingNone (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-zeroASG tags: label/taint/resource tags live outside the clusterNodePool spec — a namespaced API object, versioned with your GitOps flow
Spot handlingDelegate to ASG mixed-instances allocation strategyNative: spot capacity types with per-launch fallback across types in milliseconds
Headroom / overprovisioningLow-priority pause pods (preempted by real work), or the new CapacityBuffer APICapacityBuffers (alpha, same autoscaling.x-k8s.io/v1alpha1 API) as virtual pods in the simulation
Drift / config roll-outNot CA's job; you rebuild node groups out-of-bandDrift: NodePool/NodeClass hash changes trigger replacement, budgeted
GPU storyResource 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 surfaceIn-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

rate(karpenter_nodes_created_total[5m])
rate(karpenter_nodes_terminated_total[5m])
karpenter_nodepools_allowed_disruptions
karpenter_consolidation_moves_total

Cluster Autoscaler side

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