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

  1. What problem CSI solved
  2. The architecture: controller plugin and node plugin
  3. The sidecars and the CSIDriver and CSINode objects
  4. The complete end-to-end flow of a volume
  5. Preparing the practice cluster
  6. Volume expansion: requirements and mechanics
  7. Online expansion versus expansion with a restart
  8. Snapshots: the three objects
  9. Creating a snapshot of bookings-postgres
  10. Restoring from a snapshot with dataSource
  11. Cloning a PVC
  12. The warning: consistency and what a snapshot is NOT

  1. 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.

  1. 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.

  1. The sidecars and the CSIDriver and CSINode objects

Here 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.

kubectl describe csidriver hostpath.csi.k8s.io
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, Ephemeral

CSINode

It is created automatically for each node and records which drivers are available there and by what identifier the provider knows that machine.

kubectl describe csinode rutas-norte
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.

  1. 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.

  1. 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 volumesnapshotclasses
NAME                     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           1m

Important 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

  1. 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 -10
Conditions:
  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 succeeded

And 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/data

20 GiB, with the 8.4 GiB of data intact and without having stopped the database.

  1. 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/data

Two 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).

  1. 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 PVs

deletionPolicy 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.

  1. Creating a snapshot of bookings-postgres

The 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 photograph
kubectl apply -f k8s/base/volumesnapshotclass.yaml
kubectl apply -f k8s/environments/dev/snapshot-pre-migration.yaml
kubectl get volumesnapshot -n rutas-norte-dev
NAME                   READYTOUSE  SOURCEPVC               RESTORESIZE  SNAPSHOTCONTENT           AGE
snap-postgres-pre-v3   true        bookings-postgres-data  20Gi         snapcontent-a1b2c3d4-...  10s

READYTOUSE: 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.

  1. Restoring from a snapshot with dataSource

Now 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;"   # -> 1

Restoring 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.io

Applying 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-17

The three bookings are back. Four conditions for a restore to work:

  1. The new PVC must ask for at least the snapshot's restoreSize. Less, and it fails.
  2. It must use the same StorageClass (or at least the same driver): a snapshot does not cross storage systems.
  3. The snapshot must be READYTOUSE: true.
  4. The snapshot must exist in the same namespace as the new PVC. To cross namespaces you have to import the VolumeSnapshotContent by hand.

  1. 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.

  1. 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:

  1. 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.
  2. Verify that a snapshot works, not that it exists. An untested snapshot is nothing. Restore periodically into a test PVC and check the rows.
  3. Watch how full it is getting well in advance with a df -h over the mount point, and automate it with alerts at 75% in 07-04.
  4. 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 in kube-system.

Exercises

Exercise 1: expand the database volume

With the hostpath CSI driver installed and rutasnorte-fast recreated with allowVolumeExpansion: true:

  1. Check the current size of the bookings-postgres volume from inside the container.
  2. Grow it from 10 GiB to 20 GiB by editing the PVC.
  3. Follow the PVC's conditions until it finishes and verify the size with df -h.
  4. Try to shrink it to 5 GiB and transcribe the error.
  5. Check that the bookings are still there.

Exercise 2: snapshot, disaster and restore

Rehearse the complete procedure you will use in production:

  1. Insert five fictitious bookings into bookings-postgres.
  2. Create a VolumeSnapshot called snap-practice with the reason, owner and ticket annotations. Wait for READYTOUSE: true.
  3. Trigger the disaster: DROP TABLE bookings.
  4. Create a restored PVC with dataSource and point the Deployment at it.
  5. Verify that the five bookings are back.
  6. 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/data

The 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 value

Expansion 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"
 count
-------
     5
Real RTO: 94 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:

  1. 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.
  2. 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.
  3. 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_dump that 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 sidecarsexternal-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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved