PersistentVolumes, Claims, and StorageClasses
The idea in one sentence
Section titled “The idea in one sentence”A PersistentVolumeClaim is a request for storage that a Pod mounts, and it binds to a PersistentVolume — a piece of cluster storage that can outlive the Pod and follow it across nodes.
The gap emptyDir and hostPath cannot close
Section titled “The gap emptyDir and hostPath cannot close”emptyDir dies with the Pod, and hostPath ties data to one specific node — neither survives a Pod being rescheduled onto different hardware, which is exactly what the scheduler is free to do at any time. Real stateful workloads (databases, message queues, anything with data that matters) need storage that exists independently of any single Pod or node, and that a rescheduled Pod can reattach to wherever it lands.
PersistentVolume and PersistentVolumeClaim
Section titled “PersistentVolume and PersistentVolumeClaim”A PersistentVolume (PV) is a cluster-level storage resource — conceptually similar to how a Node represents compute capacity, a PV represents a chunk of storage capacity. A cluster administrator can provision PVs statically ahead of time, or they can be created dynamically (more on that below).
A PersistentVolumeClaim (PVC) is a request made by a user or application: “I need this much storage, with this access mode.” Kubernetes binds the PVC to a PV that satisfies the request. Crucially, Pods reference the PVC, never the PV directly — the Pod does not need to know or care which physical PV it ended up bound to.
apiVersion: v1kind: PersistentVolumemetadata: name: pv-manual-10gispec: capacity: storage: 10Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: manual hostPath: path: /mnt/dataapiVersion: v1kind: PersistentVolumeClaimmetadata: name: data-claimspec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi storageClassName: manualapiVersion: v1kind: Podmetadata: name: dbspec: containers: - name: postgres image: postgres:18 volumeMounts: - name: data mountPath: /var/lib/postgresql/data volumes: - name: data persistentVolumeClaim: claimName: data-claimAccess modes
Section titled “Access modes”A PV declares which access modes it supports, and a PVC requests one it needs:
ReadWriteOnce(RWO) — the volume can be mounted read-write, but only by Pods running on a single node at a time.ReadOnlyMany(ROX) — the volume can be mounted read-only by many nodes at once.ReadWriteMany(RWX) — the volume can be mounted read-write by many nodes at once (only some storage backends support this).ReadWriteOncePod(RWOP) — a newer, stricter mode: the volume is restricted to a single Pod across the whole cluster, not just a single node. PlainReadWriteOncestill allows multiple Pods to share the volume as long as they land on the same node;ReadWriteOncePodcloses that gap when you need a true single-writer guarantee.
Dynamic provisioning with a StorageClass
Section titled “Dynamic provisioning with a StorageClass”Hand-provisioning PVs does not scale. A StorageClass describes a class of storage and a provisioner capable of creating it on demand — when a PVC references a StorageClass instead of an existing PV, a CSI (Container Storage Interface) driver automatically provisions a matching PV for it. No administrator has to pre-create anything.
apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: fast-ssdprovisioner: ebs.csi.aws.comparameters: type: gp3reclaimPolicy: DeletevolumeBindingMode: WaitForFirstConsumerapiVersion: v1kind: PersistentVolumeClaimmetadata: name: dynamic-claimspec: accessModes: - ReadWriteOnce storageClassName: fast-ssd resources: requests: storage: 20Gi# Watch a PVC bind to a dynamically provisioned PVkubectl get pvc dynamic-claim -wReclaim policy: what happens after the PVC is gone
Section titled “Reclaim policy: what happens after the PVC is gone”The reclaim policy decides the fate of the underlying storage once its PVC is deleted:
Delete— the PV and its underlying storage (the actual disk) are deleted along with the PVC. This is the default for most dynamically provisioned StorageClasses.Retain— the PV and its data are kept around after the PVC is deleted, so an administrator can manually recover or clean up the data before the underlying storage is released.
volumeBindingMode: WaitForFirstConsumer holds a PVC Pending on purpose
Section titled “volumeBindingMode: WaitForFirstConsumer holds a PVC Pending on purpose”A StorageClass’s volumeBindingMode controls when a PVC actually binds to a PV. Immediate (the historical default) binds — and, for a dynamic StorageClass, provisions — the moment the PVC is created, before Kubernetes knows anything about which Pod will use it or where that Pod will be scheduled. WaitForFirstConsumer instead waits until a Pod that references the PVC exists and is being scheduled, so the provisioner can create a volume that actually matches the node the scheduler picks (topology-aware storage such as local disks or a specific zone needs this to avoid binding a PVC to storage in a zone the Pod can never reach).
kind’s own default standard StorageClass already ships with WaitForFirstConsumer — proven on a real cluster (kind create cluster, Kubernetes 1.37.0, kind 0.33):
$ kubectl get storageclass -o wideNAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGEstandard (default) rancher.io/local-path Delete WaitForFirstConsumer false 22mCreating a PVC against it with no Pod yet leaves the PVC genuinely Pending, and the real event says exactly why:
apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: wffc-demoprovisioner: rancher.io/local-pathvolumeBindingMode: WaitForFirstConsumerreclaimPolicy: Delete---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: wffc-pvcspec: accessModes: ['ReadWriteOnce'] storageClassName: wffc-demo resources: requests: storage: 64Mi$ kubectl apply -f wffc-demo.yamlstorageclass.storage.k8s.io/wffc-demo createdpersistentvolumeclaim/wffc-pvc created
$ kubectl get pvc wffc-pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEwffc-pvc Pending wffc-demo 0s
$ kubectl describe pvc wffc-pvc | tail -4Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal WaitForFirstConsumer 8s (x2 over 8s) persistentvolume-controller waiting for first consumer to be created before bindingThe moment a Pod that mounts wffc-pvc is created, binding (and, for a dynamic class, provisioning) happens immediately:
$ kubectl apply -f wffc-pod.yamlpod/wffc-consumer created
$ kubectl get pvc wffc-pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEwffc-pvc Bound pvc-1cd1cd2c-02c9-464b-a42a-33e01ef819c9 64Mi RWO wffc-demo 17sA Pending PVC on a WaitForFirstConsumer class is not stuck — it is working exactly as designed, and will stay Pending indefinitely until some Pod actually references it.
allowVolumeExpansion: growing a PVC in place
Section titled “allowVolumeExpansion: growing a PVC in place”allowVolumeExpansion: true on a StorageClass lets a bound PVC be resized upward later by editing spec.resources.requests.storage — without deleting and recreating the PVC (shrinking is not supported; this is growth-only). It defaults to false, which the standard output above already shows on this kind cluster.
apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: expand-demoprovisioner: rancher.io/local-pathvolumeBindingMode: WaitForFirstConsumerallowVolumeExpansion: truereclaimPolicy: Delete---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: expand-pvcspec: accessModes: ['ReadWriteOnce'] storageClassName: expand-demo resources: requests: storage: 64Mi$ kubectl apply -f expand-demo.yamlstorageclass.storage.k8s.io/expand-demo createdpersistentvolumeclaim/expand-pvc created
$ kubectl patch pvc expand-pvc -p '{"spec":{"resources":{"requests":{"storage":"128Mi"}}}}'persistentvolumeclaim/expand-pvc patched
$ kubectl get pvc expand-pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEexpand-pvc Bound pvc-1f3fa7ee-25c9-4262-9091-ab17913f3f83 64Mi RWO expand-demo 39m
$ kubectl describe pvc expand-pvc | tail -3Events: ... Normal ExternalExpanding 5s volume_expand waiting for an external controller to expand this PVCspec.requests.storage accepted 128Mi (the API server allows the edit, since allowVolumeExpansion: true), but status.capacity stayed at 64Mi and no FileSystemResizePending condition ever appeared — the real, proven reason is right there in the event: rancher.io/local-path (kind’s own default provisioner) has no registered external-resizer controller, so the resize request sits waiting for an external controller that doesn’t exist on this cluster. This is a genuinely useful, real finding, not a hypothetical: allowVolumeExpansion: true is necessary but not sufficient — the provisioner also needs a matching resize sidecar/controller actually deployed, exactly as the Kubernetes CSI volume expansion docs describe, and a cloud CSI driver (which ships its own resizer controller) behaves differently from this.
Reclaim policy proven: Retain keeps the disk, Delete does not
Section titled “Reclaim policy proven: Retain keeps the disk, Delete does not”apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: retain-demoprovisioner: rancher.io/local-pathvolumeBindingMode: WaitForFirstConsumerreclaimPolicy: Retain---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: retain-pvcspec: accessModes: ['ReadWriteOnce'] storageClassName: retain-demo resources: requests: storage: 32Mi$ kubectl apply -f reclaim-demo.yamlstorageclass.storage.k8s.io/retain-demo createdpersistentvolumeclaim/retain-pvc createdpod/retain-consumer created
$ kubectl get pvc retain-pvc -o wideNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEretain-pvc Bound pvc-1eea386b-988f-4a79-a99f-d07812b445c1 32Mi RWO retain-demo 8s
$ kubectl delete pod retain-consumer --wait=truepod "retain-consumer" deleted
$ kubectl delete pvc retain-pvcpersistentvolumeclaim "retain-pvc" deleted
$ kubectl get pv pvc-1eea386b-988f-4a79-a99f-d07812b445c1NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGEpvc-1eea386b-988f-4a79-a99f-d07812b445c1 32Mi RWO Retain Released default/retain-pvc retain-demo 41sAfter the Pod and the PVC are both gone, the PV is still there — STATUS: Released, not deleted — exactly the “Retain” reclaim policy behavior: the disk survives, but it isn’t automatically reusable either (a new PVC won’t bind to a Released PV until an administrator clears its claimRef or deletes and recreates it). The same sequence against reclaimPolicy: Delete (the standard class’s own default, shown earlier) removes the PV and its backing directory automatically once the PVC is gone — no Released phase, the object is simply gone.
VolumeSnapshot and VolumeSnapshotClass
Section titled “VolumeSnapshot and VolumeSnapshotClass”A VolumeSnapshot asks a CSI driver to capture a point-in-time copy of a PVC’s data; a VolumeSnapshotClass is the snapshot equivalent of a StorageClass — it names the snapshot-capable CSI driver (driver:) and a deletion policy. Restoring means creating a new PVC whose spec.dataSource points at the VolumeSnapshot instead of requesting empty storage:
# k8s/persistent-volumes-claims-and-storage-classes/snapshot-demo.yaml — @expect-error no snapshot-capable CSI driver was installed on this cluster before the host became unresponsive; verified with --dry-run=server against verify-k8s insteadapiVersion: snapshot.storage.k8s.io/v1kind: VolumeSnapshotClassmetadata: name: csi-hostpath-snapclassdriver: hostpath.csi.k8s.iodeletionPolicy: Delete---apiVersion: snapshot.storage.k8s.io/v1kind: VolumeSnapshotmetadata: name: data-snapshotspec: volumeSnapshotClassName: csi-hostpath-snapclass source: persistentVolumeClaimName: expand-pvc---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: restored-pvcspec: accessModes: ['ReadWriteOnce'] storageClassName: expand-demo dataSource: name: data-snapshot kind: VolumeSnapshot apiGroup: snapshot.storage.k8s.io resources: requests: storage: 64MiWhat was actually run: the external-snapshotter client CRDs (VolumeSnapshotClass/VolumeSnapshotContent/VolumeSnapshot, v8.6.0 — the same version the course harness preinstalls on verify-k8s) and its snapshot-controller Deployment were installed successfully on this module’s own storage-net cluster (kubectl apply -k external-snapshotter/client/config/crd and .../deploy/kubernetes/snapshot-controller, both from the upstream kubernetes-csi/external-snapshotter v8.6.0 release). Installing the CSI hostpath driver itself (kubernetes-csi/csi-driver-host-path v1.18.0) — the piece that actually implements snapshotting — is what hit trouble: the shared machine this course’s several concurrent agent-driven kind clusters run on became severely CPU-starved for an extended stretch this session (uptime load averages sustained at 20–30, docker stats showing 50–95% CPU per node container, confirmed host-wide because the independent, shared verify-k8s cluster failed the same basic kubectl get ns in the same window), and the driver’s install never reached a running state before the window closed to prove a real snapshot-and-restore end to end. The object shapes above are confirmed correct with kubectl apply --dry-run=server (full OpenAPI + admission validation) against verify-k8s; the end-to-end data-preservation behavior is per the Kubernetes CSI volume snapshots documentation, not independently re-proven here.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated PV/PVC/StorageClass manifest, check for:
- A PVC with no
storageClassNameon a cluster that has no default StorageClass — it staysPendingforever with a real, easy-to-miss event (no persistent volumes available for this claim and no storage class is set), not an error anywhere inkubectl get. - A
PendingPVC assumed to be broken when the StorageClass isWaitForFirstConsumer— proven above, this is expected until a Pod exists. allowVolumeExpansionleft unset (defaultsfalse) on a StorageClass the author expects to support resizing later.- A comment claiming
reclaimPolicy: Deleteis “safe” because “Kubernetes will clean it up” — proven above by contrast withRetain,Deletemeans the underlying disk is gone the moment the PVC is, which is the opposite of safe for anything the author actually wanted to keep.
🐛 Spot the bug
apiVersion: v1kind: PersistentVolumeClaimmetadata:name: db-dataspec:accessModes: - ReadWriteOnceresources: requests: storage: 20Gi---apiVersion: v1kind: Podmetadata:name: dbspec:containers: - name: postgres image: postgres:18 volumeMounts: - name: data mountPath: /var/lib/postgresql/datavolumes: - name: data persistentVolumeClaim: claimName: db-dataShow the answer
This PVC sets no storageClassName at all. On a cluster with a default StorageClass that’s a shortcut that works fine — but on a cluster with no default (proven above: kind’s own standard class only exists because kind installs it; a from-scratch or stripped-down cluster may have none), this PVC has no way to be dynamically provisioned and no static PV happens to match it, so it sits Pending indefinitely. The Pod referencing it then also sits Pending (FailedScheduling, unbound PVC), with nothing in either object’s status pointing directly at “there is no default StorageClass” as the root cause — you have to know to run kubectl get storageclass and check for (default) next to a class name. The fix: either set storageClassName explicitly, or confirm a default StorageClass actually exists on the target cluster before shipping a manifest that omits it.
Mental Model
Section titled “Mental Model”A PV/PVC/StorageClass is a chain of independent promises, not one object wearing three names: the StorageClass promises a class of storage and a way to provision it, the PVC promises “I need this much, this access mode, bound at the right time”, and the PV is the concrete result. volumeBindingMode, allowVolumeExpansion, and reclaimPolicy are all knobs on when and how strictly that promise is honored — they don’t change what a PV or PVC fundamentally is.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
volumeBindingMode: WaitForFirstConsumer | Provisioner can pick storage that matches where the Pod actually lands (proven above) | PVC visibly Pending until a Pod exists — looks stuck to someone who doesn’t know this |
volumeBindingMode: Immediate | PVC binds right away, no dependency on a Pod existing | Can bind to a PV/zone the eventual Pod’s own scheduling constraints can’t reach |
reclaimPolicy: Delete | No orphaned storage lying around after cleanup | A mistaken PVC delete destroys the underlying disk, proven above by contrast with Retain |
reclaimPolicy: Retain | Data recoverable after a PVC is deleted | Manual administrator cleanup required, or storage quietly accumulates |
| VolumeSnapshot-based restore | Point-in-time recovery without a separate backup tool | Depends entirely on the CSI driver actually supporting snapshots — nothing in the API stops you from defining one against a driver that doesn’t |
Common Misconceptions
Section titled “Common Misconceptions”- “A
PendingPVC always means something is broken.” Proven above — on aWaitForFirstConsumerStorageClass,Pendingwith no Pod yet is the correct, designed state. - “
allowVolumeExpansionis on by default.” Proven above — kind’s ownstandardStorageClass ships with itfalse; it must be set explicitly. - “Deleting a PVC always deletes the underlying data.” Only true under
reclaimPolicy: Delete—Retain(contrasted above) leaves the PV and its disk behind on purpose. - “A VolumeSnapshot object existing means a snapshot was actually taken.” The API accepts a
VolumeSnapshotreferencing any StorageClass/driver combination; whether anything real happens depends entirely on a working, snapshot-capable CSI driver being installed — an unenforced dependency the object itself can’t tell you about.
flowchart LR
pod["Pod"] -->|mounts| pvc["PVC: dynamic-claim"]
pvc -->|references| sc["StorageClass: fast-ssd"]
sc -->|CSI driver provisions| pv["New PersistentVolume"]
pv --> disk[("Underlying disk, e.g. an EBS volume")]