StorageClasses and dynamic provisioning
How a StorageClass and its CSI driver create PVs on demand, setting the default class, Immediate vs WaitForFirstConsumer binding, allowVolumeExpansion and growing a claim, and which fields you can't change later.
Exam tasks: 4.1 (StorageClasses and dynamic provisioning)
The decision: which class does a claim end up with, when is the real volume created, and will you be able to grow it later?
What happens when a claim names a class
- The provisioner named in the class does the work. For CSI drivers it's the
external-provisionersidecar running next to the driver's controller; it watches claims whose class names its driver. - The new PV copies
reclaimPolicy,mountOptionsand the class name from the StorageClass, and is pre-bound to the claim that triggered it. kubectl get csidriverslists installed CSI drivers.kubectl get scshows each class's provisioner, reclaim policy, binding mode and whether expansion is allowed.
Anatomy of a StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
annotations:
storageclass.kubernetes.io/is-default-class: "true" # optional
provisioner: csi.quarrystore.example # driver name, required
parameters: # opaque to Kubernetes, driver-specific
tier: nvme
replicas: "2"
reclaimPolicy: Retain # Delete if omitted
volumeBindingMode: WaitForFirstConsumer # Immediate if omitted
allowVolumeExpansion: true # false if omitted
mountOptions:
- noatime| Field | Default | Change later? | Notes |
|---|---|---|---|
provisioner | none, required | No | Must match a running driver, or claims stay Pending with no error from the API |
parameters | empty | No | Values are strings; quote numbers. Unknown keys are the driver's problem, not the API's |
reclaimPolicy | Delete | No | Applies only to PVs created after you set it |
volumeBindingMode | Immediate | No | See below |
allowVolumeExpansion | false | Yes | Turning it on lets existing claims of this class grow |
mountOptions | none | Yes | Not validated; a bad option fails at mount time |
Editing the class to fix existing volumes
A class is a template. Changing or recreating it never touches PVs it already made: their reclaim policy and
parameters were copied at creation. To keep data from an existing dynamic volume, patch the PV's
persistentVolumeReclaimPolicy to Retain. To change a fixed class field, delete the class and recreate it
with the same name; existing claims and PVs keep working.
The default class
- Mark a class as default with the annotation
storageclass.kubernetes.io/is-default-class: "true". Any claim created without astorageClassNamegets that class written into it at admission. - If more than one class carries the annotation, new claims get the most recently created default. Keep exactly one; multiple defaults exist only to make migrations painless.
- Claims created while no default existed stay unset, and are updated retroactively once a default appears.
- A claim with
storageClassName: ""opts out: it is never defaulted and only binds classless PVs.
# Move the default from local-path to fast-ssd
k patch sc local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
k patch sc fast-ssd -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
k get sc # "(default)" appears after the nameExam signal
"Make X the default StorageClass" is two edits, not one: set the annotation to "true" on X and remove it or
set it to "false" on the old default. The grader checks that only one class shows (default). The value must be
the quoted string "true".
Binding mode
Immediate | WaitForFirstConsumer | |
|---|---|---|
| Volume created | As soon as the PVC exists | When a Pod using the PVC is scheduled |
| PVC status before a Pod exists | Bound | Pending, event WaitForFirstConsumer. This is normal |
| Topology | Chosen without knowing the Pod, so the disk may land in a zone the Pod can't use | Follows the node the scheduler picked, and respects the Pod's affinity and resource needs |
| Use for | Network storage reachable from every node | Zonal disks, local volumes, anything node- or zone-bound |
- For
localPVs there's no dynamic provisioning: useprovisioner: kubernetes.io/no-provisionerwithWaitForFirstConsumer, so binding waits until the scheduler knows which node's disk the Pod can use. allowedTopologiesrestricts where volumes may be provisioned (for example, two zones only). Usually you leave it out and letWaitForFirstConsumerfollow the Pod.
nodeName with WaitForFirstConsumer
Setting spec.nodeName on a Pod skips the scheduler, so nothing annotates the claim with a selected node and a
WaitForFirstConsumer claim never binds. Steer the Pod with nodeSelector or node affinity instead.
Growing a volume
- Only claims bound to an expandable volume type (CSI drivers that support it) can grow. The driver must implement expansion; the field alone doesn't make it possible.
- Never shrink. A request smaller than the current size is rejected. You can't edit the class of a bound claim either: copy data to a new claim instead.
- Watch
kubectl describe pvcfor conditions such asResizingandFileSystemResizePending. File system growth finishes when a Pod uses the claim, so an unused claim waits. - If an expansion fails because the backend has no room, you may retry with a smaller value than you asked for,
as long as it's still above the current
status.capacity.
Editing the PV instead of the PVC
Raising spec.capacity on the PV and then matching it on the claim makes Kubernetes think the resize already
happened, and nothing expands the real disk. Always change the claim's request.
reclaimPolicy of a StorageClass.volumeBindingMode.allowVolumeExpansion. Expansion is grow-only.Legacy: use CSI driver names and the GA annotation instead
In-tree provisioners such as kubernetes.io/aws-ebs and kubernetes.io/gce-pd are redirected to CSI drivers
(ebs.csi.aws.com, pd.csi.storage.gke.io) through CSI migration; write new classes against the CSI name. The
annotation storageclass.beta.kubernetes.io/is-default-class and the PVC annotation
volume.beta.kubernetes.io/storage-class are replaced by storageclass.kubernetes.io/is-default-class and the
storageClassName field.
Scenarios
With the default Immediate mode, disks were created before the scheduler knew where the Pods had to run, so some
landed in zones without GPU nodes. WaitForFirstConsumer provisions in the zone of the chosen node. The binding
mode can't be edited in place, so the class is recreated. Expansion, extra defaults and RWX don't address topology.
With WaitForFirstConsumer, Pending plus a WaitForFirstConsumer event is the expected state until a Pod that
mounts the claim is scheduled. Restarting the provisioner changes nothing. An empty class disables dynamic
provisioning entirely, and size has no effect on binding timing.
allowVolumeExpansion is one of the few class fields you can change on a live class, and turning it on lets
existing claims grow; with online expansion the Pod keeps running. Classes have no default size. Editing the PV
skips the real resize. Deleting the claim risks the data under a Delete policy and causes downtime.
Drill
kubectl config use-context cka-provision
The cluster runs the rancher.io/local-path provisioner, and class local-path is currently the default.
- Create a StorageClass
scratch-wffcwith that provisioner, reclaim policyRetain, binding modeWaitForFirstConsumer, and volume expansion allowed. - Make
scratch-wffcthe only default StorageClass. - In namespace
kiln, create PVCglaze-datarequesting 256MiReadWriteOncewithout naming a class, and confirm it gotscratch-wffc. - Start a Pod
glaze(imagenginx:1.27) that mounts it at/usr/share/nginx/html, and confirm the claim binds.
# sc.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: scratch-wffc
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: rancher.io/local-path
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: glaze-data
namespace: kiln
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 256Mi
---
apiVersion: v1
kind: Pod
metadata:
name: glaze
namespace: kiln
spec:
containers:
- name: web
image: nginx:1.27
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
volumes:
- name: html
persistentVolumeClaim:
claimName: glaze-datak patch sc local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
k create ns kiln # skip if it exists
k apply -f sc.yaml
k get sc # only scratch-wffc shows (default)
k get pvc -n kiln glaze-data -o jsonpath='{.spec.storageClassName}{"\n"}' # scratch-wffc
k get pvc -n kiln glaze-data # Bound once the Pod is scheduled; Pending before that is expected
k get pv # new PV, RECLAIM POLICY Retain, CLAIM kiln/glaze-dataUnset the old default before applying the new class, so there is never more than one (default). The claim
shows Pending until the Pod exists; that's the WaitForFirstConsumer mode doing its job.
Further reading
PersistentVolumes and claims
Ephemeral vs persistent volume types, how a PVC binds to a PV, access modes including ReadWriteOncePod, Filesystem vs Block volume mode, Retain vs Delete reclaim policies and reusing a Released volume.
Domain 5 · Troubleshooting
30% of the exam, the heaviest domain. Fixing NotReady nodes, broken control plane components, noisy and failing workloads, missing logs and Services that don't answer.