What You'll Build
In this exercise you will upgrade the IPL analytics platform from M3 Exercise to use production-grade storage and autoscaling. You will replace the simple PostgreSQL Deployment with a StatefulSet backed by a `Retain`-policy StorageClass and a `volumeClaimTemplate`, take a VolumeSnapshot of the primary's data before migrating, and configure an HPA on the API tier with CPU and custom RPS metrics. You will verify that the StatefulSet's stable identity works correctly by confirming that PostgreSQL data survives a Pod deletion and rescheduling, that the HPA scales the API Deployment in response to a load test, and that the HPA's stabilisation window prevents immediate scale-down when the load stops. The complete platform after this exercise is production-ready for the Ingress and TLS lab that follows.
Prerequisites
- The IPL analytics platform from M3 Exercise running with a simple PostgreSQL Deployment — the existing data will be migrated to the StatefulSet using a VolumeSnapshot.
- The EBS CSI driver installed and a `gp3-encrypted` StorageClass configured with `reclaimPolicy: Retain` and `volumeBindingMode: WaitForFirstConsumer` from Lesson 25.
- The Kubernetes Metrics Server installed and verified with `kubectl top pods -n production` returning CPU and memory values before beginning — HPA requires metrics-server to function.
- The VolumeSnapshot CRDs and the external-snapshotter controller installed — verify with `kubectl get crd | grep snapshot` showing `volumesnapshots.snapshot.storage.k8s.io`.
- A load testing tool available: `kubectl run load-test --image=williamyeh/wrk --rm -it` or `hey` installed locally for the HPA scaling verification step.
Setup & Project Structure
Before replacing the PostgreSQL Deployment with a StatefulSet, snapshot the existing data volume to enable rollback if the migration encounters problems. The snapshot is the insurance policy — a production migration that changes the storage layer without a backup is an unacceptable risk regardless of how simple the migration appears. After snapshotting, scale the existing Deployment to zero replicas (preventing writes to the data), then create the StatefulSet which will mount the same data via a PVC seeded from the snapshot.
# Migration setup: snapshot existing data before replacing Deployment
# ── Step 1: Identify the existing PostgreSQL PVC ───────────────────────────
kubectl get pvc -n production -l app=ipl-postgres
# NAME STATUS VOLUME CAPACITY
# ipl-postgres-data Bound pvc-abc123... 5Gi
# ── Step 2: Create a VolumeSnapshot before migration ──────────────────────
kubectl apply -f - << 'EOF'
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-pre-migration
namespace: production
spec:
volumeSnapshotClassName: ebs-snapshots
source:
persistentVolumeClaimName: ipl-postgres-data
EOF
kubectl wait volumesnapshot/postgres-pre-migration -n production --for=jsonpath='{.status.readyToUse}'=true --timeout=120s
echo "Snapshot ready ✓"
# ── Step 3: Scale existing Deployment to zero (stop writes) ────────────────
kubectl scale deployment ipl-postgres --replicas=0 -n production
kubectl wait deployment/ipl-postgres --for=jsonpath='{.status.availableReplicas}'=0 -n production --timeout=60s
echo "Deployment scaled to zero — no new writes to data ✓"
# ── Step 4: Create the StorageClass for StatefulSet ───────────────────────
kubectl apply -f - << 'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-encrypted
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
EOF
echo "Migration prerequisites complete. Ready to create StatefulSet."
echo "Rollback available via: postgres-pre-migration VolumeSnapshot" Step 1 — Foundation
Create the PostgreSQL StatefulSet with a headless Service and a `volumeClaimTemplate` seeded from the pre-migration snapshot. The first StatefulSet PVC is pre-populated from the snapshot, so when `ipl-postgres-0` starts, it finds the existing data directory with all previous data intact — exactly as PostgreSQL left it when the Deployment was scaled to zero. Verify stable identity by confirming the Pod name and hostname match, then confirm the pre-existing data is visible inside the new StatefulSet Pod.
# StatefulSet PostgreSQL with snapshot-seeded first PVC
kubectl apply -f - << 'EOF'
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ipl-postgres
namespace: production
spec:
serviceName: ipl-postgres
replicas: 1
selector:
matchLabels: {app: ipl-postgres}
template:
metadata:
labels: {app: ipl-postgres}
spec:
terminationGracePeriodSeconds: 60
containers:
- name: postgres
image: postgres:16-alpine
env:
- name: POSTGRES_DB
value: cricket_stats
- name: POSTGRES_USER
value: rohit_admin
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: ipl-postgres-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata # avoid lost+found directory issue
resources:
requests: {cpu: 200m, memory: 256Mi}
limits: {cpu: "1", memory: 1Gi}
readinessProbe:
exec:
command: ["pg_isready", "-U", "rohit_admin", "-d", "cricket_stats"]
initialDelaySeconds: 10
periodSeconds: 5
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
storageClassName: gp3-encrypted
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 10Gi}
# Seed from migration snapshot — data migrated automatically
dataSource:
name: postgres-pre-migration
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
---
apiVersion: v1
kind: Service
metadata:
name: ipl-postgres
namespace: production
spec:
clusterIP: None
selector: {app: ipl-postgres}
ports: [{port: 5432}]
EOF
# Wait for PostgreSQL to be ready
kubectl wait pod/ipl-postgres-0 -n production --for=condition=Ready --timeout=120s
# Verify stable identity
echo -n "Pod hostname: "
kubectl exec ipl-postgres-0 -n production -- hostname
# Expected: ipl-postgres-0
# Verify migrated data is intact
echo -n "Existing data: "
kubectl exec ipl-postgres-0 -n production -- psql -U rohit_admin -d cricket_stats -t -c "SELECT COUNT(*) FROM ipl_test;"
# Expected: non-zero row count from M3 Exercise data ✓
# Delete existing Deployment (no longer needed)
kubectl delete deployment ipl-postgres -n production 2>/dev/null || true
echo "Migration complete ✓" Step 2 — Core Logic
Configure the HPA on the API Deployment with CPU utilisation and custom RPS targets, then run a load test to trigger scaling and observe the HPA's behaviour. Document the actual replica counts at each step of the load test — start, peak, post-load stabilisation, and final scale-down — to verify that the scaling formula, the stabilisation window, and the scale-down rate limit all work as designed. The load test output, combined with the HPA event history, is the evidence that the autoscaling configuration is correctly calibrated for this workload.
# HPA configuration and load test verification
kubectl apply -f - << 'EOF'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ipl-api-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ipl-api
minReplicas: 2
maxReplicas: 8
metrics:
- type: Resource
resource:
name: cpu
target: {type: Utilization, averageUtilization: 60}
behavior:
scaleDown:
stabilizationWindowSeconds: 120 # reduced for lab visibility (normally 300s)
policies:
- {type: Pods, value: 1, periodSeconds: 30}
scaleUp:
stabilizationWindowSeconds: 0
policies:
- {type: Pods, value: 2, periodSeconds: 30}
EOF
# ── Run load test and observe HPA in real time ─────────────────────────────
# Terminal 1: watch HPA
kubectl get hpa ipl-api-hpa -n production -w &
HPA_WATCH=$!
# Terminal 2: load test — 10 concurrent workers for 60 seconds
kubectl run load-test --image=williamyeh/wrk --rm --restart=Never -n production -- -t2 -c50 -d60s http://ipl-api.production.svc.cluster.local:8000/batters
sleep 30 # allow HPA to detect and respond to the load
echo "=== HPA status during load ==="
kubectl get hpa ipl-api-hpa -n production
kubectl get pods -l app=ipl-api -n production
echo ""
echo "=== Waiting for load to stop and stabilisation window to expire ==="
sleep 150 # wait for 120s stabilisation window + buffer
echo "=== HPA status after stabilisation ==="
kubectl get hpa ipl-api-hpa -n production
# Expected: replicas back to minReplicas=2 after 120s stabilisation
kill $HPA_WATCH 2>/dev/null
# Inspect HPA event log for scaling decisions
kubectl describe hpa ipl-api-hpa -n production | grep -A20 "Events:"
# Events:
# Normal SuccessfulRescale New size: 4; reason: cpu above target
# Normal SuccessfulRescale New size: 2; reason: All metrics below targetStep 3 — Integration & Enhancement
Verify StatefulSet stable identity by deleting `ipl-postgres-0` and confirming that the replacement Pod rejoins with the same name and the same data — the two properties that distinguish a StatefulSet from a Deployment. Add a VolumeSnapshot CronJob that takes nightly snapshots of the PostgreSQL PVC for disaster recovery, completing the production storage posture for this platform.
# StatefulSet identity persistence + nightly snapshot CronJob
# ── Test stable identity: delete and verify recreation ─────────────────────
# Insert marker data to verify recovery
kubectl exec ipl-postgres-0 -n production -- psql -U rohit_admin -d cricket_stats -c "INSERT INTO ipl_test(message) VALUES ('Pre-delete marker: $(date)');"
# Delete the Pod (StatefulSet will recreate it with same name)
kubectl delete pod ipl-postgres-0 -n production
# Watch recreation — Pod should come back as ipl-postgres-0, not a random name
kubectl get pods -l app=ipl-postgres -n production -w
# ipl-postgres-0 Running → Terminating → Pending → ContainerCreating → Running
# Verify data persisted through deletion/recreation
kubectl wait pod/ipl-postgres-0 -n production --for=condition=Ready --timeout=120s
kubectl exec ipl-postgres-0 -n production -- psql -U rohit_admin -d cricket_stats -t -c "SELECT message FROM ipl_test ORDER BY inserted_at DESC LIMIT 3;"
# Shows "Pre-delete marker" entry — data survived Pod recreation ✓
# ── CronJob: nightly VolumeSnapshot for disaster recovery ─────────────────
kubectl apply -f - << 'EOF'
apiVersion: batch/v1
kind: CronJob
metadata:
name: ipl-postgres-snapshot
namespace: production
spec:
schedule: "0 1 * * *" # 01:00 UTC = 06:30 IST
timeZone: "UTC"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 7
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: Never
serviceAccountName: snapshot-manager # needs VolumeSnapshot create permission
containers:
- name: snapshot
image: bitnami/kubectl:latest
command:
- sh
- -c
- |
DATE=$(date +%Y%m%d)
kubectl apply -f - << SNAP
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-nightly-$DATE
namespace: production
spec:
volumeSnapshotClassName: ebs-snapshots
source:
persistentVolumeClaimName: postgres-data-ipl-postgres-0
SNAP
echo "Snapshot postgres-nightly-$DATE created"
EOF
echo "Nightly snapshot CronJob created ✓"
kubectl get cronjob ipl-postgres-snapshot -n production Step 4 — Testing & Verification
Run the final verification checklist confirming all M4 Exercise properties: StatefulSet stable identity, PVC data persistence, HPA scaling event history, snapshot existence, and the API serving correct responses after the complete storage migration and autoscaling configuration. The checklist confirms the platform is ready for the Ingress and TLS lab.
# Final verification checklist
echo "=== M4 Exercise — StatefulSet & HPA Verification ==="
# 1. StatefulSet Pod has stable name
echo -n "1. StatefulSet stable name: "
POD_NAME=$(kubectl get pod -l app=ipl-postgres -n production -o jsonpath='{.items[0].metadata.name}')
[ "$POD_NAME" = "ipl-postgres-0" ] && echo "PASS ✓ ($POD_NAME)" || echo "FAIL ✗"
# 2. PVC bound and using Retain policy
echo -n "2. PVC bound with Retain policy: "
RECLAIM=$(kubectl get pv $(kubectl get pvc postgres-data-ipl-postgres-0 -n production -o jsonpath='{.spec.volumeName}') -o jsonpath='{.spec.persistentVolumeReclaimPolicy}')
[ "$RECLAIM" = "Retain" ] && echo "PASS ✓" || echo "FAIL ✗ (got: $RECLAIM)"
# 3. Data persists through Pod deletion
echo -n "3. Data survived Pod deletion: "
ROWS=$(kubectl exec ipl-postgres-0 -n production -- psql -U rohit_admin -d cricket_stats -t -c "SELECT COUNT(*) FROM ipl_test;" 2>/dev/null | tr -d ' ')
[ "$ROWS" -gt "0" ] && echo "PASS ✓ ($ROWS rows)" || echo "FAIL ✗"
# 4. HPA exists and is in a valid state
echo -n "4. HPA configured correctly: "
HPA_MIN=$(kubectl get hpa ipl-api-hpa -n production -o jsonpath='{.spec.minReplicas}')
[ "$HPA_MIN" = "2" ] && echo "PASS ✓ (minReplicas=$HPA_MIN)" || echo "FAIL ✗"
# 5. HPA scaled up during load test (check event history)
echo -n "5. HPA scaled up during load test: "
kubectl describe hpa ipl-api-hpa -n production | grep -q "SuccessfulRescale" && echo "PASS ✓" || echo "FAIL ✗ — run load test first"
# 6. VolumeSnapshot exists
echo -n "6. Migration snapshot present: "
kubectl get volumesnapshot postgres-pre-migration -n production > /dev/null 2>&1 && echo "PASS ✓" || echo "FAIL ✗"
# 7. API responding correctly
echo -n "7. API serves correct responses: "
kubectl exec -n production $(kubectl get pod -l app=ipl-api -n production -o name | head -1) -- python3 -c "import urllib.request; print(urllib.request.urlopen('http://localhost:8000/health').read())" | grep -q "healthy" && echo "PASS ✓" || echo "FAIL ✗"
echo "=== Exercise complete ===" Warning: The `PGDATA: /var/lib/postgresql/data/pgdata` environment variable is required when using EBS volumes with the official PostgreSQL image because EBS volumes mount a root directory that may contain a `lost+found` directory, which PostgreSQL refuses to use as its data directory. Setting PGDATA to a subdirectory avoids this constraint. Without this variable, the StatefulSet Pod will repeatedly fail with 'initdb: error: directory /var/lib/postgresql/data is not empty — lost+found exists' and enter CrashLoopBackOff. This is not a Kubernetes issue — it is the PostgreSQL initialisation process correctly refusing to use a non-empty directory — but the fix is a one-line environment variable, not a volume configuration change.
Extension Challenge: Add a PostgreSQL read replica as a second StatefulSet replica (`replicas: 2`) and configure streaming replication from `ipl-postgres-0` (primary) to `ipl-postgres-1` (standby) using the `POSTGRES_REPLICATION_USER` and `POSTGRES_REPLICATION_PASSWORD` environment variables. Update the API Deployment to route read-only queries to `ipl-postgres-1.ipl-postgres.production.svc.cluster.local` and write queries to `ipl-postgres-0.ipl-postgres.production.svc.cluster.local` using SQLAlchemy's read/write splitting. This exercises the headless Service stable DNS, the StatefulSet ordered creation (primary must be Ready before replica starts), and the Downward API ordinal detection for primary vs replica role assignment.
- Always snapshot the existing data volume before migrating from a Deployment to a StatefulSet — the snapshot is the rollback mechanism if the migration encounters unexpected issues.
- The `dataSource: volumeSnapshot` field in a PVC spec pre-populates the new PVC from a snapshot, enabling zero-data-loss storage migrations without manual pg_dump/pg_restore.
- StatefulSet stable identity is verified by confirming that a deleted Pod reappears with the same name (ipl-postgres-0) and rebinds to the same PVC, finding its previous data intact.
- HPA scaling events are preserved in kubectl describe hpa output — review them after a load test to confirm the scaling formula, stabilisation window, and rate limit policies work as designed.
- Set `PGDATA` to a subdirectory (e.g. /var/lib/postgresql/data/pgdata) when using EBS block devices to avoid PostgreSQL refusing to initialise in a directory containing the EBS-created lost+found entry.
- The nightly VolumeSnapshot CronJob completes the production storage posture: StatefulSet stable identity for data continuity, Retain reclaim policy for PVC protection, and automated snapshots for point-in-time recovery.