Storage Classes: Static vs Dynamic Provisioning
Video: Day 52 — StorageClasses & Dynamic Provisioning • Theme: stop hand-making PVs; let a provisioner carve volumes on demand.
Key terms
| Term | Meaning |
|---|---|
| StorageClass | A template that provisions PVs on demand |
| Provisioner | The CSI driver that creates the real volume |
reclaimPolicy | Fate of the PV when the PVC is deleted (Delete/Retain) |
volumeBindingMode | Immediate or WaitForFirstConsumer |
allowVolumeExpansion | Whether PVCs of this class can grow |
| Default class | The class used when a PVC names none |
| Static provisioning | Admin pre-creates PVs by hand |
| Dynamic provisioning | The class auto-creates a PV per PVC |
Problem & solution
Static provisioning means an admin must pre-create a PersistentVolume for every
claim — slow, error-prone, and impossible to predict sizes for. App teams running
a database like PostgreSQL should not wait on humans to get a disk.
Solution: A StorageClass binds a provisioner (a CSI driver such as the AWS EBS CSI driver) to a set of parameters. When a PVC references the class, the provisioner dynamically creates a matching PV — the developer writes only the PVC.
The analogy
Instead of pre-building every storage unit and hoping the sizes fit, the port publishes warehouse tiers: a fast climate-controlled locker for sensitive cargo and a cheap bulk shed for everything else. A tenant just files a claim slip naming the tier and the size they need, and the port builds that exact unit on demand and hands over the key. In Kubernetes each tier is a StorageClass, the claim slip is a PersistentVolumeClaim, and the unit built to match is a dynamically provisioned PersistentVolume.
Graph legend — each Kubernetes node maps a warehouse concept to dynamic EBS provisioning:
| Graph node | Maps to | What it does |
|---|---|---|
| Postgres pod / developer | the workload requesting storage | Files a claim for its data directory disk |
| PersistentVolumeClaim | kind: PersistentVolumeClaim | The request naming a class and size |
| StorageClass (ebs.csi.aws.com, gp3) | kind: StorageClass | The tier + provisioner that builds the disk |
| PersistentVolume (EBS volume) | the auto-created PersistentVolume | The real gp3 EBS volume bound to the claim |
Where this fits in the cluster
The same cluster entities appear in every day's notes; the diagram below shows where this day's topic fits.
Graph legend — each node is a cluster component in dynamic provisioning:
| Graph node | Maps to | What it does |
|---|---|---|
| api-server with StorageClass and EBS CSI provisioner | kube-apiserver + external-provisioner | Triggers volume creation when a PVC appears |
| etcd | cluster datastore | Stores the StorageClass, PVC, and PV objects |
| Postgres pod mounts a PVC bound to an EBS PV | the consuming Pod | Mounts the provisioned EBS volume at its data dir |
| provision PV | CSI CreateVolume call | Carves the real EBS disk and binds it to the PVC |
Static vs dynamic, side by side
Here is the core difference at a glance, namely who creates the PersistentVolume and when.
STATIC DYNAMIC
------ -------
admin writes PV yaml by hand developer writes only the PVC
PVC binds to a matching pre-made PV StorageClass provisioner creates a PV
sizing guessed up front size taken from the PVC request
good for: existing NFS/iSCSI exports good for: cloud disks, self-service
Anatomy of a StorageClass
The class names the provisioner and the knobs the provisioner understands.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com # the CSI driver that makes the volume
parameters: # driver-specific knobs
type: gp3
encrypted: "true"
reclaimPolicy: Delete # Delete (default) or Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
kubectl get storageclass
kubectl describe sc fast-ssd
kubectl get sc # the (default) one is marked
On GKE the equivalent is
provisioner: pd.csi.storage.gke.iowithparameters.type: pd-ssd; the rest of the spec is identical.
reclaimPolicy: what happens to data on PVC delete
The class stamps its reclaimPolicy onto every PV it creates.
Delete -> deleting the PVC deletes the PV and the cloud disk (data gone)
Retain -> PV stays in 'Released'; admin reclaims the data manually
Dynamically provisioned PVs default to Delete. Use Retain for database data you must not lose to an accidental
kubectl delete pvc.
volumeBindingMode: when binding happens
This setting decides whether the PV is created the instant the PVC appears or deferred until a pod actually needs it — which matters for topology.
Immediate -> provision the PV as soon as the PVC is created
(risk: PV lands in a zone the pod can't schedule to)
WaitForFirstConsumer -> wait until a pod using the PVC is scheduled,
then provision in the pod's zone/node
(recommended for zonal/topology-aware storage)
Dynamic provisioning: the developer writes only the PVC
With a class in place, the workflow is just a claim. The provisioner creates the PV; the PVC binds to it; the Postgres pod mounts the claim at its data directory.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
storageClassName: fast-ssd # name the class; "" disables dynamic
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
apiVersion: v1
kind: Pod
metadata:
name: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_PASSWORD
value: S3cretPg!
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- mountPath: /var/lib/postgresql/data
name: data
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
kubectl apply -f pvc.yaml
kubectl get pvc postgres-data # Pending until consumer if WaitForFirstConsumer
kubectl apply -f pod.yaml
kubectl get pvc,pv # PV auto-created; STATUS Bound
The default StorageClass
A PVC with no storageClassName gets the cluster's default class (via the
DefaultStorageClass admission controller). Exactly one class should carry the
default annotation.
# mark a class default (and there must be only one default)
kubectl annotate sc fast-ssd storageclass.kubernetes.io/is-default-class="true"
# remove default from another class
kubectl annotate sc old-class storageclass.kubernetes.io/is-default-class="false" --overwrite
storageClassName: ""(empty string) explicitly opts a PVC out of dynamic provisioning, forcing it to bind a pre-made PV.
Expanding a volume
If the class sets allowVolumeExpansion: true, edit the PVC's request to grow
it; the CSI driver resizes the backing disk online (filesystem grow may need a
pod restart on some drivers).
kubectl patch pvc postgres-data -p '{"spec":{"resources":{"requests":{"storage":"40Gi"}}}}'
kubectl get pvc postgres-data # CAPACITY grows once resize completes
End-to-end: a PVC triggers dynamic provisioning
The full flow from claim to a mounted, bound volume in the right zone.
Graph legend — each node is a real step in dynamic EBS provisioning:
| Graph node | Maps to | What it does |
|---|---|---|
| Developer applies PVC naming StorageClass | a PersistentVolumeClaim | Requests a disk by class and size |
| volumeBindingMode | StorageClass.volumeBindingMode | Decides whether to provision now or wait |
| EBS CSI provisioner creates PV | ebs.csi.aws.com CreateVolume | Carves the gp3 EBS volume and a PV |
| PVC stays Pending until the pod is scheduled | WaitForFirstConsumer | Defers provisioning to the pod's zone |
| PVC binds to the new PV | PV/PVC binding | Marks the claim Bound |
| kubelet attaches and mounts the EBS volume | attach/mount | Makes the disk available at the pod's data dir |
| reclaimPolicy decides Delete or Retain | StorageClass.reclaimPolicy | Sets the PV's fate when the PVC is deleted |
End-to-end example: dynamic provisioning for PostgreSQL, then a live expand
A complete walkthrough: create a WaitForFirstConsumer gp3 StorageClass, claim a
volume that stays Pending until a Postgres Pod needs it, mount it as the data
directory, write a row, then grow the PVC online.
Step 1 — create the StorageClass (defer binding, allow expansion).
# sc.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: topology-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
kubectl apply -f sc.yaml
# storageclass.storage.k8s.io/topology-ssd created
kubectl get sc topology-ssd
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION
# topology-ssd ebs.csi.aws.com Delete WaitForFirstConsumer true
Step 2 — create the PVC; it stays Pending (no consumer yet).
# pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
storageClassName: topology-ssd
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
kubectl apply -f pvc.yaml
kubectl get pvc postgres-data
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
# postgres-data Pending topology-ssd 5s
kubectl describe pvc postgres-data | grep -A2 Events
# Normal WaitForFirstConsumer waiting for first consumer to be created before binding
Step 3 — schedule a Postgres Pod that mounts the claim; provisioning fires.
# pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_PASSWORD
value: S3cretPg!
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- mountPath: /var/lib/postgresql/data
name: data
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
kubectl apply -f pod.yaml
kubectl wait --for=condition=Ready pod/postgres --timeout=120s
kubectl get pvc,pv
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# persistentvolumeclaim/postgres-data Bound pvc-8a3f.. 10Gi RWO topology-ssd
# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
# persistentvolume/pvc-8a3f.. 10Gi RWO Delete Bound default/postgres-data
Step 4 — verify the mount and that the database data persists.
kubectl exec postgres -- psql -U postgres -c \
"CREATE TABLE marker(note text); INSERT INTO marker VALUES ('hello');"
kubectl exec postgres -- psql -U postgres -tc "SELECT note FROM marker;"
# hello
kubectl exec postgres -- df -h /var/lib/postgresql/data
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme1n1 9.8G 54M 9.7G 1% /var/lib/postgresql/data
Step 5 — expand the PVC online (allowVolumeExpansion: true).
kubectl patch pvc postgres-data --type merge \
-p '{"spec":{"resources":{"requests":{"storage":"30Gi"}}}}'
# persistentvolumeclaim/postgres-data patched
# the EBS CSI driver resizes the backing disk, then the filesystem grows
kubectl get pvc postgres-data -w
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# postgres-data Bound pvc-8a3f.. 10Gi RWO topology-ssd
# postgres-data Bound pvc-8a3f.. 30Gi RWO topology-ssd
kubectl exec postgres -- df -h /var/lib/postgresql/data
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme1n1 29G 54M 29G 1% /var/lib/postgresql/data
Graph legend — each participant is a real actor in PostgreSQL dynamic provisioning:
| Participant | Maps to | What it does |
|---|---|---|
| Developer | client | Applies the PVC, Pod, and the expand patch |
| api-server | kube-apiserver | Coordinates binding and CSI calls |
| Scheduler | kube-scheduler | Chooses the node/zone, unblocking WaitForFirstConsumer |
| EBS CSI provisioner | ebs.csi.aws.com | Creates, attaches, and expands the gp3 volume |
| kubelet | node agent | Attaches and mounts the volume at PGDATA |
Key takeaways
- A StorageClass = provisioner + parameters; it auto-creates PVs for PVCs.
- Static = admin hand-makes PVs; dynamic = the class provisions on demand.
reclaimPolicyDelete(default) destroys data on PVC delete; useRetainto keep it.volumeBindingMode: WaitForFirstConsumerprovisions in the pod's zone — use it for zonal disks.- One default class serves PVCs with no class;
""opts a PVC out of dynamic provisioning.
Checklist
- [ ] Listed StorageClasses and identified the default
- [ ] Created a PVC with no PV and watched a gp3 PV auto-provision
- [ ] Explained
DeletevsRetainreclaim policy - [ ] Compared
ImmediatevsWaitForFirstConsumerbinding - [ ] Expanded a PostgreSQL PVC on a class with
allowVolumeExpansion