| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubectl 1.36.3metrics-server v0.9.0 |
| Applies to | Any host with kubectl configured; any Kubernetes cluster |
| Cert prep | CKA |
| Lab environment | Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm. Metrics Server must be Available (v1beta1.metrics.k8s.io). |
| Privilege | Normal user for kubectl when kubeconfig is available |
| Scope | How HPA works with the Metrics API, verify Metrics Server, prepare a Deployment with CPU requests, create HPA with kubectl autoscale and autoscaling/v2, generate load to scale up and down, memory metric targets, scaling behavior, supported scale targets, brief HPA vs VPA comparison, and common failures. Does not cover custom or external metrics, Prometheus adapters, VPA setup, cluster autoscaler, or event-driven autoscaling. |
| Related guides | Requests, limits, and QoS Deployments and rolling updates Kubernetes Pods and Pod Lifecycle |
Horizontal Pod Autoscaler (HPA) changes how many Pods a Deployment or StatefulSet runs based on observed metrics. This rewrite uses Metrics Server, a small HTTP app with a CPU request, and a repeatable load generator so you can watch scale-up and scale-down on a real cluster.
autoscaling/v2). It does not install Prometheus adapters, Vertical Pod Autoscaler, or the cluster autoscaler. For Metrics Server install and kubectl top, use monitor Pod and container resources.
How HPA works
HPA is a control loop, not an instant trigger:
Metrics API
↓
HPA controller
↓
Target utilization calculation
↓
Scale subresource
↓
Deployment / StatefulSet replica countOn each sync period the controller:
- Reads current metrics for Pods selected by the scale target
- Computes a desired replica count
- Patches the object's
/scalesubresource
The Deployment controller then creates or deletes Pods to match. CPU utilization targets are percentages of each container's CPU request, so missing requests break utilization scaling.
Verify Metrics Server and resource metrics
Confirm the aggregated Metrics API is Available:
kubectl get apiservice v1beta1.metrics.k8s.ioNAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True 52sAVAILABLE must be True. Exercise the API with kubectl top:
kubectl top nodesNAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
k8s-cp 600m 20% 3641Mi 55%
worker01 32m 3% 1219Mi 65%When this fails with Metrics API not available, install and patch Metrics Server as shown in monitor Pod and container resources before continuing. This lab runs registry.k8s.io/metrics-server/metrics-server:v0.9.0.
Prepare a scalable Deployment
Create a namespace and a Deployment that can burn CPU when requested, plus a Service for the load client:
kubectl create namespace hpa-labnamespace/hpa-lab createdkubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-apache
namespace: hpa-lab
labels:
app: php-apache
spec:
replicas: 1
selector:
matchLabels:
app: php-apache
template:
metadata:
labels:
app: php-apache
spec:
containers:
- name: php-apache
image: registry.k8s.io/hpa-example
ports:
- containerPort: 80
resources:
requests:
cpu: 200m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: php-apache
namespace: hpa-lab
spec:
selector:
app: php-apache
ports:
- port: 80
targetPort: 80
EOFWait until the Deployment is Available:
kubectl -n hpa-lab wait \
--for=condition=Available deployment/php-apache \
--timeout=120sdeployment.apps/php-apache condition metThen confirm metrics include the app. kubectl top may need one additional Metrics Server scrape after the Pod becomes Ready; Metrics Server normally collects metrics every 15 seconds.
kubectl -n hpa-lab top podsNAME CPU(cores) MEMORY(bytes)
php-apache-79b59ffff8-skwr8 17m 23MiThe CPU request of 200m is the denominator for utilization. At idle, usage is a small fraction of that request.
Create an HPA imperatively
kubectl autoscale creates an autoscaling/v2 HorizontalPodAutoscaler. Prefer the current --cpu flag (percentage utilization):
kubectl -n hpa-lab autoscale deployment php-apache --cpu=50% --min=1 --max=5horizontalpodautoscaler.autoscaling/php-apache autoscaledInspect the object:
kubectl -n hpa-lab get hpaEarly on, metrics may still show as unknown until the next scrape:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: <unknown>/50% 1 5 0 0sAfter Metrics Server reports values, idle utilization settles near zero against the 50% target:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 0%/50% 1 5 1 16sColumns mean:
| Column | Meaning |
|---|---|
TARGETS |
Current metric versus target (here CPU % of request) |
MINPODS / MAXPODS |
Replica floor and ceiling |
REPLICAS |
Current replica count observed by HPA |
Export the generated YAML as a starting point for declarative config:
kubectl -n hpa-lab get hpa php-apache -o yamlYou should see apiVersion: autoscaling/v2, scaleTargetRef pointing at Deployment/php-apache, and a Resource metric with averageUtilization: 50.
Define HPA with autoscaling/v2
Declarative HPA keeps the same fields under version control. Apply (or replace) the object:
kubectl apply -f - <<'EOF'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache
namespace: hpa-lab
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: php-apache
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
behavior:
scaleDown:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 15
EOFCore fields:
| Field | Role |
|---|---|
scaleTargetRef |
Object that exposes a scale subresource |
minReplicas / maxReplicas |
Allowed replica range |
metrics |
Resource (or other) signals and targets |
behavior |
Optional scale-up / scale-down policies — see Configure scaling behavior below |
Stay on autoscaling/v2. Do not write new manifests against deprecated beta HPA API versions. The manifest above includes a short behavior.scaleDown block so this lab can observe scale-down without waiting five minutes.
Generate load and observe scale-up
Run a client Pod that hits the Service in a tight loop. Use an image with wget (busybox works; agnhost does not treat /bin/sh as its entrypoint):
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: load-gen
namespace: hpa-lab
spec:
restartPolicy: Never
containers:
- name: load
image: busybox:1.36
command:
- /bin/sh
- -c
- while true; do wget -q -O- http://php-apache >/dev/null; done
EOFWatch HPA and Deployment while load runs. Leave this in a second terminal while you create and delete load-gen:
watch -n 10 '
kubectl -n hpa-lab get hpa php-apache
kubectl -n hpa-lab get deployment php-apache
'Sampled output from this lab:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 90%/50% 1 5 1 6m12s
NAME READY UP-TO-DATE AVAILABLE AGE
php-apache 2/2 2 2 7m11sA few syncs later utilization spiked further and replicas rose again:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 246%/50% 1 5 2 6m23s
NAME READY UP-TO-DATE AVAILABLE AGE
php-apache 4/4 4 4 7m22skubectl -n hpa-lab get pods -l app=php-apache -o wideNAME READY STATUS RESTARTS AGE IP NODE
php-apache-79b59ffff8-cj2mk 1/1 Running 0 6s 192.168.5.44 worker01
php-apache-79b59ffff8-pngrh 1/1 Running 0 21s 192.168.5.42 worker01
php-apache-79b59ffff8-pv9rv 1/1 Running 0 6s 192.168.5.43 worker01
php-apache-79b59ffff8-skwr8 1/1 Running 0 7m23s 192.168.5.38 worker01There is always a delay between load and scaling. Metrics Server aggregates over a scrape window, and the HPA controller evaluates on its own interval. Do not expect a one-second reaction.
Observe scale-down
Stop the load:
kubectl -n hpa-lab delete pod load-genWith the lab behavior.scaleDown.stabilizationWindowSeconds: 30, replicas fell after utilization dropped:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 1%/50% 1 5 5 7m58s
NAME READY UP-TO-DATE AVAILABLE AGE
php-apache 3/3 3 3 8m57sThen:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 0%/50% 1 5 3 8m14s
NAME READY UP-TO-DATE AVAILABLE AGE
php-apache 1/1 1 1 9m13sScale-down is deliberately calmer than scale-up:
- Recent recommendations inside the stabilization window can keep replica counts high briefly
minReplicasis the floor even when utilization is near zero- Without a custom
behavior, the default scale-down stabilization is about five minutes
Autoscale with memory metrics
You can target memory on the same HPA using autoscaling/v2. Two common styles:
| Target type | Meaning |
|---|---|
Utilization |
Percentage of each Pod's memory request |
AverageValue |
Absolute average memory usage (for example 100Mi) |
Example that keeps CPU utilization and adds an absolute memory average (one HPA only):
kubectl -n hpa-lab patch hpa php-apache \
--type=merge \
-p='{
"spec": {
"metrics": [
{
"type": "Resource",
"resource": {
"name": "cpu",
"target": {
"type": "Utilization",
"averageUtilization": 50
}
}
},
{
"type": "Resource",
"resource": {
"name": "memory",
"target": {
"type": "AverageValue",
"averageValue": "100Mi"
}
}
}
]
}
}'horizontalpodautoscaler.autoscaling/php-apache patchedkubectl -n hpa-lab get hpa php-apacheOn this lab, after adding both metrics, kubectl get hpa showed:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 0%/50%, memory: 26708Ki/100Mi 1 5 4 9m13sKubernetes evaluates every configured metric and uses the highest replica recommendation. Do not create a second HPA for the same Deployment — that produces AmbiguousSelector and ScalingActive=False. Avoid artificial memory-pressure loops that risk node OOM; size memory targets from quiet kubectl top readings instead.
Configure scaling behavior
The behavior field separates scale-up and scale-down policy. This lab used a practical scale-down rule:
behavior:
scaleDown:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 15| Setting | Effect |
|---|---|
stabilizationWindowSeconds |
How long past recommendations are considered before shrinking |
policies |
Rate limits (percent or absolute Pods per period) |
| Default scale-up | Aggressive (no long stabilization by default) |
Tolerance (ignore tiny metric noise) exists as an advanced, version-sensitive option on recent HPA APIs. Treat it as optional; most tutorials start with utilization targets and stabilization windows only.
Supported scale targets
HPA updates the scale subresource. Common targets:
- Deployment
- StatefulSet
- Other resources that implement scale (ReplicaSet, some custom resources)
Confirm the subresource exists:
kubectl -n hpa-lab get --raw /apis/apps/v1/namespaces/hpa-lab/deployments/php-apache/scaleTrimmed output:
{
"kind": "Scale",
"apiVersion": "autoscaling/v1",
"spec": { "replicas": 1 },
"status": { "replicas": 1, "selector": "app=php-apache" }
}Do not point HPA at a bare Pod. A single Pod has no replica scale subresource for HPA to manage.
HPA vs VPA
| HPA | VPA |
|---|---|
| Changes replica count | Changes resource recommendations or assignments |
| Built into the Kubernetes control plane | Needs additional components |
| Handles horizontal scaling | Handles vertical resource sizing |
This article does not install or configure Vertical Pod Autoscaler. Use HPA when more copies of the same Pod help; use VPA when a single Pod needs different CPU or memory sizing.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
TARGETS shows <unknown> |
Metrics API down or not ready | Fix Metrics Server; wait for the next scrape |
| Utilization always wrong or inactive | Missing CPU/memory requests | Set requests on every container in the target |
AmbiguousSelector |
Two HPAs select the same Pods | Keep one HPA per Deployment; merge metrics instead |
| No scale-up under load | Load misses the Service or wrong container | Hit the Service DNS name; confirm kubectl top rises |
| Scale-down seems stuck | Stabilization window still active | Wait, or tune behavior.scaleDown for the lab |
minReplicas equals maxReplicas |
No room to move | Widen the replica range |
Manual kubectl scale fights HPA |
Both update the same scale subresource | Let HPA own replicas, or pause/delete the HPA |
At maxReplicas with high utilization |
HPA replica ceiling reached | Raise maxReplicas if more replicas are required |
| New replicas remain Pending | Insufficient schedulable node capacity | Check Pod events and add capacity or adjust resource requests |
What's Next
- Kubernetes PodDisruptionBudget with Drain Examples
- Kubernetes Static Pods and Mirror Pods
- Kubernetes Volumes with Practical Examples
References
- Horizontal Pod Autoscaling
- HorizontalPodAutoscaler Walkthrough
- HorizontalPodAutoscaler autoscaling/v2 API
- Metrics Server
Summary
You verified Metrics Server, created a Deployment with CPU requests, and attached an HPA with kubectl autoscale and autoscaling/v2. Under synthetic HTTP load, CPU utilization rose above the target and the controller increased replicas through the Deployment scale subresource. After the load stopped, scale-down returned to minReplicas, with timing shaped by the stabilization window in behavior.
Memory metrics belong on the same HPA object when you need them; a second HPA on the same Pods fails with AmbiguousSelector. HPA remains the horizontal tool in the control plane — pair it with Metrics Server and honest resource requests, and leave VPA, custom metrics, and cluster autoscaling for their own workflows.
Clean up with kubectl delete namespace hpa-lab when you finish. Keep Metrics Server if other lessons need kubectl top.

