We closed module 4 with a platform that was reachable, encrypted and segmented, and with a debt written into a manifest comment back in module 2: bookings-postgres has no persistent storage. This module settles it, and it starts at the beginning, which is not the PersistentVolume but something more basic: understanding why a container's filesystem disappears and what exactly a volume is in Kubernetes. You will see that the volume is a property of the pod, not of the container, and that this distinction solves a container restart but not a pod replacement — precisely the problem the database has been dragging along. In this lesson you will demonstrate the data loss with your own hands, master the volumes/volumeMounts pair that governs every volume type in the rest of the module, and work in depth on the two types that depend on no external infrastructure: emptyDir and hostPath.
Contents
- Why a container's filesystem is ephemeral
- Demonstration: losing data in
bookings-postgres - What a volume is in Kubernetes
- The
volumesandvolumeMountspair mountPath,readOnly,subPathandsubPathExpremptyDirin detail- Sharing an
emptyDirbetween containers of the same pod hostPath: what it is and why it is dangerous- The configuration volumes:
configMap,secret,downwardAPI - The
projectedvolume: everything in one directory - Summary table of volume types
- Generic ephemeral volumes: the bridge to the rest of the module
- Why a container's filesystem is ephemeral
A container image is made up of read-only layers stacked on top of each other. When the runtime (containerd in your minikube) starts a container from postgres:16.4, it does not copy those layers: it mounts them stacked by means of a union filesystem (overlayfs) and adds a single writable layer on top, empty and exclusive to that container.
flowchart TB
subgraph CONT["Running container"]
RW["Writable layer (rw)<br/>empty at start-up<br/>DESTROYED when the container is recreated"]
end
subgraph IMG["Image postgres:16.4 (read-only, shared)"]
L3["Layer 3: PostgreSQL configuration"]
L2["Layer 2: PostgreSQL binaries"]
L1["Layer 1: Debian base system"]
end
RW --> L3 --> L2 --> L1
Everything the process writes — a new file, a change to an existing one, PostgreSQL's data files under /var/lib/postgresql/data — goes to that writable layer. And that layer has a devastating property: its lifecycle is exactly the container's. When the container is destroyed, the layer is deleted.
It is worth telling apart, with precision, two events that sound alike and are not:
| Event | What happens to the container | What happens to the writable layer |
|---|---|---|
The main process fails and restartPolicy: Always restarts it |
A new container is created in the same pod | Lost (a new, empty layer) |
kubectl delete pod or a rollout restart |
A new pod is created, with new containers | Lost |
| The node goes down and the pod is rescheduled elsewhere | New pod on another node | Lost |
kubectl exec comes and goes |
Nothing, the container stays alive | Preserved |
In other words: any restart, of whatever kind, wipes out what was written. And this is not a defect of Kubernetes; it is the very definition of a container and the reason they are lightweight and replaceable. The price is that state has to be taken out of the container explicitly, and that "outside" is the volume.
You will remember from 03-04 that this writable layer consumes the node's ephemeral-storage and that a container filling it up causes the pod to be evicted. Volumes are part of that accounting too, as we will see with emptyDir.
- Demonstration: losing data in
bookings-postgres
bookings-postgresDo it. It is the exercise that gives the whole module its meaning. We start from the current state: bookings-postgres running as a Deployment in rutas-norte-dev, with no volume at all. We create a real booking inside the database and, on top of that, an arbitrary witness file:
POD=$(kubectl get pod -n rutas-norte-dev -l app=bookings-postgres \
-o jsonpath='{.items[0].metadata.name}')
# 1. A table and a fictitious booking
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');"
# 2. A witness file in the writable layer
kubectl exec -n rutas-norte-dev "$POD" -- \
sh -c 'echo "persistence test" > /var/lib/postgresql/data/WITNESS.txt'Now we trigger what in production would be caused by a deployment, an eviction or a node failure:
kubectl delete pod -n rutas-norte-dev "$POD"
kubectl wait --for=condition=ready pod -n rutas-norte-dev \
-l app=bookings-postgres --timeout=120s
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 \
"SELECT * FROM bookings;"
kubectl exec -n rutas-norte-dev "$POD" -- cat /var/lib/postgresql/data/WITNESS.txtERROR: relation "bookings" does not exist
cat: /var/lib/postgresql/data/WITNESS.txt: No such file or directory
command terminated with exit code 1Everything is gone. The ReplicaSet did its job perfectly — there is a Running pod, the Service routes, the application starts — and yet the business has lost every booking. This is the exact point that separates a stateless workload from a stateful one: for web-store, recreating the pod is free; for bookings-postgres it is a catastrophe.
- What a volume is in Kubernetes
A volume is a directory, whose content and backing vary with its type, that Kubernetes makes available to the containers of a pod.
The formal definition matters less than these two statements, which are the ones to internalise:
- The volume is declared on the pod, not on the container. It lives in the pod's
spec.volumes. Containers merely mount it at a path, throughspec.containers[].volumeMounts. That is why two containers in the same pod can see the same volume at different paths. - The volume's lifecycle is tied to the pod, not to the container. A volume survives a container restart. What happens when the pod disappears depends on the volume type: some die with it (
emptyDir) and others do not (those backed by a PersistentVolumeClaim).
flowchart TB
subgraph POD["Pod"]
M1["Container A<br/>volumeMounts: data -> /var/lib/data"]
M2["Container B<br/>volumeMounts: data -> /input (readOnly)"]
V["volumes:<br/>- name: data<br/> emptyDir: {}"]
end
M1 --> V
M2 --> V
That second statement explains exactly how far this lesson goes. An emptyDir fixes the case "the PostgreSQL container crashed and the kubelet restarted it": the new container mounts the same directory again and finds its data. It does not fix the case "the pod was replaced", which is the one you have just demonstrated. For that you need the PersistentVolumes of 05-02.
- The
volumes and volumeMounts pair
volumes and volumeMounts pairEvery volume requires two declarations linked by name. It is the most repeated pattern in Kubernetes and it does not change whatever the type:
apiVersion: v1
kind: Pod
metadata:
name: volume-demo
namespace: rutas-norte-dev
labels: { app: volume-demo, app.kubernetes.io/part-of: rutas-norte, environment: dev }
spec:
containers:
- name: writer
image: busybox:1.36
command: ["sh", "-c", "echo hello > /data/greeting.txt && sleep 3600"]
volumeMounts: # (2) WHERE THIS container mounts it
- name: temp-space # must match volumes[].name
mountPath: /data
volumes: # (1) WHICH volume the POD has
- name: temp-space
emptyDir: {}The two blocks, precisely:
spec.volumes(pod level): a list of volumes. Each element has anamethat is unique within the pod and exactly one type key (emptyDir,hostPath,configMap,secret,persistentVolumeClaim,projected,ephemeral...). Declaring a volume that no container mounts is legal but useless; for some types (such asconfigMap) it still means the pod will not start if the referenced object does not exist.spec.containers[].volumeMounts(container level): where that volume is seen inside that particular container. Thenameis the cross-reference.
If the name does not match, the error is immediate and explicit when the pod is created:
initContainers can mount volumes too, and that is precisely the mechanism that makes the initialisation pattern studied in 06-04 useful: an init container prepares files in a volume and the main container finds them ready.
mountPath, readOnly, subPath and subPathExpr
mountPath, readOnly, subPath and subPathExprThe volumeMounts fields deserve detail because each one solves a real problem.
| Field | What it does | Watch out |
|---|---|---|
mountPath |
Absolute path inside the container where the volume appears | Hides whatever the image had at that path |
readOnly |
Mounts the volume read-only | Defaults to false; set it to true whenever you can |
subPath |
Mounts a subdirectory or a file of the volume, not its root | Does not receive ConfigMap/Secret updates |
subPathExpr |
Same as subPath but with environment variables expanded |
Mutually exclusive with subPath |
mountPropagation |
Propagation of submounts to or from the host | Only for very special cases |
mountPath hides what was there
This is the effect that surprises people most at first. If you mount a volume at /etc/nginx/conf.d in the web-store image, everything the image shipped in that directory stops being visible, just as when you mount a disk over a directory in Linux. It is not deleted: it is covered up. That is why mounting over /etc or /usr breaks the container.
subPath: mounting a single file
You already used it in 03-01 to inject a single configuration file without covering the whole directory. The same technique works for data volumes:
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
subPath: pgdata # mounts <volume>/pgdata, not the rootIn PostgreSQL's case there is a specific operational reason to do this, explained in detail in 05-03: many formatted block volumes come with a lost+found directory at their root, and PostgreSQL refuses to initialise a data directory that is not empty. In Rutas Norte we already solved it back in module 3 through the equivalent route of the PGDATA variable, and in 05-03 we will bring both approaches together.
subPathExpr: one path per pod
subPathExpr expands the container's environment variables, including those from the Downward API of 03-03. The canonical case is having each replica write into its own subdirectory of a shared volume:
- name: worker
image: registry.rutasnorte.example/notifications-worker:1.8.0
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: logs
mountPath: /var/log/notifications
subPathExpr: $(POD_NAME) # /logs/notifications-worker-abc123Each notifications-worker pod writes its logs into a subdirectory named after itself, and they never tread on each other.
emptyDir in detail
emptyDir in detailemptyDir is the simplest volume type and by far the most used.
When it is created: at the moment the pod is assigned to a node. It always starts empty, hence the name.
When it is deleted: when the pod is removed from the node, definitively and irrecoverably. Let it be crystal clear what survives it and what does not:
| Event | Does the emptyDir survive? |
|---|---|
| A container in the pod fails and restarts | Yes |
kubectl exec kills a secondary process |
Yes |
The pod is deleted (kubectl delete pod, rollout, eviction) |
No |
| The node reboots | No (the pod is recreated) |
Where it lives: by default, on the node's disk, under /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir/. You can see it in minikube:
sizeLimit
With no limit, an emptyDir can fill the node's disk and cause DiskPressure, with the eviction of unrelated pods that you studied in 03-04. It is capped with sizeLimit:
volumes:
- name: attachments
emptyDir:
sizeLimit: 500Mi # the kubelet evicts the pod if it goes overWhen the limit is exceeded, the kubelet evicts the pod (it does not return a write error to the process). The event says so bluntly:
Warning Evicted pod/notifications-worker-7c9d5-k4m2n
Usage of EmptyDir volume "attachments" exceeds the limit "500Mi".medium: Memory
With medium: Memory, the volume is backed by a tmpfs: RAM, not disk. It is blazingly fast and the content never touches a disk, which makes it ideal for sensitive material — it is exactly what Kubernetes does under the bonnet with secret volumes, as you saw in 03-02. It is declared with emptyDir: { medium: Memory, sizeLimit: 64Mi }.
The important warning: whatever you write there counts against the memory limit of the container using it. If web-store has limits.memory: 256Mi and you fill 200 MiB of tmpfs, the process is left with 56 MiB and will end up OOMKilled without any "process memory" graph explaining it. Rule: if you use medium: Memory, add it to the memory limit and always set sizeLimit. Without sizeLimit, the tmpfs can grow to half the node's RAM.
- Sharing an
emptyDir between containers of the same pod
emptyDir between containers of the same podThis is the use case that on its own justifies the existence of emptyDir, and it fits what you learned in 02-01: the containers of a pod share the network and can share volumes, but not their filesystem.
In Rutas Norte, web-store renders HTML templates of timetables and routes. We add a secondary container that generates them every five minutes from the API and leaves them in a directory; nginx simply serves them. Both see the same emptyDir, and nginx mounts it read-only.
# k8s/base/web-store-deployment.yaml (fragment: the podTemplate)
spec:
containers:
# --- Main container: serves the HTML ---
- name: nginx
image: nginx:1.27.1
ports: [{ name: http, containerPort: 8080 }]
volumeMounts:
- name: template-cache
mountPath: /usr/share/nginx/html/timetables
readOnly: true # nginx must NOT write here
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 200m, memory: 192Mi } # 128Mi process + 64Mi tmpfs
# --- Secondary container: regenerates the templates ---
- name: template-generator
image: registry.rutasnorte.example/template-generator:1.2.0
env:
- name: API_URL
value: "http://bookings-api:8080" # the Service from module 2
- name: INTERVAL_SECONDS
value: "300"
volumeMounts:
- name: template-cache
mountPath: /output # ANOTHER path, SAME volume
resources:
requests: { cpu: 25m, memory: 64Mi }
limits: { cpu: 100m, memory: 128Mi }
volumes:
- name: template-cache
# RAM: regenerated every 5 min, no disk needed
emptyDir: { medium: Memory, sizeLimit: 64Mi }The design points to read out of that manifest:
- One single volume, two different
mountPathvalues. The generator writes to/output; nginx reads from/usr/share/nginx/html/timetables. It is the same directory on the node. readOnly: trueon the consumer. If tomorrow a vulnerability allows writing through nginx, the attacker cannot alter the templates it serves. It costs one line.medium: MemorywithsizeLimit: 64Mi, and nginx'slimits.memoryraised to 192Mi to absorb it. Without that adjustment, a spike in templates would kill the container withOOMKilled.- This is not persistence. If the pod dies, the cache is lost and the generator rebuilds it on the next start-up. That is exactly the criterion for choosing
emptyDir: rebuildable data.
Verification, including proof that readOnly works:
kubectl exec -n rutas-norte-dev deploy/web-store -c template-generator -- \
sh -c 'echo "<h1>Bilbao-Santander</h1>" > /output/test.html'
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
cat /usr/share/nginx/html/timetables/test.html
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
sh -c 'echo intruder > /usr/share/nginx/html/timetables/intruder.html'<h1>Bilbao-Santander</h1>
sh: can't create /usr/share/nginx/html/timetables/intruder.html: Read-only file system
command terminated with exit code 1
hostPath: what it is and why it is dangerous
hostPath: what it is and why it is dangeroushostPath mounts a file or directory from the node's filesystem inside the pod.
The type field validates what is expected to be found at that path, and it is the difference between a clear failure and silent, unexpected behaviour:
type |
Behaviour |
|---|---|
| (empty) | No check at all. Backwards compatibility; avoid it |
Directory |
A directory must exist; otherwise the pod does not start |
DirectoryOrCreate |
If it does not exist, the kubelet creates it (permissions 0755, owned by the kubelet) |
File |
A file must exist |
FileOrCreate |
If it does not exist, it is created empty |
Socket |
A UNIX socket must exist (typical case: /var/run/docker.sock) |
CharDevice / BlockDevice |
Character or block device |
Legitimate uses, all of them infrastructure components, not applications:
Why it is a serious risk in production
hostPath breaks the isolation between the pod and the node. The consequences are of the highest severity:
- Escalation to root on the node. A pod mounting
/or/etcwith write access can add an SSH key to/root/.ssh/authorized_keys, or drop a manifest into/etc/kubernetes/manifests/that the kubelet will start as a static pod with full privileges. That is total control of the node. - Access to other pods' secrets. Under
/var/lib/kubelet/pods/are mounted the volumes of every pod on that node, including the Secrets with the credentials ofbookings-postgres. - Runtime escape. Mounting
/var/run/containerd/containerd.sock(or the Docker socket) allows privileged containers to be created without going through the Kubernetes API or RBAC. - Data that does not travel. Even if there were no security risk, a
hostPathis tied to one specific node: if the pod is rescheduled elsewhere, it finds a different (or empty) directory and the data "vanishes" with no error at all.
That is why hostPath is not a persistence solution for bookings-postgres, even though at first glance it looks like one. And that is why the Pod Security Standards at their baseline level forbid hostPath outright; you will see it enforced in 08-03, and the accompanying container hardening in 08-02.
Rutas Norte rule: no application manifest uses hostPath. If one shows up in a code review, it is rejected without discussion. The legitimate need — real persistence — is covered with a PVC.
- The configuration volumes:
configMap, secret, downwardAPI
configMap, secret, downwardAPIYou have already used all three in module 3; we put them back here because they are volumes, with the same volumes/volumeMounts mechanics, and it is worth seeing them as one family.
| Type | Source of the content | Backing | Updates by itself |
|---|---|---|---|
configMap |
Keys of a ConfigMap | Node's disk | Yes (except with subPath or immutable: true) |
secret |
Keys of a Secret | tmpfs (RAM) | Yes (except with subPath or immutable: true) |
downwardAPI |
Fields and resources of the pod itself | Node's disk | Only labels and annotations |
volumes:
- name: config
configMap:
name: bookings-api-config
defaultMode: 0444
items: # only these keys, with a chosen name
- key: application.yaml
path: app.yaml
- name: credentials
secret:
secretName: bookings-postgres-credentials
defaultMode: 0400 # read-only for the ownerTwo reminders that are often forgotten:
- Automatic content updates do not restart the process. That is why in 03-03 we added an annotation with the configuration hash to the pod, to force a
rolloutwhen it changes. - A mount with
subPathis frozen: it receives the content as of the moment of mounting and is never updated. It is the trade-off for being able to mount a single file.
- The
projected volume: everything in one directory
projected volume: everything in one directoryprojected combines several sources — configMap, secret, downwardAPI and serviceAccountToken — into a single directory. It serves to avoid filling the container with mount points and, above all, to request ServiceAccount tokens with a controlled audience and expiry, as you saw in 03-06.
volumes:
- name: identity-and-config
projected:
defaultMode: 0400
sources:
- secret:
name: bookings-postgres-credentials
items:
- key: password
path: db/password # -> /etc/rutasnorte/db/password
- configMap:
name: bookings-api-config
items:
- key: application.yaml
path: app/application.yaml
- serviceAccountToken:
path: token
expirationSeconds: 3600
audience: bookings-apiMounted at /etc/rutasnorte, the container sees a single tree: app/application.yaml, db/password and token.
Restrictions to remember: inside a projected there is no room for persistentVolumeClaim or emptyDir (only the four sources listed), and the same path cannot be repeated in two sources: the pod is rejected for conflict.
- Summary table of volume types
The types in use today on a 1.30+ cluster, with their lifecycle and their use case. The "in-tree" types of specific providers (awsElasticBlockStore, gcePersistentDisk, azureDisk...) are removed or migrated to CSI; do not write them in new manifests.
| Type | Lifecycle | Backing | Typical use case in Rutas Norte |
|---|---|---|---|
emptyDir |
Dies with the pod | Node's disk | web-store template cache, temporary files |
emptyDir + medium: Memory |
Dies with the pod | RAM (tmpfs) | Temporary sensitive data, small fast cache |
configMap |
Dies with the pod | Node's disk | application.yaml of bookings-api |
secret |
Dies with the pod | RAM (tmpfs) | bookings-postgres-credentials |
downwardAPI |
Dies with the pod | Node's disk | Pod name and environment for the logs |
projected |
Dies with the pod | Mixed | ServiceAccount token with an audience |
hostPath |
Survives the pod, tied to the node | Node's disk | Infrastructure only (agents, DaemonSets) |
local |
Survives, tied to the node, via a PV | Node's disk | High-performance local storage (05-02) |
persistentVolumeClaim |
Survives the pod | Whatever the PV provides | The data of bookings-postgres (05-03) |
ephemeral (generic) |
Dies with the pod | Whatever the StorageClass provides | Large, disposable working space |
csi (inline) |
Depends on the driver | CSI driver | Injection of external secrets (Vault, cloud managers) |
nfs |
Survives the pod | NFS server | Sharing files between several pods |
- Generic ephemeral volumes: the bridge to the rest of the module
One type remains that acts as a hinge between this lesson and the next ones: the generic ephemeral volume. It is a volume that is provisioned like a PVC — with all the power of real storage: whatever class you want, whatever size you want, expansion, snapshots — but whose lifecycle is the pod's: when the pod is deleted, the automatically created PVC is deleted with it.
volumes:
- name: reports-space
ephemeral:
volumeClaimTemplate:
metadata:
labels: { app: occupancy-reports, environment: dev }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources: { requests: { storage: 20Gi } }This is what to use when you need a lot of temporary space — more than reasonably fits on the node's disk — without wanting to keep it: generating a bulky report, batch processing, a large download. In Rutas Norte it will fit occupancy-reports when it arrives in 06-03.
The comparison closes the lesson's mental map:
emptyDir |
generic ephemeral |
persistentVolumeClaim |
|
|---|---|---|---|
| Lifecycle | Pod | Pod | Independent of the pod |
| Guaranteed size | No (cap only) | Yes | Yes |
| Storage class | Not applicable | Yes | Yes |
| Snapshots and expansion | No | Yes | Yes |
Survives delete pod |
No | No | Yes |
And with that, what is missing is laid out: for bookings-postgres we need the third column. That column is built by the five remaining lessons of the module.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
Believing emptyDir gives persistence |
Data lost after a rollout |
emptyDir dies with the pod; use a PVC (05-03) |
Different name in volumes and volumeMounts |
spec.containers[0].volumeMounts[0].name: Not found |
Check the cross-reference; they are two blocks linked by name |
| Mounting over a directory from the image | The container starts but "files are missing" | mountPath hides whatever was there; use subPath for a single file |
medium: Memory without adjusting limits.memory |
Inexplicable OOMKilled |
The tmpfs counts against the container's limit (03-04) |
emptyDir without sizeLimit |
Node in DiskPressure, unrelated pods evicted |
Always set sizeLimit |
Expecting a ConfigMap subPath to update |
The configuration changes and the file does not | subPath freezes the content; mount the directory or force a rollout |
Using hostPath for application data |
"Vanished" data when the pod is rescheduled, plus a security hole | Forbidden in applications; use a PVC |
hostPath with an empty type |
An unexpected directory is created, or the wrong thing is mounted | Always specify type |
| Mounting read-write what is only read | Unnecessary attack surface | readOnly: true by default on consumers |
Four tips that save hours:
- Before writing a volume, answer this: what happens if this is lost? If the answer is "it gets regenerated",
emptyDir. If it is "we lose business data", a PVC. There is no middle ground. - Inspect the effective volumes of a running pod, which are not always the ones you thought, with
kubectl exec ... -- df -handkubectl exec ... -- mount | grep -E "tmpfs|overlay". kubectl describe podshows theVolumes:andMounts:sections for each container: it is the quickest place to check that the mount is the one you intended and whether it isroorrw.kubectl explain pod.spec.volumeslists every type available in your cluster version, which is the source of truth against any out-of-date documentation.
Exercises
Exercise 1: demonstrate and cap ephemerality
In rutas-norte-dev, create a pod ephemeral-test with the image busybox:1.36 and two volumes: a normal emptyDir mounted at /survives-restart and another emptyDir with medium: Memory and sizeLimit: 32Mi mounted at /in-ram. Write a file in each one. Then:
- Kill the container's main process to force a restart and check what survives.
- Delete the pod, recreate it and check what survives.
- Use
mountto verify which of the two is ontmpfs. - Try to write 50 MiB into
/in-ramand describe what happens.
Exercise 2: shared cache for web-store
Write k8s/base/web-store-cache.yaml: a web-store Deployment in rutas-norte-dev with two containers sharing an emptyDir called template-cache:
nginx(imagenginx:1.27.1) mounting it at/usr/share/nginx/html/timetablesread-only.template-generator(usebusybox:1.36with a loop that writes the time to an HTML file every 30 seconds) mounting it at/outputread-write.
Labels and resources following the course conventions. Show that nginx sees what the generator writes and that it cannot write itself.
Exercise 3: code review
A colleague submits this manifest to give persistence to bookings-postgres in the pre-production cluster. Find four distinct problems and explain the consequence of each.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-postgres
namespace: rutas-norte-pre
spec:
replicas: 2
selector:
matchLabels: { app: bookings-postgres, environment: pre }
template:
metadata:
labels: { app: bookings-postgres, environment: pre }
spec:
containers:
- name: postgres
image: postgres:16.4
volumeMounts:
- name: data
mountPath: /var/lib/postgresql
volumes:
- name: data
hostPath:
path: /data/postgresSolutions
Exercise 1
# ephemeral-test.yaml
apiVersion: v1
kind: Pod
metadata:
name: ephemeral-test
namespace: rutas-norte-dev
labels:
app: ephemeral-test
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
containers:
- name: box
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
volumeMounts:
- name: on-disk
mountPath: /survives-restart
- name: in-ram
mountPath: /in-ram
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { cpu: 50m, memory: 96Mi } # 64Mi process + 32Mi of tmpfs
volumes:
- name: on-disk
emptyDir:
sizeLimit: 64Mi
- name: in-ram
emptyDir:
medium: Memory
sizeLimit: 32Mikubectl apply -f ephemeral-test.yaml
kubectl wait --for=condition=ready pod/ephemeral-test -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev ephemeral-test -- sh -c \
'echo disk > /survives-restart/f.txt; echo ram > /in-ram/f.txt'
# 3. What is on tmpfs
kubectl exec -n rutas-norte-dev ephemeral-test -- mount | grep -E "/in-ram|/survives"
# 1. CONTAINER restart: the volume survives
kubectl exec -n rutas-norte-dev ephemeral-test -- kill 1
kubectl wait --for=condition=ready pod/ephemeral-test -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev ephemeral-test -- cat /survives-restart/f.txt /in-ram/f.txttmpfs on /in-ram type tmpfs (rw,relatime,size=32768k)
/dev/vda1 on /survives-restart type ext4 (rw,relatime)
disk
ramBoth survive: the volume is tied to the pod, not to the container. Even the RAM one, because the tmpfs belongs to the pod.
# 2. POD deletion: everything is lost
kubectl delete pod ephemeral-test -n rutas-norte-dev
kubectl apply -f ephemeral-test.yaml
kubectl wait --for=condition=ready pod/ephemeral-test -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev ephemeral-test -- ls /survives-restart /in-ram
# 4. Exceeding the tmpfs sizeLimit
kubectl exec -n rutas-norte-dev ephemeral-test -- \
dd if=/dev/zero of=/in-ram/large bs=1M count=50Both directories are empty: it is exactly what happens to bookings-postgres today. And with medium: Memory the sizeLimit is the real size of the tmpfs, so the failure is immediate and inside the container. On a disk-backed emptyDir the behaviour is different: the write succeeds and it is the kubelet that, on detecting the excess, evicts the pod with the event Usage of EmptyDir volume ... exceeds the limit. Check it with kubectl get events -n rutas-norte-dev --sort-by=.lastTimestamp.
Exercise 2
# k8s/base/web-store-cache.yaml (fragment: the podTemplate)
spec:
containers:
- name: nginx
image: nginx:1.27.1
ports: [{ name: http, containerPort: 80 }]
volumeMounts:
- name: template-cache
mountPath: /usr/share/nginx/html/timetables
readOnly: true # the consumer does NOT write
resources:
requests: { cpu: 50m, memory: 96Mi }
limits: { cpu: 200m, memory: 160Mi } # 128Mi + 32Mi of tmpfs
- name: template-generator
image: busybox:1.36
command: ["sh", "-c", "while true; do
echo \"<h1>Timetables</h1><p>$(date)</p>\" > /output/timetables.html;
sleep 30; done"]
volumeMounts:
- name: template-cache
mountPath: /output # ANOTHER path, SAME volume
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { cpu: 50m, memory: 64Mi }
volumes:
- name: template-cache
emptyDir: { medium: Memory, sizeLimit: 32Mi }kubectl apply -f k8s/base/web-store-cache.yaml
kubectl rollout status deploy/web-store -n rutas-norte-dev
# nginx sees what the generator writes, and serves it over HTTP
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
curl -s localhost/timetables/timetables.html
# but it cannot write
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
sh -c 'touch /usr/share/nginx/html/timetables/x' || echo "DENIED (correct)"<h1>Rutas Norte Timetables</h1><p>Wed Aug 5 09:14:22 UTC 2026</p>
touch: cannot touch '...': Read-only file system
DENIED (correct)Exercise 3
The four problems:
| # | Problem | Consequence |
|---|---|---|
| 1 | hostPath for application data |
The data is tied to one node: if the pod is rescheduled, the database turns up empty with no error whatsoever. It is also a security hole and is forbidden by the baseline level of the Pod Security Standards (08-03) |
| 2 | replicas: 2 |
Two PostgreSQL processes writing over the same directory: data cluster corruption. PostgreSQL protects itself with a lock file, but only within the same node; on different nodes with shared storage, the corruption is real. A database like this goes to replicas: 1 and strategy: Recreate (or to a StatefulSet, 06-01) |
| 3 | mountPath: /var/lib/postgresql instead of /var/lib/postgresql/data |
It mounts one level too high and hides the content the image ships in that directory (the postgres user's home, with its configuration). The symptom is a start-up that fails or an initdb that is redone |
| 4 | Missing resources, strategy: Recreate, the app.kubernetes.io/part-of label and the hostPath type |
Without resources the pod is Burstable or BestEffort and can be evicted, when the decision from 03-05 is that it should be Guaranteed; with the default strategy (RollingUpdate) the new pod coexists with the old one over the same data; and without type the hostPath validates nothing |
The fix is not to touch up this manifest but to change mechanism: a PersistentVolumeClaim, which is what the following lessons build.
Conclusion
You have demonstrated with your own hands the problem that gives the module its name: you created a booking in bookings-postgres, deleted the pod and the booking disappeared. The cause is not a bug: it is the overlayfs writable layer, which is born empty with each container and dies with it. From there comes the rule that governs everything else: in Kubernetes, state has to be taken out of the container explicitly.
That "outside" is the volume, and you can now read it precisely. It is declared on the pod (spec.volumes), not on the container; containers only mount it (volumeMounts), and both blocks are linked by the name. You have mastered the mount fields: mountPath, which hides whatever was at that path; readOnly, which you should set by default on every consumer; subPath, to mount a single file at the price of freezing its content; and subPathExpr, to give each replica its own subdirectory.
You know in depth the two types that depend on no external infrastructure. emptyDir is born empty when the pod is assigned to a node and dies with the pod: it survives a container restart but not its replacement; it is capped with sizeLimit so as not to cause DiskPressure, and with medium: Memory it lives in RAM — fast and never touching disk, but counting against the container's memory limit. You have put it to work in the case it belongs to: the template cache shared by nginx and the generator inside the web-store pod, with the consumer read-only and the data rebuildable. And hostPath, with its type values and its clear verdict: legitimate only for infrastructure, forbidden in applications, because it breaks isolation with the node — root escalation, access to other pods' secrets, escape through the runtime socket — and because it ties the data to one specific machine. You have also put back in place the configuration volumes you were already using — configMap, secret, downwardAPI — and the projected one that gathers them into a single directory, and you have the table of every type with its lifecycle.
What you have not solved is precisely what opened the lesson: none of the volumes seen survives the replacement of the pod. We left the hinge marked with the generic ephemeral volume (ephemeral.volumeClaimTemplate), which is already provisioned as real storage even though it still dies with the pod. One last thread remains to be cut: decoupling the life of the disk from the life of the pod. That calls for a new object, with its own existence in the cluster, administered by whoever manages the infrastructure and consumed by whoever deploys the application. It is the PersistentVolume, and it is the next lesson: Persistent Volumes.
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
