Kubernetes Persistent Volumes and Storage Classes: Stop Losing State When Pods Die
Kubernetes persistent volumes and storage classes explained with production patterns.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓Basic Kubernetes pod and deployment syntax
- ✓Understanding of YAML manifests
- ✓Familiarity with kubectl commands
Use a StorageClass to dynamically provision PersistentVolumes when a PVC is created. Set reclaimPolicy to Retain to prevent data loss. Always test with a dummy workload before production.
Think of a PersistentVolume as a reserved parking spot in a giant lot. A PersistentVolumeClaim is your parking permit—it says 'I need a spot that fits my car.' The StorageClass is the lot manager who decides whether to assign an existing spot (static) or build a new one (dynamic) when you show up. If your car (pod) gets towed, the spot stays reserved until you clean it up.
Every Kubernetes newbie learns the hard way: pods are ephemeral, but your database isn't. I've seen a startup lose 3 days of user data because they assumed a StatefulSet's PVC would survive a node failure. It didn't. PersistentVolumes and StorageClasses are how you tell Kubernetes 'this data matters.' Without them, you're gambling with state.
The problem is simple: containers crash, nodes fail, clusters scale down. Without persistent storage, your Postgres data vanishes when the pod restarts. The hack people used was hostPath—binding to a directory on the node. That works until your pod reschedules to a different node. Then you're staring at an empty directory and a corrupted database.
By the end of this, you'll be able to configure dynamic provisioning with a StorageClass, set reclaim policies that prevent accidental data deletion, and debug the three most common PV failures I've seen in production. You'll also know when to skip PVs entirely and use a managed database instead.
Why Static Provisioning Will Burn You
Before StorageClasses, you had to create PVs manually. Every developer who needed storage had to pester the ops team: 'Hey, can you provision a 50GB EBS volume and create a PV for it?' That's slow, error-prone, and doesn't scale. Worse, when the PV was full, you couldn't resize it without downtime. I've seen a team delete a production PV by accident because they forgot to set persistentVolumeReclaimPolicy: Retain. The PVC was deleted, and boom—the PV was recycled. Data gone.
Dynamic provisioning via StorageClass automates this. You define a StorageClass that points to a provisioner (e.g., kubernetes.io/aws-ebs), and when a PVC is created, Kubernetes calls the cloud provider's API to create the volume and bind it automatically. No manual steps. No ops bottleneck.
But here's the catch: not all StorageClasses are created equal. Some provisioners (like kubernetes.io/host-path or local) don't support dynamic provisioning at all. Always check the provisioner's documentation. For production, use cloud-native provisioners (EBS, GCE PD, Azure Disk) or a CSI driver like Rook/Ceph.
reclaimPolicy: Delete (the default), deleting the PVC will delete the underlying cloud volume. I've seen a team lose a 500GB Postgres volume this way. Always set Retain for production databases, and manually clean up volumes after verifying backups.Access Modes: ReadWriteOnce vs ReadOnlyMany vs ReadWriteMany
Access modes define how many pods can mount the volume and with what permissions. ReadWriteOnce (RWO) is the most common—one pod can read and write. ReadOnlyMany (ROX) allows many pods to read simultaneously. ReadWriteMany (RWX) allows many pods to read and write—but this is the trickiest.
RWX requires a filesystem that supports concurrent writes, like NFS or GlusterFS. Cloud block storage (EBS, GCE PD) only supports RWO. I've seen teams try to use an EBS volume with RWX and wonder why their pods crash with mount.nfs: access denied by server while mounting. The fix is to use a CSI driver that supports RWX, like Amazon EFS or Azure Files.
For stateful workloads like databases, use RWO. For shared configuration or logs, use ROX or RWX with a proper distributed filesystem. Never assume RWX works—test it first.
kubectl get storageclass <name> -o yaml and look for allowVolumeExpansion: true and mountOptions. For RWX, ensure the provisioner supports it. For EBS, it doesn't—use EFS or a CSI driver.Reclaim Policies: Retain, Delete, or Recycle
When a PVC is deleted, what happens to the PV? That's the reclaim policy. Delete removes the PV and the underlying storage asset. Retain keeps the PV and volume—you must manually delete them. Recycle (deprecated) scrubs the volume and makes it available again.
In production, always use Retain for any volume with data you care about. I've seen a junior engineer delete a PVC thinking it would just unbind the PV—but the policy was Delete, and the entire EBS volume was gone. No recovery. The fix is to set persistentVolumeReclaimPolicy: Retain on the StorageClass or on individual PVs.
For ephemeral storage (e.g., build artifacts), Delete is fine. But for databases, queues, or any stateful service, Retain is non-negotiable. And always have a backup strategy—Retain doesn't protect against accidental deletion of the PV itself.
Volume Binding Modes: Immediate vs WaitForFirstConsumer
Volume binding mode controls when the PV is provisioned and bound to the PVC. Immediate creates the volume as soon as the PVC is created. WaitForFirstConsumer delays provisioning until a pod using the PVC is scheduled.
Why use WaitForFirstConsumer? In multi-zone clusters, Immediate might provision a volume in zone A, but the pod gets scheduled in zone B. Then the pod can't mount the volume because it's in a different availability zone. I've seen this bring down a multi-region deployment—pods were stuck in ContainerCreating with volume zone mismatch errors.
WaitForFirstConsumer ensures the volume is provisioned in the same zone as the pod. This is critical for cloud providers where volumes are zonal (EBS, GCE PD). For regional volumes (like Azure Disk), Immediate is fine. Always set volumeBindingMode: WaitForFirstConsumer for zonal storage.
WaitForFirstConsumer binding mode. Without it, you risk cross-zone mounting failures.Expanding Volumes Without Downtime
You deployed a 50GB Postgres instance, and now it's at 48GB. You need more space. With static provisioning, you'd have to create a new PV, migrate data, and update the PVC—downtime guaranteed. With dynamic provisioning and allowVolumeExpansion: true, you can edit the PVC's storage request and Kubernetes will expand the volume in place.
But there are caveats. Not all volume types support expansion. EBS gp2/gp3 does, but magnetic (standard) doesn't. Also, the filesystem must support resizing—ext4 and xfs do, but not all CSI drivers handle it automatically. I've seen a team expand a PVC, but the pod still saw the old size because the filesystem wasn't resized. The fix was to run resize2fs inside the pod (or use a CSI driver that does it automatically).
To enable expansion, set allowVolumeExpansion: true in the StorageClass. Then edit the PVC: kubectl edit pvc <name> and change spec.resources.requests.storage. The volume will expand, but the pod may need a restart to see the new size.
df -h. If the old size shows, the filesystem wasn't resized. For ext4, run resize2fs inside the pod. For xfs, use xfs_growfs. Some CSI drivers (like EBS) do this automatically, but don't assume.When Not to Use Persistent Volumes
PVs are great for stateful workloads, but they're not the answer to every storage problem. If you're running a stateless application, don't use PVs—use emptyDir or ConfigMap. If you need a database, consider a managed service (RDS, Cloud SQL) instead of running it yourself on PVs. Managed databases handle backups, replication, and failover—you don't have to.
I've seen teams run MySQL on PVs with a single replica, no backup strategy, and wonder why they lost data when the node died. For production databases, use a managed service or at least a StatefulSet with regular backups. PVs are infrastructure, not a backup solution.
Also, avoid PVs for temporary or cache data. Use emptyDir with a memory-backed medium (if your node has enough RAM) or a sidecar with a caching layer like Redis. PVs add latency and complexity—only use them when data must survive pod restarts.
Debugging PV Failures: The Three Most Common Issues
- PVC stuck in Pending: Run
kubectl describe pvc <name>. Look for events likeno persistent volumes available for this claim and no storage class is set. Fix: addstorageClassNameto PVC or create a default StorageClass. - Pod stuck in ContainerCreating: Run
kubectl describe pod <name>. Look forvolume node affinity conflictormulti-attach error. Fix: ensure volume is in the same zone as the pod (useWaitForFirstConsumer), or delete the pod that's holding the volume (if multi-attach not supported). - Volume mount fails with permission denied: Check the filesystem permissions. Many PVs (like NFS) mount as root. If your pod runs as non-root, you need to set
fsGroupin the pod's security context. I've seen this with Jenkins agents—they couldn't write to the workspace because the PV was owned by root.
securityContext.fsGroup. This is a common issue with NFS and EFS volumes. Set it to the group ID that owns the mount directory.The 4GB Container That Kept Dying
save 900 1 to reduce fork frequency. Set resources.requests.memory: 2Gi and limits.memory: 4Gi to prevent fork from exceeding node memory.- hostPath is a trap for stateful workloads.
- Always use network-backed PVs for databases, and always account for fork overhead in memory limits.
no persistent volumes available for this claim and no storage class is setkubectl get storageclass to list available StorageClasses. 2. If none exist, create one: kubectl apply -f storageclass.yaml. 3. If one exists, add storageClassName: <name> to the PVC spec. 4. If you want a default, annotate a StorageClass: kubectl annotate storageclass <name> storageclass.kubernetes.io/is-default-class=true.volume node affinity conflictkubectl get pv <pv-name> -o yaml | grep nodeAffinity. 2. Check the pod's node selector or affinity. 3. If the volume is in a different zone, delete the PVC and recreate it with a StorageClass that has volumeBindingMode: WaitForFirstConsumer. 4. Or delete the PV and recreate it in the correct zone.permission deniedkubectl get pod <pod-name> -o yaml | grep -A5 securityContext. 2. If fsGroup is missing, add it: kubectl patch deployment <deploy> -p '{"spec":{"template":{"spec":{"securityContext":{"fsGroup":1000}}}}}'. 3. If the volume is NFS, ensure the export permissions allow the pod's UID/GID.kubectl get storageclasskubectl describe pvc <name>| File | Command / Code | Purpose |
|---|---|---|
| dynamic-provisioning.yaml | apiVersion: storage.k8s.io/v1 | Why Static Provisioning Will Burn You |
| access-modes-pvc.yaml | apiVersion: v1 | Access Modes |
| pv-with-retain.yaml | apiVersion: v1 | Reclaim Policies |
| storageclass-wffc.yaml | apiVersion: storage.k8s.io/v1 | Volume Binding Modes |
| expand-pvc.sh | kubectl patch storageclass standard -p '{"allowVolumeExpansion": true}' | Expanding Volumes Without Downtime |
| emptydir-example.yaml | apiVersion: v1 | When Not to Use Persistent Volumes |
| debug-pvc.sh | kubectl describe pvc mysql-data | Debugging PV Failures |
Key takeaways
Interview Questions on This Topic
How does Kubernetes handle volume provisioning when a PVC uses a StorageClass with volumeBindingMode: WaitForFirstConsumer and the pod is scheduled on a node in a different zone than the volume?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Kubernetes. Mark it forged?
4 min read · try the examples if you haven't