Reported symptoms
At 03:40 a schema change to rbdr-ledger goes wrong. The team restores the
rbdr-ledger-data claim from the 03:00 CSI VolumeSnapshot. The pod reaches
Ready eleven seconds after the mount, the restore test records PASS, and the
service is back in traffic at 04:12.
At 09:20 the settlement reconciliation reports that the export files on the
volume total 418000 minor units more than the ledger rows they summarise. The
whole difference is one batch, rbdr-2026-08-28-0179: its export file is on the
volume and complete, its thirty-four ledger rows are not in the database.
Nothing is missing in a way anybody can point at. The claim is the same size as the source, every file the source held is present, and no restore step logged an error.
Evidence provided
First, what takes the snapshot.
$ kubectl -n rbdr-ledger get cronjob rbdr-snapshot-hourly -o yamlspec:
schedule: "0 * * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: rbdr-snapshotter
restartPolicy: OnFailure
containers:
- name: snapshot
image: registry.rbdr.example/rbdr-kubectl:1.36
args:
- snapshot
- --namespace=rbdr-ledger
- --claim=rbdr-ledger-data
- --class=rbdr-csi-ssd
- --name-prefix=rbdr-ledgerIllustrative output
That is the whole job, and the object it creates is equally plain:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: rbdr-ledger-0300
namespace: rbdr-ledger
spec:
volumeSnapshotClassName: rbdr-csi-ssd
source:
persistentVolumeClaimName: rbdr-ledger-data
No exec into the pod before it, no filesystem freeze, no call into the
application, no step after it. The snapshot reported readyToUse: true with a
restoreSize equal to the claim.
Second, the restored application’s own account of what it found. The pod’s log records the sequence below, quoted from the course capture of an uncoordinated copy of a running cluster.
$ pg_ctl -D /work/naive-copy start 2026-08-28 13:34:37.611 UTC [94] LOG: database system was interrupted; last known up at 2026-08-28 13:34:36 UTC
2026-08-28 13:34:37.613 UTC [94] LOG: database system was not properly shut down; automatic recovery in progress
2026-08-28 13:34:37.613 UTC [94] LOG: redo starts at 0/17615F8
2026-08-28 13:34:37.634 UTC [94] LOG: invalid record length at 0/256A8D8: expected at least 24, got 0
2026-08-28 13:34:37.634 UTC [94] LOG: redo done at 0/256A8B0 system usage: CPU: user: 0.01 s, system: 0.00 s, elapsed: 0.02 s
2026-08-28 13:34:37.639 UTC [88] LOG: database system is ready to accept connectionsThird, the invariant recorded before the snapshot, against what came back.
$ rbdr-verify-restore --register /var/lib/rbdr/settlement-register.jsonrecorded 2026-08-28T03:00:00Z, before the snapshot was requested
ledger rows : 1284416
sum(amount) : 918447320
exports total : 918447320
read from the restored claim rbdr-ledger-data-restore
ledger rows : 1284382 (-34)
sum(amount) : 918029320 (-418000)
exports total : 918447320 (matches the register)
MISMATCH - batch rbdr-2026-08-28-0179 is in the exports and not in the ledgerIllustrative output
Fourth, the state of the restored volume itself.
$ kubectl -n rbdr-restore get pvc rbdr-ledger-data-restore; rbdr-inventory /mnt/rbdr-restoreNAME STATUS CAPACITY ACCESS MODES STORAGECLASS
rbdr-ledger-data-restore Bound 64Gi RWO rbdr-csi-ssd
files bytes inventory digest
source claim at 03:00 8214 61203847168 sha256:4c1f9ab2
restored claim 8214 61203847168 sha256:4c1f9ab2Illustrative output
Fifth, the control. The rehearsal job is three lines:
- kubectl -n rbdr-restore apply -f rbdr-ledger-from-snapshot.yaml
- kubectl -n rbdr-restore wait --for=condition=Ready pod -l app=rbdr-ledger --timeout=300s
- echo PASS
The course capture shows how far that assertion sits from the data it is taken to be about.
$ kubectl apply -f rbdr-shop.yaml; kubectl -n rbdr-shop exec rbdr-orders-writer -- cat /data/orders.csv namespace/rbdr-shop created
persistentvolumeclaim/rbdr-orders created
configmap/rbdr-config created
pod/rbdr-orders-writer created
>>> exit code: 0
pod Ready after 6s
--- every object is back ---
persistentvolumeclaim/rbdr-orders Bound pvc-a29538af-09d4-4bb8-82bb-b2c222b9db93 64Mi RWO local-path <unset> 6s
configmap/kube-root-ca.crt 1 6s
configmap/rbdr-config 1 6s
pod/rbdr-orders-writer 1/1 Running 0 6s
--- and the business data? ---
total 8
drwxrwxrwx 2 root root 4096 Aug 28 14:34 .
drwxr-xr-x 1 root root 4096 Aug 28 14:34 ..
cat: can't open '/data/orders.csv': No such file or directory
command terminated with exit code 1Work the evidence before reading on
- The snapshot reported
readyToUseand arestoreSizeequal to the claim. What does that cover, and what does it say nothing about? - The inventory and the capacity match the source exactly. Which hypothesis does that eliminate, and which does it leave standing?
- PostgreSQL logged crash recovery and then started. A fault in the restore, or the expected behaviour of the thing captured?
- Nineteen PASS results. Name one volume state that would have made the rehearsal fail.
- The exports half matches the register and the ledger half does not. What does that asymmetry say about the moment the snapshot fired?
Root cause
The snapshot was taken at an instant of the storage’s choosing
A CSI VolumeSnapshot is atomic at the block layer, so what came back is a coherent image of the volume at 03:00:00 — the state it would have held if power had been cut then. That is crash consistency, and the restored cluster showed it working: redo replayed, stopped where the WAL ran out, and the server opened.
What it does not supply is an instant the application would have chosen.
rbdr-ledger settles a batch in two movements — it commits the batch rows into
the database, and it writes the batch’s export file for the downstream billing
system. 03:00:00 landed between them. Recovery discarded the uncommitted
transaction and kept the fsynced export file, both correct. The volume that came
back has two halves that are each internally consistent and that describe
different worlds.
Nothing in the pipeline could have moved that instant, because nothing in it speaks to the workload. No field on the VolumeSnapshot was left blank: the API has no hook mechanism, so coordination must come from whatever creates the object, and the CronJob creates it and exits.
The restore test asserted readiness, not correctness
The rehearsal waits for condition=Ready and echoes PASS. A readiness condition
reports that a container’s probe returned success; the ledger’s probe answers as
soon as PostgreSQL accepts connections, which it did eleven seconds after the
mount, having just written a crash-recovery sequence to its log.
It recorded PASS nineteen times because starting was never in question. It reads nothing out of the volume and holds no independently recorded property of the business data to disagree with, so no plausible volume state would have been reported as a failure. The capture above is the same assertion passing on a volume holding none of the application’s data.
Resolution
Recover the batch first. rbdr-2026-08-28-0179 exists in full in its export
file, so the thirty-four rows are reconstructable; replay them into the ledger
inside one transaction and re-run the reconciliation before anything else is
touched. Where a batch survives in neither half, the recovery point is the last
instant at which the register and the volume agreed.
Then give the snapshot an instant the application chooses. The pre-snapshot step drains the settlement writer so no batch is part-way between the ledger and the export directory, and holds the database’s backup bracket open for the capture.
set -eu
NS=rbdr-ledger
SNAP=rbdr-ledger-0300
POD=$(kubectl -n "$NS" get pod -l app=rbdr-ledger \
-o jsonpath='{.items[0].metadata.name}')
kubectl -n "$NS" exec "$POD" -c settlement -- rbdr-quiesce --begin
kubectl -n "$NS" apply -f rbdr-ledger-snapshot.yaml
kubectl -n "$NS" wait --for=jsonpath='{.status.readyToUse}'=true \
"volumesnapshot/$SNAP" --timeout=600s
kubectl -n "$NS" exec "$POD" -c settlement -- rbdr-quiesce --end
rbdr-quiesce is one in-pod helper rather than a pair of kubectl exec calls
because PostgreSQL’s non-exclusive backup bracket must be held by a single
session: pg_backup_start and pg_backup_stop issued over two connections
abort the bracket instead of establishing it.
Finally, rewrite the rehearsal so it is capable of failing.
Verification
Verification compares the restore against something recorded before the snapshot, never against a reading taken from the restored artefact alone.
Each rehearsal restores into an isolated namespace, waits for the pod, then
computes the ledger row count, the ledger sum(amount) and the exports total
from the restored volume. All three must equal what the settlement register
recorded at the snapshot instant, and the ledger sum and the exports total must
equal each other. The result records those three numbers, not the word PASS.
Then prove the pre-snapshot step does something: drive settlement at production rate, take a coordinated snapshot, confirm all three numbers agree, and repeat with the coordination disabled to confirm the rehearsal reports a mismatch. A control never observed failing has not been tested.
$ psql -p 5434 -Atc 'SELECT count(*), sum(amount) FROM orders' rows recovered : 50000 (expected 50000)
sum(amount) : 825025000 (expected 825025000)
RECOVERED - row count and business checksum both match the pre-DELETE state
Note what was validated: not that the server started, but that the
data it now holds matches an independently recorded property of the
business data from before the incident.Prevention
Name the consistency level of every recovery point. Crash-consistent and application-consistent are different properties, and a bare volume snapshot supplies only the first. A schedule that never states which it produces has been claiming the stronger one by default.
Coordination is a step before the snapshot, not a setting inside it. No field on a VolumeSnapshot makes it application-aware. If nothing execs into the workload, freezes the filesystem or drives its quiesce entry point, the instant is whichever microsecond the scheduler fired at. Velero documents pre- and post-backup hooks for this reason; a hand-rolled CronJob supplies the equivalent itself or does without.
Find the invariants that span more than one file. This workload’s correctness lived across a database and a directory, so no single-file check could see the break. Record their value beside every recovery point.
A readiness probe is not a data assertion. Ready means a process answered.
Ask of every green control what state would have turned it red. If that cannot be written down as a concrete volume state, the control reports on something other than the thing being protected.