Drill — Scale Under Load
One skill, start to finish: stand up a HorizontalPodAutoscaler, hit its target with real CPU load, and watch it react — first under Kubernetes' own defaults, then under numbers you picked yourself. You'll deploy a small CPU-hungry app, wire an HPA to it, generate load with a throwaway Pod, and time two things with a clock in your hand: how fast replicas climb when load appears, and how long it takes them to come back down once load disappears. Then you'll swap the default behavior block for a tuned one and run the identical test again, so the difference isn't something you read about — it's something you measured twice, yourself, thirty minutes apart. This drill is fully self-contained: no capstone cluster, no cloud account, nothing carried over from the six-part capstone. Budget 25–40 minutes, most of it spent watching a terminal and a clock.
Picture a food truck at lunch. The second the line gets long, the owner calls in extra cooks almost instantly — waiting even one extra minute means unhappy customers, so there's no hesitation on the way up. But when the line finally empties out, a smart owner doesn't send everyone home the second it's quiet. Maybe it's just a lull while a crosswalk light is red, and the rush comes right back in ninety seconds. So they wait — watching, cautious — before letting anyone go. That waiting period has a length, and today you're the one who gets to set it: too short, and cooks get sent home and called back all afternoon; too long, and you're paying idle cooks for far longer than the actual quiet lasted. Same instinct, your own number.
kubectl describe hpa.You need Docker, kind, and kubectl installed locally — nothing else, no cloud account, no registry. This drill deliberately waits on two real clocks (a default ~5-minute scale-down, then a tuned ~1-minute one), which is most of the time budget — the commands themselves take a few minutes total. Keep a second terminal open the whole time for kubectl get hpa --watch; you'll want to see the numbers move, not just check on them after the fact.
How this drill works
☺ Like you're 10: Break it into four short acts — set up, load it up, watch the default reaction, then change one setting and watch it react differently.
The Autoscaling deep dive already covers the HPA's formula, its metrics pipeline, and its behavior block in detail — read that first if any term below is unfamiliar. This drill doesn't re-derive any of it; it makes you run it, twice, against a target that actually burns CPU when asked, and forces you to sit through the exact timing the deep dive only describes in prose. You'll deploy php-apache — the same small, deliberately expensive-per-request image the upstream Kubernetes docs use for this exact walkthrough — wire a HorizontalPodAutoscaler to it, and drive load with a busybox Pod in a tight request loop. First under the HPA's out-of-the-box behavior defaults, then under a behavior block you write yourself. CKA's Workloads & Scheduling domain expects you to stand up a basic HPA with kubectl autoscale; this drill goes one step further and asks you to actually watch what it does, on the clock.
Set up the scratch cluster and metrics-server
☺ Like you're 10: Build one throwaway kitchen and hook up its scale before anyone tries to weigh anything.
A default single-node kind cluster is all this drill needs. Bring your own metrics-server the same way the metrics-server tool page and the autoscaling deep dive's own "Try it" both do — kind's kubelets don't ship certificates metrics-server trusts out of the box, so the insecure-TLS escape hatch is expected here, not a mistake:
kind create cluster --name scale-drill --image kindest/node:v1.31.0
kubectl create namespace scale-drill
kubectl config set-context --current --namespace=scale-drill
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl -n kube-system patch deployment metrics-server --type=json \
-p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl -n kube-system rollout status deployment/metrics-serverGive it a full scrape cycle before trusting it — metrics-server's first snapshot can take up to a minute to populate:
kubectl top nodes
# NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
# scale-drill-control-plane 187m 4% 612Mi 15%Done when: kubectl top nodes returns real numbers, not an error. If it still errors after a minute, re-check the patch landed with kubectl -n kube-system get deployment metrics-server -o jsonpath='{.spec.template.spec.containers[0].args}' — --kubelet-insecure-tls should be the last entry in the list.
Deploy the target and a baseline HPA
☺ Like you're 10: One small app that visibly works harder the more you ask of it, plus a rule that watches how hard it's working.
php-apache does one deliberately wasteful thing per request — a tight square-root loop — specifically so a handful of concurrent requests moves its CPU usage a lot, fast, without needing real production traffic to prove the point:
# target.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-apache
namespace: scale-drill
spec:
replicas: 1
selector: { matchLabels: { run: php-apache } }
template:
metadata: { labels: { run: php-apache } }
spec:
containers:
- name: php-apache
image: registry.k8s.io/hpa-example
ports: [{ containerPort: 80 }]
resources:
requests: { cpu: 200m }
limits: { cpu: 500m }
---
apiVersion: v1
kind: Service
metadata:
name: php-apache
namespace: scale-drill
labels: { run: php-apache }
spec:
selector: { run: php-apache }
ports: [{ port: 80 }]kubectl apply -f target.yaml
kubectl wait --for=condition=Available deployment/php-apache --timeout=60s
kubectl top pod -l run=php-apache # confirm metrics-server sees this Pod specifically
kubectl autoscale deployment php-apache --cpu-percent=50 --min=1 --max=10
kubectl get hpa php-apache
# NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
# php-apache Deployment/php-apache /50% 1 10 1 8s TARGETS reads <unknown>/50% for the first several seconds — not broken, just waiting on the HPA controller's own 15-second sync tick to run once against a Pod metrics-server has actually scraped. It resolves to a real percentage (something like 0%/50% at idle) within about 15–30 seconds. That 50% target is measured against the 200m CPU request above, not the 500m limit — no request, no denominator, no HPA, exactly as Scheduling & Resource Management covers for requests generally.
Push it under load and watch the default reaction
☺ Like you're 10: Open a second terminal, start the rush, and actually watch the numbers instead of checking back later.
Open a second terminal now and leave this running for the rest of the drill — you want to see the replica count change in real time, not reconstruct it afterward from memory:
# terminal 2 — leave this running
kubectl get hpa php-apache --watchBack in terminal 1, start the load generator — a plain busybox loop hitting the Service as fast as it can, the same load pattern the upstream HPA walkthrough uses:
# terminal 1 — note the wall-clock time you run this
date
kubectl run -i --tty load-generator --rm --image=busybox:1.28 --restart=Never \
-- /bin/sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done"Watch terminal 2. Within one or two 15-second sync ticks, TARGETS should read well over 50% — often several hundred percent, since one loop can generate more requests than a single replica can absorb — and REPLICAS should start climbing. Exactly how high it climbs depends on your machine's real CPU speed, so don't expect a specific number; expect it to climb fast and land somewhere between 4 and the max=10 ceiling.
Done when: REPLICAS has grown past 1, and you can name the wall-clock time between starting the load and the first replica-count increase you actually saw in terminal 2. It should be under a minute — the default scaleUp.stabilizationWindowSeconds is 0, so nothing is holding the reaction back on the way up.
Stop the load and time the default scale-down
☺ Like you're 10: Send the extra cooks home — but watch how long the manager makes them wait before actually letting them go.
Kill the load generator with Ctrl+C in terminal 1 — the --rm flag deletes the Pod for you the moment the shell exits. Note the wall-clock time again, then watch terminal 2 closely:
# press Ctrl+C in terminal 1, then immediately:
dateTARGETS should drop toward 0%/50% within the next sync tick or two — the metric itself updates fast. REPLICAS is a different story: it should stay put at whatever it climbed to, not falling at all, for what will feel like a genuinely long wait. That's the default scaleDown.stabilizationWindowSeconds: 300 at work — the HPA looks back across the whole rolling 5-minute window and keeps whichever replica recommendation was least aggressive anywhere inside it, so one clean drop in load doesn't immediately shrink anything. Let it run and actually time it:
# once REPLICAS finally drops back toward 1:
dateDone when: you have two timestamps — load stopped, and replicas returned to 1 — and the gap between them is somewhere around five minutes, not thirty seconds. That number, measured on your own clock instead of read off a page, is the entire point of this act.
Tune the behavior block and shorten the window
☺ Like you're 10: Same caution, a shorter timer — and a couple of rules for how eagerly it's allowed to add cooks in the first place.
Delete the imperative HPA and replace it with a declarative autoscaling/v2 object carrying your own behavior block. Sixty seconds is still a real, deliberate waiting period — not 0, which would just trade "too cautious" for "too flappy" — it's simply short enough to actually observe twice inside this drill's time budget:
kubectl delete hpa php-apache# hpa-tuned.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-apache
namespace: scale-drill
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: php-apache
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 50 }
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- { type: Percent, value: 100, periodSeconds: 15 }
- { type: Pods, value: 4, periodSeconds: 15 }
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 60
policies:
- { type: Percent, value: 50, periodSeconds: 60 }
selectPolicy: Minkubectl apply -f hpa-tuned.yaml
kubectl get hpa php-apacheTwo scale-up policies means the HPA computes a desired count from each one independently and, because selectPolicy: Max, keeps whichever adds more — a plain Percent policy struggles to grow a Pod count of 1 quickly (100% of 1 is still just 1 more), so the Pods: 4 policy is what actually lets this HPA jump straight from 1 toward 5 on the very first tick instead of climbing one replica at a time. scaleDown keeps selectPolicy: Min — the conservative choice, taking whichever policy removes fewer — the same asymmetry the autoscaling deep dive covers: aggressive going up, cautious coming down, just with the caution dial turned to a number you chose instead of the untouched default.
Don't carry scaleDown.stabilizationWindowSeconds: 0 into anything real. It turns off the exact safety property this whole drill is measuring — the HPA stops looking back at all and reacts to the single latest data point, which is precisely how a workload ends up flapping replicas up and down every sync tick on ordinary, noisy traffic. Sixty seconds here is a deliberately small number chosen so you can watch it inside one sitting; a real service should get a window sized to how bursty its actual traffic is, decided on purpose, not left at whatever's fastest to demo.
Reset, re-run the identical load, and compare
☺ Like you're 10: Same rush, same food truck — only the manager's patience changed, so time it again and see the difference for real.
Scale back to a clean baseline before repeating the exact same test — you want this run to start from the same place the first one did:
kubectl scale deployment php-apache --replicas=1
kubectl get hpa php-apache --watch # keep terminal 2 on this# terminal 1 — the identical command from before
date
kubectl run -i --tty load-generator --rm --image=busybox:1.28 --restart=Never \
-- /bin/sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done"Watch the scale-up shape this time, not just the endpoint — with the Pods: 4 policy in play, the first jump can be noticeably chunkier than the first run's climb was. Once replicas are well above 1 and holding, kill the load generator with Ctrl+C again, note the time, and watch terminal 2 for the drop:
# Ctrl+C in terminal 1, then:
date
# ...watch terminal 2 until REPLICAS returns to 1, then:
dateDone when: your second scale-down timestamp gap lands somewhere around one to two minutes — close to the 60s window you set, plus a sync tick or two — instead of the roughly five minutes you measured on the first, untouched run. Same load, same target, same machine; the only thing that changed between the two timings is the number you put in behavior.scaleDown.stabilizationWindowSeconds.
Rerun the whole test with a bursty load instead of a steady one: start and stop the same load-generator command every 10–15 seconds for a couple of minutes, so CPU usage genuinely spikes and dips rather than holding steady. First against scaleUp.stabilizationWindowSeconds: 0 as above, then against a version with it raised to 30, and count how many times REPLICAS actually changes value in kubectl get hpa --watch under each. A stabilization window smooths noise on either direction of scaling, not just scale-down — this is the version of that idea applied to scale-up, which the main walkthrough above deliberately left at its most reactive setting.
Clean up
☺ Like you're 10: Nothing here needs to outlive the drill — delete the whole kitchen.
kind delete cluster --name scale-drillkubectl top nodes returns real CPU/memory numbers.kubectl top pod -l run=php-apache reports real usage for the Pod.kubectl get hpa php-apache resolves past <unknown> to a real percentage.kubectl get hpa --watch live.kubectl get hpa php-apache -o yaml shows scaleDown.stabilizationWindowSeconds: 60.kind delete cluster --name scale-drill completes with no errors.Nutty the Squirrel: Logged both timestamps for the default run — load stopped at 09:14:02, replicas dropped at 09:19:07. Five minutes, five seconds.
Sol the Sloth: Good — now do the tuned run the exact same careful way. Don't eyeball "felt faster." Write down both clocks again.
Gizmo the Gremlin: Or just set every stabilization window to zero and skip the whole waiting-around part. Instant reactions, every time. 😈
Timmy the Turtle: Try that on a service with genuinely noisy traffic and you'll spend all day adding and removing the same three Pods. Zero isn't "fast," it's "no memory at all."
Sol the Sloth: Sixty seconds wasn't picked because it's small. It was picked because I could actually sit here and watch it happen twice today.
Nutty the Squirrel: Tuned run: load stopped at 09:31:40, replicas dropped at 09:32:51. Seventy-one seconds against five minutes five — same load, same app, same laptop.
Sol the Sloth: That's the whole drill in two timestamps. Everything else was just getting to the point where you could measure it honestly.
1. Why does the HPA react to a load spike in under a minute by default, but takes roughly five minutes to react once that same load disappears? 2. Does a stabilization window delay when the HPA makes a decision, or change which decision it makes once the window has passed — and how do those differ? 3. In the tuned behavior block, why does the Pods: 4 policy matter more than the Percent: 100 policy for growing off a starting count of exactly 1 replica? 4. Why did php-apache's Deployment need resources.requests.cpu set at all for any of this to work? 5. What's the actual risk of shipping scaleDown.stabilizationWindowSeconds: 0 to a real production HPA instead of a drill? 6. Between your two timed scale-downs today, what one field changed, and by roughly what factor did the wait time shrink?
Check your answers
- The default
scaleUp.stabilizationWindowSecondsis0, so nothing holds back a scale-up decision — the HPA acts on the very next sync tick. The defaultscaleDown.stabilizationWindowSecondsis300, a deliberate five-minute rolling look-back designed to avoid shrinking capacity the moment a metric dips, in case load comes right back. - It changes which decision gets used, not when the check happens — the HPA still evaluates every 15 seconds throughout the window. Once the window has elapsed, it picks the least aggressive scale-down recommendation seen anywhere across that whole rolling window, not simply the very latest reading, so one clean drop can't immediately shrink the workload.
- A
Percentpolicy computes a percentage of the current replica count — 100% of 1 replica is still only 1 more, so a pure-percent policy climbs one replica at a time off a base of 1. ThePods: 4policy adds a flat count instead, and withselectPolicy: Maxthe HPA keeps whichever policy computed the larger jump — which is thePodspolicy specifically while the replica count is still small. - HPA's CPU-utilization percentage is calculated against the container's CPU request as the denominator. With no request set, there is nothing to divide the observed usage by, so a Resource-type CPU metric can never resolve —
kubectl get hpawould sit at<unknown>indefinitely. - It removes the rolling look-back entirely, so the HPA reacts to the single latest data point on every 15-second tick instead of the calmest one seen recently. On real, naturally noisy traffic that produces flapping — replicas added and removed repeatedly within minutes — which is exactly the failure mode a stabilization window exists to prevent.
- Only
behavior.scaleDown.stabilizationWindowSecondschanged, from the default300down to60. The measured wait time shrank by roughly the same ratio — from about five minutes down to a little over a minute, since the wait is bounded by the window itself plus a sync tick or two on either side.
Both timed runs done? That's the whole drill. For the formula, the metrics pipeline, and how VPA and Cluster Autoscaler fit around the HPA, see the Autoscaling deep dive; for the CKA-paced version of the same imperative command, see the blueprint's Workloads & Scheduling domain. For the mechanics of requests, limits, and QoS classes this whole drill's math rested on, see Scheduling & Resource Management, and for metrics-server's own architecture and failure modes, see the metrics-server tool page. Ready for a different single skill? Try Drill — Harden an RBAC Configuration or Drill — Troubleshoot a Failed Upgrade, or step back to the six-part capstone for the full continuity version.