Skip to main content

Kubernetes storage

A container's filesystem is ephemeral: restart the container and every write is gone. Kubernetes offers a layered answer to that, and the layers are worth keeping straight because they fail in different ways.

Three levels of persistence

LevelLifetimeTypical use
Container filesystemDies on container restartScratch space you do not care about
Volume (emptyDir, configMap, secret, projected)Dies with the PodSharing files between containers in a Pod, injecting config
PersistentVolume via a PVCIndependent of any PodDatabases, uploads, anything that must survive rescheduling

Pod-scoped volumes

pod-with-volumes.yaml
apiVersion: v1
kind: Pod
metadata:
name: renderer
spec:
containers:
- name: app
image: myapp:1.0
volumeMounts:
- name: cache
mountPath: /var/cache/app
- name: config
mountPath: /etc/app
readOnly: true
- name: sidecar
image: uploader:1.0
volumeMounts:
- name: cache # same volume, both containers see the same files
mountPath: /data
volumes:
- name: cache
emptyDir:
sizeLimit: 1Gi
- name: config
configMap:
name: app-config

emptyDir is created empty when the Pod is assigned to a node and deleted when the Pod is removed from that node. Container restarts do not clear it — only Pod deletion does. Setting emptyDir.medium: Memory backs it with tmpfs, which is fast but counts against the container's memory limit.

PersistentVolume and PersistentVolumeClaim

The split exists to separate concerns:

  • A PersistentVolume (PV) is a piece of storage in the cluster — a cloud disk, an NFS export, a local SSD. It is a cluster-scoped resource, usually created by a provisioner rather than by hand.
  • A PersistentVolumeClaim (PVC) is a namespaced request for storage: "I need 20 GiB that I can mount read-write from one node." Pods reference the claim, never the volume.

Kubernetes binds a PVC to a suitable PV. With dynamic provisioning — the normal case today — no PV exists until the PVC is created, and the StorageClass's provisioner makes one on demand.

pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pgdata
spec:
storageClassName: standard-rwo
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
deployment-using-pvc.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
spec:
replicas: 1
selector:
matchLabels: { app: db }
strategy:
type: Recreate # RWO volumes cannot be attached to two Pods at once
template:
metadata:
labels: { app: db }
spec:
containers:
- name: postgres
image: postgres:17-alpine
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: pgdata

Access modes

Access modes describe how many nodes may mount the volume, not how many Pods.

ModeShortMeaning
ReadWriteOnceRWORead-write by a single node. Multiple Pods on that same node can share it.
ReadOnlyManyROXRead-only by many nodes
ReadWriteManyRWXRead-write by many nodes
ReadWriteOncePodRWOPRead-write by exactly one Pod in the whole cluster

Cloud block storage (EBS, GCE PD, Azure Disk) is RWO only. RWX requires a shared filesystem: NFS, CephFS, Amazon EFS, Azure Files. Asking for RWX from a block-storage StorageClass leaves the PVC Pending indefinitely.

StorageClass

A StorageClass names a provisioner and the parameters it should use. It is also where two important policies live.

storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: '5000'
throughput: '250'
encrypted: 'true'
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
  • reclaimPolicy — what happens to the PV when its PVC is deleted. Delete destroys the underlying disk; Retain keeps it and leaves the PV in Released for manual recovery. Dynamic provisioning defaults to Delete.
  • volumeBindingMode: WaitForFirstConsumer — delays provisioning until a Pod using the claim is scheduled, so the disk is created in the same zone as the Pod. With the default Immediate, a disk provisioned in us-east-1a can leave the Pod permanently unschedulable if capacity is only in us-east-1b. Use WaitForFirstConsumer on any multi-zone cluster.
  • allowVolumeExpansion: true lets you grow a PVC later by editing spec.resources.requests.storage. Shrinking is never supported.

StatefulSets and volumeClaimTemplates

A Deployment's Pods all reference the same PVC. A StatefulSet gives each replica its own, created automatically from a template:

statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db # must be a headless Service
replicas: 3
selector:
matchLabels: { app: db }
template:
metadata:
labels: { app: db }
spec:
containers:
- name: postgres
image: postgres:17-alpine
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: fast-ssd
resources:
requests:
storage: 50Gi

This produces PVCs named data-db-0, data-db-1, data-db-2. If db-1 is deleted and rescheduled, it comes back with the same name and reattaches data-db-1. That stable pairing of identity and storage is the whole point of a StatefulSet.

Deleting the StatefulSet does not delete these PVCs — that is intentional, and it means you must clean them up yourself when you really want the data gone.

CSI in one paragraph

Every storage integration today is a CSI (Container Storage Interface) driver: an out-of-tree plugin that implements a gRPC contract. A controller component handles CreateVolume and ControllerPublishVolume (provision and attach); a node component runs as a DaemonSet and handles NodeStageVolume and NodePublishVolume (format and mount). The in-tree cloud provider volume plugins have been removed in favour of CSI, so kubectl get csidrivers is now the first thing to check when storage misbehaves.

Debugging storage

kubectl get pvc
kubectl describe pvc pgdata # events explain a Pending claim
kubectl get pv # is a PV bound to it?
kubectl get storageclass # is there a default class?

Two failures cover most cases: a Pending PVC almost always means no default StorageClass or an unsatisfiable access mode, and a Pod stuck in ContainerCreating with FailedAttachVolume almost always means the RWO volume is still attached to another node.