Dynamic Volume Provisioning
Dynamic Volume Provisioning is the Kubernetes feature that materializes a PersistentVolume (PV) on-demand when a user creates a PersistentVolumeClaim (PVC) — no cluster administrator pre-provisioning required. The PVC names a StorageClass; the StorageClass names a provisioner (now invariably a CSI driver); the provisioner’s
external-provisionersidecar watches PVCs that need a PV and calls the driver’sCreateVolumegRPC to actually create storage on the backend (an AWS EBS volume, a Google Persistent Disk, a Ceph RBD image, etc.). The corresponding PV object is then created, bound to the PVC, attached to the node the Pod gets scheduled to, and mounted into the Pod’s filesystem. This dance is the one feature that makes Kubernetes storage self-service — without it, every PVC would require a cluster admin to first create a matching PV by hand. Documented authoritatively at kubernetes.io/docs/concepts/storage/dynamic-provisioning/.
Mental Model
sequenceDiagram autonumber actor User participant API as kube-apiserver participant Sched as kube-scheduler participant Prov as external-provisioner<br/>(sidecar) participant Driver as CSI driver<br/>(Controller service) participant Att as external-attacher participant Kubelet as kubelet (on chosen node) participant Backend as Storage backend<br/>(EBS / PD / Ceph) User->>API: kubectl apply Pod + PVC<br/>(PVC names StorageClass "fast") API->>API: store PVC in etcd (status: Pending) Note over Prov: external-provisioner watches PVCs<br/>with matching StorageClass alt volumeBindingMode == Immediate (default) Prov->>Driver: CreateVolume(name, capacity, params) Driver->>Backend: create EBS volume (in some AZ) Backend-->>Driver: volumeId, accessibleTopology Driver-->>Prov: CreateVolumeResponse Prov->>API: create PV (with topology constraint) API->>API: bind PV<->PVC else volumeBindingMode == WaitForFirstConsumer Note over Prov: external-provisioner DOES NOTHING yet User->>API: (Pod is also queued because PVC unbound) Sched->>API: pick node N for Pod (considers PVC's SC topology) API->>API: update PVC with "selected-node: N" annotation Prov->>Driver: CreateVolume(name, ..., accessibilityRequirements: zone(N)) Driver->>Backend: create volume in N's AZ Driver-->>Prov: response Prov->>API: create PV bound to PVC end Sched->>API: schedule Pod to node N (now PVC is bound) API->>Att: VolumeAttachment{volume, node N} Att->>Driver: ControllerPublishVolume(volumeId, nodeId) Driver->>Backend: attach EBS to EC2 instance N Att->>API: VolumeAttachment.status.attached=true API->>Kubelet: Pod assigned to you (and volume attached) Kubelet->>Driver: NodeStageVolume(volumeId, stagingPath) Driver->>Driver: mkfs (if first time) + mount to staging Kubelet->>Driver: NodePublishVolume(volumeId, podMountPath) Driver->>Driver: bind-mount staging -> Pod's mount namespace Kubelet->>Kubelet: start Pod's containers
What this diagram shows. Dynamic provisioning is an end-to-end orchestration involving five distinct actors (apiserver, scheduler, provisioner sidecar, CSI driver, attacher sidecar, kubelet) interacting via the K8s API. The control plane doesn’t actually mediate the storage operations — it mediates intent: the PVC says “I want 30 GiB of fast storage,” the StorageClass says “fast means EBS gp3,” and the sidecars + driver carry that intent down to the EBS API. The single most important branch is volumeBindingMode: Immediate provisions the volume the moment the PVC is created (risk: the volume lands in a zone the Pod can’t be scheduled to); WaitForFirstConsumer (WFFC) delays provisioning until the scheduler picks a Pod’s node, then provisions in that node’s zone. For zonal block storage (EBS, GCE PD, Azure Disk), WFFC is effectively mandatory.
Mechanical Walk-through
Step 0 — the StorageClass
A cluster administrator (or the cloud provider’s bootstrap) creates one or more StorageClass objects. Each StorageClass picks one CSI driver via the provisioner field, and supplies opaque vendor-specific parameters. The StorageClass also sets cluster-wide policy: reclaim policy, expansion permission, volume binding mode, allowed topologies.
Step 1 — the user creates a PVC
The PVC is a namespaced request for storage: “give me 30 GiB, ReadWriteOnce, of StorageClass fast.” If the user omits storageClassName, the cluster’s default StorageClass (the one annotated storageclass.kubernetes.io/is-default-class: "true") is filled in by the DefaultStorageClass admission controller. If there’s no default and the user didn’t name one, the PVC will sit Pending forever — this is the most common cause of “my Pod won’t start in a fresh cluster.”
Step 2 — the external-provisioner reacts
Each CSI driver’s deployment includes the K8s-maintained external-provisioner sidecar (per kubernetes-csi.github.io/docs/external-provisioner.html). It informer-watches every PVC and PV in the cluster. When it sees a PVC that (a) names a StorageClass whose provisioner matches the driver’s name and (b) is not yet bound to a PV, it kicks off provisioning. The provisioner picks a globally-unique PV name (typically pvc-<UUID>), then calls the CSI driver’s controller service:
CreateVolume(
name = "pvc-72d9a349-aacd-42d2-a240-d775650d2455",
capacity_range = {required_bytes: 30 * 2^30},
volume_capabilities = [{access_mode: SINGLE_NODE_WRITER, mount: {fs_type: "ext4"}}],
parameters = {... StorageClass.parameters verbatim ...},
accessibility_requirements = {... topology hints, if WFFC ...},
)Step 3 — the CSI driver creates the backing volume
The driver translates this into a call to the storage backend’s management API. For EBS, that’s ec2:CreateVolume (with VolumeType: gp3, Iops: 3000, etc., from parameters). For Ceph RBD, rbd create. For NetApp, ontap volume create. The driver returns a CSI Volume object with a volume_id (the backend’s opaque handle) and optionally accessible_topology (e.g. “this volume exists in AZ us-east-1a only”).
Step 4 — the provisioner creates the PV
The external-provisioner takes the driver’s response and writes a PV K8s object:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pvc-72d9a349-aacd-42d2-a240-d775650d2455
spec:
capacity:
storage: 30Gi
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Delete
storageClassName: fast
csi:
driver: ebs.csi.aws.com
volumeHandle: vol-09abcdef # from CreateVolumeResponse
fsType: ext4
nodeAffinity:
required:
nodeSelectorTerms: # zonal pinning from accessible_topology
- matchExpressions:
- key: topology.ebs.csi.aws.com/zone
operator: In
values: [us-east-1a]
claimRef: # back-pointer to the PVC that triggered this
name: my-data
namespace: default
uid: 72d9a349-aacd-42d2-a240-d775650d2455The claimRef and the pv.kubernetes.io/bound-by-controller annotation on the PVC together represent the binding: K8s’s PV controller (in kube-controller-manager) sees the two pointers agree and sets PVC.status.phase = Bound, PV.status.phase = Bound.
Step 5 — scheduling and attach
The Pod referencing the PVC is now scheduleable. The scheduler picks a node respecting the PV’s nodeAffinity (e.g. only nodes in us-east-1a). Once the Pod is bound to a node, the PV controller creates a VolumeAttachment object. The external-attacher sidecar sees the new VolumeAttachment, calls the driver’s ControllerPublishVolume(volume_id, node_id), which translates to (for EBS) ec2:AttachVolume. The driver returns when the volume is attached at the OS level (visible as a block device).
Step 6 — mount
The kubelet on the chosen node sees the Pod has been assigned to it, sees the PVC and its bound PV, and calls the driver’s node service: NodeStageVolume (format and mount to staging path) followed by NodePublishVolume (bind-mount into the Pod’s mount namespace). Containers in the Pod now see the filesystem at the path specified by their volumeMounts.
Step 7 — reclaim
When the PVC is deleted, the chain runs in reverse:
- Kubelet
NodeUnpublishVolume+NodeUnstageVolume(when the Pod is gone). external-attachercallsControllerUnpublishVolume(ec2:DetachVolume).- The PV’s
persistentVolumeReclaimPolicydecides what happens next:Delete(the default for dynamically-provisioned PVs) triggersexternal-provisionerto call the driver’sDeleteVolume(ec2:DeleteVolume— the data is gone forever).Retainleaves the PV inReleasedstate and the backing volume untouched; an admin must manually clean up or re-bind.
The default Delete policy is convenient for stateless tests but dangerous for production data — many platform teams change the default to Retain cluster-wide via mutating webhook to prevent accidental data loss.
WaitForFirstConsumer — Topology-Aware Binding
The volumeBindingMode field on a StorageClass is the most subtle but most consequential setting in this whole machine. Originating from KEP-1432 / the 2018 topology-aware blog post:
-
Immediate(the legacy default): the moment a PVC is created, the external-provisioner callsCreateVolumeimmediately. The driver picks some zone (itsaccessible_topology); the PV is pinned to that zone vianodeAffinity. Then the Pod is scheduled. If the cluster has nodes in zones A, B, C and the volume lands in zone B but only zone A has capacity, the Pod is unschedulable. -
WaitForFirstConsumer(the modern default in most clouds): the external-provisioner does nothing when the PVC is created. The PVC’s status staysPending. The scheduler — using the VolumeBinding scheduler plugin — knows the PVC’s StorageClass and its (multi-zone) accessible topology, considers the Pod’s other constraints (other PVCs, NodeAffinity, etc.), picks a single optimal node, and writesvolume.kubernetes.io/selected-node: <node-name>onto the PVC. The external-provisioner sees the annotation and now callsCreateVolumewithaccessibility_requirementsconstraining the volume to the chosen node’s zone.
WFFC is mandatory for zonal block storage (EBS, GCE PD, Azure Disk) and for any cluster with topology-aware scheduling. The cost is one extra round trip before the Pod can start: the volume creation no longer overlaps with scheduling. The benefit is that Pods are never unschedulable due to mismatched volume zones, and the scheduler can co-locate multiple PVCs on the same zone in one decision.
Configuration / API Surface
A complete StorageClass for AWS EBS gp3 with all the dials set:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-fast
annotations:
storageclass.kubernetes.io/is-default-class: "true" # cluster default
provisioner: ebs.csi.aws.com # CSI driver name
parameters:
type: gp3 # EBS volume type
iops: "3000" # per-volume IOPS
throughput: "125" # MiB/s
encrypted: "true" # at-rest encryption
kmsKeyId: "alias/aws/ebs" # KMS key
csi.storage.k8s.io/fstype: ext4 # filesystem type
reclaimPolicy: Delete # default; PV deletes on PVC delete
allowVolumeExpansion: true # online resize via PVC edit
volumeBindingMode: WaitForFirstConsumer # mandatory for EBS (zonal)
allowedTopologies: # optional: restrict to a subset of AZs
- matchLabelExpressions:
- key: topology.kubernetes.io/zone
values: [us-east-1a, us-east-1b]
mountOptions: # passed verbatim to mount(8)
- discard # TRIM on delete (good for SSDs)A PVC consuming it:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: default
spec:
accessModes: [ReadWriteOnce] # RWO; EBS doesn't support RWX
storageClassName: gp3-fast # name of the StorageClass above
resources:
requests:
storage: 100Gi # what gets passed to CreateVolumeIf storageClassName is omitted entirely, the default StorageClass is filled in. If storageClassName: "" is explicitly set, dynamic provisioning is disabled and the PVC will only bind to a manually-created PV — useful when you want to ensure no auto-provisioning happens for a particular workload.
Failure Modes
PVC stuck in Pending forever. The single most common storage problem. Causes (run kubectl describe pvc and read the events):
- No matching StorageClass.
Failed to provision volume with StorageClass "fast": ... not found.Fix: create the StorageClass. - No default StorageClass and PVC didn’t name one. Event:
no persistent volumes available for this claim and no storage class is set. Fix: set a default StorageClass or annotate the PVC. - Driver not installed. Event:
external-provisionernot running. Fix: install the corresponding CSI driver Helm chart. - Cloud quota. Event:
failed to provision volume ... VolumeLimitExceeded. Fix: raise the cloud-side quota. - WFFC and no Pod consuming the PVC. Event:
waiting for first consumer to be created before binding. This is normal — the PVC is intentionally waiting. Create a Pod that mounts it.
Pod stuck in ContainerCreating after PVC bound. The PVC is bound but the volume can’t attach or mount. Causes:
- Node in wrong zone. The PV is in
us-east-1abut the scheduler put the Pod inus-east-1b(this should not happen with WFFC; it happens withImmediatebinding when the scheduler ignored or couldn’t honor the PV’s nodeAffinity). - IAM / RBAC. The driver lacks permission to call
ec2:AttachVolume(or equivalent). Driver logs showAccessDenied. - Multi-attach. Two Pods on different nodes both reference an RWO PVC. The first one attaches; the second one’s VolumeAttachment fails. Symptom:
Multi-Attach error for volume. Fix: use a StatefulSet (which guarantees one Pod per identity), or change access mode to RWX (and switch to a backend that supports it).
Orphaned PV after PVC deletion. If the reclaim policy is Retain, deleting the PVC leaves the PV in Released state forever. The backing storage continues to bill. Cleanup: delete the PV and the underlying volume manually, or change reclaim policy to Delete before deleting the PVC.
DeleteVolume failures. When a PVC is deleted with reclaimPolicy: Delete, the provisioner calls DeleteVolume. If the call fails (e.g. backend reports the volume is still attached, or the driver has lost cloud credentials), the PV stays in Released with a finalizer. The external-provisioner retries with exponential backoff. If you want to give up and delete the PV anyway, remove the finalizer manually — but the backing storage is now leaked.
Volume expansion stuck. Edit a PVC’s spec.resources.requests.storage upward; the PVC gains a Resizing condition. If the StorageClass doesn’t have allowVolumeExpansion: true, the request is rejected. If the driver doesn’t support online expansion, the volume expansion completes on the backend but the filesystem inside the Pod still shows the old size — kill the Pod to trigger NodeExpandVolume on Pod restart, or use a driver that supports online FS expansion.
Alternatives and When to Choose Them
- Static provisioning — an admin creates the PV manually, the user creates a matching PVC. Useful when (a) the backing storage was created by some other process (e.g. an existing NFS export, a pre-existing EBS volume restored from backup), or (b) you want explicit, audited control over what gets provisioned. The trade-off is operational toil: every new workload requires admin intervention.
- Generic ephemeral volumes (Generic Ephemeral Volume) — embed a PVC template directly in the Pod spec; the ephemeral-volume controller creates a PVC at Pod-create time and deletes it at Pod-delete time. Use when you want CSI features (snapshots, specific StorageClass, encryption) on data that’s tied to the Pod’s lifetime, not stored long-term.
emptyDir— Pod-lifetime scratch; no driver involvement; backed by node-local disk or tmpfs. Use for caches and temporary workspaces; not for anything you’d cry about losing. Compare with Generic Ephemeral Volume for the richer Pod-lifetime story.
For any persistent storage in a managed cluster, dynamic provisioning is the default and the right choice. The combination StorageClass + PVC + CSI is the Kubernetes-native idiom; static provisioning is the escape hatch.
Production Notes
- Default to
Retain, notDelete, for critical data classes. Many production platforms set the default StorageClass’s reclaim policy toRetainand rely on a separate cleanup process (a backup-and-then-delete sweep) to actually release storage. The cost of a stuck-forever PV is operational toil; the cost of an accidental delete is irrecoverable data loss. - Always
WaitForFirstConsumeron zonal storage. EKS, GKE, and AKS managed StorageClasses use WFFC by default. Custom StorageClasses on top of zonal drivers should match. - Storage capacity tracking (
storageCapacity: true) lets the scheduler avoid binding a PVC to a node whose zone has run out of capacity. CSI 1.4+, K8s 1.21+. Newer drivers all enable it; some custom drivers don’t. kubectl get eventsis the right tool for PVC debugging. Almost every dynamic-provisioning problem shows up as a clear-text event on the PVC or Pod.kubectl describe pvcaggregates them;kubectl get events --sort-by=.lastTimestamporders them.- Provisioner versioning. Each driver’s
external-provisionersidecar is pinned; mismatched versions across DaemonSet pods can lead to confusing intermittent failures. Use the driver’s bundled Helm chart rather than hand-rolling manifests.
See Also
- StorageClass — the dynamic-provisioning template
- PersistentVolume — what gets created
- PersistentVolumeClaim — what triggers the creation
- Container Storage Interface — the gRPC contract underneath
- Volume Snapshot — provisioning from a snapshot (
dataSource: VolumeSnapshot) - Volume Cloning — provisioning from another PVC (
dataSource: PersistentVolumeClaim) - Generic Ephemeral Volume — dynamic provisioning tied to Pod lifetime
- Access Modes (PV) — RWO/RWX/ROX implications for provisioning
- Volume Health and Resize — online expansion via PVC edit
- Kubernetes MOC — parent MOC