Every webapp Deployment so far has been driven by a single static replicaCount in the chart values — set once, ignored thereafter. That is the easiest thing to declare in a Helm chart, and the worst thing to run in production. At 3 AM your two pods are idle and you are paying for capacity you do not use. At 9 AM a marketing email lands, traffic spikes 10×, and those same two pods get CPU-throttled into latency hell before anyone notices. Fixed replicas are a compromise between two failure modes; they do not solve either one.
The Horizontal Pod Autoscaler (HPA) removes the compromise. You tell it the minimum and maximum number of replicas, plus a target metric — typically CPU utilisation — and it scales the Deployment up or down to keep that metric near the target. Idle at 3 AM? Two pods. 10× traffic at 9 AM? Six pods. The HPA control loop runs every 15 seconds; the response curve is in minutes, not in human-reaction time.
CPU is the entry-level metric. The harder, more useful pattern is scaling on metrics your application actually cares about — requests-per-second, queue depth, websocket connections, p99 latency. The Prometheus Adapter plugs the metrics already in your Day 8 Prometheus stack into the same HPA API, so the autoscaler can react to anything you can write a PromQL query against.
Three terms, before you start:
- Horizontal Pod Autoscaler (HPA) — a Kubernetes object that automatically adds or removes pod replicas to keep a metric (usually CPU) near a target you set. "Horizontal" means more copies of the pod — as opposed to "vertical" (a bigger pod), which is Day 26.
- metrics-server — the small component that collects live CPU/memory usage from every node and serves it on the
metrics.k8s.ioAPI. The HPA reads from it. (kind doesn't ship it, so you install it first.)- Utilization — usage measured as a percentage of what the pod requested. "60% CPU utilization" means each pod averages 60% of its
resources.requests.cpu. With no CPU request set, the HPA can't compute a percentage and shows<unknown>.
What you will build
By the end of this article you will have:
- The metrics-server installed in your kind cluster (it does not ship by default)
- An
HorizontalPodAutoscalerresource added to the webapp Helm chart, gated by anautoscaling.enabledvalue - The Deployment template patched to omit
replicas:when HPA owns scaling — preventing a fight between Helm/Argo CD and the HPA controller - A k6 load Job running inside the cluster that drives constant load against the webapp via the in-cluster Service DNS
- A live demo of scale-up:
kubectl get hpa --watchshowing replicas climb from 2 → 6 within ~90 seconds of load starting - A live demo of scale-down: replicas falling back to 2 after the 5-minute stabilisation window
- The Prometheus Adapter installed, translating the Prometheus counter
nginx_ingress_controller_requestsinto the Kubernetes custom metricrequests_per_second— and an HPA scaling on that metric directly
How HPA works
Before installing anything, understand the control loop the HPA controller implements.
%%{init: {'theme': 'dark'}}%%
flowchart LR
PODS["webapp Pods\n(2 → N replicas)"]:::pod
subgraph CLUSTER ["kube-system"]
KUBELET["kubelet cAdvisor\n(per node)\nCPU, memory samples"]:::node
METRICS["metrics-server\nDeployment\naggregates cluster-wide\nexposes metrics.k8s.io"]:::metrics
end
subgraph CONTROL ["kube-controller-manager"]
HPA["HPA controller\npolls every 15 s\ndesired = ceil(current × ratio)"]:::hpa
end
DEP["Deployment\nwebapp-webapp\nspec.replicas"]:::dep
PODS -->|"1 · scrape per-Pod usage"| KUBELET
KUBELET -->|"2 · /metrics/resource"| METRICS
METRICS -->|"3 · metrics.k8s.io API\nGET PodMetricsList"| HPA
HPA -->|"4 · desired = ceil(current × \n currentCPU / targetCPU)\n patch spec.replicas"| DEP
DEP -->|"5 · ReplicaSet scales\nnew Pods created"| PODS
classDef pod fill:#1d2d1d,stroke:#3fb950,color:#e6edf3
classDef node fill:#1c2128,stroke:#30363d,color:#8b949e
classDef metrics fill:#1a2744,stroke:#58a6ff,color:#e6edf3
classDef hpa fill:#2a1f10,stroke:#d29922,color:#e6edf3
classDef dep fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3Reading this diagram:
Five numbered arrows form a closed loop. Follow them in order — they describe one full HPA cycle.
The diagram is split into two labelled groupings. The kube-system box on the left is the metric data plane: it collects and exposes raw usage numbers. The kube-controller-manager box on the right is the scaling control plane: it reads those numbers and decides what to do. Everything else — the webapp Pods (green, the live workload) and the Deployment (purple, the scaled resource) — sits outside both groupings.
Arrow 1. Each kubelet (grey — the always-on node agent) watches its local Pods through an embedded cAdvisor. The arrow points from the Pods to the kubelet because that is the direction the usage data flows: cgroup counters are sampled by the kubelet on the same node. No installation required.
Arrow 2. metrics-server (blue, in kube-system) is the new component you install in Part 1. Every 15 seconds it scrapes the /metrics/resource endpoint of every kubelet, aggregates the numbers cluster-wide, and holds them in memory. This is a short-term sample buffer feeding the HPA — it is not a monitoring system, and nothing is persisted to disk.
Arrow 3. The HPA controller (amber — the decision-maker) lives inside kube-controller-manager. Every 15 seconds it issues GET PodMetricsList against the metrics.k8s.io API. That API is served by metrics-server through an APIService registration: from the HPA's point of view it is just calling the Kubernetes API, and the API server proxies the call to the metrics-server Pod behind the scenes.
Arrow 4. The controller computes a single number: desiredReplicas = ceil(currentReplicas × currentCPU% / targetCPU%). Concrete example — current replicas = 2, current CPU usage = 120 % of request, target = 60 % of request. The ratio is 120 / 60 = 2.0, so desired = ceil(2 × 2.0) = 4. If desired ≠ currentReplicas, the controller patches spec.replicas on the Deployment (purple) directly. It does not touch any other field — not the Pod template, not the image, just the replica count.
Arrow 5. The Deployment controller takes over from here — it scales the ReplicaSet, which creates or terminates Pods. New Pods immediately start reporting CPU through arrows 1 → 2 → 3, which closes the loop. The next HPA reconcile (15 s later) sees the new usage and either holds steady or adjusts again.
The split visual grouping is a debugging hint: if kubectl top pods works but the HPA shows <unknown> for its target, the break is between the two groupings — metrics-server is fine, but the HPA controller cannot reach the API. If kubectl top pods itself fails, the break is inside the left grouping — metrics-server is unhealthy.
Prerequisites
This article continues directly from Day 11. The following must be in place:
- The
devops-clusterkind cluster running with NGINX Ingress Controller, cert-manager, kube-prometheus-stack, and Argo CD - The
gitops-webappGitHub repo synced by an Argo CD Application namedwebapp - The Sealed Secrets controller from Day 11 (used implicitly — your webapp consumes
webapp-secretviaenvFrom)
Run the pre-flight check:
kubectl get application -n argocd webapp
kubectl get deployment -n default webapp-webappExpected output:
NAME SYNC STATUS HEALTH STATUS
webapp Synced Healthy
NAME READY UP-TO-DATE AVAILABLE AGE
webapp-webapp 3/3 3 3 1dThe Deployment currently has 3 fixed replicas from the static replicaCount: 3 set on Day 10. That value will become a default that the HPA overrides once we wire it in.
| Tool | Minimum version | Check |
|---|---|---|
| kubectl | 1.29 | kubectl version --client |
| Helm | 3.14 | helm version --short |
| gh CLI | 2.x | gh --version |
Part 1 — Install metrics-server
The HPA does not collect metrics itself; it reads them from the metrics.k8s.io API. That API has to be served by something — by convention, the metrics-server Deployment maintained by the Kubernetes SIG. Production clusters from cloud providers (EKS, GKE, AKS) ship with metrics-server pre-installed; kind clusters do not, so installing it is the first step.
There is one kind-specific gotcha. By default, metrics-server validates the kubelet's serving certificate against the cluster CA. Kind's kubelet uses a self-signed certificate that is not in the cluster CA bundle, so validation fails and metrics-server cannot scrape any node. The fix is to pass --kubelet-insecure-tls, which tells metrics-server to skip the kubelet certificate check. This is safe inside a kind cluster (the network is local-only) but must not be set in production — that flag is a kind-cluster workaround, not a feature.
Add the metrics-server Helm repository:
helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server
helm repo updateExpected output:
"metrics-server" has been added to your repositories
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "metrics-server" chart repository
Update Complete. ⎈Happy Helming!⎈Install metrics-server with the kind-specific flag:
mkdir -p ~/30-days-devops/day-12 && cd ~/30-days-devops/day-12
# --set args puts the extra CLI argument on the metrics-server binary.
# The {} syntax escapes the curly braces so Helm passes a list, not a map.
helm install metrics-server metrics-server/metrics-server \
--namespace kube-system \
--version 3.12.2 \
--set 'args={--kubelet-insecure-tls}'Expected output:
NAME: metrics-server
LAST DEPLOYED: Sat May 23 09:00:00 2026
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
***********************************************************************
* Metrics Server *
***********************************************************************
Chart version: 3.12.2
App version: 0.7.2
Image tag: registry.k8s.io/metrics-server/metrics-server:v0.7.2
***********************************************************************Wait for the Pod to be ready and the APIService to register:
kubectl wait --namespace kube-system \
--for=condition=ready pod \
-l app.kubernetes.io/name=metrics-server \
--timeout=120sExpected output:
pod/metrics-server-7f4c8b9d6-pp1xz condition metVerify the metrics API is registered and available — without this, the HPA controller cannot read metrics:
kubectl get apiservice v1beta1.metrics.k8s.ioExpected output:
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True 45sThe AVAILABLE: True state means the API server is successfully proxying requests to the metrics-server Pod. If this shows False, the HPA will report <unknown> for every metric and never scale (see Common Errors #1).
Sanity-check by querying live metrics:
# kubectl top reads from the same metrics.k8s.io API the HPA uses
kubectl top pods -n defaultExpected output:
NAME CPU(cores) MEMORY(bytes)
webapp-webapp-6c9d8f7b5-x7k2p 1m 4Mi
webapp-webapp-6c9d8f7b5-q4m9r 1m 4Mi
webapp-webapp-6c9d8f7b5-z8j3n 1m 4MiThree idle nginx pods consuming 1 millicore each. This is the same data feed the HPA controller will use.
Part 2 — Add HPA to the Helm chart
Three coordinated changes go into the chart, all in the gitops-webapp repo from Day 10–11:
- Add
autoscaling.*defaults towebapp/values.yaml - Patch
webapp/templates/deployment.yamlto conditionally omitreplicas:when autoscaling is enabled - Add a new template
webapp/templates/hpa.yaml - Enable autoscaling in
webapp/values-dev.yaml
Each step below is delivered as a complete copy-paste file — no find-and-replace, no append-by-hand. Paste the whole block and the file is written correctly.
Clone (or cd into) the repo:
cd ~/30-days-devops/day-12
gh repo clone YOUR_GITHUB_USERNAME/gitops-webapp
cd gitops-webapp2.1 — Add autoscaling defaults to values.yaml
Rewrite webapp/values.yaml with the full file below — your Day 6 chart defaults plus a new
autoscaling block at the bottom:
cat > webapp/values.yaml << 'EOF'
# Default values for webapp chart.
# Override these from the CLI (--set) or from a values file (-f).
replicaCount: 3
image:
repository: nginx
tag: "1.25-alpine"
pullPolicy: IfNotPresent
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
service:
type: NodePort
port: 80
targetPort: 80
nodePort: 30080
probes:
readiness:
initialDelaySeconds: 5
periodSeconds: 5
liveness:
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi
# Horizontal Pod Autoscaler defaults.
# When enabled, the HPA owns the Deployment's replica count and the
# static replicaCount above is ignored (the template drops the replicas
# field entirely when this is true — see deployment.yaml).
autoscaling:
enabled: false # off by default; turn on per-env
minReplicas: 2
maxReplicas: 6
targetCPUUtilizationPercentage: 60 # average across all pods
EOFThe defaults keep autoscaling off so that any existing release using this chart at values.yaml defaults does not silently start auto-scaling. Each environment values file opts in explicitly.
2.2 — Conditionally omit replicas in deployment.yaml
Rewrite webapp/templates/deployment.yaml with the full file below. It's your Day 11 template
(with the envFrom Secret injection) — the only change is that the static replicas: line
is now wrapped in {{- if not .Values.autoscaling.enabled }}, so the field
disappears entirely once the HPA takes over:
cat > webapp/templates/deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "webapp.fullname" . }}
labels:
{{- include "webapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: {{ .Values.rollingUpdate.maxSurge }}
maxUnavailable: {{ .Values.rollingUpdate.maxUnavailable }}
selector:
matchLabels:
{{- include "webapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "webapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
envFrom:
- secretRef:
name: webapp-secret
EOFThis is the single most important detail in the whole article. If the Deployment manifest contains replicas: 3 while the HPA also patches spec.replicas, the two controllers fight every reconcile cycle. Argo CD/Helm sees the live count drift away from 3 and sets it back. The HPA sees the live count is wrong and patches it again. The result is replica thrash and constant OutOfSync flapping in Argo CD.
By omitting the field entirely from the manifest when autoscaling is enabled, Helm/Argo CD never reconciles spec.replicas at all. The HPA becomes the sole owner of that field, which is exactly what the Kubernetes API server's field-manager system is designed to support.
2.3 — Create the HPA template
Create webapp/templates/hpa.yaml:
cat > webapp/templates/hpa.yaml << 'EOF'
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "webapp.fullname" . }}
labels:
{{- include "webapp.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "webapp.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
EOFThree things to note:
apiVersion: autoscaling/v2is the stable HPA API. The olderautoscaling/v1only supports CPU; v2 supports multiple metrics, custom metrics, and external metrics. Always use v2.type: Utilizationscales based on the percentage ofresources.requests.cpuactually used.targetCPUUtilizationPercentage: 60means the HPA tries to keep average per-pod CPU at 60 % of the requested 25 m — about 15 m sustained per pod under steady load. Withoutresources.requests.cpuset on the container, the HPA cannot compute utilisation and will report<unknown>.scaleTargetRefpoints at the Deployment by name; this is why the template uses the sameinclude "webapp.fullname"helper as the Deployment manifest. Mismatched names would leave the HPA targeting a non-existent Deployment.
2.4 — Enable autoscaling in the dev values file
Rewrite webapp/values-dev.yaml with the full file below — your Day 10 dev values plus the
autoscaling on-switch at the bottom:
cat > webapp/values-dev.yaml << 'EOF'
# Dev environment overrides — merged over webapp/values.yaml at sync time.
# Only include keys that differ from the chart defaults.
replicaCount: 3
image:
tag: "1.27-alpine"
# ClusterIP so traffic enters through the NGINX Ingress, not a NodePort.
# This matches the values-ingress.yaml pattern established in Day 7.
service:
type: ClusterIP
port: 80
targetPort: 80
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 50m
memory: 64Mi
# Day 12: turn HPA on for the dev environment.
# minReplicas/maxReplicas/target inherit from values.yaml.
autoscaling:
enabled: true
EOFYou inherit the chart defaults (minReplicas: 2, maxReplicas: 6, target: 60 %) — only the on-switch is overridden in dev. Production would typically set a higher maxReplicas.
Part 3 — Commit and sync via Argo CD
Stage all four changes and push:
cd ~/30-days-devops/day-12/gitops-webapp
git add webapp/values.yaml webapp/values-dev.yaml \
webapp/templates/deployment.yaml webapp/templates/hpa.yaml
git commit -m "feat(autoscaling): add HPA on CPU, drop static replicas when enabled"
git push origin mainExpected output:
[main 9f1b4d3] feat(autoscaling): add HPA on CPU, drop static replicas when enabled
4 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 webapp/templates/hpa.yamlTrigger the sync:
argocd app sync webapp --server argocd.local --insecureExpected output (abbreviated):
TIMESTAMP GROUP KIND NAMESPACE NAME STATUS HEALTH MESSAGE
2026-05-23T09:15:01+05:30 autoscaling HorizontalPodAutoscaler default webapp-webapp OutOfSync Missing
2026-05-23T09:15:02+05:30 Deployment default webapp-webapp OutOfSync Healthy
2026-05-23T09:15:03+05:30 autoscaling HorizontalPodAutoscaler default webapp-webapp Synced Healthy horizontalpodautoscaler.autoscaling/webapp-webapp created
2026-05-23T09:15:04+05:30 Deployment default webapp-webapp Synced Healthy deployment.apps/webapp-webapp configured
SyncStatus: Synced
HealthStatus: HealthyConfirm the HPA is live:
kubectl get hpa -n defaultExpected output:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
webapp-webapp Deployment/webapp-webapp cpu: 4%/60% 2 6 2 30sThree things to read here:
TARGETS: cpu: 4%/60%— current average CPU usage is 4 % of requested; target is 60 %. The numerator must be populated for the HPA to make decisions; if it shows<unknown>/60%, metrics-server is not feeding the HPA (Common Errors #1).REPLICAS: 2— the HPA has already scaled the Deployment from 3 down to 2 (itsminReplicas). This is expected — at 4 % current usage, the desired replica count is well belowminReplicas, so the HPA floors at the minimum.MINPODS / MAXPODS— the safety rails. Inside this range the HPA owns the count; outside it, the HPA refuses to scale further.
Part 4 — Generate load with k6
k6 is a load-generation tool from Grafana Labs. It runs JavaScript test scripts that drive HTTP requests at configurable concurrency, then reports latency, RPS, and error metrics. For this article you will run k6 as a Kubernetes Job — inside the cluster — so the load source is deterministic and not bottlenecked by your laptop's network.
Create a ConfigMap containing the k6 script, then a Job that mounts it:
cd ~/30-days-devops/day-12
cat > k6-load.yaml << 'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: k6-script
namespace: default
data:
load.js: |
import http from 'k6/http';
import { sleep } from 'k6';
// 50 virtual users (concurrent simulated clients), each looping
// GET / against the webapp Service. Runs for 5 minutes total.
export const options = {
vus: 50,
duration: '5m',
};
export default function () {
// Route through the NGINX Ingress Controller in-cluster, with the
// Host header overridden so the controller matches the webapp Ingress
// rule (webapp.local). This exercises the full ingress → service →
// pod path, which is what nginx_ingress_controller_requests counts —
// important for the custom-metric demo in Part 7.
http.get('http://ingress-nginx-controller.ingress-nginx.svc.cluster.local/', {
headers: { Host: 'webapp.local' },
});
sleep(0.1);
}
---
apiVersion: batch/v1
kind: Job
metadata:
name: k6-load
namespace: default
spec:
ttlSecondsAfterFinished: 60 # auto-delete the Pod 60 s after completion
backoffLimit: 0
template:
spec:
restartPolicy: Never
# Hardened so this load-test Pod still admits once the "restricted"
# Pod Security profile is enforced on the namespace in Day 14. k6 runs
# fine as a non-root user and writes nothing to the root filesystem.
securityContext:
runAsNonRoot: true
runAsUser: 12345
seccompProfile:
type: RuntimeDefault
containers:
- name: k6
image: grafana/k6:0.51.0
args: ["run", "/scripts/load.js"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: script
mountPath: /scripts
volumes:
- name: script
configMap:
name: k6-script
EOF
kubectl apply -f k6-load.yamlExpected output:
configmap/k6-script created
job.batch/k6-load createdThe k6 Pod starts immediately and begins driving traffic. The script hits the NGINX Ingress Controller's in-cluster Service (ingress-nginx-controller.ingress-nginx.svc.cluster.local) with the Host: webapp.local header — so requests follow the full ingress → service → pod path, exactly as production traffic would. This is required for Part 7's custom-metric demo to see anything. Each of the 50 virtual users issues a request every 100 ms, so steady-state load is ~500 RPS.
Check the Job is running:
kubectl get pods -n default -l job-name=k6-loadExpected output:
NAME READY STATUS RESTARTS AGE
k6-load-xk2vp 1/1 Running 0 10sPart 5 — Watch HPA scale up
In one terminal, watch the HPA in real time:
kubectl get hpa -n default --watchExpected output (each line is a state change, shown over ~2 minutes):
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
webapp-webapp Deployment/webapp-webapp cpu: 4%/60% 2 6 2 2m
webapp-webapp Deployment/webapp-webapp cpu: 152%/60% 2 6 2 2m30s
webapp-webapp Deployment/webapp-webapp cpu: 152%/60% 2 6 6 2m45s
webapp-webapp Deployment/webapp-webapp cpu: 110%/60% 2 6 6 3m15s
webapp-webapp Deployment/webapp-webapp cpu: 62%/60% 2 6 6 4mThe sequence to read:
- t=0 — idle baseline, 4 % CPU, 2 replicas.
- t=30 s — load has hit the two pods and metrics-server has scraped the new numbers; average CPU is 152 % of the 60 % target (each pod is doing ~38 m against a 25 m request). The HPA controller has observed the spike but the snapshot here is the moment just before it patches.
- t=45 s — the controller has computed desired =
ceil(2 × 152/60)= 6 and patchedspec.replicasfrom 2 → 6 in a single step. The defaultbehavior.scaleUppolicy withselectPolicy: Maxallows the larger of+4 Podsor+100 %per 15 s window — from 2 pods, both rules permit reaching 6 in one tick. - t=75 s — the four new Pods have come up and are absorbing requests; average CPU has dropped to 110 % of target. Still over, but the HPA cannot scale further because it is already at
maxReplicas: 6. - t=2 m — load is fully distributed across all 6 pods; average CPU settles near the 60 % target and the HPA stops moving.
Open a second terminal and confirm new Pods exist:
kubectl get pods -n default -l app.kubernetes.io/instance=webappExpected output:
NAME READY STATUS RESTARTS AGE
webapp-webapp-6c9d8f7b5-x7k2p 1/1 Running 0 5m
webapp-webapp-6c9d8f7b5-q4m9r 1/1 Running 0 5m
webapp-webapp-6c9d8f7b5-aa1tt 1/1 Running 0 75s
webapp-webapp-6c9d8f7b5-bb2uu 1/1 Running 0 75s
webapp-webapp-6c9d8f7b5-cc3vv 1/1 Running 0 75s
webapp-webapp-6c9d8f7b5-dd4ww 1/1 Running 0 75sTwo original pods (5 min old) plus four added by the HPA in a single scale event. Cross-check CPU per pod via kubectl top:
kubectl top pods -n default -l app.kubernetes.io/instance=webappExpected output:
NAME CPU(cores) MEMORY(bytes)
webapp-webapp-6c9d8f7b5-x7k2p 15m 5Mi
webapp-webapp-6c9d8f7b5-q4m9r 14m 5Mi
webapp-webapp-6c9d8f7b5-aa1tt 16m 5Mi
webapp-webapp-6c9d8f7b5-bb2uu 15m 5Mi
webapp-webapp-6c9d8f7b5-cc3vv 15m 5Mi
webapp-webapp-6c9d8f7b5-dd4ww 14m 5MiEach pod is using ~15 millicores, which is 60 % of the 25 m request — the HPA has settled exactly on its target.
Part 6 — Stop load and watch scale-down
The k6 Job ends after its 5-minute duration. Either wait for it, or delete it now to fast-forward:
kubectl delete job k6-load -n defaultExpected output:
job.batch "k6-load" deletedContinue watching the HPA. You will notice scale-down is much slower than scale-up — this is intentional. Kubernetes' default scale-down behaviour holds the replica count steady for 5 minutes after the last scale-up event before lowering it. The full timeline is roughly:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
webapp-webapp Deployment/webapp-webapp cpu: 12%/60% 2 6 6 8m
# ... 5 minutes of nothing — the stabilisation window protects against flapping ...
webapp-webapp Deployment/webapp-webapp cpu: 4%/60% 2 6 3 13m
webapp-webapp Deployment/webapp-webapp cpu: 4%/60% 2 6 2 14mWhy scale-down is conservative:
- A scale-up that turns out to have been premature wastes 30 s of compute. A scale-down that turns out to have been premature kills serving pods just as a second wave of traffic arrives — costing latency and dropped requests.
- The default
behavior.scaleDown.stabilizationWindowSeconds: 300smooths out short load valleys. Brief dips below target do not immediately trigger termination. - The default policy then allows a maximum of 100 % of pods removed per 15 s, but only after the window expires.
You can tune this in the HPA spec.behavior block. For example, to react more aggressively in a non-prod environment:
spec:
behavior:
scaleDown:
stabilizationWindowSeconds: 60 # default 300
policies:
- type: Percent
value: 50 # at most 50 % removed per period
periodSeconds: 15
scaleUp:
stabilizationWindowSeconds: 0 # respond immediately to load
policies:
- type: Percent
value: 100
periodSeconds: 15For this article we keep defaults — they reflect what almost every production HPA actually runs.
Part 7 — Custom metric: scale on requests-per-second
CPU is a proxy. The real thing a webapp cares about is request rate. Two pods at 30 % CPU getting 1 000 RPS each are still close to saturation if the bottleneck is per-pod connection limits, not CPU. To scale on RPS directly, you wire the Prometheus Adapter into the HPA pipeline.
%%{init: {'theme': 'dark'}}%%
flowchart LR
INGRESS["NGINX Ingress\ncontroller\n(emits nginx_ingress_\ncontroller_requests)"]:::ingress
subgraph MON ["namespace monitoring"]
PROM["Prometheus\n(kube-prometheus-stack\nfrom Day 8)"]:::prom
ADAPTER["Prometheus Adapter\n(translates PromQL\n→ k8s metrics API)"]:::adapter
end
HPA["HPA controller\nreads custom.metrics.k8s.io"]:::hpa
DEP["Deployment\nwebapp-webapp"]:::dep
INGRESS -->|"1 · /metrics scrape"| PROM
PROM -->|"2 · PromQL query\n(rate of requests)"| ADAPTER
ADAPTER -->|"3 · custom.metrics.k8s.io\nGET requests_per_second"| HPA
HPA -->|"4 · patch spec.replicas\nbased on RPS target"| DEP
classDef ingress fill:#1c2128,stroke:#30363d,color:#8b949e
classDef prom fill:#2d1a1a,stroke:#f85149,color:#e6edf3
classDef adapter fill:#1a2744,stroke:#58a6ff,color:#e6edf3
classDef hpa fill:#2a1f10,stroke:#d29922,color:#e6edf3
classDef dep fill:#2d1a2d,stroke:#bc8cff,color:#e6edf3Reading this diagram:
Four numbered arrows trace one metric — the rate of HTTP requests through the ingress — from the moment it is emitted to the moment it changes the replica count. All four arrows point in the direction the data flows, not in the direction the request originates; that matters for arrows 2 and 3, where the puller (adapter, HPA) sits downstream of the data source.
The monitoring namespace box groups the two infra pieces that live there together (Prometheus from Day 8; Prometheus Adapter from this part). The ingress controller (grey, peripheral) and the HPA controller (amber) sit outside the box — they live in their own namespaces and were already deployed in earlier days.
Arrow 1. The NGINX Ingress controller (grey) increments the counter nginx_ingress_controller_requests on every routed request. The counter carries labels namespace, service, host, method, and status, which is what lets us filter down to just webapp traffic later. Prometheus scrapes this counter via the controller's /metrics endpoint every 30 s — that scrape is the arrow.
Arrow 2. Prometheus (red — the "metrics warehouse" colour, signalling the durable store) holds the time series. Every 30 seconds the Prometheus Adapter (blue) issues a PromQL query against Prometheus and receives a numeric result. The arrow is drawn from Prometheus to the Adapter because the result flows that way — even though the request originates from the Adapter. (The same convention applies to arrow 3.) The PromQL expression is the metricsQuery in your adapter values file, evaluated with the adapter's label-overrides substituted in.
Arrow 3. Every 15 seconds the HPA controller (amber) asks the API server for custom.metrics.k8s.io/v1beta1/namespaces/default/services/webapp-webapp/requests_per_second. The API server routes that request to the Adapter through an APIService registration — exactly the same mechanism metrics-server uses for CPU. The Adapter responds with whatever it last received from Prometheus, packaged as a MetricValueList. The arrow shows the response value flowing from Adapter to HPA.
Arrow 4. The HPA treats this custom value identically to CPU: same desired = ceil(current × actual / target) formula, same patch against spec.replicas on the Deployment (purple). The only difference compared to Diagram 1 is which API group the value came from.
The architectural insight: the APIService registration mechanism lets the Adapter make any PromQL expression look like a first-class Kubernetes metric. p99 latency, queue depth, websocket connections, business-domain counters — once Prometheus can compute it, the HPA can scale on it.
7.1 — Enable ingress-nginx Prometheus metrics
The NGINX Ingress Controller installed on Day 7 was deployed without its metrics endpoint enabled (the chart defaults to controller.metrics.enabled: false to keep the install lean). The nginx_ingress_controller_requests counter is therefore not currently being scraped by the Day 8 Prometheus. Turn it on with a helm upgrade --reuse-values so the rest of the Day 7 configuration stays intact:
# --reuse-values keeps the Day 7 values file untouched; we only flip the
# two new flags. The ServiceMonitor is created in the ingress-nginx
# namespace; Day 8 Prometheus has serviceMonitorSelectorNilUsesHelmValues=false
# so it picks up every ServiceMonitor cluster-wide automatically.
helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --reuse-values \
--set controller.metrics.enabled=true \
--set controller.metrics.serviceMonitor.enabled=trueExpected output (abbreviated):
Release "ingress-nginx" has been upgraded. Happy Helming!
NAME: ingress-nginx
NAMESPACE: ingress-nginx
STATUS: deployed
REVISION: 2Wait ~30 seconds for the ingress controller to roll out and for Prometheus to discover the new ServiceMonitor, then confirm the metric exists. Port-forward to Prometheus and query:
kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090:9090 &
sleep 2
curl -s 'http://localhost:9090/api/v1/query?query=nginx_ingress_controller_requests' | jq '.data.result | length'Expected output:
3A non-zero count means Prometheus is now collecting the metric (the exact number depends on how many ingress rules exist). If you see 0, the ServiceMonitor has not been picked up yet — wait another 30 s and retry.
Kill the port-forward when done:
kill %1 # or kill the backgrounded job in your shell7.2 — Install Prometheus Adapter
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
cd ~/30-days-devops/day-12
cat > values-adapter.yaml << 'EOF'
# Point the adapter at the in-cluster Prometheus from kube-prometheus-stack.
# The service name follows the chart's naming pattern from Day 8.
prometheus:
url: http://kps-kube-prometheus-stack-prometheus.monitoring.svc
port: 9090
# Custom rules tell the adapter which PromQL queries to expose as
# Kubernetes metrics. The default rules cover CPU/memory only; we add
# one rule for ingress-derived requests-per-second.
rules:
default: false
custom:
- seriesQuery: 'nginx_ingress_controller_requests{namespace!="",service!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
service: {resource: "service"}
name:
matches: "^nginx_ingress_controller_requests$"
as: "requests_per_second"
metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)'
EOF
helm install prometheus-adapter prometheus-community/prometheus-adapter \
--namespace monitoring \
--version 4.11.0 \
-f values-adapter.yamlExpected output:
NAME: prometheus-adapter
LAST DEPLOYED: Sat May 23 09:30:00 2026
NAMESPACE: monitoring
STATUS: deployed
REVISION: 1Wait for the adapter Pod and confirm the new APIService is registered:
kubectl wait --namespace monitoring \
--for=condition=ready pod \
-l app.kubernetes.io/name=prometheus-adapter \
--timeout=120s
kubectl get apiservice v1beta1.custom.metrics.k8s.ioExpected output:
pod/prometheus-adapter-6f8d5c7b9-tt9xy condition met
NAME SERVICE AVAILABLE AGE
v1beta1.custom.metrics.k8s.io monitoring/prometheus-adapter True 45sVerify the custom metric is being served (the adapter is querying Prometheus and converting the result):
# This is the raw API call the HPA will make. Reasonable load must already
# be flowing through the ingress for the value to be non-zero.
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/services/webapp-webapp/requests_per_second" | jq .Expected output:
{
"kind": "MetricValueList",
"apiVersion": "custom.metrics.k8s.io/v1beta1",
"metadata": {},
"items": [
{
"describedObject": {
"kind": "Service",
"namespace": "default",
"name": "webapp-webapp",
"apiVersion": "v1"
},
"metricName": "requests_per_second",
"timestamp": "2026-05-23T09:32:18Z",
"value": "0",
"selector": null
}
]
}Value is 0 because there is no load right now. Send a quick burst to confirm the value moves:
# Apply the k6 Job again, wait 30 s for Prometheus to scrape, query again
kubectl apply -f k6-load.yaml
sleep 60
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/services/webapp-webapp/requests_per_second" | jq '.items[0].value'Expected output:
"487"~487 RPS — matches the 50 VUs × ~10 req/s/VU expected rate from the k6 script.
7.3 — Add a custom-metric scaling rule to the HPA
In real GitOps flow you would commit this to the chart. For demonstration purposes we patch the live HPA directly to keep this section tight:
kubectl patch hpa webapp-webapp -n default --type=merge -p '
{
"spec": {
"metrics": [
{
"type": "Resource",
"resource": {
"name": "cpu",
"target": {"type": "Utilization", "averageUtilization": 60}
}
},
{
"type": "Object",
"object": {
"describedObject": {
"apiVersion": "v1",
"kind": "Service",
"name": "webapp-webapp"
},
"metric": {"name": "requests_per_second"},
"target": {"type": "Value", "value": "200"}
}
}
]
}
}'Expected output:
horizontalpodautoscaler.autoscaling/webapp-webapp patchedThe HPA now scales on two metrics simultaneously: CPU and RPS. Kubernetes runs each independently, gets a desired replica count from each, and picks the maximum — whichever metric is most stressed drives the scaling. Target is 200 RPS per Service (note: this is total RPS across all pods, not per-pod, because the metric is attached to the Service object). At 487 RPS observed against a 200 target, the HPA will quickly scale to maxReplicas: 6:
kubectl get hpa -n defaultExpected output:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
webapp-webapp Deployment/webapp-webapp 487/200, 88%/60% 2 6 6 12mTwo TARGETS columns now — both contributing to the scaling decision. To revert to CPU-only and clean up:
kubectl delete job k6-load -n default
# After validation, re-apply the chart's HPA template from Git to restore single-metric scaling:
argocd app sync webapp --server argocd.local --insecureCommon Errors
1. HPA shows <unknown>/60% for CPU target
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
webapp-webapp Deployment/webapp-webapp <unknown>/60% 2 6 2 1mThe HPA cannot read CPU metrics. Three possible causes, in order of frequency:
# (a) metrics-server is not installed or not Available
kubectl get apiservice v1beta1.metrics.k8s.io
# AVAILABLE must be True. If False, check metrics-server logs:
kubectl logs -n kube-system -l app.kubernetes.io/name=metrics-server
# (b) On kind, metrics-server is installed but missing --kubelet-insecure-tls
# Symptom in logs: "x509: cannot validate certificate ... because it doesn't contain any IP SANs"
helm upgrade metrics-server metrics-server/metrics-server \
--namespace kube-system \
--reuse-values \
--set 'args={--kubelet-insecure-tls}'
# (c) The Deployment container has no resources.requests.cpu set.
# HPA cannot compute Utilization without a request to compute against.
kubectl get deploy webapp-webapp -n default -o jsonpath='{.spec.template.spec.containers[0].resources.requests}'
# Must return {"cpu":"25m","memory":"32Mi"} or similar.2. Argo CD reports Deployment as OutOfSync after every HPA scale event
Symptom: the Argo CD UI flickers between Synced and OutOfSync. The diff shows spec.replicas: 3 (from Git) vs spec.replicas: 5 (from cluster, set by HPA).
Cause: the deployment.yaml template was not patched to omit replicas: when autoscaling is enabled. Helm/Argo CD keep reconciling the static count back, fighting the HPA.
Fix: verify the template conditional from Part 2.2 is present. After redeploying, also set ignoreDifferences on the Application as a belt-and-braces guard:
spec:
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas3. Error from server (NotFound): the server could not find the requested resource
kubectl top pods -n default
error: Metrics API not availableThe metrics.k8s.io APIService is registered but Available: False. Almost always a metrics-server problem on kind.
Fix:
# Look at why metrics-server is unhealthy
kubectl describe apiservice v1beta1.metrics.k8s.io | tail -10
kubectl logs -n kube-system deploy/metrics-server --tail=50
# Most common: kubelet TLS validation failure — apply --kubelet-insecure-tls4. HPA scales up but new Pods stay Pending with 0/3 nodes available
Events:
Warning FailedScheduling 10s default-scheduler 0/3 nodes are available: 3 Insufficient cpu.The HPA created new replicas, but the kind cluster has run out of CPU capacity to schedule them. Each pod requests 25 m; six pods need 150 m, plus the system overhead.
Fix: either lower resources.requests.cpu in values-dev.yaml, or lower maxReplicas, or give Docker Desktop more CPU. On a 4-CPU laptop you usually want maxReplicas: 4 for this chart.
5. Custom metric returns empty items: []
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/services/webapp-webapp/requests_per_second" | jq
# {"kind":"MetricValueList","apiVersion":"custom.metrics.k8s.io/v1beta1","metadata":{},"items":[]}The adapter is running but Prometheus has no matching series. Two checks:
# (a) Is the ingress controller actually being scraped? Open the Day 8 Prometheus UI
# (kubectl port-forward svc/kps-kube-prometheus-stack-prometheus 9090:9090 -n monitoring)
# and run: nginx_ingress_controller_requests
# If no series, the ingress-nginx ServiceMonitor is missing or pointing wrong.
# (b) Is there any traffic? Counters only exist after the first request.
# Send a few requests, wait 30 s, query again.6. HPA scales but kubectl describe hpa shows Unable to fetch metrics from custom metrics API
Conditions:
Type Status Reason Message
AbleToScale True SucceededRescale
ScalingActive False FailedGetObjectMetric unable to get metric requests_per_secondBoth metrics-server and Prometheus Adapter must report AVAILABLE: True. If the adapter is degraded, HPAs scaling on custom metrics stall while CPU-only HPAs continue working.
Fix:
kubectl get apiservice v1beta1.custom.metrics.k8s.io
kubectl logs -n monitoring deploy/prometheus-adapter --tail=30
# Common cause: wrong prometheus.url in values-adapter.yaml.
# Confirm the actual service:
kubectl get svc -n monitoring | grep prometheusRecap
In this article you:
- Installed metrics-server (chart 3.12.2 / app v0.7.2) into
kube-systemwith the kind-specific--kubelet-insecure-tlsflag, exposing themetrics.k8s.ioAPI - Added an
HorizontalPodAutoscalerresource to the webapp Helm chart, gated by.Values.autoscaling.enabled, scaling on CPU utilisation between 2 and 6 replicas - Patched the Deployment template to conditionally omit
spec.replicaswhen autoscaling is enabled — preventing Helm/Argo CD from fighting the HPA - Pushed the changes through the GitOps loop from Day 10 and watched Argo CD apply the new HPA resource
- Drove constant load using a k6 Kubernetes Job that routes through the in-cluster NGINX Ingress Controller (so the same load exercises both CPU and the ingress request-counter)
- Watched the HPA scale 2 → 6 replicas in a single step as CPU climbed past target (default
scaleUppolicy), then scale back down to 2 after the 5-minute stabilisation window - Installed Prometheus Adapter (chart 4.11.0), wired a PromQL rule that converts
nginx_ingress_controller_requestsinto the Kubernetes custom metricrequests_per_second, and scaled the HPA on RPS in addition to CPU
The cluster now auto-adjusts its capacity to actual load — both to a system-level signal (CPU) and to an application-level signal (RPS).
What's next
Day 13: RBAC — ServiceAccounts, Roles, and the Principle of Least Privilege →
On Day 13 you will replace the over-broad default ServiceAccount your webapp Pods have been running with since Day 5. You will create a webapp-runtime ServiceAccount with automountServiceAccountToken: false (the app does not talk to the API server, so it should never carry an API token), commit it to the gitops-webapp chart, and watch Argo CD wire it into the Deployment. Then you will build a webapp-readonly ServiceAccount with a namespace-scoped Role granting only get / list / watch on the resources an observer would need — and verify every permission with kubectl auth can-i. The result: the webapp ships with no API credentials, and any operational tooling that does need access gets a token bound to a single, auditable role.