The previous lesson ended with a PersistentVolume that appeared on its own, and with a black box left unopened: we said "the provisioner creates the volume" without explaining who that provisioner is, how it finds out there is a pending PVC, how it attaches the disk to a node and how it ends up mounted in the container. That machinery is called CSI, the Container Storage Interface, and understanding it is what separates someone who applies manifests from someone who can diagnose why a pod has spent twenty minutes in ContainerCreating. Besides, the StorageClass announced two capabilities we have not used yet: expansion of a running volume and snapshots, indispensable before touching the schema of a production database. In this lesson you will put them to work on bookings-postgres, and you will close with the warning that prevents the most incidents: a snapshot is not a backup.
Contents
- What problem CSI solved
- The architecture: controller plugin and node plugin
- The sidecars and the
CSIDriverandCSINodeobjects - The complete end-to-end flow of a volume
- Preparing the practice cluster
- Volume expansion: requirements and mechanics
- Online expansion versus expansion with a restart
- Snapshots: the three objects
- Creating a snapshot of
bookings-postgres - Restoring from a snapshot with
dataSource - Cloning a PVC
- The warning: consistency and what a snapshot is NOT
- What problem CSI solved
Before 2018, the code that talked to AWS EBS, to Google Persistent Disk, to Ceph or to NetApp lived inside the Kubernetes repository. They were called in-tree drivers, and the model had three serious problems:
| Problem | Consequence |
|---|---|
| The provider's code shipped inside Kubernetes | A bug in one vendor's driver could bring down the kube-controller-manager |
| Release cycles were coupled | Fixing a bug meant waiting for a Kubernetes release, and every improvement went through the project's review |
| The provider's credentials lived in the control plane | A considerable attack surface |
CSI (Container Storage Interface) is a standard specification — not exclusive to Kubernetes: Mesos and Nomad use it too — defining a gRPC contract between the orchestrator and the storage system. The provider implements that contract in a component of its own, packages it as a container and deploys it in the cluster, like any other workload. The result: the in-tree drivers became obsolete and were removed (kubernetes.io/aws-ebs, kubernetes.io/gce-pd, kubernetes.io/azure-disk no longer exist in 1.30+). Today all network storage goes through CSI, and there are more than a hundred drivers available.
- The architecture: controller plugin and node plugin
A CSI driver is always deployed in two halves, because storage operations are of two different natures:
flowchart TB
subgraph CP["Cluster control plane"]
API["kube-apiserver"]
subgraph CTRLPOD["CONTROLLER plugin (Deployment, 1-2 replicas)"]
SC1["sidecars: external-provisioner,<br/>-attacher, -resizer, -snapshotter"]
DRVC["CSI driver<br/>(provider logic)"]
end
end
subgraph N1["Node 1"]
K1["kubelet"]
subgraph NODEPOD["NODE plugin (DaemonSet, on EVERY node)"]
REG["node-driver-registrar"]
DRVN["CSI driver<br/>(local operations)"]
end
end
NUBE[("Storage provider<br/>API")]
API <--> SC1
SC1 <-->|"gRPC over a UNIX socket"| DRVC
DRVC <--> NUBE
K1 <-->|"gRPC over a UNIX socket"| DRVN
REG -->|"registers the driver"| K1
DRVN --> DISCO["Format and mount<br/>on the node's filesystem"]
| Controller plugin | Node plugin | |
|---|---|---|
| Deployed as | A Deployment (1 or 2 replicas) |
A DaemonSet (one per node, 06-02) |
| Talks to | The storage provider's API | The node's filesystem |
| Operations | CreateVolume, DeleteVolume, ControllerPublishVolume, CreateSnapshot, ControllerExpandVolume |
NodeStageVolume, NodePublishVolume, NodeExpandVolume |
| In plain English | "Create a 20 GiB disk and attach it to machine 3" | "Format that disk and mount it at this path" |
| Needs cloud credentials | Yes | No |
The separation is not arbitrary: creating a disk is a call to a remote API that should only be made once from one place; mounting it is a local operation that only the machine where the pod is can perform.
- The sidecars and the
CSIDriver and CSINode objects
CSIDriver and CSINode objectsHere is the elegant part of the design. The provider's driver does not talk to the Kubernetes API: it only implements the CSI gRPC contract. What watches the API and translates are some helper containers maintained by the Kubernetes project, the sidecars, deployed alongside the driver in the same pod (the multi-container pattern of 06-04).
| Sidecar | What it watches in the API | What it calls on the driver |
|---|---|---|
external-provisioner |
PVCs pending for one of its classes | CreateVolume / DeleteVolume |
external-attacher |
VolumeAttachment objects |
ControllerPublishVolume |
external-resizer |
PVCs whose requests.storage has grown |
ControllerExpandVolume |
external-snapshotter |
VolumeSnapshot objects |
CreateSnapshot / DeleteSnapshot |
node-driver-registrar |
— | Registers the driver with the node's kubelet |
There is also a livenessprobe that probes the driver's health. Thanks to this design, a vendor only writes the logic of its storage; all the Kubernetes integration is already written and tested. And there are two API objects that describe the state of this infrastructure:
CSIDriver
It declares the capabilities of an installed driver. The cluster consults it to know what it can ask of it.
Name: hostpath.csi.k8s.io
Spec:
Attach Required: true # is the attach phase needed?
Fs Group Policy: File # how fsGroup is applied (see 05-03)
Pod Info On Mount: true # the driver receives pod data on mounting
Volume Lifecycle Modes: Persistent, EphemeralCSINode
It is created automatically for each node and records which drivers are available there and by what identifier the provider knows that machine.
Name: rutas-norte
Spec:
Drivers:
hostpath.csi.k8s.io:
Node ID: rutas-norte
Allocatables:
Count: 10 # maximum number of volumes per node
Topology Keys: [topology.hostpath.csi/node]That Count is a very real limitation in production: AWS, for instance, restricts the number of EBS volumes that can be attached to an instance. When it runs out, pods stay Pending with node(s) exceed max volume count, a message you now know the origin of.
- The complete end-to-end flow of a volume
This is the diagram to remember: what happens exactly from the moment you apply a PVC until the process writes its first byte.
sequenceDiagram
participant U as You (kubectl apply)
participant API as apiserver
participant PRO as external-provisioner
participant DRV as CSI driver (controller)
participant NUBE as Provider API
participant SCH as Scheduler
participant ATT as external-attacher
participant KBL as node kubelet
participant DVN as CSI driver (node)
U->>API: PVC (class rutasnorte-fast)
Note over API: PVC Pending (WaitForFirstConsumer)
U->>API: Deployment bookings-postgres
SCH->>API: pod assigned to node-2
PRO->>DRV: CreateVolume(20Gi, node-2's zone)
DRV->>NUBE: create disk
NUBE-->>DRV: volumeHandle vol-0a1b2c3d
PRO->>API: creates the PV and binds it (Bound)
ATT->>API: creates a VolumeAttachment
ATT->>DRV: ControllerPublishVolume(vol, node-2)
DRV->>NUBE: attach the disk to the instance
KBL->>DVN: NodeStageVolume
Note over DVN: formats (ext4) and mounts in the<br/>node's global directory
KBL->>DVN: NodePublishVolume
Note over DVN: bind-mounts into the pod's directory<br/>and applies fsGroup
KBL->>API: pod Running
The five phases, with their technical names and the symptom when they fail:
| Phase | Who | If it fails, you will see |
|---|---|---|
| Provision | external-provisioner + controller |
PVC Pending, ProvisioningFailed event |
| Attach | external-attacher + controller |
Pod ContainerCreating, FailedAttachVolume event, Multi-Attach error |
| NodeStage | kubelet + node plugin | FailedMount, formatting or mount-option errors |
| NodePublish | kubelet + node plugin | FailedMount, permission or fsGroup problems |
| Unpublish → Delete | The same ones, in reverse order | PVC in Terminating, orphaned volumes |
Two details worth their weight in gold when diagnosing. NodeStageVolume happens once per node; NodePublishVolume, once per pod, which is why a volume can be "mounted" and yet the pod not see it: they are two different mounts. And you can follow the attach phase in the API with kubectl get volumeattachments, something rarely known: it shows one row per attached volume, with the driver, the PV, the node and an ATTACHED column; if that column is false for minutes, the problem is in the controller plugin, not on the node.
- Preparing the practice cluster
The minikube provisioner (k8s.io/minikube-hostpath) supports neither expansion nor snapshots, so for this lesson we will install the hostpath CSI driver, which does implement them. It is a reference driver for testing — do not use it in production — but it faithfully reproduces the behaviour of a real CSI.
# 1. The snapshot CRDs and the snapshot-controller (they do NOT ship with Kubernetes)
VER=v8.1.0
BASE=https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/$VER
for f in client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml \
client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml \
client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml \
deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml \
deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml; do
kubectl apply -f "$BASE/$f"
done
# 2. The hostpath CSI driver
minikube addons enable csi-hostpath-driver -p rutas-norte
minikube addons enable volumesnapshots -p rutas-norte
# 3. Check
kubectl get csidrivers
kubectl get sc
kubectl get volumesnapshotclassesNAME ATTACHREQUIRED MODES AGE
hostpath.csi.k8s.io true Persistent,Ephemeral 1m
NAME PROVISIONER RECLAIMPOLICY ALLOWVOLUMEEXPANSION
csi-hostpath-sc hostpath.csi.k8s.io Delete true
NAME DRIVER DELETIONPOLICY AGE
csi-hostpath-snapclass hostpath.csi.k8s.io Delete 1mImportant point: the snapshot CRDs and the snapshot-controller are not part of Kubernetes. On a managed cluster they usually come installed, but on your own you have to add them. If you try to create a VolumeSnapshot without them, the error is no matches for kind "VolumeSnapshot" in version "snapshot.storage.k8s.io/v1".
Now we rewrite rutasnorte-fast on top of this driver — it has to be deleted and recreated, because StorageClasses cannot be edited —, keeping the contract (the name and Retain) and gaining the two capabilities:
# k8s/environments/dev/storageclass-fast-csi.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: rutasnorte-fast
labels: { app.kubernetes.io/part-of: rutas-norte }
provisioner: hostpath.csi.k8s.io # CSI driver for testing
reclaimPolicy: Retain
allowVolumeExpansion: true # <-- now it IS
volumeBindingMode: Immediate
- Volume expansion: requirements and mechanics
The day comes when 10 GiB is not enough: the bookings table takes up 8.4 GiB and the disk is on course to fill up in two weeks. Expansion lets you grow the volume without migrating the data.
The requirements, all three at once: the StorageClass must have allowVolumeExpansion: true, the CSI driver must implement the capability (ControllerExpandVolume) and the PVC must be Bound.
And the absolute restriction: a volume cannot be shrunk. Not with a new PVC, not with kubectl edit, not in any way; Kubernetes rejects the request with spec.resources.requests.storage: Forbidden: field can not be less than previous value.
The reason is data safety: shrinking a filesystem requires moving and compacting it, and no provider guarantees doing so without risk. Practical consequence: ask sensibly, because you can only go up. Shrinking means creating a smaller PVC and copying, with downtime, as in 05-04.
How it is requested
You edit the PVC's spec.resources.requests.storage — never the PV's. The quick route is a patch; the proper one is editing the manifest in Git and applying it, as the declarative model of 01-06 requires:
kubectl get pvc bookings-postgres-data -n rutas-norte-dev # CAPACITY: 10Gi
kubectl patch pvc bookings-postgres-data -n rutas-norte-dev \
-p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
# The process is followed through the PVC's CONDITIONS
kubectl describe pvc bookings-postgres-data -n rutas-norte-dev | tail -10Conditions:
Type Status Message
Resizing True Waiting for user to (re-)start a pod
Events:
Normal Resizing external-resizer hostpath.csi.k8s.io External resizer is resizing volume
Normal FileSystemResizeSuccessful kubelet MountVolume.NodeExpandVolume succeededAnd the check that really matters, inside the container:
kubectl get pvc bookings-postgres-data -n rutas-norte-dev # CAPACITY: 20Gi
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- df -h /var/lib/postgresql/data
# /dev/vdb 20G 8.4G 11G 45% /var/lib/postgresql/data20 GiB, with the 8.4 GiB of data intact and without having stopped the database.
- Online expansion versus expansion with a restart
Expansion happens in two stages, hence the two conditions you will see on the PVC:
flowchart LR
A["You edit the PVC:<br/>10Gi -> 20Gi"] --> B["external-resizer<br/>ControllerExpandVolume"]
B --> C["The REAL disk becomes 20Gi<br/>(condition: Resizing)"]
C --> D{"Online<br/>expansion?"}
D -->|"Yes"| E["kubelet: NodeExpandVolume<br/>resize2fs over the mounted FS"]
D -->|"No"| F["FileSystemResizePending<br/>-> RECREATE the pod"] --> E
E --> H["df -h inside the container<br/>shows 20Gi"]
| Online expansion | Expansion with a restart | |
|---|---|---|
| The pod keeps running | Yes | No: it has to be recreated |
| Condition that appears | Resizing, and it goes away |
Persistent FileSystemResizePending |
| Support | Most modern CSI drivers | Old drivers, some cases with volumeMode: Block |
| Action required | None | kubectl rollout restart |
If the PVC is left with the FileSystemResizePending condition, the disk is already bigger but the filesystem does not know it: df -h inside the container still shows the old size. The fix is to recreate the pod, and the kubelet runs NodeExpandVolume during the mount:
kubectl get pvc bookings-postgres-data -n rutas-norte-dev \
-o jsonpath='{.status.conditions[*].type}'; echo # -> FileSystemResizePending
# bookings-postgres uses Recreate: there will be a brief outage. Warn people first.
kubectl rollout restart deploy/bookings-postgres -n rutas-norte-dev
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- df -h /var/lib/postgresql/dataTwo operational warnings. First, subPath can prevent the automatic expansion of the filesystem on some drivers: it is the reason, flagged in 05-03, why on bookings-postgres we prefer the PGDATA variable to subPath. And second, watch how full it is getting before it becomes urgent: Kubernetes exposes kubelet_volume_stats_available_bytes and kubelet_volume_stats_capacity_bytes, and an alert at 75% gives you plenty of room (07-03 and 07-04).
- Snapshots: the three objects
A snapshot is a point-in-time copy of a volume's content, taken by the storage system. Its great virtue is that it is almost instantaneous — modern systems use copy-on-write, so they do not duplicate the data on creating it — and that it allows new volumes to be created from it. The object model is an exact replica of the volumes one, which means you already know it:
| Volumes | Snapshots | Role |
|---|---|---|
StorageClass |
VolumeSnapshotClass |
Which driver and with what policy (cluster resource) |
PersistentVolumeClaim |
VolumeSnapshot |
The user's request (namespaced) |
PersistentVolume |
VolumeSnapshotContent |
The object representing the real snapshot (cluster resource) |
flowchart LR
subgraph NS["namespace rutas-norte-dev"]
PVC["PersistentVolumeClaim<br/>bookings-postgres-data"]
VS["VolumeSnapshot<br/>snap-pre-migration"]
end
subgraph CL["Cluster resources"]
PV["PersistentVolume"]
VSC["VolumeSnapshotContent<br/>snapcontent-a1b2..."]
VSCLASS["VolumeSnapshotClass<br/>rutasnorte-snapclass"]
end
PVC --> PV
VS -->|"source"| PVC
VS --> VSC
VSC -.->|"class"| VSCLASS
VSC -->|"lives in the SAME<br/>storage system"| PV
And the VolumeSnapshotClass, analogous to the StorageClass:
# k8s/base/volumesnapshotclass.yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: rutasnorte-snapclass
labels: { app.kubernetes.io/part-of: rutas-norte }
driver: hostpath.csi.k8s.io # must match the StorageClass's
deletionPolicy: Retain # Retain or Delete, as with PVsdeletionPolicy decides what happens to the real snapshot when the VolumeSnapshot object is deleted: Delete destroys it; Retain keeps it, and it is the choice consistent with rutasnorte-fast for the database snapshots.
Prerequisites, which you already covered in section 5: the three CRDs, the snapshot-controller and the external-snapshotter sidecar alongside the driver.
- Creating a snapshot of
bookings-postgres
bookings-postgresThe real scenario: tomorrow version 3.0 of bookings-api is deployed, and it includes a schema migration — it adds columns to bookings and rewrites the customers table. If the migration goes wrong, you have to go back in minutes, and a rollout undo of the Deployment (02-04) does not undo the changes in the database. A prior snapshot does.
Starting state: three bookings in the table, according to kubectl exec ... psql -c "SELECT count(*) FROM bookings;". The snapshot manifest:
# k8s/environments/dev/snapshot-pre-migration.yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: snap-postgres-pre-v3
namespace: rutas-norte-dev
labels: { app: bookings-postgres, app.kubernetes.io/part-of: rutas-norte, environment: dev }
annotations:
rutasnorte.example/reason: "prior to the bookings-api 3.0.0 schema migration"
rutasnorte.example/owner: "[email protected]"
rutasnorte.example/ticket: "RN-874"
spec:
volumeSnapshotClassName: rutasnorte-snapclass
source:
persistentVolumeClaimName: bookings-postgres-data # the PVC to photographkubectl apply -f k8s/base/volumesnapshotclass.yaml
kubectl apply -f k8s/environments/dev/snapshot-pre-migration.yaml
kubectl get volumesnapshot -n rutas-norte-devNAME READYTOUSE SOURCEPVC RESTORESIZE SNAPSHOTCONTENT AGE
snap-postgres-pre-v3 true bookings-postgres-data 20Gi snapcontent-a1b2c3d4-... 10sREADYTOUSE: true is the column to look at: until it is, the snapshot is no use for restoring. The describe adds the Bound Volume Snapshot Content Name, the Creation Time, the Restore Size and the CreatingSnapshot and SnapshotCreated events from the snapshot-controller.
The annotations with the reason, the owner and the ticket are not decoration: a cluster accumulates snapshots, each one costs money, and without that information nobody dares delete any of them. It is the direct application of what you learned in 02-07.
- Restoring from a snapshot with
dataSource
dataSourceNow we simulate the disaster. The migration runs and goes badly: it deletes bookings.
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "DELETE FROM bookings WHERE id > 1;
SELECT count(*) FROM bookings;" # -> 1Restoring does not overwrite the original volume: it creates a new PVC whose dataSource points at the snapshot. It is a crucial difference, because it preserves the evidence of the damaged state so it can be analysed afterwards.
# k8s/environments/dev/pvc-restored.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bookings-postgres-data-restored
namespace: rutas-norte-dev
labels: { app: bookings-postgres, app.kubernetes.io/part-of: rutas-norte, environment: dev }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-fast # the SAME class as the source
resources: { requests: { storage: 20Gi } } # >= the snapshot's restoreSize
dataSource:
name: snap-postgres-pre-v3
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.ioApplying it brings up a second Bound PVC alongside the original, with its own PV. And we point the Deployment at the restored volume:
kubectl apply -f k8s/environments/dev/pvc-restored.yaml
kubectl scale deploy bookings-postgres -n rutas-norte-dev --replicas=0
kubectl patch deploy bookings-postgres -n rutas-norte-dev --type=json -p='[
{"op":"replace",
"path":"/spec/template/spec/volumes/0/persistentVolumeClaim/claimName",
"value":"bookings-postgres-data-restored"}]'
kubectl scale deploy bookings-postgres -n rutas-norte-dev --replicas=1
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT * FROM bookings;" id | customer | route | date
----+-----------------+-------------------+------------
1 | Marta Iglesias | Bilbao-Santander | 2026-08-15
2 | Ignacio Sabater | Oviedo-Gijon | 2026-08-16
3 | Lucia Berenguer | Leon-Ponferrada | 2026-08-17The three bookings are back. Four conditions for a restore to work:
- The new PVC must ask for at least the snapshot's
restoreSize. Less, and it fails. - It must use the same StorageClass (or at least the same driver): a snapshot does not cross storage systems.
- The snapshot must be
READYTOUSE: true. - The snapshot must exist in the same namespace as the new PVC. To cross namespaces you have to import the
VolumeSnapshotContentby hand.
- Cloning a PVC
A sibling case and a very useful one: creating a copy of a volume straight from another PVC, without going through a snapshot. Only the dataSource's kind changes:
# k8s/environments/dev/pvc-clone-for-testing.yaml (same shape as the restored one)
metadata:
name: bookings-postgres-data-clone-qa
annotations:
rutasnorte.example/reason: "clone to test the 3.0 migration without touching dev"
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-fast
resources: { requests: { storage: 20Gi } }
dataSource:
name: bookings-postgres-data # ANOTHER PVC, not a snapshot
kind: PersistentVolumeClaim # <-- the only thing that changes| Restore from a snapshot | Clone a PVC | |
|---|---|---|
dataSource.kind |
VolumeSnapshot |
PersistentVolumeClaim |
| Source | A photograph of a past moment | The volume's current state |
| Requires a prior snapshot | Yes | No |
| Namespace | The same | The same |
| Typical use | Recovering from a disaster | Setting up a test environment with real data |
Cloning is the clean way of giving the development team a copy of the pre-production data to rehearse a migration. And mind the small print: that clone contains real personal customer data — name, ID number, phone number, email address. A clone for testing is data processing exactly as the original is and demands the same protections, or better still, prior anonymisation. It is picked up again in 05-06.
One technical limitation worth knowing: cloning a PVC while the database is writing produces a copy equivalent to one taken during a power cut, with the same consistency problems as in the next section.
- The warning: consistency and what a snapshot is NOT
A disk snapshot may not be consistent
This is the central warning of the lesson. A snapshot photographs the disk's blocks, not the application's logical state. And a running database has, at any instant, modified pages in memory that have not yet been written to disk, half-committed transactions, and WAL writes that the operating system has in its cache and has not flushed.
The resulting snapshot is equivalent to the state of the disk after a power cut. The good news is that PostgreSQL is prepared for that: on starting up on that volume it runs a WAL recovery and reaches a coherent state. The bad news is twofold: that recovery can take minutes on a large database, and you will lose the transactions that never reached disk. And with less robust engines, or with data spread across several volumes photographed separately, the result can be plainly useless. The professional solution is to coordinate the snapshot with the application: leave it in a consistent state just before, and release it just after.
| Consistency level | How it is achieved | Risk |
|---|---|---|
| Crash-consistent | A snapshot and nothing more | WAL recovery at start-up; loss of what was not flushed |
| Filesystem-consistent | sync and freeze the filesystem (fsfreeze) beforehand |
Low |
| Application-consistent | The application enters backup mode (pg_backup_start / pg_backup_stop) |
Minimal |
Kubernetes offers the mechanism for the third: the pre and post hooks that run a command inside the container before and after the snapshot. It is not a native capability of the VolumeSnapshot, but of the backup tools, and that is why it is studied in the next lesson, 05-06, with Velero.
A snapshot is NOT a backup
It is the most important statement of the module and the conceptual mistake that has destroyed the most data:
| Snapshot | Backup | |
|---|---|---|
| Where it lives | In the same storage system as the original | In an independent system (object store, another region) |
| Survives losing the array, the zone or the account | No | Yes (if it is in another account) |
| Survives ransomware encryption with stolen credentials | Usually not | Yes, with immutability |
| Portable to another cluster or provider | No | Yes |
| Creation time / cost | Seconds, low cost (incremental) | Minutes or hours, higher cost |
| Includes the Kubernetes manifests | No: only the volume's data | Yes, if the tool captures them |
If the cloud account is deleted, if the zone is lost, if someone with administrator credentials destroys the project, the snapshots go with everything else. They are a magnificent tool for what they are — undoing a local change, quickly, in minutes — but they are not a disaster recovery strategy. Rutas Norte rule: snapshots are the short-term safety net — before a migration, a risky deployment, touching production — with a retention of days; the real backup goes to an external object store and is what gets built in the next lesson.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
Creating a VolumeSnapshot with no CRDs or controller |
no matches for kind "VolumeSnapshot" |
Install the CRDs and the snapshot-controller |
| Trying to shrink a PVC | field can not be less than previous value |
You cannot: copy to a smaller PVC with downtime |
Expanding with allowVolumeExpansion: false |
The PVC does not change and a rejection event appears | The class must be recreated with the field set to true |
| Editing the PV instead of the PVC to expand | Nothing useful happens | Expansion is always requested on the PVC |
Not looking at df -h inside the container |
People think the expansion finished | The FileSystemResizePending condition requires recreating the pod |
| Restoring into a PVC smaller than the snapshot, or with another class | The PVC is not provisioned | Ask for >= restoreSize, and the same class and driver |
| Believing a snapshot is a backup | Total loss if the storage system goes down | An external backup (05-06) |
| Snapshotting a running DB without coordination | Long recovery or useless data | pre/post hooks, or a logical dump |
| Snapshots with no annotations or retention | Dozens of expensive snapshots nobody dares delete | Note the reason, owner and ticket; define retention |
| Cloning production into a test environment without thinking | Personal customer data in a less protected environment | Anonymise or apply the same protections |
Tips:
- Before any irreversible change — schema migration, major engine upgrade, clean-up script — take a snapshot and annotate it with the ticket. It costs seconds and it has saved careers.
- Verify that a snapshot works, not that it exists. An untested snapshot is nothing. Restore periodically into a test PVC and check the rows.
- Watch how full it is getting well in advance with a
df -hover the mount point, and automate it with alerts at 75% in 07-04. - Diagnose storage by phase. If a pod does not start because of the volume, look in this order:
kubectl describe pvc(provision),kubectl get volumeattachments(attach),kubectl describe pod(mount) and the logs of the CSI driver pod inkube-system.
Exercises
Exercise 1: expand the database volume
With the hostpath CSI driver installed and rutasnorte-fast recreated with allowVolumeExpansion: true:
- Check the current size of the
bookings-postgresvolume from inside the container. - Grow it from 10 GiB to 20 GiB by editing the PVC.
- Follow the PVC's conditions until it finishes and verify the size with
df -h. - Try to shrink it to 5 GiB and transcribe the error.
- Check that the bookings are still there.
Exercise 2: snapshot, disaster and restore
Rehearse the complete procedure you will use in production:
- Insert five fictitious bookings into
bookings-postgres. - Create a
VolumeSnapshotcalledsnap-practicewith the reason, owner and ticket annotations. Wait forREADYTOUSE: true. - Trigger the disaster:
DROP TABLE bookings. - Create a restored PVC with
dataSourceand point the Deployment at it. - Verify that the five bookings are back.
- Measure the total time from step 3 to step 5 and note it down: it is your real RTO for this kind of incident.
Exercise 3: design the Rutas Norte snapshot policy
Answer with reasoning, in the form of a decision table you could present to the team:
- A. Which Rutas Norte volumes deserve automatic snapshots and which do not? Justify each component.
- B. With what frequency and what retention? Bear in mind that over a bank-holiday weekend about 400 bookings are sold per hour and that a 20 GiB snapshot costs money every month.
- C. Why are snapshots not enough as a data protection strategy for Rutas Norte? List three specific scenarios in which they would not save the situation.
Solutions
Exercise 1
# 1: starts at 9.8G, 2% used
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- df -h /var/lib/postgresql/data
# 2
kubectl patch pvc bookings-postgres-data -n rutas-norte-dev \
-p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
# 3
kubectl get pvc bookings-postgres-data -n rutas-norte-dev -w # Ctrl-C on seeing 20Gi
kubectl get pvc bookings-postgres-data -n rutas-norte-dev \
-o jsonpath='{range .status.conditions[*]}{.type}={.status}{"\n"}{end}'
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- df -h /var/lib/postgresql/dataThe final df -h should say 20G with only 1% used. If status.conditions shows FileSystemResizePending=True, the second stage is missing: a kubectl rollout restart deploy/bookings-postgres and then it will say so.
# 4
kubectl patch pvc bookings-postgres-data -n rutas-norte-dev \
-p '{"spec":{"resources":{"requests":{"storage":"5Gi"}}}}'
# 5
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;"The PersistentVolumeClaim "bookings-postgres-data" is invalid:
spec.resources.requests.storage: Forbidden: field can not be less than previous valueExpansion does not touch the data: it only grows the device and extends the filesystem over the new space.
Exercise 2
# 1
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- psql -U rutasnorte -d bookings -c \
"TRUNCATE bookings;
INSERT INTO bookings (customer, route, date) VALUES
('Marta Iglesias','Bilbao-Santander','2026-08-15'),
('Ignacio Sabater','Oviedo-Gijon','2026-08-16'),
('Lucia Berenguer','Leon-Ponferrada','2026-08-17'),
('Ander Zuloaga','Vitoria-Burgos','2026-08-18'),
('Rosa Ferreiro','Lugo-A Coruna','2026-08-19');"# 2: snap-practice.yaml (the VolumeSnapshot of section 9, with another name)
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: snap-practice
namespace: rutas-norte-dev
labels: { app: bookings-postgres, app.kubernetes.io/part-of: rutas-norte, environment: dev }
annotations:
rutasnorte.example/reason: "module 5 restore rehearsal"
rutasnorte.example/owner: "[email protected]"
rutasnorte.example/ticket: "RN-901"
spec:
volumeSnapshotClassName: rutasnorte-snapclass
source: { persistentVolumeClaimName: bookings-postgres-data }kubectl apply -f snap-practice.yaml
kubectl wait --for=jsonpath='{.status.readyToUse}'=true \
volumesnapshot/snap-practice -n rutas-norte-dev --timeout=180s
# 3: the disaster. From here on, start the clock.
START=$(date +%s)
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "DROP TABLE bookings;"
# 4: the restored PVC of section 10, with dataSource -> snap-practice
kubectl apply -f k8s/environments/dev/pvc-restored.yaml
kubectl scale deploy bookings-postgres -n rutas-norte-dev --replicas=0
kubectl patch deploy bookings-postgres -n rutas-norte-dev --type=json -p='[
{"op":"replace","path":"/spec/template/spec/volumes/0/persistentVolumeClaim/claimName",
"value":"bookings-postgres-data-restored"}]'
kubectl scale deploy bookings-postgres -n rutas-norte-dev --replicas=1
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
# 5 and 6
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;"
echo "Real RTO: $(( $(date +%s) - START )) seconds"That number — of the order of one to three minutes on a local cluster, and five to fifteen in production with large volumes — is your RTO for this kind of incident. Note it down: it is the figure to bring to the recovery objectives conversation of 05-06. And notice that the original volume is still there, with the table dropped, available for investigating what happened.
Exercise 3
A. What deserves snapshots:
| Component | Snapshot? | Justification |
|---|---|---|
bookings-postgres |
Yes, top priority | The only source of truth for bookings and personal data. Losing it stops sales |
| The backup PVC | Yes | It holds the logical dumps; losing them leaves you with no safety net |
redis-cache |
No | Rebuildable from the database. A snapshot would only hold stale data |
web-store, bookings-api, notifications-worker |
No | Stateless: their content is in the image and in Git, and the pending queue lives in PostgreSQL |
occupancy-reports working volume |
No | Ephemeral by design; the report is regenerated |
B. Frequency and retention:
| Type | Frequency | Retention | Reason |
|---|---|---|---|
| Scheduled nightly | 1 a day (03:00) | 7 days | Covers the error spotted a few days later. 7 incremental 20 GiB snapshots are cheap |
| Before every risky change | On demand | 72 h after validating the change | That is its useful window: if it has not blown up in 72 h, it will not blow up because of that change |
| Peak season (bank holidays, summer) | Every 4 h | 48 h | At 400 bookings/hour, a daily snapshot would mean losing up to 9,600 bookings |
The arithmetic that justifies point three: with a daily snapshot, the RPO is 24 h and over a bank-holiday weekend that is some 9,600 bookings lost. With a snapshot every 4 h, the RPO drops to 4 h, that is, about 1,600. And with continuously archived WAL it would drop to minutes, which is the real solution and is discussed in 05-06. The RPO is a business decision, not a technical one: somebody has to decide how many bookings it is acceptable to lose.
C. Three scenarios in which snapshots do not save the situation:
- Loss of the storage system or of the zone. Snapshots live in the same array or region as the original volumes. If it disappears, they disappear with it. A backup in an object store in another region does survive.
- Deletion of the cloud account or project, accidental or malicious. A compromised administrator, or a billing error that suspends the account, takes volumes and snapshots alike. The protection is a copy in another account, with different credentials and immutability enabled.
- Logical corruption not detected in time. An application bug that corrupts records little by little over three weeks makes 7-day snapshots useless: they all already contain the corruption. You need copies with long retention — monthly, yearly — and, above all, verifiable logical dumps: a
pg_dumpthat restores correctly proves the data is coherent, something a block snapshot does not prove. A fourth scenario worth mentioning: migrating to another provider or restoring on another cluster; an EBS snapshot does not restore on GKE, a logical dump does.
Conclusion
You have opened the black box. You know that CSI exists because having each vendor's code inside Kubernetes was unsustainable, and that its architecture is two halves: a controller plugin, deployed as a Deployment, that talks to the provider's API and creates, attaches and photographs volumes; and a node plugin, deployed as a DaemonSet, that formats and mounts on each machine. Between the driver and the Kubernetes API sit the sidecars — external-provisioner, external-attacher, external-resizer, external-snapshotter, node-driver-registrar —, which translate API objects into gRPC calls and let a vendor write only the logic of its storage. And you know the objects that describe that infrastructure: CSIDriver, with the declared capabilities, and CSINode, with each node's drivers and that Count which explains exceed max volume count. Above all, you have the complete flow in your head — Provision, Attach, NodeStage, NodePublish — and you know which symptom each phase produces when it fails, including the kubectl get volumeattachments that almost nobody uses and that tells a controller problem from a node one in a second.
You have expanded the bookings-postgres volume live from 10 to 20 GiB, with the database running and without touching a single piece of data. You know the three requirements — allowVolumeExpansion, driver support and a Bound PVC —, that the request is made always on the PVC and never on the PV, and the two stages of the process: the one on the real disk and the one on the filesystem, with the FileSystemResizePending condition that betrays when the pod has to be recreated for df -h to tell the truth. And you have the restriction etched in: it cannot be shrunk, so the size is thought through beforehand.
And you have mastered snapshots, with their object model copied from the volumes one — VolumeSnapshotClass, VolumeSnapshot, VolumeSnapshotContent, exactly the roles of StorageClass, PVC and PV —, their prerequisites that do not come with Kubernetes (the CRDs and the snapshot-controller), and the real workflow: a snapshot annotated with the reason, owner and ticket before a schema migration; the disaster; and the restore creating a new PVC with dataSource, which does not overwrite the original and preserves the evidence. You know how to clone a PVC live by changing the dataSource's kind, and that such a production clone carries real personal data and deserves the same care as the original.
And you take away the two warnings that give the next lesson its purpose. The first: a snapshot of a disk with a running database is crash-consistent, equivalent to the state after a power cut; PostgreSQL recovers via WAL, but it takes time and may lose what was not flushed, and the remedy is to coordinate the application with hooks before and after. The second, the most important in the module: a snapshot is not a backup. It lives in the same storage system as the original, so it does not survive the loss of the zone, nor the deletion of the account, nor logical corruption discovered three weeks late, nor is it any use for restoring on another cluster. It is a short-term safety net, excellent for what it is.
What is missing, therefore, is what really protects Rutas Norte: getting the data out of the cluster, putting it in an independent store, including the API objects too, coordinating the database so that the copy is consistent, defining retention and — this is non-negotiable — testing the restore. That is Backup and Restore of Persistent Data, the lesson that closes the module.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
