Kubernetes Storage Explained (2026): PV, PVC, StorageClass and CSI
Most Kubernetes outages I have been pulled into that involved data were not exotic. Someone deleted a namespace with a Delete reclaim policy underneath it, or a pod sat Pending because its disk lived in one availability zone and every node with capacity lived in another. Kubernetes storage looks like three YAML kinds (PersistentVolume, PersistentVolumeClaim, StorageClass), but the behaviour that bites you is in the fields people copy without reading. This post explains Kubernetes storage as it stands in Kubernetes 1.37.2, released on 28 October 2026: how volumes, PVs, PVCs and StorageClasses relate, what a CSI driver does, what the access modes really guarantee, why WaitForFirstConsumer should be your default, and what snapshots, StatefulSets and VolumeAttributesClass add on top.
Kubernetes volumes vs PersistentVolumes: what is the difference?
A volume, in the plain Kubernetes sense, is a directory that a pod's containers can mount. Some volume types live and die with the pod: emptyDir is scratch space on the node that is wiped when the pod is removed, and configMap or secret volumes project API objects into files. Those are ephemeral by design, and nobody should keep anything they care about in them.
A PersistentVolume (PV) is different in kind. It is a cluster-scoped API object that represents a real piece of storage: an EBS volume, a GCE persistent disk, an NFS export, a Ceph RBD image. Its lifetime is independent of any pod. A PersistentVolumeClaim (PVC) is a namespaced request for storage ("I need 20 GiB, read-write, of class gp3") and the pod refers to the claim, never to the PV directly. The persistent volumes documentation describes the PVC to PV binding as a one-to-one mapping, exclusive once made.
The split lets application authors and storage operators ignore each other: a developer asks for capacity and an access mode, the platform team defines what "fast" or "cheap" means through StorageClasses. For the wider picture of the core objects, see the companion Kubernetes explainer on pods, Deployments and Services.
PV, PVC and StorageClass: how dynamic provisioning works
There are two ways a PVC gets a PV. With static provisioning an administrator creates PV objects by hand that point at existing disks, and Kubernetes matches claims to them by class, size and access mode. With dynamic provisioning the PVC names a StorageClass, and the class's provisioner creates a disk on demand. Almost everyone on a managed cloud uses dynamic provisioning.
A StorageClass carries five fields that matter in practice. provisioner names the CSI driver (ebs.csi.aws.com, pd.csi.storage.gke.io, disk.csi.azure.com). parameters are opaque to Kubernetes and passed to the driver, so their meaning is driver-specific. reclaimPolicy decides what happens to the disk when the claim is deleted, and per the StorageClass docs it defaults to Delete when unset. allowVolumeExpansion decides whether users can grow a claim by editing its size. volumeBindingMode decides when provisioning happens, which gets its own section below.
That Delete default deserves a second look. It means deleting a PVC, or the namespace containing it, deletes the cloud disk. For anything you would restore from backup, create a class with reclaimPolicy: Retain, or patch the bound PV's persistentVolumeReclaimPolicy after creation. Retained PVs go to Released and need manual cleanup, which is the price of not losing data to a typo. The older Recycle policy, a basic rm -rf on the volume, is deprecated.
Default classes are the other trap. A PVC with no storageClassName gets the class annotated storageclass.kubernetes.io/is-default-class: "true". If several classes carry that annotation, Kubernetes uses the most recently created one, and the docs say plainly you should have only one. Since 1.28, retroactive default StorageClass assignment is stable, so claims created before any default existed get one assigned when a default appears. A claim that sets storageClassName: "" explicitly opts out of dynamic provisioning and will only bind to a pre-created PV with no class.
On EKS this matters more than people expect. Starting with EKS 1.30, new clusters no longer mark the old gp2 class as default, as Replicated documented when it broke their installer. EKS Auto Mode goes further: it does not create a StorageClass at all, and its classes must use the provisioner ebs.csi.eks.amazonaws.com, not the self-managed ebs.csi.aws.com. A Helm chart that assumes a default class will leave its PVCs Pending on a fresh cluster, which is the most common thing the PVC Pending troubleshooting guide walks through.
What is a CSI driver, and why are in-tree plugins gone?
The Container Storage Interface is a gRPC spec that lets storage vendors ship drivers outside the Kubernetes codebase. Before CSI, drivers for AWS, GCE, Azure and others were compiled into Kubernetes itself ("in-tree"), which meant a storage bug fix waited for a Kubernetes release. The in-tree plugins have now been migrated or removed: the StorageClass docs note that the in-tree awsElasticBlockStore driver was deprecated in 1.19 and removed entirely in 1.27, and point you to the AWS EBS CSI driver instead.
A CSI driver deployment is two halves. A controller Deployment runs the driver plus Kubernetes-maintained sidecars: external-provisioner (watches PVCs and calls CreateVolume), external-attacher (watches VolumeAttachment objects and calls ControllerPublishVolume), external-resizer, and external-snapshotter's sidecar. A node DaemonSet runs on every node, registers with the kubelet, and handles formatting and mounting. When a volume "won't attach", you are debugging the controller half; when it "won't mount", you are debugging the node half.
The current releases at the time of writing are aws-ebs-csi-driver v1.67.2 (26 October 2026), external-provisioner v6.4.0 and external-snapshotter v8.7.0. On EKS you would normally install the EBS driver as the aws-ebs-csi-driver managed add-on with an IAM role for the ebs-csi-controller-sa service account; AWS now points at the scoped AmazonEBSCSIDriverPolicyV2 managed policy in its setup page. The EBS driver defaults to gp3 when a class does not set type, per its parameters reference.
Access modes explained: RWO vs RWX vs RWOP
Access modes are the most misunderstood field in Kubernetes storage, mostly because the names suggest something stronger than they deliver. The access modes section is explicit: for RWO, ROX and RWX the mode is used for matching claims to volumes and for attach constraints, and it does not enforce write protection once mounted.
| Mode | Short | What it actually guarantees | Typical backends | Source |
|---|---|---|---|---|
| ReadWriteOnce | RWO | Read-write on one node; several pods on that node can still share it | EBS, PD, Azure Disk, Ceph RBD | Kubernetes docs |
| ReadOnlyMany | ROX | Mountable read-only by many nodes; not enforced as read-only | NFS, CephFS, Azure File | Kubernetes docs |
| ReadWriteMany | RWX | Read-write from many nodes | EFS, Filestore, Azure File, CephFS, NFS | Kubernetes docs |
| ReadWriteOncePod | RWOP | Exactly one pod in the whole cluster; CSI only; stable since 1.29 | Any CSI driver that supports it | Kubernetes docs |
| RWX on EBS | RWX | io1/io2 Multi-Attach, volumeMode: Block only, same AZ, app must fence I/O |
EBS io2 | EBS CSI multi-attach |
Two consequences follow. First, RWO does not stop two replicas from writing to the same filesystem if they land on the same node, which works until the day it corrupts a SQLite file. If you mean "one writer", use RWOP. Second, block storage is RWO almost everywhere. A Deployment with replicas: 2 and an EBS-backed claim will run one pod and leave the other complaining that the volume is already attached elsewhere. You want either a StatefulSet with one claim per replica, or a file storage backend that genuinely supports RWX.
EBS does offer a form of RWX through Multi-Attach, and the EBS CSI driver supports it, but only for io1 and io2, only in raw block mode, only within one AZ, and the driver's own docs warn that without application-level fencing you risk data loss and silent corruption. That is a tool for clustered databases that already know how to share a disk, not a shortcut for sharing uploads between web pods.
Why WaitForFirstConsumer should be your default volumeBindingMode
volumeBindingMode has two values. Immediate, the default when unset, provisions and binds the volume as soon as the PVC is created. The StorageClass docs are candid about the consequence: for topology-constrained backends, volumes get provisioned "without knowledge of the Pod's scheduling requirements," which may result in unschedulable pods.
Concretely: an EBS volume lives in one availability zone. With Immediate, the provisioner picks a zone before any pod exists. If the pod later needs a node selector, a toleration or a GPU that only exists in another zone, the scheduler cannot place it, because the disk cannot follow. WaitForFirstConsumer delays provisioning until a pod using the claim is scheduled, then creates the disk in that pod's zone. The EBS CSI parameters doc recommends it whenever possible.
The side effect confuses people the first time: a PVC on a WaitForFirstConsumer class shows Pending with the event "waiting for first consumer to be created before binding" until a pod uses it. That is normal, not a fault. Once bound, though, the zone is fixed for the life of that disk. If you later scale node groups down to one zone, or Karpenter only has capacity elsewhere, the pod goes Pending and the scheduler reports that nodes "didn't match PersistentVolume's node affinity". The fix is capacity in the right zone, not a different PVC. This is one of the quiet reasons I am cautious about running databases on Kubernetes without a zone plan.
StatefulSets, snapshots and VolumeAttributesClass
StatefulSets are the workload type built around storage. A volumeClaimTemplates block creates one PVC per replica, named <template>-<statefulset>-<ordinal>, and a replacement pod with the same ordinal reattaches the same claim. Historically those claims lived forever, even after the StatefulSet was deleted. Since 1.32 the persistentVolumeClaimRetentionPolicy field is stable, with whenDeleted and whenScaled each set to Retain (the default) or Delete. I leave both on Retain for databases and only use Delete for caches.
Volume snapshots have been stable since 1.20 and are CSI-only. They are three CRDs, VolumeSnapshot, VolumeSnapshotContent and VolumeSnapshotClass in snapshot.storage.k8s.io/v1, plus a snapshot controller. The snapshot docs make the CRDs and controller the distribution's responsibility, which in practice means many clusters do not have them until you install them. A snapshot is crash-consistent at best; for databases, pair it with the database's own backup tooling, or use something like Velero that orchestrates hooks around it.
VolumeAttributesClass (VAC) is the newest piece. It lets you change mutable properties of a live volume, such as IOPS and throughput, by pointing a PVC's volumeAttributesClassName at a different class. It went stable in 1.34 and the feature gate was locked on in 1.36, in storage.k8s.io/v1. The VAC docs note that a class's parameters are immutable, so you create a new class and repoint the claim. For EBS the driver accepts type, iops and throughput as modifiable parameters, and AWS relaxed its old six-hour cooldown in January 2026 to up to four modifications per volume in a rolling 24 hours.
Here is the set I would start from on EKS with the self-managed EBS CSI driver: a retained, expandable gp3 default class, a faster VAC, and a StatefulSet that keeps its claims.
# Tested against Kubernetes 1.37 API and aws-ebs-csi-driver v1.67.2.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-retain
annotations:
storageclass.kubernetes.io/is-default-class: "true" # exactly one default
provisioner: ebs.csi.aws.com # Auto Mode uses ebs.csi.eks.amazonaws.com
parameters:
type: gp3
encrypted: "true"
csi.storage.k8s.io/fstype: ext4
reclaimPolicy: Retain # default would be Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer # provision in the pod's AZ
---
apiVersion: storage.k8s.io/v1
kind: VolumeAttributesClass
metadata:
name: gp3-fast
driverName: ebs.csi.aws.com
parameters: # immutable; make a new class to change
type: gp3
iops: "6000"
throughput: "500"
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: pg
spec:
serviceName: pg
replicas: 1
selector:
matchLabels: { app: pg }
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
template:
metadata:
labels: { app: pg }
spec:
containers:
- name: postgres
image: postgres:18.6
env:
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
- { name: POSTGRES_PASSWORD, valueFrom: { secretKeyRef: { name: pg, key: password } } }
volumeMounts:
- { name: data, mountPath: /var/lib/postgresql/data }
volumeClaimTemplates:
- metadata:
name: data # PVC becomes data-pg-0
spec:
accessModes: ["ReadWriteOncePod"] # one writer, enforced
storageClassName: gp3-retain
resources:
requests:
storage: 50Gi
To move the live volume to the faster profile later, kubectl patch pvc data-pg-0 -p '{"spec":{"volumeAttributesClassName":"gp3-fast"}}'. To grow it, raise resources.requests.storage; expansion of in-use CSI volumes has been stable since 1.24, and you can only grow, never shrink. For real Postgres I would still reach for an operator such as CloudNativePG rather than a hand-rolled StatefulSet, but the storage objects underneath are the same.
What changed in Kubernetes storage from 1.20 to 1.37
If your mental model dates from the in-tree era, most of it holds, but a run of GA milestones changed the defaults. Every version in the figure comes from the docs and feature-gate pages linked above.
| Version | Change | What it means for you | Source |
|---|---|---|---|
| 1.27 | In-tree awsElasticBlockStore removed |
Old PVs must be on the EBS CSI driver | StorageClass docs |
| 1.28 | Non-graceful node shutdown stable | node.kubernetes.io/out-of-service taint detaches volumes from a dead node |
Node shutdown docs |
| 1.34 | VolumeAttributesClass, RecoverVolumeExpansionFailure stable | Change IOPS live; retry a failed resize with a smaller size | Feature gates |
| 1.36 | MutableCSINodeAllocatableCount stable | Kubelet refreshes per-node attach limits | v1.36 release blog |
| 1.37 | PersistentVolumeClaimUnusedSinceTime beta |
PVCs get an Unused condition with a timestamp |
PV docs |
| 1.37 | "Multi-Attach error" event now "Waiting for detach" | Update alerts and runbooks that grep the old text | PR 138837 |
The last row is small but practical. For years the attach/detach controller emitted Multi-Attach error for volume ... when an RWO volume was still attached to another node. Since 1.37 the same event reads Waiting for detach for volume ..., with a similar suffix about the volume being exclusively attached or already used by named pods. The behaviour is unchanged, including the six-minute timer after which the controller force-detaches from an unhealthy node, but any log alert matching "Multi-Attach" stops firing on upgraded clusters. That is the kind of change platform teams exist to catch.
The Unused condition is the other one I like. With it on, the PVC protection controller records when a claim stopped being referenced by any non-terminal pod, which turns "find orphaned disks we are paying for" into a short jsonpath query instead of a script that cross-references every pod.
FAQ
What is the difference between a PV and a PVC in Kubernetes?
A PersistentVolume is a cluster-wide object representing an actual disk or share. A PersistentVolumeClaim is a namespaced request for storage that a pod mounts. The claim binds one-to-one to a volume, either a pre-created one or one a StorageClass provisions on demand.
Do I need a CSI driver for Kubernetes storage?
For any cloud block or file storage, yes. The in-tree cloud plugins have been migrated to CSI or removed, with AWS EBS removed in 1.27. Local hostPath, local and emptyDir volumes do not need one, but they do not survive a node either.
Should I use ReadWriteOnce or ReadWriteOncePod?
Use ReadWriteOncePod when exactly one pod should ever write, such as a single-instance database. ReadWriteOnce only limits the volume to one node, so two pods on the same node can both mount it. RWOP requires a CSI driver and has been stable since 1.29.
Why is my PVC Pending with WaitForFirstConsumer?
That is expected until a pod that uses the claim is scheduled. The event says it is waiting for the first consumer. If a pod exists and the PVC is still Pending, look at the pod's scheduling events and the provisioner logs; the PVC Pending and Multi-Attach guide has the commands.
This quarter, on every cluster I touch, I would do three boring things: make sure exactly one default StorageClass exists and that it uses WaitForFirstConsumer and allowVolumeExpansion: true; flip the reclaim policy to Retain on every PV that holds data I could not regenerate; and grep the alerting rules for "Multi-Attach" before the 1.37 upgrade lands. None of it is clever. All of it is cheaper than a restore.
Related: Should You Run Databases on Kubernetes in 2026? 66% Do, RDS Costs 1.7x, How to Back Up and Restore Kubernetes with Velero 1.18 (2026) and How to Run Postgres on Kubernetes with CloudNativePG 1.30 (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
Apache Kafka in 2026: Kafka 4.4, No ZooKeeper, Queues and Diskless
Apache Kafka 4.4 explained for 2026: KRaft-only clusters since 4.0, production-ready queues (KIP-932), tiered storage, the accepted but unshipped diskless KIP-1150, WarpStream vs AutoMQ vs Redpanda, and when you do not need Kafka at all.
11 minNov 10, 2026How to Fix AWS ALB Unhealthy Targets and 502/503 Errors (2026)
Fix AWS Application Load Balancer unhealthy targets and 502, 503 and 504 errors step by step: read the health reason code, fix health check paths and success codes, security groups, ip vs instance targets, EKS readiness gates, deregistration delay, keep-alive timeouts and access logs.
13 minNov 10, 2026How to Fix Kubernetes PVC Pending and Multi-Attach Errors (2026)
Fix a Kubernetes PersistentVolumeClaim stuck in Pending and the Multi-Attach (now Waiting for detach) error: missing default StorageClass, EBS CSI driver not installed, zone conflicts, access modes, stuck Terminating and failed resizes, with commands for Kubernetes 1.37.
15 min