Kubernetes doesn’t ship with opinions about where your data actually lives — it delegates that entirely to storage providers through the Container Storage Interface. I’ve deployed workloads against EBS, Azure Disk, GCE Persistent Disk, and Ceph-backed storage over the years, and while the underlying provider changes, the Kubernetes-side pattern stays remarkably consistent once you understand the abstraction layers. This article walks through that abstraction, how CSI drivers plug in, and how to actually wire external storage into real workloads.
The Storage Abstraction Layers
Kubernetes storage is built from four layers, from most abstract to most concrete:
- StorageClass — describes a “class” of storage and which provisioner (driver) creates it
- PersistentVolumeClaim (PVC) — a user’s request for storage of a certain size/access mode
- PersistentVolume (PV) — the actual provisioned storage resource, bound to a PVC
- CSI Driver — the plugin that talks to the actual external storage system’s API
Pod
│ (references)
▼
PersistentVolumeClaim ──requests──▶ StorageClass ──provisioner──▶ CSI Driver
│ (binds to) │
▼ ▼
PersistentVolume ◀──────────────────────────── creates ───── External Storage
(EBS, Azure Disk, NFS, Ceph...)
This separation means application manifests referencing a PVC never need to know or care what’s underneath — swapping storage backends is (mostly) a StorageClass change, not an application rewrite.
Installing a CSI Driver
Most cloud providers publish their own CSI driver. As an example, here’s the AWS EBS CSI driver via Helm:
helm repo add aws-ebs-csi-driver https://kubernetes-sigs.github.io/aws-ebs-csi-driver
helm repo update
helm install aws-ebs-csi-driver aws-ebs-csi-driver/aws-ebs-csi-driver \
--namespace kube-system \
--set controller.serviceAccount.create=true
For on-prem or hybrid setups, Ceph via Rook is common:
helm repo add rook-release https://charts.rook.io/release
helm install rook-ceph rook-release/rook-ceph --namespace rook-ceph --create-namespace
Verify the driver registered correctly:
kubectl get csidriver
NAME ATTACHREQUIRED PODINFOONMOUNT STORAGECAPACITY
ebs.csi.aws.com true true false
Defining a StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
A few fields worth understanding deeply:
volumeBindingMode: WaitForFirstConsumerdelays provisioning until a pod actually needs the volume, letting the scheduler pick a node first — critical in multi-zone clusters, since EBS volumes are zone-locked and provisioning too early can create a volume in the wrong zone.reclaimPolicy: DeletevsRetain—Deleteremoves the underlying cloud volume when the PVC is deleted;Retainleaves it (and the PV moves toReleasedstatus) so you can manually recover data.allowVolumeExpansion: truelets you grow a PVC later without recreating it.
kubectl apply -f storageclass.yaml
kubectl get storageclass
Creating a PVC Against External Storage
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
namespace: production
spec:
accessModes:
- ReadWriteOnce
storageClassName: fast-ssd
resources:
requests:
storage: 50Gi
kubectl apply -f pvc.yaml
kubectl get pvc -n production
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
app-data Bound pvc-9f2a... 50Gi RWO fast-ssd
Mount it into a Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: data-processor
namespace: production
spec:
replicas: 1
selector:
matchLabels:
app: data-processor
template:
metadata:
labels:
app: data-processor
spec:
containers:
- name: processor
image: myregistry/data-processor:1.4.0
volumeMounts:
- name: data
mountPath: /var/lib/appdata
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data
Access Modes and Their Limits
Understanding access modes matters a lot when picking an external provider:
- ReadWriteOnce (RWO) — mounted read-write by a single node. Most block storage (EBS, Azure Disk, GCE PD) only supports this.
- ReadOnlyMany (ROX) — mounted read-only across many nodes.
- ReadWriteMany (RWX) — mounted read-write across many nodes simultaneously. Requires network filesystems like NFS, EFS, Azure Files, or CephFS — block storage generally can’t do this.
If your workload needs shared read-write access (multiple replicas writing to the same volume), you must pick an RWX-capable backend:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: shared-fs
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap
fileSystemId: fs-0123456789abcdef0
directoryPerms: "700"
Volume Expansion
With allowVolumeExpansion: true set on the StorageClass, growing storage is just editing the PVC:
kubectl patch pvc app-data -n production -p '{"spec":{"resources":{"requests":{"storage":"100Gi"}}}}'
kubectl get pvc app-data -n production -w
Depending on the CSI driver and filesystem, this may require a pod restart to pick up the resized filesystem — check driver docs, since some support online expansion without downtime.
Static Provisioning for Pre-Existing Storage
Sometimes you’re not creating new storage but attaching existing external volumes (e.g., an existing NFS export). This uses a manually defined PV instead of dynamic provisioning:
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-data-pv
spec:
capacity:
storage: 100Gi
accessModes:
- ReadWriteMany
nfs:
server: nfs.internal.example.com
path: /exports/appdata
persistentVolumeReclaimPolicy: Retain
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-data-claim
namespace: production
spec:
accessModes:
- ReadWriteMany
storageClassName: ""
resources:
requests:
storage: 100Gi
volumeName: nfs-data-pv
Note storageClassName: "" and the explicit volumeName — this binds the PVC directly to the pre-created PV, bypassing dynamic provisioning entirely.
Security and RBAC for Storage
CSI controller pods need broad permissions to manage volumes; restrict who can create PVCs and StorageClasses at the namespace level:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pvc-manager
namespace: production
rules:
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "create", "delete"]
StorageClass creation should be restricted to cluster admins — a malicious or careless StorageClass can point at unintended backends or disable encryption.
High Availability Across Zones
A subtlety that catches people out in multi-zone clusters: most block storage (EBS, PD, Azure Disk) is zone-locked, meaning a PV created in us-east-1a cannot attach to a pod scheduled in us-east-1b. Combined with WaitForFirstConsumer, this generally resolves itself since the scheduler picks a node first and provisioning follows — but for StatefulSets with pre-existing PVCs, a zone outage can leave pods unable to reschedule anywhere:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: production
spec:
serviceName: postgres
replicas: 3
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: postgres
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 100Gi
For true HA where you need to tolerate an entire zone outage without data loss, look at storage backends with native cross-zone replication (Ceph in a stretched cluster, cloud file services like EFS/Azure Files that aren’t zone-locked) rather than relying purely on Kubernetes scheduling to route around zone failures.
Disaster Recovery Patterns
External storage providers differ significantly in their DR story:
- Cloud block storage (EBS, PD, Azure Disk): pair with the snapshot mechanisms covered in the companion Volume Snapshots article, and replicate snapshots cross-region using your cloud provider’s native tooling since Kubernetes-native VolumeSnapshots are typically region-bound.
- Network file storage (EFS, Azure Files, Filestore): often has built-in cross-region replication as a managed feature — check your provider’s documentation rather than reinventing this at the Kubernetes layer.
- Self-hosted Ceph/Rook: supports RBD mirroring for cross-cluster replication, which needs to be configured explicitly at the Ceph pool level, not through Kubernetes objects.
kubectl get volumesnapshot -n production -o json | jq '.items[].metadata.name'
I run a scheduled job (see the CronJob pattern in the Volume Snapshots article) that also triggers a cross-region copy of the underlying cloud snapshot immediately after each Kubernetes-native snapshot completes, since that’s the piece Kubernetes doesn’t handle for you.
Troubleshooting Storage Issues
PVC stuck in Pending. Check events for the actual provisioning error:
kubectl describe pvc app-data -n production
Common causes: no StorageClass matching the requested name, IAM/permission errors on the CSI controller’s service account, or a zone mismatch when using Immediate binding mode.
Pod stuck in ContainerCreating with a volume attach error. Usually a multi-attach conflict — an RWO volume already attached to a pod on a different node:
kubectl describe pod <pod-name> -n production | grep -A 5 "Events"
kubectl get volumeattachment | grep <pv-name>
If a stale VolumeAttachment exists from a crashed node, you may need to manually delete it after confirming the old node is genuinely gone, to unblock the new attachment.
Slow I/O performance despite adequate provisioned IOPS. Check whether the bottleneck is actually network-attached storage throughput versus filesystem-level issues:
kubectl exec -it <pod> -- fio --name=test --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30 --group_reporting
Compare results against your StorageClass’s provisioned throughput/IOPS parameters — if you’re hitting the ceiling, that’s a StorageClass tuning problem, not an application problem.
Performance Optimization
- Match
iops/throughputparameters to actual workload needs — over-provisioning gp3 IOPS costs money for no benefit; under-provisioning causes latency spikes under load. - Use
volumeBindingMode: WaitForFirstConsumerin every multi-zone cluster to avoid the classic “PV created in zone A, pod scheduled in zone B, PVC stuck Pending” failure mode. - For database workloads, benchmark with
fioagainst the actual StorageClass before committing — cloud block storage performance characteristics vary significantly between providers.
Common Mistakes
- Using
Immediatebinding mode in multi-AZ clusters, leading to zone-mismatch scheduling failures. - Forgetting
reclaimPolicy: Retainon StorageClasses backing critical data, resulting in permanent data loss on accidental PVC deletion. - Assuming all storage backends support RWX — most block storage doesn’t, and pods will hang in
ContainerCreatingwhen the CSI driver rejects a multi-attach RWO request.
Summary
External storage providers plug into Kubernetes through a clean, layered abstraction: CSI driver → StorageClass → PVC → PV. Once that model clicks, moving between AWS, GCP, Azure, or self-hosted Ceph is mostly a matter of swapping StorageClass parameters rather than rewriting application manifests. The details that actually bite people — binding mode, access modes, reclaim policy — are worth getting right before you have real data sitting on top of them.