The previous lesson ended with a precise diagnosis: every volume we knew — emptyDir, configMap, secret, projected — dies with the pod, and hostPath survives but ties the data to one specific node and opens an unacceptable security hole. For bookings-postgres to keep the bookings, something different is needed: a storage object with a life of its own in the cluster, independent of any pod. That object is the PersistentVolume (PV). In this lesson you will see why Kubernetes separated it from consumption, take its specification apart field by field — including the access modes trap, which applies per node and not per pod —, walk through its phases with a state diagram, and create a real 10 GiB PersistentVolume for the Rutas Norte database in your practice cluster.
Contents
- The real problem: who administers and who consumes
- What a PersistentVolume is
- Anatomy of the object:
capacityandvolumeMode - The access modes and the node trap
- The reclaim policy:
Retain,Deleteand the obsoleteRecycle storageClassName,mountOptionsandnodeAffinity- The possible backends and which modes each family supports
- Lifecycle: the phases of a PV
- What to do with a PV in
Released - Hands-on: a 10 GiB PV for
bookings-postgres - Why static provisioning does not scale
- The real problem: who administers and who consumes
Before looking at a single YAML field, you need to understand what organisational problem this abstraction solves, because otherwise the PV/PVC pair looks like unnecessary bureaucracy.
At Rutas Norte there are two distinct roles and they do not overlap:
| Platform team | Application team | |
|---|---|---|
| Knows | What disk arrays there are, what performance classes, in which zones, what each GiB costs, how snapshots are taken | That bookings-postgres needs 10 fast GiB and cannot lose them |
| Does not know | What each specific application needs | Whether underneath there is an EBS gp3, an iSCSI LUN or a node directory |
| Writes | PersistentVolume and StorageClass | PersistentVolumeClaim |
| Resource | No namespace: it belongs to the cluster | Namespaced: it lives next to the application |
If the bookings-postgres manifest had to name an AWS disk directly by its identifier, three bad things would happen: the manifest would stop being portable between minikube, pre-production and production; the developer would need credentials and knowledge of the infrastructure; and any change of provider would force a rewrite of every manifest on the platform.
Kubernetes' solution is a two-sided contract:
flowchart TB
subgraph PLATFORM["Platform team (cluster resources)"]
SC["StorageClass<br/>rutasnorte-fast<br/>(05-04)"]
PV["PersistentVolume<br/>capacity: 10Gi<br/>accessModes: RWO<br/>reclaimPolicy: Retain"]
end
subgraph APP["Application team (namespace rutas-norte-pro)"]
PVC["PersistentVolumeClaim<br/>'I want 10Gi RWO<br/>of class rutasnorte-fast'"]
POD["Pod bookings-postgres<br/>volumes:<br/>persistentVolumeClaim"]
end
CTRL["PersistentVolume controller<br/>(kube-controller-manager)"]
PVC -->|"1. requests it"| CTRL
PV -->|"2. available candidates"| CTRL
CTRL -->|"3. BINDS (Bound)"| PVC
SC -.->|"provisions on demand"| PV
POD -->|"4. mounts"| PVC
The sentence that sums up the whole lesson: the PVC is the demand, the PV is the supply, and the controller matches them. Whoever deploys the application writes only the demand.
- What a PersistentVolume is
A PersistentVolume is an API object representing a specific piece of storage that already exists in the cluster, provisioned by an administrator or created dynamically by a StorageClass.
Three properties define its nature and it is worth nailing them down from the outset:
- It has no namespace. It is a cluster-scoped resource, like nodes or StorageClasses. You can check it with what you learned in 02-06:
kubectl api-resources | grep -E "^NAME|persistentvolume"
The PVC is namespaced: it belongs to the application. The PV is not.NAME SHORTNAMES APIVERSION NAMESPACED KIND persistentvolumeclaims pvc v1 true PersistentVolumeClaim persistentvolumes pv v1 false PersistentVolume - Its lifecycle is independent of the pod. You can delete the whole Deployment, the pod, even the PVC, and the PV may still exist with the data inside. That independence is exactly what was missing in 05-01.
- It is a declarative object like any other. It has
apiVersion: v1,kind: PersistentVolume,metadata,specandstatus, exactly the model of 01-06. Explore it:kubectl explain pv.spec kubectl explain pv.spec.accessModes
- Anatomy of the object:
capacity and volumeMode
capacity and volumeModeA complete PV, with every field that matters, commented:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-bookings-postgres-10gi # no namespace: it belongs to the cluster
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: dev
disk-type: ssd # useful for the PVC selectors (05-03)
spec:
capacity:
storage: 10Gi # declared size of the volume
volumeMode: Filesystem # Filesystem (default) or Block
accessModes:
- ReadWriteOnce # a single NODE in read-write
persistentVolumeReclaimPolicy: Retain # what to do when it is released
storageClassName: rutasnorte-fast # the class it belongs to
mountOptions: # options passed to the mount
- noatime
hostPath: # the backend: for TESTING only
path: /data/bookings-postgres
type: DirectoryOrCreatecapacity.storage
The size of the volume, in Kubernetes units (Gi = 2^30 bytes, G = 10^9 bytes; always use Gi). It is a declared figure, not a measured one: Kubernetes does not check that the disk really is that size. With a hostPath test backend you can write capacity: 10Gi over a directory on a 4 GiB disk and nobody will complain until it fills up. Its real function is to serve as a criterion in the matching with the PVC.
Today storage is the only resource supported in capacity; attributes such as IOPS are expressed through the StorageClass parameters (05-04).
volumeMode: Filesystem versus Block
Filesystem (default) |
Block |
|
|---|---|---|
| What the pod receives | A directory mounted at mountPath |
A raw block device at devicePath |
| Formatting | Done by Kubernetes/CSI (ext4, xfs) | There is no filesystem |
| Field in the container | volumeMounts |
volumeDevices |
| Typical use | 99% of cases, PostgreSQL included | Databases that manage their own I/O, distributed storage systems |
In block mode the container does not use volumeMounts but volumeDevices, with devicePath: /dev/xvda instead of mountPath: the device appears raw, with no filesystem.
At Rutas Norte we use Filesystem for everything. Block only makes sense if the application knows how to write straight to the device, and PostgreSQL does not.
- The access modes and the node trap
The accessModes declare in what ways the volume can be mounted. There are four:
| Mode | Abbreviation | Exact meaning |
|---|---|---|
ReadWriteOnce |
RWO | Read and write from a single node |
ReadOnlyMany |
ROX | Read-only from many nodes |
ReadWriteMany |
RWX | Read and write from many nodes |
ReadWriteOncePod |
RWOP | Read and write from a single pod in the whole cluster |
The trap is in ReadWriteOnce, and almost everyone falls into it at least once. The "Once" refers to one node, not one pod. This means that two, three or twenty pods can mount the same RWO volume simultaneously in read-write mode if the scheduler places them on the same node. No layer of Kubernetes will prevent it.
The consequence for a database is direct and serious: if a rollout with the RollingUpdate strategy brings up a new bookings-postgres pod on the same node before terminating the old one, you will have two PostgreSQL processes over the same data directory. That is why bookings-postgres has had strategy: Recreate since module 2, and that is why accessModes: ["ReadWriteOncePod"] exists.
ReadWriteOncePod is the only guarantee that a single pod can use the volume; the second one stays Pending with an explicit event. It is a stable mode from Kubernetes 1.29 onwards and requires the CSI driver to support it. When the driver allows it, it is the right choice for a database with a single replica.
Two more warnings about the modes:
- The list is a declaration of capabilities, not an active restriction. A PV can declare
["ReadWriteOnce", "ReadOnlyMany"]to say it supports both uses; the PVC will pick one. - What a backend actually supports does not depend on what you write. You can put
ReadWriteManyon a PV backed by an AWS block disk, and Kubernetes will accept it without a murmur; the failure will show up when trying to mount it from the second node. The table in section 7 records what each family really supports.
- The reclaim policy:
Retain, Delete and the obsolete Recycle
Retain, Delete and the obsolete RecyclepersistentVolumeReclaimPolicy decides what happens to the volume and to the data when the PVC that was using it is deleted. It is the field with the greatest consequences in the object.
| Policy | On deleting the PVC | State of the PV | Is the data lost? |
|---|---|---|---|
Retain |
Nothing is done with the volume | Goes to Released |
No. It stays intact, awaiting manual intervention |
Delete |
The PV and the underlying real volume are deleted | The PV object disappears | Yes, irreversibly |
Recycle |
An rm -rf /volume/* and back to Available |
Available |
Yes |
Recycle is obsolete and must not be used: it only worked with hostPath and NFS, it deleted through a helper pod with no guarantees whatsoever, and it does not fit the CSI model. Its replacement is dynamic provisioning (05-04): instead of recycling a used volume, a new, clean one is created.
The practical decision at Rutas Norte:
bookings-postgres→Retain. The scenario to avoid is that someone deletes therutas-norte-pronamespace by mistake — remember from 02-06 thatkubectl delete nsdrags the PVCs with it — and that this destroys every customer's data. WithRetain, the PVC disappears but the volume is still there and can be recovered.- Rebuildable volumes (caches, working spaces) →
Delete. Recreating is cheaper than cleaning by hand, andDeleteavoids being billed for orphaned disks.
An important detail that causes a lot of confusion: in dynamic provisioning the PV inherits the policy from the StorageClass, and most default cloud classes use Delete. That is, if you do nothing, deleting a production PVC deletes the disk. Check it with kubectl get storageclass -o custom-columns=NAME:.metadata.name,POLICY:.reclaimPolicy.
And on an existing PV it can be changed on the fly, which is the standard defensive manoeuvre before touching anything:
kubectl patch pv pv-bookings-postgres-10gi \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
storageClassName, mountOptions and nodeAffinity
storageClassName, mountOptions and nodeAffinitystorageClassName
It is a matching label: a PVC only binds to a PV whose class matches exactly. Three situations to tell apart carefully (and which we will see again in 05-04):
| In the PV | In the PVC | Result |
|---|---|---|
storageClassName: fast |
storageClassName: fast |
They can be matched |
Field absent or "" |
storageClassName: "" |
They can be matched (static "classless" PV) |
| Field absent | Field absent | The PVC will use the default class and will not look at that PV |
A static PV can carry a storageClassName that does not correspond to any existing StorageClass (for example manual). That is common practice and perfectly valid: the string is only used to match supply and demand.
mountOptions
Options passed as-is to the filesystem mount — noatime and nodiratime to reduce writes, or hard and nfsvers=4.1 on NFS. Kubernetes does not validate them: if the backend does not support them, the mount fails and the pod stays in ContainerCreating with a FailedMount event.
nodeAffinity
It restricts from which nodes the volume can be mounted. It is mandatory on local volumes, because the disk is physically on one machine and the scheduler must know it so as not to place the pod where the volume does not exist.
spec:
local:
path: /mnt/disks/ssd1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: ["rutas-norte"] # the minikube nodeThis is a case of storage conditioning scheduling, a topic explored in depth in 06-05. In the cloud the mechanism is identical but the key is usually topology.kubernetes.io/zone: a disk created in eu-west-1a can only be mounted from nodes in that zone, and from there comes the volumeBindingMode problem that 05-04 will solve.
- The possible backends and which modes each family supports
The PV spec contains exactly one backend key, which defines where the real storage comes from.
hostPath and local: for testing and for local disks
hostPath as the backend of a PV has the same dangers described in 05-01 and must only be used on single-node clusters such as your minikube. local is its serious cousin: it also uses a disk on the machine, but it is a type designed for production, it respects scheduling thanks to the mandatory nodeAffinity and it does not allow arbitrary system paths. Even so, a local volume does not survive the loss of the node: redundancy is the application's responsibility (PostgreSQL replication, for instance).
NFS: the shared classic
spec:
capacity: { storage: 100Gi }
accessModes: ["ReadWriteMany"] # several nodes at once: that is its point
nfs:
server: nas.rutasnorte.example
path: /exports/attachments
mountOptions: ["hard", "nfsvers=4.1"]NFS is the simplest route to ReadWriteMany, useful for instance so that the three web-store replicas share a directory of route images. It is not suitable for PostgreSQL's data files: file locking over NFS is a classic source of corruption.
CSI drivers: what is used in production
On a managed cluster you will hardly ever write a PV by hand; the provider's CSI driver will create them. When you inspect them, you will see a csi key with driver: ebs.csi.aws.com, the volumeHandle (the identifier of the real disk at the provider), the fsType and some volumeAttributes.
The CSI architecture is studied in depth in 05-05.
Which modes each family really supports
An indicative table; the final truth is set by the driver and its version.
| Family | RWO | ROX | RWX | RWOP | Note |
|---|---|---|---|---|---|
hostPath (one node) |
Yes | Yes | Yes | Yes | Everything "works" because there is only one node. Not production |
local |
Yes | Yes | No | Yes | Tied to the node by nodeAffinity |
| NFS | Yes | Yes | Yes | Yes | The classic option for sharing |
| Cloud block disks (EBS, PD, Azure Disk) | Yes | No | No | Yes | A disk only attaches to one machine |
| Cloud shared files (EFS, Filestore, Azure Files) | Yes | Yes | Yes | Yes | More expensive and with more latency |
| Ceph RBD | Yes | Yes | No | Yes | Block |
| CephFS | Yes | Yes | Yes | Yes | Distributed filesystem |
The rule that follows: if you need ReadWriteMany, you need a shared filesystem, and that is an architecture and cost decision, not a YAML field. For bookings-postgres we do not need it: a database with a single replica wants ReadWriteOnce (or better, ReadWriteOncePod) over a fast block disk.
- Lifecycle: the phases of a PV
The PV has a status.phase field with four possible values:
stateDiagram-v2
[*] --> Available: the PV is created (static)<br/>or the StorageClass provisions it
Available --> Bound: the controller matches it<br/>with a compatible PVC
Bound --> Released: the PVC is deleted<br/>(the data IS STILL there)
Released --> [*]: reclaimPolicy Delete<br/>-> PV and volume deleted
Released --> Available: manual intervention<br/>(reclaimPolicy Retain)
Available --> Failed: failure in provisioning<br/>or in reclamation
Bound --> Failed: backend failure
Failed --> [*]: manual correction
| Phase | Meaning | What to do |
|---|---|---|
Available |
Free and available to be bound | Nothing; wait for a PVC |
Bound |
Bound to a specific PVC | Normal operating state |
Released |
The PVC was deleted; the PV keeps the data but cannot be reused as-is | Recover the data or free it by hand (section 9) |
Failed |
Automatic reclamation failed | Investigate with kubectl describe pv |
Check on the cluster:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
pv-bookings-postgres-10gi 10Gi RWO Retain Bound rutas-norte-dev/bookings-postgres-data rutasnorte-fast 4m
pv-attachments-50gi 50Gi RWX Delete Available rutasnorte-standard 4mThe CLAIM column tells you, for each PV, which PVC in which namespace has it taken. It is the at-a-glance view most used day to day.
- What to do with a PV in
Released
ReleasedThis is an operational situation that always turns up and baffles people the first time. Someone deletes the bookings-postgres PVC; since the policy is Retain, the PV goes to Released. You create the same PVC again, with the same name and the same request… and it stays Pending forever, even though the PV it wants is right there with 10Gi free.
The cause is that the PV keeps in spec.claimRef the reference to the previous PVC, including its uid:
{"kind":"PersistentVolumeClaim","namespace":"rutas-norte-dev",
"name":"bookings-postgres-data","uid":"1c5b7f2a-3d9e-4c11-9f88-0a2b6d4e7c31"}Even though the new PVC has the same name, its uid is different, so it does not match. And that block is deliberate: it is the protection that prevents one tenant's data from ending up mounted by mistake in another's pod.
The correct procedure has three routes, in order of preference:
a) Reuse the volume for the same purpose (the case of a recovery after an accidental deletion). The stale reference is removed and the PV returns to Available:
kubectl patch pv pv-bookings-postgres-10gi \
--type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]'
kubectl get pv pv-bookings-postgres-10giNAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
pv-bookings-postgres-10gi 10Gi RWO Retain Available rutasnorte-fast 1hNow any compatible PVC can take it. The data is still inside.
b) Pre-assign it to a specific PVC, which is safer than leaving it loose. The claimRef is edited leaving namespace and name but without the uid: the PV will only accept that exact PVC.
spec:
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
namespace: rutas-norte-dev
name: bookings-postgres-data
# no uid: it will be filled in on bindingc) Discard it. If the data is no longer worth anything, the PV is deleted and the real volume is cleaned up by hand (with Retain, deleting the PV object does not delete the data on the backend: in the cloud, the disk keeps being billed).
Operational tip: before reusing a
ReleasedPV that contains data, take a copy. Section 10 creates the volume; backups are studied in 05-06, but the rule applies from today.
- Hands-on: a 10 GiB PV for
bookings-postgres
bookings-postgresLet us create the volume that settles the module's debt. In minikube the backing will be a node directory, and here hostPath is acceptable because it is a single-node test cluster; the manifest carries a comment making it explicit so that nobody copies it into production.
First we prepare the directory on the node:
minikube ssh -p rutas-norte -- "sudo mkdir -p /data/bookings-postgres && \
sudo chmod 777 /data/bookings-postgres && ls -ld /data/bookings-postgres"In minikube, /data is a directory that persists across restarts of the virtual machine, unlike most of the filesystem. That is why it is the right place for this exercise.
Now the PersistentVolume:
# k8s/base/pv-bookings-postgres.yaml
#
# WARNING: the hostPath backend is valid ONLY on the practice minikube,
# which is a single-node cluster. In pre and pro this PV is created by the CSI
# driver dynamically from the rutasnorte-fast StorageClass (see 05-04).
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-bookings-postgres-10gi
labels:
app: bookings-postgres
app.kubernetes.io/name: bookings-postgres
app.kubernetes.io/component: database
app.kubernetes.io/part-of: rutas-norte
environment: dev
disk-type: ssd
spec:
capacity:
storage: 10Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
# Retain: if someone deletes the PVC (or the whole namespace), the booking
# and customer data is NOT destroyed. A deliberate decision.
persistentVolumeReclaimPolicy: Retain
storageClassName: rutasnorte-fast
mountOptions:
- noatime
hostPath:
path: /data/bookings-postgres
type: DirectoryOrCreatepersistentvolume/pv-bookings-postgres-10gi created
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
pv-bookings-postgres-10gi 10Gi RWO Retain Available rutasnorte-fast 3sAvailable: it exists, it is free and it is waiting for a PVC. Notice that there is no namespace at all in the output: it is a cluster resource.
Detailed view:
Name: pv-bookings-postgres-10gi
Finalizers: [kubernetes.io/pv-protection]
StorageClass: rutasnorte-fast
Status: Available
Claim:
Reclaim Policy: Retain
Access Modes: RWO
VolumeMode: Filesystem
Capacity: 10Gi
Node Affinity: <none>
Source:
Type: HostPath (bare host directory volume)
Path: /data/bookings-postgres
HostPathType: DirectoryOrCreate
Events: <none>Two details worth reading:
Finalizers: [kubernetes.io/pv-protection]. It is the mechanism from 01-06: it prevents the PV from being deleted while it is bound to a PVC. If you delete a PV in use, the object stays inTerminatinguntil it stops being so.- Empty
Claim:. Nobody has claimed it yet. That gap will be filled by the PVC of the next lesson.
Now check what still does not happen: the bookings-postgres pod is still not using it. A PV does not mount itself; a PVC is needed to claim it. That is exactly the content of 05-03.
- Why static provisioning does not scale
What you have just done is called static provisioning: a human creates the PV by hand, in advance, and waits for someone to claim it. It works, and for a directory in minikube or for a fixed storage array in your own data centre it is perfectly reasonable. But as a general model it fails in four places:
| Problem | Why it hurts |
|---|---|
| Manual work on the critical path | Every team deploying something stateful has to wait for an administrator to create their PV. The platform becomes a bottleneck |
| Impossible size fitting | If you prepare PVs of 10, 20 and 100 GiB, a 12 GiB PVC will bind to the 20 GiB one and you will waste 8 GiB; and if it asks for 150 GiB there will be no candidate even if there are terabytes free |
| Orphaned volumes | Released PVs with Retain pile up; in the cloud they keep being billed even though nobody uses them |
| Blind topology | The administrator creates the PV in one zone without knowing where the pod will end up; the match can leave the pod impossible to schedule |
The solution is to reverse the order: instead of creating volumes just in case, create the exact volume at the moment someone asks for it. That is dynamic provisioning, and the object that makes it possible is the StorageClass, which we have already named in storageClassName without explaining it. You will see it in 05-04 and the machinery behind it, CSI, in 05-05.
The good news is that what you are learning does not change: in dynamic provisioning there are still PVs with exactly these fields — capacity, access modes, reclaim policy, node affinity —; the only difference is who writes them.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
Believing ReadWriteOnce means "one pod" |
Two pods write at once and corrupt the data | It means one node. Use ReadWriteOncePod and strategy: Recreate |
Declaring ReadWriteMany over a block disk |
The pod on the second node does not start: Multi-Attach error |
Kubernetes does not validate the modes; check the backend table |
Leaving the Delete policy on a data volume |
A PVC is deleted and the data disappears | Retain on anything holding business data |
Trying to reuse a Released PV |
The new PVC stays Pending with no clear explanation |
Remove spec.claimRef with kubectl patch --type=json |
Creating a local PV without nodeAffinity |
The pod is scheduled on a node without the disk: FailedMount |
nodeAffinity is mandatory on local |
Assuming capacity measures something |
The volume fills up long before the declared size | capacity is a declared figure, not a measured one |
hostPath on a multi-node cluster |
The data "changes" depending on where the pod lands | Only on single-node clusters; in production, CSI |
Setting mountOptions the backend does not support |
Pod stuck in ContainerCreating, FailedMount event |
Check the driver's options first |
Looking for the PV with -n <namespace> |
"No resources found" even though it exists | The PV has no namespace |
Operational tips:
- Label your PVs. Without labels you cannot use them with a
selectorfrom a PVC nor filter them in an audit. Use the same conventions as the rest of Rutas Norte, includingapp.kubernetes.io/part-of. - Quick risk audit — which volumes would be destroyed on deleting their PVC:
kubectl get pv -o custom-columns=\ NAME:.metadata.name,POLICY:.spec.persistentVolumeReclaimPolicy,\ STATE:.status.phase,CLAIM:.spec.claimRef.name | grep Delete - Before any delicate operation, change the policy to
Retainwithkubectl patch. It is reversible and it has saved many databases. kubectl get pv,pvc -Ain a single command gives you the full picture of the cluster's supply and demand.
Exercises
Exercise 1: the database PV and its phases
Create the pv-bookings-postgres-10gi PersistentVolume from section 10 in your minikube. Then:
- Check that it is a resource without a namespace and that its phase is
Available. - Write a file inside the node directory and verify it is there.
- Change its reclaim policy to
Deletewithkubectl patchand set it back toRetain, checking the change at each step. - Try to delete the PV and explain which finalizer appears in
kubectl describe.
Exercise 2: design three PVs for Rutas Norte
The platform team has to prepare storage for three needs. For each one, decide capacity, accessModes, persistentVolumeReclaimPolicy, volumeMode and the backend type, and justify every choice:
- A. The
bookings-postgresdata in production: 200 GiB, a single instance, containing personal customer data, performance-critical. - B. A directory of route images that the three replicas of
web-storemust read simultaneously, and that an administration process updates once a day: 20 GiB. - C. A 500 GiB working space for
occupancy-reportsto generate the nightly report and delete it when finished.
Write the manifest for case B.
Exercise 3: rescue a blocked PV
Reproduce and solve the classic incident:
- Create a 1 GiB PV
pv-rescue-practicewithRetain, classmanualand ahostPathbacking at/data/rescue. - Create a PVC called
practice-datainrutas-norte-devthat binds it and write a witness file from a pod. - Delete the PVC and observe the PV's phase.
- Create the same PVC again. What happens and why?
- Fix it without losing the witness file.
Solutions
Exercise 1
# 1
kubectl apply -f k8s/base/pv-bookings-postgres.yaml
kubectl get pv -n rutas-norte-dev pv-bookings-postgres-10gi # the -n is IGNORED
kubectl api-resources --namespaced=false | grep persistentvolumes
kubectl get pv pv-bookings-postgres-10gi -o jsonpath='{.status.phase}'; echo# 2
minikube ssh -p rutas-norte -- "echo 'PV witness' | sudo tee /data/bookings-postgres/TEST.txt"
# 3
for P in Delete Retain; do
kubectl patch pv pv-bookings-postgres-10gi \
-p "{\"spec\":{\"persistentVolumeReclaimPolicy\":\"$P\"}}"
kubectl get pv pv-bookings-postgres-10gi \
-o jsonpath='{.spec.persistentVolumeReclaimPolicy}'; echo
done
# 4
kubectl describe pv pv-bookings-postgres-10gi | grep -i finalizersWhile the PV is Available the deletion is immediate: the finalizer is removed on its own. If it were Bound to a PVC, the object would stay in Terminating until the PVC disappeared. It is the volume-in-use protection, sister to the pvc-protection you will see in 05-03.
Exercise 2
| Case | capacity |
accessModes |
reclaimPolicy |
volumeMode |
Backend |
|---|---|---|---|---|---|
A. bookings-postgres pro |
200Gi | ReadWriteOncePod |
Retain |
Filesystem |
SSD block disk via CSI |
| B. Route images | 20Gi | ReadWriteMany |
Retain |
Filesystem |
NFS or cloud shared file |
C. occupancy-reports space |
500Gi | ReadWriteOnce |
Delete |
Filesystem |
Standard block disk |
Justifications:
- A. A single PostgreSQL instance:
ReadWriteOncePodguarantees that no second pod can mount it, not even on the same node, which removes the risk of corruption from two processes.Retainbecause it contains personal customer data and an accidental deletion of the PVC must not destroy it. A block disk for performance: shared files add latency that is unacceptable for a database. - B. Several readers on different nodes require
ReadWriteMany, and that forces a shared filesystem: a block disk is no use here however much you writeRWXin the YAML.Retainbecause the images are business content that is recoverable but expensive to regenerate. - C. Completely rebuildable data:
Deleteavoids paying for 500 orphaned GiB every night. In fact, case C is the perfect candidate for a generic ephemeral volume (05-01), which does not even require creating the PV.
# k8s/base/pv-route-images.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-route-images-20gi
labels:
app: web-store
app.kubernetes.io/name: route-images
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
capacity:
storage: 20Gi
volumeMode: Filesystem
accessModes:
- ReadWriteMany # the 3 web-store replicas read at once
persistentVolumeReclaimPolicy: Retain
storageClassName: rutasnorte-shared
mountOptions:
- hard
- nfsvers=4.1
nfs:
server: nas.rutasnorte.example
path: /exports/route-imagesExercise 3
# 1
minikube ssh -p rutas-norte -- "sudo mkdir -p /data/rescue && sudo chmod 777 /data/rescue"
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-rescue-practice
labels: { app: practice, environment: dev }
spec:
capacity: { storage: 1Gi }
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: { path: /data/rescue, type: DirectoryOrCreate }
EOF
# 2
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: practice-data
namespace: rutas-norte-dev
labels: { app: practice, environment: dev }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: manual
resources: { requests: { storage: 1Gi } }
EOF
kubectl get pv pv-rescue-practice
minikube ssh -p rutas-norte -- "echo 'BOOKING-2026-0815-MARTA' | sudo tee /data/rescue/witness.txt"
# 3
kubectl delete pvc practice-data -n rutas-norte-dev
kubectl get pv pv-rescue-practiceNAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
pv-rescue-practice 1Gi RWO Retain Released rutas-norte-dev/practice-data manual 2m# 4
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: practice-data
namespace: rutas-norte-dev
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: manual
resources: { requests: { storage: 1Gi } }
EOF
kubectl get pvc practice-data -n rutas-norte-dev
kubectl describe pvc practice-data -n rutas-norte-dev | tail -5It stays Pending. Why: the PV is in Released, not Available, because it keeps a spec.claimRef pointing at the previous PVC with its uid. The new PVC has the same name but a different uid, so the controller does not consider it a candidate. Besides, a PV in the Released phase is never offered for binding.
# 5
kubectl get pv pv-rescue-practice -o jsonpath='{.spec.claimRef.uid}'; echo
kubectl patch pv pv-rescue-practice \
--type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]'
kubectl get pvc practice-data -n rutas-norte-dev
minikube ssh -p rutas-norte -- "cat /data/rescue/witness.txt"NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
practice-data Bound pv-rescue-practice 1Gi RWO manual 2m
BOOKING-2026-0815-MARTAThe witness file is still intact: Retain did its job. To clean up, delete the PVC, repeat the claimRef patch and delete the PV.
Conclusion
You have taken on board the abstraction that underpins all Kubernetes storage, and the first thing you have understood is why it exists: it separates whoever administers the storage — the platform team, which writes PersistentVolumes and StorageClasses, cluster resources — from whoever consumes it — the application team, which writes a PersistentVolumeClaim in its namespace without knowing what lies underneath. Thanks to that separation, the bookings-postgres manifest is identical in minikube and in production even though underneath there may be a node directory or a cloud SSD.
You know the PV specification in full. The capacity, which is declared and not measured. The volumeMode, with Filesystem for 99% of cases and Block for whoever manages their own I/O. The four access modes, and above all the trap that costs the most dearly: ReadWriteOnce means one node, not one pod, so two pods on the same machine can write at the same time over PostgreSQL's files; the real guarantee is ReadWriteOncePod, stable since 1.29. The reclaim policy, with Retain for anything holding business data, Delete for the rebuildable — and the warning that default cloud classes come with Delete, so deleting a PVC deletes the disk — and Recycle obsolete and replaced by dynamic provisioning. And storageClassName as a matching label, mountOptions with no prior validation and nodeAffinity mandatory on local volumes, the first example of storage conditioning scheduling.
You know which backends exist and what each family really supports — hostPath and local for testing and local disks, NFS as the simple route to ReadWriteMany, and CSI drivers in production — and you are clear that writing ReadWriteMany over a block disk does not make it shared. You have mastered the phases (Available, Bound, Released, Failed) and, very particularly, the operation that always shows up in a real incident: a PV trapped in Released by its stale claimRef, rescued by removing that field with kubectl patch --type=json without losing a single byte.
And you have created the volume: pv-bookings-postgres-10gi, Retain, ReadWriteOnce, class rutasnorte-fast, waiting in the Available phase with an empty CLAIM column. That empty gap is the subject of the next lesson. A PersistentVolume does not mount itself: someone has to claim it from the application's namespace, and that someone is the object that finally makes the Rutas Norte database persistent. We build it in Persistent Volume Claims, where we will also run the test the whole module has been promising: create a booking, delete the pod and find it intact.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
