Autoscaling: HPA, VPA & Cluster Autoscaler
Kubernetes ships three autoscalers, and each one answers a different question. The HorizontalPodAutoscaler (HPA) asks how many copies of a Pod should exist right now, and changes the replica count to match. The VerticalPodAutoscaler (VPA) asks how big each individual Pod should be, and changes its CPU and memory requests instead. Neither can conjure room that isn't there — both are bounded by whatever capacity the cluster's nodes actually have, which is where a node autoscaler, the Cluster Autoscaler or its newer, groupless alternative Karpenter, takes over: it watches for Pods stuck Pending because nothing fits them, and adds or removes whole machines to make room. CKA's Workloads & Scheduling domain expects you to stand up a basic HPA with kubectl; this page goes past that, into the metrics pipeline underneath it, the two autoscalers the exam blueprint doesn't test directly, and the specific, well-documented ways all three can end up fighting each other instead of cooperating.
Picture a bakery on a busy Saturday. The HorizontalPodAutoscaler is the manager calling in more bakers the moment the order queue gets long, and sending them home again once it's quiet — same-size bakers, just more or fewer of them. The VerticalPodAutoscaler works differently: instead of calling in more bakers, it hands each one a bigger oven and a bigger mixing bowl once it notices they keep running out of room mid-batch. Both ideas hit the same wall, though — you can only fit so many bakers, or so many big ovens, into one kitchen. That's where the Cluster Autoscaler and Karpenter come in: once the kitchen itself is full, one of them calls the landlord and rents the exact right amount of kitchen next door, then gives it back the moment nobody's using it anymore.
Three axes of scale, one shared limit
☺ Like you're 10: More copies, bigger copies, or more room to put copies in — three completely different levers, and pulling one doesn't pull the others.
It helps to fix the vocabulary before anything else, because the three autoscalers genuinely don't overlap in what they touch. HPA changes .spec.replicas on a Deployment, StatefulSet, or ReplicaSet — more or fewer identical Pods. VPA changes .spec.containers[].resources.requests (and optionally limits) on the Pods themselves — the same number of Pods, each one bigger or smaller. Cluster Autoscaler and Karpenter change neither; they change how many nodes exist, reacting to whether the Pods that HPA and VPA already decided on actually have anywhere to run. All three are control loops in the same reconciliation sense as the object model and the controller pattern — a scaling decision is a special case of "does observed state match desired state, and if not, issue an API write to close the gap" — just applied to a replica count, a resource request, or a node pool instead of a workload's own spec.
How the HorizontalPodAutoscaler decides: the control loop and the formula
☺ Like you're 10: Every so often the manager checks the order queue, does one piece of math, and rounds up — never a guess, always the same formula.
HPA runs as a control loop inside kube-controller-manager, polling on a fixed interval — 15 seconds by default, set with the --horizontal-pod-autoscaler-sync-period flag on that binary. Each tick, for every HPA object in the cluster, it fetches the current value of every metric the HPA references, and for each metric it applies the same formula:
desiredReplicas = ceil[ currentReplicas × ( currentMetricValue / desiredMetricValue ) ]
If a Deployment is running 4 replicas averaging 90% CPU against a 70% target, that's ceil(4 × (90/70)) = ceil(5.14) = 6 — HPA patches .spec.replicas to 6 and stops; it never asks whether there's room for those 2 new Pods, because placing Pods was never HPA's job to begin with. It's exactly the same handoff Kubernetes Architecture covers for kube-scheduler: HPA writes a desired number, and kube-scheduler is the one that has to go find somewhere to put what that number implies. When an HPA object references more than one metric, HPA computes a desired replica count per metric independently and then takes the largest of them — the logic being that if any single metric says the workload needs more capacity, that's binding, even if every other metric would have been satisfied with fewer replicas.
To stop a workload from thrashing over noise near its target, HPA applies a tolerance band — by default ±10% around the desired-to-current ratio — inside which it does nothing at all, even though the formula technically produced a slightly different number. A workload sitting at 71–77% CPU against a 70% target with tolerance applied simply doesn't move; only a ratio outside that band triggers an actual scale event.
Where HPA gets its numbers: resource, custom, and external metrics
☺ Like you're 10: CPU and memory come from one built-in gauge; anything else — requests per second, a queue's length — needs a different gauge plugged in first.
HPA never measures anything itself; it reads from one of three Kubernetes metrics APIs, and which one an HPA metric block uses decides what has to be running in the cluster before that block can work at all.
metrics.k8s.io— resource metrics. CPU and memory only, served by metrics-server, which itself scrapes every kubelet's/stats/summaryendpoint (cAdvisor data) on a short interval and holds only the latest snapshot in memory — no history, no persistence. This is the metrics APIkubectl topreads from too, and it's what a plaintype: ResourceHPA metric block uses. No metrics-server running means no CPU/memory HPA can compute anything, full stop.custom.metrics.k8s.io— custom metrics. Anything tied to a Kubernetes object — requests per second on a Pod, queue depth labeled by a Deployment — served by an adapter you install yourself, almost always the Prometheus Adapter, translating a PromQL query into this API's shape on demand.external.metrics.k8s.io— external metrics. A number with no Kubernetes object behind it at all — an SQS queue's message count, a Kafka consumer group's lag, a managed database's connection count. This is the API KEDA implements: KEDA'sScaledObjectdoesn't replace HPA's algorithm, it creates and manages an HPA on your behalf, wired to KEDA's own metrics adapter as the external-metric source — which is also the one legitimate way to get true scale-to-zero. Ordinary HPA can only dropminReplicasto 0 when every metric it references is External or Object (never Resource), which is narrow enough in practice that scale-to-zero on Kubernetes almost always means KEDA rather than a hand-written HPA.
metrics-server is a single Deployment with no built-in high availability by default, and it is trivially easy to under-request its own CPU on a large cluster — it has to scrape every kubelet, and a metrics-server that's throttled or OOMKilled means kubectl top returns nothing and every Resource-based HPA silently stalls, holding its last-known replica count instead of erroring loudly. Treat metrics-server's own resource requests, and its Pod's spread across nodes, as production infrastructure — not a fire-and-forget add-on.
Writing an HPA that won't flap: multiple metrics and behavior policies
☺ Like you're 10: Scaling up fast when busy and down slowly when quiet keeps the manager from calling bakers in and sending them home again every five minutes.
The autoscaling/v2 API lets one HPA reference several metrics at once and control the shape of its own reaction with a behavior block — the piece that actually prevents flapping:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: checkout-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: checkout-api
minReplicas: 3
maxReplicas: 30
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
- type: Resource
resource:
name: memory
target: { type: Utilization, averageUtilization: 80 }
- type: Pods
pods:
metric: { name: http_requests_per_second }
target: { type: AverageValue, averageValue: "500" }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react to a spike immediately
policies:
- { type: Percent, value: 100, periodSeconds: 30 }
- { type: Pods, value: 4, periodSeconds: 30 }
selectPolicy: Max # take whichever policy adds more
scaleDown:
stabilizationWindowSeconds: 300 # wait 5min of sustained low load
policies:
- { type: Percent, value: 10, periodSeconds: 60 }
selectPolicy: MinThe asymmetry in that example is deliberate and idiomatic: scale-up defaults to reacting instantly (stabilizationWindowSeconds: 0) because under-provisioning during a real spike costs latency and error budget right now, while scale-down defaults to a 300-second stabilization window because removing capacity too eagerly, only to need it back ninety seconds later, is exactly the flapping pattern that erodes trust in autoscaling. A stabilization window doesn't delay the decision — it looks back across that whole window and picks the least aggressive recommendation seen within it, so one brief dip in load can't trigger a scale-down the metric would take back thirty seconds later.
# Quick imperative HPA — same object autoscaling/v2 creates declaratively kubectl autoscale deployment checkout-api --cpu-percent=70 --min=3 --max=30 # metrics-server has to be up before any Resource-metric HPA can compute anything kubectl top pods -n checkout kubectl top nodes # Watch the loop decide, live — Events at the bottom name the exact reason kubectl get hpa checkout-api --watch kubectl describe hpa checkout-api
VerticalPodAutoscaler: right-sizing the Pod instead of the replica count
☺ Like you're 10: Instead of calling in more bakers, VPA watches how much room each one actually needs and hands out a bigger — or smaller — oven.
VPA isn't one component; it's three, shipped together, each with a narrow job:
- Recommender — watches each targeted Pod's actual CPU and memory usage over time (plus its OOMKill history) and computes a recommendation using a decaying-histogram, percentile-based algorithm — not a snapshot, a trend. It exposes
lowerBound,target, andupperBoundon the VPA object's status, readable at any time even inOffmode. - Updater — checks running Pods against the Recommender's current output and, in modes that allow it, evicts a Pod whose requests have drifted meaningfully from the recommendation, so its owning controller recreates it with fresh, correctly-sized requests. It respects PodDisruptionBudgets the same way Kubernetes Best Practices expects any voluntary disruption to.
- Admission Controller — a mutating webhook that intercepts every new Pod matching a VPA and rewrites its container resource requests to the current recommendation at creation time. This is the actual mechanism behind eviction-driven resizing: VPA doesn't patch a running Pod's resources in place, it evicts and lets this webhook set the new numbers the moment the replacement Pod is admitted.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: checkout-worker
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: checkout-worker
updatePolicy:
updateMode: "Auto"
resourcePolicy:
containerPolicies:
- containerName: worker
minAllowed: { cpu: 100m, memory: 128Mi }
maxAllowed: { cpu: "2", memory: 4Gi }
controlledResources: ["cpu", "memory"]updateMode controls how far VPA is allowed to act on its own recommendation: Off only computes and exposes it for a human to read, useful purely for sizing guidance; Initial sets requests once, at Pod creation, and never touches a running Pod again; Recreate and Auto both evict and let the Admission Controller re-set requests on the replacement — today they behave identically, but Auto is the forward-looking name, since Kubernetes' own in-place Pod resize feature (beta as of 1.33) lets a container's resources change without a restart at all, and newer VPA releases are starting to use exactly that path under Auto where the cluster and container runtime support it, instead of an eviction every time.
Cluster Autoscaler: scaling nodes to match Pending Pods
☺ Like you're 10: When the kitchen's full, it doesn't guess how much space to rent — it counts exactly the orders that don't fit, then calls for that much more room.
Cluster Autoscaler's trigger is narrow and specific: a Pod sitting in Pending because kube-scheduler's filtering phase (see Kubernetes Architecture) eliminated every existing node on resource grounds. It doesn't watch CPU or memory utilization directly at all — a cluster running at 95% average utilization with every Pod successfully placed triggers nothing, and a cluster running at 5% utilization with one Pod that needs a GPU nobody has triggers a scale-up immediately. Once triggered, it evaluates each configured node group — an AWS Auto Scaling Group, a GCP managed instance group, an Azure VMSS — by simulating whether that group's template would actually fit the stuck Pod, then increases the size of whichever group can, and waits for the cloud to boot and register the new node.
Scale-down runs on a separate, gentler clock: a node is only a scale-down candidate once every Pod on it could be rescheduled elsewhere and its overall utilization has sat below a threshold (50% by default) continuously for --scale-down-unneeded-time (10 minutes by default) — and even then, several conditions block it outright: a Pod without a controller behind it, a Pod using local storage that isn't annotated cluster-autoscaler.kubernetes.io/safe-to-evict: "true", or a PodDisruptionBudget that the eviction would violate. That last one is the same guardrail as VPA's Updater — Cluster Autoscaler will leave an underused node running indefinitely rather than force an eviction a PDB explicitly forbids.
containers: - name: cluster-autoscaler image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.30.0 command: - ./cluster-autoscaler - --cloud-provider=aws - --nodes=2:10:eks-general-purpose # min:max:node-group-name - --nodes=0:20:eks-spot-pool - --scale-down-enabled=true - --scale-down-unneeded-time=10m - --scale-down-utilization-threshold=0.5 - --expander=least-waste - --balance-similar-node-groups
When more than one node group could fit a Pending Pod, the --expander flag decides which one wins: random for no preference, most-pods to pick the group that would schedule the most currently-Pending Pods at once, least-waste to minimize leftover CPU/memory after placement, priority to follow an operator-defined ranking, or price to favor the cheapest option the cloud provider reports — most production setups run least-waste or priority, since random makes capacity planning unpredictable and most-pods can bias toward oversized nodes.
Karpenter's different bet: groupless, just-in-time provisioning
☺ Like you're 10: Instead of picking from three van sizes you already own, it measures the actual pile and rents the exact right van, from the whole catalog, on the spot.
Cluster Autoscaler's whole model assumes node groups exist first and picks among them; Karpenter, donated to the Kubernetes project by AWS and now a provider-neutral core under kubernetes-sigs/karpenter, removes that assumption entirely. There's no ASG or VMSS to pre-create — instead you define a NodePool (constraints: which instance families, architectures, and capacity types like on-demand or spot are allowed) and a cloud-specific NodeClass (AMI, subnets, security groups), and for each batch of Pending Pods Karpenter evaluates the entire instance-type catalog the NodePool permits and launches whichever specific instance fits that batch most efficiently — often a different instance type on every single scale-up. It also runs consolidation continuously in the background, not just on a scale-down timer: it constantly asks whether the Pods currently spread across several under-packed nodes would fit on fewer, or cheaper, ones, and replaces nodes to make that true even while the cluster is otherwise idle-quiet, not just shrinking.
| Dimension | Cluster Autoscaler | Karpenter |
|---|---|---|
| Unit of scaling | Node groups — homogeneous by construction | Individual nodes, chosen per Pending batch |
| Instance selection | Whichever pre-defined group's template says | Best fit from the whole permitted instance catalog |
| Bin-packing | Only within one group's fixed instance shape | Continuous consolidation across any allowed type |
| Setup | Point at ASGs / MIGs / VMSS you already manage | NodePool + NodeClass CRDs — no group to pre-create |
| Cloud support | Broadest — every major CA-supported cloud | AWS most mature; Azure via AKS Node Auto Provisioning; growing |
Neither tool is strictly "better" — Cluster Autoscaler's node-group model is the more portable, more predictable-capacity-planning choice, and it's what most non-AWS clusters still run; Karpenter trades that predictability for tighter bin-packing and materially faster scale-up on the clouds it supports well. This course's own Cluster Autoscaler and Karpenter tool guides go deeper on configuring each one, and Platform Engineering's Scaling, Scheduling & Performance and Karpenter pages cover NodeClaims, drift detection, and disruption budgets at implementation depth beyond what this page repeats.
Where these three fight each other
☺ Like you're 10: Two managers changing the same thing for different reasons at the same time don't cooperate — they argue, and the kitchen never settles down.
The single most common production incident in this space isn't any one autoscaler misbehaving — it's two of them targeting the same resource on the same workload. Point an HPA and a VPA at CPU or memory on the same Deployment and you get a genuine feedback loop: VPA raises the Pod's CPU request, which lowers that Pod's CPU utilization percentage for the same absolute usage, which HPA reads as "load dropped" and scales down — even though nothing about real demand changed at all. The upstream VPA project documents this explicitly as unsupported: never let HPA and VPA both react to CPU or memory on one workload. The safe combination is VPA managing memory or CPU while HPA scales on a genuinely independent signal — requests-per-second, queue depth — so the two loops never read each other's output as their own input.
Resource requests aren't just a scheduling hint — they're the denominator in HPA's own math (a percentage is measured against the request) and the exact field VPA exists to change, which is why Scheduling & Resource Management covers requests and limits first: get that page's fundamentals wrong on a workload and every autoscaler layered on top of it inherits the mistake, silently.
The second collision is between HPA and the node autoscaler: HPA can decide to add ten replicas in thirty seconds, but Cluster Autoscaler still has to wait for a real cloud API call and a real machine to boot — commonly one to three minutes even on a fast provider, longer on most. Those ten new Pods sit Pending the entire time, which is neither broken nor instant; it's two control loops with genuinely different reaction speeds, chained together, and readiness/liveness probes plus a sane minReplicas floor are what keep that gap from becoming a user-visible outage rather than a brief, boring queue.
On a kind cluster: install metrics-server (kind's default kubelet cert setup usually needs --kubelet-insecure-tls added to its args to work at all), deploy any small HTTP app with explicit CPU requests, then run kubectl autoscale deployment app --cpu-percent=50 --min=1 --max=5. Generate load with a throwaway Pod running while true; do wget -q -O- http://app; done, and watch kubectl get hpa app --watch scale up within a couple of sync intervals. Kill the load and time how long scale-down actually takes against the 300-second default stabilization window — reading the real number is the fastest way to build intuition for how conservative that default really is.
The full stack, one push of load
☺ Like you're 10: One busy moment can ripple through every layer on this page in order — and each layer only ever does its own one job before handing off to the next.
Putting the whole chain together end to end: load rises, metrics-server or a custom-metrics adapter reports higher utilization, HPA's control loop runs its formula and patches the Deployment's replica count upward, and the Deployment/ReplicaSet controllers create the new Pods. From there it forks. If existing nodes have room, kube-scheduler binds the new Pods immediately and the story ends in seconds. If they don't, the new Pods stay Pending, Cluster Autoscaler or Karpenter notices exactly that signal, provisions a node, and only once it's registered does kube-scheduler get a feasible node to bind against.
"I used to treat a Pending Pod during a scale-up as a bug. It isn't — it's the honest gap between how fast HPA can decide something and how fast a cloud can boot a machine. What actually matters is whether minReplicas and readiness probes are set high enough that the gap never shows up to a user. Do that arithmetic once, slowly, with real p99 latencies and real boot times, and you stop needing to panic every time kubectl get pods shows a few Pending rows for ninety seconds."
Benny the Beaver: Checkout's HPA keeps scaling down right when traffic's climbing. I watched it happen twice this morning.
Sol the Sloth: Show me the HPA and the VPA on that Deployment. Slowly. What metric is each one actually watching?
Benny the Beaver: HPA's on CPU utilization. VPA's... also on CPU, I set that last week to stop OOMKills.
Sol the Sloth: There it is. VPA raised the CPU request to fix the OOMKills — good call — but that same raise dropped the utilization percentage HPA reads, for the exact same real load. HPA sees "less busy" and scales down. Nothing about actual traffic changed.
Gizmo the Gremlin: Easy fix — turn VPA off, it's obviously the troublemaker. 🤑
Timmy the Turtle: That brings the OOMKills right back. The fix isn't removing a layer, it's not stacking two autoscalers on the same signal.
Sol the Sloth: Keep VPA on memory only. Point HPA at requests-per-second instead of CPU. Two loops, two genuinely different signals — now neither one can mistake the other's work for a change in real demand.
1. In one sentence each, what does HPA change, what does VPA change, and what do Cluster Autoscaler and Karpenter change? 2. Write out HPA's scaling formula and walk through it for 4 replicas averaging 90% CPU against a 70% target. 3. Which Kubernetes metrics API does a plain CPU/memory HPA metric depend on, and what happens to that HPA if the component serving it is throttled? 4. Why is combining HPA and VPA on the same CPU or memory metric unsupported — what's the actual feedback loop? 5. What specific signal triggers Cluster Autoscaler's scale-up, and name two conditions that can block its scale-down even on a genuinely idle node. 6. What's the core structural difference between how Cluster Autoscaler and Karpenter choose an instance to add?
Check your answers
- HPA changes a workload's replica count (same-size Pods, more or fewer). VPA changes an individual Pod's CPU/memory requests (same replica count, bigger or smaller Pods). Cluster Autoscaler and Karpenter change how many nodes exist, reacting to whether HPA's and VPA's decisions actually fit anywhere.
desiredReplicas = ceil[currentReplicas × (currentMetricValue / desiredMetricValue)]. For 4 replicas at 90% against a 70% target:ceil(4 × (90/70)) = ceil(5.14) = 6replicas.metrics.k8s.io, served by metrics-server. If metrics-server is throttled or OOMKilled,kubectl topreturns nothing and any Resource-based HPA silently stalls at its last-known replica count — it doesn't error loudly, which is exactly what makes it dangerous to leave unmonitored.- VPA raising a Pod's CPU request lowers that Pod's CPU utilization percentage for the same absolute usage, which HPA then reads as reduced load and scales down — even though real demand never changed. The upstream VPA project documents this combination as explicitly unsupported; the fix is having VPA and HPA react to different, independent signals.
- A Pod stuck
Pendingbecause kube-scheduler's filtering phase eliminated every existing node on resource grounds — Cluster Autoscaler doesn't watch raw utilization at all. Scale-down on an idle node is blocked by, among other things, a Pod using local storage not annotated safe-to-evict, or a PodDisruptionBudget the eviction would violate. - Cluster Autoscaler picks among pre-existing, homogeneous node groups (one instance type per group) using an expander strategy. Karpenter has no groups at all — for each batch of Pending Pods it evaluates the entire permitted instance-type catalog and launches whichever specific instance fits that batch best, then continuously consolidates afterward.