5. Kubernetes Autoscaling
At 10:00 the BoxOffice catalog goes from a quiet morning to ten times its normal traffic in under a minute. Nobody can add servers that fast by hand — a Horizontal Pod Autoscaler can. But "automatic" is not "instant": metrics are sampled, decisions are periodic, new pods must start, warm up and pass their readiness probe. In this exercise you deploy a service on Kubernetes, put it under a k6 ramp, and measure how fast and how well it scales — and what breaks while it does.
Learning objectives
After this exercise you will be able to:
- Configure a production-shaped Deployment: requests/limits, startup/readiness/liveness probes, graceful shutdown, Service, Ingress.
- Configure an HPA (
autoscaling/v2) with a CPU target and scaling behavior. - Predict the number of replicas with the HPA formula and measure the actual scaling timeline under load.
- Decompose the scaling lag (metrics window, sync period, pod start-up, JVM warm-up, readiness).
- Prove a zero-downtime rolling update under load, and find the next bottleneck (connections to the database).
Context
Class 11 covered containers, Kubernetes objects, probes and autoscaling; Class 5 the theory of autoscaling (target tracking, warm-up, predictive). Lab 10 deployed BoxOffice to kind or minikube with an Ingress. This exercise turns that deployment into an experiment.
The HPA controller computes, every sync period (15 s by default)1:
For CPU, the metric is the average utilization relative to the pod's CPU request. If 2 pods run at 150% of their request and the target is 60%, the HPA asks for ⌈2 × 150 / 60⌉ = 5 pods.
sequenceDiagram
participant K as k6 (load ramp)
participant P as catalog pods
participant M as metrics-server
participant H as HPA controller
participant D as Deployment
K->>P: requests per second grow
M->>P: scrape CPU (every ~15 s)
H->>M: read average utilization
H->>D: replicas = ceil(current × util / target)
D->>P: create pods
P-->>P: start JVM, startup probe, warm-up
P-->>H: readiness OK: pods receive traffic, utilization drops Task
- Deploy the BoxOffice
catalog(or your own service) and PostgreSQL on kind or minikube with the manifests below (adapted): requests/limits, three probes, graceful shutdown, Service, Ingress and an HPA (min 2, max 10, CPU target 60%). - E1 — Scale-up timeline: run a k6 ramp from a low rate to a rate that needs ~3× the minimum replicas; record replicas, CPU utilization, p99 and errors over time; then watch it scale down.
- E2 — One variable: repeat E1 changing one thing: no readiness probe, a different CPU request, or a different scale-up policy. Compare.
- E3 — Rolling update under load:
kubectl rollout restartduring steady load, with zero failed requests. - Compute the database connection budget at maximum scale and say what breaks first.
Requirements
- (mandatory) Deployment with CPU/memory requests and limits, startup, readiness and liveness probes and graceful shutdown; Service; Ingress; HPA
autoscaling/v2withbehavior. - (mandatory) A time series (CSV committed) of desired/current/ready replicas and CPU utilization, every ≤ 5 s, aligned with k6 latency and errors.
- (mandatory) A chart of replicas and p99 over time for E1, with the load ramp marked.
- HPA scales from 2 to ≥ 4 replicas during E1 and back down after the stabilization window.
- The predicted steady-state replicas (formula, with your measured CPU per request) vs the observed ones — within ±1 replica, or the difference explained.
- The scaling lag decomposed: load step → utilization above target → HPA decision → pod scheduled → pod ready.
- E2 compared with E1 (same load, one change), with a conclusion.
- E3: 0 failed requests during a rolling restart at a steady rate (k6 summary).
- Connection budget:
maxReplicas × pool sizevs PostgreSQLmax_connections, and the fix you would apply. - A video of at most 3 minutes showing E1 (k6 running,
kubectl get hpa -w, pods appearing). - AI usage section (see the policy).
Deliverables
In your repository (tag ex5): k8s/ manifests, k6 scripts, the recording script, CSVs and charts in k8s/results/, the video (or a link to it); a kubernetes-autoscaling page on your documentation site following the template.
Step by step
1. Cluster and metrics-server
The HPA needs resource metrics, served by metrics-server.
kind create cluster --config kind.yaml # with extraPortMappings for the Ingress, as in Lab 10
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
# kind's kubelets use self-signed certificates
kubectl patch -n kube-system deployment metrics-server --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kind load docker-image boxoffice/catalog:latest
Check with kubectl top pods -A (it takes about a minute after start-up). PostgreSQL comes from Lab 10 (Secret, PVC, Deployment and Service postgres).
2. The Deployment
apiVersion: v1
kind: ConfigMap
metadata:
name: catalog
data:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE: "10"
SERVER_SHUTDOWN: graceful
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: 20s
MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED: "true"
JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog
spec:
replicas: 2
selector:
matchLabels:
app: catalog
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: catalog
spec:
terminationGracePeriodSeconds: 30
containers:
- name: catalog
image: boxoffice/catalog:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: catalog
- secretRef:
name: catalog-db # SPRING_DATASOURCE_USERNAME / SPRING_DATASOURCE_PASSWORD
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 768Mi
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 2
failureThreshold: 60 # up to 120 s to start
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
sleep:
seconds: 5 # let the endpoints update before shutdown (Kubernetes >= 1.30)
---
apiVersion: v1
kind: Service
metadata:
name: catalog
spec:
selector:
app: catalog
ports:
- port: 8080
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: catalog
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: catalog
port:
number: 8080
Why each block matters2:
| Block | What goes wrong without it |
|---|---|
requests.cpu | The HPA cannot compute utilization (it is relative to the request); the scheduler overcommits nodes |
limits.memory + MaxRAMPercentage | The JVM sizes its heap for the node, not the container, and gets OOM-killed |
startupProbe | A slow JVM start is killed by the liveness probe before it is up (restart loop) |
readinessProbe | New pods receive traffic before they can serve it: errors exactly when you scale up |
preStop + graceful shutdown + maxUnavailable: 0 | In-flight requests fail on every deploy and every scale-down |
3. The HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: catalog
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: catalog
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100 # at most double the replicas...
periodSeconds: 30 # ...every 30 s
scaleDown:
stabilizationWindowSeconds: 120
policies:
- type: Pods
value: 1
periodSeconds: 30
4. Record the timeline
#!/usr/bin/env bash
# usage: ./k8s/record.sh k8s/results/e1-hpa.csv (stop with Ctrl+C)
OUT=${1:-k8s/results/hpa.csv}
mkdir -p "$(dirname "$OUT")"
echo "ts,desired,current,ready,cpu_util" > "$OUT"
while true; do
H=$(kubectl get hpa catalog -o jsonpath='{.status.desiredReplicas},{.status.currentReplicas},{.status.currentMetrics[0].resource.current.averageUtilization}')
R=$(kubectl get deployment catalog -o jsonpath='{.status.readyReplicas}')
IFS=, read -r DESIRED CURRENT CPU <<< "$H"
echo "$(date +%s),${DESIRED},${CURRENT},${R:-0},${CPU}" >> "$OUT"
sleep 5
done
5. The load
Measure the CPU cost of one request first (a short constant rate with 2 replicas: CPU used ÷ requests per second) so that you can predict the replicas before running E1.
import http from 'k6/http';
import { check } from 'k6';
const BASE = __ENV.BASE_URL || 'http://localhost';
const PEAK = Number(__ENV.PEAK || 600); // req/s at the top of the ramp
export const options = {
scenarios: {
ramp: {
executor: 'ramping-arrival-rate', startRate: Math.round(PEAK * 0.1), timeUnit: '1s',
preAllocatedVUs: 100, maxVUs: 1000,
stages: [
{ target: Math.round(PEAK * 0.1), duration: '1m' }, // quiet morning
{ target: PEAK, duration: '1m' }, // 10:00, the on-sale starts
{ target: PEAK, duration: '6m' }, // sustained peak
{ target: Math.round(PEAK * 0.1), duration: '1m' },
{ target: Math.round(PEAK * 0.1), duration: '4m' }, // watch the scale-down
],
},
},
summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
};
export default function () {
const id = 1 + Math.floor(Math.random() * 1000);
const res = http.get(`${BASE}/events/${id}`, { tags: { name: 'GET /events/{id}' } });
check(res, { 'status is 200': (r) => r.status === 200 });
}
./k8s/record.sh k8s/results/e1-hpa.csv &
k6 run -e BASE_URL=http://localhost -e PEAK=600 --out csv=k8s/results/e1-k6.csv load/hpa-ramp.js
kill %1
Do not load test through kubectl port-forward
port-forward is a debugging tunnel through the API server; it becomes the bottleneck long before your pods do. Use the Ingress (Lab 10). If your local networking does not allow it, run k6 inside the cluster as a Job (below).
k6 as a Job inside the cluster
apiVersion: batch/v1
kind: Job
metadata:
name: k6-ramp
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: k6
image: grafana/k6:latest
args: ["run", "--quiet", "--no-summary", "--out", "csv=/dev/stdout",
"-e", "BASE_URL=http://catalog:8080", "-e", "PEAK=600", "/scripts/hpa-ramp.js"]
volumeMounts:
- name: scripts
mountPath: /scripts
volumes:
- name: scripts
configMap:
name: k6-scripts
kubectl create configmap k6-scripts --from-file=load/hpa-ramp.js
kubectl apply -f k8s/k6-job.yaml
kubectl wait --for=condition=complete job/k6-ramp --timeout=20m
kubectl logs job/k6-ramp > k8s/results/e1-k6.csv
k6 then shares the cluster's CPU with your pods — say so in the report.
6. Plot replicas and latency together
import csv
import sys
from collections import defaultdict
import matplotlib.pyplot as plt
# usage: python k8s/plot_hpa.py k8s/results/e1-hpa.csv k8s/results/e1-k6.csv k8s/results/e1.png
hpa_file, k6_file, out = sys.argv[1:4]
hpa = list(csv.DictReader(open(hpa_file)))
t0 = int(hpa[0]["ts"])
t = [int(r["ts"]) - t0 for r in hpa]
lat, err = defaultdict(list), defaultdict(lambda: [0, 0])
for row in csv.DictReader(open(k6_file)):
if row.get("metric_name") == "http_req_duration":
lat[int(float(row["timestamp"])) // 10 * 10].append(float(row["metric_value"]))
elif row.get("metric_name") == "http_req_failed":
bucket = err[int(float(row["timestamp"])) // 10 * 10]
bucket[0] += float(row["metric_value"])
bucket[1] += 1
def p99(values):
values = sorted(values)
return values[int(0.99 * (len(values) - 1))]
windows = sorted(lat)
fig, ax1 = plt.subplots(figsize=(10, 4))
ax1.step(t, [int(r["desired"] or 0) for r in hpa], where="post", label="desired")
ax1.step(t, [int(r["ready"] or 0) for r in hpa], where="post", label="ready")
ax1.set_xlabel("time (s)")
ax1.set_ylabel("replicas")
ax2 = ax1.twinx()
ax2.plot([w - t0 for w in windows], [p99(lat[w]) for w in windows], color="tab:red", label="p99 (10 s)")
ax2.set_ylabel("p99 (ms)")
fig.legend(loc="upper right")
fig.tight_layout()
fig.savefig(out, dpi=150)
print("error rate per window:", {w - t0: round(e[0] / e[1], 4) for w, e in sorted(err.items()) if e[1]})
7. Rolling update under load (E3)
Run a constant rate at about half of what the minimum replicas sustain, and restart the Deployment in the middle:
k6 run -e BASE_URL=http://localhost -e PEAK=150 load/hpa-ramp.js &
sleep 120 && kubectl rollout restart deployment/catalog && kubectl rollout status deployment/catalog
wait
The k6 summary must show http_req_failed = 0. If it does not, find which piece is missing: readiness, preStop, graceful shutdown, maxUnavailable: 0.
8. The next bottleneck
Each replica has its own connection pool. At maxReplicas: 10 with a pool of 10, that is 100 connections — exactly PostgreSQL's default max_connections, with nothing left for migrations, admin or a second service. The autoscaler can make things worse by scaling the stateless tier into a database that cannot follow. Compute your budget and propose a fix (smaller pools, PgBouncer, a maxReplicas derived from the database, read replicas, a cache).
Templates
# Kubernetes autoscaling — <service>
## 1. Cluster and manifests
kind/minikube version, nodes, CPU/RAM; links to the manifests; table of requests/limits, probes, HPA settings.
## 2. Prediction
CPU per request: ... millicores (measured at ... req/s with 2 replicas).
At ... req/s: total CPU = ...; replicas = ceil(total / (request × 0.60)) = ...
## 3. E1 — scale-up timeline
Chart: replicas (desired, ready) and p99 over time, load ramp marked.
| Event | Time (s) |
|-|-|
| Load reaches the peak | |
| Utilization above target | |
| HPA raises desired replicas | |
| First new pod ready | |
| Steady state (replicas, p99) | |
| Scale-down finished | |
Observed replicas at steady state ... vs predicted ...; errors during scale-up ...
## 4. E2 — one change
Change: ... · Result compared with E1: ... · Conclusion: ...
## 5. E3 — rolling update under load
Rate ...; failed requests: ... (k6 summary).
## 6. Connection budget
maxReplicas × pool = ... vs max_connections = ...; fix: ...
## 7. Video
Link (≤ 3 min).
## 8. AI usage
# Autoscaling no Kubernetes — <serviço>
## 1. Cluster e manifestos
Versão do kind/minikube, nós, CPU/RAM; links para os manifestos; tabela de requests/limits, probes, configuração do HPA.
## 2. Previsão
CPU por requisição: ... milicores (medido a ... req/s com 2 réplicas).
A ... req/s: CPU total = ...; réplicas = ceil(total / (request × 0,60)) = ...
## 3. E1 — linha do tempo da escalada
Gráfico: réplicas (desejadas, prontas) e p99 ao longo do tempo, rampa de carga marcada.
| Evento | Tempo (s) |
|-|-|
| Carga atinge o pico | |
| Utilização acima da meta | |
| HPA aumenta as réplicas desejadas | |
| Primeiro pod novo pronto | |
| Regime (réplicas, p99) | |
| Fim da redução | |
Réplicas observadas em regime ... vs previstas ...; erros durante a escalada ...
## 4. E2 — uma mudança
Mudança: ... · Resultado comparado com o E1: ... · Conclusão: ...
## 5. E3 — atualização gradual sob carga
Taxa ...; requisições com falha: ... (resumo do k6).
## 6. Orçamento de conexões
maxReplicas × pool = ... vs max_connections = ...; correção: ...
## 7. VÃdeo
Link (≤ 3 min).
## 8. Uso de IA
Rubric
Levels as defined in How the exercises are graded.
| Criterion | I | D | C | B | A |
|---|---|---|---|---|---|
| Manifests (mandatory) | Do not apply | Requests/limits or a probe missing | Requests/limits, 3 probes, graceful shutdown, Service, Ingress, HPA v2 with behavior | Each setting justified (table) | JVM memory, probes and timeouts tuned from measurements |
| Timeline data (mandatory) | No data | Screenshots only | CSV time series + chart of replicas and p99 | Lag decomposed into its phases with timestamps | Lag reduced by a change and the reduction measured |
| Prediction | — | None | Formula applied | Within ±1 replica or difference explained | CPU per request measured at several rates (linearity checked) |
| E2 comparison | — | Not done | Done with the same load | Clear conclusion with numbers | Second variable or a custom metric (e.g. requests per second, KEDA) |
| Zero-downtime update | — | Errors not investigated | 0 failed requests | Explains which setting prevents which failure | Shows the failure without one setting and with it |
| Next bottleneck and communication | — | Missing | Connection budget computed | Fix proposed and argued | Fix implemented and tested at max scale; video crisp |
FAQ
kubectl get hpa shows <unknown>/60%.
metrics-server is not running or not ready yet (wait a minute after start-up; on kind, check the --kubelet-insecure-tls patch), or the pods have no CPU request — utilization is relative to the request.
The HPA does not scale even with high load.
Check the actual CPU of the pods (kubectl top pods): if the bottleneck is the database or the connection pool, CPU stays low and the HPA has no reason to scale. That is a valid — and important — finding. Say what metric would have scaled correctly.
Pods scale up but errors appear exactly at that moment.
Usually a missing or too permissive readiness probe (traffic before the app is ready) or a cold JVM (the first requests are slow). Compare with E2, and consider a warm-up step before the readiness probe passes.
Scale-down takes forever.
By design: the default scale-down stabilization window is 5 minutes, to avoid flapping. The manifest above shortens it to 2 minutes for the experiment. Discuss the trade-off for BoxOffice (an on-sale may have a second wave).
My laptop cannot generate enough load to need 4 replicas.
Lower the CPU request (e.g. 250m) so that each pod saturates sooner, or make each request more expensive (a heavier endpoint). Explain the choice; the goal is to observe the mechanism, not a big number.
Can I use a cloud cluster instead?
Yes (EKS, GKE, AKS), if you watch the costs and delete it afterwards. The manifests are the same; the Ingress class and the metrics-server setup may differ.
-
Kubernetes documentation, "Horizontal Pod Autoscaling — Algorithm details", kubernetes.io. ↩
-
Kubernetes documentation, "Configure Liveness, Readiness and Startup Probes", kubernetes.io. ↩