Your traffic doubles at 2 a.m., and the pods that handled yesterday’s load start timing out. Kubernetes HPA fixes that without a pager. The HorizontalPodAutoscaler v2 API reference describes the Horizontal Pod Autoscaler as a controller that changes the replica count of any resource implementing the scale subresource. This tutorial walks through 13 steps, about 60 minutes of hands-on work, and ends with a working lab you can rebuild from one script.
The tutorial targets Kubernetes 1.37, the release line that shipped on August 26, 2026, and the one that moved HPA scale-to-zero to beta. If you already scale game workloads, our guide to autoscaling game servers with Kubernetes HPA and KEDA covers that niche. This article takes the general-purpose route: a web service, a queue worker, and the node layer underneath them.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What You Will Build in This Kubernetes HPA Tutorial
The lab runs on a local three-node cluster created with kind, so it costs nothing and you can break it freely. You will deploy a CPU-hungry sample web app, attach a Horizontal Pod Autoscaler to it, hammer it with synthetic traffic, and watch replicas climb and fall. Then you will tune how fast the scaling reacts, add KEDA for a queue-driven worker that sits at zero pods when idle, and finish with the node-autoscaling commands for GKE, AKS and EKS.
Three layers of autoscaling exist in a real cluster, and people mix them up constantly. Pod-level horizontal scaling adds or removes replicas. Pod-level vertical scaling resizes the CPU and memory requests of each replica. Node-level scaling adds or removes the machines that run those pods. This article covers the first and third in depth, touches the second, and shows where KEDA fits as an event-driven front end to the first.
If you are still deciding whether Kubernetes is the right orchestrator for a small team, read our Kubernetes vs Docker Swarm comparison first. Autoscaling is one of the strongest arguments for Kubernetes, and also one of the easiest features to misconfigure.
By the end you will have these working pieces:
- A kind cluster with metrics-server, so
kubectl topand the HPA both get live CPU numbers. - A sample Deployment with correct resource requests, the single most common thing people forget.
- An imperative HPA from
kubectl autoscale, then a declarativeautoscaling/v2manifest with scale-up and scale-down behavior rules. - KEDA installed through Helm, with a Redis-backed worker that scales from zero to ten pods and back.
- A verification checklist, 8+ troubleshooting fixes and a single script that rebuilds the whole lab.
Prerequisites and Versions for Kubernetes HPA
You need a laptop or VM with Docker running, about 8 GB of free RAM and a terminal. Everything else installs in the first steps. The table lists what this tutorial was written against. Where a version is not pinned, check the project’s release page and use the newest stable build.
| Component | Version used | Why it matters |
|---|---|---|
| Kubernetes | 1.37 (released August 26, 2026) | HPA scale-to-zero is beta and on by default in this release |
| kubectl | Within one minor version of the cluster | Skewed clients hide new HPA fields |
| kind | Latest release | Runs the cluster as Docker containers |
| Docker | Latest stable engine | kind needs a running container runtime |
| Helm | Latest 3.x or newer | Installs metrics-server and KEDA |
| metrics-server | Latest release | Feeds CPU and memory data to the HPA |
| KEDA | 2.21.0 (released September 23, 2026) | Event-driven scaling and scale-to-zero |
| Cloud CLIs (optional) | gcloud, az, aws | Only needed for Step 12 |
The Kubernetes 1.37 line matters for one specific reason. According to the Kubernetes release blog, HPA scale-to-zero graduated to beta in 1.37 and is enabled by default. In 1.36, released April 22, 2026, the same feature was alpha and disabled by default. If your managed cluster still runs 1.36 or older, the native zero-replica path in this article will not work, and KEDA is your route. Our Kubernetes 1.37 upgrade guide lists the breaking changes to check before you move a production cluster.
KEDA 2.21.0 is the newest core release at the time of writing, according to Ivinco’s autoscaling overview. The release adds dynamic HPA targets for external scalers, Prometheus OAuth2 client-credentials authentication and Kafka Kerberos credential-cache support. The release notes also reportedly list three breaking changes, so anyone upgrading from the 2.20 line should read the upgrade notes before touching a live cluster. The Vertical Pod Autoscaler also shipped a 1.8.0 release on September 23, 2026, which you can confirm on the kubernetes/autoscaler release page.
Check your tooling before you start:
docker version --format '{{.Server.Version}}'
kind version
kubectl version --client
helm version --short
If any command fails, install that tool from its official documentation and rerun the block. Do not continue with a missing Docker daemon, because the next step will fail with a confusing socket error.
How the Kubernetes HPA Math Works
Before you type a manifest, learn the one formula that explains almost every HPA surprise. The controller compares the current metric value to the target and scales the replica count by that ratio, rounding up:
desiredReplicas = ceil( currentReplicas * ( currentMetricValue / desiredMetricValue ) )
The Kubernetes documentation gives two worked examples. If the current metric is 200.0 and the target is 100.0, the ratio is 2.0 and the replica count doubles. If the current metric is 50.0 against the same target, the ratio is 0.5 and the replica count halves. The controller skips any scaling action when the ratio sits close enough to 1.0, within a tolerance that defaults to 0.1, which is 10%. That dead band stops the HPA from twitching on every small fluctuation.
Why CPU requests drive everything
Utilization is not raw CPU usage. The documentation defines it as the ratio between the current usage of a resource and the amount the pod requested. A pod that requests 200 millicores and uses 100 millicores sits at 50% utilization. A pod with no CPU request has no denominator, so the HPA cannot compute anything and reports an unknown target. This single rule causes more broken autoscalers than any bug in Kubernetes.
Startup, readiness and the stabilization window
New pods do not report useful CPU the moment they start. The controller handles not-yet-ready pods conservatively. On a scale-up it assumes those pods consume 0% of the desired value, and on a scale-down it assumes they consume 100%. Both assumptions dampen the swing and prevent overshoot. The kube-controller-manager flag --horizontal-pod-autoscaler-cpu-initialization-period defaults to 5 minutes, and during that window the controller treats early CPU samples with suspicion.
Scale-down gets its own brake. The flag --horizontal-pod-autoscaler-downscale-stabilization defaults to 5 minutes, and the controller considers every desired state from that window before it removes a replica. If you load-test and then wonder why pods linger after the traffic stops, this is the reason. You can override the window per HPA with the behavior field, which Step 8 covers.
Build the Lab Cluster (Steps 1 to 3)
Step 1: Create a three-node kind cluster
Save this file as kind-hpa-lab.yaml. One control-plane node and two workers give the scheduler room to spread replicas, which makes the scale-up visible.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
Create the cluster. Each kind release ships with a default node image, so pick a kind release whose default image is Kubernetes 1.37. If you need a specific build, read the kind release notes and pass the matching tag with --image rather than guessing a tag.
kind create cluster --name hpa-lab --config kind-hpa-lab.yaml
kubectl cluster-info --context kind-hpa-lab
kubectl get nodes
Expected output from the last command (ages will differ):
NAME STATUS ROLES AGE VERSION
hpa-lab-control-plane Ready control-plane 62s v1.37.x
hpa-lab-worker Ready <none> 42s v1.37.x
hpa-lab-worker2 Ready <none> 42s v1.37.x
Step 2: Install metrics-server
The HPA reads CPU and memory from the Metrics API, and metrics-server provides it. kind kubelets use self-signed certificates, so the lab needs the --kubelet-insecure-tls flag. Never carry that flag into a production cluster.
helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/
helm repo update
helm upgrade --install metrics-server metrics-server/metrics-server \
--namespace kube-system \
--set args={--kubelet-insecure-tls}
kubectl -n kube-system rollout status deployment/metrics-server
Give it about a minute to scrape the first samples, then confirm that numbers appear:
kubectl top nodes
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
hpa-lab-control-plane 142m 3% 711Mi 9%
hpa-lab-worker 38m 0% 215Mi 2%
hpa-lab-worker2 35m 0% 204Mi 2%
If you see error: Metrics API not available, wait another minute and retry. Persistent failures appear in the troubleshooting section below.
Step 3: Deploy the sample app with resource requests
The Kubernetes project publishes a small PHP image that burns CPU on every request, which makes it ideal for autoscaling demos. Save the following as app.yaml. Notice two details. The container sets a CPU request, which the HPA needs as its denominator. And the Deployment omits spec.replicas on purpose, so the HPA owns the replica count from day one.
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-apache
labels:
app: php-apache
spec:
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
labels:
app: php-apache
spec:
selector:
app: php-apache
ports:
- port: 80
targetPort: 80
kubectl apply -f app.yaml
kubectl rollout status deployment/php-apache
kubectl top pods
One pod runs at close to zero CPU. That is your baseline, and the starting point for every number you will watch in the next steps.
Create Your First Kubernetes HPA (Steps 4 to 6)
Step 4: Create the HPA with kubectl autoscale
The fastest route is imperative. The kubectl autoscale command creates an autoscaler for a Deployment, ReplicaSet, StatefulSet or replication controller. The kubectl reference notes that it tries the autoscaling/v2 API first and falls back to autoscaling/v1 if needed. The documentation’s own example uses the same pattern you are about to run: kubectl autoscale rs foo --min=2 --max=5 --cpu=80% creates an autoscaler for ReplicaSet foo with an 80% CPU target.
kubectl autoscale deployment php-apache --cpu=50% --min=2 --max=10
kubectl get hpa php-apache
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: <unknown>/50% 2 10 1 8s
The <unknown> value is normal for the first half minute. After a short wait the target fills in, and the replica count rises to the minimum of two:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 1%/50% 2 10 2 74s
Step 5: Inspect what the controller thinks
Make a habit of reading kubectl describe hpa. It reveals the controller’s reasoning through three status conditions.
kubectl describe hpa php-apache
Metrics: ( current / target )
resource cpu on pods (as a percentage of request): 1% (2m) / 50%
Min replicas: 2
Max replicas: 10
Deployment pods: 2 current / 2 desired
Conditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True ReadyForNewScale recommended size matches current size
ScalingActive True ValidMetricFound the HPA was able to successfully calculate a replica count from cpu resource utilization (percentage of request)
ScalingLimited False DesiredWithinRange the desired count is within the acceptable range
Read the three conditions in order. AbleToScale says the controller can change the target. ScalingActive says metrics arrive and make sense. ScalingLimited says the min or max bound is clamping the answer. When an HPA misbehaves, one of these three almost always flips and tells you which layer to investigate.
Step 6: Generate load and watch it scale
Open two terminals. In the first, watch the HPA:
kubectl get hpa php-apache --watch
In the second, start a throwaway pod that requests the service in a tight loop:
kubectl run load-generator --rm -i --tty --image=busybox:1.36 --restart=Never -- \
/bin/sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done"
Within a minute or two the watch window shows CPU climbing past the target, followed by new replicas. Your exact numbers will differ, but the shape should resemble this:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 1%/50% 2 10 2 4m
php-apache Deployment/php-apache cpu: 118%/50% 2 10 2 4m30s
php-apache Deployment/php-apache cpu: 118%/50% 2 10 5 4m45s
php-apache Deployment/php-apache cpu: 71%/50% 2 10 5 5m15s
php-apache Deployment/php-apache cpu: 49%/50% 2 10 6 5m45s
Run the formula on the second data row. Divide the current CPU value by the target to get the ratio, multiply the current replica count by that ratio, and round up. That matches the jump you see. Stop the load with Ctrl+C in the second terminal. CPU drops within seconds, but the replica count stays high for the stabilization window, then steps down. That delay is a feature, not a bug.
Write a Production-Style autoscaling/v2 HPA (Steps 7 and 8)
Step 7: Move the HPA into a manifest
Imperative commands are fine for experiments, but a real team keeps autoscaling in Git next to the Deployment. Save this as hpa-v2.yaml. The autoscaling/v2 API is the current one. The older autoscaling/v2beta2 is no longer current, so any tutorial that still uses it is out of date.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: php-apache
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
kubectl apply -f hpa-v2.yaml
kubectl get hpa php-apache -o yaml | head -30
Applying over the HPA you made in Step 4 works, and kubectl prints a harmless warning about a missing last-applied annotation. If you prefer a clean slate, run kubectl delete hpa php-apache first.
You can stack several metrics in one HPA. When you list more than one, the controller computes a desired replica count for each metric and picks the highest. That rule is why a forgotten memory target can pin a Deployment at a high replica count long after CPU settles. Add extra metrics only when you can explain why each one deserves a vote.
Step 8: Tune scale-up and scale-down behavior
The default behavior suits many workloads, but a user-facing API usually wants to scale up fast and scale down slowly. The behavior block gives you that control. It lets you set a stabilization window and rate-limiting policies for each direction. Replace the file with this tuned version:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: php-apache
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
behavior:
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
Here is what each part does:
- scaleUp window of 0 seconds: the controller acts on a spike immediately instead of waiting.
- selectPolicy Max: when two scale-up policies disagree, the one allowing the bigger change wins, so the app can double or add four pods per minute, whichever is larger.
- scaleDown window of 300 seconds: the controller looks back five minutes and uses the highest recommendation in that window, which smooths out short dips.
- Percent policy of 10 per 60 seconds: at most 10% of current replicas leave each minute. The docs show that with 72 replicas, 10% is 7.2, and the number rounds up to 8.
kubectl apply -f hpa-v2.yaml
kubectl describe hpa php-apache | sed -n '/Behavior/,/Conditions/p'
Run the load generator from Step 6 again and compare. The scale-up phase should look similar, since the defaults already allow quick growth. The big change appears after you stop the load. Pods now leave in small steps instead of falling off a cliff, which protects you from the classic pattern where an HPA kills capacity just before the next wave arrives.
One more knob deserves a mention. The Kubernetes docs describe a per-HPA tolerance, with an example of a 5% scale-up tolerance on a memory target of 100MiB, so scaling triggers only above 105MiB. If you do not set it, the cluster-wide default of 10% applies. Check the feature status for your Kubernetes version before relying on it, because configurable tolerance has sat behind a feature gate in earlier releases.
Add KEDA for Event-Driven Scaling and Scale-to-Zero (Steps 9 to 11)
CPU is a lagging signal. By the time a worker pool burns CPU, the queue is already deep. KEDA scales on the signal that matters, such as queue length, and it can take a Deployment to zero pods when nothing needs doing. The KEDA documentation describes the project as event-driven autoscaling for Kubernetes workloads, and it adds a layer on top of the HPA rather than replacing it. KEDA contributor Jorge Turrado put it plainly on the KubeFM podcast: “KEDA is relying totally on the HPA controller because it’s a well tested piece in Kubernetes. For us is better than reinventing the wheel.”
That design explains the architecture. KEDA’s operator watches your ScaledObject, feeds external metrics to the Kubernetes metrics pipeline, and manages an HPA for you. It handles the zero-to-one and one-to-zero transitions itself, then hands the one-to-N range to the HPA. For a deeper comparison with node-level tools, see our breakdown of Karpenter vs Cluster Autoscaler vs KEDA.
Step 9: Install KEDA with Helm
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm search repo kedacore/keda --versions | head -5
helm install keda kedacore/keda --namespace keda --create-namespace
kubectl -n keda get pods
The search command prints the chart versions on offer. This tutorial was written against KEDA 2.21.0, so pin the chart that ships that app version when you build something you plan to keep, using --version. The pod list should settle into three running pods:
NAME READY STATUS RESTARTS AGE
keda-admission-webhooks-6d9c7b5f4-x8k2p 1/1 Running 0 48s
keda-operator-7c84b6d9f5-q2m7t 1/1 Running 0 48s
keda-operator-metrics-apiserver-5b7f9c6d8-h4v9r 1/1 Running 0 48s
Step 10: Deploy a Redis queue, a worker and a ScaledObject
The worker pops jobs from a Redis list, pretends each one takes two seconds and logs what it did. Save this as queue.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
spec:
selector:
matchLabels:
app: worker
template:
metadata:
labels:
app: worker
spec:
containers:
- name: worker
image: redis:7-alpine
command: ["/bin/sh", "-c"]
args:
- |
while true; do
job=$(redis-cli -h redis -p 6379 BLPOP jobs 5 | tail -n 1)
if [ -n "$job" ]; then
echo "processing $job"
sleep 2
fi
done
resources:
requests:
cpu: 50m
memory: 32Mi
Now the ScaledObject. Save it as scaledobject.yaml:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-scaler
spec:
scaleTargetRef:
name: worker
minReplicaCount: 0
maxReplicaCount: 10
pollingInterval: 5
cooldownPeriod: 30
triggers:
- type: redis
metadata:
address: redis.default.svc.cluster.local:6379
listName: jobs
listLength: "5"
kubectl apply -f queue.yaml
kubectl rollout status deployment/redis
kubectl apply -f scaledobject.yaml
kubectl get scaledobject worker-scaler
kubectl get deployment worker
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX READY ACTIVE AGE
worker-scaler apps/v1.Deployment worker 0 10 True False 15s
NAME READY UP-TO-DATE AVAILABLE AGE
worker 0/0 0 0 40s
The worker sits at zero pods because the queue is empty, and ACTIVE reads False. No idle compute, no cost. The listLength value of 5 tells KEDA to aim for roughly five queued jobs per replica, so a backlog of 50 jobs asks for ten pods.
Step 11: Push jobs and watch zero become ten
In one terminal, watch the Deployment. In the other, push 100 jobs into the list.
# terminal 1
kubectl get deployment worker --watch
# terminal 2
kubectl exec deploy/redis -- sh -c 'for i in $(seq 1 100); do redis-cli RPUSH jobs job-$i > /dev/null; done'
kubectl exec deploy/redis -- redis-cli LLEN jobs
NAME READY UP-TO-DATE AVAILABLE AGE
worker 0/0 0 0 3m
worker 0/1 1 0 3m6s
worker 1/1 1 1 3m12s
worker 1/4 4 1 3m27s
worker 4/10 10 4 3m42s
worker 10/10 10 10 3m57s
...
worker 10/0 0 10 7m10s
worker 0/0 0 0 7m12s
The sequence tells the whole story. The first job wakes the worker from zero. KEDA reports a backlog of 100 jobs, the HPA that KEDA manages asks for the ten-replica cap, and the pods drain the queue. Once the list is empty and the 30-second cooldown passes, the Deployment returns to zero. Check the logs of any worker to confirm it processed real jobs:
kubectl logs deploy/worker --tail=5
If the pods vanished before you could read logs, push another batch and query them while the backlog is still draining. A complete lab for your own team would also add a dead-letter list and an alert on queue age, since autoscaling hides symptoms as easily as it fixes them.
Native scale-to-zero versus KEDA
Kubernetes 1.37 now lets a plain HPA reach zero, with limits. According to the official docs, a workload can scale to zero pods when you use object or external metrics and set spec.minReplicas: 0. CPU and memory targets cannot do this, which makes sense: resource metrics come from running pods, so zero pods leave nothing to measure. You also need something that publishes the external metric, and that is exactly the job KEDA does well. The practical rule for late 2026 is simple. Use KEDA when you want ready-made scalers for queues, streams and databases. Use the native beta path when you already run a metrics adapter and want fewer moving parts.
Add Node Autoscaling on GKE, AKS and EKS (Step 12)
Pods can only multiply if somewhere exists to run them. When the HPA asks for ten replicas and the cluster has room for six, four pods sit in Pending until a node appears. Node autoscaling closes that gap by watching for unschedulable pods, adding machines and later removing the underused ones. Kubernetes documentation describes node autoscaling as provisioning and consolidating nodes to match demand while cutting cost, and as creating and deleting the cloud resources that back each node. Your kind lab has no cloud API to call, so this step is a runbook for the three big managed platforms. Run the commands against a test cluster first.
Google Kubernetes Engine
GKE still distinguishes Autopilot from Standard mode, according to Google’s documentation as of October 26, 2026. Autopilot manages nodes for you. On Standard, you enable autoscaling per node pool. Our Google Kubernetes Engine setup guide covers cluster creation in detail.
gcloud container clusters update CLUSTER_NAME \
--location=LOCATION \
--node-pool=default-pool \
--enable-autoscaling \
--min-nodes=1 \
--max-nodes=5
Google announced on October 20, 2026 that GKE gained built-in scale-to-zero support for HPA workloads and custom PromQL-based HPA scaling. Treat both as new features, and confirm availability on your release channel before you design around them.
Azure Kubernetes Service
AKS offers the classic cluster autoscaler per node pool, plus Node Auto-Provisioning, which documentation updated on October 26, 2026 describes through AKSNodeClass resources. Check the availability level and regional support of Node Auto-Provisioning before production use. Our walkthrough on how to set up Azure AKS builds the cluster these commands assume.
az aks update \
--resource-group RESOURCE_GROUP \
--name CLUSTER_NAME \
--enable-cluster-autoscaler \
--min-count 1 \
--max-count 5
A deadline sits three days from this article’s publication date. Microsoft states that AKS removes Azure Linux 2.0 node images beginning October 31, 2026, and node pools still using them can no longer scale. An autoscaler cannot add nodes it cannot build. Upgrade to a supported Kubernetes version or migrate the pool to osSku AzureLinux3 before that date.
Amazon EKS
EKS offers more paths than the other two. According to AWS documentation dated October 8, 2026, EKS Auto Mode is the default fully managed capacity mode for new clusters created without capacity settings, and it provisions and scales EC2 capacity from pod requirements. Self-managed Auto Scaling Group capacity remains supported. If you run managed node groups, you set the group bounds yourself and then add Karpenter or Cluster Autoscaler to act on them, as our guide to setting up Karpenter for EKS autoscaling shows.
aws eks update-nodegroup-config \
--cluster-name CLUSTER_NAME \
--nodegroup-name NODEGROUP_NAME \
--scaling-config minSize=1,maxSize=5,desiredSize=2
That command only moves the bounds. Without a node autoscaler running, nothing uses them. EKS also supports Kubernetes 1.37, which AWS reportedly released for the service on October 2, 2026, so upgrading ahead of scale-to-zero workloads is realistic. Our EKS 1.37 upgrade tutorial walks through that move.
| Platform | Node autoscaling options | Enable at | Watch out for |
|---|---|---|---|
| GKE | Autopilot (managed) or Standard node pool autoscaling | Node pool | Set realistic max nodes to cap spend |
| AKS | Cluster autoscaler or Node Auto-Provisioning | Node pool or cluster | Azure Linux 2.0 images removed from October 31, 2026 |
| EKS | Auto Mode, Karpenter or Cluster Autoscaler | Cluster or node group | Group bounds alone do not scale anything |
| kind (lab) | None | Not applicable | Fixed node count, so pods may sit Pending |
| Any cloud | Pair with HPA or KEDA | Workload | Missing pod requests break node sizing too |
Prove the node layer reacts
On a cloud test cluster, scale the sample Deployment past the cluster’s free capacity and watch the pending count. Raise maxReplicas and request more CPU per pod if needed. Then run kubectl get pods --field-selector=status.phase=Pending --watch in one terminal and kubectl get nodes --watch in another. A healthy setup shows pending pods, a new node joining within a few minutes, and the pending count falling to zero. If pods stay pending with no new node, jump to the troubleshooting list below.
Verify, Clean Up and Ship (Step 13)
Finish the lab with a short checklist so you know the setup works for the right reasons, not by accident.
# 1. Metrics flow
kubectl top pods
# 2. HPA is healthy: look for ScalingActive=True
kubectl describe hpa php-apache | grep -A4 Conditions
# 3. KEDA objects are ready
kubectl get scaledobject
# 4. No pod is stuck
kubectl get pods -A --field-selector=status.phase=Pending
# 5. Events show real rescales
kubectl get events --field-selector reason=SuccessfulRescale
When you are done experimenting, delete the cluster. The command removes every container and local network that kind created:
kind delete cluster --name hpa-lab
Before you move any of this to production, write down three numbers for each service: the minimum replicas you can survive with, the maximum your budget allows and the CPU utilization at which latency starts to degrade. Those three numbers, not defaults copied from a blog, are your real HPA settings. For wider context on managed cluster options, browse our cloud computing coverage.
Kubernetes HPA vs VPA vs KEDA vs Node Autoscalers
Newcomers often ask which autoscaler to use. The honest answer is that you usually run several at once, each at its own layer. The table below sorts them by what they change and what signal they read.
| Tool | What it changes | Typical signal | Ships with Kubernetes? | Best for |
|---|---|---|---|---|
| HPA | Replica count | CPU, memory, custom or external metrics | Yes, built into the control plane | Stateless web services and APIs |
| VPA | CPU and memory requests per pod | Observed usage history | No, a separately deployed add-on | Right-sizing requests, batch jobs |
| KEDA | Replica count, including zero | Queues, streams, databases, cron | No, installed with Helm | Workers and event-driven apps |
| Cluster Autoscaler | Node count in a pool | Unschedulable pods | No, a separate component | Predictable node groups |
| Karpenter or Auto Mode | Node type and count | Pending pod requirements | No, provider-specific | Mixed instance sizes on AWS |
Two notes on VPA. First, the Kubernetes docs describe it as an add-on that you deploy yourself, and its stable API is autoscaling.k8s.io/v1. Second, VPA now has an InPlace update mode that applies supported resource changes without recreating the pod. The docs list it as alpha in VPA 1.7.0, requiring Kubernetes 1.33 or later with the InPlacePodVerticalScaling feature, so check the status in the 1.8.0 release before you depend on it.
The key safety rule: do not let HPA and VPA fight over the same metric. If both react to CPU, the HPA adds pods while the VPA resizes them, and the two loops chase each other. Run VPA in recommendation-only mode on workloads that the HPA scales on CPU, or scale the HPA on a custom metric instead.
Common Kubernetes HPA Pitfalls to Avoid
Most autoscaling incidents trace back to a short list of repeat mistakes. Read these before you ship.
1. Missing or wrong resource requests
No CPU request means no utilization percentage, and the HPA stays at unknown. A request set far too high means the HPA never sees pressure. A request set far too low means it scales out at the first sip of traffic. Measure real usage under load, then set requests close to the typical figure and the limit above the peak.
2. Hard-coding replicas in the Deployment
If your Deployment manifest carries replicas: 3, a later edit or a replace operation can reset the count and fight the HPA. Delete the field once an HPA owns the workload. GitOps tools make this worse, because they enforce the Git state on every sync. Configure them to ignore the replicas field, or remove it from the manifest.
3. Two autoscalers on one workload
KEDA creates and manages its own HPA. If you also keep a hand-written HPA that targets the same Deployment, the two controllers issue conflicting scale commands and the replica count flaps. Pick one owner per workload. If you want CPU and a queue signal together, add a CPU trigger to the ScaledObject instead of keeping a second HPA.
4. Scaling on memory for runtimes that never release it
Many runtimes hold on to memory after load drops. A memory-based target then keeps the replica count high forever. Because the controller takes the highest desired count across metrics, one sticky memory figure overrides a calm CPU graph. Prefer CPU or a request-rate metric unless you have proof that memory tracks load.
5. Setting minReplicas to 1 for a critical service
One replica is a single point of failure during node drains and rollouts. Use at least two for anything user-facing, and add a PodDisruptionBudget so voluntary evictions never remove both at once.
6. Expecting instant relief from a spike
Metrics take time to scrape, pods take time to pull images and start, and nodes take longer still. Autoscaling absorbs ramps, not cliffs. For sudden launches, pre-scale by raising minReplicas an hour ahead, then lower it after the event.
7. Ignoring the v1 Metrics API note
Kubernetes 1.37 graduated metrics.k8s.io to a stable v1 API, per the August 27, 2026 release note. The HPA documentation also gained a description of a limitation involving v1.metrics.k8s.io. Read that section before you remove older Metrics API versions from your cluster, and keep metrics-server current.
Troubleshooting Kubernetes HPA and KEDA: 10 Fixes
Start every investigation with kubectl describe hpa NAME and read the conditions and events. Then match your symptom below.
| # | Symptom | Likely cause | Fix |
|---|---|---|---|
| 1 | TARGETS shows unknown forever | metrics-server missing or unhealthy | Run kubectl top pods, fix metrics-server first |
| 2 | Event: missing request for cpu | Container has no CPU request | Add resources.requests.cpu to every container, including sidecars |
| 3 | Metrics API not available on kind | Kubelet certificate not trusted | Add --kubelet-insecure-tls in the lab only |
| 4 | Replicas bounce up and down | Window too short or two autoscalers | Raise the scaleDown stabilization window, remove the duplicate HPA |
| 5 | Replicas never shrink | Another metric still above target, or window not elapsed | Check every metric in describe output and wait out the 5-minute default |
| 6 | Stuck at maxReplicas | Load really exceeds capacity or request too low | Raise max, retune requests, check app latency |
| 7 | Pods Pending after scale-up | No node capacity | Enable a node autoscaler and check max node limits |
| 8 | ScaledObject READY is False | Bad trigger address or auth | Run kubectl describe scaledobject, test the Redis address from a debug pod |
| 9 | API rejects minReplicas: 0 | No object or external metric, or old cluster | Add such a metric, confirm Kubernetes 1.37 or use KEDA |
| 10 | AKS pool will not scale | Azure Linux 2.0 node image removed | Move the pool to a supported image such as AzureLinux3 |
The table gives the short version. These commands handle the three that need more than one line.
Debugging an unknown target
kubectl get apiservices | grep metrics
kubectl -n kube-system logs deploy/metrics-server --tail=30
kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes" | head -c 300
The first command should show the metrics API service as available. Logs full of x509 errors point to the kubelet certificate issue from the table. An empty raw query means metrics-server has not scraped yet or cannot reach the nodes.
Debugging a ScaledObject that never activates
kubectl describe scaledobject worker-scaler
kubectl -n keda logs deploy/keda-operator --tail=40
kubectl run redis-debug --rm -it --image=redis:7-alpine --restart=Never -- \
redis-cli -h redis.default.svc.cluster.local LLEN jobs
If the debug pod returns a number and KEDA still reports no activity, compare the list name in the trigger with the one your producer uses. A typo in listName produces a healthy but permanently idle scaler, which looks like success until the queue grows.
Debugging pods that sit in Pending
kubectl get pods --field-selector=status.phase=Pending
kubectl describe pod POD_NAME | sed -n '/Events/,$p'
kubectl describe nodes | grep -A6 "Allocated resources"
The event message names the blocker, such as insufficient CPU or an unsatisfied node selector. If the message says no node fits and your autoscaler shows no action, check its own logs and its maximum node setting. The autoscaler cannot exceed a cap you set months ago and forgot.
Advanced Kubernetes HPA Tips for Production
Once the basics hold, these upgrades pay off. Each one answers a failure mode that shows up only under real traffic.
Scale on request rate or latency, not just CPU
CPU tracks work only for compute-bound services. A service that waits on a database can sit at low CPU while users suffer. Feed the HPA a custom or external metric such as requests per second per pod through a Prometheus adapter or a KEDA Prometheus trigger. Google said on October 20, 2026 that GKE HPA now supports custom PromQL metrics, which cuts the glue code on that platform. Elsewhere, you still need an adapter in the middle.
Combine triggers in one ScaledObject
A worker can scale on queue depth and CPU together. KEDA converts every trigger into a metric, and the underlying HPA picks the highest replica count. Add a second trigger like this:
triggers:
- type: redis
metadata:
address: redis.default.svc.cluster.local:6379
listName: jobs
listLength: "5"
- type: cpu
metricType: Utilization
metadata:
value: "70"
Note that CPU and memory triggers cannot take a workload to zero, for the same reason as with the native HPA. Keep a queue or cron trigger in the mix if you want zero.
Add a cron floor for known peaks
If traffic climbs every weekday at the same hour, do not wait for CPU to notice. A KEDA cron trigger raises the minimum before the rush and drops it afterward. Pair it with the queue trigger, and the worker stays warm during business hours but still reaches zero overnight.
Protect scale-down with a PodDisruptionBudget and graceful shutdown
Scale-down terminates pods, and a pod that dies mid-request creates user-visible errors. Set terminationGracePeriodSeconds to cover your longest request, handle SIGTERM by finishing in-flight work and add a preStop sleep so load balancers drain first. A PodDisruptionBudget guards against too many simultaneous evictions during node consolidation.
Watch the autoscaler itself
Alert on three signals: HPA at maxReplicas for more than a set time, pods pending beyond a few minutes and KEDA scaler errors. The first tells you the ceiling is too low, the second says the node layer is stuck and the third means a trigger is silently failing. Tie cost reporting to the same labels so finance sees which team’s autoscaler spent what. Cost labels are the first step toward a proper FinOps practice around your clusters.
The Complete Working Project: One Script to Rebuild the Lab
Put the five YAML files from the steps in one folder with these names: kind-hpa-lab.yaml, app.yaml, hpa-v2.yaml (the tuned version from Step 8), queue.yaml and scaledobject.yaml. Then save the following as run-lab.sh, make it executable and run it. It builds the cluster, installs metrics-server and KEDA, applies every manifest and waits until each piece is ready.
#!/usr/bin/env bash
set -euo pipefail
CLUSTER=hpa-lab
echo "==> Creating kind cluster"
kind create cluster --name "$CLUSTER" --config kind-hpa-lab.yaml
echo "==> Installing metrics-server"
helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/ >/dev/null
helm repo add kedacore https://kedacore.github.io/charts >/dev/null
helm repo update >/dev/null
helm upgrade --install metrics-server metrics-server/metrics-server \
--namespace kube-system --set args={--kubelet-insecure-tls}
kubectl -n kube-system rollout status deployment/metrics-server --timeout=180s
echo "==> Deploying app and HPA"
kubectl apply -f app.yaml
kubectl rollout status deployment/php-apache --timeout=180s
kubectl apply -f hpa-v2.yaml
echo "==> Installing KEDA"
helm upgrade --install keda kedacore/keda --namespace keda --create-namespace
kubectl -n keda rollout status deployment/keda-operator --timeout=180s
kubectl -n keda rollout status deployment/keda-operator-metrics-apiserver --timeout=180s
kubectl -n keda rollout status deployment/keda-admission-webhooks --timeout=180s
echo "==> Deploying queue, worker and ScaledObject"
kubectl apply -f queue.yaml
kubectl rollout status deployment/redis --timeout=180s
kubectl apply -f scaledobject.yaml
echo "==> Lab ready"
kubectl get hpa
kubectl get scaledobject
echo "Next: run the load generator from Step 6 and the job push from Step 11."
chmod +x run-lab.sh
./run-lab.sh
Expected tail of the output, after a few minutes:
==> Lab ready
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache Deployment/php-apache cpu: 1%/50% 2 10 2 95s
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX READY ACTIVE AGE
worker-scaler apps/v1.Deployment worker 0 10 True False 12s
From here, extend the project: swap the sample image for your own service, point the ScaledObject at a managed queue and move the manifests into your GitOps repository with the replicas field removed.
Kubernetes HPA FAQ
What is Kubernetes HPA?
The Horizontal Pod Autoscaler is a built-in Kubernetes controller that raises or lowers the replica count of a workload based on observed metrics such as CPU. It is open source and has no separate price.
How long does the HPA take to scale down?
By default the controller applies a 5-minute downscale stabilization window, so replicas drop only after the metrics have stayed low for that period. You can change the window per HPA through the behavior.scaleDown field.
Can Kubernetes HPA scale to zero?
Yes, with conditions. In Kubernetes 1.37 the scale-to-zero feature is beta and enabled by default, and it works with object or external metrics when minReplicas is 0. CPU and memory targets cannot reach zero. KEDA offers the same outcome on older clusters.
Should I use HPA, VPA or KEDA?
Use the HPA for stateless services, KEDA for event-driven workers and queues, and VPA in recommendation mode to right-size requests. Avoid running HPA and VPA on the same CPU metric for one workload.
Why does my HPA show unknown for CPU?
Either metrics-server is not working, or a container lacks a CPU request. Check kubectl top pods first, then confirm every container in the pod declares a CPU request.
Does the HPA add nodes to my cluster?
No. The HPA only changes replica counts. A separate node autoscaler such as the cluster autoscaler, Karpenter, EKS Auto Mode or GKE Autopilot adds machines when new pods cannot be scheduled.
Can I use KEDA and a normal HPA together?
Not on the same Deployment. KEDA manages its own HPA for each ScaledObject, so a second HPA creates conflicting commands. Put every trigger, including CPU, inside the ScaledObject.





