CoursesKubernetes administrationStorageClasses & provisioning

StorageClasses & provisioning

Dynamic provisioning and binding modes.

Advanced10 min · lesson 40 of 65
In plain terms
A StorageClass is a vending machine for lockers: ask for one and it builds it on the spot, instead of an admin pre-making rows of lockers by hand and hoping one fits.

Hand-cutting a disk for every app that asks for one does not scale. Ten teams, forty databases, three availability zones, and you turn into the person clicking 'create volume' at 2am. A StorageClass ends that. Think of it as a standing order the cluster keeps on file: it says which storage system to use and with what settings, and the cluster fills the order the moment an app asks. The app asks with a PersistentVolumeClaim, or PVC. That's a pod's written request for storage, and a pod is the smallest thing Kubernetes runs, one or more containers bundled together. Out comes a PersistentVolume, or PV, which is the real disk, created and attached with nobody in the loop. That auto-fill is called dynamic provisioning, and it is why modern clusters almost never keep a shelf of pre-built disks sitting around waiting to be claimed.

The order form, not the disk

A StorageClass holds no storage of its own. It is a short recipe, and a handful of fields carry all the weight. provisioner names the plugin that does the real work, almost always a CSI driver. CSI is the Container Storage Interface, the standard plug that lets any storage vendor snap into Kubernetes the way a wall socket accepts any brand of appliance. parameters get handed straight to that driver: disk type, IOPS (how many reads and writes per second the disk allows), whether to encrypt the data at rest. reclaimPolicy decides the disk's fate when its claim is deleted, either Delete to tear it down or Retain to keep it around for recovery. allowVolumeExpansion lets you grow a volume later. And volumeBindingMode sets when provisioning happens, which turns out to be the field that quietly bites people. One class usually wears the annotation storageclass.kubernetes.io/is-default-class set to true, and any PVC that names no class inherits it.

storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
terminal
$ kubectl apply -f storageclass.yaml
$ kubectl get storageclass
terminal
storageclass.storage.k8s.io/fast created
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
fast (default) ebs.csi.aws.com Delete WaitForFirstConsumer true 12d
standard ebs.csi.aws.com Retain Immediate true 12d

What fires when a claim shows up

A mailroom clerk has one job: watch the incoming tray. A parcel lands, they read the label, fetch the item, set it on the outgoing shelf. Kubernetes runs that exact loop for storage. A CSI driver ships a small helper container, a sidecar, called the external-provisioner, and it watches every PVC in the cluster. When a claim shows up naming a class whose provisioner matches this driver, the sidecar calls CreateVolume on the driver, the driver calls out to the cloud provider to actually cut the disk, and then the sidecar writes a PV object back into Kubernetes and binds your claim to it. From where you sit, you only ever created a PVC. Everything after that was a controller reacting to it. That is the whole model of Kubernetes storage in one sentence: you declare what you want, a controller notices, and it makes reality match.

Dynamic provisioning with WaitForFirstConsumer
1PVC creatednames a class, stays Pending2pod scheduledscheduler picks a node and zone3external-provisionersees the claim, calls the driver4CSI CreateVolumedriver cuts the disk in that…5PV bound + mountedthe node's kubelet agent…
WaitForFirstConsumer holds provisioning until step 2, so the disk is born in the pod's zone. Immediate would fire at step 1, before any zone is known.

Here is the part that trips up everyone the first time. On the recommended binding mode, the clerk does not move the instant your claim lands. Apply a PVC against a class set to WaitForFirstConsumer and it just sits there, Pending, apparently doing nothing at all.

terminal
$ kubectl apply -f pvc.yaml
$ kubectl -n payments get pvc payments-data
terminal
persistentvolumeclaim/payments-data created
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
payments-data Pending fast 9s

Binding modes and the zone trap

Two settings, one real consequence. Immediate provisions the disk the second the PVC is created. On a single-zone cluster that does no harm. On a cluster spread across three availability zones it is a landmine. The driver cuts the disk in whatever zone it likes, before anyone knows where the pod will run. The scheduler later places the pod in a different zone. And a cloud block disk cannot cross a zone boundary, the same way a filing cabinet bolted to one floor of a building can't be read from another. The pod gets stuck, the claim shows Bound (which fools you into thinking storage is fine), and the pod's event reads 'volume node affinity conflict.' WaitForFirstConsumer flips the order. It holds off until a pod that uses the claim is actually being scheduled, lets the scheduler pick the node first, then provisions the disk in that node's zone. Same zone by construction. For any zonal block storage, whether that's EBS (Amazon's block storage), Google Persistent Disk, or Azure Disk, this is the mode you want, and it is exactly why the Pending claim above was healthy. It was waiting for its pod.

Once you wire a real pod to that claim, the whole story shows up in the claim's events. This is the first place to look whenever provisioning misbehaves, before you start blaming the storage backend. Read it top to bottom. WaitForFirstConsumer means it was waiting for a pod. Provisioning means the driver got the call. And ProvisioningSucceeded next to a Bound status means the disk exists and is attached. If you see nothing at all in the events, that usually points at the driver itself not running, not at your claim.

terminal
$ kubectl -n payments describe pvc payments-data
terminal
Name: payments-data
Namespace: payments
StorageClass: fast
Status: Bound
Volume: pvc-6d2f8b1a-4c07-4f2e-9a1b-8f3c2d5e7a90
Access Modes: RWO
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 62s persistentvolume-controller waiting for first consumer to be created before binding
Normal Provisioning 14s ebs.csi.aws.com_ebs-csi-controller-7d9 External provisioner is provisioning volume for claim "payments/payments-data"
Normal ProvisioningSucceeded 11s ebs.csi.aws.com_ebs-csi-controller-7d9 Successfully provisioned volume pvc-6d2f8b1a-4c07-4f2e-9a1b-8f3c2d5e7a90

Growing a volume in place

Ran out of disk? If the class allows it, you grow a volume by editing the claim's requested size. No migration, no second PVC, no downtime on the modern drivers. Most CSI drivers resize the block device online while the pod keeps running. A few older ones need the pod to cycle so the filesystem can catch up, but the current EBS and Persistent Disk drivers do it live. One rule holds everywhere: you can only ever grow. Shrinking a PVC is not supported, and the API server rejects the edit outright.

terminal
$ kubectl -n payments patch pvc payments-data --type merge \
-p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'
$ kubectl -n payments get pvc payments-data
terminal
persistentvolumeclaim/payments-data patched
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
payments-data Bound pvc-6d2f8b1a-4c07-4f2e-9a1b-8f3c2d5e7a90 50Gi RWO fast 41m
Zero default classes, or two of them
A PVC that names no storageClassName leans entirely on the cluster default. If no class carries the default annotation, that claim hangs Pending forever with no obvious error, because nothing has been told to provision it. If two classes are both marked default, which is easy to cause by installing a second CSI driver, admission control picks one for you, and your data can land on storage you never chose, with a reclaim policy you never checked. Run kubectl get sc and confirm exactly one row shows (default) before you trust it with anything that matters.

WaitForFirstConsumer delays binding until a pod is scheduled — critical for zone-aware disks.

Default StorageClass mistakes send data to the slow or expensive tier. Be explicit in production YAML.

ReclaimPolicy on the class influences what happens when claims go away. Set it deliberately.

Try this

List StorageClasses, note the default, create a PVC without an explicit volumeName, and watch dynamic provisioning create a PV.

terminal
$ kubectl apply -f storageclass.yaml
$ kubectl get storageclass
$ kubectl apply -f pvc.yaml
$ kubectl -n payments get pvc payments-data
$ kubectl -n payments describe pvc payments-data
$ kubectl -n payments patch pvc payments-data --type merge \
-p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'
$ kubectl -n payments get pvc payments-data

Takeaway

StorageClasses name provisioners and parameters. Dynamic provisioning turns a PVC into a real volume without pre-creating PVs.

Quick check
01Your cluster spans three availability zones. A team's Deployment is stuck: kubectl get pvc shows the claim Bound, but the pod stays Pending with the event 'volume node affinity conflict.' The StorageClass uses volumeBindingMode: Immediate on EBS. What actually fixes this?
Incorrect — A single ReadWriteOnce (RWO) EBS disk lives in one zone and attaches to one node, so extra replicas can't share it and they keep hitting the same conflict.
Correct — Immediate cut the disk before the scheduler chose a node. WaitForFirstConsumer delays provisioning until scheduling, so the volume is born in the pod's zone. The already-provisioned disk cannot move, so the fix is a WFC class plus a fresh claim.
Incorrect — ReadWriteMany needs a shared filesystem like NFS or EFS. EBS is block storage and does not offer it, so this changes nothing about the zone mismatch.
Incorrect — Expansion grows the existing disk in place. It never relocates it to another zone.
02A PVC is submitted with no storageClassName field, and the cluster has no StorageClass marked with the is-default-class annotation. What happens to the claim?
Incorrect — there is no alphabetical fallback; with no default set, nothing is chosen to provision the claim.
Incorrect — storageClassName is optional; the claim is accepted, it just never gets provisioned.
Incorrect — dynamic clusters rarely keep spare PVs, and without a default class nothing provisions one; it does not grab an arbitrary volume.
Correct — a class-less PVC leans entirely on the cluster default, so with zero defaults no provisioner claims it and it waits forever.
03A database PVC is nearly full. Its StorageClass has allowVolumeExpansion: true and uses a modern CSI (Container Storage Interface) driver. You need more space with no migration and ideally no downtime. What do you do?
Correct — editing the claim's requested size triggers in-place expansion, and current Amazon EBS and Google Persistent Disk drivers resize the block device live.
Incorrect — that migration is unnecessary when the class allows expansion; you can grow the existing claim directly.
Incorrect — shrinking a PVC is not supported; the API server rejects any size decrease outright.
Incorrect — deleting the claim risks destroying the data under reclaimPolicy Delete and is never needed to add space when expansion is allowed.

Related