Storage is where a lot of Kubernetes beginners get tripped up, mostly because the abstraction layers — StorageClass, PersistentVolume, PersistentVolumeClaim — don’t map cleanly onto anything from traditional server administration. Once the model clicks, though, it’s genuinely elegant: developers request storage by characteristics, not by specific disks, and the cluster figures out the rest. This guide covers that model end to end.
The Storage Abstraction Chain
- StorageClass: describes a class of storage — which provisioner to use, what parameters (disk type, IOPS, filesystem), and reclaim behavior. Cluster-scoped, defined once by an admin.
- PersistentVolume (PV): an actual piece of provisioned storage, either created dynamically from a StorageClass or manually by an admin.
- PersistentVolumeClaim (PVC): a request for storage made by a Pod’s owner — “I need 10Gi, ReadWriteOnce, from the
fast-ssdclass.” Kubernetes binds it to a matching PV.
Dynamic provisioning means developers never touch PVs directly — they just write a PVC, and the StorageClass’s provisioner creates the backing PV automatically.
Checking Available Storage Classes
kubectl get storageclass
Output on a typical cloud cluster:
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
gp3 (default) ebs.csi.aws.com Delete WaitForFirstConsumer
gp3-immediate ebs.csi.aws.com Delete Immediate
Defining a Custom StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "6000"
throughput: "250"
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
Key fields worth understanding individually:
reclaimPolicy: RetainvsDelete— whether the underlying disk survives after the PVC is deleted.Retainis safer for anything holding data you can’t easily regenerate.volumeBindingMode: WaitForFirstConsumer— delays provisioning until a Pod actually needs the volume, so the disk gets created in the same zone as the Pod that will use it (critical for zone-locked block storage like EBS).allowVolumeExpansion: true— lets PVCs be resized later without recreating them.
Apply it:
kubectl apply -f fast-ssd-storageclass.yaml
Requesting Storage with a PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-pvc
namespace: production
spec:
accessModes:
- ReadWriteOnce
storageClassName: fast-ssd
resources:
requests:
storage: 20Gi
kubectl apply -f data-pvc.yaml
kubectl get pvc data-pvc -n production
Output:
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
data-pvc Bound pvc-8f3a1e2b-... 20Gi RWO fast-ssd
If volumeBindingMode: WaitForFirstConsumer is set, the PVC will show Pending until a Pod referencing it is scheduled — that’s expected, not an error.
Mounting the PVC in a Pod
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: production
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 50Gi
Using volumeClaimTemplates in a StatefulSet is the standard pattern for databases — each replica gets its own dedicated PVC, created and bound automatically as the StatefulSet scales.
Access Modes Explained
- ReadWriteOnce (RWO): one node can mount read-write. Most block storage (EBS, Azure Disk, GCE PD).
- ReadOnlyMany (ROX): many nodes, read-only.
- ReadWriteMany (RWX): many nodes, read-write — requires a filesystem-based backend (NFS, EFS, Azure Files, CephFS), not raw block storage.
- ReadWriteOncePod: newer mode restricting mount to a single Pod rather than a single node — useful for guaranteeing exclusivity in clusters using shared-node storage.
Resizing a PVC
With allowVolumeExpansion: true set on the StorageClass:
kubectl patch pvc data-pvc -n production \
-p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'
kubectl get pvc data-pvc -n production -w
The underlying filesystem typically needs the Pod to restart (or, for some CSI drivers, an online resize happens automatically) before the OS sees the new size — check the specific CSI driver’s docs for whether online expansion is supported.
Multiple StorageClasses for Different Workloads
A production cluster commonly runs several classes side by side:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: standard-hdd
provisioner: ebs.csi.aws.com
parameters:
type: st1
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: shared-nfs
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap
fileSystemId: fs-0123456789abcdef0
reclaimPolicy: Retain
Use fast-ssd for databases, standard-hdd for logs/backups, and shared-nfs wherever multiple Pods genuinely need concurrent read-write access to the same files.
Monitoring Storage Usage
kubectl get pv
kubectl describe pvc data-pvc -n production
kubectl top pod -n production --containers
For deeper visibility, kube-state-metrics (commonly paired with Grafana, as in the monitoring setup) exposes kube_persistentvolumeclaim_resource_requests_storage_bytes and related metrics for dashboarding actual vs. requested capacity.
What’s Actually Happening Under the Hood: CSI
Every modern StorageClass ultimately delegates to a CSI (Container Storage Interface) driver — a standardized plugin interface that lets Kubernetes talk to any storage backend without the backend’s code living inside Kubernetes core itself. When a PVC is created, three things happen in sequence: the external-provisioner sidecar watches for the PVC and calls the CSI driver’s CreateVolume method; the driver talks to the actual backend (an AWS API call for EBS, for instance) to provision a real disk; and a PV object is created in Kubernetes representing that disk, which then gets bound to the PVC. When a Pod using that PVC is scheduled, the kubelet calls the CSI driver’s NodePublishVolume to actually attach and mount it on that specific node.
kubectl get csidrivers
NAME ATTACHREQUIRED PODINFOONMOUNT
ebs.csi.aws.com true true
efs.csi.aws.com false true
ATTACHREQUIRED is a meaningful distinction — block storage like EBS needs an explicit attach step tying the volume to a specific node before it can be mounted, while filesystem storage like EFS doesn’t, since many nodes can mount it concurrently without any per-node attachment step.
Volume Snapshots
Beyond simple provisioning, CSI drivers that support the snapshot extension enable point-in-time backups managed entirely through the Kubernetes API:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: csi-snapclass
driver: ebs.csi.aws.com
deletionPolicy: Retain
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: data-pvc-snapshot
namespace: production
spec:
volumeSnapshotClassName: csi-snapclass
source:
persistentVolumeClaimName: data-pvc
kubectl apply -f data-pvc-snapshot.yaml
kubectl get volumesnapshot -n production
NAME READYTOUSE SOURCEPVC
data-pvc-snapshot true data-pvc
Restoring from a snapshot into a brand-new PVC:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: restored-pvc
namespace: production
spec:
storageClassName: fast-ssd
dataSource:
name: data-pvc-snapshot
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
This is the foundation most Kubernetes-native backup tools (like Velero) build on for application-consistent disaster recovery, rather than relying purely on cloud-provider snapshot tooling outside Kubernetes’ own API.
Storage Class Defaults and Immutability
Exactly one StorageClass in a cluster can be marked default, via an annotation:
metadata:
annotations:
storageclass.kubernetes.io/is-default-class: "true"
Worth noting: most fields on a StorageClass are immutable after creation — changing provisioner or parameters on an existing StorageClass has no effect on already-provisioned PVs and often isn’t even accepted by the API server. To change these meaningfully, create a new StorageClass under a new name and migrate PVCs to it deliberately, rather than expecting an in-place edit to propagate.
Local Persistent Volumes for Performance-Sensitive Workloads
Network-attached block storage (EBS, Azure Disk) adds latency that some workloads — databases with strict I/O requirements, certain caching layers — can’t tolerate. Local PersistentVolumes expose a node’s actual local disk through the same PVC/PV abstraction, trading portability for raw performance:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-fast
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-node1
spec:
capacity:
storage: 100Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-fast
local:
path: /mnt/disks/ssd1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node1
The critical trade-off: a Pod using this PVC can only ever be scheduled onto node1, since the data physically lives there. If that node fails, the data isn’t automatically available elsewhere — this pattern is only appropriate for workloads that handle their own replication at the application layer (many distributed databases do exactly this), not as a general-purpose default.
Generic Ephemeral Volumes
For storage that should be sized and scaled like a PVC but doesn’t need to outlive the Pod — scratch space for a batch job, temporary large-file processing — generic ephemeral volumes provide PVC semantics (including StorageClass-driven provisioning) without any persistence guarantee:
apiVersion: v1
kind: Pod
metadata:
name: batch-processor
spec:
containers:
- name: processor
image: registry.example.com/processor:1.0.0
volumeMounts:
- mountPath: /scratch
name: scratch-data
volumes:
- name: scratch-data
ephemeral:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 100Gi
This volume is created alongside the Pod and deleted automatically when the Pod is deleted — genuinely useful for workloads needing more scratch space than emptyDir‘s node-local disk can comfortably offer, without the operational overhead of manually cleaning up leftover PVCs after every batch run.
Common Mistakes
- Setting
reclaimPolicy: Deleteon StorageClasses backing databases, then losing data permanently after an accidental PVC deletion. - Requesting
ReadWriteManyon a StorageClass backed by block storage that fundamentally can’t support it — the PVC will sitPendingforever with a scheduling error. - Ignoring
volumeBindingModeand hitting cross-zone scheduling failures where the Pod and its EBS volume end up in different availability zones. - Not setting
allowVolumeExpansion, requiring a full data migration later just to grow a volume by a few gigabytes.
Summary
StorageClasses turn “give me a disk” into a declarative, provider-agnostic request: developers specify access mode and size in a PVC, the StorageClass’s provisioner and parameters determine what actually gets created underneath. Getting the details right — reclaim policy, binding mode, access mode — up front avoids the two most painful storage incidents in Kubernetes: silent data loss and unschedulable Pods.
References
- Kubernetes Storage Classes: https://kubernetes.io/docs/concepts/storage/storage-classes/
- Persistent Volumes: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
- Container Storage Interface (CSI): https://kubernetes-csi.github.io/docs/
- CNCF storage landscape: https://landscape.cncf.io/category=cloud-native-storage