Kubernetes Persistent Volumes 2025 — Storage for Stateful Workloads
Advertisement
Introduction
Why This Matters
Kubernetes was designed for stateless workloads, but real applications need stateful storage — databases, message queues, object stores, and log files. Without proper storage management, data is lost every time a pod restarts or is rescheduled to a different node.
Kubernetes Persistent Volumes decouple storage from pod lifecycle. A database pod can be killed, rescheduled to a different node, and come back using the same data — if storage is configured correctly. StorageClasses and dynamic provisioning make this automatic on cloud platforms like AWS EKS, GKE, and AKS.
Understanding Kubernetes storage is essential for anyone running databases (PostgreSQL, MySQL, MongoDB, Redis) or message queues (Kafka, RabbitMQ) on Kubernetes.
Storage Concepts
PersistentVolume (PV): A piece of storage provisioned in the cluster (by an admin or dynamically by a StorageClass). Independent of any specific pod.
PersistentVolumeClaim (PVC): A request for storage by a pod. Specifies size, access mode, and StorageClass. Kubernetes binds PVCs to matching PVs.
StorageClass: Defines the provisioner (AWS EBS, GCE PD, NFS) and parameters. Enables dynamic provisioning — PVs are created automatically when a PVC is applied.
Access Modes:
ReadWriteOnce (RWO)— one node can mount read/write (EBS, standard disks)ReadOnlyMany (ROX)— multiple nodes can mount read-onlyReadWriteMany (RWX)— multiple nodes can mount read/write (EFS, NFS, CephFS)
StorageClasses
# AWS EBS StorageClass (gp3 — faster than gp2, same cost)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ebs-gp3
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer # provision in the same AZ as the pod
reclaimPolicy: Delete # Delete PV when PVC is deleted (use Retain for production databases)
parameters:
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"# AWS EFS StorageClass (ReadWriteMany — shared across pods/nodes)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: efs-sc
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap
fileSystemId: fs-0123456789abcdef0
directoryPerms: "700"# List available storage classes
kubectl get storageclass
kubectl describe storageclass ebs-gp3PersistentVolumeClaims
# PVC for a PostgreSQL database
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: production
spec:
accessModes:
- ReadWriteOnce
storageClassName: ebs-gp3
resources:
requests:
storage: 50Gikubectl apply -f pvc.yaml
kubectl get pvc -n production
# STATUS: Bound (PV has been provisioned and bound)
# VOLUME: pvc-abc123 (the auto-created PV name)
kubectl describe pvc postgres-data -n productionUsing PVCs in Pods
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: production
spec:
replicas: 1 # databases should be StatefulSets, not Deployments
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2
memory: 4Gi
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-dataStatefulSets — The Right Way for Stateful Apps
StatefulSets are designed for stateful workloads. They provide:
- Stable, predictable pod names (pod-0, pod-1, pod-2)
- Ordered startup and shutdown (pod-0 first, then pod-1, etc.)
- Stable network identities (DNS:
pod-0.service-name.namespace.svc.cluster.local) - Per-pod PVCs via
volumeClaimTemplates
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: production
spec:
serviceName: postgres # headless service for stable DNS
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
env:
- name: POSTGRES_USER
value: app
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: POSTGRES_DB
value: mydb
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2
memory: 4Gi
readinessProbe:
exec:
command: ["pg_isready", "-U", "app", "-d", "mydb"]
initialDelaySeconds: 15
periodSeconds: 5
# Each pod gets its own PVC automatically
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: ebs-gp3
resources:
requests:
storage: 50Gi# Headless Service for StatefulSet DNS
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: production
spec:
selector:
app: postgres
clusterIP: None # headless — no load balancing, direct pod DNS
ports:
- port: 5432
targetPort: 5432Expand PVC Storage
# Edit the PVC to increase storage (only works with allowVolumeExpansion: true in StorageClass)
kubectl patch pvc postgres-data -n production \
-p '{"spec": {"resources": {"requests": {"storage": "100Gi"}}}}'
kubectl get pvc postgres-data -n production
# SIZE will show 100Gi once expansion is completeBackup Strategies
VolumeSnapshot (CSI)
# Create a snapshot (requires CSI driver with snapshot support)
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-snapshot-20260301
namespace: production
spec:
volumeSnapshotClassName: csi-aws-vsc
source:
persistentVolumeClaimName: data-postgres-0
---
# Restore from snapshot into new PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-restored
namespace: production
spec:
accessModes: [ReadWriteOnce]
storageClassName: ebs-gp3
resources:
requests:
storage: 50Gi
dataSource:
name: postgres-snapshot-20260301
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.iopg_dump for PostgreSQL
# Logical backup (works across versions)
kubectl exec -it postgres-0 -n production -- \
pg_dump -U app mydb | gzip > postgres-backup-$(date +%Y%m%d).sql.gz
# Restore
gunzip -c postgres-backup-20260301.sql.gz | \
kubectl exec -i postgres-0 -n production -- \
psql -U app mydbReclaim Policies
# Delete (default): PV deleted when PVC deleted — use for ephemeral data
reclaimPolicy: Delete
# Retain: PV preserved when PVC deleted — use for production databases
reclaimPolicy: Retain
# To reuse a Retain PV after PVC deletion:
# 1. Delete the PV's claimRef
kubectl patch pv <pv-name> -p '{"spec":{"claimRef": null}}'
# 2. Create a new PVC that references this PVCommon Mistakes
- Using a Deployment (not a StatefulSet) for databases — Deployments do not guarantee stable pod names or ordered scaling
- Not setting
reclaimPolicy: Retainfor production databases —Deleteremoves data when the PVC is deleted - Using
ReadWriteManywhenReadWriteOnceis available and sufficient — RWX (EFS, NFS) has higher latency than RWO (EBS) - Not specifying
PGDATAto a subdirectory inside the mount — PostgreSQL fails to initialize in a non-empty directory - Forgetting to configure
allowVolumeExpansion: truein the StorageClass — blocks PVC expansion later
Best Practices
- Use StatefulSets for all stateful workloads — databases, Kafka, ZooKeeper, Elasticsearch
- Set
reclaimPolicy: Retainfor any StorageClass used by production databases - Use
WaitForFirstConsumervolume binding mode to provision storage in the same availability zone as the pod - Automate backups with VolumeSnapshots or logical backup jobs (CronJobs with pg_dump)
- Monitor PVC usage with
kubelet_volume_stats_used_bytesin Prometheus — add alerts at 80% and 90% usage
Key Takeaways
- PersistentVolumes (PV) are storage resources; PersistentVolumeClaims (PVC) are requests for storage — StorageClasses automate PV creation
- StatefulSets are the correct controller for databases — they provide stable pod names, ordered scaling, and per-pod PVCs
ReadWriteOnce(EBS, standard disks) is for single-pod access;ReadWriteMany(EFS, NFS) enables shared access across pods and nodes- Set
reclaimPolicy: Retainon production StorageClasses — the defaultDeleteremoves data when PVCs are removed WaitForFirstConsumervolume binding ensures storage is provisioned in the same availability zone as the scheduled pod- VolumeSnapshots enable point-in-time backups of PVC data using the CSI snapshot API
- Monitor PVC disk usage with Prometheus — without monitoring, PVCs silently fill up and crash databases
- PVC storage expansion requires the StorageClass to have
allowVolumeExpansion: true— plan for this from day one
Advertisement