Backup & DRXIII · Container and Kubernetes RecoveryContainers
A container image is not a backup
What you'll learn
- Separate the three places container state lives: image layers, the container writable layer, and attached storage
- Predict which of those a given capture mechanism will and will not include
- Explain why a directory that is a mount point comes back empty in a container started from a committed image
- Identify state written into a container layer as state no backup in the estate is protecting
Prerequisites
Practice
Verified against restic 0.19.1 · BorgBackup 1.4.5 · rclone 1.75.0 · MinIO (S3-compatible object storage) RELEASE.2025-09-07T16-13-09Z · OpenZFS 2.4.1 · LVM2 2.03.31(2) · btrfs-progs 6.17.1 · PostgreSQL 18.6 · pgBackRest 2.59.1 · Kubernetes (k3s) and etcd k3s v1.36.3+k3s1, etcd 3.7.1 · Velero 1.18.2 · Docker Engine 29.7.2 · Proxmox Backup Server (documentation only) 4.0.10-1 · Ubuntu (host baseline) 26.04 LTS · 2026-08-28
Losing a whole virtualisation cluster left an estate that had to be rebuilt before anything could be restored into it, and every unit of state in that exercise had a name somebody could point at: a disk image, a datastore, a configuration file on a host. Containers break that habit. They put state in places that are created and destroyed by the same command that starts and stops a process, and estate inventories rarely list those places at all. That is why container estates are so routinely under-protected, and the correction is not a new tool. It is knowing which of three layers a given byte is sitting in.
Three places, three lifetimes, one command that captures the wrong one
A container image is an ordered stack of read-only layers. Each layer is a filesystem changeset — a set of added, changed and removed paths recorded against the layer beneath it — and the stack is produced from a definition, usually a Dockerfile, then addressed by digest. An image is immutable in one narrow sense: the bytes of a layer cannot be altered without changing the digest that names it. Immutable there does not mean retained, protected or recoverable. It is a property of content addressing, and it says nothing about whether that digest will still resolve in a registry next quarter.
When the daemon starts a container from an image it adds one more layer on top, and that one is writable. Every file the process creates or modifies at a path that is not covered by a mount lands there. The writable layer belongs to the container rather than to the image, and it is discarded when the container is removed — which is an ordinary event, not an incident, because replacing the container is how a containerised service gets upgraded, rescheduled, or brought back after a host reboot.
The third place is not a layer at all. A volume or a bind mount is storage attached to the container at a path, supplied at run time. It outlives the container, and — this is the part that gets missed — it is not part of the image and never becomes part of one. The image knows a directory exists at that path. It does not know, and cannot carry, what is behind the mount.
So ask where a file lives before asking how it is protected. The answer decides which mechanism can reach it, and two of the three places are invisible to the mechanism people reach for first.
Measured: what docker commit captured, and what it did not
The claim is testable, so it was tested rather than asserted. On Docker 29.7.2,
build a7dcaa6, an application container was started with a named volume
rbdr-data mounted at /var/lib/app. Two files were written: orders.csv into
the volume, holding two order lines and hashing to md5
9eb4e2ad8e08e1dcaaf87ababab964b0, and /etc/app-marker into the container’s
own filesystem at a path with no mount over it — the writable layer.
Then docker commit, which is the capture most people reach for when they want
“a copy of this container before we change it”.
$ docker commit rbdr-app rbdr-committed:v1 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: 0The two halves point in opposite directions. The file written into the writable
layer came back: a fresh container started from rbdr-committed:v1 printed
written-into-the-container-layer and exited 0, so the commit captured
something real and captured it faithfully.
The directory that had been the volume’s mount point came back with total 8
and two entries, . and ... Empty. Not corrupt, not partial — the order data
was never in the image, so there was nothing for the new container to show. One
image, and the two files ended up on opposite sides of the boundary from the one
an operator would guess.
The volume is a directory on the host with a name attached
If the volume contents are not in the image, they are somewhere, and it is worth
being precise about where. The capture asked the daemon: the volume’s
Mountpoint was /var/lib/docker/volumes/rbdr-data/_data and its Driver was
local. That is the whole trick of the local driver — a named volume is a
directory on the host, in a path the daemon manages, presented to the container
at whatever target path the run command asked for.
Which means it can be backed up like any other directory, by either of two
routes. The host route reads /var/lib/docker/volumes/... directly and needs
the writer quiesced to be worth anything. The portable route mounts the volume
into a short-lived helper container and streams an archive out, which behaves
the same way whatever driver is behind the volume.
$ docker run --rm -v rbdr-data:/src:ro -v $PWD:/out alpine tar czf /out/rbdr-data.tgz -C /src . -rw-r--r-- 1 root root 160 Aug 28 13:49 /tmp/rbdr-out/rbdr-data.tgzThe :ro on the source mount is deliberate: the helper has no business writing
to what it is reading. Note also what the archive is not. Those 160 bytes are
volume contents — not the application, its image, its configuration or the
container-layer file — and a restore procedure that assumes otherwise is wrong
in a way that only shows up when it is used.
The two routes also fail differently. The host route is an artefact of the
driver: the capture’s volume reported Driver: local, and it is the local
driver that puts the bytes under /var/lib/docker/volumes/rbdr-data/_data. The
Docker volumes documentation describes volume drivers that place a volume’s
storage elsewhere, including on a remote host, and under one of those the host
path is not where the data is — so a job hard-coded to that directory would exit
0 and archive nothing. The helper-container route asks the daemon for the mount
rather than guessing at a path, which is why it survives that change and belongs
in any procedure written for an estate somebody else configured.
Neither route says anything about the state of the writer. tar czf walks a
tree and reads each file when it reaches it, so a volume written during the walk
yields files captured at different instants, and whether those reassemble into
something the application will start is a property of the application, not the
archive. Nor should default flags be assumed to carry everything a directory
holds: measured elsewhere in this course on GNU tar 1.35, an archive taken with
default flags restored names, sizes and contents intact while the source’s ACL
came back with zero entries and its extended attribute came back ABSENT.
Whether a given helper image’s tar behaves the same way is worth testing. For a
volume holding one CSV it does not matter. For one holding a mail spool or a
certificate store, where ownership and ACLs decide who may read a file, it is
the difference between a restore and a directory of correct bytes nobody can
open.
Destroy both, restore one
An image and an archive that have never been exercised are two claims. The
capture converted them into results by removing the container and the volume,
which is what a host rebuild, a docker volume prune on the wrong machine, or a
mistyped compose teardown does in production.
$ docker volume inspect rbdr-data []
Error response from daemon: get rbdr-data: no such volume
>>> exit code: 1Nothing recoverable from the daemon. What existed at that point was one 160-byte archive on a different filesystem, and it was unpacked into a brand-new volume — clean infrastructure, not the original.
$ 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-identical
--- 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: 1The volume half of this is a clean result and worth stating plainly: the same
md5 on both sides, 9eb4e2ad8e08e1dcaaf87ababab964b0, from an archive taken
before the destruction and unpacked after it. That is a proven restore of the
volume, by the standard this course has used since Part I — bytes compared, not
a job that exited 0.
The other half is the lesson. /etc/app-marker is gone, with
cat: can't open '/etc/app-marker': No such file or directory and exit code 1.
It had been captured, once, into an image still sitting in the local image
store. But the procedure that was written and rehearsed was the volume
procedure, so the volume is what came back, and everything written into the
container layer was outside every mechanism running on a schedule.
What the registry does and does not tell you
Three conclusions follow, and each contradicts something people say about container estates.
An image registry is not a backup system. It stores definitions — the artefact you would rebuild a container from, which is worth protecting and is protected by ordinary means. It stores no runtime state, because runtime state was never pushed to it. A registry holding every image an estate runs tells you the estate can be reconstructed empty, and nothing about the orders, uploads or databases those containers were holding.
docker commit captures the wrong half. It is not that commit fails; it
succeeded above and its result was verified. It captures the container’s diff
against its image, which is where accumulated drift lives, and structurally
cannot reach what is behind a mount. Used as a pre-change safety copy it
produces something that looks like a backup, runs under docker run, and omits
the data.
State in a container layer is state nobody is protecting. Not because the
tools are inadequate, but because nothing points at it. It is not in the image
CI builds, not in the volume the backup job archives, and it disappears on the
next docker rm — an event that happens on every deploy. The only sound
handling is to keep durable data out of it: mount the path, and back up the
mount.
What to take from this
- Container state lives in three places with three lifetimes: read-only image
layers, the container’s writable layer, and attached storage. Only the first
is in the image, and only the third survives
docker rmon its own. - Measured on Docker 29.7.2:
docker commitcaptured/etc/app-markerfrom the writable layer — a new container printedwritten-into-the-container-layerand exited 0 — and captured nothing from the volume, whose mount point listed astotal 8with only.and... - A named volume on the
localdriver is a host directory; the capture reportedMountpoint: /var/lib/docker/volumes/rbdr-data/_data. Streaming it out through a helper container produced a 160-byte archive that was independent of both the container and the volume. - After both were destroyed,
docker volume inspectreturnedError response from daemon: get rbdr-data: no such volumeat exit 1, and the archive restored into a new volume with md59eb4e2ad8e08e1dcaaf87ababab964b0matching on both sides. - The container-layer file was gone in the same restore, with
cat: can't open '/etc/app-marker': No such file or directoryat exit 1. It was captured into an image and it was still not part of any restore that was being practised.
Cross-course references
- Docker & Containers for Production Sysadmins — Part VIII (Storage) develops the writable-layer, volume and bind-mount distinction that this lesson measures, and Part XXI (Backup) builds the volume backup and restore procedure whose boundary is exactly the one established here.
- Kubernetes for Production Sysadmins — Part XLIX (Volumes) and Part XCVI (Workload Backup) carry the same split into an orchestrator, where a rescheduled pod replaces the container layer routinely and the only surviving state is what a PersistentVolume holds, which makes the distinction taught here operationally unavoidable rather than merely correct.
- Linux for Production Sysadmins — Part LXXVIII (Containers from the Linux Perspective) explains the mount namespace and union filesystem underneath the behaviour above, which is why a mount point in a committed image contains a mount rather than the data behind it.
Quiz
Knowledge check · 5 questions
Q1. A team runs `docker commit` on a live container before an upgrade and pushes the image. The application writes its data to a named volume at /var/lib/app. What does the pushed image contain?
Q2. In the capture the container and the named volume were both destroyed, then the volume archive was unpacked into a new volume. Which statement describes what came back?
Q3. The empty directory listing at the volume mount point in a container started from the committed image shows that the commit failed or was incomplete.
Q4. Forty containers all write durable data to named volumes. Which actions protect that data? Select all that apply.
Q5. A service writes customer uploads to /srv/uploads inside its container, with nothing mounted at that path. State where the data is and what happens at the next deploy.
Passing score: 75%. Answers are checked in this browser.