Back up a database, delete its cluster, restore into another region, and watch the checksum match — then hand the drill to GitHub Actions.
Two numbers your director will ask for by name
When a region goes dark, two questions decide whether you keep your job: how long until we're back (RTO, recovery time objective) and how much data did we lose (RPO, recovery point objective). Everyone quotes targets; almost nobody has measured their real ones, because measuring means actually destroying something and recovering it. This lab makes you do exactly that — with a stateful database, across regions — so the numbers you put in a runbook are ones you have seen with your own eyes.
The tool is Velero: it snapshots your Kubernetes objects and the disks behind your persistent volumes, and writes both to blob storage. Point that storage at a geo-redundant (GRS) account and Azure replicates your backups to the paired region for free — so a cluster in East US 2 can restore from a backup taken in East US without you copying a byte.
A backup you have never restored is a rumour, not a recovery.
You run two AKS clusters (D-series nodes) at once, so this bills at a real clip — budget $10–20 for a single sitting and do it in one go. The final step deletes everything; set a budget alert before you start. To halve the cost while you learn the mechanics, drop the node VM size to Standard_B2ms and node count to 2.
You need a paid Azure subscription, the Azure CLI, kubectl, Helm, the Velero CLI, and jq. This is an advanced build: it assumes you are comfortable with AKS, kubectl contexts, and Helm. If AKS is new, run the identity and networking labs first — this page won't teach the basics under you.
Two clusters, matched on purpose
Create a primary cluster in East US and a DR cluster in the paired region, East US 2. They must match — same VM size, same node count — or a restored persistent volume can land on an incompatible disk tier and fail. Save each as a named kubectl context so you can aim commands at either cluster deliberately.
export MSYS_NO_PATHCONV=1 # Windows/Git Bash: leave resource-id args alone
RG="rg-dr-lab"; PRIMARY="aks-primary"; DR="aks-dr"
az group create -n "$RG" -l eastus
for pair in "$PRIMARY:eastus" "$DR:eastus2"; do
NAME="${pair%%:*}"; LOC="${pair##*:}"
az aks create -g "$RG" -n "$NAME" -l "$LOC" \
--node-count 3 --node-vm-size Standard_D4s_v3 \
--network-plugin azure --enable-managed-identity \
--enable-oidc-issuer --zones 1 2 3 --generate-ssh-keys
done
az aks get-credentials -g "$RG" -n "$PRIMARY" --context aks-primary
az aks get-credentials -g "$RG" -n "$DR" --context aks-dr
kubectl get nodes --context aks-primary
kubectl get nodes --context aks-dr
Ready nodes across zones 1–3. Provisioning two clusters takes several minutes — a good moment to read ahead. The matched spec is not cosmetic: a mismatched VM size is the classic cause of a PVC that restores but never binds.Geo-redundant storage, and a key that can touch only it
Velero writes to a blob container. Make the storage account GRS so Azure replicates it to East US 2 automatically, and give Velero a service principal scoped to that storage account only — Storage Blob Data Contributor, not subscription owner. Least privilege is the difference between a leaked backup key and a leaked cloud.
SA="velerodr$RANDOM"; CONTAINER="velero-backups" SUB_ID=$(az account show --query id -o tsv) az storage account create -n "$SA" -g "$RG" -l eastus \ --sku Standard_GRS --min-tls-version TLS1_2 --allow-blob-public-access false az storage container create -n "$CONTAINER" --account-name "$SA" STORAGE_ID=$(az storage account show -n "$SA" -g "$RG" --query id -o tsv) SP=$(az ad sp create-for-rbac -n velero-sp \ --role "Storage Blob Data Contributor" --scopes "$STORAGE_ID" --sdk-auth) CLIENT_ID=$(echo "$SP" | jq -r .clientId) CLIENT_SECRET=$(echo "$SP" | jq -r .clientSecret) TENANT_ID=$(echo "$SP" | jq -r .tenantId) echo "storage: $SA container: $CONTAINER"
Standard_GRS (confirm with az storage account show -n "$SA" -g "$RG" --query sku.name) and the service principal's role scope is the storage id, nothing broader. GRS replicates to the paired region on its own — that is what lets the DR cluster read these backups without a copy job.Velero: one writer, one reader
Install Velero on the primary as the writer, and on the DR cluster pointed at the same GRS-replicated container as a read-only location. This is the correct production shape — only one cluster ever writes backups, so they can't fight over the store.
cat > /tmp/velero-creds.conf <<EOF AZURE_SUBSCRIPTION_ID=$SUB_ID AZURE_TENANT_ID=$TENANT_ID AZURE_CLIENT_ID=$CLIENT_ID AZURE_CLIENT_SECRET=$CLIENT_SECRET AZURE_RESOURCE_GROUP=$RG AZURE_CLOUD_NAME=AzurePublicCloud EOF # PRIMARY — read/write backup location + volume snapshots velero install --provider azure \ --plugins velero/velero-plugin-for-azure:v1.10.0 \ --bucket "$CONTAINER" \ --secret-file /tmp/velero-creds.conf \ --backup-location-config resourceGroup=$RG,storageAccount=$SA \ --snapshot-location-config apiTimeout=5m \ --kubecontext aks-primary # DR — same bucket, marked read-only (--access-mode ReadOnly) velero install --provider azure \ --plugins velero/velero-plugin-for-azure:v1.10.0 \ --bucket "$CONTAINER" --no-secret \ --secret-file /tmp/velero-creds.conf \ --backup-location-config resourceGroup=$RG,storageAccount=$SA \ --use-volume-snapshots=false \ --kubecontext aks-dr velero backup-location set default --access-mode=ReadOnly --kubecontext aks-dr
velero backup-location get --kubecontext aks-primary shows Available; on aks-dr the same location is Available and ReadOnly. Both clusters now see one shared, geo-replicated backup store.Stateful data — and a checksum to judge the restore by
Deploy PostgreSQL with a persistent volume and seed it with relational data — two tables joined by a foreign key — so the restore has to bring back consistent state, not just a flat file. Then compute a checksum of the data. That single hash is how you'll later prove the recovery was byte-perfect rather than merely "the pod came up".
helm repo add bitnami https://charts.bitnami.com/bitnami && helm repo update
kubectl create namespace production --context aks-primary
helm install pg-prod bitnami/postgresql --kube-context aks-primary -n production \
--set auth.postgresPassword=DrLabP@ss123 \
--set primary.persistence.enabled=true \
--set primary.persistence.size=10Gi \
--set primary.persistence.storageClass=managed-premium
kubectl rollout status statefulset/pg-prod-postgresql -n production --context aks-primary
kubectl exec -i pg-prod-postgresql-0 -n production --context aks-primary -- \
psql -U postgres -d postgres <<'EOF'
CREATE TABLE customers (id SERIAL PRIMARY KEY, name TEXT, email TEXT UNIQUE, tier TEXT DEFAULT 'standard');
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id), amount NUMERIC(10,2), status TEXT);
INSERT INTO customers (name,email,tier) VALUES
('Alice Chen','[email protected]','enterprise'),
('Bob Martinez','[email protected]','standard'),
('Carol Johnson','[email protected]','enterprise');
INSERT INTO orders (customer_id,amount,status) VALUES
(1,15000.00,'completed'),(1,8750.50,'processing'),(2,2340.00,'completed'),(3,99999.99,'completed');
SELECT md5(string_agg(id::text||name||email, ',' ORDER BY id)) AS checksum FROM customers;
EOF
checksum value down. That md5 is your RPO evidence: after the cross-region restore you'll run the identical query and the hashes must match. If they don't, the restore lost or reordered data — exactly the silent failure this lab exists to catch.Schedule backups — and confirm the disk came too
Create an hourly schedule (your RPO target) and a daily one with 30-day retention, then take one manual backup now and inspect it. The most dangerous Velero mistake is a backup that captured your Kubernetes YAML but not the volume snapshot behind it — it restores pods with empty disks and you don't find out until the incident.
velero schedule create hourly-production --schedule="0 * * * *" \ --include-namespaces production --ttl 48h0m0s --kubecontext aks-primary velero schedule create daily-production --schedule="0 2 * * *" \ --include-namespaces production --ttl 720h0m0s --kubecontext aks-primary velero backup create manual-$(date +%H%M) \ --include-namespaces production --wait --kubecontext aks-primary velero backup get --kubecontext aks-primary # PHASE=Completed, ERRORS=0 velero backup describe <backup-name> --details --kubecontext aks-primary \ | grep -A3 "Persistent Volumes" # must list 1 PV
--details shows one Persistent Volume included — the PostgreSQL disk. A backup that lists no PVs is the silent-failure case; treat it as a broken backup, not a minor warning.Lose the cluster. Bring it back in another region.
Now the drill. Simulate the disaster — delete the production namespace on the primary — then restore from the shared backup into the DR cluster, and re-run the checksum. This is the whole point: not "did a backup exist," but "can I stand the data back up somewhere else and prove it's intact."
# 1 · the disaster kubectl delete namespace production --context aks-primary # 2 · restore into the DR cluster from the geo-replicated backup velero restore create dr-restore --from-backup <backup-name> \ --kubecontext aks-dr --wait kubectl rollout status statefulset/pg-prod-postgresql -n production --context aks-dr # 3 · the verdict — same query, compare to the checksum you saved kubectl exec -i pg-prod-postgresql-0 -n production --context aks-dr -- \ psql -U postgres -d postgres -tAc \ "SELECT md5(string_agg(id::text||name||email, ',' ORDER BY id)) FROM customers;"
| Scenario | What you did | Measured RTO | RPO |
|---|---|---|---|
| Namespace loss | Restore in place, same cluster | ~5 min | ≤ 1 hr (hourly schedule) |
| Full cluster / region loss | Restore into DR cluster, other region | ~20–30 min | ≤ 1 hr; GRS store itself, minutes |
Make the drill run itself
A DR plan tested once is theatre. Wire the same backup → restore → checksum sequence into a GitHub Actions workflow on a weekly cron, so degradation surfaces in a green-or-red check while it's cheap to fix — not during the outage. Federate the workflow into Azure (no stored secret) and fail the job if the checksum drifts.
# .github/workflows/dr-drill.yml (essentials) name: Weekly DR Drill on: schedule: [{ cron: '0 3 * * 0' }] # Sundays 03:00 UTC workflow_dispatch: jobs: drill: runs-on: ubuntu-latest steps: - uses: azure/login@v2 with: { creds: ${{ secrets.AZURE_CREDENTIALS }} } - name: Backup, restore to DR, verify checksum run: | az aks get-credentials -g "$RG" -n aks-primary --context aks-primary --overwrite-existing az aks get-credentials -g "$RG" -n aks-dr --context aks-dr --overwrite-existing B="dr-drill-$(date +%Y%m%d-%H%M)" velero backup create "$B" --include-namespaces production --wait --kubecontext aks-primary velero restore create --from-backup "$B" --kubecontext aks-dr --wait NEW=$(kubectl exec -i pg-prod-postgresql-0 -n production --context aks-dr -- \ psql -U postgres -tAc "SELECT md5(string_agg(id::text||name||email, ',' ORDER BY id)) FROM customers;") [ "$NEW" = "${{ vars.BASELINE_CHECKSUM }}" ] || { echo "::error::checksum drift"; exit 1; }
workflow_dispatch run goes green and the job fails loudly if the checksum drifts. You now own an automated, evidence-producing DR drill — the line that separates "we have backups" from "we have tested recovery," and the one auditors actually want to see.Tear it down — this one bills fast
Two clusters plus GRS storage add up quickly. One resource-group delete removes both clusters, the storage account, and the disks. Do it the moment you're done, and clean up the service principal too.
az group delete -n rg-dr-lab --yes --no-wait az ad sp delete --id "$CLIENT_ID" # remove the Velero service principal az group exists -n rg-dr-lab # -> false once the async delete finishes
What you can now honestly claim
You provisioned matched AKS clusters across paired regions, backed up a stateful PostgreSQL workload with Velero to geo-redundant storage, restored it into a different region, and proved data integrity by checksum — then automated the whole drill in GitHub Actions with measured RTO and RPO. That is "designed and validated cross-region Kubernetes disaster recovery with automated drills and documented RTO/RPO" — a staff-level line on a reliability posting, done rather than described. The transferable lesson outlives Velero: recovery is a claim you verify, on a schedule, or it isn't real.
- GRS replicates asynchronously to the paired region, so the backup store's own RPO is minutes, not zero — a backup written seconds before a total primary-region outage might not have replicated yet. For most workloads that window is acceptable; know it's there before you promise RPO=0 on the backups themselves.
- The Velero install flags shift between chart and plugin versions. Pin the plugin (here
v1.10.0) to your Velero version and check the plugin compatibility matrix — a mismatched plugin is the usual cause of a backup that completes but silently skips the volume snapshot. - Restoring PostgreSQL from a disk snapshot recovers the data files as they were on disk, which for a busy database means crash-recovery on startup. It works, but for zero-data-loss on a hot database, pair volume snapshots with WAL archiving — the disk snapshot is the floor, not the ceiling.