📐 VPA — Vertical Pod Autoscaler
The HPA scales horizontally (more pods). The VPA scales vertically — it watches a workload's actual resource usage and automatically adjusts the requests and limits on containers. This solves the most common cost problem: over-provisioned requests that waste money and block scheduling.
📊 Off mode
VPA only recommends new requests/limits — never applies them. Use this to understand what your pods actually need without disrupting production.
🔍 Initial mode
VPA applies recommendations only when pods are first created. No restarts of existing pods. Safe for stateful workloads.
⚡ Auto mode
VPA applies recommendations immediately — evicting pods to restart them with new resource values. Only use with multi-replica workloads where disruption is acceptable.
🎯 Recreate mode
Like Auto but only restarts pods when they are OOMKilled or exceed limits. Less disruptive than Auto.
Installing VPA
# Install VPA components (recommender, updater, admission controller)
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-install.sh
# Or via Helm
helm repo add fairwinds-stable https://charts.fairwinds.com/stable
helm install vpa fairwinds-stable/vpa --namespace vpa --create-namespace
VPA in Off mode — discover right-sizing
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: checkout-api-vpa
namespace: production
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: checkout-api
updatePolicy:
updateMode: "Off" # recommend only — never evict
resourcePolicy:
containerPolicies:
- containerName: app
minAllowed:
cpu: 50m
memory: 64Mi
maxAllowed:
cpu: 4000m
memory: 4Gi
# After running for 24h+, read the recommendations:
kubectl describe vpa checkout-api-vpa -n production
# VPA recommendation output:
# Recommendation:
# Container Recommendations:
# Container Name: app
# Lower Bound:
# cpu: 50m ← minimum safe request
# memory: 180Mi
# Target:
# cpu: 120m ← recommended request (what VPA would set)
# memory: 320Mi
# Upper Bound:
# cpu: 500m ← maximum seen; set as limit
# memory: 680Mi
# Your manifest was requesting: cpu: 1000m, memory: 1Gi
# VPA recommends: cpu: 120m, memory: 320Mi
# Savings: 88% CPU, 69% memory — typical for over-provisioned services
Auto mode and HPA on CPU/memory for the same workload simultaneously — they fight each other. Safe combinations: VPA Auto + HPA on custom metrics (KEDA), or VPA Off/Initial + HPA on CPU. The updateMode: "Off" pattern is safe with any HPA.
⚡ Karpenter — Next-Generation Node Provisioning
Karpenter (from AWS, now CNCF) takes a different approach to CA. Instead of managing pre-configured node groups, Karpenter provisions exactly the right instance type for each pending pod — directly calling the cloud API, no ASG required. Result: faster provisioning (~60s vs 3–5 min), better bin-packing, and dramatically simpler spot management.
Core concepts
NodePool
Defines constraints for nodes Karpenter can provision: instance families, zones, capacity type (spot/on-demand), OS, architecture. Karpenter picks the cheapest matching instance.
EC2NodeClass (AWS)
AWS-specific config: AMI family, subnet selectors, security groups, instance profile. Analogous to a launch template.
Just-in-time provisioning
When a pod is Pending, Karpenter evaluates its requirements (resources, affinity, topology) and launches the cheapest node that satisfies them — in ~60 seconds.
Consolidation
Karpenter continuously evaluates if nodes can be consolidated — evicting pods from underutilised nodes and replacing two small nodes with one larger cheaper one.
Karpenter NodePool — production configuration
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: default
spec:
template:
metadata:
labels:
karpenter.sh/nodepool: default
spec:
nodeClassRef:
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: [spot, on-demand] # prefer spot, fall back to OD
- key: kubernetes.io/arch
operator: In
values: [amd64]
- key: karpenter.k8s.aws/instance-category
operator: In
values: [c, m, r] # compute, general, memory families
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["2"] # only recent generations
limits:
cpu: "1000" # max total CPU across all Karpenter nodes
memory: 4000Gi
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 30s # consolidate quickly in dev, longer in prod
expireAfter: 720h # rotate nodes every 30 days (security hygiene)
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
name: default
spec:
amiFamily: AL2
role: KarpenterNodeRole-my-cluster
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: my-cluster
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: my-cluster
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 100Gi
volumeType: gp3
encrypted: true
⚖️ CA vs Karpenter — Decision Guide
| Dimension | Cluster Autoscaler | Karpenter |
|---|---|---|
| Provisioning speed | 3–5 minutes (ASG launch + bootstrap) | ~60 seconds (direct EC2 API) |
| Instance selection | Pre-defined node groups only | Picks cheapest from 300+ instance types |
| Spot management | Needs separate node groups per type | Automatic fallback across families/sizes |
| Bin-packing | Moderate — limited by node group sizes | Excellent — right-sizes node to pod requests |
| Consolidation | Limited scale-down heuristics | Active consolidation (replaces 2 nodes with 1) |
| Cloud support | All major clouds + on-prem | AWS (native), Azure/GCP (via community providers) |
| Complexity | Moderate — manage node group config | Lower — declare constraints, Karpenter decides |
| Maturity | Very mature, widely deployed | Production-ready since 0.30, rapidly adopted |
- VPA (Off mode) — continuously recommends right-sized requests; apply manually or in Initial mode
- HPA / KEDA — scales pod count based on load or event queues
- Karpenter — provisions and removes nodes as pod count changes
🖥️ Cluster Autoscaler
The Cluster Autoscaler (CA) watches for unschedulable pods (Pending because no node has room) and adds nodes to the cluster. When nodes are underutilised and their pods can be moved, it removes them. CA works with cloud provider node groups (AWS ASGs, GCP MIGs, Azure VMSS).
Installing CA on AWS EKS
# 1. Tag your Auto Scaling Groups (required for CA discovery)
# Tags: k8s.io/cluster-autoscaler/enabled = true
# k8s.io/cluster-autoscaler/my-cluster = owned
# 2. Deploy CA (Helm)
helm repo add autoscaler https://kubernetes.github.io/autoscaler
helm install cluster-autoscaler autoscaler/cluster-autoscaler \
--namespace kube-system \
--set autoDiscovery.clusterName=my-cluster \
--set awsRegion=us-east-1 \
--set rbac.serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::123456789:role/cluster-autoscaler
# 3. Verify CA is running
kubectl logs -n kube-system -l app.kubernetes.io/name=aws-cluster-autoscaler --tail=20
Key CA configuration flags
# Critical flags to tune (set via helm --set or extraArgs)
--scale-down-delay-after-add=10m # wait 10m after scale-up before evaluating scale-down
--scale-down-unneeded-time=10m # node must be unneeded for 10m before removal
--scale-down-utilization-threshold=0.5 # scale down if node is <50% requested
--max-node-provision-time=15m # give up waiting for node after 15m
--balance-similar-node-groups=true # spread across AZs evenly
--skip-nodes-with-system-pods=true # don't evict nodes with kube-system pods
--expander=least-waste # pick node group with smallest resource waste
Spot/preemptible nodes with CA
# Use multiple node groups: on-demand + spot, with priority expander
--expander=priority
# Priority ConfigMap: try spot first, fall back to on-demand
apiVersion: v1
kind: ConfigMap
metadata:
name: cluster-autoscaler-priority-expander
namespace: kube-system
data:
priorities: |
10:
- .*spot.* # lowest priority number = try first
20:
- .*on-demand.* # fallback to on-demand
--scale-down-unneeded-time=2m. In production, 10m is appropriate — the cost of keeping an extra node for a few minutes is negligible vs the disruption of premature scale-down.