Kubernetes Horizontal Pod Autoscaler with Examples

Tested on Rocky Linux 10.2 (Red Quartz) workstation
Package kubectl 1.36.3
metrics-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.

IMPORTANT
This article covers resource-metric HPA with Metrics Server (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:

text
Metrics API
HPA controller
Target utilization calculation
Scale subresource
Deployment / StatefulSet replica count

On each sync period the controller:

  • Reads current metrics for Pods selected by the scale target
  • Computes a desired replica count
  • Patches the object's /scale subresource

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:

bash
kubectl get apiservice v1beta1.metrics.k8s.io
output
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        52s

AVAILABLE must be True. Exercise the API with kubectl top:

bash
kubectl top nodes
output
NAME       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:

bash
kubectl create namespace hpa-lab
output
namespace/hpa-lab created
bash
kubectl 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
EOF

Wait until the Deployment is Available:

bash
kubectl -n hpa-lab wait \
  --for=condition=Available deployment/php-apache \
  --timeout=120s
output
deployment.apps/php-apache condition met

Then 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.

bash
kubectl -n hpa-lab top pods
output
NAME                          CPU(cores)   MEMORY(bytes)
php-apache-79b59ffff8-skwr8   17m          23Mi

The 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):

bash
kubectl -n hpa-lab autoscale deployment php-apache --cpu=50% --min=1 --max=5
output
horizontalpodautoscaler.autoscaling/php-apache autoscaled

Inspect the object:

bash
kubectl -n hpa-lab get hpa

Early on, metrics may still show as unknown until the next scrape:

output
NAME         REFERENCE               TARGETS              MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: <unknown>/50%   1         5         0          0s

After Metrics Server reports values, idle utilization settles near zero against the 50% target:

output
NAME         REFERENCE               TARGETS       MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: 0%/50%   1         5         1          16s

Columns 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:

bash
kubectl -n hpa-lab get hpa php-apache -o yaml

You 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:

bash
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
EOF

Core 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):

bash
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
EOF

Watch HPA and Deployment while load runs. Leave this in a second terminal while you create and delete load-gen:

bash
watch -n 10 '
kubectl -n hpa-lab get hpa php-apache
kubectl -n hpa-lab get deployment php-apache
'

Sampled output from this lab:

output
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           7m11s

A few syncs later utilization spiked further and replicas rose again:

output
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           7m22s
bash
kubectl -n hpa-lab get pods -l app=php-apache -o wide
output
NAME                          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   worker01

There 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:

bash
kubectl -n hpa-lab delete pod load-gen

With the lab behavior.scaleDown.stabilizationWindowSeconds: 30, replicas fell after utilization dropped:

output
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           8m57s

Then:

output
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           9m13s

Scale-down is deliberately calmer than scale-up:

  • Recent recommendations inside the stabilization window can keep replica counts high briefly
  • minReplicas is 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):

bash
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"
            }
          }
        }
      ]
    }
  }'
output
horizontalpodautoscaler.autoscaling/php-apache patched
bash
kubectl -n hpa-lab get hpa php-apache

On this lab, after adding both metrics, kubectl get hpa showed:

output
NAME         REFERENCE               TARGETS                              MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   cpu: 0%/50%, memory: 26708Ki/100Mi   1         5         4          9m13s

Kubernetes 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:

yaml
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:

bash
kubectl -n hpa-lab get --raw /apis/apps/v1/namespaces/hpa-lab/deployments/php-apache/scale

Trimmed output:

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


References


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.


Frequently Asked Questions

1. Why does my HPA show cpu as unknown?

The Metrics API is missing or not ready, or the target Pods lack CPU requests so utilization cannot be calculated. Confirm Metrics Server is Available and that every container in the scale target defines a CPU request.

2. Does HPA scale the moment CPU spikes?

No. The HPA controller polls metrics on an interval, computes a desired replica count, then updates the scale subresource. Expect a short delay between load change and replica change, and a longer delay on scale-down because of stabilization.

3. Can two HPA objects manage the same Deployment?

Avoid it. When more than one HPA selects the same Pods, the controller reports AmbiguousSelector and ScalingActive becomes False. Use one HPA per scale target, or combine metrics in a single autoscaling/v2 object.

4. What is the difference between Utilization and AverageValue for memory?

Utilization is a percentage of each Pod memory request. AverageValue is an absolute quantity such as 100Mi averaged across Pods. Pick one style that matches how you size requests and what you want to defend against.

5. Does preferred scale-down behavior remove Pods immediately when load drops?

Not by default. Scale-down uses a stabilization window so brief dips do not thrash replica counts. You can shorten that window in behavior.scaleDown for labs, but production clusters usually keep a longer window.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)