A Kubernetes deployment that cannot scale itself is just a slower, more expensive way to run a fixed number of pods. The Horizontal Pod Autoscaler (HPA) is the control loop that fixes that, watching CPU, memory, or any custom signal you choose, then adding or removing pod replicas to match demand. It ships inside every Kubernetes distribution, it costs nothing extra to turn on, and as of October 2026 it has absorbed a wave of new tricks, from scale-to-zero on managed clusters to the custom Prometheus metrics pipelines that power today’s AI-agent workloads.
This tutorial builds a working Kubernetes HPA setup from a blank cluster to a production-ready configuration that scales on CPU, scales on custom application metrics, scales to zero when idle, and reports what it costs, one of the core cloud computing skills worth having regardless of which provider’s Kubernetes offering you run it on. By the end you will have a complete, tested project you can drop into any cluster running Kubernetes 1.30 or newer, including the Kubernetes 1.37 release now rolling out through Google Kubernetes Engine’s Rapid channel.
Don't miss new tech stories on Google
Add Shattered once in the Google app and our stories show up in your news suggestions.
Why Kubernetes HPA autoscaling matters more in 2026 #
Kubernetes adoption has not slowed down. Kubernetes was used by 28.5% of all respondents in the 2025 Stack Overflow Developer Survey, cementing its place as the default way teams run containers at scale. What has changed is what those containers are doing. A growing share of workloads are AI agents and inference services with spiky, unpredictable load, and the old approach of pinning a fixed replica count no longer holds up financially or operationally.
Google’s engineering teams have been pushing autoscaling hardest on this front. Google Cloud‘s AI infrastructure update describes GKE Agent Substrate, a runtime built for high-density agent workloads that the company says can run millions of sandboxes with roughly 10x the density of a standard container runtime, sub-500-millisecond resume operations, and more than 500 suspend-and-resume activations per second in Google’s internal testing. The same update covers GKE’s native scale-to-zero capability, which pairs the standard Horizontal Pod Autoscaler with a Kubernetes Enhancement Proposal (KEP-2021) metric adapter so idle services drop to zero replicas instead of idling at one. Google also reports that Pod snapshots, which preserve CPU and GPU memory state for later restoration, cut AI-inference cold-start time by as much as 89% in its internal tests.
None of that changes the fundamentals of how HPA works. It changes what you point it at. This tutorial covers the standard CPU and memory path first, then layers on the custom-metrics and scale-to-zero techniques that make 2026-era autoscaling genuinely different from the HPA most engineers learned five years ago. If you are running agent-style workloads behind an API gateway, pair this guide with our walkthrough on confidential containers for AI on Kubernetes, which covers the isolation side of the same problem.
The reason this is worth a dedicated tutorial rather than a quick reference card is that most teams configure HPA once, during the initial rollout of a service, and never revisit it. That is fine for a stateless web tier with predictable diurnal traffic. It is not fine for an agent pool fielding bursty, unscheduled requests from other services, where the gap between “scaled correctly” and “either Pending pods or a six-figure idle GPU bill” can be a single misjudged target percentage. The steps below are ordered so that each one builds a piece you will actually reuse: a baseline CPU autoscaler, a custom-metrics path for anything CPU cannot represent, a scale-to-zero layer for genuinely idle periods, and the guardrails that keep all three from becoming a cost or security problem on their own.
Prerequisites: tools and versions you need #
You do not need a production cloud account to follow this tutorial. A local cluster works for every step except the managed scale-to-zero section, which is called out separately. Install the following before you start:
- A Kubernetes cluster on version 1.30 or newer. This guide is written against Kubernetes 1.37, now available in GKE’s Rapid channel, though every command here also runs on the 1.36.x line that most managed providers ship as their default or stable channel today. Use kind, minikube, k3d, GKE, EKS, or AKS, whichever you have access to.
- kubectl matching your cluster’s minor version, within one version skew in either direction.
- Helm 3.15 or newer , used to install the Prometheus Adapter and KEDA later in the tutorial.
- metrics-server 0.7.x , the component that feeds CPU and memory data to the autoscaling/v2 API.
- KEDA 2.16 or newer , used for the scale-to-zero and event-driven scaling steps.
- A container registry and image build tool such as Docker or Podman, for the sample workload.
- hey or k6 , a lightweight HTTP load-testing tool, to generate the traffic that triggers scaling.
If you plan to run this against a managed provider rather than a local cluster, decide that now. Our comparison of ECS, EKS, and Fargate is a useful reference if you have not picked a platform yet, since the autoscaling building blocks in this tutorial behave slightly differently depending on whether the control plane is self-managed, EKS, GKE, or AKS.
A quick note on why each tool is on this list rather than left optional. metrics-server is non-negotiable, autoscaling/v2 cannot function without a resource metrics source. Helm is technically avoidable if you prefer raw manifests, but both the Prometheus Adapter and KEDA ship their supported install path as a Helm chart, and hand-rolling the equivalent RBAC and API service registrations is a lot of YAML to maintain for no real benefit. KEDA specifically matters even if you never touch its event-source scalers, because it is the most widely adopted way to get scale-to-zero behavior on a cluster that is not GKE, and its ScaledObject resource is intentionally designed to generate and manage a standard HPA object rather than replace one, so everything you learn about reading HPA status later in this guide still applies once KEDA is in the picture.
Step 1 and 2: provision a cluster and deploy the metrics server #
Step 1: Create or connect to your cluster
If you are using kind locally, create a three-node cluster so the autoscaler has somewhere to put new pods:
kind create cluster --name hpa-tutorial --config - <
On a managed provider, swap this for your normal cluster-creation command, for example gcloud container clusters create or eksctl create cluster. Confirm your server version before continuing:
kubectl version --short
Step 2: Deploy the metrics server
HPA's default autoscaling/v2 API needs a resource metrics source. metrics-server is the standard choice. Most managed clusters ship it already; local clusters almost never do.
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl patch deployment metrics-server -n kube-system --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl rollout status deployment metrics-server -n kube-system
kubectl top nodes
If kubectl top nodes returns real numbers instead of an error, metrics-server is wired up correctly and HPA has something to read.
Step 3 and 4: deploy a sample workload and write your first HPA manifest #
Step 3: Deploy a CPU-bound sample service
Use a small PHP-Apache image that burns CPU on request, a standard pattern for demonstrating autoscaling because it is cheap to load-test and easy to reason about.
kubectl create deployment php-apache --image=registry.k8s.io/hpa-example --port=80
kubectl set resources deployment php-apache --requests=cpu=200m,memory=128Mi --limits=cpu=500m,memory=256Mi
kubectl expose deployment php-apache --port=80 --target-port=80
kubectl get deployment php-apache
kubectl get svc php-apache
Setting a CPU request is not optional. HPA's CPU utilization target is a percentage of the request, so a pod with no CPU request has no baseline to scale against and the autoscaler will refuse to compute a target.
Step 4: Write the HorizontalPodAutoscaler manifest
Save this as hpa-cpu.yaml. It targets 50% average CPU utilization across replicas, with a floor of two pods and a ceiling of ten.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache-hpa
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
The autoscaling/v2 API is the one to use in every new manifest. The older autoscaling/v1 only supports CPU utilization and has been functionally frozen for years, it still exists for backward compatibility but has no reason to appear in new YAML in 2026.
Step 5 and 6: apply the HPA and load-test it #
Step 5: Apply and verify
kubectl apply -f hpa-cpu.yaml
kubectl get hpa php-apache-hpa --watch
You should see output similar to this once metrics-server has reported a data point, usually within 30 to 60 seconds:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache-hpa Deployment/php-apache 0%/50% 2 10 2 48s
Step 6: Generate load and watch it scale
Open a second terminal and hammer the service with a busy-loop pod, or use a dedicated load tool if you have one installed:
kubectl run load-generator --image=busybox:1.36 --restart=Never -- /bin/sh -c \
"while true; do wget -q -O- http://php-apache; done"
Within two to three polling intervals (HPA checks every 15 seconds by default), the TARGETS column should climb well past 50%, and REPLICAS should start increasing:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
php-apache-hpa Deployment/php-apache 305%/50% 2 10 2 3m02s
php-apache-hpa Deployment/php-apache 287%/50% 2 10 4 3m32s
php-apache-hpa Deployment/php-apache 156%/50% 2 10 6 4m02s
php-apache-hpa Deployment/php-apache 48%/50% 2 10 6 5m17s
Kill the load generator with kubectl delete pod load-generator and the replica count will drift back down to the minimum over the next several minutes, deliberately slower than the scale-up, which is covered in the stabilization window section below.
Step 7: move beyond CPU with custom Prometheus metrics #
CPU utilization is a poor proxy for load on anything that is I/O-bound, queue-bound, or GPU-bound, which describes most inference and agent workloads. Google's own guidance on GKE autoscaling now points teams toward Horizontal Pod Autoscaler configurations driven by custom Prometheus-compatible PromQL metrics rather than CPU alone, letting scaling rules reflect application-specific conditions like queue depth or requests-per-second instead of a generic resource proxy.
To wire a custom metric into HPA, you need an adapter that implements the Kubernetes custom metrics API and translates it from whatever you are actually monitoring, usually Prometheus. Install the Prometheus Adapter with Helm:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install prometheus-adapter prometheus-community/prometheus-adapter \
--namespace monitoring --create-namespace \
--set prometheus.url=http://prometheus-server.monitoring.svc \
--set prometheus.port=80
Then define a rule that exposes your chosen PromQL query, for example http_requests_per_second, as a custom metric, and reference it from a second HPA:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache-hpa-custom
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: php-apache
minReplicas: 2
maxReplicas: 20
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "100"
Run kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1" to confirm the adapter is registered before you apply this manifest. If that call 404s, the HPA controller will silently fail to compute a target and the replica count will never move, which is one of the most common failure modes covered later in this guide.
Step 8: add scale-to-zero with KEDA #
Standard HPA has a hard floor of one replica, it cannot scale a deployment to zero on its own. KEDA (Kubernetes Event-Driven Autoscaling) solves that by acting as a metrics source that can report zero when there is no work, then handing control back to a standard HPA object once at least one replica exists. This is the same pattern Google describes for GKE's native scale-to-zero, where the platform pairs HPA with a KEP-2021-compatible metric adapter so idle services drop to zero pods instead of idling at a minimum of one.
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
Define a ScaledObject that targets the same deployment and scales on a queue length, with minReplicaCount: 0 enabling the scale-to-zero behavior:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: php-apache-scaled
spec:
scaleTargetRef:
name: php-apache
minReplicaCount: 0
maxReplicaCount: 10
cooldownPeriod: 120
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus-server.monitoring.svc
metricName: http_requests_per_second
query: sum(rate(http_requests_total{app="php-apache"}[2m]))
threshold: "50"
KEDA creates and manages its own HPA object behind the scenes once the ScaledObject is applied. You can confirm this with kubectl get hpa, you will see a new HPA named after the ScaledObject with keda-hpa- as a prefix, fully compatible with everything you already learned about reading HPA status in Step 5.
Step 9: tune scaling behavior with stabilization windows and policies #
The default HPA behavior scales up fast and scales down slowly, on purpose, to avoid flapping. The behavior field lets you override both directions explicitly. This is the section most tutorials skip and most production incidents trace back to.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache-hpa-tuned
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
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
This configuration adds up to four pods per minute when scaling up with no stabilization delay, and removes at most 25% of current replicas per minute after a five-minute stabilization window on the way down. The stabilization window works by looking back over the window and picking the largest recommended replica count seen in that period, which is what prevents a brief dip in traffic from immediately shedding pods you will need again in ninety seconds.
Step 10: pair HPA with cluster and node autoscaling #
HPA changes how many pods exist. It has no opinion on whether your nodes have room for them. If HPA tries to schedule six more replicas onto a cluster with no spare capacity, those pods sit in Pending state indefinitely. Pair HPA with the Cluster Autoscaler, or your provider's equivalent such as GKE's node auto-provisioning or Karpenter on EKS, so new nodes appear when HPA needs somewhere to put pods.
kubectl get pods -o wide | grep Pending
kubectl describe pod | grep -A5 Events
If you see FailedScheduling events citing insufficient CPU or memory, that is the cluster autoscaler's job to fix, not HPA's. The two systems are independent control loops that happen to cooperate: HPA decides replica count, the node autoscaler decides node count, and a Pod Disruption Budget (covered next) decides how much churn either one is allowed to cause at once.
Step 11: secure, monitor, and control costs for autoscaled workloads #
An autoscaler that nobody is watching is a cost incident waiting to happen. Three things belong in any production HPA rollout: a Pod Disruption Budget, image and policy scanning on whatever gets autoscaled, and a budget alert tied to node count.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: php-apache-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: php-apache
If your autoscaled workloads run arbitrary or third-party code, run them through a policy engine before they ever reach HPA's scaling path. Our guide to Kyverno pod security policies on Kubernetes covers admission-time enforcement that blocks misconfigured pods from being created in the first place, which matters more once autoscaling means dozens of replicas can be spun up from a single bad template in minutes. Pair that with routine image scanning, our roundup of free and paid container vulnerability scanners breaks down what each tool actually catches before those images get autoscaled across a cluster.
On the cost side, every extra replica is extra node capacity, extra network egress, and on GPU-backed inference pods, meaningfully more spend per hour than a typical web pod. If you are autoscaling GPU workloads specifically, read our breakdown of Google Cloud's FinOps tooling for flexible GPU commitments before you raise maxReplicas past single digits, since an autoscaler with no ceiling and no budget alert will happily scale your bill along with your pods.
Step 12: validate the complete pipeline end to end #
With every piece in place, run a final end-to-end check before calling the setup production-ready:
kubectl get hpa
kubectl get scaledobject
kubectl get pdb
kubectl top pods
kubectl get --raw "/apis/autoscaling/v2" | head -5
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1" | head -5
You should see every HPA object reporting live TARGETS numbers (not <unknown>), the ScaledObject showing an ACTIVE status, and both API groups returning data instead of errors. If any one of those checks fails, work through the troubleshooting table below before moving workloads onto this setup.
A real-world walkthrough: autoscaling an AI agent inference pool #
The steps above work through a synthetic PHP-Apache example because it is the standard, reproducible way to demonstrate HPA mechanics without depending on a specific model or inference stack. It is worth walking through how the same pieces fit together for the workload most teams are actually trying to autoscale in October 2026: a pool of agent-serving pods sitting behind an internal API that other services call on demand.
Picture a support-ticket triage agent, one pod per replica, each holding a loaded model in memory and able to handle a handful of concurrent requests before latency degrades. Traffic is lumpy: quiet overnight, a spike every morning when the support queue fills up, and occasional bursts when a product incident generates hundreds of tickets in an hour. CPU utilization is a weak signal here, the pod can be CPU-idle while still saturated on concurrent request slots or GPU memory. This is exactly the case for the custom-metrics path from Step 7, scaling on a PromQL query like avg(agent_queue_depth) or sum(rate(agent_requests_total[2m])) rather than CPU.
Overnight, when the queue is empty for hours at a stretch, paying for even two idle replicas around the clock is the kind of waste that shows up on a monthly cloud bill without ever showing up in an incident. That is the case for the KEDA scale-to-zero layer from Step 8: a ScaledObject watching the same queue-depth metric, with minReplicaCount: 0 and a cooldown period long enough that a single stray request does not trigger a scale-up and scale-down cycle every few minutes. Google's own description of this pattern on GKE, pairing HPA with a KEP-2021 metric adapter so idle agent sandboxes drop to zero instead of idling at a floor of one, is the platform-native version of the same idea this tutorial builds with open tooling.
During the morning spike, the stabilization and policy tuning from Step 9 decides how fast the pool catches up. A scaleUp policy with no stabilization delay and a generous pods-per-minute ceiling gets replicas online quickly when the queue starts climbing. A five-minute scaleDown stabilization window stops the pool from shedding a replica the moment the queue dips for ninety seconds, only to need it again shortly after. And because each new replica has to land somewhere, the cluster or node autoscaler from Step 10 is what actually determines whether that morning spike resolves in thirty seconds or stalls with pods stuck Pending while new nodes boot.
The pieces that matter most once this is running in production are the ones from Step 11: a Pod Disruption Budget so a node drain or rolling update does not take the whole agent pool offline at once, and a cost ceiling on maxReplicas so a runaway upstream retry loop cannot scale the pool, and the bill, without limit. None of this requires abandoning the CPU-based HPA from Step 4, it simply means CPU is the floor-level safety net while the custom metric does the real scaling decisions, which is the same multi-metric pattern covered in the advanced tips below.
Reference: metric sources and version support #
HPA metric sources compared
| Metric source | What it measures | Setup complexity | Scales to zero | Best for |
|---|---|---|---|---|
| Resource metrics (metrics-server) | CPU and memory utilization | Low | No | Generic web and API workloads |
| Custom metrics (Prometheus Adapter) | Any PromQL query exposed as a metric | Medium | No | Queue depth, request rate, app-specific load |
| External metrics | Signals from outside the cluster, e.g. a cloud queue | Medium | No | Cloud-managed queues and pub/sub backlogs |
| KEDA ScaledObject | 50+ built-in event source scalers | Medium | Yes | Event-driven services, batch, idle-heavy workloads |
| GKE Agent Substrate scale-to-zero | Platform-native HPA plus KEP-2021 metric adapter | Low on GKE, unavailable elsewhere | Yes | Agent sandboxes and inference on GKE specifically |
Common pitfalls when configuring Kubernetes HPA #
- No resource requests set on the target deployment. HPA's CPU utilization target is a percentage of the request. Skip the request and the HPA controller has nothing to divide by, so it reports
<unknown>forever. - Setting minReplicas and maxReplicas to the same value. This disables autoscaling entirely while making it look configured. If the floor and ceiling match, nothing can ever change.
- Forgetting the Prometheus Adapter when referencing a custom metric. A
type: Podsortype: Externalmetric block does nothing without a registered adapter serving the custom or external metrics API, and the failure is silent rather than an error. - Using autoscaling/v1 in new manifests. It only supports CPU, it has no
behaviorfield, and it has been effectively superseded by autoscaling/v2 for years. - Ignoring the Cluster Autoscaler. HPA will dutifully request more pods than the cluster can schedule, and those pods will sit Pending with no visible error in the HPA status itself.
- Combining HPA with a Vertical Pod Autoscaler on the same resource metric. Both controllers adjusting CPU-related values on the same deployment at the same time creates a feedback loop neither one is designed to resolve, document which controller owns which dimension before enabling both.
Troubleshooting Kubernetes HPA: 9 issues and fixes #
| Symptom | Likely cause | Fix |
|---|---|---|
| TARGETS column shows <unknown>/50% | No resource requests on the pod spec, or metrics-server not reporting yet | Add CPU requests; run kubectl top pods to confirm metrics-server is live |
| HPA never scales despite high load | minReplicas equals maxReplicas, or the deployment is d | Check kubectl describe hpa for the Conditions section and widen the replica range |
| Custom metric query returns no data | Prometheus Adapter not installed, or the PromQL rule name does not match the metric referenced in the HPA | Run kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1" and compare names exactly |
| Pods stuck Pending after HPA scales up | Cluster has no spare node capacity | Check Cluster Autoscaler logs, or raise node pool limits manually |
| HPA scales down too aggressively during a brief traffic dip | No stabilization window configured on scaleDown | Add a 300+ second stabilizationWindowSeconds under behavior.scaleDown |
| KEDA ScaledObject shows status Unknown | Trigger endpoint (Prometheus, queue, etc.) unreachable from the cluster | Verify network policy and serverAddress, check KEDA operator logs |
| HPA and VPA fighting over the same deployment | Both controllers targeting CPU on the same resource | Scope VPA to memory only, or disable VPA's CPU recommendations |
| Replica count flaps rapidly between two values | Target utilization set too close to normal baseline load | Raise the target percentage or widen the behavior policies |
| HPA works locally but not on the managed cluster | Managed provider uses a different default metrics pipeline or RBAC scope | Check provider-specific metrics API docs; confirm the HPA controller's service account has read access |
Advanced tips for production-grade autoscaling #
Once the base setup is running, a few refinements separate a demo from something you would trust in production.
- Scale on multiple metrics at once. List more than one entry under
metricsin a single HPA, and the controller computes a recommended replica count for each one, then uses the largest. This lets you scale on CPU and queue depth simultaneously without picking a winner in advance. - Use container-level resource metrics for multi-container pods. The
type: ContainerResourcemetric scopes CPU or memory to one named container instead of averaging across every container in the pod, which matters once sidecars enter the picture. - Watch for the KEP-2021 pattern on your own cluster, not just GKE. The underlying mechanism Google uses for native scale-to-zero, an external metric adapter reporting zero when idle, is portable. KEDA implements the same idea today on any CNCF-conformant cluster.
- Snapshot warm state for fast-scaling AI workloads. If cold starts are your bottleneck rather than scheduling, look at checkpoint and restore approaches similar to Google's Pod snapshots, which the company reports cut AI-inference startup time by up to 89% by restoring CPU and GPU memory state instead of reinitializing it.
- Treat the autoscaling API group as a dependency to monitor. Alert on
kube_horizontalpodautoscaler_status_conditionfrom kube-state-metrics so a silently broken metrics pipeline shows up before an incident does, not during one. - Give new pods a startup grace period before HPA counts them. The
readinessProbeinitial delay on a slow-starting container, especially one a model into memory, matters more than it looks. HPA's utilization math includes pods that are not yet Ready at a default utilization of 0%, which can drag the average down and mask real load on the pods that are already serving traffic. - Keep scaling policies in version control alongside the deployment they target. A
behaviorblock tuned for one traffic pattern can quietly become wrong as a service's usage changes. Reviewing HPA manifests on the same cadence as the deployments they scale catches drift before it turns into either wasted spend or a slow-to-react autoscaler during an actual spike.
The complete working project #
Here is every piece from this tutorial combined into a single file you can apply in one pass against a fresh namespace. Save it as hpa-complete-project.yaml.
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-apache
labels:
app: php-apache
spec:
replicas: 2
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: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: php-apache
spec:
selector:
app: php-apache
ports:
- port: 80
targetPort: 80
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache-hpa
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
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: php-apache-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: php-apache
Apply it, confirm the rollout, then run the same load test from Step 6 to prove the whole chain works together:
kubectl apply -f hpa-complete-project.yaml -n hpa-tutorial
kubectl -n hpa-tutorial rollout status deployment/php-apache
kubectl -n hpa-tutorial get hpa,pdb
Add the KEDA ScaledObject from Step 8 and the Prometheus Adapter custom-metric HPA from Step 7 to the same file once you have Prometheus and KEDA installed in the cluster, and you have the full 2026-pattern autoscaling stack: CPU-based scaling as a baseline, custom-metric scaling for real application load, scale-to-zero for idle periods, and a disruption budget that keeps rolling updates from colliding with autoscaling events.
Kubernetes HPA versions and API support at a glance
| Component | Version used in this tutorial | Minimum supported | Notes |
|---|---|---|---|
| Kubernetes | 1.37 (GKE Rapid channel) | 1.30 | autoscaling/v2 behavior field requires 1.23+ |
| metrics-server | 0.7.x | 0.6.x | Required for CPU/memory metrics |
| Prometheus Adapter | Latest Helm chart release | v0.11+ | Required for custom/external metrics API |
| KEDA | 2.16+ | 2.10+ | Required for scale-to-zero outside GKE |
| Helm | 3.15+ | 3.10+ | Used to install Prometheus Adapter and KEDA |
This tutorial focused on GKE's scale-to-zero implementation because it is the most fully documented in Google's current release notes, but the same KEP-2021 pattern applies regardless of cloud provider since it operates at the Kubernetes API level rather than inside a specific vendor's control plane. Amazon's serverless autoscaling work follows a similar philosophy on the compute side, our coverage of AWS's incident response tooling is a useful companion if you are running autoscaled workloads on EKS and want visibility into security findings generated as replica counts change, and AWS's own October 2026 serverless highlights cover parallel announcements on the Lambda side of the autoscaling spectrum.
How HPA fits into a broader cloud autoscaling strategy #
HPA is one layer of a stack that, done well, has no single point of manual intervention. Below HPA sits the Cluster Autoscaler or your provider's node-provisioning equivalent, handling physical capacity. Above HPA sits whatever is actually generating load, an ingress controller, a service mesh, or in agent-heavy architectures, a gateway routing requests to different agent pools. Microsoft's Azure team has been shipping comparable updates on its own autoscaling and monitoring surface, as tracked in Azure's ongoing announcements feed, which is worth a periodic check if your workloads straddle GKE and AKS.
The practical takeaway for most teams migrating from fixed replica counts: start with CPU-based HPA because it is nearly free to configure and immediately removes the worst over-provisioning waste, add custom metrics once you understand your actual bottleneck, and only reach for scale-to-zero once you have a workload with genuine idle periods worth collapsing, since the cold-start cost of scaling from zero is not free even with snapshot-based restoration techniques.
It is also worth being honest about where this tutorial's techniques run out of road. If a single request needs more CPU or memory than one pod can hold, no amount of HPA tuning fixes that, that is a Vertical Pod Autoscaler or a bigger instance type problem. If your bottleneck is a downstream database or third-party API rather than your own pods, autoscaling the pods in front of it just means more concurrent callers hitting the same saturated dependency, sometimes making an outage worse rather than better. And if your workload has genuinely predictable, scheduled load, like a nightly batch job, a CronJob with a fixed replica count is simpler and more debuggable than dressing the same problem up as an autoscaling pipeline. HPA earns its complexity when load is real and variable, not as a default for every deployment regardless of its traffic shape.
Frequently asked questions #
What is the difference between HPA and the Vertical Pod Autoscaler?
HPA changes the number of pod replicas running behind a deployment. The Vertical Pod Autoscaler changes the CPU and memory requests of the existing pods themselves, resizing them rather than multiplying them. They solve different problems: HPA handles variable concurrent load by spreading it across more or fewer copies of the same pod, while VPA handles a pod that is consistently over- or under-provisioned relative to what it actually needs. As noted in the pitfalls section above, the two should not both control the same resource metric on the same deployment at once, since each one adjusting CPU-related values independently creates a feedback loop neither is built to resolve.
Can HPA scale a deployment to zero replicas?
Not on its own. Standard HPA enforces a hard minimum of one replica, by design, since the controller has no concept of "no pods currently serving" versus "no pods because nothing is configured." Scale-to-zero requires an external mechanism like KEDA, which creates and manages its own HPA object while adding the ability to report a zero target, or a platform-native implementation like GKE's scale-to-zero, which pairs standard HPA with a KEP-2021-compatible metric adapter to get the same result without a third-party controller.
How often does HPA check metrics and make scaling decisions?
Every 15 seconds by default, controlled by the --horizontal-pod-autoscaler-sync-period flag on the controller manager. Most clusters never need to change this default sync period, it is frequent enough to react to real load changes within roughly 30 to 60 seconds once stabilization windows are accounted for, and shortening it further mostly adds API server load without meaningfully improving reaction time.
Why does my HPA show TARGETS as unknown instead of a percentage?
This almost always means either metrics-server is not running or not yet reporting data for the target pods, or the target deployment has no CPU resource request set, which removes the denominator HPA needs to compute a utilization percentage. Both failure modes, along with five other common ones, are covered with specific fixes in the troubleshooting table above.
Is KEDA a replacement for HPA or does it work alongside it?
KEDA works alongside HPA, it is not a competing autoscaler. When you create a KEDA ScaledObject, KEDA generates and manages a standard Kubernetes HPA object behind the scenes, visible with a normal kubectl get hpa, and adds the ability to scale from and to zero, which plain HPA cannot do by itself. Everything this tutorial covers about reading HPA status, tuning behavior policies, and troubleshooting unknown targets still applies to the HPA object KEDA creates.
Does HPA work the same way on EKS, AKS, and GKE?
The core autoscaling/v2 API behaves identically across all three, since it is a Kubernetes API defined by upstream Kubernetes, not a vendor-specific feature. What differs is the default metrics pipeline each managed provider ships out of the box and, in GKE's case specifically, additional native scale-to-zero and Agent Substrate tooling not present on EKS or AKS by default as of this writing.
What is the Kubernetes Enhancement Proposal referenced for scale-to-zero?
KEP-2021 defines a pattern for metric adapters to report a scaleable-to-zero signal that HPA-compatible controllers can act on, letting a deployment's replica count legitimately reach zero rather than being floored at one. It underpins both GKE's native scale-to-zero capability, described in Google's recent AI infrastructure updates, and the general approach KEDA has used for scale-to-zero for several years prior to that.
Do I need Prometheus specifically, or can I use another monitoring system for custom metrics?
Prometheus is the most common choice because the Prometheus Adapter is the most mature and widely deployed implementation of the Kubernetes custom metrics API, but any system that can expose a compatible custom or external metrics API endpoint will work, including several commercial APM and observability vendors that ship their own adapters for exactly this purpose.
What happens if I set maxReplicas too low for actual peak demand?
HPA caps the deployment at the configured ceiling regardless of how high the underlying metric climbs, so excess load simply queues up or gets rejected at whatever layer sits in front of the pods, typically a load balancer or ingress controller. There is no warning baked into HPA itself when this happens, which is why the end-to-end validation in Step 12 and the budget guardrails in Step 11 both matter: a maxReplicas ceiling that was correct at launch can quietly become the actual bottleneck months later as traffic grows.