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
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.
| 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-serverinstalled sokubectl topworks. - 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:
helm repo add prometheus-community https://prometheus-community.github.io/helm-chartshelm repo updatehelm upgrade --install monitoring prometheus-community/kube-prometheus-stack \ --namespace monitoring --create-namespace \ --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ --set prometheus.prometheusSpec.ruleSelectorNilUsesHelmValues=falseSafety rules
- Run everything in the dedicated
chaos-labnamespace, 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.
TipKeep a plain text
incident-log.mdopen 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:
mkdir -p chaos-lab/app chaos-lab/k8s chaos-lab/scripts && cd chaos-labapp/package.json:
{ "name": "shop-api", "version": "1.0.0", "main": "server.js", "dependencies": { "pg": "^8.11.0", "prom-client": "^15.1.0" }}app/server.js:
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:
FROM node:20-alpineWORKDIR /appCOPY package.json ./RUN npm install --omit=devCOPY server.js ./USER nodeEXPOSE 8080CMD ["node", "server.js"]Build and push the image. Replace the registry with yours:
export REGISTRY=your-registry.example.com/your-teamdocker build -t $REGISTRY/shop-api:v1 app/docker push $REGISTRY/shop-api:v1Now the manifests. k8s/baseline.yaml is the single source of truth for the known good state:
apiVersion: apps/v1kind: Deploymentmetadata: name: postgres namespace: chaos-labspec: 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: v1kind: Servicemetadata: name: postgres namespace: chaos-labspec: selector: app: postgres ports: - port: 5432---apiVersion: apps/v1kind: Deploymentmetadata: name: shop-api namespace: chaos-labspec: 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: v1kind: Servicemetadata: name: shop-api namespace: chaos-lab labels: app: shop-apispec: selector: app: shop-api ports: - name: http port: 80 targetPort: 8080---apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: shop-api namespace: chaos-labspec: selector: matchLabels: app: shop-api endpoints: - port: http path: /metrics interval: 15sDeploy it, and create a reset script that returns the lab to this known good state between scenarios:
kubectl create namespace chaos-labkubectl -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 bashset -euo pipefailkubectl -n chaos-lab delete networkpolicy --all --ignore-not-foundkubectl -n chaos-lab delete deployment cost-hog --ignore-not-foundkubectl -n chaos-lab delete pod load --ignore-not-foundenvsubst < k8s/baseline.yaml | kubectl apply -f -kubectl -n chaos-lab rollout restart deployment/shop-apikubectl -n chaos-lab rollout status deployment/shop-api --timeout=180sEOFchmod +x scripts/reset.sh ./scripts/reset.shkubectl -n chaos-lab get podsVerify the baseline. All pods should be Running with 1/1 ready and zero restarts:
kubectl -n chaos-lab port-forward svc/shop-api 8080:80 &sleep 2curl -s localhost:8080/health/readycurl -s localhost:8080/api/menukill %1Send 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:
kubectl -n chaos-lab run load --image=williamyeh/hey --restart=Never -- \ -z 60s -c 5 -q 5 http://shop-api/api/menusum(rate(http_requests_total{namespace="chaos-lab"}[1m]))Common MistakeSkipping the baseline and discovering mid-scenario that Prometheus never scraped the app. If the query above is empty, check that the
shop-apitarget isUPunder 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:
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=kubectl -n chaos-lab get pods --watchWithin 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
## 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 crashkubectl -n chaos-lab logs POD_NAME --previous ## 3. Exit code and recent eventskubectl -n chaos-lab describe pod POD_NAME | grep -A 8 "Last State"kubectl -n chaos-lab get events --sort-by=.lastTimestamp | tail -15The 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
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=postgreskubectl -n chaos-lab rollout status deployment/shop-apikubectl -n chaos-lab get podsPrevent
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:
kubectl -n chaos-lab set env deployment/shop-api VALIDATE_ENV=truekubectl -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 logskubectl -n chaos-lab logs POD_NAME --previousYou 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.
kubectl -n chaos-lab set env deployment/shop-api DB_HOST=postgresCommon MistakeRunning
kubectl logswithout--previouson 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:
./scripts/reset.shkubectl -n chaos-lab set resources deployment/shop-api \ --requests=memory=64Mi --limits=memory=128Mikubectl -n chaos-lab rollout status deployment/shop-api kubectl -n chaos-lab port-forward deployment/shop-api 8081:8080 &PF=$!sleep 2for i in 1 2 3 4; do curl -s "localhost:8081/debug/leak?mb=40" || echo "connection lost"; sleep 1; donekill $PFThe first calls succeed. When the process crosses 128Mi the kernel kills it and the connection drops.
Detect
kubectl -n chaos-lab get podskubectl -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:
container_memory_working_set_bytes{namespace="chaos-lab", container="shop-api"}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:
quantile_over_time(0.95, container_memory_working_set_bytes{namespace="chaos-lab", container="shop-api"}[1h])kubectl -n chaos-lab set resources deployment/shop-api \ --requests=memory=96Mi --limits=memory=256Mikubectl -n chaos-lab rollout status deployment/shop-apiPrevent
Add an alert so a restart caused by OOM pages someone instead of hiding in a restart counter. Save as k8s/oom-alert.yaml:
apiVersion: monitoring.coreos.com/v1kind: PrometheusRulemetadata: name: shop-api-oom namespace: chaos-labspec: 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"kubectl apply -f k8s/oom-alert.yamlCommon MistakeSizing 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.
TipThe 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:
./scripts/reset.shkubectl -n chaos-lab set image deployment/shop-api \ shop-api=$REGISTRY/shop-api:v2-does-not-existkubectl -n chaos-lab rollout status deployment/shop-api --timeout=60sThe rollout never completes. The command times out while old pods keep serving.
Detect
kubectl -n chaos-lab get podskubectl -n chaos-lab describe pod POD_NAME | grep -A 6 "Events:"kubectl -n chaos-lab get deployment shop-apikubectl -n chaos-lab rollout history deployment/shop-apiThe 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
kubectl -n chaos-lab rollout undo deployment/shop-apikubectl -n chaos-lab rollout status deployment/shop-apikubectl -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:
spec: progressDeadlineSeconds: 120 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1maxUnavailable: 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:
- 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 MistakeTreating 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 statusin 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
./scripts/reset.shkubectl -n chaos-lab set env deployment/shop-api DB_POOL_SIZE=20kubectl -n chaos-lab scale deployment/shop-api --replicas=4kubectl -n chaos-lab rollout status deployment/shop-api ## 4 replicas x 20 connections = 80 wanted, about 27 availablekubectl -n chaos-lab run load --image=williamyeh/hey --restart=Never -- \ -z 90s -c 60 http://shop-api/api/menuDetect
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 loadYou 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:
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.
kubectl -n chaos-lab delete pod load --ignore-not-foundkubectl -n chaos-lab scale deployment/shop-api --replicas=3kubectl -n chaos-lab set env deployment/shop-api DB_POOL_SIZE=5Deploy 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:
apiVersion: apps/v1kind: Deploymentmetadata: name: pgbouncer namespace: chaos-labspec: 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: v1kind: Servicemetadata: name: pgbouncer namespace: chaos-labspec: selector: app: pgbouncer ports: - port: 5432kubectl apply -f k8s/pgbouncer.yamlkubectl -n chaos-lab rollout status deployment/pgbouncer ## Point the app at the pooler and allow each pod a bigger client poolkubectl -n chaos-lab set env deployment/shop-api DB_HOST=pgbouncer DB_POOL_SIZE=20kubectl -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/menuRepeat 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 sizemust stay belowmax_connectionsminus 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 MistakeRaising
max_connectionson 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
./scripts/reset.shkubectl -n chaos-lab create deployment cost-hog --image=nginx:1.27 --replicas=30kubectl -n chaos-lab get pods | grep -c cost-hogIf 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
kubectl top nodeskubectl -n chaos-lab get deployment cost-hogkubectl 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:
count(kube_pod_container_info{namespace="chaos-lab"}) - count(kube_pod_container_resource_requests{namespace="chaos-lab", resource="cpu"})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
kubectl -n chaos-lab delete deployment cost-hogkubectl top nodesPrevent
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:
apiVersion: v1kind: LimitRangemetadata: name: default-limits namespace: chaos-labspec: limits: - type: Container defaultRequest: cpu: 50m memory: 64Mi default: cpu: 500m memory: 512Mi max: cpu: "2" memory: 4Gi---apiVersion: v1kind: ResourceQuotametadata: name: namespace-limits namespace: chaos-labspec: hard: pods: "25" requests.cpu: "2" requests.memory: 3Gi limits.memory: 8Gikubectl apply -f k8s/guardrails.yaml ## Prove the guardrail: repeat the injectionkubectl -n chaos-lab create deployment cost-hog --image=nginx:1.27 --replicas=30sleep 15kubectl -n chaos-lab get pods | grep -c cost-hogkubectl -n chaos-lab get events --sort-by=.lastTimestamp | grep -i "exceeded quota" | tail -3kubectl -n chaos-lab delete deployment cost-hogThe 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 MistakeAdding a ResourceQuota without a LimitRange. Every existing manifest that omits requests then fails to deploy with a confusing
must specify requestserror. 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
./scripts/reset.shkubectl apply -f - << 'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: deny-all-broken namespace: chaos-labspec: podSelector: {} policyTypes: - Ingress - EgressEOF kubectl -n chaos-lab rollout restart deployment/shop-apikubectl -n chaos-lab get pods --watchThe old pods are replaced by new ones that cannot resolve the database name, so they exit and crash-loop.
Detect
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 networkpolicykubectl -n chaos-lab describe networkpolicy deny-all-brokenThe 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
kubectl -n chaos-lab delete networkpolicy deny-all-brokenkubectl -n chaos-lab rollout restart deployment/shop-apikubectl -n chaos-lab rollout status deployment/shop-apiThen apply policies that are least-privilege but do not break the platform. Save as k8s/network-policies.yaml:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-dns-egress namespace: chaos-labspec: 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/v1kind: NetworkPolicymetadata: name: allow-same-namespace namespace: chaos-labspec: podSelector: {} policyTypes: - Ingress - Egress ingress: - from: - podSelector: {} egress: - to: - podSelector: {}---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-prometheus-scrape namespace: chaos-labspec: podSelector: matchLabels: app: shop-api policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: monitoring ports: - port: 8080kubectl apply -f k8s/network-policies.yamlkubectl -n chaos-lab rollout restart deployment/shop-apikubectl -n chaos-lab rollout status deployment/shop-api kubectl -n chaos-lab run dns-test --image=busybox:1.36 --restart=Never --rm -it -- nslookup postgresDNS 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 MistakeTesting 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:
apiVersion: monitoring.coreos.com/v1kind: PrometheusRulemetadata: name: shop-api-slos namespace: chaos-labspec: 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"kubectl apply -f k8s/slo-rules.yamlProve 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:
kubectl -n chaos-lab set env deployment/shop-api ERROR_RATE=0.5kubectl -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, restorekubectl -n chaos-lab set env deployment/shop-api ERROR_RATE=0kubectl -n chaos-lab delete pod load --ignore-not-foundWrite 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 MistakeSetting 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:
rate(container_cpu_cfs_throttled_seconds_total{namespace="chaos-lab"}[5m])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:
Title: Scenario N - short descriptionImpact: who or what was affected, and for how longTimeline: injected / detected / diagnosed / fixed (with times)Detection: the first signal and the command or graph that showed itRoot cause: the actual cause, not the symptomWhat went well:What was slow or confusing:Fix applied:Guardrail added: and how you proved it worksFollow-ups: one owner and one date eachTipIf 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.
./scripts/reset.sh ## 1. Baseline is healthy. Expected: 3/3 shop-api pods ready, 0 restarts climbingkubectl -n chaos-lab get pods ## 2. Startup validation is on. Expected: truekubectl -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 0kubectl -n chaos-lab get deployment shop-api \ -o jsonpath='{.spec.progressDeadlineSeconds} {.spec.strategy.rollingUpdate.maxUnavailable}' ## 4. Pooler is deployed. Expected: pgbouncer 1/1 availablekubectl -n chaos-lab get deployment pgbouncer ## 5. Cost guardrails exist. Expected: both objects listedkubectl -n chaos-lab get limitrange,resourcequota ## 6. Network policies exist and the deny-all is gone. Expected: three allow policies onlykubectl -n chaos-lab get networkpolicy ## 7. Rules are loaded in Prometheus. Expected: shop-api-slos and shop-api-oom groupskubectl -n monitoring port-forward svc/monitoring-kube-prometheus-prometheus 9090 &sleep 2curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[].name' | grep shop-apikill %1Finally, 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
kubectl delete namespace chaos-lab## Optional, if you installed it only for this projecthelm -n monitoring uninstall monitoringCommon MistakeLeaving 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.