Horizontal Pod Autoscaling
The idea in one sentence
Section titled “The idea in one sentence”A HorizontalPodAutoscaler watches a metric against a target and adjusts a Deployment’s replicas between a minimum and maximum to keep that metric near the target — nothing more.
Two hard prerequisites before HPA does anything
Section titled “Two hard prerequisites before HPA does anything”The HorizontalPodAutoscaler (autoscaling/v2) sounds simple, but it silently does nothing at all unless two things are true first.
- The workload’s Pods must have resource
requestsset. By default HPA scales on CPU or memory utilization, and utilization is calculated as a percentage of what a Pod requested — no requests means there is no denominator to compute a percentage from. metrics-server(or a custom/external metrics adapter) must be installed in the cluster. HPA does not measure anything itself; it reads metrics that something else collected and exposed through the metrics API. No metrics source, no scaling decision, ever.
Proof: no requests, real unknown and FailedGetResourceMetric
Section titled “Proof: no requests, real unknown and FailedGetResourceMetric”apiVersion: apps/v1kind: Deploymentmetadata: name: web-appspec: replicas: 2 selector: matchLabels: app: web-app template: metadata: labels: app: web-app spec: containers: - name: web-app image: registry.k8s.io/pause:3.10---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: web-appspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-app minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60Even with metrics-server installed and working, this Deployment has no resources.requests on its container, and the real result is exactly what the prerequisite predicts:
$ kubectl get hpa web-appNAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGEweb-app Deployment/web-app cpu: <unknown>/60% 2 10 2 30s
$ kubectl describe hpa web-appConditions: Type Status Reason Message ---- ------ ------ ------- AbleToScale True SucceededGetScale the HPA controller was able to get the target's current scale ScalingActive False FailedGetResourceMetric the HPA was unable to compute the replica count: failed to get cpu utilization: missing request for cpu in container web-app of Pod web-app-6d4d8d45d8-fl6rgEvents: Warning FailedGetResourceMetric failed to get cpu utilization: missing request for cpu in container web-app of Pod web-app-6d4d8d45d8-fl6rgcpu: <unknown>/60% and the real FailedGetResourceMetric reason with the exact missing-request message — not a made-up error, this is what a live 1.37 cluster returns the moment metrics-server tries to compute utilization and finds nothing to divide by.
Installing metrics-server on kind, for real
Section titled “Installing metrics-server on kind, for real”metrics-server is not part of kind by default and needs one real patch to tolerate kind’s self-signed kubelet certificates:
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yamlkubectl -n kube-system patch deployment metrics-server --type=json \ -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'kubectl -n kube-system rollout status deploy/metrics-server$ kubectl top nodesNAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)scheduling-control-plane 169m 1% 847Mi 9%scheduling-worker 43m 0% 163Mi 1%Without --kubelet-insecure-tls, metrics-server on kind fails its own TLS verification against the kubelet’s self-signed serving certificate and never reports anything — a real, common first-run gotcha, not a hypothetical one.
Defining the HorizontalPodAutoscaler
Section titled “Defining the HorizontalPodAutoscaler”With requests in place and metrics-server running, the HPA object itself just names a target and a metric:
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: web-appspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-app minReplicas: 3 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60This says: keep average CPU utilization across all web-app Pods near 60% of their requested CPU, scaling the Deployment anywhere between 3 and 20 replicas to hold that line. HPA also supports scaling on custom, non-resource metrics — queue depth, requests-per-second, and similar — through the custom.metrics.k8s.io and external.metrics.k8s.io APIs, for teams whose bottleneck is not CPU or memory at all.
autoscaling/v1 vs v2: one object, two shapes, proven
Section titled “autoscaling/v1 vs v2: one object, two shapes, proven”autoscaling/v1 predates custom/external metrics and multi-metric support — it only knows a single, flat targetCPUUtilizationPercentage. autoscaling/v2 replaced it with a metrics: [] array that can hold several metrics of different types at once. Both names refer to the same underlying object; the API server converts between the two shapes on the fly. Proof — apply as v1, read back as v2:
apiVersion: autoscaling/v1kind: HorizontalPodAutoscalermetadata: name: web-appspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-app minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 60$ kubectl get hpa.v2.autoscaling web-app -o yamlspec: metrics: - resource: name: cpu target: averageUtilization: 60 type: Utilization type: Resource minReplicas: 2 scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: web-app}The object was created with targetCPUUtilizationPercentage: 60 under autoscaling/v1, and reading the exact same object back through the v2 endpoint shows it converted, automatically, into metrics[0].resource.target.averageUtilization: 60 — no re-apply, no separate object, just the API server’s built-in version conversion doing its job.
behavior: controlling how fast HPA scales up and down
Section titled “behavior: controlling how fast HPA scales up and down”autoscaling/v2 adds behavior.scaleUp/behavior.scaleDown, each with a stabilizationWindowSeconds and one or more rate-limiting policies:
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: load-appspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: load-app minReplicas: 1 maxReplicas: 5 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 50 behavior: scaleDown: stabilizationWindowSeconds: 60 policies: - type: Pods value: 1 periodSeconds: 30 scaleUp: stabilizationWindowSeconds: 0 policies: - type: Percent value: 100 periodSeconds: 15scaleDown.stabilizationWindowSeconds: 60 makes HPA look back over the last 60 seconds of recommendations and pick the highest replica count seen in that window before scaling down — this is what stops a single brief dip in traffic from immediately shedding replicas that will be needed again a minute later. scaleUp.stabilizationWindowSeconds: 0 with a Percent/100/periodSeconds: 15 policy means “react immediately, and allow doubling the replica count every 15 seconds” — deliberately asymmetric: cautious on the way down, aggressive on the way up.
Real scale-up under load, proven
Section titled “Real scale-up under load, proven”A Deployment running stress --cpu 1 (one busy-loop per Pod) against a cpu: "100m" request, with the behavior above:
$ kubectl get hpa load-appNAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGEload-app Deployment/load-app cpu: 201%/50% 1 5 2 49s...load-app Deployment/load-app cpu: 200%/50% 1 5 4 84s
$ kubectl describe hpa load-appEvents: Normal SuccessfulRescale New size: 2; reason: cpu resource utilization (percentage of request) above target Normal SuccessfulRescale New size: 4; reason: cpu resource utilization (percentage of request) above target Normal SuccessfulRescale New size: 5; reason: cpu resource utilization (percentage of request) above targetcpu: 200%/50% is real: each stress Pod consumes far more than its 100m request, metrics-server reports it, and HPA doubles the replica count on each 15-second window (1 → 2 → 4 → capped at maxReplicas: 5) exactly as the Percent/100 scale-up policy allows — every SuccessfulRescale event above came from a real controller reconcile against real metrics, not a simulated trace.
minReplicas: 0: only for custom/external metrics
Section titled “minReplicas: 0: only for custom/external metrics”minReplicas can be 0, but the docs are explicit that scaling to and from zero only works with custom or external metrics — not plain CPU/memory resource metrics. The reason follows directly from the two prerequisites above: with zero replicas there is no Pod left to report CPU/memory utilization from, so a resource-metric HPA has no signal to ever scale back up on. A custom/external metric (queue depth, a message count) does not depend on a running Pod to produce a value, so it can trigger the first replica.
What HPA does not do
Section titled “What HPA does not do”It is worth being precise about the boundary here, because two other autoscalers sound similar but do completely different jobs:
- HorizontalPodAutoscaler changes how many replicas a Deployment or StatefulSet has. That is the entire job.
- VerticalPodAutoscaler (VPA) changes the
requests/limitsof the Pods that already exist, resizing them instead of adding more of them — mentioned here for contrast only; this course does not install one, see the Kubernetes VPA docs for the full picture. - Cluster Autoscaler changes how many Nodes the cluster has, adding or removing machines underneath everything else (the next lesson covers this).
flowchart LR pods["Deployment Pods"] --> ms["metrics-server"] ms --> hpa["HorizontalPodAutoscaler controller"] hpa -->|"scaleUp: fast, per behavior policy"| replicas["Deployment.spec.replicas"] hpa -->|"scaleDown: stabilized over a window"| replicas
Mental Model
Section titled “Mental Model”HPA is a proportional controller with exactly one input (a metric’s current value vs its target) and exactly one output (a replica count), and every prerequisite in this lesson exists to make that one input meaningful: requests gives utilization a denominator, metrics-server gives it a numerator, and behavior shapes how aggressively the output is allowed to move once the input is known. autoscaling/v1 and v2 are not two different controllers — they are two different views of the same object, proven above by the API server’s own conversion, so “which version should I use” is really “which shape do I want to read and write,” not a behavioral choice.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| CPU/memory resource metrics | Zero extra infrastructure beyond metrics-server | No signal at all without requests set (proven above); cannot scale to/from zero |
| Custom/external metrics | Can scale on the thing that actually matters (queue depth, RPS); supports minReplicas: 0 | Requires a metrics adapter and a real pipeline feeding it — more moving parts |
Aggressive scaleUp behavior (proven above: doubling every 15s) | Reacts to a real traffic spike almost immediately | Can overshoot on a brief, non-sustained spike, briefly over-provisioning |
Conservative scaleDown.stabilizationWindowSeconds | Absorbs short dips without flapping replica count down and back up | Slower to release capacity after load genuinely drops, costing money longer than strictly needed |
autoscaling/v1 (targetCPUUtilizationPercentage) | Simple, one field | No multi-metric support, no behavior block — proven above, it’s a strict subset of what v2 expresses |
Common Misconceptions
Section titled “Common Misconceptions”- “HPA measures CPU/memory usage itself.” Proven above: HPA reads from the metrics API; without
metrics-server(or an adapter) installed, it has literally nothing to read and reports<unknown>. - “
autoscaling/v1andv2are separate, independently-configured autoscalers.” Proven above: applying asv1and reading back asv2shows the API server converting the same object’s fields automatically — there is one object, two API shapes. - “A Deployment with
limits.cpuset but norequests.cpuworks fine with HPA.” Proven above (seeFailedGetResourceMetric): HPA’s utilization percentage is requests-relative, and a missing request produces a real error, not a fallback to limits. - “
minReplicas: 0works for any HPA.” Proven via the docs cited above: it is only supported for custom/external metrics — a resource-metric HPA has no way to detect load with zero Pods running to measure. - “Scale-up and scale-down use the same caution by default.” Proven above with a real
behaviorblock: this course’s example is deliberately asymmetric (instant, doubling scale-up vs a 60-second stabilized scale-down), and that asymmetry is a common, deliberate production pattern, not a special case.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated HPA, check for:
- An HPA targeting a Deployment whose containers have no
resources.requests.cpu/memory— proven above, this produces a realFailedGetResourceMetric/<unknown>at runtime, not a slow-to-converge autoscaler. minReplicas: 0on a plain CPU/memory resource-metric HPA — proven above via the docs, this is only supported for custom/external metrics and is a silent configuration mismatch otherwise (it’s accepted at apply time; it just can never scale back up from zero).- A generated manifest mixing
autoscaling/v1field names (targetCPUUtilizationPercentage) withautoscaling/v2fields (metrics:,behavior:) in the same object — proven above, the two are different shapes of one API; a real manifest must pick oneapiVersionand use only that shape’s fields. - No
behaviorblock at all on a workload with spiky traffic — the scheduler defaults to a 5-minute scale-down stabilization window and unlimited-rate scale-up, which is not always the right default; proven above, an explicitbehaviorblock is how you actually tune this. - Assuming
metrics-serverships with every cluster by default — proven above on kind, it needed a separate install and a--kubelet-insecure-tlspatch just to report anything at all.
🐛 Spot the bug
apiVersion: apps/v1kind: Deploymentmetadata:name: web-appspec:replicas: 2selector: matchLabels: app: web-apptemplate: metadata: labels: app: web-app spec: containers: - name: web-app image: web-app:3.2.0 resources: limits: cpu: "500m" memory: "512Mi"---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata:name: web-appspec:scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-appminReplicas: 2maxReplicas: 10metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60Show the answer
The Deployment sets limits.cpu but never requests.cpu. This HPA looks completely correctly configured — target, min/max, metric type are all fine — but it will never scale on CPU, proven above with the exact real error: failed to get cpu utilization: missing request for cpu in container web-app of Pod .... HPA’s utilization math is always requests-relative (current usage / requests.cpu), and it does not fall back to limits when requests is absent. kubectl get hpa on this exact setup reports cpu: <unknown>/60% forever, and kubectl describe hpa shows ScalingActive: False, Reason: FailedGetResourceMetric — the fix is adding an explicit requests.cpu (commonly equal to or a fraction of limits.cpu), not adjusting anything about the HPA object itself.