Backup & DRXIII · Container and Kubernetes RecoveryKubernetes
Persistent volumes and CSI snapshots
What you'll learn
- Trace the stored bytes of a workload from the claim it names to the storage its class provisioned
- Predict what a reclaim policy does to volume data when the claim that requested it is deleted
- Explain what a CSI VolumeSnapshot orchestrates and which storage system ends up holding the result
- Assess a VolumeSnapshot schedule against application consistency and against loss of the storage system
Prerequisites
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
An etcd snapshot restored in the previous lesson brings the API objects back and
then stops at the edge of the volumes those objects point to, and that edge is
where this lesson works. A recovered cluster can report a PersistentVolumeClaim
as Bound and a pod as 1/1 Running over a mount point holding nothing at all,
and every one of those states is correct as far as the control plane is
concerned. What the volume contains is decided where the API never looks.
A claim is a request; a volume is the thing that answers it
The Kubernetes storage model separates the thing a workload asks for from the thing that satisfies the request, and the separation is what makes recovery reasoning possible at all.
A PersistentVolumeClaim is a request. It names a capacity, one or more access modes, a StorageClass, and optionally a data source — the field the second half of this lesson turns on. It is a namespaced object, and it is the only storage object a pod ever refers to. A pod spec mounts a claim by name; it does not know what answered the claim, where that storage physically is, or how many times it has been replaced.
A PersistentVolume is the object that represents the storage that answered. It carries the driver-specific handle for a real piece of storage: a directory on a node, an iSCSI target, an RBD image, a cloud disk. Binding pairs one claim with one volume, and once bound the pair is exclusive. The claim’s identity is a name in a namespace; the volume’s identity is an opaque handle to something a storage system created.
A StorageClass is the policy that gets applied when no volume exists yet. It
names the provisioner that will create one, carries parameters that
provisioner understands, and sets two fields that the rest of this lesson turns
on. volumeBindingMode decides whether provisioning happens as soon as the claim
appears or waits until a pod is scheduled, which is why a claim re-created
during a recovery can sit Pending indefinitely on a cluster whose nodes cannot
yet run the workload: the storage is waiting on a scheduling decision that has
not been made, and nothing is wrong with the claim. reclaimPolicy decides what
happens to the provisioned storage when the claim that caused it to exist is
deleted.
The reclaim policy is worth stating in full because it is the field that
converts an ordinary namespace cleanup into a data-loss event. Kubernetes
documents Retain and Delete as the policies in current use, and Recycle as
deprecated. Under Retain, deleting the claim leaves the volume object behind
in a Released state with the underlying storage untouched, and an
administrator has to decide what to do with it. Under Delete, removing the
claim removes the volume object and asks the provisioner to destroy the
storage asset it represents. Dynamically provisioned volumes inherit the policy
from their class, and the class default is Delete. The value is stamped onto
each volume as persistentVolumeReclaimPolicy at provisioning time, and it can
be patched on a bound volume afterwards — which is the only way to change the
outcome for storage that already exists.
So the sentence that matters for recovery is short. Deleting a claim is not a metadata operation. Under the default policy it is a storage operation, and the API gives you no more warning about it than it gives you about deleting a ConfigMap.
Measured: the claim came back Bound and the orders did not come back
A single-node k3s cluster running the local-path provisioner was given four
objects — a namespace, a claim, a ConfigMap and a pod — and the pod wrote two
orders into the volume. The provisioner satisfies a claim by creating a
directory on the node, so the storage in question is visible from the host.
$ kubectl -n rbdr-shop exec rbdr-orders-writer -- cat /data/orders.csv ORDER-1001,4500.00
ORDER-1002,1250.00
orders.csv md5: 9eb4e2ad8e08e1dcaaf87ababab964b0
--- where that data physically lives on the node ---
/var/lib/rancher/k3s/storage/pvc-6edd5db0-25f4-4b33-b26b-e2ad78aac9cf_rbdr-shop_rbdr-orders/orders.csvThe directory name encodes the whole binding: the volume’s identity
pvc-6edd5db0-25f4-4b33-b26b-e2ad78aac9cf, the namespace rbdr-shop, and the
claim name rbdr-orders. That string is the only durable link between the
business data and the objects that describe it. The namespace was then deleted,
which is an entirely routine operation and takes the claim with it.
$ kubectl delete namespace rbdr-shop namespace "rbdr-shop" deleted
namespace fully removed after 0s
--- is the PersistentVolume data still on disk? ---
GONE - the local-path provisioner reclaimed the volume with the PVCNothing here was a mistake in the tooling. The claim was deleted, the volume’s reclaim policy said what to do about that, and the provisioner did it. The orders were destroyed on the node before any recovery had even been attempted, which is the detail that makes the next transcript worth reading carefully: the recovery below is not a failed restore, it is a successful reconstruction of everything the cluster knew about.
$ kubectl -n rbdr-shop get pvc,configmap,pod--- 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 1Read the volume identity in that listing against the one in the first
transcript. The claim is Bound, but it is bound to
pvc-a29538af-09d4-4bb8-82bb-b2c222b9db93 — a volume the provisioner created
fresh, in a directory whose entire listing is two entries long and holds no file
at all. Bound is a statement about the claim’s relationship to a volume. It
says nothing whatsoever about what that volume contains, and a health check that
reads Bound and 1/1 Running will report a fully recovered service on top of
an empty disk.
The capture goes on to show what does bring the orders back, and it is worth
being exact about the sequence because the exactness is the point. The order
lines had to be written again before anything could be copied, since the
originals were already destroyed. A tar archive of the volume directory was then
written to /tmp on the node — 2560 bytes — the namespace was destroyed a
second time, the manifests recreated the objects against a third volume
identity, and only after the archive was unpacked into that third directory did
the file read back at md5 9eb4e2ad8e08e1dcaaf87ababab964b0, the value recorded
when the application first wrote it. The archive was the one artefact in the
sequence that the reclaim policy had no authority over, because it was never
inside a volume the cluster managed.
VolumeSnapshot, VolumeSnapshotContent, VolumeSnapshotClass
The obvious response to the transcripts above is that the cluster should have been taking snapshots of the volume, and Kubernetes has an API for precisely that. It mirrors the claim-and-volume model one level up.
A VolumeSnapshot is a namespaced request for a point-in-time copy of a named
claim; it is the snapshot world’s PVC. A VolumeSnapshotContent is the
cluster-scoped object representing a snapshot that actually exists in a storage
system, carrying the driver’s snapshotHandle; it is the snapshot world’s PV.
A VolumeSnapshotClass names the CSI driver to use, the parameters it
understands, and a deletionPolicy of Delete or Retain that governs whether
removing the content object also destroys the storage-side snapshot.
None of this is built into the Kubernetes control plane. The three resources are
custom resource definitions shipped by the external-snapshotter project, and
they need a snapshot controller running in the cluster plus a csi-snapshotter
sidecar alongside each CSI driver that supports snapshots. A cluster where
kubectl get volumesnapshotclass returns nothing does not have a broken
snapshot feature; it has no snapshot feature. The capture cluster used above is
one of those: it ran the local-path provisioner with no CSI snapshot driver
and no snapshot controller installed, which is why every command in this section
is shown without output rather than with output invented for it.
$ kubectl get volumesnapshotclassCreating a snapshot is a request against a claim, and reading it back means watching its status:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: rbdr-orders-0900
namespace: rbdr-shop
spec:
volumeSnapshotClassName: csi-example-snapclass
source:
persistentVolumeClaimName: rbdr-orders
The controller binds that object to a VolumeSnapshotContent, and the status
fields worth alerting on are readyToUse, which only becomes true once the
storage system reports the snapshot usable, and restoreSize. Recovery then
happens by naming the snapshot as the data source of a new claim:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: rbdr-orders-restored
namespace: rbdr-shop
spec:
storageClassName: csi-example-sc
dataSource:
name: rbdr-orders-0900
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes: ['ReadWriteOnce']
resources:
requests:
storage: 64Mi
The two things a VolumeSnapshot is not
A CSI snapshot gives you a genuine, useful recovery point, and on storage that
does copy-on-write it costs almost nothing at the moment it is taken: the ZFS
capture this section returns to below recorded a freshly taken snapshot at
rbdrprod/ledger@0900 0B 50.1M. That figure describes the moment rather
than the recovery point. Once the live dataset had been rewritten past it, the
same snapshot read rbdrprod/ledger@0900 47.0M 50.1M, because it had become
the only thing still referencing the blocks production overwrote. A snapshot
retention window is therefore a capacity commitment that grows with write
volume, not with snapshot count, and a schedule sized against the first number
is the one that fills the array at the second.
None of that is an argument against taking snapshots. It is an argument about what a schedule of them covers, because a CSI snapshot falls two things short of a backup and both shortfalls have already been measured elsewhere in this course.
It is not application-consistent unless the workload was quiesced. The
snapshot controller asks the storage system to copy a volume at a point in time.
Nothing in that sequence tells the process inside the container to flush its
buffers, finish its transaction, or stop writing. What lands in the snapshot is
whatever was on disk at that instant, which is the crash-consistent state Part
IV of this course examined: a database will usually replay its log and come up,
and a system that spreads one logical write across several files may not. Making
it application-consistent requires a pre-snapshot action inside the container —
a freeze, a flush, a BEGIN BACKUP-style call — orchestrated by something that
knows the application. The CSI layer has no such knowledge and never claims to.
It usually lives in the same storage system as the volume it came from. The
snapshotHandle in a VolumeSnapshotContent is a reference the driver hands back
to its own array, its own Ceph pool, its own cloud disk service. That is the
shared-fate problem from Part VI, restated in Kubernetes vocabulary, and Part VI
measured what it costs:
$ zpool import--- zpool import (can ZFS find anything to import?) ---
no pools available to import
--- can the 09:00 snapshot be reached? ---
cannot open 'rbdrprod': dataset does not exist
--- what the INDEPENDENT backup pool still holds ---
NAME USED REFER
rbdrbkp 100M 24K
rbdrbkp/ledger 100M 50.1M
rbdrbkp/ledger@0900 50.0M 50.1M
rbdrbkp/ledger@0930 0B 50.1MThe ZFS mechanism and the CSI mechanism are unrelated in implementation and identical in this respect. A copy that the storage system holds internally disappears with the storage system. A copy that was sent somewhere else does not. Nothing about wrapping the first arrangement in Kubernetes objects changes which of the two you have.
The snapshot objects are cluster state, and cluster state is lost with etcd
There is a second dependency that is easy to miss because it points the other way. VolumeSnapshot and VolumeSnapshotContent are API objects. They live in etcd, exactly like the claim, the pod and the ConfigMap in the transcripts above, and they are lost in exactly the same circumstances.
Losing etcd, or restoring it to a point before a snapshot was created, removes
the cluster’s knowledge that the snapshot exists. Under a deletionPolicy of
Retain the storage-side snapshot itself survives that — but a snapshot the
cluster cannot see is not a recovery point you can use. Reconnecting it means
creating a pre-provisioned VolumeSnapshotContent by hand, with the driver name
and the exact snapshotHandle filled in, and that handle only exists in two
places: inside the storage system, and in whatever record you kept outside the
cluster.
Which makes the recording of those handles an operational obligation, not a nicety. The etcd snapshot from the previous lesson protects the objects; the storage system protects the blocks; and the handle that ties one to the other is the piece nobody backs up until the first time they need it. Export the snapshot inventory — namespace, claim, snapshot name, class, handle, creation time — on the same schedule as the snapshots themselves, and keep it where a lost cluster cannot take it with it.
What to take from this
- On k3s v1.36.3+k3s1 with the
local-pathprovisioner, deleting the namespace deleted the claim and the capture recordedGONE - the local-path provisioner reclaimed the volume with the PVC. The reclaim policy decided that, not the delete command. - Re-applying the manifests bound the same claim name
rbdr-orderstopvc-a29538af-09d4-4bb8-82bb-b2c222b9db93, a different volume identity from thepvc-6edd5db0-25f4-4b33-b26b-e2ad78aac9cfdirectory that had held the orders.Bounddescribes a relationship, not contents. - The recovered pod reported
1/1 Runningafter 6s and answeredcat: can't open '/data/orders.csv': No such file or directorywithcommand terminated with exit code 1. A healthy workload is not evidence of present data. - The orders returned only from a 2560-byte tar archive held on the node outside
any volume, unpacked into the directory of a third volume identity, where the
file read back at md5
9eb4e2ad8e08e1dcaaf87ababab964b0. No reclaim policy had authority over that archive. - Shared fate is measurable. On zfs-2.4.1-1ubuntu5, after the production device
was overwritten,
zpool importreportedno pools available to importand the 09:00 snapshot answeredcannot open 'rbdrprod': dataset does not exist, while the independent pool still listedrbdrbkp/ledger@0900 50.0M 50.1M. A CSI snapshot beside its volume is that same arrangement. - Cheapness is a property of the moment, not of the recovery point. The same ZFS
capture recorded
rbdrprod/ledger@0900 0B 50.1Mwhen the snapshot was taken andrbdrprod/ledger@0900 47.0M 50.1Monce the live dataset had been rewritten past it, because the snapshot then held the only reference to the overwritten blocks.
Cross-course references
- Kubernetes for Production Sysadmins — Part L (PersistentVolumes and Claims) and Part LI (StorageClasses) develop the binding and provisioning model that this lesson uses only as far as recovery requires, and Part LV (Storage Snapshots) teaches the VolumeSnapshot API as an operational feature; read them for the mechanism, and read this lesson for what the mechanism does and does not protect.
- Ceph & Distributed Storage for Production Sysadmins — Part LXXXVIII (Kubernetes Storage Failure Scenarios) is the concrete answer to the shared-fate question posed abstractly here: when the CSI driver is Ceph, the snapshot the cluster created lives in the same RADOS cluster as the image it was taken from, and that part shows what that costs when the cluster degrades.
- Linux for Production Sysadmins — Part III (Filesystems and Files) is the
layer the volume in this capture actually was, a directory under
/var/lib/rancher/k3s/storageon one node, so its durability is the durability of that node’s filesystem and nothing more, no matter what the claim object says.
Quiz
Knowledge check · 5 questions
Q1. A PVC bound to a dynamically provisioned volume sits in a namespace that an operator deletes. What decides whether the bytes on the underlying storage still exist a minute later?
Q2. A nightly CSI VolumeSnapshot of a database claim completes and reports readyToUse. The storage array that holds both the volume and the snapshot is then lost outright. What can be recovered from that snapshot?
Q3. Which of these are true of CSI VolumeSnapshots as Kubernetes and the external-snapshotter project describe them? Select all that apply.
Q4. In the k3s capture, the re-created PersistentVolumeClaim reported Bound while being bound to a different volume than the one that had held the orders file.
Q5. A team protects a stateful workload with hourly CSI VolumeSnapshots on the same array as the volumes, plus a nightly etcd snapshot copied off-cluster. State what is still unprotected and why.
Passing score: 75%. Answers are checked in this browser.