Restore container persistent data
1 · Prerequisites
Confirm every item is in place before any state change.
- The archive you intend to restore, and the checksum that was recorded when it was taken. A restore with no independently recorded checksum can only be compared against itself.
- Volumes, bind mounts, and where the state actually is
- backup-dr-lab-18-container-volume-backup-and-restore
- Access to the container host daemon as root or as a member of the docker group, and enough free space on the host for the restored copy alongside whatever is already there.
- A written inventory, produced before the incident, of which volumes hold state and which state this application writes into the container writable layer where no volume backup can reach it.
- Authority to stop the consuming container, and the incident or change reference under which that stop is authorised.
- The service owner answer to the question "what data has to be present before this service counts as working", expressed as something you can query rather than as an adjective.
- A place to record the start time, the chosen recovery point, both checksums, the elapsed time, and what was knowingly not recovered.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · List every mount on the running container before you touch anything.
docker inspectreports type, name, source and destination for each. The volumes you are about to restore are only the ones the engine manages; a bind mount belongs to the host and will not appear in any volume listing. - · Separate the state that is in a volume from the state that is in the writable layer. MEASURED on Docker version 29.7.2: a container started from a committed image printed the file written into the layer, then listed the volume mount point as
total 8holding only.and... Whatever the application writes outside a mount is not in the archive you are holding, and this check is where you find that out. - · Read the recovery point metadata rather than the filename. A filename records when somebody named the file. The archive timestamp, its size, and the listing of what is inside it record what it contains.
- · Compute the checksum of the archive now, before restoring it. If it does not match the checksum recorded at backup time, stop: you are holding a different object from the one that was verified, and restoring it will overwrite the evidence of that.
- · List the archive contents and confirm the files you expect are present and non-zero. An archive that extracts cleanly and contains nothing extracts cleanly.
- · Confirm you can stop the consumer, and enumerate what depends on it. Restoring into a volume that a running process holds open produces a mixture of old and new bytes that no checksum will describe.
- · Confirm free space on the host for the restored data alongside the current data. The decision to keep the existing volume until validation completes is only available if there is room for both.
- · Record the start time. The elapsed time of this procedure is the recovery time this estate actually has for this service, as opposed to the one written in the plan.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Select the recovery point explicitly and write down why you chose it. Never "restore the latest". Ask what time the data was last known good, then choose the newest recovery point at or before that moment. If the incident is corruption or an unwanted deletion, the newest archive may already contain it, and the correct choice is older and costs more data.
- 2Announce the outage window before stopping anything. This procedure is service-affecting by construction: the consumer is down from the moment you stop it until business validation passes, not until the extraction finishes.
- 3Stop the consuming container before any byte is written into the volume.
docker stop rbdr-app. A live consumer holding open files in the target volume will keep reading its own cached view while the bytes underneath change. - 4Confirm the consumer is stopped by reading its state, not the exit code of the stop command.
docker inspect rbdr-app --format {{.State.Status}}must reportexited. Confirm too that nothing restarted it: a restart policy or an orchestrator will bring it back mid-restore. - 5Decide between restoring into the existing volume and creating a fresh one. Into the existing volume is fewer moving parts and destroys the current contents. Into a fresh volume keeps the current contents as evidence and requires you to re-point the service. Choose the fresh volume whenever the cause is not yet understood, whenever the archive has not been restored before, or whenever the current data may still be needed for investigation.
- 6If you chose a fresh volume, create it with a name that records the recovery point, so that a second responder can tell which volume holds which moment without reading your notes.
- 7Restore through a helper container that mounts the volume by name. Mount the archive source read-only and let the helper write only into the destination. Addressing the volume by name asks the daemon where it is; addressing the daemon storage path directly hard-codes one driver layout into your recovery procedure.
- 8Read the exit status of the extraction before reading anything else, then list what landed in the volume. An extraction that reports nothing and produces nothing reports nothing.
- 9Checksum the restored file inside a helper container and compare it against the value recorded at backup time. MEASURED:
restored md5 : 9eb4e2ad8e08e1dcaaf87ababab964b0againstoriginal md5 : 9eb4e2ad8e08e1dcaaf87ababab964b0, reported asMATCH - the volume data was recovered byte-identical. - 10Refuse a comparison that passed because both sides were empty. Assert that each checksum variable is non-empty and that the byte count is greater than zero before you compare them. Two unset variables compare equal, and a validation built only on equality will report success on a volume containing nothing.
- 11If you created a fresh volume, re-point the service at it and record the change in the same place the service definition lives, so the next deployment does not silently mount the old volume again.
- 12Start the consumer and read the application log, not the daemon log. The daemon reports that a process started. The application reports whether it could open its data.
- 13Perform the business validation with the service owner, then record the outcome, including the state that was in the writable layer and has not come back.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓The archive integrity comparison printed
>>> archive integrity exit: 0, meaning the recorded sha256 and the computed sha256 were both non-empty and equal. Any other value ends the procedure at the pre-checks. - ✓The helper container running
tar xzfprinted>>> extract exit: 0, and a listing of the volume through a second helper shows the expected files at non-zero size. - ✓The comparison printed
>>> compare exit: 0: the md5 computed on the restored file equals the value recorded at backup time, and both were printed rather than assumed. In the captured run both sides read9eb4e2ad8e08e1dcaaf87ababab964b0. - ✓The byte count of each restored file is greater than zero, asserted by
test -n "$SIZE"andtest "$SIZE" -gt 0separately from the checksum comparison, so two empty values cannot pass as a match. - ✓The consumer is running -
docker inspect rbdr-app --format {{.State.Status}}reportsrunning- and its own application log, not the daemon log, shows it opened its existing data rather than creating an empty store. - ✓The business validation printed
>>> business validation exit: 0: the query the service owner named returned their expected count and their expected latest reference for the chosen recovery point, asked of the application rather than of the file. - ✓The state that lived in the container writable layer is enumerated and its absence is written down, because MEASURED on Docker version 29.7.2 the restored service failed on exactly that file with exit code 1.
- ✓The elapsed time from the stop to the passing business validation is recorded and compared against the recovery time objective for this service.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶If you restored into a fresh volume, rollback is to stop the consumer, re-point the service at the original volume, and start it. The original data was never touched, which is the reason that decision exists.
- ↶If you restored into the existing volume and the restore was wrong, the previous contents are gone. Stop the consumer immediately so that nothing writes on top of the wrong data, and restore a different recovery point into a fresh volume rather than layering a second extraction over the first.
- ↶If a restart policy or an orchestrator brought the consumer back while the extraction was running, stop it, discard the volume entirely, recreate it, and restore again from the archive. A partially overwritten volume has no checksum that describes it.
- ↶Undo the reflexes as well as the restore: remove the alert silence, restore any monitor threshold you widened, and revert any restart policy you disabled to keep the container down. These outlive the incident and nobody remembers them.
- ↶If you re-pointed the service definition at a new volume and are rolling back, revert that definition in the same place you changed it. A service that runs correctly today and mounts the old volume on the next deployment is a second incident with a delay fuse.
- ↶Keep the archive and both volumes until the service owner confirms the validation, then remove the superseded volume deliberately and record that you did.
6 · Escalation
When the runbook isn't enough, contact:
- · ABORT and escalate if the consumer cannot be stopped: a restart policy keeps returning it, an orchestrator reconciles it back, or the service owner will not authorise the outage. Restoring into a volume with a live writer is not a slower restore, it is an unpredictable one.
- · ABORT and escalate if the archive checksum does not match the value recorded at backup time. This is a repository integrity question, not a restore question, and the answer is a different copy rather than a retry.
- · ABORT and escalate if the archive extracts but the volume is empty or the files are zero length. Escalate to whoever owns the backup job; the fault is upstream and another restore attempt will reproduce it.
- · ABORT and escalate if the data the service needs turns out to have been in the container writable layer. No volume restore reaches it, and the decision about reconstructing it belongs to the application owner.
- · Escalate to the incident owner if the elapsed time is approaching the recovery time objective, with the measured remaining work, so that a decision about degraded service can be made rather than waited for.
- · Escalate to the service owner if business validation fails on a technically successful restore. Correct bytes at the wrong recovery point look exactly like a successful restore to every check in this runbook except that one.
A container restore has two halves, and only one of them is in the archive. The volume holds the data somebody is asking for. The writable layer holds whatever the application happened to write outside a mount, and nothing in this procedure recovers it. Establish which half you are holding before the service owner asks, because the answer does not change once you are already extracting.
The image is not the state
$ docker commit rbdr-app rbdr-committed:v1, then probing the committed image for the layer file and for the volume mount point image created
--- start a NEW container from that committed image, with NO volume ---
written-into-the-container-layer
>>> exit code: 0
--- and the file that was in the volume? ---
total 8
drwxr-xr-x 2 root root 4096 Aug 28 13:48 .
drwxr-xr-x 1 root root 4096 Aug 28 13:48 ..
>>> exit code: 0Both probes returned 0. The first printed the layer file. The second listed the volume destination and found two directory entries, because that path exists in the image only as a mount point. Read the exit codes before the text: a probe that succeeds and finds nothing is the shape of this failure.
Which state is protected, and which is not
Before choosing a recovery point, split the container state into three groups. Named volumes are the group the engine can enumerate and the group this procedure restores. Bind mounts are host directories grafted into the container; they never appear in a volume listing, and whoever protects the host protects them. Everything else the application wrote — logs, caches, upload staging, a database file somebody dropped into a path with no mount — is in the writable layer, and no volume archive contains it.
$ docker volume inspect rbdr-data, reporting the mount point and the driver Mountpoint: /var/lib/docker/volumes/rbdr-data/_data
Driver: localWrite that split down now, while there is still time to read it calmly. The list is what you will hand the service owner at the end, and the third group is the part of the conversation nobody wants to be having for the first time after the restore has already succeeded.
Recovery point selection
Ask when the data was last known good, then take the newest recovery point at or before that moment. That is a different question from “which archive is newest”, and in a corruption or accidental-deletion incident the newest archive already contains the damage. Two people should agree the answer: the responder knows what the archives contain, and the service owner knows when the data was last trusted. Write the chosen point and the reason next to each other, because a recovery point chosen for a good reason and a recovery point chosen by default look identical afterwards.
START_EPOCH=$(date -u +%s) # the clock this procedure is measured against
BACKUP_DIR=/srv/rbdr-backups
ARCHIVE="$BACKUP_DIR/rbdr-data-2026-08-28T1349.tgz"
RECORDED_SHA=$(cut -d' ' -f1 "$ARCHIVE.sha256") # written when the archive was taken
ls -l "$BACKUP_DIR"
tar tzf "$ARCHIVE"
ACTUAL_SHA=$(sha256sum "$ARCHIVE" | cut -d' ' -f1)
test -n "$RECORDED_SHA" && test -n "$ACTUAL_SHA" && test "$RECORDED_SHA" = "$ACTUAL_SHA"
echo ">>> archive integrity exit: $?" # 0 continues; anything else is an abort
The listing is not ceremony. An archive that extracts cleanly and contains
nothing extracts cleanly, and you would rather learn that now than after the
existing volume is gone. The integrity comparison asserts each value non-empty
before it asserts them equal, for the same reason the restored-data comparison
does later: a missing .sha256 file and a missing archive compare equal.
Decision point: the existing volume, or a fresh one
| Criterion | Existing volume | Fresh volume |
|---|---|---|
| Cause of the incident understood | Yes | Not yet |
| This archive restored before | Yes | No |
| Current data may be needed as evidence | No | Yes |
| Rollback available | None | Re-point and start |
| Extra work | None | Re-point the service definition |
The fresh volume is the default whenever any cell in the right-hand column applies. It costs one configuration change and buys a rollback; the existing volume costs nothing and removes the only remaining copy of the current data at the moment you extract over it.
Stop the consumer first
docker inspect rbdr-app \
--format '{{range .Mounts}}{{.Type}} {{.Name}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
docker stop rbdr-app
docker inspect rbdr-app --format '{{.State.Status}}' # must print: exited
The first command prints type, name, source and destination per mount: a
volume row carries a name the daemon can resolve, a bind row carries a
host path and no name, and anything written outside both has no row at all.
The last command is the one that matters. docker stop returning 0 means
the request was accepted; exited means the process is gone. If a restart
policy or an orchestrator returns the container mid-extraction, you get a
volume that is partly old and partly new, and no checksum describes that
state. If you cannot stop it, abort.
Restore through a helper container
TARGET=rbdr-data-restored
docker volume create "$TARGET"
docker run --rm -v "$TARGET":/dst -v "$BACKUP_DIR":/in:ro alpine \
tar xzf "/in/$(basename "$ARCHIVE")" -C /dst
echo ">>> extract exit: $?"
The helper mounts the volume by name, so the daemon resolves where the bytes
live. The measured host path for the local driver was
/var/lib/docker/volumes/rbdr-data/_data, and a recovery procedure written
against that path is written against one driver layout rather than against
the volume.
$ docker run --rm -v rbdr-data-restored:/dst -v /tmp/rbdr-out:/in alpine tar xzf /in/rbdr-data.tgz -C /dst--- the restored service reads its data ---
ORDER-1001,4500.00
ORDER-1002,1250.00
restored md5 : 9eb4e2ad8e08e1dcaaf87ababab964b0
original md5 : 9eb4e2ad8e08e1dcaaf87ababab964b0
MATCH - the volume data was recovered byte-identicalVerify both sides, and refuse two empty values
RESTORED=$(docker run --rm -v "$TARGET":/src:ro alpine md5sum /src/orders.csv | cut -d' ' -f1)
ORIGINAL=9eb4e2ad8e08e1dcaaf87ababab964b0
SIZE=$(docker run --rm -v "$TARGET":/src:ro alpine stat -c %s /src/orders.csv)
test -n "$RESTORED" && test -n "$SIZE" && test "$SIZE" -gt 0 &&
test "$RESTORED" = "$ORIGINAL"
echo ">>> compare exit: $?" # 0 is the only result that closes this step
The four assertions are deliberately separate. A comparison of two unset
variables succeeds, and a checksum of an absent file can leave both sides
empty; a validation built on equality alone then reports success on a volume
holding nothing. Assert each value non-empty, assert non-zero length, and only
then assert equal. An unset SIZE makes test "$SIZE" -gt 0 a shell error
rather than a refusal, and a shell error inside an && chain reads past
easily.
Abort criteria
Stop and escalate — do not continue — if any of these hold:
- The consumer will not stay stopped.
docker inspectreports anything other thanexited, or it reportsexitedand thenrunningagain a minute later. A restart policy or an orchestrator that returns the container mid-extraction leaves a volume that is part old and part new, and no checksum describes that state. The platform owner answers this one. >>> archive integrity exit:is anything but 0. You are holding a different object from the one that was verified at backup time. The answer is a different copy from the repository owner, not another attempt at this one.- The extraction exited 0 and the volume is empty, or the files are zero length. The fault is upstream in the backup job; a second restore reproduces it exactly. Escalate to whoever owns that job.
- The data the service needs lived in the container writable layer. No volume restore reaches it. Whether it can be reconstructed, and from what, is the application owner’s decision rather than yours.
Each of those is answered by somebody else. Continuing anyway converts a recoverable situation into a decision you made alone under time pressure.
Business validation
$ asking the restored container for the file that had been written into the container writable layer--- but what about the file that lived in the container layer? ---
cat: can't open '/etc/app-marker': No such file or directory
>>> exit code: 1A running container is not a working service. Ask the owner for a query whose answer they already know for the chosen recovery point — the count of orders, the latest order reference, the balance on a named account — and run it through the application rather than against the file.
docker start rbdr-app
docker logs --since 2m rbdr-app # the application's own view, not the daemon's
# The two values the service owner named for the chosen recovery point.
EXPECT_COUNT=2
EXPECT_LATEST=ORDER-1002
# Replace the two commands below with the query this service exposes. The
# assertions around them are the part that does not change.
ACTUAL_COUNT=$(docker exec rbdr-app rbdr-orders-cli count)
ACTUAL_LATEST=$(docker exec rbdr-app rbdr-orders-cli latest)
test -n "$ACTUAL_COUNT" && test -n "$ACTUAL_LATEST" &&
test "$ACTUAL_COUNT" = "$EXPECT_COUNT" && test "$ACTUAL_LATEST" = "$EXPECT_LATEST"
echo ">>> business validation exit: $?"
A checksum proves the bytes are the ones that were archived; only the owner can say whether those bytes are the ones the business needs, and the gap between the two is exactly where a restore to the wrong recovery point survives every technical check in this runbook.
Then hand over the list of state that was in the writable layer and did not come back. The capture above is what that looks like from inside a restored container: the volume data read correctly and the layer file failed with exit code 1. Nothing there is a fault, and none of it is recoverable from the archive you hold, so the absence needs to become a decision somebody made rather than something discovered next week.
What to record
RECORD=/tmp/rbdr-restore-record.txt
{
printf 'recovery point : %s\n' "$ARCHIVE"
printf 'chosen because : %s\n' "$WHY_THIS_POINT"
printf 'archive sha256 : %s\n' "$ACTUAL_SHA"
printf 'restored md5 : %s\n' "$RESTORED"
printf 'recorded md5 : %s\n' "$ORIGINAL"
printf 'volume now serving : %s\n' "$TARGET"
printf 'service re-pointed : %s\n' "$REPOINTED"
printf 'not recovered : %s\n' "$LAYER_STATE_LOST"
printf 'elapsed seconds : %s\n' "$(( $(date -u +%s) - START_EPOCH ))"
} | tee "$RECORD"
Two of those lines change future decisions rather than closing this incident:
the elapsed time is the recovery time this service actually has, as distinct
from the one written in the plan, and not recovered names the writable-layer
state file by file.