Skip to content
Documentation Background

Persistent Volumes

Container filesystems are ephemeral by design — when a container restarts or a pod is rescheduled, everything written to the container’s local disk is gone. For stateful workloads (databases, message queues, shared uploads), you need storage that outlives the pod. Kubernetes solves this through the persistent volume subsystem: a layered abstraction that lets applications claim durable storage from any cloud or on-premises backend without knowing the underlying hardware.


The persistent volume subsystem separates the concerns of storage administration from storage consumption using three distinct layers:

┌──────────────────────┐ ┌────────────────────┐ ┌──────────────────────────────────┐
│ Storage Providers │ │ Plugin Layer │ │ Kubernetes PV Subsystem │
│ │ │ │ │ │
│ AWS EBS │────▶│ CSI Drivers │────▶│ StorageClass → PV → PVC │
│ GCP Persistent Disk │ │ (in kube-system) │ │ │
│ Linode Block Store │ │ │ │ Pods reference PVCs, │
│ NetApp / EMC │ │ ebs.csi.aws.com │ │ not PVs directly │
│ NFS / File stores │ │ pd.csi.storage.gke │ │ │
└──────────────────────┘ └────────────────────┘ └──────────────────────────────────┘
LayerRole
Storage ProvidersPhysical or cloud storage backends — own the hardware, provide replication, snapshots, encryption
CSI Plugin LayerTranslation bridge between Kubernetes API and vendor-specific storage APIs
PV SubsystemKubernetes-native API objects (StorageClass, PersistentVolume, PersistentVolumeClaim) that pods use to request and mount storage

Two key architectural rules:

  1. 1:1 mapping — A single physical volume maps to exactly one PersistentVolume. You cannot split a 50 GiB disk into two 25 GiB PVs.
  2. Locality — Storage is geographically bound. Pods must run in the same zone/region as their volume. Cross-zone mounts fail.

The Container Storage Interface is an open-source, vendor-neutral spec that defines how container orchestrators talk to storage backends. It replaced the old “in-tree” plugin model where storage code was compiled directly into Kubernetes.

AttributeLegacy in-tree pluginsModern CSI drivers
Code locationCompiled into Kubernetes coreMaintained outside the core repository
LicensingMust be open sourceVendors can keep code proprietary
Release cycleUpdates tied to Kubernetes releasesVendors ship updates independently at any time
MaintenanceHigh burden on core maintainersVendors own their own codebases

CSI drivers run as regular pods inside the cluster. A typical driver is split into two co-operating components:

┌──────────────────────────────────────┐
│ Kubernetes API Server │
└──────────────────────────────────────┘
╱ ╲
╱ manages PVs ╲ manages mounts
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Controller Component │ │ Node-Level Agent │
│ - Dynamic provisioning│ │ - Attaches/detaches │
│ - PV deletion │ │ - Mounts/unmounts │
│ - Deployed centrally │ │ - Runs per worker node│
└───────────────────────┘ └───────────────────────┘
  • Controller — watches the API server for new PVCs and calls the cloud API to provision/delete physical disks
  • Node agent — runs on every worker node (e.g., as pdcsi-node-xyz in kube-system) to attach storage hardware to the node and mount it into containers

Deployment methods:

  • Distributed via Helm charts or YAML manifests
  • Managed cloud platforms (GKE, EKS, LKE) pre-install their native CSI drivers automatically
  • On-premises or third-party backends (NetApp, EMC) require manual driver installation

Driver examples:

CloudCSI driver name
AWS EBSebs.csi.aws.com
GCP Persistent Diskpd.csi.storage.gke.io
GCP Filestore (NFS)filestore.csi.storage.gke.io
Linode Block Storagelinodebs.csi.linode.com

Non-CSI in-tree backends still in use:

TypeUse case
fc (Fibre Channel)Enterprise SAN storage attached directly to pods — requires specialized FC hardware on nodes
iscsiSCSI storage over IP networks — provides raw block-level access

CSI drivers register themselves in the cluster using the CSIDriver cluster-scoped resource:

Terminal window
kubectl get csidrivers # list all registered drivers

Key spec fields inside a CSIDriver:

FieldPurpose
attachRequiredWhether the volume must be attached to a node before being mounted
fsGroupPolicyHow the driver handles filesystem group ownership changes on mount
volumeLifecycleModesWhether the driver supports ephemeral inline volume lifecycles

Admins rarely write CSIDriver manifests manually — they ship as part of the driver vendor’s Helm chart or YAML bundle.


The persistent volume subsystem uses three resources that work together. Admins manage StorageClass and PersistentVolume; developers work with PersistentVolumeClaim.

Developer: Pod ─────────────▶ PVC (namespace-scoped)
│ requests StorageClass
Admin: StorageClass ─────▶ CSI driver ─────▶ Backend disk
PV (cluster-wide) ◀── bound to PVC

A StorageClass is a blueprint for a tier of storage. It defines which CSI driver to call, what parameters to pass it, and how volumes should be provisioned and reclaimed.

  • API: storage.k8s.io/v1 | Kind: StorageClass | Short: sc
  • Scope: Cluster-wide (not namespaced)
  • Immutable — once deployed, a StorageClass cannot be modified. Delete and recreate under a new name if a change is needed.
FieldPurpose
provisionerWhich CSI driver to invoke for this class
parametersDriver-specific key-value config (disk type, IOPS, encryption…) — opaque to Kubernetes
reclaimPolicyWhat happens to the PV and physical disk when the PVC is deleted
volumeBindingModeWhen to provision the physical disk — immediately or when a pod needs it
allowVolumeExpansionWhether pods can resize the volume after it’s provisioned
allowedTopologiesRestricts volume provisioning to specific zones / regions
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com # CSI driver that provisions the disk
reclaimPolicy: Retain # Keep disk alive when PVC is deleted
volumeBindingMode: WaitForFirstConsumer # Provision only after a pod is scheduled
allowVolumeExpansion: true # Allow resizing existing volumes
parameters: # AWS-specific — docs: driver README
encrypted: "true"
type: io1 # Provisioned IOPS SSD
iopsPerGB: "10"
allowedTopologies: # Restrict provisioning to one zone
- matchLabelExpressions:
- key: topology.ebs.csi.aws.com/zone
values:
- eu-west-1a
PolicyWhat happens when PVC is deletedDefault for
DeletePV object and physical backend disk are deleted automatically — use with cautionDynamic provisioning
RetainPV and physical disk are preserved — admin must manually clean up and delete the PVStatically provisioned PVs
RecycleBasic data scrub (rm -rf) then PV becomes Availabledeprecated, avoid
ModeWhen disk is provisioned
ImmediateAs soon as the PVC is created, regardless of whether a pod is ready
WaitForFirstConsumerDelayed until a pod referencing the PVC is scheduled to a node — ensures the disk is created in the pod’s zone

A PersistentVolume is the Kubernetes representation of a physical storage volume on a cloud or on-premises backend.

  • API: v1 | Kind: PersistentVolume | Short: pv
  • Scope: Cluster-wide (not namespaced)
  • With dynamic provisioning: the StorageClass controller creates PVs automatically
  • With static provisioning: admins pre-create PVs by hand to represent existing volumes

A PV passes through distinct phases during its lifetime:

PhaseMeaning
AvailableFreshly created; not yet bound to any PVC
BoundA matching PVC has claimed it — dedicated exclusively to that claim
ReleasedThe PVC was deleted, but the PV still holds data from it — cannot be re-bound yet
FailedAutomatic reclamation failed
[Created/Provisioned] ──▶ Available ──▶ Bound ──▶ Released ──▶ [manually cleaned] ──▶ Available
└──▶ Failed (if reclamation errors)

Kubernetes blocks deletion of storage resources that are still actively in use:

What you try to deleteWhen it’s blockedWhen deletion completes
PV (while bound to PVC)Immediately blocked; PV status → TerminatingWhen the PVC is deleted
PVC (while used by a running pod)Immediately blocked; PVC status → TerminatingWhen the pod is terminated

The application pod continues running unaffected in both cases — Kubernetes will never evict a pod because an administrator requested its storage back.


A PersistentVolumeClaim is a developer’s request for storage. Pods never reference PVs directly — they always reference a PVC by name.

  • API: v1 | Kind: PersistentVolumeClaim | Short: pvc
  • Scope: Namespaced
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-pvc
spec:
accessModes:
- ReadWriteOnce # how the volume can be mounted
volumeMode: Filesystem # Filesystem (default) or Block
storageClassName: fast-ssd # which StorageClass to use for provisioning
resources:
requests:
storage: 10Gi # minimum capacity required

When a PVC is created, a control loop automatically searches for an existing, unbound PV to satisfy the claim based on four criteria:

  1. Storage Capacity: The PV’s capacity must be greater than or equal to the PVC’s requested size. A larger PV can bind to a smaller PVC, but the remaining capacity is wasted.
  2. Access Modes: The PV must support all access modes requested by the claim.
  3. StorageClass Match:
    • Static: Set storageClassName: "" in the PVC to force it to bypass dynamic provisioners and only look for statically created PVs without a class.
    • Dynamic: If a class is specified, the PVC only matches PVs of that class, or triggers the class’s provisioner if no match exists.
  4. Volume Mode: The requested volume mode (Filesystem vs Block) must match.

The relationship between a bound PVC and its PV is strictly one-to-one. Once bound, the PV is locked to that specific claim. Even if a 10Gi PV binds to a 2Gi PVC, the remaining 8Gi cannot be partitioned out to other claims.

To bypass the automatic matching algorithm, you can force a PVC to connect to a specific PV by setting spec.volumeName:

spec:
volumeName: specific-db-pv # Bypasses automatic matching
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi

For this to succeed, the targeted PV must exist, be Available, and meet the capacity/access mode requirements. If not, the PVC remains stuck in Pending.


Access modes control how many nodes (and pods) can mount the volume simultaneously:

ModeShortExclusivityTypical storage
ReadWriteOncePodRWOPSingle pod across the entire clusterBlock storage, strict isolation
ReadWriteOnceRWOSingle node — multiple pods on that node can shareBlock storage (EBS, PD)
ReadWriteManyRWXMultiple nodes, read-writeNFS, file storage
ReadOnlyManyROXMultiple nodes, read-onlyAny

RWOP vs RWO — the difference is the unit of exclusivity:

  • RWOP: only one pod anywhere in the cluster can mount the volume. If a second pod tries, it stays Pending until the first terminates.
  • RWO: only one node can mount. Multiple pods scheduled on that same node can all read/write concurrently. Any pod on a different node gets a FailedAttachVolume / Multi-Attach error.

RWX depends on the storage backend — block storage (EBS, GCP PD) does not support RWX out of the box. You need a file storage backend (NFS, GCP Filestore) or an addon that provides file-based storage classes.

Storage Architecture Constrains Your Access Mode

Section titled “Storage Architecture Constrains Your Access Mode”

The access mode you can request is determined by the physical storage type, not just by what you write in the PVC:

Storage typeSupported access modes
Block storage (SAN, iSCSI, cloud block disks like EBS/GCP PD)ReadWriteOnce (RWO), ReadWriteOncePod (RWOP) only
Shared file systems (NFS, GCP Filestore, AWS EFS)ReadWriteMany (RWX), ReadOnlyMany (ROX)

Requesting RWX on a block storage backend will leave the PVC Pending indefinitely — the CSI driver simply doesn’t support it.

A ReadOnlyMany volume provisioned dynamically starts empty, which is useless. Use dataSourceRef to clone it from an existing PVC at provisioning time:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-read-only
spec:
accessModes:
- ReadOnlyMany
volumeMode: Filesystem
resources:
requests:
storage: 10Gi
dataSourceRef: # clone content from this existing PVC
kind: PersistentVolumeClaim
name: source-data-pvc

spec.volumeMode controls how the storage is presented inside the container:

ModeHow it appears in the containerUse case
Filesystem (default)Formatted filesystem mounted as a directoryMost workloads
BlockRaw block device — no filesystem, maximum I/O performanceHigh-perf databases, custom storage engines
spec:
volumeMode: Block # exposed as /dev/xvda inside the container

StaticDynamic
Who creates the diskAdmin manuallyCSI driver automatically
Who creates the PVAdmin manuallyCSI driver automatically
Admin effortHigh — one PV per diskLow — configure StorageClass once
SizingRigid — PVC binds to nearest fitting PV (may waste space)Precise — carves exactly the requested size
PortabilityLow — PVCs must match specific labels/sizesHigh — same PVC works on any cluster with a matching SC name
Default reclaimRetainDelete
Best forNode-local disks, bare-metal, existing NASCloud environments (EBS, GCP PD, Azure Disk)
  1. Admin provisions the physical disk (NFS share, cloud disk, local directory)
  2. Admin creates PV manifest pointing to the disk — sets capacity, access modes, and storage class
  3. Developer creates PVC requesting matching size and access mode
  4. Kubernetes binds — scans available PVs; PV capacity must be ≥ PVC request; access modes must satisfy PVC; first match wins; PV becomes exclusively Bound
  5. Pod mounts the PVC via its volume spec

After PVC is deleted (with Retain policy):

  • PV transitions to Released — data is safe but PV cannot be re-bound
  • A new PVC will remain Pending indefinitely, blocked by the old claimRef metadata
  • Admin must manually release it:
    • Option A — Delete and recreate the PV object (doesn’t touch physical files; just removes the Kubernetes pointer). Fresh PV → Available
    • Option B — Edit the live PV and remove the spec.claimRef block → status immediately flips to Available

Dynamic provisioning eliminates the need for admins to pre-create volumes. The flow when WaitForFirstConsumer is set:

[1] kubectl apply Pod+PVC+SC
[2] PVC enters Pending ◀─── WaitForFirstConsumer holds provisioning
[3] Scheduler picks a node for the Pod
[4] StorageClass controller detects pending PVC, invokes CSI plugin
[5] CSI plugin calls cloud API → physical disk created in pod's zone
[6] CSI reports back → controller creates PV object in cluster
[7] PVC binds to PV (status: Bound)
[8] kubelet mounts disk into container at mountPath

CSIStorageCapacity — Smarter Provisioning Decisions

Section titled “CSIStorageCapacity — Smarter Provisioning Decisions”

CSIStorageCapacity is a cluster-scoped API object that CSI drivers publish to report how much capacity is available per topology zone. The scheduler uses this data to make smarter pod placement decisions when using WaitForFirstConsumer.

Without it: The scheduler may place a pod on a node in a zone where the CSI driver cannot provision the requested disk size, leaving the PVC in Pending after the pod is already scheduled.

With it: If a zone has insufficient capacity, the scheduler skips that zone and picks one that can accommodate the PVC — preventing a difficult-to-diagnose stuck state.

Terminal window
kubectl get csistoragecapacities -A # view per-zone capacity objects published by CSI drivers

Some workloads need ultra-low latency or direct hardware access — network-attached storage adds too much overhead. Node-local PVs provision storage on a worker node’s own disk.

Problem with hostPathLocal PV solution
Pod reschedules to a different node → loses dataScheduler guarantees pods are always placed on the node owning the PV
Any user can mount arbitrary host pathsOnly admin-approved PV objects are claimable; regular users cannot specify raw host paths

1. Create the physical directory on the node

Section titled “1. Create the physical directory on the node”
Terminal window
# On GKE node:
mkdir /tmp/my-disk
# On a Kind cluster (node is a Docker container):
docker exec kind-worker mkdir /tmp/my-disk

2. Create the StorageClass (no auto-provisioner)

Section titled “2. Create the StorageClass (no auto-provisioner)”
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-storage
provisioner: kubernetes.io/no-provisioner # no automatic provisioning
volumeBindingMode: WaitForFirstConsumer # CRITICAL: delay until pod is scheduled

WaitForFirstConsumer is mandatory for local storage — the scheduler must see both the pod’s requirements AND the volume’s nodeAffinity before choosing a node.

3. Create the PV with nodeAffinity (Strictly Mandatory)

Section titled “3. Create the PV with nodeAffinity (Strictly Mandatory)”
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-disk-node-01
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
storageClassName: local-storage
persistentVolumeReclaimPolicy: Retain
local:
path: /tmp/my-disk # directory on the physical worker node
nodeAffinity: # tells the scheduler which node owns this disk
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node-01 # name of the worker node

The nodeAffinity is what makes local PVs fundamentally different from hostPath — it lets the scheduler co-locate pods with their storage.

Claiming and Using Local Storage (Developer)

Section titled “Claiming and Using Local Storage (Developer)”
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-local-pvc
spec:
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi # ≤ PV capacity

The PVC stays Pending until a pod references it. When a pod is created:

  1. Scheduler sees WaitForFirstConsumer → evaluates pod constraints alongside PV nodeAffinity
  2. Scheduler picks the node that owns the local PV
  3. Kubernetes binds the PVC → PV
  4. Kubelet mounts the host directory into the container

With Retain policy, deleting the PVC leaves the PV in Released — no new PVC can bind to it (data-leak protection). To reuse:

  1. Clean the physical files: SSH into the node and delete/archive old data
  2. Reset the PV pointer: either delete + recreate the PV object, or edit the live PV and remove spec.claimRef

# ----- StorageClass: defines the storage tier -----
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: premium-ssd
provisioner: pd.csi.storage.gke.io # GKE block storage CSI driver
volumeBindingMode: WaitForFirstConsumer # provision after pod is scheduled
reclaimPolicy: Delete # auto-delete disk when PVC is removed
allowVolumeExpansion: true
parameters:
type: pd-ssd # GCP SSD disk type
---
# ----- PersistentVolumeClaim: developer's storage request -----
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: premium-ssd
resources:
requests:
storage: 50Gi
---
# ----- Pod: mounts the PVC -----
apiVersion: v1
kind: Pod
metadata:
name: app-pod
spec:
volumes:
- name: app-storage
persistentVolumeClaim:
claimName: app-pvc # references the PVC by name (not the PV)
containers:
- name: app
image: nginx:stable
volumeMounts:
- name: app-storage
mountPath: /var/www/html
Terminal window
kubectl apply -f storage-stack.yaml
kubectl get pvc -w # watch Pending → Bound
kubectl get pv # inspect the auto-created PV
kubectl describe pod app-pod

An ephemeral PV is a CSI-backed temporary volume defined inline in the pod spec. No separate PVC object needed — Kubernetes creates and destroys it automatically with the pod.

spec:
volumes:
- name: scratch
ephemeral:
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
storageClassName: fast-ssd # omit to use cluster default
resources:
requests:
storage: 10Gi
containers:
- name: app
image: my-app:1.0
volumeMounts:
- name: scratch
mountPath: /mnt/scratch

Auto-generated PVC naming: {pod-name}-{volume-name} — e.g., pod batch-job + volume scratch → PVC batch-job-scratch.

Lifecycle:

  1. Pod created → ephemeral volume controller detects the template → creates the PVC
  2. CSI provisions the PV → PVC binds → pod schedules → volume mounts
  3. kubectl delete pod → PVC deleted automatically → Delete reclaim policy → PV and physical disk cleaned up
FeatureemptyDirEphemeral PV
Storage topologyNode-local onlyNode-local or network-attached (depends on SC)
Size enforcementNo hard limitFixed size limit (e.g., 10Gi) enforced
ResizeNot supportedSupported via allowVolumeExpansion
SnapshotsNot supportedSupported if CSI driver supports it
Storage classNot applicableUses any configured StorageClass

When application data grows, resize an existing bound PVC without data loss — no need to clone or recreate.

Prerequisites:

  • The StorageClass must have allowVolumeExpansion: true
  • Volume size can only be increased — shrinking is not supported
# Edit the PVC to request more capacity:
spec:
resources:
requests:
storage: 50Gi # was 10Gi
Terminal window
kubectl apply -f pvc.yaml
kubectl describe pvc app-pvc # check for FileSystemResizePending condition

You may see this condition immediately after applying:

Conditions:
Type Status Message
FileSystemResizePending True Waiting for user to (re-)start a pod to finish file system resize...

This means the storage backend has expanded the disk, but the filesystem inside the container cannot resize while it’s mounted. Delete and recreate the pod — on restart, the kubelet expands the filesystem and the new capacity becomes visible.


Kubernetes can take point-in-time backups of PVCs if the underlying CSI driver supports it. Snapshots use three resources mirroring the SC/PV/PVC pattern:

ResourceScopeRole
VolumeSnapshotClassClusterWhich driver handles snapshots + deletion policy
VolumeSnapshotNamespaceDeveloper’s request for a specific PVC backup
VolumeSnapshotContentClusterAuto-created backend representation of the actual backup

Step 1 — Admin: create a VolumeSnapshotClass

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: gcp-pd-snapshots
driver: pd.csi.storage.gke.io # CSI driver that handles the snapshot
deletionPolicy: Delete # delete physical backup when VolumeSnapshot is deleted

Step 2 — Developer: request a snapshot

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: app-data-backup-1
spec:
volumeSnapshotClassName: gcp-pd-snapshots
source:
persistentVolumeClaimName: app-pvc # the PVC to back up

Monitoring:

Terminal window
kubectl get vs # vs = VolumeSnapshot shorthand
# NAME READYTOUSE RESTORESIZE SNAPSHOTCONTENT
# app-data-backup-1 true 50Gi snapcontent-abc123
kubectl get vsc # vsc = VolumeSnapshotContent (cluster-scoped)

READYTOUSE transitions from falsetrue when the backup completes. The auto-created VolumeSnapshotContent is the cluster-scoped backend representation (like a PV is the cluster-scoped backend of a PVC).

Create a new PVC pointing to the snapshot via dataSourceRef:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data-restored
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
dataSourceRef:
apiGroup: snapshot.storage.k8s.io # REQUIRED for snapshots (not needed for PVC clones)
kind: VolumeSnapshot
name: app-data-backup-1

If the StorageClass uses WaitForFirstConsumer, restoration is deferred until a pod references the new PVC and is scheduled.

For multi-volume stateful applications (e.g., a database with separate PVCs for data and WAL logs), snapshotting each volume independently can leave an inconsistent state — the data snapshot may be seconds ahead of the WAL log snapshot.

Volume Group Snapshots (Beta, K8s 1.32+) allow taking crash-consistent, atomic snapshots of multiple PVCs simultaneously using two new resources:

# Step 1 — Admin: define which CSI driver handles group snapshots
apiVersion: groupsnapshot.storage.k8s.io/v1beta1
kind: VolumeGroupSnapshotClass
metadata:
name: gcp-group-snapshots
driver: pd.csi.storage.gke.io
deletionPolicy: Delete
---
# Step 2 — Developer: trigger a group snapshot targeting a label selector
apiVersion: groupsnapshot.storage.k8s.io/v1beta1
kind: VolumeGroupSnapshot
metadata:
name: db-consistent-backup
spec:
volumeGroupSnapshotClassName: gcp-group-snapshots
source:
selector:
matchLabels:
app: my-database # snapshots ALL PVCs with this label atomically
Terminal window
kubectl get volumegroupsnapshot # check status
kubectl get volumegroupsnapshotcontent # inspect the cluster-scoped backend object

A VolumeAttributesClass (Beta, K8s 1.31+) lets you modify storage performance parameters on an existing volume without recreating it — the equivalent of changing disk IOPS or throughput tier post-provisioning.

The core problem it solves: StorageClasses are immutable. Once a volume is provisioned, you could not change iopsPerGB or throughput without deleting the PVC and recreating it (with data loss risk). VAC separates mutable performance attributes from the immutable StorageClass.

# Step 1 — Admin: define performance tiers as VolumeAttributesClass objects
apiVersion: storage.k8s.io/v1beta1
kind: VolumeAttributesClass
metadata:
name: high-iops
driverName: ebs.csi.aws.com
parameters:
iops: "10000"
throughput: "500"
---
apiVersion: storage.k8s.io/v1beta1
kind: VolumeAttributesClass
metadata:
name: standard-iops
driverName: ebs.csi.aws.com
parameters:
iops: "3000"
throughput: "125"
# Step 2 — Developer: reference VAC in PVC at creation time
spec:
storageClassName: fast-ssd
volumeAttributesClassName: standard-iops # start at standard tier
resources:
requests:
storage: 50Gi
# Step 3 — Upgrade tier in place (edit PVC — no data loss, no recreation)
spec:
volumeAttributesClassName: high-iops # CSI driver applies change non-disruptively

When a StatefulSet creates volumes via volumeClaimTemplates, those PVCs are not automatically deleted by default when the StatefulSet scales down or is deleted. This leads to orphaned storage that accumulates cost indefinitely.

The persistentVolumeClaimRetentionPolicy field (Stable, K8s 1.32) controls this lifecycle explicitly:

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
replicas: 3
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain # keep PVCs when StatefulSet is deleted (safe default)
whenScaled: Delete # auto-delete PVCs for pods that scale down
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: fast-ssd
resources:
requests:
storage: 50Gi
Policy fieldValueBehaviour
whenDeletedRetain (default)PVCs survive StatefulSet deletion — data is safe, manual cleanup required
whenDeletedDeletePVCs deleted automatically when StatefulSet is deleted
whenScaledRetain (default)PVCs for scaled-down replicas persist — safe but costly over time
whenScaledDeletePVCs deleted when a pod is removed due to scale-down

Checking the policy on an existing StatefulSet:

Terminal window
kubectl get sts postgres -o jsonpath='{.spec.persistentVolumeClaimRetentionPolicy}'
# → {"whenDeleted":"Retain","whenScaled":"Delete"}

ObjectAPI GroupShortScopeImmutable?Created by
StorageClassstorage.k8s.io/v1scCluster✅ YesAdmin
PersistentVolumev1pvClusterNoAuto (dynamic) or Admin (static)
PersistentVolumeClaimv1pvcNamespacePartiallyDeveloper
VolumeSnapshotClasssnapshot.storage.k8s.io/v1ClusterNoAdmin
VolumeSnapshotsnapshot.storage.k8s.io/v1vsNamespaceNoDeveloper
VolumeSnapshotContentsnapshot.storage.k8s.io/v1vscClusterNoAuto
ConfigurationOptions
Access modesReadWriteOncePod (1 pod), ReadWriteOnce (1 node), ReadWriteMany (N nodes), ReadOnlyMany
Volume modeFilesystem (default), Block (raw device)
Reclaim policyDelete (default dynamic), Retain (default static), Recycle (deprecated)
Binding modeImmediate, WaitForFirstConsumer

SymptomLikely causeDiagnosticFix
PVC stuck in PendingNo matching StorageClass, or CSI driver not installedkubectl describe pvc → EventsCheck SC name; verify CSI driver pods in kube-system
PVC stuck in Pending (dynamic, no error)CSI driver cannot allocate capacity in zonekubectl get csistoragecapacities -ACheck zone capacity; switch to WaitForFirstConsumer
Pod stuck in ContainerCreatingPVC not yet Boundkubectl get pvcWait for binding; check SC binding mode
Mount failure: zone mismatchDisk provisioned in wrong zone (Immediate mode)kubectl describe pod → EventsUse WaitForFirstConsumer binding mode
PVC stuck in Released after PVC deleteRetain reclaim policy — PV not reusablekubectl get pvDelete PV and recreate, or remove spec.claimRef from PV
Multi-Attach error on podRWO volume attached to a different nodekubectl describe podEnsure pods land on the same node, or switch to RWX storage
CSI driver errorsDriver not installed or misconfiguredkubectl get pods -n kube-system | grep csiInstall driver; check driver-specific docs
Volume resize not workingallowVolumeExpansion: false in StorageClasskubectl get sc -o yamlRecreate SC with allowVolumeExpansion: true; resize PVC
Resize stuck at FileSystemResizePendingFilesystem can’t resize while mountedkubectl describe pvcDelete and recreate the pod; kubelet resizes FS on restart
Snapshot READYTOUSE: falseSnapshot in progress or driver doesn’t support itkubectl describe volumesnapshot → EventsWait; check CSI driver capabilities
Pod on wrong node (local PV)Missing or wrong nodeAffinity in PVkubectl get pv -o yamlCorrect nodeAffinity to match the node hosting the disk
PVC binds to dynamic PV instead of staticstorageClassName not set to "" in PVC — default SC provisioner takes overkubectl describe pvcStorageClass fieldSet storageClassName: "" explicitly to force static binding
StatefulSet PVCs not deleted on scale-downpersistentVolumeClaimRetentionPolicy.whenScaled is Retain (default)kubectl get sts -o yamlSet whenScaled: Delete in StatefulSet spec

Speed and accuracy are essential for the CKA exam. Keep these strategies in mind when working with storage:

  • Use Short-Forms: Always use built-in aliases to save time: pv, pvc, and sc.
  • No Imperative PV Creation: There is no kubectl create pv command. You must write or copy a YAML manifest and apply it.
  • Reveal Volume Mode: Standard kubectl get pv output hides the volume mode (Filesystem vs Block). Use kubectl get pv -o wide to see the VOLUMEMODE column.
  • Inspect StorageClasses: kubectl get storageclass lists all available classes — look for the (default) marker to identify which class is auto-applied to PVCs with no storageClassName. Use kubectl describe storageclass <name> to inspect reclaim policy, provisioner, and binding mode.
  • Fast JSONPath Extraction: Use jsonpath to quickly inspect critical PV configurations without scrolling through full YAML outputs:
    • Check access modes: kubectl get pv db-pv -o jsonpath='{.spec.accessModes}'
    • Check reclaim policy: kubectl get pv db-pv -o jsonpath='{.spec.persistentVolumeReclaimPolicy}'
  • Practice Failure Scenarios: Create a PVC requesting 10Gi against a 5Gi static PV. Watch it get stuck in Pending. This muscle memory is invaluable for troubleshooting exam scenarios.