Container storage is ephemeral by default. When a Pod dies, everything inside the container’s filesystem dies with it. That’s fine for stateless apps, but terrible for databases. If our PostgreSQL Pod restarts, we don’t want to lose all our data. That’s where Persistent Volumes come in.
The Three Pieces
Kubernetes storage has three main objects, and they work together like a request system.
PersistentVolume (PV) — the actual storage resource. Think of it like a physical hard drive in the cluster. It can be an AWS EBS volume, a GCP Persistent Disk, an NFS share, or local storage on a node.
PersistentVolumeClaim (PVC) — a request for storage. Our Pod says “I need 10Gi of fast storage” and Kubernetes finds (or creates) a matching PV.
StorageClass — defines how storage gets provisioned. Instead of manually creating PVs, the StorageClass tells Kubernetes to dynamically create them when a PVC asks.
Static Provisioning
We manually create a PV, then claim it with a PVC.
# The actual storage
apiVersion: v1
kind: PersistentVolume
metadata:
name: my-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath: # local storage (dev only!)
path: /data/my-volume
---
# The request
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
Dynamic Provisioning — The Better Way
In production, we don’t want to manually create PVs. We define a StorageClass and let Kubernetes provision storage on-demand.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com # AWS EBS CSI driver
parameters:
type: gp3 # SSD storage type
reclaimPolicy: Delete # delete PV when PVC is deleted
volumeBindingMode: WaitForFirstConsumer
Now our PVC just references the StorageClass:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-storage
spec:
storageClassName: fast-ssd # use our StorageClass
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
Kubernetes automatically creates a 20Gi EBS volume when this PVC is created. No manual PV needed.
Using a PVC in a Pod
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: db-data
mountPath: /var/lib/postgresql/data
volumes:
- name: db-data
persistentVolumeClaim:
claimName: db-storage
Access Modes
- ReadWriteOnce (RWO) — one node can mount it as read-write. Most common for databases.
- ReadOnlyMany (ROX) — many nodes can mount it as read-only. Good for shared config or static assets.
- ReadWriteMany (RWX) — many nodes can mount it as read-write. Needs special storage backends like NFS or EFS.
Most cloud block storage (EBS, Persistent Disk) only supports RWO. For RWX, we need network file systems.
Reclaim Policies
What happens to the PV when the PVC is deleted?
- Delete — the PV and underlying storage are deleted. Default for dynamic provisioning.
- Retain — the PV sticks around with its data. We have to manually clean it up. Safer for critical data.
Common Pattern: Database Storage
For databases, we typically use a StatefulSet with a volumeClaimTemplate. Each Pod gets its own PVC that persists across restarts.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 1
template:
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # each replica gets its own PVC
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 50Gi
In simple language, PVCs are like ordering storage from a menu (StorageClass). We say what we need, and Kubernetes provisions the real storage (PV) behind the scenes. Our Pod just mounts the PVC and uses it like a normal directory.