Kubernetes StorageClasses
Dynamic volume provisioning with StorageClasses. Provisioners, parameters, reclaim policy, volumeBindingMode, expansion, and setting a sensible default.
A StorageClass describes a kind of storage the cluster can create on demand. A PersistentVolumeClaim names one, and a provisioner creates a matching volume automatically.
Without StorageClasses an administrator has to pre-create every PersistentVolume by hand. With them, an application team writes a claim and gets a disk.
What one looks like
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION
gp3 (default) ebs.csi.aws.com Delete WaitForFirstConsumer true
gp3-retain ebs.csi.aws.com Retain WaitForFirstConsumer true
efs efs.csi.aws.com Delete Immediate false
Using it is a single line in the claim:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: payments-data
spec:
accessModes: [ReadWriteOnce]
storageClassName: gp3
resources:
requests:
storage: 20Gi
The fields that matter
provisioner
Which CSI driver creates the volume, ebs.csi.aws.com, disk.csi.azure.com, pd.csi.storage.gke.io, efs.csi.aws.com, and so on.
The driver must actually be installed. A StorageClass naming a provisioner that is not present leaves claims Pending forever with an event saying no provisioner is available. This is the most common cause of a PVC that never binds on a self-managed cluster:
kubectl get csidrivers
parameters
Provisioner-specific and completely unportable. AWS gp3 parameters mean nothing to the Azure driver. Check the driver's own documentation rather than copying between clouds.
encrypted: "true" is worth making the default. Encryption at rest costs nothing on most providers and cannot be turned on after the volume exists.
reclaimPolicy
What happens to the underlying disk when the PVC is deleted.
| Value | Effect |
|---|---|
Delete | The volume and its data are destroyed. The default for dynamic provisioning |
Retain | The PV and data survive; an administrator handles it |
Delete is the default, and it means deleting a namespace can permanently destroy a database volume. A practical pattern is to offer two classes and use the retaining one for anything stateful:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-retain
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
The trade-off is that retained volumes accumulate and keep costing money, so someone has to clean them up deliberately.
volumeBindingMode
The field most worth understanding, because its default behaviour produces a confusing symptom.
| Mode | Behaviour |
|---|---|
Immediate | Provision the volume as soon as the PVC is created |
WaitForFirstConsumer | Wait until a Pod using the claim is scheduled |
With Immediate on a multi-zone cluster, the volume is created in some zone before anyone knows where the Pod will run. If the scheduler later picks a node in a different zone, the Pod cannot mount it and sits Pending with a volume node affinity conflict.
WaitForFirstConsumer defers provisioning until the scheduler has chosen a node, so the disk is created in the right zone. Use it for any zonal block storage.
The side effect is an event that looks like a problem and is not:
Normal WaitForFirstConsumer waiting for first consumer to be created before binding
A PVC sitting Pending with that message is working correctly. It binds as soon as a Pod uses it.
Immediate remains appropriate for genuinely zone-independent storage such as EFS or NFS.
allowVolumeExpansion
Must be true before a PVC can grow:
kubectl patch pvc payments-data -p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'
Shrinking is never supported by any provisioner. Enable expansion on every class you create. There is no downside, and turning it on later does not help volumes that already exist.
The default StorageClass
A PVC with no storageClassName uses whichever class is annotated as default:
kubectl patch storageclass gp3 \
-p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
Two things to watch. Exactly one class should be default, with several, behaviour is unpredictable. And storageClassName: "" is not the same as omitting the field: an empty string explicitly disables dynamic provisioning and forces binding to a pre-created PV.
Managed clusters ship a default, and it is rarely the best choice. On EKS it has historically been gp2, which is slower and more expensive than gp3 for most workloads. Checking and replacing it is a quick win.
Snapshots
CSI drivers that support it provide volume snapshots, which is the real backup mechanism for Kubernetes storage:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: ebs-snapshots
driver: ebs.csi.aws.com
deletionPolicy: Delete
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: payments-data-2026-09-12
spec:
volumeSnapshotClassName: ebs-snapshots
source:
persistentVolumeClaimName: payments-data
A snapshot can then be restored into a new PVC via dataSource. reclaimPolicy: Retain is not a backup. It only helps if the PVC is deleted, and does nothing for corruption or accidental writes.
Debugging
kubectl describe pvc payments-data
kubectl get storageclass
kubectl get csidrivers
kubectl logs -n kube-system -l app=ebs-csi-controller -c csi-provisioner --tail=50
| Symptom | Cause |
|---|---|
no persistent volumes available... and no storage class is set | No default class, and the PVC named none |
waiting for first consumer | WaitForFirstConsumer working normally |
no volume plugin matched | The named provisioner's CSI driver is not installed |
volume node affinity conflict | Immediate binding put the volume in the wrong zone |
| Expansion ignored | allowVolumeExpansion is false on the class |
Quick reference
| Task | Command |
|---|---|
| List classes | kubectl get storageclass |
| Installed drivers | kubectl get csidrivers |
| Set the default | kubectl patch storageclass <n> -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' |
| Why is a PVC pending | kubectl describe pvc <n> |
| Expand a volume | kubectl patch pvc <n> -p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}' |
| Which class a PV uses | kubectl get pv <n> -o jsonpath='{.spec.storageClassName}' |
Key takeaways
- A StorageClass plus a PVC gives you a disk on demand: no pre-created PersistentVolumes.
- The named provisioner's CSI driver must be installed, or claims stay
Pendingforever. reclaimPolicy: Deleteis the default and will destroy data with the namespace. Offer aRetainclass for stateful workloads.- Use
WaitForFirstConsumerfor zonal block storage, and do not mistake its "waiting for first consumer" event for an error. - Set
allowVolumeExpansion: trueon every class. It cannot be applied retroactively to existing volumes. - Exactly one default class, and check what your managed cluster shipped. It is often not the best option.
- Volume snapshots are the backup mechanism.
Retainis not a backup.
Check your understanding
A volume in the wrong zone
A StorageClass uses volumeBindingMode: Immediate on a three-zone cluster. A PVC binds instantly, but the Pod using it stays Pending with "volume node affinity conflict". What happened?
Data lost with a namespace
A team deletes a staging namespace to clean up. The Postgres data volume is gone permanently. Which StorageClass setting caused this?
Frequently Asked Questions
What is a StorageClass in Kubernetes?
It is a cluster-scoped object describing a type of storage that can be created on demand, which CSI driver provisions it, what parameters that driver should use, what happens to the disk when the claim goes away, and when provisioning should happen. A PersistentVolumeClaim names a StorageClass and the provisioner creates a matching PersistentVolume automatically, which removes the need for an administrator to pre-create volumes by hand.
What does volumeBindingMode: WaitForFirstConsumer do?
It defers creating the volume until a Pod using the claim has been scheduled, so the disk is provisioned in the same Availability Zone as the node that will mount it. With Immediate, the volume is created straight away in some arbitrary zone, and if the scheduler later places the Pod elsewhere the Pod is stuck with a volume node affinity conflict. Use WaitForFirstConsumer for any zonal block storage, and do not mistake its "waiting for first consumer" event for an error. It is the mode working as intended.
Why is my PVC stuck in Pending with a StorageClass set?
Check kubectl describe pvc for the exact event. "Waiting for first consumer" is normal and resolves once a Pod uses the claim. "No volume plugin matched" or a missing provisioner means the CSI driver named by the StorageClass is not installed. Confirm with kubectl get csidrivers. Otherwise look at the provisioner's own logs, since quota limits, permissions and unsupported parameter combinations all surface there rather than on the claim.
How do I set or change the default StorageClass?
Add the annotation storageclass.kubernetes.io/is-default-class: "true" to the class you want, and remove it from any other. Having two defaults makes the behaviour unpredictable. Claims that omit storageClassName then use it. Be aware that storageClassName: "" is not the same as leaving the field out: an empty string explicitly disables dynamic provisioning and forces binding to a pre-created PersistentVolume.
How do I stop deleting a namespace from destroying my data?
Create a second StorageClass with reclaimPolicy: Retain and use it for anything stateful. With Delete, which is the default on nearly every cloud StorageClass, removing a PVC destroys the underlying disk, and deleting a namespace removes every PVC in it. Retain keeps the volume and its data, leaving the PV in a Released state that an administrator can recover. Retained volumes do accumulate cost, so they need deliberate cleanup, and real volume snapshots remain the actual backup mechanism.
Can I change a StorageClass after volumes have been created?
Most fields on a StorageClass are immutable, and in any case changes do not apply retroactively to existing PersistentVolumes. They were provisioned with the settings in force at the time. To move a workload onto different storage you create a new StorageClass, create a new PVC using it, and migrate the data, typically from a volume snapshot. This is why enabling allowVolumeExpansion and encryption from the beginning matters: neither can be added to a volume that already exists.