Skip to content

Kubernetes StorageClasses, PersistentVolumes, and PVCs

Step 17 of 24

Kubernetes StorageClasses, PersistentVolumes, and PVCs for CKA

Introduction

Kubernetes StorageClasses, PersistentVolumes (PVs) and PersistentVolumeClaims (PVCs) connect application storage requests to real storage. They form the Storage domain (10% of the CKA), covering dynamic provisioning, volume types, access modes, reclaim policies and claim management. Original exercises below bind a static volume, provision a zonal disk and recover retained storage.

The official exam page currently lists Kubernetes v1.37. Confirm your sitting's version and the lab's storage driver capabilities. Examples use Linux and namespaces selected explicitly with your kubectl context; a Pod and its PVC must share a namespace. Fixed images are lab examples rather than production version recommendations.

After this lesson you will be able to choose a volume type for a requirement, create PersistentVolumes, PersistentVolumeClaims and StorageClasses, explain access modes and reclaim policies, enable dynamic provisioning, and diagnose a claim that will not bind.

Learning objectives

  • Distinguish ephemeral volumes (emptyDir, configMap, secret, projected) from persistent storage.
  • Create a PersistentVolume and a PersistentVolumeClaim and predict whether they bind.
  • Explain ReadWriteOnce, ReadOnlyMany, ReadWriteMany and ReadWriteOncePod.
  • Explain the reclaim policies Retain, Delete and the legacy Recycle.
  • Create a StorageClass, set a default class, and explain volumeBindingMode: WaitForFirstConsumer.
  • Expand a claim and take a snapshot conceptually, and troubleshoot a Pending PVC.

Volumes in Pods

A container's writable layer is ephemeral and a replacement container starts from its image; do not rely on that layer to retain application data. A volume is declared in spec.volumes of the Pod and mounted into containers through volumeMounts. Common types:

Type Lifetime Typical use
emptyDir Pod lifetime (survives container restarts, lost when the Pod is deleted) Scratch space, cache, sharing files between containers of a Pod
hostPath Node filesystem path Node agents, lab setups; avoid for applications (ties the Pod to a node and is a security risk)
configMap, secret Mirrors the object Configuration and credentials as files
projected Combines several sources in one directory Service account tokens, configuration bundles
persistentVolumeClaim Independent of the Pod Databases and any data that must survive
nfs, CSI-backed volumes External Shared or cloud-backed storage

A small example with a memory-backed emptyDir shared by two containers:

YAML
apiVersion: v1
kind: Pod
metadata:
  name: shared
spec:
  containers:
  - name: writer
    image: busybox:1.36
    command: ["sh", "-c", "while true; do date >> /data/log; sleep 5; done"]
    volumeMounts:
    - {name: scratch, mountPath: /data}
  - name: reader
    image: busybox:1.36
    command: ["sh", "-c", "tail -F /data/log"]
    volumeMounts:
    - {name: scratch, mountPath: /data, readOnly: true}
  volumes:
  - name: scratch
    emptyDir:
      medium: Memory
      sizeLimit: 64Mi

Memory-backed emptyDir uses tmpfs: bytes written count against the writing container's memory budget. Its sizeLimit caps the volume but does not reserve RAM or replace appropriate container memory requests/limits.

Entries for in-tree cloud volume plugins (such as awsElasticBlockStore) have been removed or migrated to CSI drivers. In current versions you consume cloud disks through a CSI driver and a StorageClass, rather than relying on removed in-tree sources. CSI also has inline ephemeral-volume support for drivers that implement it; that is distinct from persistent PVC-backed disks.

PersistentVolumes and claims

Kubernetes separates two roles:

  • A PersistentVolume (PV) is a cluster-wide resource that represents a piece of storage. It is created by an administrator or dynamically by a provisioner.
  • A PersistentVolumeClaim (PVC) is a namespaced request for storage by a user: a size, an access mode, optionally a class. For this PV/PVC workflow, Pods name a PVC in their own namespace rather than a PV name. Direct inline volume sources such as nfs are a separate mechanism.

The control plane binds a claim to a PV that satisfies it. Binding is one-to-one: a PV bound to a claim cannot serve another, even if capacity remains.

Matching rules

A PVC binds to a PV when all of these hold:

  1. The PV capacity is greater than or equal to the request.
  2. The PV offers every requested access mode.
  3. The storageClassName is identical. An empty string "" means "no class" (static binding only). An omitted field means "use the default class", if one exists.
  4. The PV satisfies volumeMode (Filesystem or Block), and any label selector or volumeName in the claim.

A PV that is Available has not been claimed; Bound has; Released was bound to a claim that was deleted but has not been reclaimed; Failed means automatic reclamation failed.

Worked example: static provisioning

Scenario. The administrator prepared the directory /mnt/data on node worker-1. Create a 2Gi PV, a claim for 1Gi, and a Pod that mounts it at /usr/share/nginx/html.

Steps.

  1. Use hostPath only for this controlled lab. In a multi-node lab, constrain this PV to worker-1 and verify that node's kubernetes.io/hostname label equals worker-1. For production node-local storage, consider a local PV with node affinity or an appropriate CSI solution. Assume no other claim competes for this PV and /mnt/data contains a readable index.html. The declared capacity is used for binding; a hostPath directory does not itself enforce a 2Gi filesystem quota.
YAML
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-data
spec:
  capacity:
    storage: 2Gi
  accessModes: ["ReadWriteOnce"]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: manual
  hostPath:
    path: /mnt/data
    type: Directory
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values: [worker-1]
  1. Create the claim. The class must match, and the request may be smaller than the PV:
YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-data
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi
  1. Check: kubectl get pv,pvc shows both Bound, and the claim reports a capacity of 2Gi (the whole PV is reserved).
  2. Mount it in the Pod:
YAML
apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  nodeSelector:
    kubernetes.io/hostname: worker-1
  containers:
  - name: nginx
    image: nginx:1.27
    volumeMounts:
    - {name: html, mountPath: /usr/share/nginx/html}
  volumes:
  - name: html
    persistentVolumeClaim:
      claimName: pvc-data

Why not the alternatives? The Pod's persistentVolumeClaim source requires claimName, not a PV name. If the default class differs from manual, omitting storageClassName will not target pv-data; another suitable PV or dynamic provisioning may satisfy the claim. A request larger than 2Gi would never bind.

Access modes

Access modes advertise supported mounting capabilities and participate in claim matching. They are not filesystem permissions or a general read-only security control; enforce read-only access with mount and backend permissions where needed. ReadWriteOncePod additionally constrains use to a single Pod and requires a compatible CSI driver/sidecars.

Mode Short Meaning
ReadWriteOnce RWO Read-write by a single node (several Pods on that node can share it)
ReadOnlyMany ROX Read-only by many nodes
ReadWriteMany RWX Read-write by many nodes
ReadWriteOncePod RWOP Read-write by a single Pod in the whole cluster

Block-based cloud disks are typically RWO. For shared filesystem workloads, RWX commonly uses NFS, a managed file service or another multi-node-capable CSI backend. Asking for RWX from a driver that cannot provide it leaves the claim Pending.

Reclaim policies

When a bound claim is deleted, the PV's persistentVolumeReclaimPolicy decides what happens:

Policy Result Typical use
Retain The PV becomes Released and data stays; an administrator controls sanitisation or intentional data-preserving rebinding Valuable data
Delete The PV and the backing storage asset are removed Dynamic provisioning default for most classes
Recycle Deprecated; basic scrub (rm -rf) Do not use

Change a policy with kubectl patch pv pv-data -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'. Dynamically provisioned volumes inherit the reclaimPolicy of their StorageClass (default Delete).

To reuse a Released PV, first identify its former owner, preserve any required data and ensure old workloads cannot still write to it. Prevent competing claims, then remove or replace the stale claimRef as part of an administrator-controlled rebinding procedure. A reservation naming the replacement claim can avoid exposing the PV to unrelated claimants. Removing claimRef alone can expose retained data to another matching claim. Retain is a recovery aid, not a backup.

StorageClasses and dynamic provisioning

Creating PVs by hand does not scale. A StorageClass describes a "kind" of storage and the provisioner (often a CSI driver or an external provisioning controller) that creates volumes on demand. When provisioning is needed, a running compatible provisioner and backend create the PV according to the class and binding mode; a StorageClass object alone does not install a driver. A suitable pre-existing PV can also satisfy a claim.

YAML
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
mountOptions:
  - discard

Key fields:

  • provisioner: the CSI driver name (here the AWS EBS driver is only an example; each driver documents its own parameters).
  • reclaimPolicy: Delete (default) or Retain for volumes created by this class.
  • allowVolumeExpansion: lets you increase a claim later by editing spec.resources.requests.storage. Shrinking is never supported.
  • volumeBindingMode: Immediate starts binding/provisioning without the consumer's scheduling requirements; WaitForFirstConsumer coordinates binding/provisioning with the scheduler after a consuming Pod exists, so eligible topology and placement constraints are considered. Do not set nodeName to bypass the scheduler in this workflow; use nodeSelector or affinity.
  • The annotation storageclass.kubernetes.io/is-default-class: "true" marks the default class, used by claims that omit storageClassName. Prefer one default; with several, an omitted class uses the most recently created default StorageClass. Claims with storageClassName explicitly set to an empty string opt out. Unset class fields can be assigned retroactively when a default becomes available.

kubectl get storageclass shows (default) next to the default one. To change defaults: kubectl patch storageclass old -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}', then set "true" on the new one.

Worked example: a database with dynamic storage

Scenario. A PostgreSQL Pod needs a 5Gi volume that survives Pod deletion and lives in the same zone as the Pod. The cluster has a class fast with WaitForFirstConsumer.

Reasoning.

  1. Create only the claim, naming the class: no PV needed.
  2. Before a consuming Pod exists, the claim normally stays Pending. This is normal with WaitForFirstConsumer and is not an error. kubectl describe pvc says waiting for first consumer to be created before binding.
  3. Create the Pod. The scheduler and provisioner coordinate eligible placement, the driver creates a suitable disk and the claim becomes Bound. Check Pod events, node affinity and available backend capacity if it remains Pending.
YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pgdata
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: fast
  resources:
    requests:
      storage: 5Gi

Why not Immediate? With zonal disks the volume could be created in a zone where the Pod cannot be scheduled (for example because of node affinity or effective resource requests), leaving the Pod Pending until the topology or workload constraints are corrected. Review Pod scheduling, affinity, and resource requests when diagnosing this interaction.

Expanding a claim

If the class has allowVolumeExpansion: true and the driver/backend support expansion, request growth through the PVC rather than editing PV capacity:

Bash
kubectl patch pvc pgdata -p '{"spec":{"resources":{"requests":{"storage":"10Gi"}}}}'
kubectl get pvc pgdata -w

Expansion support, filesystem type and online versus remount requirements are driver-dependent. FileSystemResizePending means filesystem work remains; inspect conditions and driver guidance, and mount/restart only when required. Do not delete the claim to force normal expansion. A failed oversized expansion request can be retried with a lower request still above the current status.capacity; this does not shrink the actual volume.

Comparison: static versus dynamic provisioning

Aspect Static Dynamic
Who creates the PV Administrator Provisioner, on demand
Claim storageClassName Must match the PV (often "" or a label class) Names a StorageClass
Default reclaim policy Whatever the PV says Class reclaimPolicy (Delete by default)
Best for Pre-existing disks, NFS shares, labs Everyday workloads

Using volumes with StatefulSets

A StatefulSet can declare volumeClaimTemplates; each replica gets its own claim named <template>-<statefulset>-<ordinal>. With the default Retain retention settings, scaling down does not delete claims. Explicit whenScaled: Delete or whenDeleted: Delete policies change the corresponding behaviour. PVC deletion is distinct from backing-volume deletion: the PV reclaim policy controls the latter. See Deployments, StatefulSets, and application configuration when choosing a controller and storage identity.

CSI drivers, snapshots, and raw block volumes

Storage drivers plug into Kubernetes through the Container Storage Interface (CSI). A driver usually runs as a controller Deployment (creating, attaching and snapshotting volumes) plus a node DaemonSet (mounting them). When provisioning fails, these Pods in kube-system or the driver namespace are the first place to look, and kubectl get csidrivers and kubectl get csinodes show what is registered.

Two optional features appear in practice:

  • Volume snapshots. With snapshot CRDs, snapshot controller, compatible CSI sidecars and driver snapshot support installed, a VolumeSnapshotClass and VolumeSnapshot capture a claim at a point in time, and a new PVC with a dataSource of kind VolumeSnapshot restores it. Example: before a risky schema migration, Contoso snapshots pgdata, and if the migration fails, creates pgdata-restore from that snapshot and points the Deployment at it. The API is provided by CRDs; kubectl api-resources | grep snapshot checks API availability, not driver functionality. Verify readyToUse and restoration, and coordinate database quiescing or native backups for application consistency. An etcd snapshot preserves Kubernetes objects, not PV payloads; etcd recovery and cluster backups covers that separate layer.
  • Raw block volumes. Setting volumeMode: Block on both PV and claim exposes the device without a file system, referenced in the container by volumeDevices with a devicePath instead of volumeMounts. Some databases use this for performance. A Filesystem claim never binds to a Block PV.

A method for storage problems

  1. kubectl get pvc: is it Bound or Pending?
  2. kubectl describe pvc <name>: the events explain the reason (no matching PV, no default class, provisioner failure, waiting for consumer).
  3. Compare the claim with kubectl get pv: size, access mode, class, volumeMode.
  4. For dynamic claims, check the provisioner Pods: kubectl get pods -n kube-system | grep -i csi.
  5. If the Pod itself is ContainerCreating, kubectl describe pod shows mount errors (FailedAttachVolume, FailedMount): a RWO volume still attached to another node, a wrong claimName, or a missing Secret/ConfigMap volume.

Common pitfalls and exam tips

  • Storage class mismatch. A claim with storageClassName: standard never binds to a PV of class manual. "" is not the same as omitted.
  • Capacity. A PV smaller than the request is ignored; a larger one is accepted and fully reserved.
  • Pending is not always a bug. With WaitForFirstConsumer the claim waits for a Pod.
  • RWO is per node, not per Pod. Pods on the same node may share it; single-attach block drivers can reject a second-node attachment. Use RWOP with a compatible CSI driver when one-Pod access is required; access modes do not replace application locking.
  • hostPath data is per node. If the Pod moves to another node, it sees different (or no) data.
  • Released PVs do not rebind automatically; clear claimRef after dealing with the data.
  • Deleting a PVC that a Pod uses leaves the claim in Terminating until the Pod is gone (storage object in use protection).
  • No shrinking. Expansion only grows.
  • Exam tip: copy the PV and PVC YAML from the docs, change the values, and check kubectl get pv,pvc immediately. Use kubectl explain pv.spec for fields. If asked to set a default class, change annotations with kubectl patch and verify with kubectl get sc.

Mini case study: Contoso's reporting database

Contoso needs the following in namespace reports:

  1. A StorageClass retained using the cluster's CSI provisioner, reclaim policy Retain, expansion allowed, binding WaitForFirstConsumer.
  2. A 20Gi RWO claim reportsdb using it, mounted at /var/lib/postgresql/data by a Deployment with one replica.
  3. A week later the claim is accidentally marked for deletion. The team needs to preserve its data, restore use of the claim, then grow it to 40Gi.

Solution.

  1. Create the StorageClass with the fields above (copy a class from the docs, then change reclaimPolicy: Retain). Verify with kubectl get sc retained -o yaml.
  2. Create the PVC naming retained; it stays Pending until the Deployment is created, then Bound. Because Postgres expects an empty directory, mount at /var/lib/postgresql/data and, if the volume has a lost+found directory, set the PGDATA environment variable to a subdirectory.
  3. If Pods still reference the claim, PVC protection can keep it Terminating; it has not yet released the PV. Pause the workload controller and stop consumers through the approved procedure. After deletion completes, inspect Released state, the actual disk and backups. With Retain, data should remain unless an external process deleted it. Rebind under administrator control: prevent competing claims, replace the stale claimRef with a reservation naming the intended namespace/claim (without the old UID), and create a compatible PVC with volumeName naming that PV. Preserve class, access/volume modes and requested capacity. Verify data before resuming the workload. With Delete, provisioner cleanup can remove the backing disk, so recovery would depend on an independent backup or snapshot.
  4. After recovery, confirm driver/backend expansion support, patch the PVC request to 40Gi and watch events and conditions. Confirm the size inside the application filesystem as well as PVC status.

Commentary. The decisive choice was the reclaim policy; it turned a destructive mistake into a recoverable one. The same task in an exam checks that you know Released is not Available and that claimRef must be cleared.

Key terms

  • Volume: directory accessible to containers in a Pod.
  • PersistentVolume (PV): cluster-level storage resource.
  • PersistentVolumeClaim (PVC): namespaced request that binds to a PV.
  • StorageClass: template and provisioner for dynamic volumes.
  • Access mode: RWO, ROX, RWX or RWOP.
  • Reclaim policy: Retain, Delete (and deprecated Recycle).
  • WaitForFirstConsumer: coordinate volume binding/provisioning with a consuming Pod's scheduler constraints.
  • CSI: Container Storage Interface, the plugin API for storage drivers.

Summary checklist

  • emptyDir lives with the Pod; PVCs outlive it.
  • A claim binds to a PV with at least the requested size, matching modes and the same class.
  • storageClassName: "" means no class; omitted means the default class.
  • RWO is per node; RWOP needs compatible CSI support; RWX requires a backend supporting multi-node writes.
  • Retain keeps data and leaves the PV Released; Delete removes it.
  • WaitForFirstConsumer leaves claims Pending until a Pod uses them.
  • Expansion needs class permission plus driver/backend support; reducing a failed request above current capacity is not volume shrink.
  • kubectl describe pvc explains why a claim is Pending.

Official sources