Kubernetes Failover Testing: Simulating Node Failures and Pod Disruptions
Kubernetes is designed for resilience — but resilience requires testing. A pod that doesn't restart cleanly, a deployment that fails to roll over to healthy nodes, a service that routes traffic to terminating pods — these failures are silent until they happen at 3am during peak traffic.
This guide covers systematic Kubernetes failover testing: how to simulate failures at every layer, validate that your cluster responds correctly, and automate these tests before problems reach production.
Kubernetes Failure Modes
Understanding what can fail helps you design tests that cover the right scenarios:
| Layer | Failure | Impact |
|---|---|---|
| Pod | OOM kill, crash, liveness probe failure | Application restarts or traffic rerouted |
| Node | Hardware failure, kernel panic, cordon | Pods rescheduled to other nodes |
| Network | Packet loss, latency spike, partition | Service degradation |
| Storage | PV unavailable, slow disk | Stateful workloads fail |
| Control plane | API server unavailable | No new deployments; existing workloads continue |
| Availability zone | AZ outage | All nodes in AZ lost |
Test each layer. Don't assume Kubernetes handles failures because it's theoretically designed to — verify that it handles your specific workloads with your specific configuration.
Pod-Level Failure Testing
Validating Pod Restart Behavior
#!/bin/bash
# test_pod_restart.sh — Validate pods restart and traffic routes correctly
NAMESPACE="production"
DEPLOYMENT="api"
SERVICE_URL="http://api.internal/health"
RTO_SECONDS=30
echo "=== Pod Restart Failover Test ==="
START_TIME=$(date +%s)
# Record initial pod
INITIAL_PODS=$(kubectl get pods -n "$NAMESPACE" -l app="$DEPLOYMENT" \
-o jsonpath='{.items[*].metadata.name}')
echo "Initial pods: $INITIAL_PODS"
# Start continuous health monitoring
HEALTH_LOG=$(mktemp)
monitor_health() {
while true; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "$SERVICE_URL" || echo "000")
echo "$(date +%s) $STATUS" >> "$HEALTH_LOG"
sleep 0.5
done
}
monitor_health &
MONITOR_PID=$!
sleep 5
# Kill all pods (force delete simulates crash)
echo "Deleting all pods..."
KILL_TIME=$(date +%s)
kubectl delete pods -n "$NAMESPACE" -l app="$DEPLOYMENT" --grace-period=0 --force
# Wait for recovery
RECOVERED=false
while [ "$(date +%s)" -lt "$((KILL_TIME + RTO_SECONDS + 10))" ]; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "$SERVICE_URL" || echo "000")
if [ "$STATUS" = "200" ]; then
RECOVERY_TIME=$(date +%s)
ACTUAL_RTO=$((RECOVERY_TIME - KILL_TIME))
echo "Service recovered in ${ACTUAL_RTO}s"
RECOVERED=true
break
fi
sleep 1
done
kill "$MONITOR_PID" 2>/dev/null || true
# Analyze downtime
TOTAL_REQUESTS=$(wc -l < "$HEALTH_LOG")
FAILED=$(grep -c " 000\| 50[0-9]" "$HEALTH_LOG" || echo 0)
echo "Downtime requests: ${FAILED}/${TOTAL_REQUESTS}"
# Verify new pods are running
NEW_PODS=$(kubectl get pods -n "$NAMESPACE" -l app="$DEPLOYMENT" \
-o jsonpath='{.items[*].metadata.name}')
echo "New pods: $NEW_PODS"
# Validate pod count matches replica set
DESIRED=$(kubectl get deployment "$DEPLOYMENT" -n "$NAMESPACE" \
-o jsonpath='{.spec.replicas}')
RUNNING=$(kubectl get pods -n "$NAMESPACE" -l app="$DEPLOYMENT" \
--field-selector=status.phase=Running --no-headers | wc -l)
echo "Desired: $DESIRED | Running: $RUNNING"
[ "$RUNNING" = "$DESIRED" ] && echo "PASS: All replicas running" || echo "FAIL: Replica count mismatch"
if [ "$RECOVERED" = "false" ]; then
echo "FAIL: Service did not recover within $((RTO_SECONDS + 10))s"
exit 1
fi
[ "$ACTUAL_RTO" -le "$RTO_SECONDS" ] && echo "PASS: RTO met" || echo "WARN: RTO exceeded"
rm -f "$HEALTH_LOG"Testing Liveness and Readiness Probes
Probes are critical for failover — a pod that fails its liveness check gets restarted; one that fails readiness gets removed from load balancing. Test that they work:
#!/bin/bash
# test_probes.sh — Verify liveness and readiness probe behavior
NAMESPACE="production"
POD_NAME="api-7d9f4b8c6-xk9p2"
echo "=== Liveness Probe Test ==="
# Make the app fail its liveness check
kubectl exec -n "$NAMESPACE" "$POD_NAME" -- \
sh -c "touch /tmp/unhealthy" # If your app checks for this file
# Wait for Kubernetes to detect failure and restart
echo "Waiting for restart..."
RESTART_COUNT_BEFORE=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" \
-o jsonpath='{.status.containerStatuses[0].restartCount}')
sleep 60 # Longer than liveness probe failure threshold
RESTART_COUNT_AFTER=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" \
-o jsonpath='{.status.containerStatuses[0].restartCount}' 2>/dev/null || echo "pod replaced")
echo "Restart count before: $RESTART_COUNT_BEFORE"
echo "Restart count after: $RESTART_COUNT_AFTER"
echo ""
echo "=== Readiness Probe Test ==="
# Make app fail readiness (but stay alive)
kubectl exec -n "$NAMESPACE" "$POD_NAME" -- \
sh -c "touch /tmp/not_ready"
sleep 15
# Check pod is not receiving traffic (NotReady)
POD_READY=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')
echo "Pod ready status: $POD_READY"
[ "$POD_READY" = "False" ] && echo "PASS: Pod removed from load balancing" || echo "FAIL: Pod still receiving traffic"
# Verify other pods are healthy
kubectl get endpoints -n "$NAMESPACE" api-serviceNode-Level Failure Testing
Simulating Node Failure
#!/bin/bash
# test_node_failure.sh
NAMESPACE="production"
TARGET_NODE="k8s-worker-3"
SERVICE_URL="http://api.internal/health"
echo "=== Node Failure Test ==="
# Check current pod distribution
echo "Pod distribution before failure:"
kubectl get pods -n "$NAMESPACE" -o wide | grep -v "RESTARTS"
# Get pods on target node
PODS_ON_NODE=$(kubectl get pods -n "$NAMESPACE" --field-selector spec.nodeName="$TARGET_NODE" \
-o jsonpath='{.items[*].metadata.name}')
echo "Pods on $TARGET_NODE: $PODS_ON_NODE"
# Start health monitoring
HEALTH_LOG=$(mktemp)
monitor() {
while true; do
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "$SERVICE_URL" || echo "000")
echo "$(date +%s) $CODE" >> "$HEALTH_LOG"
sleep 0.5
done
}
monitor &
MONITOR_PID=$!
sleep 5
# Simulate node failure by cordoning and draining
echo "Simulating node failure: $TARGET_NODE"
FAILURE_TIME=$(date +%s)
# Option 1: Graceful (eviction with drain)
kubectl drain "$TARGET_NODE" --ignore-daemonsets --delete-emptydir-data --force
# Option 2: Abrupt (for hardware crash simulation)
# kubectl delete node "$TARGET_NODE"
# ssh "$TARGET_NODE" "sudo poweroff" # actual machine shutdown
# Wait for pods to reschedule
sleep 30
echo "Pod distribution after failure:"
kubectl get pods -n "$NAMESPACE" -o wide
# Stop monitoring
kill "$MONITOR_PID" 2>/dev/null || true
# Analyze
TOTAL=$(wc -l < "$HEALTH_LOG")
FAILED=$(grep -c " 000\| 50[0-9]" "$HEALTH_LOG" || echo 0)
echo "Error rate during node failure: ${FAILED}/${TOTAL} ($(( FAILED * 100 / TOTAL ))%)"
# Verify all pods are running on remaining nodes
DESIRED=$(kubectl get deployment api -n "$NAMESPACE" -o jsonpath='{.spec.replicas}')
RUNNING=$(kubectl get pods -n "$NAMESPACE" -l app=api --field-selector=status.phase=Running \
--no-headers | wc -l)
echo "Desired replicas: $DESIRED | Running: $RUNNING"
# Re-add node to cluster
kubectl uncordon "$TARGET_NODE"
rm -f "$HEALTH_LOG"Testing Pod Disruption Budgets
PodDisruptionBudgets (PDBs) ensure a minimum number of pods remain available during voluntary disruptions. Verify yours are configured and working:
# pdb.yaml — Example PDB
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-pdb
namespace: production
spec:
minAvailable: 2 # At least 2 pods must be available during disruptions
selector:
matchLabels:
app: api# Test PDB enforcement
kubectl get pdb -n production
# Try to drain a node when it would violate PDB
kubectl drain k8s-worker-2 --ignore-daemonsets --delete-emptydir-data
# Kubernetes should block or slow this down if PDB would be violated
# Expected output:
# error when evicting pods/"api-7d9f4b8c6-xk9p2" -n "production"
# (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.Network Failure Testing
Simulating Network Partition with tc
#!/bin/bash
# Simulate packet loss between services using tc (traffic control)
TARGET_POD="api-7d9f4b8c6-xk9p2"
NAMESPACE="production"
DURATION_SECONDS=60
PACKET_LOSS_PERCENT=50
echo "=== Network Partition Test: ${PACKET_LOSS_PERCENT}% packet loss for ${DURATION_SECONDS}s ==="
# Inject packet loss into target pod's network
kubectl exec -n "$NAMESPACE" "$TARGET_POD" -- \
tc qdisc add dev eth0 root netem loss "${PACKET_LOSS_PERCENT}%"
echo "Packet loss injected. Testing service behavior..."
# Test application behavior during network degradation
for i in {1..20}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://api.internal/endpoint" || echo "000")
echo "Request $i: $STATUS"
sleep 3
done
# Remove packet loss after test duration
sleep "$DURATION_SECONDS"
kubectl exec -n "$NAMESPACE" "$TARGET_POD" -- \
tc qdisc del dev eth0 root
echo "Network partition removed"Using Chaos Mesh for Network Chaos
Chaos Mesh provides Kubernetes-native chaos injection:
# network-chaos.yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
name: api-network-delay
namespace: production
spec:
action: delay
mode: one
selector:
namespaces:
- production
labelSelectors:
app: api
delay:
latency: "100ms"
correlation: "25"
jitter: "50ms"
duration: "5m"
direction: both# Apply and monitor
kubectl apply -f network-chaos.yaml
# Check application response times during chaos
hey -z 5m -c 10 http://api.internal/endpoint
# Cleanup
kubectl delete networkchaos api-network-delay -n productionAvailability Zone Failure Simulation
Test that your multi-AZ deployment survives losing an entire AZ:
#!/bin/bash
# test_az_failure.sh — Simulate AZ outage
FAILED_AZ="us-east-1b"
NAMESPACE="production"
echo "=== AZ Failure Test: Simulating loss of $FAILED_AZ ==="
# Get all nodes in the target AZ
NODES_IN_AZ=$(kubectl get nodes \
-l "topology.kubernetes.io/zone=$FAILED_AZ" \
-o jsonpath='{.items[*].metadata.name}')
echo "Nodes in $FAILED_AZ: $NODES_IN_AZ"
# Count pods that will be affected
AFFECTED_PODS=$(kubectl get pods -n "$NAMESPACE" -o wide | grep -E "$NODES_IN_AZ" | wc -l)
echo "Pods to be evacuated: $AFFECTED_PODS"
# Start load testing
hey -z 5m -c 50 http://api.internal/endpoint &
HEY_PID=$!
# Simulate AZ failure: cordon and drain all nodes in AZ
FAILURE_START=$(date +%s)
for NODE in $NODES_IN_AZ; do
kubectl cordon "$NODE"
done
for NODE in $NODES_IN_AZ; do
kubectl drain "$NODE" --ignore-daemonsets --delete-emptydir-data --force --timeout=120s
done
DRAIN_END=$(date +%s)
echo "Drain completed in $((DRAIN_END - FAILURE_START))s"
# Wait for pods to settle
sleep 30
# Verify all pods are running on remaining AZs
echo "Pod distribution after AZ failure:"
kubectl get pods -n "$NAMESPACE" -o wide
# Check that remaining nodes are across multiple AZs
kubectl get nodes -l "topology.kubernetes.io/zone" \
--label-columns topology.kubernetes.io/zone
# Wait for load test to finish
wait "$HEY_PID"
echo "Restoring nodes..."
for NODE in $NODES_IN_AZ; do
kubectl uncordon "$NODE"
doneAutomating Failover Tests
Run failover tests on a schedule in a staging cluster:
# CronJob for weekly failover testing
apiVersion: batch/v1
kind: CronJob
metadata:
name: failover-test-weekly
namespace: test-automation
spec:
schedule: "0 4 * * 0" # Sundays at 4am
jobTemplate:
spec:
template:
spec:
serviceAccountName: failover-test-sa
containers:
- name: failover-test
image: company/failover-test:latest
command: ["python", "failover_tests.py"]
env:
- name: TARGET_NAMESPACE
value: "staging"
- name: NOTIFY_SLACK
valueFrom:
secretKeyRef:
name: slack-webhook
key: url
restartPolicy: Never# failover_tests.py — Orchestrate all failover tests
import asyncio
import json
import os
from datetime import datetime
async def run_all_tests() -> dict:
results = {
'run_at': datetime.utcnow().isoformat(),
'tests': {}
}
# Pod restart test
from tests.pod_restart import test_pod_restart
results['tests']['pod_restart'] = await test_pod_restart(
namespace=os.environ['TARGET_NAMESPACE'],
deployment='api',
rto_seconds=30
)
# Node failure test (one node)
from tests.node_failure import test_node_failure
results['tests']['node_failure'] = await test_node_failure(
namespace=os.environ['TARGET_NAMESPACE'],
rto_seconds=120
)
# Network chaos test
from tests.network_chaos import test_network_latency
results['tests']['network_chaos'] = await test_network_latency(
namespace=os.environ['TARGET_NAMESPACE'],
latency_ms=100,
duration_seconds=60
)
# Calculate overall pass/fail
results['passed'] = all(
t.get('rto_met', False)
for t in results['tests'].values()
)
# Notify
await notify_slack(results, os.environ['NOTIFY_SLACK'])
return results
if __name__ == '__main__':
results = asyncio.run(run_all_tests())
print(json.dumps(results, indent=2))
exit(0 if results['passed'] else 1)What Good Kubernetes Failover Looks Like
Pod failure (single pod in multi-replica deployment):
- Detection: < 5 seconds (health check interval)
- Pod removed from LB: < 5 seconds
- New pod started: < 30 seconds
- Zero requests to failed pod after removal
Node failure (one of N nodes):
- Pod eviction: immediate (Kubernetes marks node NotReady after 40s by default)
- Pod rescheduling: 2-5 minutes
- Service continuity: depends on PDB and replica count
- Full recovery: 5-10 minutes
AZ failure (1 of 3 AZs):
- Traffic automatically routes to remaining AZs (if multi-AZ service configured)
- Pod rescheduling: 5-15 minutes
- Full capacity on remaining AZs: 10-20 minutes
If your actual numbers are significantly worse than these, investigate:
- Image pull time (use pre-pulled images or image pull policy optimization)
- Startup probe vs readiness probe (use startup probes for slow-starting apps)
- PDB configuration (too strict PDBs slow down node draining)
- Node affinity rules (may prevent rescheduling if no suitable nodes remain)
Summary
Kubernetes failover testing covers four layers:
- Pod failures — liveness/readiness probes, restart policies
- Node failures — pod rescheduling, PodDisruptionBudgets
- Network failures — packet loss, latency, partition behavior
- AZ failures — multi-AZ coverage, cross-zone traffic routing
Automate what you can. Pod restart tests should run weekly in staging. Node failure tests should run monthly. AZ failure simulation should run quarterly. Each test result should be captured, compared to your RTO/RPO targets, and trigger alerts when thresholds are missed.
Kubernetes resilience is not a feature you enable — it's a property you verify through testing.