10.42 - Storage and Stateful Workloads
The storage stack (three layers)
StorageClass -> PersistentVolume -> PersistentVolumeClaim -> pod mount
(local-path) (actual disk dir) (your request for disk) (/data)
- StorageClass: the “how disk is provisioned” template. k3s ships
local-path(creates hostPath-style dirs on the node) and marks it default.kubectl get sc. - PersistentVolume (PV): the actual storage unit, cluster-wide.
kubectl get pv. Withlocal-path, PVs appear dynamically when you create a PVC. - PersistentVolumeClaim (PVC): your namespace-scoped request. Bind to a PV matching its size + access mode.
You only ever create PVCs (and StorageClasses for special cases). PVs are provisioned for you.
kubectl get sc,pv,pvc -A
kubectl get sc local-path -o yaml # provisioner, reclaimPolicy, volumeBindingMode
Access modes
- ReadWriteOnce (RWO): one node can mount it read-write. Default; right for most single-pod apps.
- ReadOnlyMany (ROX) / ReadWriteMany (RWX): multiple nodes. local-path does NOT support RWX; it is per-node hostPath. RWX needs a real shared filesystem (NFS, CephFS). For a single-node k3s, RWO is all you need.
Reclaim policies
- Delete (local-path default): delete the PVC, the PV and its data are removed. Good for scratch.
- Retain: PV survives PVC deletion; you must delete it manually. Good for backups.
The “my data survives” recipe (challenge C13)
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mydata
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 1Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: writer }
spec:
replicas: 1
selector: { matchLabels: { app: writer } }
template:
metadata: { labels: { app: writer } }
spec:
volumes:
- name: data
persistentVolumeClaim: { claimName: mydata }
containers:
- name: writer
image: busybox
command: ["sh", "-c", "echo survive > /data/marker; sleep 3600"]
volumeMounts: [ { name: data, mountPath: /data } ]
EOF
kubectl exec deploy/writer -- sh -c "cat /data/marker" # survive
kubectl delete pod -l app=writer # kill the pod
kubectl exec deploy/writer -- sh -c "cat /data/marker" # STILL survive
Deleting the pod does nothing to the PVC. Deleting the PVC with local-path deletes the PV and the directory. This is why: keep PVCs, delete pods.
Volume binding mode
local-path is WaitForFirstConsumer: the PV is created only when a pod
actually schedules and needs it, and it lands on that pod’s node. That
matters for multi-node clusters (a PVC created “eagerly” could bind to a PV
on the wrong node, and RWO then blocks scheduling). On single-node k3s this
is invisible, but know the term for interviews and for when you add agents.
StatefulSets: identity + per-pod storage
Use when pods need to be individually addressable and have their own disk (databases, kafka, anything with “node id”).
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Service
metadata: { name: db }
spec:
clusterIP: None # headless: no VIP, DNS returns pod IPs
selector: { app: db }
ports: [ { port: 3306 } ]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db }
spec:
serviceName: db
replicas: 3
selector: { matchLabels: { app: db } }
template:
metadata: { labels: { app: db } }
spec:
containers:
- name: db
image: mysql:8
env:
- name: MYSQL_ROOT_PASSWORD
value: rootpw
volumeMounts: [ { name: data, mountPath: /var/lib/mysql } ]
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 1Gi } }
EOF
kubectl get pods -o wide # db-0, db-1, db-2
kubectl get pvc # data-db-0, data-db-1, data-db-2 (per pod!)
kubectl get endpoints db # three pod IPs, no VIP
kubectl scale statefulset db --replicas=0 # scale down: pods gone, PVCs stay
kubectl scale statefulset db --replicas=3 # scale up: db-0/1/2 return, same identity
What’s different from a Deployment:
- Stable names db-0..db-N (Deployment pods get random suffixes).
- Each pod gets its OWN PVC from the template (a Deployment shares one PVC among all replicas, which breaks RWO).
- Pods start/stop in order: db-0 first, then db-1… (depends on podManagementPolicy, default OrderedReady).
- DNS:
db-0.db.default.svc.cluster.localworks via the headless service.
Storage on k3s: local-path details
- Default storage class:
local-path(rancher local-path-provisioner). - Data lands in
/var/lib/rancher/k3s/storage/<pvc-uid>_default_mydataon the node (check withsudo find /var/lib/rancher/k3s/storage). - It’s hostPath under the hood: single-node fine, multi-node means data lives on the node that owns the pod. For HA storage you’d add Longhorn or NFS; note it, don’t build it this week.
Backups (day 7 final project hook)
Backups of PVC data = back up the hostPath directory. k3s has k3s etcd
snapshot save (or sqlite backup) for cluster state, but app data you back
up yourself. A CronJob that tars /data to another PVC (or offsite) is the
pattern:
apiVersion: batch/v1
kind: CronJob
metadata: { name: backup }
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
volumes:
- name: data
persistentVolumeClaim: { claimName: mydata }
containers:
- name: backup
image: busybox
command: ["sh", "-c", "tar czf /backup/$(date +%F).tgz -C /data ."]
volumeMounts: [ { name: data, mountPath: /data, readOnly: true } ]
Gotchas
- RWO + multiple replicas = pods stuck Pending (volume can’t mount on two nodes). If your app is stateless, don’t give it a PVC at all.
- Deleting a StatefulSet does NOT delete its PVCs (by design). To wipe
state: delete the StatefulSet, then
kubectl delete pvc -l app=db. - local-path + Retain: change
reclaimPolicyon a custom StorageClass, not the shared default. kubectl get pvshowsBound; a PVC stuck inPendingwith no events = no StorageClass matches (checkkubectl get scand the class name).