Kubernetes Storage: PV, PVC, and StorageClass

The problem this layer solves

A Pod's container filesystem is exactly as ephemeral as a plain Docker container's — gone on Pod deletion, exactly like the writable-layer behavior in Docker Fundamentals. On top of that, a Pod can be rescheduled to a different node entirely after a restart — so persistent storage in Kubernetes can't just be "a folder on this machine"; it has to be a resource the cluster tracks independently of any specific node, that can be attached to whichever node ends up running the Pod that needs it.

Three objects, three jobs

graph TD sc["StorageClass: fast-ssd<br/>(the template: which provisioner, what disk type)"] pvc["PersistentVolumeClaim: db-data<br/>(the request: 'I need 10Gi, ReadWriteOnce')"] pv["PersistentVolume<br/>(the actual storage: a real cloud disk, bound 1:1 to the PVC)"] pod["Pod<br/>(mounts the PVC by name, doesn't know or care what's behind it)"] pod -->|"volumes: persistentVolumeClaim"| pvc pvc -->|"references"| sc sc -->|"provisioner creates on demand"| pv pv -.->|"bound"| pvc
  • PersistentVolume (PV) — represents one actual piece of storage: a cloud block volume, an NFS export, a local disk. Cluster-scoped, not tied to any namespace — it's infrastructure, not application config.
  • PersistentVolumeClaim (PVC) — a namespaced request for storage ("10Gi, ReadWriteOnce") that a Pod actually references in its volumes: list. A PVC binds to a PV that satisfies its request — the Pod spec never mentions the PV directly, only the PVC, which is the layer of indirection that lets the same Pod spec work whether the underlying PV came from AWS EBS, GCP Persistent Disk, or a local test cluster.
  • StorageClass — the template that makes PVCs self-service instead of requiring an administrator to hand-create a matching PV for every single PVC. A PVC that references a StorageClass triggers dynamic provisioning: the StorageClass's provisioner automatically creates a brand-new PV (an actual new cloud disk, API-provisioned on the spot) sized to match the claim.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
reclaimPolicy: Delete
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data
spec:
  storageClassName: fast-ssd
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 10Gi

This exact pattern is what the StatefulSet's volumeClaimTemplates generates automatically, once per replica (data-db-0, data-db-1, data-db-2) — each is a PVC just like the one above, each triggering its own independent dynamic provisioning through the same StorageClass.

Access modes: the gotcha that breaks multi-node sharing

  • ReadWriteOnce (RWO) — the volume can be mounted read-write by one node at a time. This is what almost all cloud block storage (AWS EBS, GCP Persistent Disk) actually supports.
  • ReadOnlyMany (ROX) — many nodes, read-only.
  • ReadWriteMany (RWX) — many nodes, read-write simultaneously. Requires a genuinely networked filesystem underneath (NFS, AWS EFS, Azure Files) — a plain cloud block disk fundamentally cannot do this, because block storage is attached to one machine's block device layer at a time, not shared over a network protocol.
graph TD subgraph rwo["ReadWriteOnce: works"] n1["Node A"] --> vol1["EBS volume"] note1["Pod scheduled on Node A mounts it fine.<br/>A second Pod on Node B trying to mount<br/>the SAME PVC fails to schedule."] end subgraph rwx["ReadWriteMany: needed for this"] n2["Node A"] --> vol2["NFS / EFS share"] n3["Node B"] --> vol2 note2["Both nodes mount the same<br/>network filesystem simultaneously — fine."] end

A Deployment with replicas: 3, all trying to mount the same RWO PVC, scheduled across different nodes, will have at most one replica actually start — the others sit stuck in ContainerCreating, unable to attach a volume already exclusively attached elsewhere. This is one of the most common storage-related scheduling failures, and the fix is either genuinely needing RWX-capable storage (NFS/EFS-backed StorageClass), or restructuring so each replica has its own PVC (exactly what a StatefulSet's volumeClaimTemplates does, sidestepping the shared-RWO problem by not sharing one PVC across replicas at all).

Reclaim policy: what happens when the PVC is deleted

reclaimPolicy: Delete (the common default for dynamically provisioned storage) means deleting the PVC also deletes the underlying cloud disk — the data is gone, not just detached. reclaimPolicy: Retain keeps the underlying PV (and its data) around even after the PVC is deleted, requiring manual cleanup or manual rebinding later — the safer choice for anything where an accidental kubectl delete pvc shouldn't be equivalent to an accidental DROP DATABASE.

Common pitfall

A PVC stuck in Pending almost always means one of two things: either no StorageClass matches what the PVC asked for (a typo in storageClassName, or no default StorageClass configured on the cluster when the PVC didn't specify one at all), or — for statically provisioned storage — no existing PV satisfies the requested size and access mode. kubectl describe pvc <name> surfaces the specific scheduling/binding error directly; guessing at network or application causes for what's actually a storage-binding failure wastes time that one command would have saved.