Chaos Engineering — Hands-On Exercises
Runnable exercises that build on chaos-engineering.md. Requires a working K8s cluster (kind/minikube/EKS) with kubectl and helm configured. Each exercise is self-contained: prerequisites → commands → expected output → pass/fail bar → cleanup.
Exercise 1 — Litmus Chaos: Pod Delete on nginx Deployment
Tests: does the Deployment controller reschedule a killed pod fast enough to avoid a service-level outage.
Prerequisites
kubectl create namespace litmus
kubectl create namespace chaos-target
# Install Litmus Chaos operator via Helm
helm repo add litmuschaos https://litmuschaos.github.io/litmus-helm/
helm repo update
helm install litmus litmuschaos/litmus --namespace litmus
# Verify operator is running
kubectl get pods -n litmus
# litmus-litmus-frontend-... Running
# litmus-litmus-server-... Running
# litmus-litmus-chaos-operator-... Running
# Deploy target: nginx with 3 replicas + a Service
kubectl create deployment nginx --image=nginx:1.25 --replicas=3 -n chaos-target
kubectl expose deployment nginx --port=80 -n chaos-target
kubectl get pods -n chaos-target -o wide
Step 1 — Install the pod-delete experiment CR
kubectl apply -f https://hub.litmuschaos.io/api/chaos/3.5.0?file=charts/generic/pod-delete/experiment.yaml -n chaos-target
# Create the service account + RBAC Litmus needs to act in this namespace
kubectl apply -f https://hub.litmuschaos.io/api/chaos/3.5.0?file=charts/generic/rbac.yaml -n chaos-target
Step 2 — ChaosEngine YAML
# pod-delete-engine.yaml
apiVersion: litmuschaos.io/v1alpha1
kind: ChaosEngine
metadata:
name: nginx-pod-delete
namespace: chaos-target
spec:
appinfo:
appns: chaos-target
applabel: "app=nginx"
appkind: deployment
engineState: active
chaosServiceAccount: litmus-admin
annotationCheck: "false"
jobCleanUpPolicy: retain # keep pods around for post-run inspection
experiments:
- name: pod-delete
spec:
components:
env:
- name: TOTAL_CHAOS_DURATION
value: "30" # seconds
- name: CHAOS_INTERVAL
value: "10" # kill a pod every 10s
- name: FORCE
value: "false" # graceful delete, not SIGKILL
- name: PODS_AFFECTED_PERC
value: "33" # ~1 of 3 replicas per kill cycle
kubectl apply -f pod-delete-engine.yaml
Step 3 — Observe in real time
# Terminal 1: watch pods getting killed and rescheduled
kubectl get pods -n chaos-target -w
# Terminal 2: watch the chaos runner + experiment pod logs
kubectl get pods -n chaos-target -l name=nginx-pod-delete-runner
kubectl logs -n chaos-target -l name=nginx-pod-delete -f
# Terminal 3: hit the service continuously to check for dropped requests
kubectl run loadgen --rm -it --image=busybox -n chaos-target -- \
sh -c 'while true; do wget -q -O- http://nginx 2>&1 | head -c1; sleep 0.5; done'
Step 4 — Check the result
kubectl get chaosresult nginx-pod-delete-pod-delete -n chaos-target -o yaml
Expected output (trimmed):
status:
experimentStatus:
phase: Completed
verdict: Pass
probeSuccessPercentage: "100"
Pass vs Fail
| Signal | Pass | Fail |
|---|---|---|
verdict field |
Pass |
Fail or Awaited (stuck) |
| Service availability during test | 0% dropped requests in loadgen loop | Any wget timeout/refused |
| Pod count | Returns to 3/3 Ready within CHAOS_INTERVAL |
Pods stuck Pending/CrashLoopBackOff |
| Replica recovery time | < 5s per killed pod | > 15s (image pull, scheduling delay) |
Cleanup
kubectl delete chaosengine nginx-pod-delete -n chaos-target
kubectl delete chaosresult nginx-pod-delete-pod-delete -n chaos-target
kubectl delete deployment nginx -n chaos-target
kubectl delete service nginx -n chaos-target
kubectl delete namespace chaos-target litmus
Exercise 2 — Chaos Mesh: Network Partition (Service → Database)
Tests: whether the app's DB client handles connection loss gracefully (retries/circuit breaker) instead of cascading into a full outage.
Prerequisites
# Install Chaos Mesh (kind/minikube: use --set chaosDaemon.runtime=containerd as needed)
curl -sSL https://mirrors.chaos-mesh.org/v2.6.3/install.sh | bash
kubectl get pods -n chaos-mesh
# chaos-controller-manager-... Running
# chaos-daemon-... Running (one per node)
# chaos-dashboard-... Running
# Deploy a target app + a fake "database" pod, both labeled for selection
kubectl create namespace demo
kubectl run app --image=nginx:1.25 -n demo --labels="role=app"
kubectl run db --image=nginx:1.25 -n demo --labels="role=db"
kubectl expose pod db --port=80 --name=db-svc -n demo
Step 1 — Confirm baseline connectivity
kubectl exec -n demo app -- curl -s -o /dev/null -w "%{http_code}\n" http://db-svc
# 200
Step 2 — NetworkChaos YAML (partition app from db)
# network-partition.yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
name: app-db-partition
namespace: demo
spec:
action: partition
mode: all
selector:
namespaces: [demo]
labelSelectors:
role: app
direction: to
target:
mode: all
selector:
namespaces: [demo]
labelSelectors:
role: db
duration: "60s"
kubectl apply -f network-partition.yaml
Step 3 — Verify the partition takes effect
# From the app pod, requests to db-svc should now fail/timeout
kubectl exec -n demo app -- curl -s -m 3 -o /dev/null -w "%{http_code}\n" http://db-svc
# curl: (28) Connection timed out after 3000 milliseconds -> exit code 28, no HTTP code printed
# Confirm the chaos object is actively injected
kubectl get networkchaos app-db-partition -n demo -o jsonpath='{.status.conditions}'
Step 4 — Wait for auto-recovery and verify restoration
sleep 65 # duration was 60s
kubectl exec -n demo app -- curl -s -o /dev/null -w "%{http_code}\n" http://db-svc
# 200 -> connectivity restored automatically once the NetworkChaos duration expires
Pass vs Fail
| Signal | Pass | Fail |
|---|---|---|
| During partition | App returns cached data / circuit-breaker error (e.g. 503 with clear message) | App hangs indefinitely or crashes (panic, OOM from retry storm) |
| Retry behavior | Bounded retries with backoff visible in app logs | Unbounded retry loop hammering the network daemon |
| After partition ends | Connectivity restored within 1 poll interval, no manual restart needed | App pod stuck in a broken state requiring restart |
| curl exit code during test | 28 (timeout) — expected/injected |
0 (200 OK) — chaos didn't actually apply, check selectors |
Cleanup
kubectl delete networkchaos app-db-partition -n demo
kubectl delete pod app db -n demo
kubectl delete service db-svc -n demo
kubectl delete namespace demo
# Optional full removal of Chaos Mesh itself:
curl -sSL https://mirrors.chaos-mesh.org/v2.6.3/install.sh | bash -s -- --template | kubectl delete -f -
Exercise 3 — Chaos Mesh: CPU Stress to Validate HPA Scaling
Tests: whether HPA detects CPU pressure and scales out within its polling interval, and scales back in once load stops.
Prerequisites
kubectl create namespace hpa-demo
# metrics-server must be running for HPA to read CPU% (kind users: install with --kubelet-insecure-tls)
kubectl get deployment metrics-server -n kube-system
# Deploy a CPU-bound target with requests/limits set (HPA needs `requests` to compute %)
kubectl create deployment stress-app --image=vish/stress -n hpa-demo -- -cpus 1
kubectl set resources deployment stress-app -n hpa-demo \
--requests=cpu=100m,memory=64Mi --limits=cpu=500m,memory=128Mi
kubectl autoscale deployment stress-app -n hpa-demo \
--cpu-percent=50 --min=1 --max=5
kubectl get hpa -n hpa-demo -w
Step 1 — Baseline: confirm 1 replica, low CPU%
kubectl get hpa stress-app -n hpa-demo
# NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
# stress-app Deployment/stress-app 3%/50% 1 5 1
Step 2 — PodChaos CPU stress YAML
# cpu-stress.yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos
metadata:
name: stress-app-cpu
namespace: hpa-demo
spec:
action: pod-kill # placeholder — actual stressor uses StressChaos, see below
Note: CPU load injection uses the
StressChaoskind, notPodChaos— use this instead:
# cpu-stress.yaml (correct kind)
apiVersion: chaos-mesh.org/v1alpha1
kind: StressChaos
metadata:
name: stress-app-cpu
namespace: hpa-demo
spec:
mode: all
selector:
namespaces: [hpa-demo]
labelSelectors:
app: stress-app
stressors:
cpu:
workers: 2
load: 100 # % load per worker
duration: "180s"
kubectl apply -f cpu-stress.yaml
Step 3 — Watch HPA react in real time
# Terminal 1
kubectl get hpa stress-app -n hpa-demo -w
# Terminal 2 — raw metrics feed
watch -n 5 'kubectl top pods -n hpa-demo'
# Terminal 3 — scale events as they happen
kubectl get events -n hpa-demo --field-selector reason=SuccessfulRescale -w
Expected progression (default HPA sync period ~15s, scale-up is fast, scale-down has a 5-min stabilization window by default):
TARGETS REPLICAS
3%/50% 1
94%/50% 1 <- stress starts, CPU spikes immediately
94%/50% 2 <- HPA reacts within ~15-30s
91%/50% 4 <- continues scaling toward max
88%/50% 5 <- hits max=5, holds
[stress ends at 180s]
22%/50% 5 <- CPU drops but replicas held (stabilization window)
22%/50% 1 <- scales back down after ~5 min
Pass vs Fail
| Signal | Pass | Fail |
|---|---|---|
| Scale-up latency | New replicas Running within ~30-60s of CPU% exceeding target | No scale-up after 2+ minutes — check metrics-server, resources.requests set |
| Max replicas respected | Caps at --max=5, never exceeds |
Unbounded scaling (misconfigured HPA) |
| Scale-down | Returns toward min=1 after stabilization window post-stress |
Stuck at max replicas indefinitely — check behavior.scaleDown config |
kubectl top pods during stress |
Each pod near its CPU limit (500m) | Pods show near-zero CPU — stressor not actually applied, check selector labels |
Cleanup
kubectl delete stresschaos stress-app-cpu -n hpa-demo
kubectl delete hpa stress-app -n hpa-demo
kubectl delete deployment stress-app -n hpa-demo
kubectl delete namespace hpa-demo
Exercise 4 — Game Day: Simulated Full AZ Failure (EKS)
Tests: organizational readiness — pod rescheduling speed, PDB enforcement, and whether the service stays available while an entire AZ's worth of nodes goes away. This is a manual, scripted game day, not a CRD-driven fault injection.
Prerequisites
# Identify node-to-AZ mapping
kubectl get nodes -L topology.kubernetes.io/zone
# Example output:
# NAME STATUS ZONE
# ip-10-0-1-23.ec2.internal Ready us-east-1a
# ip-10-0-2-45.ec2.internal Ready us-east-1b
# ip-10-0-3-67.ec2.internal Ready us-east-1c
# Deploy a realistic multi-AZ app with a PodDisruptionBudget and anti-affinity
kubectl create namespace gameday
# app-with-pdb.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: gameday
spec:
replicas: 6
selector:
matchLabels: {app: web}
template:
metadata:
labels: {app: web}
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels: {app: web}
topologyKey: topology.kubernetes.io/zone
containers:
- name: web
image: nginx:1.25
resources:
requests: {cpu: 100m, memory: 64Mi}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
namespace: gameday
spec:
minAvailable: 4 # tolerate losing at most 2 of 6 at once
selector:
matchLabels: {app: web}
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: gameday
spec:
selector: {app: web}
ports:
- port: 80
kubectl apply -f app-with-pdb.yaml
kubectl get pods -n gameday -o wide -L topology.kubernetes.io/zone
Step 1 — Establish steady state and start continuous availability probe
# Terminal 1: hit the service every 500ms, log failures with timestamps
kubectl run loadgen --rm -it --image=busybox -n gameday -- sh -c \
'while true; do
code=$(wget -q -O- --timeout=2 http://web 2>&1 >/dev/null; echo $?)
echo "$(date +%T) exit=$code"
sleep 0.5
done'
Step 2 — Pick target AZ and cordon its nodes (stop new scheduling)
TARGET_ZONE=us-east-1a
for node in $(kubectl get nodes -l topology.kubernetes.io/zone=$TARGET_ZONE -o name); do
kubectl cordon "$node"
done
kubectl get nodes -L topology.kubernetes.io/zone
# Confirm SchedulingDisabled on the targeted AZ's nodes only
Step 3 — Drain nodes in that AZ (simulates AZ becoming unreachable)
for node in $(kubectl get nodes -l topology.kubernetes.io/zone=$TARGET_ZONE -o name); do
kubectl drain "$node" \
--ignore-daemonsets \
--delete-emptydir-data \
--timeout=120s
done
Step 4 — Observe during the drain (run these while drain is in progress)
# Watch pod rescheduling live
kubectl get pods -n gameday -o wide -w
# Watch PDB — eviction should be blocked once minAvailable would be violated
kubectl get pdb web-pdb -n gameday -w
# Watch drain-triggered events for eviction blocks
kubectl get events -n gameday --field-selector reason=FailedEviction -w
# Time to full rescheduling
date; kubectl get pods -n gameday -o wide
Checklist — what to observe
| Item | What "good" looks like |
|---|---|
| Pod rescheduling time | Evicted pods reach Running in another AZ within ~30-60s (image already cached; longer if pulling fresh) |
| PDB behavior | kubectl drain pauses/retries evictions once minAvailable: 4 would be breached — drain should NOT force through and violate the budget |
| Service availability | loadgen shows zero or near-zero failed requests throughout — anti-affinity + PDB + surplus replicas absorb the loss |
| Node status | Drained nodes show Ready,SchedulingDisabled, zero non-DaemonSet pods remaining |
| Pod distribution post-drain | Remaining 6 pods redistributed only across the 2 healthy AZs |
| kube-scheduler decisions | kubectl describe pod <new-pod> shows scheduling reason avoiding the cordoned zone |
Pass vs Fail
| Signal | Pass | Fail |
|---|---|---|
| Availability during drain | 0 failed requests, or brief sub-second blip only | Sustained failures / 5xx for more than one probe interval |
| PDB enforcement | Drain respects minAvailable, throttles eviction pace |
Drain forces evictions below minAvailable (PDB misconfigured or --disable-eviction used) |
| Recovery | All 6 replicas Running and Ready in remaining AZs within a few minutes |
Pods stuck Pending — insufficient capacity in remaining AZs (a real finding, not a test failure) |
| Node state | Cleanly cordoned+drained, no stuck pods needing --force |
Pods stuck due to local storage / missing PDB tolerance requiring --force (data loss risk) |
Cleanup / Rollback
# Uncordon the AZ's nodes to restore scheduling (does NOT auto-move pods back)
for node in $(kubectl get nodes -l topology.kubernetes.io/zone=$TARGET_ZONE -o name); do
kubectl uncordon "$node"
done
# Optional: rebalance pods back across all AZs now that nodes are schedulable
kubectl rollout restart deployment web -n gameday
# Teardown
kubectl delete namespace gameday
Rollback if something goes wrong mid-drain: kubectl uncordon immediately re-enables scheduling on the target nodes; already-evicted pods will not automatically move back, but new pods can land there again. If nodes were terminated (real AZ failure test on EKS via ASG desired-count changes rather than drain), scale the ASG back to its original desired count instead.
Chaos Engineering Maturity Checklist (Crawl / Walk / Run)
Use this to assess where a team actually is — most teams overestimate their stage.
Crawl (getting started)
- Chaos experiments run only in staging/dev, never prod
- Steady-state metrics (error rate, p99 latency) are defined and dashboarded before any experiment
- Single blast radius: one pod, one deployment — never namespace-wide
- Every experiment has a documented rollback command copy-pasted and tested beforehand
- Experiments run during business hours with a human watching, never unattended
- Post-experiment write-up for every run, even successful ones (what was learned)
- Tooling installed (Litmus/Chaos Mesh) but no automation — everything triggered manually
Walk (building confidence)
- Chaos experiments run in production, but only for services with a defined owner and on-call
- Blast radius expanded to a full deployment/service, still within one namespace
- Experiments run on a schedule (e.g., weekly game day) rather than ad hoc
- Steady-state checks are automated as pre/post probes in the ChaosEngine itself, not eyeballed
- PDBs, resource limits, and HPA exist and are exercised as part of experiments (not just pod-kill)
- Findings feed into a tracked backlog (tickets), not just a doc nobody reopens
- Game days include cross-team participants (not just the platform team)
- Alerting is validated as part of the experiment — if a fault doesn't page anyone, that's a finding
Run (mature practice)
- Chaos experiments are triggered automatically in CI/CD against staging before promoting a release
- Continuous, low-grade chaos runs in production in the background (e.g., Netflix-style random pod termination)
- Multi-fault experiments combine failures (network partition + CPU stress simultaneously) to test compounding failure modes
- AZ/region-level failure is tested on a regular cadence, not just once
- Auto-remediation exists for the most common findings (see sre/self-healing-aiops.md) and is itself chaos-tested
- Chaos experiment results feed SLO error-budget tracking directly
- New services must pass a defined chaos test suite before being marked production-ready ("chaos gate" in the release process)
- Game days simulate realistic multi-service cascading failures, not single-component faults
Rule of thumb progression: don't move to the next stage until every unchecked box in the current one is checked and stable for at least a full quarter. Skipping straight to "Run" without Crawl/Walk discipline is how chaos engineering causes the outage it was meant to prevent.