Storage Classes: Static vs Dynamic Provisioning
Theme: stop hand-making PVs; let a provisioner carve volumes on demand.
Run it in the public monorepo
This course is built around the public HelixWorks Kubernetes Lab monorepo. The excerpt below is runnable source, not pseudocode.
Source: services/control_plane/domain.py
class EventPublisher(Protocol):
def publish(self, event: dict[str, object]) -> None: ...
@dataclass
class Project:
Code to reality
- Declared intent
- Define an extension seam whose implementation can change without changing the product model.
- Interpreter
- Python accepts any injected event publisher that satisfies the small structural protocol.
- Software effect
- The Forge model emits domain events without knowing whether HTTP, a queue, or a test fake transports them.
- Hardware effect
- The selected adapter decides which network connection, broker, and durable storage consume resources.
- Observable evidence
- Contract tests and one persisted evidence event prove both substitutability and the chosen concrete side effect.
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