52

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

TermMeaning
StorageClassA template that provisions PVs on demand
ProvisionerThe CSI driver that creates the real volume
reclaimPolicyFate of the PV when the PVC is deleted (Delete/Retain)
volumeBindingModeImmediate or WaitForFirstConsumer
allowVolumeExpansionWhether PVCs of this class can grow
Default classThe class used when a PVC names none
Static provisioningAdmin pre-creates PVs by hand
Dynamic provisioningThe 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 nodeMaps toWhat it does
Postgres pod / developerthe workload requesting storageFiles a claim for its data directory disk
PersistentVolumeClaimkind: PersistentVolumeClaimThe request naming a class and size
StorageClass (ebs.csi.aws.com, gp3)kind: StorageClassThe tier + provisioner that builds the disk
PersistentVolume (EBS volume)the auto-created PersistentVolumeThe 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 nodeMaps toWhat it does
api-server with StorageClass and EBS CSI provisionerkube-apiserver + external-provisionerTriggers volume creation when a PVC appears
etcdcluster datastoreStores the StorageClass, PVC, and PV objects
Postgres pod mounts a PVC bound to an EBS PVthe consuming PodMounts the provisioned EBS volume at its data dir
provision PVCSI CreateVolume callCarves 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.io with parameters.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 nodeMaps toWhat it does
Developer applies PVC naming StorageClassa PersistentVolumeClaimRequests a disk by class and size
volumeBindingModeStorageClass.volumeBindingModeDecides whether to provision now or wait
EBS CSI provisioner creates PVebs.csi.aws.com CreateVolumeCarves the gp3 EBS volume and a PV
PVC stays Pending until the pod is scheduledWaitForFirstConsumerDefers provisioning to the pod's zone
PVC binds to the new PVPV/PVC bindingMarks the claim Bound
kubelet attaches and mounts the EBS volumeattach/mountMakes the disk available at the pod's data dir
reclaimPolicy decides Delete or RetainStorageClass.reclaimPolicySets 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:

ParticipantMaps toWhat it does
DeveloperclientApplies the PVC, Pod, and the expand patch
api-serverkube-apiserverCoordinates binding and CSI calls
Schedulerkube-schedulerChooses the node/zone, unblocking WaitForFirstConsumer
EBS CSI provisionerebs.csi.aws.comCreates, attaches, and expands the gp3 volume
kubeletnode agentAttaches 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.
  • reclaimPolicy Delete (default) destroys data on PVC delete; use Retain to keep it.
  • volumeBindingMode: WaitForFirstConsumer provisions 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 Delete vs Retain reclaim policy
  • [ ] Compared Immediate vs WaitForFirstConsumer binding
  • [ ] Expanded a PostgreSQL PVC on a class with allowVolumeExpansion