Karpenter: Kubernetes Node Autoscaling Done Right
The Kubernetes cluster-autoscaler works by watching for unschedulable pods, finding a node group whose launch template would accommodate them, and incrementing that node group’s desired count. AWS then starts an EC2 instance from the group’s predefined launch template. When nodes are underutilized, the autoscaler drains and terminates them. This works, but it has a fundamental constraint: you must define your node groups in advance, each with a fixed instance type and configuration. Scaling up means picking which predefined box to add more of.
Karpenter removes this constraint. It watches directly for unschedulable pods and, rather than picking a node group, picks the cheapest EC2 instance type that satisfies the pod’s resource requests and scheduling constraints — from any family, any size, any generation — and provisions it directly via the EC2 Fleet API. The decision happens in 45–60 seconds. There is no node group to manage. When nodes become underutilized, Karpenter consolidates workloads and terminates the underused nodes, potentially replacing multiple smaller nodes with fewer larger ones.
For clusters with variable or unpredictable workloads, Karpenter routinely reduces EC2 spend by 25–40% through bin-packing alone, and by 50–90% on batch or CI/CD workloads where Spot is appropriate. The configuration is simpler once you understand the two core CRDs. The operational model is different enough from cluster-autoscaler that it warrants careful understanding before deploying to production.
How Karpenter Works
Karpenter is a Kubernetes controller that runs inside the cluster and watches for pods in the Pending state with the condition Unschedulable. When it finds them, it:
- Reads the pod’s resource requests (
cpu,memory, extended resources likenvidia.com/gpu) - Reads the pod’s scheduling constraints (
nodeSelector,nodeAffinity,topologySpreadConstraints,taints/tolerations) - Evaluates which NodePools the pod is eligible for
- Selects the cheapest EC2 instance type from the eligible set that fits the pod and any other pending pods that could co-locate on the same node
- Calls the EC2 Fleet API to launch the instance
- Creates a
NodeandNodeClaimobject in Kubernetes - The node joins the cluster; kubelet registers it; the pod is scheduled
┌──────────────────────────────────────────────────────────────┐
│ Karpenter Controller │
│ │
│ Watch Pods ──► Filter Unschedulable ──► Evaluate NodePools │
│ │ │
│ ▼ │
│ Select cheapest fitting │
│ instance type │
│ │ │
│ ▼ │
│ EC2 Fleet API │
│ (launch instance) │
│ │ │
│ ▼ │
│ NodeClaim → Node joins │
│ cluster → Pod scheduled │
└──────────────────────────────────────────────────────────────┘
The reverse path — consolidation — also runs continuously. Karpenter evaluates whether any node is underutilized enough that its pods could be scheduled on other existing nodes. If so, it cordons the node, evicts the pods (respecting PodDisruptionBudgets), and terminates the EC2 instance. The pods reschedule onto remaining nodes, which Karpenter may then further consolidate. This ongoing bin-packing is the source of most of the cost savings.
Installation on EKS
Karpenter requires specific IAM permissions and a running EKS cluster. The recommended installation path uses Helm after configuring the prerequisites.
IAM setup
Karpenter needs two IAM roles: one for the controller itself (to call EC2 APIs and manage instances), and one for the nodes it provisions.
|
|
The full controller policy is documented at karpenter.sh and covers EC2 Fleet, EC2 instance management, pricing API, SQS (for interruption events), and IAM PassRole to the node role. Use the policy from the official docs rather than writing it by hand — it is updated with each Karpenter version.
SQS queue for interruption events
Karpenter handles Spot interruption notices, instance health events, and scheduled maintenance by subscribing to an SQS queue fed by EventBridge:
|
|
Helm installation
|
|
The Two Core CRDs
EC2NodeClass
EC2NodeClass defines the AWS-specific infrastructure configuration for nodes Karpenter provisions. It specifies the AMI family, subnets, security groups, instance profile, and any additional node configuration.
|
|
Tag your subnets and security groups with karpenter.sh/discovery: <cluster-name> so Karpenter can discover them:
|
|
Pinning AMI versions for production: alias: al2023@latest always uses the newest AMI, which is appropriate for dev/staging but risks unexpected behavior changes in production. Pin to a specific AMI ID or use @v20250501 style versioning once you have validated an AMI:
|
|
NodePool
NodePool defines the scheduling constraints, instance types, capacity types, and disruption behavior for a class of nodes. Multiple NodePools can coexist — for example, one for general workloads and one for GPU workloads.
|
|
Spot Instances and Capacity Fallback
Listing spot before on-demand in the capacity-type requirement does not force Spot — it includes both and lets Karpenter choose. Karpenter’s scheduler prefers the cheapest option, which is usually Spot when available.
When Spot capacity is unavailable for all selected instance types, Karpenter falls back to on-demand automatically. No manual intervention required.
For workloads that must be on-demand (production databases, stateful services), use a separate NodePool that excludes Spot:
|
|
Pods that need on-demand nodes add the corresponding toleration:
|
|
Spot interruption handling
When AWS sends a Spot interruption notice (2-minute warning), Karpenter:
- Detects the notice via the SQS queue (EventBridge → SQS → Karpenter)
- Cordons the node immediately
- Begins draining pods (evicting them respecting PodDisruptionBudgets)
- Simultaneously provisions a replacement node
- Terminated node’s pods reschedule onto the new node
The entire sequence — from interruption notice to pod running on replacement — completes within the 2-minute window for most workloads. This is faster than the cluster-autoscaler + aws-node-termination-handler combination, which is a separate component with additional latency.
For batch workloads that tolerate interruption, configure pods to checkpoint state before eviction using a preStop hook or SIGTERM handler:
|
|
Consolidation and Disruption
Consolidation is Karpenter’s ongoing optimization loop. After provisioning nodes, Karpenter continuously evaluates whether the current node set is optimal. If it finds nodes whose workloads could be packed onto fewer nodes, it evicts and terminates the underused ones.
WhenEmptyOrUnderutilized is the most aggressive setting:
- Empty nodes (no pods except DaemonSets): terminated immediately after
consolidateAfterdelay - Underutilized nodes: Karpenter simulates moving the node’s pods to other nodes. If the simulation succeeds (all pods can be scheduled elsewhere), it evicts and terminates
WhenEmpty only removes completely empty nodes — safer for production but leaves underutilized capacity running.
Disruption budgets
Disruption budgets limit how many nodes Karpenter can simultaneously disrupt (for consolidation, drift, or emptiness). Without budgets, aggressive consolidation can evict many pods at once, overwhelming remaining nodes or violating application availability requirements.
|
|
Budgets interact with PodDisruptionBudgets (PDBs). Karpenter respects PDBs during eviction — if evicting a pod would violate a PDB (minAvailable or maxUnavailable), Karpenter waits. Configure PDBs for all production workloads:
|
|
Weighted NodePools
When multiple NodePools match a pod’s requirements, Karpenter uses weights to prefer one over another. Higher weight = preferred. This is useful for graduated cost tiers: prefer Spot, fall back to cheaper on-demand families, fall back to more expensive families only as a last resort.
|
|
Karpenter tries the highest-weight NodePool first. If no instance type in that pool can fit the pending pods (Spot unavailable, limits reached, instance family doesn’t fit), it falls through to the next pool by weight.
GPU Node Provisioning
GPU workloads require a dedicated NodePool using GPU instance families. Karpenter provisions GPU nodes on demand and terminates them when workloads finish — ideal for batch ML training and inference jobs that run periodically rather than continuously.
|
|
GPU-requesting pods must tolerate the taint and request the GPU resource:
|
|
Install the NVIDIA device plugin for Kubernetes to expose GPU resources to the scheduler:
|
|
Drift Detection
Drift occurs when the configuration of a running node diverges from what the NodePool or EC2NodeClass specifies — the AMI has been updated, a new instance type requirement was added, the security groups changed. Karpenter detects this automatically and marks drifted nodes for replacement.
Drift replacement follows the same disruption budget rules as consolidation. Karpenter provisions a new node matching the current spec before evicting pods from the old node. For AMI updates, this is how you perform rolling node upgrades without manual intervention:
|
|
Updating the AMI alias in the EC2NodeClass triggers drift on all nodes in NodePools referencing it. Karpenter replaces them rolling, respecting the budget. New nodes get the new AMI; existing pods move to new nodes; old nodes are terminated. A fully automated, controlled node upgrade.
Topology Spread and Node Affinity
Use topologySpreadConstraints to distribute pods across Karpenter-provisioned nodes and AZs:
|
|
When Karpenter receives multiple pending pods with zone spread constraints, it provisions nodes across AZs to satisfy the constraints rather than stacking all pods on nodes in a single AZ.
Combine with nodeAffinity to prefer certain node characteristics without hard requirements:
|
|
Observability
Key metrics
Karpenter exposes Prometheus metrics. The most operationally important:
| Metric | What it tells you |
|---|---|
karpenter_nodes_total |
Total nodes managed by Karpenter |
karpenter_nodeclaims_total |
NodeClaims by lifecycle state |
karpenter_provisioner_scheduling_duration_seconds |
Time from pod pending to node launch decision |
karpenter_nodeclaims_disrupted_total |
Nodes disrupted by reason (Consolidation, Drift, Emptiness) |
karpenter_nodeclaims_launched_total |
Nodes launched by NodePool and capacity type |
karpenter_interruption_received_messages_total |
Spot interruption events received |
karpenter_pods_state |
Pods by state (pending, bound, etc.) |
A dashboard with these metrics surfaces the provisioning latency, consolidation activity, and Spot interruption frequency in one view. The EKS Best Practices guide includes a sample Grafana dashboard.
Useful kubectl commands
|
|
Migrating from Cluster Autoscaler
Running both simultaneously during migration is supported. Cluster-autoscaler manages its node groups; Karpenter manages its own nodes. Pods scheduled to cluster-autoscaler node groups continue as before; new workloads or workloads that drain off cluster-autoscaler nodes migrate to Karpenter-provisioned nodes.
Migration sequence:
- Install Karpenter with NodePools matching your current node group configuration
- Set cluster-autoscaler node groups to
min=current,max=current(freeze scale-out) - Gradually cordon and drain cluster-autoscaler nodes — Karpenter provisions replacements
- Remove cluster-autoscaler node groups once drained
- Uninstall cluster-autoscaler
Do not set cluster-autoscaler node group min to 0 before Karpenter is handling the workload — you risk all pods going unschedulable simultaneously.
Honest Trade-offs
What Karpenter gets right. The bin-packing and consolidation are genuinely better than cluster-autoscaler. The Spot handling is native rather than bolted on. The flexibility of picking from any instance type rather than predefined node groups makes cost optimization automatic rather than manual. For teams spending meaningful money on EC2 in EKS clusters, the payback period on the operational investment is typically measured in weeks.
Consolidation disrupts workloads. Karpenter’s consolidation model means running pods get evicted and rescheduled more frequently than with cluster-autoscaler. For stateful applications, database connections, or workloads with warm caches, this matters. Configure PDBs, appropriate consolidationPolicy settings, and disruption budgets to control this — but understand that aggressive consolidation and zero disruption are in tension.
NodePool limits require attention. Forgetting to set limits in a NodePool means Karpenter can provision an unbounded number of nodes in response to a workload spike (or a misconfigured deployment with thousands of replicas). Set limits on every NodePool and configure billing alarms.
AMI pinning vs drift. Using @latest AMI aliases means Karpenter automatically replaces nodes when AWS publishes new AMIs. This is good for security but can surprise you with unexpected node churn. Pin AMIs in production and update them deliberately during maintenance windows.
EKS-only in practice. The karpenter.k8s.aws provider is AWS-specific. There are community providers for Azure (AKS) and other platforms, but the maturity and feature parity lag the AWS provider. If you run multi-cloud Kubernetes, cluster-autoscaler remains the portable option.
Karpenter on Karpenter is not supported. The Karpenter controller must run on nodes not managed by Karpenter — typically a small managed node group (2 nodes, m5.large or similar). If the Karpenter controller pod lands on a node that Karpenter is trying to consolidate, it terminates itself. Use node affinity or a dedicated node group for the controller.
Comments