How to Use External Storage Providers in Kubernetes

How to Use External Storage Providers in Kubernetes

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:

  1. StorageClass — describes a “class” of storage and which provisioner (driver) creates it
  2. PersistentVolumeClaim (PVC) — a user’s request for storage of a certain size/access mode
  3. PersistentVolume (PV) — the actual provisioned storage resource, bound to a PVC
  4. 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: WaitForFirstConsumer delays 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: Delete vs Retain — Delete removes the underlying cloud volume when the PVC is deleted; Retain leaves it (and the PV moves to Released status) so you can manually recover data.
  • allowVolumeExpansion: true lets 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/throughput parameters to actual workload needs — over-provisioning gp3 IOPS costs money for no benefit; under-provisioning causes latency spikes under load.
  • Use volumeBindingMode: WaitForFirstConsumer in 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 fio against the actual StorageClass before committing — cloud block storage performance characteristics vary significantly between providers.

Common Mistakes

  • Using Immediate binding mode in multi-AZ clusters, leading to zone-mismatch scheduling failures.
  • Forgetting reclaimPolicy: Retain on 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 ContainerCreating when 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.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Pod Disruption Budgets with Custom Metrics in Kubernetes

How to Set Up Pod Disruption Budgets with Custom Metrics in Kubernetes

Next Post
How to Set Up Kubernetes Monitoring with Kibana

How to Set Up Kubernetes Monitoring with Kibana

Related Posts