apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: db
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:17
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: gp3
resources:
requests:
storage: 50Gi
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
reclaimPolicy: DeleteKubernetes graph
PVC, StorageClass and CSI: how a cloud disk ends up in your Pod
Dynamic provisioning with the AWS EBS CSI driver, object by object: StatefulSet, PVC, StorageClass, PV, CSIDriver, VolumeAttachment and the controller and node plugins that create, attach and mount the disk.
A PersistentVolumeClaim is a request for storage. Nothing in it says where the disk comes from. The StorageClass names a CSI driver, and that driver's controller creates a real disk in the cloud and a PersistentVolume that points at it. The kubelet and the driver's node plugin then attach and mount the disk on the node that runs the Pod.
The example uses the AWS EBS CSI driver. Swap ebs.csi.aws.com for pd.csi.storage.gke.io or disk.csi.azure.com and the objects stay the same. The volume binding mode matters more than anything else here, because a disk lives in one availability zone and the Pod has to run in that zone too.
Interactive graph
Click a resource to see what it does, or follow the setup step by step.
How it works
All 11 resources and what they do
- StatefulSet postgresapps/v1
- Runs one Pod per replica with a stable name,
postgres-0, and creates one PVC per Pod fromvolumeClaimTemplates. If the Pod is recreated, it gets the same PVC back. - Pod postgres-0v1
- Created by the StatefulSet. Its
volumessection points at the claimdata-postgres-0. It stays inContainerCreatinguntil the disk is attached and mounted. - PersistentVolumeClaim data-postgres-0v1
- Created by the StatefulSet from its template, named
<template>-<pod>. It asks for 50Gi ofReadWriteOncestorage of classgp3. It showsPendinguntil a PV is bound to it. - StorageClass gp3storage.k8s.io/v1
- Cluster-scoped. Names the CSI driver in
provisionerand passes itparameters.WaitForFirstConsumerdelays the disk until the Pod is scheduled, so the disk is created in the Pod's zone. - CSIDriver ebs.csi.aws.comstorage.k8s.io/v1
- Registers the driver with Kubernetes and says how to treat it, for example
attachRequired: true, which makes Kubernetes create a VolumeAttachment before mounting. - Deployment ebs-csi-controllerapps/v1
- The driver's controller plugin, with sidecars from the Kubernetes CSI project.
external-provisionerwatches PVCs and callsCreateVolume.external-attacherwatches VolumeAttachments and callsControllerPublishVolume. It needs AWS permissions to create and attach EBS volumes. - PersistentVolume pvc-3f1c9a7e-…v1
- Created by
external-provisioneronce the disk exists.csi.volumeHandleis the EBS volume ID, andnodeAffinitypins it to the disk's zone, so the scheduler never puts a Pod using it elsewhere. - EBS volume vol-0a1b2c3d4e5f67890
- The actual disk, a 50 GiB encrypted gp3 volume in
eu-west-1a. It is billed whether a Pod uses it or not. - Node ip-10-0-1-23v1
- The EC2 instance the scheduler picked for
postgres-0, in zoneeu-west-1a. The EBS volume gets attached to this instance as a block device. - VolumeAttachment csi-8e2f…storage.k8s.io/v1
- Created by the attach/detach controller in kube-controller-manager. It asks for this PV to be attached to this node.
external-attacherdoes the attach and setsstatus.attached: true. - DaemonSet ebs-csi-nodeapps/v1
- The driver's node plugin, one Pod on every node. The kubelet calls it to format the device the first time (
NodeStageVolume) and to mount it into the Pod's directory (NodePublishVolume).
Common mistakes
volumeBindingMode: Immediatecreates the disk as soon as the claim exists, in whatever zone the driver picks. If the Pod then can't be scheduled in that zone, it staysPendingwith avolume node affinity conflictevent.ReadWriteOncemeans one node at a time. When a Pod moves to another node, the old attachment must be removed first, and if the old node is unreachable you'll seeMulti-Attach erroruntil the attach/detach controller force-detaches it, by default after about six minutes.- Resizing works only with
allowVolumeExpansion: true. Then you raisespec.resources.requests.storageon the PVC. EditingvolumeClaimTemplatesin a StatefulSet is not allowed, so resize each PVC directly. - A claim stuck in
Pendingsays why in its events.kubectl describe pvc data-postgres-0 -n dbshows messages fromexternal-provisioner, such as missing IAM permissions, and the controller logs have the details. - Since Kubernetes 1.32 a StatefulSet can delete its claims for you with
persistentVolumeClaimRetentionPolicy. Leave it at the defaultRetainfor databases unless you want data gone when you scale down.
The manifests you write
Everything above that is marked "You write it", in one file. The rest comes from add-ons and controllers.
Keep going
More graphs
- Deployment, Service and Ingress: how a request reaches your Pods
- Gateway API: GatewayClass, Gateway and HTTPRoute with a canary split
- RBAC: how a ServiceAccount gets its permissions
- cert-manager with Let's Encrypt: Certificate, CertificateRequest, Order and Challenge
- A private CA with cert-manager and trust-manager
- Istio traffic management: Gateway, VirtualService, DestinationRule and policies
- Flux dependencies: Git and OCI sources, Kustomizations and HelmReleases
- Argo CD: ApplicationSet, AppProject and the Applications it generates
- Inside an OCI image: tag, index, manifests, config and layers
- OCI artifacts: Helm charts, Flux artifacts and image volumes