Skip to main content

Kubernetes Production Readiness Challenge

Break a Kubernetes app on purpose with 6 real failures, diagnose each with Prometheus and kubectl, then add guardrails and SLO burn-rate alerts.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project is a Kubernetes production readiness challenge. You deploy a small API with PostgreSQL and Prometheus monitoring into a sandbox namespace, then break it on purpose with six real failures: CrashLoopBackOff, OOMKilled, a bad rollout, database connection exhaustion, a cost spike and a NetworkPolicy lockdown. For each one you diagnose it with kubectl and Prometheus, fix it, and add a guardrail so it cannot return unnoticed. You finish by defining SLOs with burn-rate alerts and writing a debugging playbook and postmortems.

It exists because building a platform and operating one are different skills. Memory leaks, bad deploys and over-strict network rules arrive regularly in production, and the only way to be fast at 2 AM is to have caused and fixed each one before. Chaos engineering is the practice of injecting failures deliberately to find weaknesses before users do. Netflix popularised it with Chaos Monkey, and payment teams such as Razorpay run failure drills before big sale events.

What you build, learn and can show

  • You build: a sandbox app (API, database, metrics), six failure drills, six guardrails, an SLO rule set with multi-window burn-rate alerts, and a debugging playbook.
  • You learn: the first command to run for each symptom, how exit codes and events point at the cause, why guardrails beat heroics, and how error budgets turn percentages into minutes.
  • You can show: a repo with the lab, an incident log with a timeline per scenario, the alert rules, and a one-page playbook. Interviewers can read it and see you have operated a cluster, not only built one.

The lab

flowchart TD YOU([You inject
failures]) --> API[shop-api
3 replicas] LOAD([hey load pod]) --> API API -->|queries| DB[(PostgreSQL)] PROM[Prometheus] -->|scrapes| API PROM --> RULES[SLO burn
alerts] classDef people fill:#EDE9FE,color:#1E293B classDef app fill:#326CE5,color:#FFFFFF classDef data fill:#10B981,color:#FFFFFF classDef tool fill:#F97316,color:#FFFFFF class YOU,LOAD people class API app class DB data class PROM,RULES tool

Every scenario follows the same loop. Do not skip the last step, because the guardrail is the part that makes you production ready.

stateDiagram-v2 [*] --> Healthy Healthy --> Broken: Inject Broken --> Diagnosed: Detect Diagnosed --> Recovered: Fix Recovered --> Hardened: Prevent Hardened --> [*]
Scenario What breaks First signal Guardrail you add
1. CrashLoopBackOff A required variable is empty Pod status and --previous logs Startup validation
2. OOMKilled Memory limit too low for real use Exit code 137 Right-sizing and an OOM alert
3. Bad rollout Image tag does not exist ImagePullBackOff, stuck rollout Rollout deadline and a CI image check
4. Connection exhaustion Too many replicas times pool size too many clients in database logs PgBouncer and a sizing rule
5. Cost spike 30 pods with no resource requests Request versus usage queries LimitRange and ResourceQuota
6. Network lockdown A deny-all policy blocks DNS EAI_AGAIN and failed lookups Least-privilege policies

Prerequisites

  • A non-production Kubernetes cluster, kubectl, Helm and Docker. The cluster must run a CNI that enforces NetworkPolicy (Calico, Cilium, or the AWS VPC CNI with network policy enabled), or Scenario 6 will appear to do nothing.
  • A container registry you can push to (ECR, Docker Hub or GHCR) and metrics-server installed so kubectl top works.
  • The Prometheus Operator stack. If you do not have it from an earlier module, install it with the flags below, which let Prometheus pick up your ServiceMonitor and rules without extra labels:
Bash
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm upgrade --install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring --create-namespace \
--set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
--set prometheus.prometheusSpec.ruleSelectorNilUsesHelmValues=false

Safety rules

  • Run everything in the dedicated chaos-lab namespace, never in a namespace real traffic uses.
  • Inject one failure at a time and reset to known good before the next.
  • If your cluster uses GitOps with self-heal, pause sync for this namespace, or your fixes and injections will be reverted.
  • In real production, chaos experiments need automated rollback, approval and the on-call team present. Start in staging, always.
Tip

Keep a plain text incident-log.md open for the whole project. Write the time you injected, the time you noticed, the command that revealed the cause and the time you recovered. Those four timestamps are the raw material for your postmortems in Milestone 9.

Milestone 1: Deploy the Target App and Baseline Monitoring

This milestone deploys the sandbox app, its database and a ServiceMonitor so Prometheus scrapes it, and proves the system is healthy before anything is broken. A baseline you trust is what lets you tell a real signal from noise later.

Why a purpose-built app

The app has three properties that make each drill reproducible: it fails fast at startup if it cannot reach the database, its readiness probe checks the database, and it exposes request counters and a latency histogram. It also has two chaos switches, an ERROR_RATE variable and a /debug/leak endpoint, so you can create errors and memory growth on demand. Real services should not ship a leak endpoint.

Create the project layout and the app code:

Bash
mkdir -p chaos-lab/app chaos-lab/k8s chaos-lab/scripts && cd chaos-lab

app/package.json:

JSON
{
"name": "shop-api",
"version": "1.0.0",
"main": "server.js",
"dependencies": {
"pg": "^8.11.0",
"prom-client": "^15.1.0"
}
}

app/server.js:

JAVASCRIPT
const http = require("http");
const { Pool } = require("pg");
const client = require("prom-client");
const REQUIRED = ["DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD"];
// Switched on in Scenario 1. In your own services this check should always run.
if (process.env.VALIDATE_ENV === "true") {
const missing = REQUIRED.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(`FATAL: missing required environment variables: ${missing.join(", ")}`);
process.exit(1);
}
}
const pool = new Pool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: Number(process.env.DB_POOL_SIZE || 5),
connectionTimeoutMillis: 3000,
});
pool.on("error", (err) => console.error("pool error:", err.message));
client.collectDefaultMetrics();
const requests = new client.Counter({
name: "http_requests_total",
help: "HTTP requests",
labelNames: ["route", "status"],
});
const duration = new client.Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration",
labelNames: ["route"],
buckets: [0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
});
const ERROR_RATE = Number(process.env.ERROR_RATE || 0);
const leaked = [];
async function handle(req, res) {
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/metrics") {
res.setHeader("Content-Type", client.register.contentType);
return res.end(await client.register.metrics());
}
if (url.pathname === "/health/live") {
res.writeHead(200);
return res.end("ok");
}
if (url.pathname === "/health/ready") {
try {
await pool.query("SELECT 1");
res.writeHead(200);
return res.end('{"status":"ok"}');
} catch (err) {
res.writeHead(503);
return res.end('{"status":"database unreachable"}');
}
}
if (url.pathname === "/debug/leak") {
const mb = Number(url.searchParams.get("mb") || 10);
leaked.push(Buffer.alloc(mb * 1024 * 1024, 1));
res.writeHead(200);
return res.end(`holding ${leaked.length * mb} MB\n`);
}
if (url.pathname === "/api/menu") {
const stop = duration.startTimer({ route: "/api/menu" });
let status = 200;
try {
if (Math.random() < ERROR_RATE) throw new Error("injected failure");
await pool.query("SELECT pg_sleep(0.05)");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ items: ["biryani", "dosa", "paneer tikka"] }));
} catch (err) {
status = 500;
console.error("menu error:", err.message);
res.writeHead(500);
res.end('{"error":"internal"}');
}
stop();
requests.inc({ route: "/api/menu", status: String(status) });
return;
}
res.writeHead(404);
res.end("not found");
}
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
console.error("unhandled:", err.message);
res.writeHead(500);
res.end();
});
});
// Fail fast: a pod that cannot reach its database should not pretend to be alive.
pool
.query("SELECT 1")
.then(() => server.listen(8080, () => console.log("shop-api listening on 8080")))
.catch((err) => {
console.error("startup failed:", err.message);
process.exit(1);
});

app/Dockerfile:

Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
USER node
EXPOSE 8080
CMD ["node", "server.js"]

Build and push the image. Replace the registry with yours:

Bash
export REGISTRY=your-registry.example.com/your-team
docker build -t $REGISTRY/shop-api:v1 app/
docker push $REGISTRY/shop-api:v1

Now the manifests. k8s/baseline.yaml is the single source of truth for the known good state:

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: chaos-lab
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
args: ["-c", "max_connections=30"]
env:
- name: POSTGRES_DB
value: shop
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shop-db
key: password
ports:
- containerPort: 5432
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: chaos-lab
spec:
selector:
app: postgres
ports:
- port: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-api
namespace: chaos-lab
spec:
replicas: 3
selector:
matchLabels:
app: shop-api
template:
metadata:
labels:
app: shop-api
spec:
containers:
- name: shop-api
image: ${REGISTRY}/shop-api:v1
ports:
- name: http
containerPort: 8080
env:
- name: DB_HOST
value: postgres
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: shop
- name: DB_USER
value: postgres
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: shop-db
key: password
- name: DB_POOL_SIZE
value: "5"
- name: ERROR_RATE
value: "0"
- name: VALIDATE_ENV
value: "false"
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: shop-api
namespace: chaos-lab
labels:
app: shop-api
spec:
selector:
app: shop-api
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: shop-api
namespace: chaos-lab
spec:
selector:
matchLabels:
app: shop-api
endpoints:
- port: http
path: /metrics
interval: 15s

Deploy it, and create a reset script that returns the lab to this known good state between scenarios:

Bash
kubectl create namespace chaos-lab
kubectl -n chaos-lab create secret generic shop-db \
--from-literal=password="$(openssl rand -base64 18 | tr -d '/+=')"
cat > scripts/reset.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
kubectl -n chaos-lab delete networkpolicy --all --ignore-not-found
kubectl -n chaos-lab delete deployment cost-hog --ignore-not-found
kubectl -n chaos-lab delete pod load --ignore-not-found
envsubst < k8s/baseline.yaml | kubectl apply -f -
kubectl -n chaos-lab rollout restart deployment/shop-api
kubectl -n chaos-lab rollout status deployment/shop-api --timeout=180s
EOF
chmod +x scripts/reset.sh
./scripts/reset.sh
kubectl -n chaos-lab get pods

Verify the baseline. All pods should be Running with 1/1 ready and zero restarts:

Bash
kubectl -n chaos-lab port-forward svc/shop-api 8080:80 &
sleep 2
curl -s localhost:8080/health/ready
curl -s localhost:8080/api/menu
kill %1

Send a minute of traffic, then check Prometheus. Open it with kubectl -n monitoring port-forward svc/monitoring-kube-prometheus-prometheus 9090 and run this query. You should see a non-zero rate:

Bash
kubectl -n chaos-lab run load --image=williamyeh/hey --restart=Never -- \
-z 60s -c 5 -q 5 http://shop-api/api/menu
TEXT
sum(rate(http_requests_total{namespace="chaos-lab"}[1m]))
Common Mistake

Skipping the baseline and discovering mid-scenario that Prometheus never scraped the app. If the query above is empty, check that the shop-api target is UP under Status then Targets in the Prometheus UI before going further.

Milestone 2: Scenario 1 - CrashLoopBackOff

A CrashLoopBackOff means a container starts, exits, and Kubernetes keeps restarting it with growing delays. It is usually a startup problem such as a missing variable, a bad config file or an unreachable dependency, so the evidence lives in the logs of the container that just died.

Inject

Clear the database host. The app fails fast when it cannot connect:

Bash
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=
kubectl -n chaos-lab get pods --watch

Within about a minute you will see new pods in Error then CrashLoopBackOff with a rising restart count. The old pods keep serving traffic because a rolling update never removes a healthy pod until its replacement is ready. That is why users notice nothing yet, and why a stuck rollout can hide for hours if you do not watch it.

Detect

Bash
## 1. Which pod is crashing?
kubectl -n chaos-lab get pods
## 2. Logs of the container that just died. Plain "kubectl logs" is often empty for a fast crash
kubectl -n chaos-lab logs POD_NAME --previous
## 3. Exit code and recent events
kubectl -n chaos-lab describe pod POD_NAME | grep -A 8 "Last State"
kubectl -n chaos-lab get events --sort-by=.lastTimestamp | tail -15

The logs show startup failed: followed by a connection error. Notice what is missing: nothing says which variable is wrong. Exit code 1 means the application chose to exit, which separates this from exit code 137 (killed, Scenario 2).

Fix

Bash
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=postgres
kubectl -n chaos-lab rollout status deployment/shop-api
kubectl -n chaos-lab get pods

Prevent

The fix for a confusing crash is a clear one. Turn on the app's startup validation, then repeat the injection and compare the log message:

Bash
kubectl -n chaos-lab set env deployment/shop-api VALIDATE_ENV=true
kubectl -n chaos-lab rollout status deployment/shop-api
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=
## Wait for a crashing pod, then read its previous logs
kubectl -n chaos-lab logs POD_NAME --previous

You should now read FATAL: missing required environment variables: DB_HOST. The crash is self-diagnosing. Restore the host, and edit k8s/baseline.yaml so VALIDATE_ENV is "true" and reset.sh keeps it.

Bash
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=postgres
Common Mistake

Running kubectl logs without --previous on a crash-looping pod and concluding there are no logs. The current container is new and empty. Always read the previous one first.

Milestone 3: Scenario 2 - OOMKilled

An OOMKilled container did not crash on its own. The Linux kernel killed it because it exceeded its memory limit, and Kubernetes then restarted it. It is caused either by a limit set too low for real usage or by a leak that grows until it hits the ceiling.

Inject

Lower the limit to 128Mi, which is realistic for a small Node.js service, then make the app hold more memory than that. A limit of 32Mi would kill Node at startup, which is a different failure, so do not use it:

Bash
./scripts/reset.sh
kubectl -n chaos-lab set resources deployment/shop-api \
--requests=memory=64Mi --limits=memory=128Mi
kubectl -n chaos-lab rollout status deployment/shop-api
kubectl -n chaos-lab port-forward deployment/shop-api 8081:8080 &
PF=$!
sleep 2
for i in 1 2 3 4; do curl -s "localhost:8081/debug/leak?mb=40" || echo "connection lost"; sleep 1; done
kill $PF

The first calls succeed. When the process crosses 128Mi the kernel kills it and the connection drops.

Detect

Bash
kubectl -n chaos-lab get pods
kubectl -n chaos-lab describe pod POD_NAME | grep -A 6 "Last State"

You should see Reason: OOMKilled and Exit Code: 137. Exit code 137 is 128 plus signal 9, so it always means the process was killed from outside. Now confirm it in Prometheus, where you can also see the shape of the problem over time:

TEXT
container_memory_working_set_bytes{namespace="chaos-lab", container="shop-api"}
TEXT
kube_pod_container_status_last_terminated_reason{namespace="chaos-lab", reason="OOMKilled"}

The first graph climbs towards 128Mi and drops to near zero at the restart. The second turns to 1 for the killed container.

Fix

Size the limit from observed usage, not from a guess. Look at the working set during normal load, and take the high percentile rather than the average, then leave headroom:

TEXT
quantile_over_time(0.95, container_memory_working_set_bytes{namespace="chaos-lab", container="shop-api"}[1h])
Bash
kubectl -n chaos-lab set resources deployment/shop-api \
--requests=memory=96Mi --limits=memory=256Mi
kubectl -n chaos-lab rollout status deployment/shop-api

Prevent

Add an alert so a restart caused by OOM pages someone instead of hiding in a restart counter. Save as k8s/oom-alert.yaml:

YAML
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: shop-api-oom
namespace: chaos-lab
spec:
groups:
- name: shop-api-oom
rules:
- alert: ContainerOOMKilled
expr: |
increase(kube_pod_container_status_restarts_total{namespace="chaos-lab"}[10m]) > 0
and on (namespace, pod, container)
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} == 1
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }} was OOMKilled"
runbook: "Check memory graph, compare to limit, look for a leak before raising the limit"
Bash
kubectl apply -f k8s/oom-alert.yaml
Common Mistake

Sizing limits from the average. If average usage is 100Mi but the 95th percentile is 180Mi, a 120Mi limit kills the pod during every traffic spike. Use a high percentile plus headroom, and treat repeated limit increases as a sign of a leak, not a fix.

Tip

The Vertical Pod Autoscaler in recommendation-only mode (updateMode: "Off") is a good next step. It suggests requests and limits from real usage without changing pods.

Milestone 4: Scenario 3 - Bad Rollout

A bad rollout ships a version that cannot run. The most common causes are a wrong image tag, a broken image or a bad config, and the cleanest way to recover is to roll back first and investigate second.

Inject

Deploy a tag that does not exist, which is what a mistyped CI variable produces:

Bash
./scripts/reset.sh
kubectl -n chaos-lab set image deployment/shop-api \
shop-api=$REGISTRY/shop-api:v2-does-not-exist
kubectl -n chaos-lab rollout status deployment/shop-api --timeout=60s

The rollout never completes. The command times out while old pods keep serving.

Detect

Bash
kubectl -n chaos-lab get pods
kubectl -n chaos-lab describe pod POD_NAME | grep -A 6 "Events:"
kubectl -n chaos-lab get deployment shop-api
kubectl -n chaos-lab rollout history deployment/shop-api

The new pod is in ErrImagePull then ImagePullBackOff, and events say the image could not be pulled. The Deployment shows fewer up-to-date replicas than desired.

Fix

Bash
kubectl -n chaos-lab rollout undo deployment/shop-api
kubectl -n chaos-lab rollout status deployment/shop-api
kubectl -n chaos-lab get deployment shop-api -o jsonpath='{.spec.template.spec.containers[0].image}'

The output should be your v1 image again. Rolling back is a one-line recovery. Fixing forward is fine later, once users are safe.

Prevent

Make the safe behaviour explicit instead of relying on defaults, and fail the pipeline before a missing image reaches the cluster. Add this to the shop-api Deployment spec in k8s/baseline.yaml:

YAML
spec:
progressDeadlineSeconds: 120
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1

maxUnavailable: 0 guarantees old pods stay until new ones are ready, and progressDeadlineSeconds marks a stalled rollout as failed so alerts and pipelines can react. Then add an image check to your CI deploy job:

YAML
- name: Verify the image exists before deploying
run: |
docker manifest inspect "$REGISTRY/shop-api:$IMAGE_TAG" > /dev/null
echo "Image $IMAGE_TAG found, safe to deploy"
Common Mistake

Treating a stuck rollout as success because users are fine. The old version is still running and the new one never shipped. Always check kubectl rollout status in the pipeline and fail the job when it times out.

Milestone 5: Scenario 4 - Database Connection Exhaustion

Every PostgreSQL server has a hard limit on simultaneous connections. Each app replica keeps a pool of connections open, so total connections equal replicas multiplied by pool size. Scale out or enlarge the pool and the product can exceed the limit, after which new connections fail and requests return errors. In this lab the limit is 30 with 3 reserved for admins, so about 27 are usable.

Inject

Bash
./scripts/reset.sh
kubectl -n chaos-lab set env deployment/shop-api DB_POOL_SIZE=20
kubectl -n chaos-lab scale deployment/shop-api --replicas=4
kubectl -n chaos-lab rollout status deployment/shop-api
## 4 replicas x 20 connections = 80 wanted, about 27 available
kubectl -n chaos-lab run load --image=williamyeh/hey --restart=Never -- \
-z 90s -c 60 http://shop-api/api/menu

Detect

Bash
kubectl -n chaos-lab logs deployment/postgres | grep -i "too many clients\|remaining connection slots" | tail -5
kubectl -n chaos-lab exec deployment/postgres -- psql -U postgres -d shop \
-c "SELECT count(*) FROM pg_stat_activity;"
kubectl -n chaos-lab exec deployment/postgres -- psql -U postgres -c "SHOW max_connections;"
kubectl -n chaos-lab logs -l app=shop-api --tail=15 | grep "menu error"
kubectl -n chaos-lab logs load

You should see sorry, too many clients already in the database log, a connection count at the ceiling, and a share of 500 responses in the load tool's summary. In Prometheus, check the error rate:

TEXT
sum(rate(http_requests_total{namespace="chaos-lab", status=~"5.."}[1m]))

Fix

Stop the damage first, then fix the structure. A connection pooler such as PgBouncer sits between the apps and the database, accepts many client connections and reuses a small number of real ones.

Bash
kubectl -n chaos-lab delete pod load --ignore-not-found
kubectl -n chaos-lab scale deployment/shop-api --replicas=3
kubectl -n chaos-lab set env deployment/shop-api DB_POOL_SIZE=5

Deploy PgBouncer as k8s/pgbouncer.yaml. The environment variable names below are those of the edoburu/pgbouncer image, so check its documentation if you pick a different image:

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer
namespace: chaos-lab
spec:
replicas: 1
selector:
matchLabels:
app: pgbouncer
template:
metadata:
labels:
app: pgbouncer
spec:
containers:
- name: pgbouncer
image: edoburu/pgbouncer:latest
ports:
- containerPort: 5432
env:
- name: DB_HOST
value: postgres
- name: DB_NAME
value: shop
- name: DB_USER
value: postgres
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: shop-db
key: password
- name: AUTH_TYPE
value: scram-sha-256
- name: POOL_MODE
value: transaction
- name: MAX_CLIENT_CONN
value: "500"
- name: DEFAULT_POOL_SIZE
value: "10"
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: pgbouncer
namespace: chaos-lab
spec:
selector:
app: pgbouncer
ports:
- port: 5432
Bash
kubectl apply -f k8s/pgbouncer.yaml
kubectl -n chaos-lab rollout status deployment/pgbouncer
## Point the app at the pooler and allow each pod a bigger client pool
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=pgbouncer DB_POOL_SIZE=20
kubectl -n chaos-lab rollout status deployment/shop-api
kubectl -n chaos-lab run load --image=williamyeh/hey --restart=Never -- \
-z 60s -c 60 http://shop-api/api/menu

Repeat the detection commands. The connection count to PostgreSQL should stay near the pooler's size of 10, and the load summary should show no 500 responses. Requests that cannot get a database connection now queue briefly instead of failing, so expect higher latency at the same load, which is the right trade.

Prevent

  • Apply a sizing rule everywhere: replicas x pool size must stay below max_connections minus reserved connections. With a pooler, the rule applies to the pooler's server pool instead.
  • Set a maximum on your HorizontalPodAutoscaler so scale-out cannot outgrow the database.
  • Keep PgBouncer in the baseline by adding it to your reset flow.
Common Mistake

Raising max_connections on the database to make the errors stop. Each connection costs memory on the server, so a bigger limit just moves the cliff. Pool connections instead of multiplying them.

Milestone 6: Scenario 5 - Cost Spike

A cost spike is usually not an attack. It is a forgotten load test, a runaway scale-out or a workload with no resource requests, which the scheduler cannot account for and billing tools cannot attribute. If an autoscaler is installed, extra pods also mean extra nodes, which is where the money goes.

Inject

Bash
./scripts/reset.sh
kubectl -n chaos-lab create deployment cost-hog --image=nginx:1.27 --replicas=30
kubectl -n chaos-lab get pods | grep -c cost-hog

If your nodes are small, some pods will sit Pending. That is itself a signal, and on a cluster with an autoscaler it is the moment new nodes would launch.

Detect

Bash
kubectl top nodes
kubectl -n chaos-lab get deployment cost-hog
kubectl describe nodes | grep -A 6 "Allocated resources"

In Prometheus, count containers that have no CPU request. These are the workloads that look free on paper but are not:

TEXT
count(kube_pod_container_info{namespace="chaos-lab"})
- count(kube_pod_container_resource_requests{namespace="chaos-lab", resource="cpu"})
TEXT
count(kube_pod_info{namespace="chaos-lab"})

If you installed Kubecost in an earlier module, open Allocations grouped by namespace to see the same spike in money terms and a falling efficiency score.

Fix

Bash
kubectl -n chaos-lab delete deployment cost-hog
kubectl top nodes

Prevent

Two Kubernetes objects work together. A LimitRange gives every container default requests and limits, so nothing is created without them. A ResourceQuota caps the total a namespace can use. The quota needs the LimitRange, because a quota on requests rejects any pod that does not declare them. Save as k8s/guardrails.yaml:

YAML
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: chaos-lab
spec:
limits:
- type: Container
defaultRequest:
cpu: 50m
memory: 64Mi
default:
cpu: 500m
memory: 512Mi
max:
cpu: "2"
memory: 4Gi
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: namespace-limits
namespace: chaos-lab
spec:
hard:
pods: "25"
requests.cpu: "2"
requests.memory: 3Gi
limits.memory: 8Gi
Bash
kubectl apply -f k8s/guardrails.yaml
## Prove the guardrail: repeat the injection
kubectl -n chaos-lab create deployment cost-hog --image=nginx:1.27 --replicas=30
sleep 15
kubectl -n chaos-lab get pods | grep -c cost-hog
kubectl -n chaos-lab get events --sort-by=.lastTimestamp | grep -i "exceeded quota" | tail -3
kubectl -n chaos-lab delete deployment cost-hog

The pod count now stops at the quota instead of reaching 30, and events show exceeded quota. For admission-time policy with friendlier error messages, a policy engine such as Kyverno is a good extension.

Common Mistake

Adding a ResourceQuota without a LimitRange. Every existing manifest that omits requests then fails to deploy with a confusing must specify requests error. Apply them together.

Milestone 7: Scenario 6 - NetworkPolicy Lockdown

A NetworkPolicy that selects a pod makes that pod default-deny for the traffic types it lists. A deny-all policy with no rules therefore blocks everything, including DNS lookups on port 53, which is the most common way a well-meant security change breaks an application. Policies apply to new connections, so you must restart pods to see the full effect.

Inject

Bash
./scripts/reset.sh
kubectl apply -f - << 'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-broken
namespace: chaos-lab
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
EOF
kubectl -n chaos-lab rollout restart deployment/shop-api
kubectl -n chaos-lab get pods --watch

The old pods are replaced by new ones that cannot resolve the database name, so they exit and crash-loop.

Detect

Bash
kubectl -n chaos-lab logs POD_NAME --previous
## Look for: getaddrinfo EAI_AGAIN postgres (a temporary DNS failure)
kubectl -n chaos-lab run dns-test --image=busybox:1.36 --restart=Never --rm -it -- nslookup postgres
## Times out: DNS is blocked
kubectl -n chaos-lab get networkpolicy
kubectl -n chaos-lab describe networkpolicy deny-all-broken

The error name is the clue. EAI_AGAIN means name resolution is failing, which points to DNS or the network, not to your application. Also notice a second casualty: with ingress blocked, Prometheus can no longer scrape the pods, so your metrics go quiet exactly when you need them.

Fix

Bash
kubectl -n chaos-lab delete networkpolicy deny-all-broken
kubectl -n chaos-lab rollout restart deployment/shop-api
kubectl -n chaos-lab rollout status deployment/shop-api

Then apply policies that are least-privilege but do not break the platform. Save as k8s/network-policies.yaml:

YAML
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: chaos-lab
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: chaos-lab
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {}
egress:
- to:
- podSelector: {}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-scrape
namespace: chaos-lab
spec:
podSelector:
matchLabels:
app: shop-api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- port: 8080
Bash
kubectl apply -f k8s/network-policies.yaml
kubectl -n chaos-lab rollout restart deployment/shop-api
kubectl -n chaos-lab rollout status deployment/shop-api
kubectl -n chaos-lab run dns-test --image=busybox:1.36 --restart=Never --rm -it -- nslookup postgres

DNS should resolve, pods should be ready, and the shop-api target should be UP in Prometheus again.

Prevent

  • Roll out policies to one namespace at a time, always starting with the DNS allow rule.
  • After every policy change, run the same three checks: a DNS lookup from a pod, the readiness state, and the Prometheus target status.
  • Remember that these policies now block everything else, including outbound internet traffic from the namespace. That is intended, but add explicit rules when a service legitimately needs it.
Common Mistake

Testing a new policy only against existing pods. Established connections survive, so everything looks fine until the next restart. Restart the workload after applying the policy to see what a fresh pod experiences.

Milestone 8: Define SLOs and Burn-Rate Alerts

An SLO (Service Level Objective) is an internal reliability target that guides engineering decisions. It is not a promise to customers, which is an SLA. When a service is inside its SLO the team ships features, and when it is outside, reliability work comes first.

Choose SLOs from data

Run the service for a while and look at real numbers before committing, because an SLO you cannot meet only teaches the team to ignore alerts. For this lab, use these:

SLO Target What it means
Availability 99.9% of requests succeed Up to 0.1% of requests may fail
Latency 95th percentile under 500 ms 95 of every 100 requests are fast

The error budget is the allowed failure. Over 30 days there are 43,200 minutes, so a 99.9% target allows 43.2 minutes of full outage. "We have 20 minutes of budget left this month" is something a team can act on, while "we are at 99.93%" is not.

Why burn rate, not a threshold

A burn rate is how fast you consume the budget compared with the pace that would use it up exactly at the end of the period. A burn rate of 1 uses the whole budget in 30 days. A burn rate of 14.4 uses 2% of the monthly budget in one hour, which deserves a page. Alerting on burn rate tells you how urgent a problem is, not only that it exists. Using two windows, a long one to confirm the problem is real and a short one to confirm it is still happening, avoids pages for blips that have already ended.

Recording and alerting rules

Save as k8s/slo-rules.yaml:

YAML
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: shop-api-slos
namespace: chaos-lab
spec:
groups:
- name: shop-api-sli
interval: 30s
rules:
- record: sli:http_error_ratio:rate5m
expr: |
sum(rate(http_requests_total{namespace="chaos-lab", status=~"5.."}[5m]))
/ sum(rate(http_requests_total{namespace="chaos-lab"}[5m]))
- record: sli:http_error_ratio:rate30m
expr: |
sum(rate(http_requests_total{namespace="chaos-lab", status=~"5.."}[30m]))
/ sum(rate(http_requests_total{namespace="chaos-lab"}[30m]))
- record: sli:http_error_ratio:rate1h
expr: |
sum(rate(http_requests_total{namespace="chaos-lab", status=~"5.."}[1h]))
/ sum(rate(http_requests_total{namespace="chaos-lab"}[1h]))
- record: sli:http_error_ratio:rate6h
expr: |
sum(rate(http_requests_total{namespace="chaos-lab", status=~"5.."}[6h]))
/ sum(rate(http_requests_total{namespace="chaos-lab"}[6h]))
- record: sli:http_latency_p95:rate5m
expr: |
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket{namespace="chaos-lab"}[5m])) by (le))
- name: shop-api-slo-alerts
rules:
- alert: ErrorBudgetBurnFast
expr: |
sli:http_error_ratio:rate1h > (14.4 * 0.001)
and sli:http_error_ratio:rate5m > (14.4 * 0.001)
for: 2m
labels:
severity: critical
annotations:
summary: "Burning the monthly error budget about 14x too fast"
runbook: "docs/runbooks/error-budget-burn.md"
- alert: ErrorBudgetBurnSlow
expr: |
sli:http_error_ratio:rate6h > (6 * 0.001)
and sli:http_error_ratio:rate30m > (6 * 0.001)
for: 15m
labels:
severity: warning
annotations:
summary: "Budget will run out in about 5 days at this rate"
runbook: "docs/runbooks/error-budget-burn.md"
- alert: LatencyP95TooHigh
expr: sli:http_latency_p95:rate5m > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "p95 latency above the 500 ms objective"
runbook: "docs/runbooks/latency.md"
Bash
kubectl apply -f k8s/slo-rules.yaml

Prove the alerts fire

Inject errors, send steady traffic for ten minutes, and watch the Alerts page in the Prometheus UI move from inactive to pending to firing:

Bash
kubectl -n chaos-lab set env deployment/shop-api ERROR_RATE=0.5
kubectl -n chaos-lab run load --image=williamyeh/hey --restart=Never -- \
-z 10m -c 5 -q 5 http://shop-api/api/menu
## After the alert fires, restore
kubectl -n chaos-lab set env deployment/shop-api ERROR_RATE=0
kubectl -n chaos-lab delete pod load --ignore-not-found

Write a short runbook for each alert in docs/runbooks/: what the alert means, the first three commands to run, the likely causes and how you fixed it last time. An alert with no runbook only tells the on-call engineer that something is wrong.

Common Mistake

Setting a 99.9% SLO because it sounds professional, then discovering the service has never reached it. Measure for a few weeks first and set the target at what you can hold with normal effort. A 99.5% SLO you meet is worth more than a 99.9% SLO you constantly breach.

Milestone 9: Write the Debugging Playbook and Postmortems

This milestone turns what you did into the two artifacts hiring managers and teammates actually read: a symptom-first playbook and a blameless postmortem for each scenario. Writing them forces you to separate what you knew from what you guessed.

The playbook

Start every investigation from the symptom and run the first command in the table:

Symptom First command Usually means
CrashLoopBackOff kubectl logs POD --previous Startup failure: config, dependency, bad build
OOMKilled, exit 137 kubectl describe pod POD then memory graph Limit too low or a leak
ImagePullBackOff kubectl describe pod POD events Wrong tag, missing image, registry auth
Pending kubectl describe pod POD events No capacity, quota exceeded, bad node selector
503 from the service kubectl get endpoints SERVICE No ready pods, or traffic blocked
500 errors kubectl logs -l app=NAME then the error-rate query Dependency failing, connection limits, a code bug
Timeouts, lookups fail kubectl run dns-test ... nslookup NetworkPolicy or DNS problem
Slow responses p95 latency query and CPU throttling query Throttling, saturation, slow queries

For slow responses, two queries separate the usual suspects:

TEXT
rate(container_cpu_cfs_throttled_seconds_total{namespace="chaos-lab"}[5m])
TEXT
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{namespace="chaos-lab"}[5m])) by (le))

The postmortem template

Write one per scenario from your incident-log.md. Focus on systems and decisions, not on people:

TEXT
Title: Scenario N - short description
Impact: who or what was affected, and for how long
Timeline: injected / detected / diagnosed / fixed (with times)
Detection: the first signal and the command or graph that showed it
Root cause: the actual cause, not the symptom
What went well:
What was slow or confusing:
Fix applied:
Guardrail added: and how you proved it works
Follow-ups: one owner and one date each
Tip

If a postmortem's root cause is "someone made a mistake", keep asking why until you reach a missing guardrail. The guardrail is the real fix.

Validation & Testing

Reset the lab, then confirm every guardrail is in place and working.

Bash
./scripts/reset.sh
## 1. Baseline is healthy. Expected: 3/3 shop-api pods ready, 0 restarts climbing
kubectl -n chaos-lab get pods
## 2. Startup validation is on. Expected: true
kubectl -n chaos-lab get deployment shop-api \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="VALIDATE_ENV")].value}'
## 3. Rollout protection is explicit. Expected: 120 and 0
kubectl -n chaos-lab get deployment shop-api \
-o jsonpath='{.spec.progressDeadlineSeconds} {.spec.strategy.rollingUpdate.maxUnavailable}'
## 4. Pooler is deployed. Expected: pgbouncer 1/1 available
kubectl -n chaos-lab get deployment pgbouncer
## 5. Cost guardrails exist. Expected: both objects listed
kubectl -n chaos-lab get limitrange,resourcequota
## 6. Network policies exist and the deny-all is gone. Expected: three allow policies only
kubectl -n chaos-lab get networkpolicy
## 7. Rules are loaded in Prometheus. Expected: shop-api-slos and shop-api-oom groups
kubectl -n monitoring port-forward svc/monitoring-kube-prometheus-prometheus 9090 &
sleep 2
curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[].name' | grep shop-api
kill %1

Finally, complete this scorecard from your incident log. It is your portfolio summary:

Scenario Time to detect Time to fix Guardrail added Proven how
1. CrashLoopBackOff Startup validation Clear FATAL message
2. OOMKilled Right-sized limit and alert Alert fired on a replay
3. Bad rollout Deadline and image check Rollout failed fast
4. Connection exhaustion PgBouncer No 500s under load
5. Cost spike LimitRange and ResourceQuota Pods capped at quota
6. Network lockdown Least-privilege policies DNS and scrape recovered

Cleanup

Bash
kubectl delete namespace chaos-lab
## Optional, if you installed it only for this project
helm -n monitoring uninstall monitoring
Common Mistake

Leaving the sandbox running after the project. The database, the load pods and any autoscaled nodes keep billing. Delete the namespace and check that your cluster has scaled back down.

Found a mistake, an outdated step or a broken link?