We left the PersistentVolume pv-bookings-postgres-10gi in the Available phase, with an empty CLAIM column and 10 GiB waiting for someone. A PV does not mount itself: the application has to claim it, and that claim is a namespaced object called a PersistentVolumeClaim (PVC). This is the lesson where the module's debt is genuinely settled: you will write the PVC, understand precisely the algorithm by which the controller matches demand and supply, mount the volume in the bookings-postgres pod and run the test we have been promising since module 2 — create a booking, delete the pod and find it intact. You will also see what happens when you delete a PVC, why it sometimes gets stuck in Terminating, and why a Deployment with a ReadWriteOnce PVC cannot scale.
Contents
- What a PersistentVolumeClaim is, exactly
- Anatomy of the object, field by field
- The matching algorithm
- Why you can get more space than you asked for
- The
Pendingstate and its diagnosis - Consuming the PVC from the pod
- The definitive
bookings-postgresmanifest - The demonstration: the data survives the pod
- The 1:1 relationship and the danger of two pods on one RWO
- Deleting a PVC: finalizers and
Terminating - Why a Deployment with a PVC does not scale
- What a PersistentVolumeClaim is, exactly
A PersistentVolumeClaim is the storage request an application makes. The analogy that works best is the one with the compute resources you already know from 03-04:
| Compute | Storage |
|---|---|
The pod asks for requests.cpu: 500m |
The PVC asks for requests.storage: 10Gi |
| The scheduler looks for a node with capacity | The controller looks for a compatible PV |
If there is no node, the pod stays Pending |
If there is no PV, the PVC stays Pending |
| The pod does not know which machine it will get | The app does not know which disk it will get |
Its two defining properties:
- It is namespaced. It lives next to the application using it, it is affected by the namespace's ResourceQuota (03-04) and it disappears if the namespace is deleted. A pod can only mount PVCs from its own namespace: there is no way of referencing a PVC from another one.
- It is the only storage piece the application team writes. On a cluster with dynamic provisioning properly configured, a developer never writes a PersistentVolume; they write PVCs.
- Anatomy of the object, field by field
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bookings-postgres-data
namespace: rutas-norte-dev # it IS namespaced
labels:
app: bookings-postgres
app.kubernetes.io/name: bookings-postgres
app.kubernetes.io/component: database
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
accessModes: ["ReadWriteOnce"] # how I intend to mount it
volumeMode: Filesystem # Filesystem (default) or Block
resources:
requests:
storage: 10Gi # how much I ask for, AS A MINIMUM
storageClassName: rutasnorte-fast # of which class
selector: # (optional) filter PVs by labels
matchLabels: { disk-type: ssd }
# volumeName: pv-bookings-postgres-10gi # (optional) tie it to ONE specific PVThe fields, with their exact meaning:
| Field | What it means | The detail that matters |
|---|---|---|
accessModes |
Modes it is intended to be mounted in | Must be a subset of the PV's. They apply per node (05-02) |
resources.requests.storage |
The minimum acceptable capacity | You can get more. In dynamic mode it is created at exactly that size |
resources.limits.storage |
Upper cap | Rarely used; some admission controllers enforce it |
volumeMode |
Filesystem or Block |
Must match the PV's; being compatible is not enough |
storageClassName |
The class requested | "" means "no class"; omitting it means "the default class" |
selector |
Filter by PV labels | Only applies to static provisioning; dynamic ignores it |
volumeName |
Name of a specific PV | Manual binding. Skips the normal matching |
Two fields deserve a separate comment.
selector
It works just like the selectors of 02-07, with matchLabels and matchExpressions, and it applies to the PV's labels. It serves to express requirements that capacity does not capture: matchExpressions: [{ key: disk-type, operator: In, values: ["ssd", "nvme"] }] means "I want a fast disk, I do not care which".
Careful: if the PVC carries a selector, dynamic provisioning cannot satisfy it (a freshly created volume will not have those labels), so the PVC will stay Pending forever on a cluster that only uses StorageClasses. It is a field from the static world.
volumeName
It ties the PVC to a specific PV by name, skipping the search. It is useful in a recovery — "I want exactly that volume, the one with the data" — and it is the counterpart of the claimRef you saw in 05-02: if both objects name each other, the binding is deterministic and nobody else can sneak in.
- The matching algorithm
When you create a PVC without volumeName, the PersistentVolume controller (part of kube-controller-manager) looks for a candidate PV. The process, in order:
flowchart TB
A["The PVC is created<br/>status: Pending"] --> B{"Does it have volumeName?"}
B -->|"Yes"| C["Try to bind THAT PV<br/>if it is compatible"]
B -->|"No"| D["List the PVs in the Available phase"]
D --> E{"Same class? enough capacity?<br/>accessModes contained?<br/>identical volumeMode? selector matches?"}
E -->|"No"| X["Discarded"]
E -->|"Yes"| J["CANDIDATE"]
J --> K["Of all the candidates,<br/>the SMALLEST capacity"]
K --> L["Bound: claimRef is written on the PV<br/>and volumeName on the PVC"]
D --> M{"No candidates"}
M -->|"There is a StorageClass"| N["Dynamic provisioning (05-04)"]
M -->|"No class"| O["Stays Pending"]
The five criteria, with no exceptions:
- The same
storageClassName, compared as an exact string.""and absent are not the same thing. - The PV's
capacity.storage≥ the PVC'srequests.storage. Greater or equal, never less. - The PVC's
accessModesmust be contained in the PV's. If the PVC asks for["ReadWriteMany"]and the PV offers["ReadWriteOnce"], there is no deal. - Identical
volumeMode. - The PVC's
selector, if there is one, must match the PV's labels.
Among all the candidates, the controller picks the one with the smallest capacity, so as to waste as little as possible. And once decided, the binding is bidirectional and exclusive: spec.claimRef is written on the PV and spec.volumeName on the PVC. From then on both objects are married and no other PVC can use that PV, even if there is space to spare.
- Why you can get more space than you asked for
From rule 2 follows a behaviour that takes people by surprise: requests.storage is a minimum, not an exact size. If you ask for 8 GiB and the only available PV has 10 GiB, you bind to it and kubectl get pvc reports CAPACITY: 10Gi. Practical consequences:
- There is no refund. The 2 GiB of difference are lost to the rest of the cluster: nobody else can use that PV.
- The ResourceQuota counts what was requested, not what was obtained. If the namespace has a quota of
requests.storage: 20Gi(03-04), your 8 GiB PVC consumes 8 GiB of quota even though you enjoy 10. - In dynamic provisioning this does not happen: the volume is created at exactly the requested size (rounded up to the provider's minimum). That is one more reason to prefer it.
What never happens is the opposite: a PVC is never bound to a PV smaller than it asked for.
- The
Pending state and its diagnosis
Pending state and its diagnosisPending is the state you will see and diagnose most. It means one thing only: the PVC has not found a volume. The causes are few and the method for telling them apart is always the same.
The events at the end are the key:
| Event / message | Cause | Fix |
|---|---|---|
no persistent volumes available for this claim and no storage class is set |
There is no compatible PV and the PVC has no class to provision with | Create the PV or assign a StorageClass |
storageclass.storage.k8s.io "rutasnorte-fast" not found |
The named class does not exist | Correct the name or create the class (05-04) |
waiting for first consumer to be created before binding |
The class uses volumeBindingMode: WaitForFirstConsumer |
Not an error: create the pod and it will bind (05-04) |
failed to provision volume with StorageClass ... |
The provisioner failed (provider quota, permissions) | Look at the CSI provisioner logs |
exceeded quota: requests.storage |
The namespace's ResourceQuota prevents it | Adjust the quota or ask for less |
And the diagnostic procedure, in order, when the event is not enough:
# 1. Are there PVs available? Of what class, size and modes?
kubectl get pv -o custom-columns=\
NAME:.metadata.name,CAP:.spec.capacity.storage,\
MODES:.spec.accessModes,CLASS:.spec.storageClassName,PHASE:.status.phase
# 2. Does the class the PVC asks for exist?
kubectl get storageclass
# 3. The events, which nearly always say it all
kubectl describe pvc bookings-postgres-data -n rutas-norte-dev | tail -15
# 4. Is there a quota blocking it?
kubectl describe resourcequota -n rutas-norte-devA Pending PVC drags the pod with it: it stays Pending too, with the scheduler event pod has unbound immediate PersistentVolumeClaims. Always diagnose the PVC first; the pod is only the symptom.
- Consuming the PVC from the pod
In the pod, the PVC is used through a volume of type persistentVolumeClaim. The volumes/volumeMounts mechanics of 05-01 do not change at all:
containers:
- name: postgres
volumeMounts:
- name: data # (2) where
mountPath: /var/lib/postgresql/data
volumes:
- name: data # (1) what
persistentVolumeClaim:
claimName: bookings-postgres-data # the PVC, from the SAME namespace
readOnly: falseThat is all. The pod mentions the PersistentVolume nowhere, nor the provider, nor the disk: only the PVC's name. That indirection is what makes the same manifest work in minikube and in production.
- The definitive
bookings-postgres manifest
bookings-postgres manifestThe time has come to delete the "PROVISIONAL" comment we have been dragging along since 02-05. First the PVC:
# k8s/base/bookings-postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bookings-postgres-data
namespace: rutas-norte-dev
labels:
app: bookings-postgres
app.kubernetes.io/name: bookings-postgres
app.kubernetes.io/component: database
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
accessModes: ["ReadWriteOnce"]
volumeMode: Filesystem
resources: { requests: { storage: 10Gi } }
storageClassName: rutasnorte-fastNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
persistentvolumeclaim/bookings-postgres-data Bound pv-bookings-postgres-10gi 10Gi RWO rutasnorte-fast 4s
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
persistentvolume/pv-bookings-postgres-10gi 10Gi RWO Retain Bound rutas-norte-dev/bookings-postgres-data rutasnorte-fast 1hBound on both sides. The PV's CLAIM column, empty in the previous lesson, now names the PVC with its namespace. And the complete Deployment:
# k8s/base/bookings-postgres-deployment.yaml (NO LONGER PROVISIONAL)
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-postgres
namespace: rutas-norte-dev
labels:
app: bookings-postgres
app.kubernetes.io/name: bookings-postgres
app.kubernetes.io/component: database
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
replicas: 1 # ONE. See section 11
strategy:
type: Recreate # NEVER RollingUpdate over an RWO
selector:
matchLabels: { app: bookings-postgres, environment: dev }
template:
metadata:
labels:
app: bookings-postgres
app.kubernetes.io/name: bookings-postgres
app.kubernetes.io/component: database
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
securityContext:
fsGroup: 999 # 'postgres' group of the official image:
# gives the process ownership of the volume (08-02)
containers:
- name: postgres
image: postgres:16.4
ports: [{ name: postgres, containerPort: 5432 }]
env:
# username, password and database from the module 3 Secret
- name: POSTGRES_USER
valueFrom:
secretKeyRef: { name: bookings-postgres-credentials, key: username }
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef: { name: bookings-postgres-credentials, key: password }
- name: POSTGRES_DB
valueFrom:
secretKeyRef: { name: bookings-postgres-credentials, key: database }
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
subPath: pgdata # see the explanation about lost+found
resources: # Guaranteed, decided in 03-05
requests: { cpu: "1", memory: 2Gi }
limits: { cpu: "1", memory: 2Gi }
volumes:
- name: data
persistentVolumeClaim:
claimName: bookings-postgres-dataThe lost+found business and why there is a subPath
When a block volume is formatted with ext4, its root contains a lost+found directory. And PostgreSQL's initdb refuses to initialise a data directory that is not empty:
initdb: error: directory "/var/lib/postgresql/data" exists but is not empty
It contains a lost+found directory, perhaps due to it being a mount point.There are two ways of solving it, and the manifest has both, which is not redundancy but belt and braces:
| Technique | What it does | Effective path of the data |
|---|---|---|
subPath: pgdata in the volumeMount |
Mounts the volume's pgdata subdirectory, not its root |
<volume>/pgdata mounted at /var/lib/postgresql/data |
PGDATA=/var/lib/postgresql/data/pgdata |
Tells PostgreSQL to use a subdirectory of the mount point | One level further down inside the mount |
At Rutas Norte we adopt PGDATA as the main mechanism (it already came from module 3) because subPath has a serious drawback: a volume mounted with subPath is not resized automatically on some drivers, which complicates the expansion of 05-05. If you pick only one, pick PGDATA for PostgreSQL and keep subPath for the general case of other applications.
fsGroup: the other classic failure
The PostgreSQL process does not run as root, but as the postgres user (UID 999 in the official image). A freshly created volume usually belongs to root:root, so the process cannot write:
initdb: error: could not change permissions of directory
"/var/lib/postgresql/data": Operation not permittedsecurityContext.fsGroup: 999 makes the kubelet change the volume's owning group to 999 and add write permission to it on mounting. It is the standard solution and it is covered in depth in 08-02.
- The demonstration: the data survives the pod
This is the module's moment. We apply and repeat exactly the experiment of 05-01, which back then lost all the data.
kubectl apply -f k8s/base/bookings-postgres-pvc.yaml
kubectl apply -f k8s/base/bookings-postgres-deployment.yaml
kubectl rollout status deploy/bookings-postgres -n rutas-norte-devWe create a fictitious booking:
POD=$(kubectl get pod -n rutas-norte-dev -l app=bookings-postgres \
-o jsonpath='{.items[0].metadata.name}')
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d bookings -c \
"CREATE TABLE IF NOT EXISTS bookings (
id serial PRIMARY KEY, customer text, route text, date date);
INSERT INTO bookings (customer, route, date) VALUES
('Marta Iglesias', 'Bilbao-Santander', '2026-08-15'),
('Ignacio Sabater', 'Oviedo-Gijon', '2026-08-16');"And now the test. We delete the pod, just as a rollout, an eviction or a node failure would:
kubectl delete pod -n rutas-norte-dev "$POD"
kubectl wait --for=condition=ready pod -n rutas-norte-dev \
-l app=bookings-postgres --timeout=180s
POD=$(kubectl get pod -n rutas-norte-dev -l app=bookings-postgres \
-o jsonpath='{.items[0].metadata.name}')
echo "NEW pod: $POD"
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d bookings -c \
"SELECT * FROM bookings;"NEW pod: bookings-postgres-8f7c4b2d9-q7wnl
id | customer | route | date
----+-----------------+-------------------+------------
1 | Marta Iglesias | Bilbao-Santander | 2026-08-15
2 | Ignacio Sabater | Oviedo-Gijon | 2026-08-16
(2 rows)The bookings are still there, in a different pod, with another name and another IP. The module 2 debt is settled. And you can verify where the bytes really live with minikube ssh -p rutas-norte -- "sudo ls -l /data/bookings-postgres/pgdata": the files belong to UID 999 thanks to fsGroup, and they are in the node's directory, outside the container.
Raise the stakes if you like: delete the whole Deployment and apply it again. The data is still there, because the PVC and the PV do not depend on the Deployment.
kubectl delete deploy bookings-postgres -n rutas-norte-dev
kubectl get pvc -n rutas-norte-dev # still Bound
kubectl apply -f k8s/base/bookings-postgres-deployment.yaml
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;" # 2
- The 1:1 relationship and the danger of two pods on one RWO
The relationship between PVC and PV is strictly 1:1. A bound PV does not accept a second PVC even if it has 9 GiB out of 10 to spare. Check it:
kubectl create -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: other-claimant, namespace: rutas-norte-dev }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-fast
resources: { requests: { storage: 1Gi } }
EOF
kubectl get pvc other-claimant -n rutas-norte-devNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
other-claimant Pending rutasnorte-fast 10sPending: the only PV of that class is Bound. Delete it and let us carry on.
Now the dangerous part. Several pods can indeed mount the same PVC, and that is where the trap of 05-02 reappears:
| Scenario | With ReadWriteOnce |
With ReadWriteOncePod |
|---|---|---|
| Two pods on the same node | It works. Both write at once → risk of corruption | The second stays Pending |
| Two pods on different nodes | The second does not start: Multi-Attach error |
The second stays Pending |
The error in the second case, which you will see as soon as you work with a multi-node cluster:
Warning FailedAttachVolume pod/bookings-postgres-8f7c4b2d9-k3mp2
Multi-Attach error for volume "pvc-3a9f..." Volume is already exclusively
attached to one node and can't be attached to anotherThat message is a protection, not a breakdown: the attach controller prevents a block disk from being attached to two machines. The serious case is the first one, where nobody protects you. That is why, for any stateful workload on ReadWriteOnce, the mandatory combination at Rutas Norte is:
replicas: 1strategy: Recreate(terminates the old pod before creating the new one)- and, if the driver supports it,
ReadWriteOncePodon both PV and PVC.
- Deleting a PVC: finalizers and
Terminating
TerminatingDeleting a PVC in use is an operation that almost never goes as the beginner expects, and for good reasons.
The command hangs. And in another terminal, the explanation:
kubectl get pvc bookings-postgres-data -n rutas-norte-dev
kubectl get pvc bookings-postgres-data -n rutas-norte-dev \
-o jsonpath='{.metadata.finalizers}'; echoNAME STATUS VOLUME CAPACITY ACCESS MODES AGE
bookings-postgres-data Terminating pv-bookings-postgres-10gi 10Gi RWO 2h
["kubernetes.io/pvc-protection"]kubernetes.io/pvc-protection is the storage-object-in-use protection. While an active pod is using the PVC, the finalizer is not removed and the object is not deleted. It is the finalizer mechanism of 01-06 applied to preventing someone from blowing away the storage of a running application.
The fix is to remove the consumer first, never to force the finalizer:
kubectl delete deploy bookings-postgres -n rutas-norte-dev
kubectl get pvc -n rutas-norte-dev # now it does disappearNever run
kubectl patch pvc ... -p '{"metadata":{"finalizers":null}}'. It is the recipe doing the rounds on the internet for "unsticking" a PVC and what it achieves is leaving the real volume attached to a node with no object representing it: an invisible orphan that has to be cleaned up by hand at the provider.
And what happens to the PV
When the PVC disappears, the PV's fate is decided by its reclaim policy (05-02):
| PV policy | On deleting the PVC | Data? |
|---|---|---|
Retain |
The PV goes to Released, with its stale claimRef |
Intact. Rescuable with kubectl patch |
Delete |
The PV and the real volume are deleted | Destroyed |
In our case the PV is left Released, with the CLAIM column still showing rutas-norte-dev/bookings-postgres-data, and the bookings inside. To get back to the operational situation, you apply the rescue you already know and recreate the PVC and the Deployment:
kubectl patch pv pv-bookings-postgres-10gi \
--type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]'
kubectl apply -f k8s/base/bookings-postgres-pvc.yaml
kubectl apply -f k8s/base/bookings-postgres-deployment.yaml
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;" # 2That is the whole point of Retain on a database holding personal data. With Delete, this exercise would have ended with the loss of every Rutas Norte booking.
- Why a Deployment with a PVC does not scale
The last piece, and it is a fundamental limitation, not a detail. Try scaling:
kubectl scale deploy bookings-postgres -n rutas-norte-dev --replicas=3
kubectl get pods -n rutas-norte-dev -l app=bookings-postgresOn a multi-node cluster you would see two pods stuck with Multi-Attach error. In minikube, which has a single node, you would see something worse: all three pods start, mount the same data directory and PostgreSQL starts complaining that there is already an instance using that directory (or, with another, less protected engine, it corrupts the files silently).
The reason is structural: all the pods in a Deployment share exactly the same template, including the volumes list. If the template says claimName: bookings-postgres-data, all three replicas ask for that same PVC. A Deployment does not know how to give each replica its own volume.
flowchart TB
subgraph DEP["Deployment: one template for all"]
P1["pod-abc"] --> PVC1["PVC bookings-postgres-data"]
P2["pod-def"] --> PVC1
P3["pod-ghi"] --> PVC1
PVC1 --> PV1["ONE single PV"]
end
subgraph STS["StatefulSet: volumeClaimTemplate (06-01)"]
S0["bookings-postgres-0"] --> C0["PVC data-...-0"] --> V0["its own PV"]
S1["bookings-postgres-1"] --> C1["PVC data-...-1"] --> V1["its own PV"]
end
The correct solution is the StatefulSet, which incorporates a volumeClaimTemplate: it creates one PVC per replica, with a stable name tied to the pod's identity, so that bookings-postgres-0 always finds its volume again. It is the object that opens module 6 in 06-01, and now you know exactly what problem it comes to solve.
In the meantime, the operational rule for Rutas Norte is clear and sufficient: bookings-postgres is a single-replica Deployment with strategy: Recreate and a ReadWriteOnce PVC. Put it back where it belongs:
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
A Pending PVC without looking at the events |
Hours lost | kubectl describe pvc first; the event nearly always says it |
Confusing storageClassName: "" with omitting it |
The PVC does not see the static PV, or uses an unexpected class | "" = no class; absent = default class (05-04) |
Setting a selector with dynamic provisioning |
Eternal Pending |
The selector only works with static PVs |
Mounting the volume at /var/lib/postgresql |
The container does not start | That is one level too high: use /var/lib/postgresql/data |
Forgetting PGDATA or subPath |
directory exists but is not empty ... lost+found |
Use a subdirectory of the mount point |
Forgetting fsGroup |
could not change permissions ... Operation not permitted |
securityContext.fsGroup with the process's GID |
RollingUpdate with an RWO PVC |
Two pods writing, or the new one stuck | strategy: Recreate |
| Scaling a Deployment with a PVC | Multi-Attach error or silent corruption |
One replica, or a StatefulSet (06-01) |
| Forcing the deletion by removing the finalizer | Orphaned volume attached to a node | Delete the pod using it and let the finalizer be removed on its own |
| Assuming that deleting the PVC keeps the data | With Delete, it is lost |
Check the PV's policy before deleting anything |
Tips:
- Go-to command for seeing supply and demand at once:
kubectl get pvc -A -o custom-columns=\ NS:.metadata.namespace,PVC:.metadata.name,STATE:.status.phase,\ PV:.spec.volumeName,REQUESTED:.spec.resources.requests.storage,CLASS:.spec.storageClassName - Find out who is using a PVC before deleting it, filtering
kubectl get pods -o jsonwithjqon.spec.volumes[]?.persistentVolumeClaim.claimName. - Name PVCs by their function, not their size:
bookings-postgres-data, notpvc-10gi. The size changes with expansion (05-05); the function does not. - Ask with room to spare but without exaggerating. In static provisioning you bind to the smallest PV that serves you; in dynamic you create exactly what you asked for and you will be able to expand it later, but never shrink it.
Exercises
Exercise 1: the complete persistence test
Apply the definitive bookings-postgres PVC and Deployment from section 7 in rutas-norte-dev and demonstrate persistence at three increasing levels:
- Insert three fictitious bookings and remove them from the pod with
kubectl delete pod. - Delete the whole Deployment and apply it again.
- Delete the PVC (having deleted the Deployment first) and recover the data by rescuing the PV.
Document the state of the PVC and of the PV at each step.
Exercise 2: diagnose four stuck PVCs
Create these four PVCs in rutas-norte-dev knowing that the only available PV is pv-bookings-postgres-10gi (10Gi, RWO, class rutasnorte-fast, label disk-type: ssd) and that it is already Bound. For each one, predict whether it will end up Bound or Pending and why; then check it.
| PVC | accessModes |
storageClassName |
requests.storage |
|---|---|---|---|
| A | ["ReadWriteOnce"] |
rutasnorte-fast |
5Gi |
| B | ["ReadWriteMany"] |
rutasnorte-fast |
5Gi |
| C | ["ReadWriteOnce"] |
"" |
5Gi |
| D | ["ReadWriteOnce"] |
rutasnorte-fast |
50Gi |
Exercise 3: the redis-cache PVC
The team wants redis-cache to keep its RDB dump between restarts so as not to lose the seat-availability cache on every deployment. Write the PVC (redis-cache-data, 2 GiB) and the Deployment fragment that mounts it at /data, with the Rutas Norte labels.
Then answer, with reasoning: is it a good decision? Bear in mind what was decided in 03-05 about the nature of redis-cache.
Solutions
Exercise 1
kubectl apply -f k8s/base/bookings-postgres-pvc.yaml
kubectl apply -f k8s/base/bookings-postgres-deployment.yaml
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- psql -U rutasnorte -d bookings -c \
"CREATE TABLE IF NOT EXISTS bookings (id serial PRIMARY KEY, customer text, route text, date date);
INSERT INTO bookings (customer, route, date) VALUES
('Marta Iglesias','Bilbao-Santander','2026-08-15'),
('Ignacio Sabater','Oviedo-Gijon','2026-08-16'),
('Lucia Berenguer','Leon-Ponferrada','2026-08-17');"
# --- Level 1: delete the pod ---
kubectl delete pod -n rutas-norte-dev -l app=bookings-postgres
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;" # 3
kubectl get pvc,pv -n rutas-norte-dev # PVC Bound, PV Bound
# --- Level 2: delete the Deployment ---
kubectl delete deploy bookings-postgres -n rutas-norte-dev
kubectl get pvc -n rutas-norte-dev # Bound: the PVC outlives the Deployment
kubectl apply -f k8s/base/bookings-postgres-deployment.yaml
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev # 3 bookings
# --- Level 3: delete the PVC ---
kubectl delete deploy bookings-postgres -n rutas-norte-dev
kubectl delete pvc bookings-postgres-data -n rutas-norte-dev
kubectl get pv # Released (thanks to Retain)
kubectl patch pv pv-bookings-postgres-10gi \
--type=json -p='[{"op":"remove","path":"/spec/claimRef"}]'
kubectl get pv # Available
kubectl apply -f k8s/base/bookings-postgres-pvc.yaml
kubectl apply -f k8s/base/bookings-postgres-deployment.yaml
kubectl rollout status deploy/bookings-postgres -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT * FROM bookings;" # 3 rowsSummary of states:
| Step | PVC | PV | Data |
|---|---|---|---|
| Initial | Bound |
Bound |
3 bookings |
| After deleting the pod | Bound |
Bound |
3 bookings |
| After deleting the Deployment | Bound |
Bound |
3 bookings |
| After deleting the PVC | does not exist | Released |
3 bookings, inaccessible |
| After the rescue | Bound |
Bound |
3 bookings |
Had the policy been Delete, the fourth step would have destroyed the data for good.
Exercise 2
| PVC | Prediction | Reason |
|---|---|---|
| A | Pending |
The only PV of that class is Bound. The relationship is 1:1: the 5 spare GiB are not reusable |
| B | Pending |
Even if the PV were free, it asks for ReadWriteMany and the PV only offers ReadWriteOnce: the PVC's modes must be contained in the PV's |
| C | Pending |
storageClassName: "" only matches PVs without a class; ours has rutasnorte-fast |
| D | Pending |
It asks for 50 GiB and the PV has 10: the PV's capacity must be greater than or equal |
All four stay Pending, each for a different reason, and for x in a b c d; do kubectl describe pvc test-$x -n rutas-norte-dev | tail -3; done confirms it with the same generic event FailedBinding: no persistent volumes available for this claim and no storage class is set — a reminder that the event says that there is no candidate, but not why: that you have to reason out yourself with the five criteria.
For A to bind, the PV would first have to be freed (delete the bookings-postgres PVC and clean up the claimRef), which illustrates the central limitation of static provisioning that 05-04 solves.
Exercise 3
# k8s/base/redis-cache-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-cache-data
namespace: rutas-norte-dev
labels:
app: redis-cache
app.kubernetes.io/name: redis-cache
app.kubernetes.io/component: cache
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
accessModes: ["ReadWriteOnce"]
volumeMode: Filesystem
resources: { requests: { storage: 2Gi } }
storageClassName: rutasnorte-standard
---
# fragment of the redis-cache Deployment
spec:
securityContext:
fsGroup: 999 # redis user of the official image
containers:
- name: redis
image: redis:7.2-alpine
args: ["--save", "60", "1", "--appendonly", "no"]
ports: [{ name: redis, containerPort: 6379 }]
volumeMounts:
- name: data
mountPath: /data # RDB path in the official image
volumes:
- name: data
persistentVolumeClaim: { claimName: redis-cache-data }Is it a good decision? It is acceptable, but it is not a priority and it has trade-offs. In 03-05 we classified redis-cache as Burstable precisely because it is expendable: what it holds — the cached seat availability — is recalculated by querying bookings-postgres. What is lost on a restart is not information, it is a spell of higher latency.
In favour: after a deployment, the cache starts warm and bookings-postgres does not get an avalanche of queries; on a bank-holiday weekend, that recalculation spike can be considerable.
Against, three things: the PVC turns it into a stateful workload, with replicas: 1 and Recreate compulsory; a persisted cache can serve stale data if the RDB is hours old and seat availability has changed, which is worse than having no cache; and if Redis holds any personal customer data, that volume falls within the scope of data protection and of the backups of 05-06.
Rutas Norte's decision: do not persist redis-cache. If the real problem is the query spike after a deployment, the correct solution is a controlled cache warm-up at start-up, not keeping potentially obsolete availability data.
Conclusion
The debt Rutas Norte had been dragging since module 2 is settled. You have written the PersistentVolumeClaim — the namespaced object that expresses the application's demand and the only storage piece an application team writes —, you have bound it to the previous lesson's PV and you have watched the CLAIM column fill up with Bound on both sides. And above all you have run the test: three bookings inserted, the pod deleted, the Deployment deleted, and the bookings intact in a new pod with another name and another IP. The bytes, verifiable on the node, live outside the container.
You know how that binding comes about. The matching algorithm demands five conditions — the same exact class, sufficient capacity, the PVC's access modes contained in the PV's, an identical volumeMode and a selector that matches — and, among the candidates, it picks the smallest, from which comes the behaviour that surprises people: requests.storage is a minimum and you can get more space than you asked for, with no refund possible and without the quota reflecting it. When there is no candidate, the PVC stays Pending, and you no longer diagnose that blindly: kubectl describe pvc and its events tell apart in seconds "there is no compatible PV", "the class does not exist", "waiting for the first consumer" — which is not an error — and "quota exceeded".
You have mastered the definitive database manifest and the three traps around it: the right mountPath (/var/lib/postgresql/data, not one level higher), the subdirectory via PGDATA or subPath that avoids the lost+found failure, and the fsGroup without which the non-root process cannot write to its own volume. You know the 1:1 relationship between PVC and PV — a bound PV does not accept a second claimant even with space to spare — and the difference between the two ways of breaking with ReadWriteOnce: on different nodes the Multi-Attach error protects you; on the same node nobody protects you, and that is why replicas: 1 with strategy: Recreate is not a preference but a requirement. And you know how to delete without destroying: the kubernetes.io/pvc-protection finalizer leaves the PVC in Terminating while a pod is using it, it is resolved by removing the consumer and never by forcing the finalizer; and what happens to the PV afterwards is decided by its reclaim policy, where Retain has let you recover the bookings from a deletion that with Delete would have been final.
The ceiling of this architecture has been noted: a Deployment has a single template, so all its replicas ask for the same PVC and therefore it does not scale; the answer is the volumeClaimTemplate of StatefulSets (06-01). And there is the other ceiling, the one that opened this lesson: we are still creating PersistentVolumes by hand, one by one, with the waste, the manual work and the orphans that entails. The solution is for the cluster to create the exact volume at the moment someone asks for it, and the object that makes it possible is the one we have spent three lessons naming in storageClassName without explaining: Storage Classes.
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
