Skip to content
Hogin Hogin
Go back

Karpenter: just-in-time nodes and half the cluster bill

9 мин чтения

Load drops at night, but the bill doesn’t: the node group stays the same size it was at noon. Cluster Autoscaler only knows how to add and remove whole groups of pre-defined instances, so most of the cluster sits idle “just in case” almost all the time. Karpenter solves it differently: it watches the actual pods that can’t be scheduled, picks the cheapest instance that fits them from the whole cloud catalog, and boots a node in seconds. As soon as the pod is gone, it shrinks the cluster back. Let’s see how it works and why it’s usually the first lever teams pull when they need to cut a Kubernetes bill.

Table of contents

Open Table of contents

Why Cluster Autoscaler hits a ceiling

Cluster Autoscaler (CA) showed up when the only way to describe scaling was node groups — fixed sets of identical instances tied to an Auto Scaling Group or its equivalent. CA does exactly one thing: grow or shrink an existing group’s size when pods don’t fit or nodes sit idle.

The problem is granularity. If a group is defined as m5.xlarge, CA adds another m5.xlarge even if the pod stuck in Pending only needs 200Mi of memory. Teams react by pre-provisioning a dozen different groups for different load profiles — and take on a second job: maintaining those groups, watching that the right availability zones don’t run out of capacity, and manually deciding where spot makes sense and where on-demand does.

The second weak spot is reaction time. CA checks the Pending-pod queue on an interval (10 seconds by default), but the real time-to-ready-node is the sum of ASG scaling time, image pull, and kubelet startup — usually one and a half to three minutes. For autoscaling meant to absorb a traffic spike, that’s a noticeable lag.

Why this matters right now

For a long time Karpenter was seen as “yet another AWS-specific autoscaler” — an experimental project tied to one cloud. That’s changed: the project reached a stable v1 API (you can build on the manifests without fearing a field rename in the next release — see karpenter.sh), and the community moved its core beyond AWS with providers for Azure and GCP. At the same time, FinOps pressure on Kubernetes budgets keeps growing — the more teams move to managed clusters, the more visible the gap between “paid for” and “actually used” becomes. Karpenter is a rare optimization that doesn’t touch the application at all: only how nodes get allocated for load that’s already there.

What Karpenter does differently

Karpenter drops node groups as a middle abstraction entirely. Instead of “grow this group,” it looks directly at the pod that can’t be scheduled — with all its requests, node affinity, taints, and topology constraints — and decides which specific instance, out of everything available in the cloud, closes that gap the cheapest.

Cluster Autoscaler scales a whole node group, Karpenter picks an instance per pod

Key differences:

NodePool and NodeClass: what you actually describe

Instead of a node group, Karpenter introduces two resources with a clean split of responsibility.

NodePool is the Kubernetes side: which pods this pool serves (via requirements — architecture, capacity-type, zone), aggregate CPU/memory limits for the pool, and the disruption policy (when and how Karpenter is allowed to touch already-running nodes).

EC2NodeClass (or the equivalent for another cloud) is the cloud side: AMI, subnets, security groups, disk size, node IAM role. A NodePool references a NodeClass via nodeClassRef.

The split pays off in practice: the same NodeClass (same AMI, disk, role) can be reused across several NodePools with different requirements — say, a separate pool for GPU workloads and one for general services, sharing the same cloud-side config.

Consolidation: how the cluster shrinks itself

This is what actually moves the bill. Karpenter runs a disruption controller that constantly looks for two cases:

Before consolidation pods are spread over underused nodes; after, the same load runs on fewer, tightly packed nodes

This is controlled by the consolidationPolicy field on the NodePool:

Spot, on-demand, and what happens on interruption

Karpenter can mix capacity types in a single NodePool via requirements on karpenter.sh/capacity-type: [spot, on-demand]. When choosing an instance, it first looks for spot capacity that fits, and falls back to on-demand if none is available — no manual intervention needed.

Separately, Karpenter watches the spot interruption notice — the cloud’s two-minute warning that a specific spot instance is about to be reclaimed. On that signal it starts preparing a replacement early and drains pods gracefully, instead of waiting for the node to vanish and the pods to come back up already Pending.

One NodePool picks spot or on-demand and reacts to interruption early

Traps that quietly kill the savings

Side by side

Cluster AutoscalerKarpenter
Unit of scalinga whole node groupone instance per pending pod
Tuning for new loadmanually pre-provision groupsrequirements in NodePool, no pre-set groups
Time to a ready node~1.5–3 minutesseconds
Packing idle nodesempty nodes onlyempty + underused (consolidationPolicy)
Spot/on-demand fallbackseparate groups per typeone NodePool, automatic
Support beyond AWSdepends on the cloud providerAWS, Azure, GCP, bare metal (via providers)

What it takes to turn this on

At minimum you need the Karpenter controller installed (official Helm chart) with IAM permissions to launch/terminate instances, plus one NodeClass and one NodePool. Here’s a working AWS example with limits and aggressive consolidation:

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiFamily: AL2023
  role: karpenter-node-role
  subnetSelectorTerms:
    - tags: { karpenter.sh/discovery: my-cluster }
  securityGroupSelectorTerms:
    - tags: { karpenter.sh/discovery: my-cluster }
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
  limits:
    cpu: "1000"
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m

Three details that save debugging time:

How to check it’s actually working

Run a pod with deliberately small requests and watch Karpenter bring up a dedicated node for it in seconds:

kubectl run test-pod --image=nginx --requests='cpu=100m,memory=128Mi'
kubectl get events --field-selector reason=Nominated -w

Check consolidation — after removing the load, confirm the node count drops without manual intervention:

kubectl get nodes -l karpenter.sh/nodepool=default -w
kubectl logs -n kube-system deployment/karpenter -f | grep -i consolidat

If nodes aren’t collapsing, check PDBs and the do-not-disrupt annotation on your pods first — nine times out of ten a stuck consolidation traces back to one of those.

Bottom line

Node-level autoscaling driven by actual load is the fastest, least disruptive FinOps win available in Kubernetes: nothing to touch in the application, nothing to rewrite, just a correctly configured NodePool and NodeClass. Most of the savings come not from Karpenter adding nodes faster, but from it not hesitating to remove and repack them the moment load drops. The cost of entry is getting PDBs and requests right enough that consolidation can actually do its job instead of stalling on the first pod with the wrong annotation.


Share this post:

Next Post
External Secrets Operator: Vault secrets in Kubernetes without copy-paste