10
PV, PVC & CSI
A PersistentVolumeClaim is a request ("10Gi, one writer"), a PersistentVolume is the volume that satisfies it, and a StorageClass says which driver creates volumes on demand. Since the in-tree volume plugins were removed, that driver is almost always a CSI driver: a set of containers that turn Kubernetes objects into gRPC calls like
CreateVolume and NodePublishVolume. Once you know which component makes each call, a PVC stuck in Pending stops being a mystery.demo 1 · lifecycle
From claim to /data, one call at a time
Step through what happens between
kubectl apply and your container seeing a mounted directory. Toggle the volumeBindingMode. With Immediate the disk is created before anyone knows where the pod will run. With WaitForFirstConsumer the scheduler picks the node first and the disk is created in that node's topology. Turn on the nodeSelector to see Immediate fail.Cluster
step 0/12control plane
kubectl
you
kube-scheduler
VolumeBinding plugin
kube-apiserver / etcd
PVC data—
PV pvc-7f3a…—
Pod app—
VolumeAttachment—
kube-controller-manager
PV binder · attach/detach
csi controller · Deployment (1 replica, leader-elected)
external-provisioner
watches PVCs
external-attacher
watches VolumeAttachments
external-resizer
idle here
driver (controller)
talks to the storage API
worker-1zone-a
kubelet
csi node plugin
DaemonSet pod
…/globalmount
…/pods/<uid>/…/mount
·
zone-a storage
worker-2zone-b
kubelet
csi node plugin
DaemonSet pod
…/globalmount
…/pods/<uid>/…/mount
·
zone-b storage
worker-3zone-c
kubelet
csi node plugin
DaemonSet pod
…/globalmount
…/pods/<uid>/…/mount
·
zone-c storage
What just happened
Press Play or Step. Then flip the binding mode and the nodeSelector toggle to see what changes.
CSI calls so far
none yet
storageclass + pvc + pod
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast
provisioner: csi.proxmox.sinextra.dev # = CSIDriver name; picks the driver
volumeBindingMode: WaitForFirstConsumer # let the scheduler choose the node first
reclaimPolicy: Delete # or Retain
allowVolumeExpansion: true
parameters: # opaque to Kubernetes, passed to CreateVolume
storage: local-lvm
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
storageClassName: fast
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo hi > /data/hello && sleep infinity"]
volumeMounts:
- { name: data, mountPath: /data }
volumes:
- name: data
persistentVolumeClaim:
claimName: datadebugging a stuck volume
kubectl get pvc data -o wide # Pending? read the events:
kubectl describe pvc data # "waiting for first consumer", provisioning errors
kubectl get pv # the PV + its claim, reclaim policy, status
kubectl get pv <pv> -o jsonpath='{.spec.nodeAffinity}' # where the volume can be used
kubectl get volumeattachments # attach status per node
kubectl get csidrivers,csinodes # which drivers are registered where
kubectl -n <csi-ns> logs deploy/<controller> -c csi-provisioner # sidecar logsWhy WaitForFirstConsumer is the right default
Any volume with topology, such as a disk on a specific Proxmox host, a node-local path, or a zonal cloud disk, should use WaitForFirstConsumer. Otherwise the volume's location silently overrides your pod's scheduling constraints. Immediate is only harmless for storage reachable from every node equally (Ceph RBD, NFS).demo 2 · components
Who is who in a CSI driver
CSI deliberately keeps the vendor's code free of Kubernetes. The driver only speaks gRPC on a unix socket. Kubernetes-aware sidecars maintained by sig-storage watch the API and translate. A driver ships as a controller Deployment (cluster-wide operations: create, attach, resize, snapshot) and a node DaemonSet (format and mount on each node), plus a
CSIDriver object.Anatomy of a CSI driver · click anything
API objects
controller · Deployment1–2 replicas, leader election
shared emptyDir · /csi/csi.sock (gRPC)
node · DaemonSet (every node)privileged
/var/lib/kubelet/plugins/<driver>/csi.sock
/var/lib/kubelet/plugins_registry/ ← registration
/var/lib/kubelet/plugins/kubernetes.io/csi/… ← staging
/var/lib/kubelet/pods/<uid>/volumes/… ← publish
/dev ← block devices
external-provisioner
sidecar in the controller Deployment
watches
PersistentVolumeClaims whose StorageClass.provisioner matches the driver
CSI
CreateVolume / DeleteVolume
Turns a claim into a real volume and a PV. Honors volumeBindingMode: with WaitForFirstConsumer it waits for the scheduler's selected-node annotation and forwards that node's topology. On PVC deletion with reclaimPolicy Delete, it calls DeleteVolume and removes the PV.
demo 3 · access modes
Access modes: the "once" is per node
Access modes describe how many nodes (or with RWOP, pods) may mount a volume at once, and they only mean something if the driver supports them. Switch the backend between a block disk and a shared filesystem.
Access modes · 4 pods share one PVC
Read-write by a single node, not a single pod. Every pod on the node holding the attachment can mount it. A pod on another node gets stuck in ContainerCreating with a Multi-Attach error until the first node lets go.
worker-1
worker-2
worker-3
Hover or tap a pod to see why.
demo 4 · reclaim
Reclaim policy: who owns the data after the claim is gone?
The StorageClass's
reclaimPolicy is copied onto every PV it provisions. Changing the class later doesn't touch existing PVs. Patch persistentVolumeReclaimPolicy on the PV itself to protect something important.Reclaim policy · what deleting a PVC does
PVC data
Bound
PV pvc-7f3a…
Bound
backend disk
exists · 10Gi of data
PV persistentVolumeReclaimPolicy: Delete (copied from the StorageClass's reclaimPolicy at provisioning time). Delete the claim to see what happens.
expansion · snapshots · statefulsets
Day 2: grow, snapshot, replicate
Volumes grow but never shrink. Snapshots are an add-on API: you need the
snapshot.storage.k8s.io/v1 CRDs and the snapshot-controller installed, plus a driver whose controller pod runs the csi-snapshotter sidecar. A StatefulSet's volumeClaimTemplates give each replica its own claim, and that claim follows the replica's stable name across restarts and reschedules.online volume expansion
# StorageClass must have allowVolumeExpansion: true
kubectl patch pvc data -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
# external-resizer -> ControllerExpandVolume (grow the backend disk)
# kubelet -> NodeExpandVolume (grow ext4/xfs, usually online)
kubectl get pvc data -o jsonpath='{.status.capacity.storage}' # 20Gi when donestatefulset with per-replica claims
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: pg
spec:
serviceName: pg
replicas: 3
persistentVolumeClaimRetentionPolicy: # default: Retain / Retain
whenDeleted: Retain # keep PVCs if the StatefulSet is deleted
whenScaled: Delete # drop pg-data-pg-2 when scaling 3 -> 2
selector: { matchLabels: { app: pg } }
template:
metadata: { labels: { app: pg } }
spec:
containers:
- name: pg
image: postgres:17
volumeMounts:
- { name: pg-data, mountPath: /var/lib/postgresql/data }
volumeClaimTemplates: # one PVC per replica: pg-data-pg-0, -1, -2
- metadata: { name: pg-data }
spec:
storageClassName: fast
accessModes: [ReadWriteOncePod]
resources: { requests: { storage: 20Gi } }snapshot + restore
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: csi-snap
driver: rook-ceph.rbd.csi.ceph.com # must match the PV's driver
deletionPolicy: Delete
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: data-before-upgrade
spec:
volumeSnapshotClassName: csi-snap
source:
persistentVolumeClaimName: data
---
# restore = a new PVC whose dataSource is the snapshot
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-restored
spec:
storageClassName: ceph-block
dataSource:
apiGroup: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: data-before-upgrade
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 10Gi } }talos specifics
On Talos: where do bytes actually live?
Talos has no writable root filesystem. Everything stateful lives on the EPHEMERAL partition mounted at
/var, where etcd, container images, kubelet state and any hostPath data sit side by side. It survives reboots but is wiped by talosctl reset, so it's node-scoped state, not durable storage.Three things that bite on Talos
- kubelet runs in a container. Host paths that hostPath-style provisioners use must also be mounted into kubelet via
machine.kubelet.extraMounts, withrsharedpropagation. Otherwise the pod gets a directory that doesn't match what the provisioner created. - Filling /var hurts the node. A runaway local-path volume competes with etcd and image storage on the same partition, and kubelet starts evicting pods under disk pressure. With Talos 1.10+ you can carve a separate partition with a
UserVolumeConfig(mounted under/var/mnt/<name>, which Talos already mounts into kubelet, so no extraMounts needed for local-path) and point local storage there. You can also cap EPHEMERAL's size with aVolumeConfig. - Host tools come from extensions. Drivers that shell out to iSCSI or block tools on the host need the matching Talos system extensions (e.g.
siderolabs/iscsi-tools,siderolabs/util-linux-tools) baked into the image from Image Factory. See the StorageClasses page.
machine config patch · kubelet extraMounts
# Talos machine config patch: let kubelet (a container) see a host path
# used by a hostPath-based provisioner such as local-path-provisioner
machine:
kubelet:
extraMounts:
- destination: /var/local-path-provisioner
type: bind
source: /var/local-path-provisioner
options: [bind, rshared, rw]machine config document · UserVolumeConfig
# Talos 1.10+: carve a dedicated partition for workloads instead of sharing /var
apiVersion: v1alpha1
kind: UserVolumeConfig
name: local-storage # mounted at /var/mnt/local-storage
provisioning:
diskSelector:
match: disk.transport == "nvme" && !system_disk
minSize: 100GB
maxSize: 500GB
filesystem:
type: xfsinspect volumes on a node
talosctl -n 10.0.0.21 get discoveredvolumes # disks/partitions Talos sees
talosctl -n 10.0.0.21 get volumestatus # EPHEMERAL, user volumes, their phase
talosctl -n 10.0.0.21 get mountstatus # what is mounted where
talosctl -n 10.0.0.21 ls /var/mnt/local-storage- why it mattersA PVC is only a request. The external-provisioner sidecar turns it into a real volume plus a PV, and kubelet (through the node plugin) turns that into a mount. When something is stuck, find which sidecar should be acting and read its logs and the PVC events.
- why it mattersUse WaitForFirstConsumer for anything with topology, which on Proxmox and bare metal is almost everything. Otherwise the disk is placed before the scheduler has a say, and pods end up unschedulable with "volume node affinity conflict".
- why it mattersRWO means one node, not one pod. Use RWOP when you need a single writer. Choose Retain for data you can't recreate, because the default Delete removes the backend disk the moment the claim goes away.