Kubernetes Persistent Volumes, PVCs and StatefulSets
Keep data when pods restart: PersistentVolumeClaims, StorageClasses, access modes, StatefulSets with volumeClaimTemplates, volume expansion and backups.
TLDR: Files written inside a container disappear with the pod. To keep data, a pod mounts a PersistentVolumeClaim (PVC), a request for storage of a certain size and access mode. A StorageClass creates a matching PersistentVolume (PV) for it on demand, usually through a CSI driver. A StatefulSet gives each of its pods a stable name and its own PVC, which is what databases and caches need.
In this part you watch the cache lose its data when its pod restarts, create a PVC and see when and where its volume appears, move the cache to a StatefulSet that keeps its data, and learn the limits of node-local storage that will matter again in Part 8.
Prerequisites
- The cache and the configured podinfo from Part 6. On a new cluster, apply
04-deployments/deployment.yamland05-networking/service.yaml, create thepodinfo-tlsSecret as Part 6 shows, then apply the files in06-config/(cache.yaml,configmap.yaml,deployment.yaml).
Data Without a Volume Is Lost
The cache from Part 6 is a Deployment without a volume, so Valkey keeps its data in the container's own filesystem. Write a key through podinfo, then replace the cache pod:
podinfo answers 404 for a key that does not exist, and also when it cannot reach the cache at all. The round trip with the check key proves that podinfo is connected to the new cache pod, so the 404 for greeting means the data is gone: the new pod started empty. If you get a 404 for check too, podinfo has not reconnected yet; wait a few seconds and try again. That is fine for a cache you can refill, and a disaster for a database.
Volumes, PersistentVolumes and Claims
Kubernetes separates what a pod needs from how storage is provided:
- A volume in a pod spec is anything mounted into containers. Some live as long as the pod (
emptyDir,configMap,secret,projected), others point at storage that outlives it (persistentVolumeClaim). - A PersistentVolumeClaim is a namespaced request: "1 GiB that one node can mount read-write". Apps ask for storage with claims and do not need to know the storage system.
- A PersistentVolume is the cluster-wide object for a real piece of storage: a cloud disk, an NFS export, a directory on a node.
- A StorageClass tells Kubernetes how to create PVs on demand (dynamic provisioning) through a provisioner. On cloud clusters that is a CSI driver, the plugin that talks to the storage system; kind uses a simpler provisioner. Most clusters have a default StorageClass, used by claims that name none.
hostPath volumes, which mount a directory of the node directly, appear in many old examples. Avoid them in apps: a pod can read or overwrite node files through them, the data stays on one node, and the restricted Pod Security level forbids them (Part 10).
Dynamic Provisioning on kind
kind ships Rancher's local-path provisioner (not a CSI driver) as its default StorageClass. It creates each volume as a hostPath directory on the node where the pod runs:
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
standard (default) rancher.io/local-path Delete WaitForFirstConsumer false 77s
WaitForFirstConsumer means the volume is created only when a pod that uses the claim is scheduled, on that pod's node. Immediate would create it right away, possibly in a zone or on a node where the pod cannot run. ALLOWVOLUMEEXPANSION false comes back below.
Create a claim:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: scratch
spec:
accessModes:
- ReadWriteOnce # one node can mount it read-write
resources:
requests:
storage: 1Gi
# no storageClassName: the cluster's default StorageClass is used
Pending is expected here. Now start a pod that mounts the claim and writes a file:
apiVersion: v1
kind: Pod
metadata:
name: writer
spec:
containers:
- name: writer
image: busybox:1.38.0
command: ['sh', '-c', 'date -u > /data/created-at.txt && sleep 3600']
volumeMounts:
- name: scratch
mountPath: /data
volumes:
- name: scratch
persistentVolumeClaim:
claimName: scratch
The provisioner created a PV, named after the claim's UID, and bound the two. Node Affinity pins this PV to the node the pod landed on: a local volume exists on one node only, so any pod that uses it must run there. Cloud disks are pinned the same way to a zone.
When you delete the claim, the reclaim policy decides what happens to the volume. Delete, the default for dynamically provisioned volumes, deletes the PV and the data:
For data you care about, use a StorageClass with reclaimPolicy: Retain. The PV then stays after the claim is deleted, and an administrator decides whether to reuse or delete it.
Access Modes
| Mode | Short | Meaning |
|---|---|---|
ReadWriteOnce |
RWO | Mounted read-write by pods on one node at a time |
ReadWriteOncePod |
RWOP | Mounted read-write by exactly one pod (stable since 1.29) |
ReadOnlyMany |
ROX | Mounted read-only on many nodes |
ReadWriteMany |
RWX | Mounted read-write on many nodes, for example NFS or a cloud file service |
Block storage such as cloud disks supports RWO and RWOP; RWX needs a file system that many nodes can share, such as NFS, CephFS, Amazon EFS, Azure Files or Google Filestore. Use RWOP for databases, so that a second pod can never mount the same disk by mistake.
A StatefulSet for the Cache
A Deployment's pods are interchangeable, and they would all share one claim. A StatefulSet gives each pod:
- a stable name,
cache-0,cache-1and so on, kept when the pod is replaced; - a stable DNS name through a headless Service,
cache-0.cache-headless.demo.svc.cluster.local; - its own PVC from
volumeClaimTemplates,data-cache-0forcache-0, which the replacement pod mounts again; - ordered startup, scaling and updates, one pod at a time.
# A headless Service gives each StatefulSet pod a stable DNS name: cache-0.cache-headless
apiVersion: v1
kind: Service
metadata:
name: cache-headless
labels:
app: cache
spec:
clusterIP: None
selector:
app: cache
ports:
- name: valkey
port: 6379
targetPort: valkey
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cache
labels:
app: cache
spec:
serviceName: cache-headless
replicas: 1
selector:
matchLabels:
app: cache
# Keep the volumes when the StatefulSet is deleted or scaled down (the default)
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
template:
metadata:
labels:
app: cache
spec:
containers:
- name: valkey
image: valkey/valkey:9.1.2-alpine
# --appendonly yes writes every change to a log file in /data
args: ['/etc/valkey/valkey.conf', '--appendonly', 'yes']
ports:
- name: valkey
containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /etc/valkey
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
volumes:
- name: config
configMap:
name: cache-config
# One PersistentVolumeClaim per pod: data-cache-0, data-cache-1, ...
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ['ReadWriteOnce']
resources:
requests:
storage: 1Gi
--appendonly yes makes Valkey log every write to a file under /data, which is now the volume. The existing cache Service from Part 6 selects app: cache, so podinfo reaches the StatefulSet's pod without any change. Replace the Deployment:
Check that podinfo reaches the new cache pod, write a key and read it back, delete the pod, and read the key again:
The StatefulSet recreated cache-0 with the same name, it mounted data-cache-0 again, and Valkey replayed its append-only file. Each pod also has a DNS name of its own, which clustered databases use to find each other:
kubectl exec deploy/podinfo -- nslookup cache-0.cache-headless.demo.svc.cluster.local
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: cache-0.cache-headless.demo.svc.cluster.local
Address: 10.244.2.13
Scaling a StatefulSet down does not delete its claims (persistentVolumeClaimRetentionPolicy controls that, and Retain is the default), so scaling back up brings the data back:
Note where cache-0 runs. Its volume is a directory on that node, so cache-0 can only ever run there; Part 8 drains that node and shows what happens:
Expanding a Volume
To grow a claim, raise resources.requests.storage. That only works when the StorageClass has allowVolumeExpansion: true and its CSI driver supports resizing. kind's local-path class does not:
kubectl patch pvc data-cache-0 -p '{"spec":{"resources":{"requests":{"storage":"2Gi"}}}}'
Error from server (Forbidden): persistentvolumeclaims "data-cache-0" is forbidden: only dynamically provisioned pvc can be resized and the storageclass that provisions the pvc must support resize
Cloud StorageClasses usually allow it, and the disk grows while the pod runs. Claims can only grow, never shrink. In a StatefulSet, volumeClaimTemplates cannot be changed in place, so you resize each existing PVC yourself and update the template for future pods by recreating the StatefulSet with kubectl delete statefulset cache --cascade=orphan, which keeps the pods and claims. To change a volume's performance, such as IOPS, without a new disk, use a VolumeAttributesClass (stable since 1.34) if your CSI driver supports it.
Storage on Managed Clusters
Managed services install their CSI driver, or offer it as an add-on (the EBS CSI driver on EKS clusters without Auto Mode), and usually a default StorageClass. On DigitalOcean Kubernetes, the default do-block-storage class creates a DigitalOcean Volume for each claim, billed while it exists. Here are StorageClasses for the other big providers (examples; each needs that provider's cluster):
# StorageClasses for managed clusters. Each needs that provider's CSI driver,
# which the managed service installs or offers as an add-on.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ebs-gp3 # Amazon EKS Auto Mode, as created in Part 2
provisioner: ebs.csi.eks.amazonaws.com # with the EBS CSI driver add-on instead: ebs.csi.aws.com
parameters:
type: gp3
encrypted: 'true'
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
allowedTopologies:
- matchLabelExpressions:
- key: eks.amazonaws.com/compute-type
values: ['auto']
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: pd-balanced # Google Kubernetes Engine
provisioner: pd.csi.storage.gke.io
parameters:
type: pd-balanced
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: azure-premium # Azure Kubernetes Service
provisioner: disk.csi.azure.com
parameters:
skuName: Premium_LRS
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
Old tutorials use in-tree provisioners such as kubernetes.io/aws-ebs or kubernetes.io/gce-pd. Kubernetes migrated them to CSI drivers and has removed the in-tree plugins for the major clouds, so use the CSI provisioner names above.
Snapshots and Backups
A VolumeSnapshot asks the CSI driver for a point-in-time copy of a claim, and a new claim can start from it. It needs the snapshot CRDs, the snapshot controller and a CSI driver that supports snapshots, which most managed services provide (kind's local-path driver does not):
# Needs the snapshot CRDs and controller, and a CSI driver with snapshot support
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: cache-data-snapshot
spec:
volumeSnapshotClassName: csi-snapclass # provided by your CSI driver
source:
persistentVolumeClaimName: data-cache-0
---
# A new claim that starts with the snapshot's data
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-cache-restore
spec:
dataSource:
name: cache-data-snapshot
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes: ['ReadWriteOnce']
resources:
requests:
storage: 1Gi
Snapshots are not a full backup plan. For databases, also take application-level backups (pg_dump, Valkey's RDB files) and store them outside the cluster. Velero backs up Kubernetes objects and volumes together and restores them into another cluster. For a production database on Kubernetes, use an operator such as CloudNativePG, which handles replicas, failover and backups, or a managed database outside the cluster.
Common Mistakes
- No volume for data you need. Anything written to the container's filesystem or an
emptyDiris gone when the pod is replaced. Immediatebinding in multi-zone clusters. The disk can end up in a zone where the pod cannot run. UseWaitForFirstConsumer.reclaimPolicy: Deletefor important data. Deleting the claim deletes the disk. UseRetain, snapshots and backups.- Sharing one RWO claim between replicas. A Deployment with several replicas and one RWO claim only works while all pods land on one node. Use a StatefulSet with one claim per pod.
- Forgetting that local volumes pin pods to nodes. A pod with a node-local volume cannot move when its node is drained or dies.
Summary
- Pods ask for storage with PersistentVolumeClaims; StorageClasses and their provisioners (usually CSI drivers) create PersistentVolumes on demand.
WaitForFirstConsumercreates the volume where the pod runs; the reclaim policy decides what happens when the claim goes.- A StatefulSet gives each pod a stable name, DNS name and its own claim, so its data survives restarts and rescheduling.
- Expansion, snapshots and performance changes depend on the CSI driver; backups belong outside the cluster.
Keep Learning
- The anatomy of Kubernetes persistent storage
- Running Postgres on Kubernetes
- Kubernetes 1.37: finding unused PVCs
Next, in Part 8, you give the pods CPU and memory budgets, scale them with load and drain a node, including the one this volume lives on.
Tags
Found an issue?