Lesson 49 of 66 · 15 min read · Updated
Horizontal Pod Autoscaler with metrics-server
On this page
So far every Deployment in this series has had a fixed replicas value. You picked three, Kubernetes kept three, and that was the end of it. Real traffic does not work like that. A .NET API that is idle at 03:00 and saturated at 12:00 either wastes money all night or falls over at lunch.
The HorizontalPodAutoscaler (HPA) fixes this by changing replicas for you. It watches a metric, compares it to a target, and adds or removes Pods to keep the metric near that target. This article builds one from scratch on a local multi-node cluster and watches it scale a CPU-hungry .NET API from one Pod to eight and back again.
We will cover the following topics:
- How the HPA control loop works and where its numbers come from
- Installing metrics-server, the component that feeds the HPA
- Why resource requests are the foundation of utilization-based scaling
- Writing an
autoscaling/v2HPA with CPU and memory targets - Tuning scale-up and scale-down speed with the
behaviorfield - Where the Cluster Autoscaler and Karpenter fit in
Technical Requirements
- A local multi-node cluster. The examples use Docker Desktop's built-in Kubernetes with the kind provisioner: one control-plane node and three workers (
desktop-worker,desktop-worker2,desktop-worker3), Kubernetes v1.36. A plainkind create clusterwith one control-plane and three workers behaves the same way. kubectlv1.36 and Docker.- The .NET 10 SDK if you want to build the sample image yourself.
Core Concepts
The Control Loop
The HPA is a controller inside kube-controller-manager. Every 15 seconds it runs the same loop for each HPA object:
- Read the current value of each metric for every Pod of the target workload.
- Compute how many replicas would bring that metric back to the target.
- Apply the
behaviorrules that limit how fast it may move. - Write the result to the
replicasfield of the Deployment.
The core formula is short:
If one Pod is running at 250% CPU utilization and the target is 50%, the HPA wants ceil(1 * 250 / 50) = 5 Pods. When the HPA watches several metrics it computes a replica count for each one and takes the largest. Scaling is driven by whichever resource is under the most pressure.
The HPA never touches Pods directly. It only changes replicas on the Deployment, and the Deployment's ReplicaSet does the rest, exactly as if you had run kubectl scale yourself.
Where the Metrics Come From
The HPA does not measure anything. It asks the Kubernetes metrics APIs:
| API | Served by | Used for |
|---|---|---|
metrics.k8s.io | metrics-server | CPU and memory of Pods and nodes (type: Resource) |
custom.metrics.k8s.io | An adapter such as Prometheus Adapter | Metrics attached to Kubernetes objects, for example requests per second per Pod |
external.metrics.k8s.io | An adapter such as KEDA | Metrics from outside the cluster, for example queue length |
A fresh cluster serves none of them. Without metrics-server, kubectl top fails and every CPU-based HPA reports <unknown>. Managed clusters (AKS, EKS, GKE) usually install it for you. Local clusters usually do not.
metrics-server scrapes the kubelet on every node, keeps only the latest sample in memory, and serves it through the API. It is deliberately simple. It is not a monitoring system and keeps no history.
Utilization Is Measured Against Requests
This is the single most important rule of CPU and memory autoscaling:
The percentage is relative to the container's request, not its limit and not the node's capacity. A container that requests 200m CPU and uses 500m is at 250% utilization. That is not an error. It simply means the container is borrowing spare CPU on the node, up to its limit.
Two consequences follow:
- A container with no CPU request cannot be scaled on CPU utilization. The HPA reports
failed to get cpu utilization: missing request for cpu. - The request you choose decides how aggressive the HPA is. With a tiny request, normal load already looks like 300% and the HPA scales out early. With a huge request, the HPA waits too long.
If you are unsure what the request should be, the next article on the Vertical Pod Autoscaler shows how to measure it.
Target Types
An autoscaling/v2 metric can use three kinds of target:
| Target type | Meaning | Example |
|---|---|---|
Utilization | Average percentage of the request across Pods | averageUtilization: 50 |
AverageValue | Average absolute value per Pod | averageValue: 300m |
Value | One total value for the whole workload | value: "100" for an external metric |
CPU and memory usually use Utilization. Memory is a weaker scaling signal than CPU for .NET: the garbage collector holds on to memory it has already allocated, so memory often does not drop when load drops. Treat a memory target as a safety net, not as the main driver.
The behavior Field
Without tuning, the HPA scales up quickly (by default it may double the replica count or add four Pods every 15 seconds, whichever is more) and scales down cautiously. The behavior field lets you set both directions explicitly:
| Field | Purpose |
|---|---|
stabilizationWindowSeconds | Look back over this window and use the most conservative recommendation. It prevents flapping. The default is 0 for scale-up and 300 for scale-down. |
policies | Limits on how much can change in one periodSeconds, either as a number of Pods or as a Percent of current replicas. |
selectPolicy | Max picks the policy allowing the biggest change, Min the smallest, and Disabled turns that direction off entirely. |
A common production shape is fast up, slow down. Add capacity as soon as it is needed, and remove it one Pod at a time once the load has clearly gone.
What the HPA Cannot Do
The HPA adds Pods. It does not add nodes. If the new Pods do not fit, they stay Pending and the HPA has done its job but nothing improves. On a cloud cluster a node autoscaler closes this gap:
- Cluster Autoscaler watches for Pods that are
Pendingbecause of insufficient resources and grows a node group (an AKS node pool, an EKS managed node group). When nodes sit underused, it drains and removes them. - Karpenter works on the same trigger but skips node groups. It looks at the pending Pods' requirements and launches whichever instance type fits them best. It started on AWS and also has an Azure provider.
Neither can run on a local kind cluster, because there is no cloud API to create machines. The principle still matters: Pod autoscaling and node autoscaling are two separate layers, and production needs both.
Hands-On: Kubernetes Commands
Check that the metrics API is available
If this returns NotFound, metrics-server is not installed.
See live CPU and memory per node and per Pod
These are the same numbers the HPA reads.
Create an HPA imperatively
Handy for experiments. In kubectl v1.36 the flags are --cpu and --memory, which accept a percentage or an absolute value.
Generate the YAML instead of applying it
The safest way to learn the autoscaling/v2 shape.
Watch the HPA make decisions
The TARGETS column shows current value against target for every metric.
Read the HPA's reasoning
The Conditions and Events sections explain every scaling decision and every failure.
Step-by-Step Example
The Scenario
You run scaling-api, a .NET 10 minimal API with a /burn endpoint that spends a configurable number of milliseconds of pure CPU per request. It stands in for any CPU-heavy work: image resizing, PDF generation, JSON transformation. You want it to run on one Pod when idle and grow to at most eight under load.
Step 1 — Install metrics-server
The official manifest works on any cluster. kind's kubelets use self-signed serving certificates, so metrics-server must be told not to verify them with --kubelet-insecure-tls. Never use this flag on a production cluster with properly signed kubelet certificates.
After about 20 seconds the first samples arrive:
Step 2 — Build the Application
Create the project:
Replace Program.cs:
Add a Dockerfile and build:
Docker Desktop's kind cluster can use images from your local Docker image store directly. The first Pod on each node may sit in ContainerCreating for a minute while the node fetches the image. With a standalone kind cluster, load the image first with kind load docker-image scaling-api:1.0.
Step 3 — Deploy With Requests
Save as 2-scaling-api.yaml. The requests block is what makes CPU utilization meaningful.
Notice that replicas: 1 is still in the manifest. Once an HPA owns the Deployment, the HPA's value wins. If you later re-apply this file, replicas: 1 will briefly fight the HPA. Many teams remove replicas from the manifest once an HPA exists for exactly that reason.
Step 4 — Create the HPA
Save as 3-hpa.yaml:
The idle API uses about 2% of its 200m CPU request and 15% of its 128Mi memory request. Both are under target, so one replica is correct.
Step 5 — Generate Load
Save as 4-load-generator.yaml. Four busybox Pods call /burn?ms=200 in a tight loop through the Service.
Open a second terminal and start watching before you apply it:
Step 6 — Watch It Scale Out
This is the real output from the test cluster, with the REFERENCE, MINPODS and MAXPODS columns removed:
Read it line by line:
- At 250%, the formula asks for
ceil(1 * 250 / 50) = 5Pods. The scale-up policies allow at mostmax(100% of 1, 2) = 2extra Pods per 15 seconds, so the HPA goes to 3. - Fifteen seconds later the formula still wants more. Now the policies allow
max(100% of 3, 2) = 3extra Pods, but only 5 are needed, so it goes to 5. - It reaches 8, the
maxReplicasceiling, and stops. CPU stays above target because the load generator is relentless, but the HPA is not allowed to go higher.
The describe output confirms it:
Two details are worth noticing:
ScalingLimited: TooManyReplicastells you the HPA wanted more thanmaxReplicas. In production, that condition is a signal to raise the ceiling or to make each Pod more efficient.- The early
FailedGetResourceMetricwarning appeared in the first seconds after the HPA was created, before metrics-server had a sample for the Pod. It is harmless at startup. If it persists, check that metrics-server is healthy and that the Pods areReady.
kubectl get pods -o wide shows the new Pods spread across the three worker nodes. That spread is the scheduler's job, not the HPA's.
Step 7 — Watch It Scale In
Stop the load:
CPU drops to almost zero within a minute, but the replica count does not move straight away:
The 60-second stabilization window is holding the old, higher recommendation. After that, the scale-down policy removes one Pod every 30 seconds:
Going from 8 to 1 took about five minutes. That is deliberate. If traffic comes back after a short pause, the Pods are still there. With the default scaleDown behavior (a 300-second window), the wait would be even longer.
Memory utilization ended slightly higher than it started (26% against 15%) even with no load. That is the .NET garbage collector keeping its heap after the burst, and it is the reason memory alone is a poor scale-down signal.
Step 8 — Clean Up
Deleting the namespace removes the Deployment, the Service and the HPA together:
Keep metrics-server installed if you continue with the next article. To remove it:
Summary
- The HPA changes
replicason a Deployment so that a metric stays near a target. The formula isceil(currentReplicas * current / target), evaluated every 15 seconds, and the largest result across metrics wins. - CPU and memory metrics come from metrics-server, which local clusters usually lack. On kind it needs
--kubelet-insecure-tls. - Utilization is a percentage of the container's request. No request means no utilization-based scaling, and the size of the request sets how aggressive the HPA is.
behaviorcontrols speed:stabilizationWindowSecondsprevents flapping,policiescap each step, andselectPolicychooses between them. Fast up and slow down is a sound default.- Memory is a weak scaling signal for .NET because the GC keeps its heap. Use CPU, or a business metric, as the main driver.
- The HPA scales Pods, not nodes. On a cloud cluster, the Cluster Autoscaler or Karpenter adds the machines those Pods need.
The HPA changes how many Pods you run. In the next article we look at the other dimension, how big each Pod is, with the Vertical Pod Autoscaler and in-place Pod resize.