Hands-On Labs · Guided Drills

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.

☺ Explain it like I'm 10

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.

🐿️🦥Your hosts for this drill: Nutty the Squirrel & Sol the Sloth — Nutty won't let a single HPA event or replica count slip past unlogged, and Sol refuses to guess at a stabilization window when the actual arithmetic is sitting right there in kubectl describe hpa.
⚠ Before you start

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.

Two loops running at once load-generator Pod while sleep 0.01; wget php-apache Service: php-apache ClusterIP, port 80 Deployment: php-apache replicas: 1 → N cpu usage ↓ replicas ↑ kubelet /stats/summary cAdvisor, per node metrics-server metrics.k8s.io HPA controller 15s sync, applies the formula patches .spec.replicas

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-server

Give 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
◆ Key idea

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 --watch

Back 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.

✓ Checkpoint

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:
date

TARGETS 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:
date

Done 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.

Same load drop, two different windows load stops Default — scaleDown window: 300s ~300s later: drops to min Tuned — scaleDown window: 60s ~60s later: drops to min

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: Min
kubectl apply -f hpa-tuned.yaml
kubectl get hpa php-apache

Two 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.

⚠ Watch out

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:
date

Done 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.

✎ Try it — going further

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-drill
0 / 9 steps complete
1Stand up the kind cluster and install metrics-server
Done when: kubectl top nodes returns real CPU/memory numbers.
2Deploy php-apache and its Service, with a CPU request set
Done when: kubectl top pod -l run=php-apache reports real usage for the Pod.
3Create the baseline imperative HPA
Done when: kubectl get hpa php-apache resolves past <unknown> to a real percentage.
4Run the load generator and watch replicas climb past 1
Done when: you can name how long the climb took, watching kubectl get hpa --watch live.
5Stop the load and time the default scale-down
Done when: you have two timestamps and the gap is roughly five minutes.
6Replace the HPA with a tuned autoscaling/v2 behavior block
Done when: kubectl get hpa php-apache -o yaml shows scaleDown.stabilizationWindowSeconds: 60.
7Reset to 1 replica and re-run the identical load test
Done when: replicas climb again, and you noted whether the shape of the climb differed.
8Stop the load and time the tuned scale-down
Done when: the second timed gap lands around one to two minutes, clearly shorter than the first.
9Delete the cluster
Done when: kind delete cluster --name scale-drill completes with no errors.
🎬 At the Pod Squad
🐿️

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.

🐢 Timmy's checkpoint

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
  1. The default scaleUp.stabilizationWindowSeconds is 0, so nothing holds back a scale-up decision — the HPA acts on the very next sync tick. The default scaleDown.stabilizationWindowSeconds is 300, a deliberate five-minute rolling look-back designed to avoid shrinking capacity the moment a metric dips, in case load comes right back.
  2. 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.
  3. A Percent policy 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. The Pods: 4 policy adds a flat count instead, and with selectPolicy: Max the HPA keeps whichever policy computed the larger jump — which is the Pods policy specifically while the replica count is still small.
  4. 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 hpa would sit at <unknown> indefinitely.
  5. 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.
  6. Only behavior.scaleDown.stabilizationWindowSeconds changed, from the default 300 down to 60. 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.