Skip to content

Module 4.1: Volumes

Hands-On Lab Available
K8s Cluster intermediate 35 min
Launch Lab ↗

Opens in Killercoda in a new tab

Complexity: [MEDIUM] - Foundation for all storage concepts

Time to Complete: 35-45 minutes

Prerequisites: Module 2.1 (Pods), Module 2.7 (ConfigMaps & Secrets)


After this module, you will be able to:

  • Configure emptyDir, hostPath, and projected volumes and explain when to use each
  • Mount volumes into containers at specific paths with read-only or read-write access
  • Explain volume lifecycle (tied to pod, not container) and data persistence guarantees
  • Debug volume mount failures by checking events, paths, and permissions

Containers are ephemeral - when they restart, all data is lost. Volumes solve this problem by providing persistent or shared storage that outlives container restarts. On the CKA exam, you’ll need to configure various volume types to share data between containers, cache temporary files, and inject configuration.

The Filing Cabinet Analogy

Think of a container as a desk with drawers that get emptied every time you leave work. A volume is like a filing cabinet in the corner - it keeps your files even when you’re gone. Some cabinets are shared between desks (emptyDir), some are building-wide storage (PV), and some are just mirrors of the company directory (configMap/secret projected volumes).


By the end of this module, you’ll be able to:

  • Understand why volumes are needed
  • Use emptyDir for temporary shared storage
  • Use hostPath for node-level storage (and know its risks)
  • Use projected volumes for configuration injection
  • Understand volume lifecycle and when data persists

  • emptyDir in memory: You can back emptyDir with RAM instead of disk using medium: Memory - great for sensitive temp files that should never hit disk
  • Projected volumes combine 4 sources: configMap, secret, downwardAPI, and serviceAccountToken can all be projected into a single directory
  • hostPath is banned in production: Most security policies (including Pod Security Standards) block hostPath because it exposes the node filesystem to pods

Containers have an isolated filesystem. When a container crashes and restarts:

┌──────────────────────────────────────────────────────────────┐
│ Container Restart = Data Loss │
│ │
│ Container v1 Container v2 (after restart) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /app │ │ /app │ │
│ │ ├── config.yml │ ──→ │ ├── config.yml │ (from image) │
│ │ └── data/ │ │ └── data/ │ │
│ │ └── cache │ │ └── (empty!)│ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Runtime data is GONE after restart │
└──────────────────────────────────────────────────────────────┘

Volumes provide storage that exists outside the container’s filesystem:

┌──────────────────────────────────────────────────────────────┐
│ With Volumes = Data Persists │
│ │
│ Container v1 Container v2 (after restart) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /app │ │ /app │ │
│ │ ├── config.yml │ │ ├── config.yml │ │
│ │ └── data/ ──────┼───┐ │ └── data/ ──────┼───┐ │
│ └─────────────────┘ │ └─────────────────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Volume (shared) │ │
│ │ └── cache (still here!) │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Volume TypeLifetimeUse CaseData Persistence
emptyDirPod lifetimeTemp storage, inter-container sharingLost when pod deleted
hostPathNode lifetimeNode-level data, DaemonSetsPersists on node
configMapConfigMap lifetimeConfig filesManaged by ConfigMap
secretSecret lifetimeCredentialsManaged by Secret
projectedSource lifetimeMultiple sources in one mountDepends on sources
persistentVolumeClaimPV lifetimePersistent dataSurvives pod deletion
imageImage lifetimeOCI image content as read-only volume (K8s 1.35+)Read-only, pulled from registry

New in K8s 1.35: Image Volumes (GA)

Image volumes let you pull an OCI image from a registry and mount its contents directly as a read-only volume. No init containers or bootstrap scripts needed. Perfect for ML models, config bundles, or static assets:

volumes:
- name: model-data
image:
reference: registry.example.com/ml-models/bert:v2
pullPolicy: IfNotPresent

An emptyDir volume is created when a pod is assigned to a node and exists as long as the pod runs on that node. It starts empty, hence the name.

Key characteristics:

  • Created when pod starts on a node
  • Deleted when pod is removed from node (any reason)
  • Shared between all containers in the pod
  • Can be backed by disk or memory (tmpfs)
apiVersion: v1
kind: Pod
metadata:
name: shared-data
spec:
containers:
- name: writer
image: busybox
command: ['sh', '-c', 'echo "Hello from writer" > /data/message; sleep 3600']
volumeMounts:
- name: shared-storage
mountPath: /data
- name: reader
image: busybox
command: ['sh', '-c', 'sleep 5; cat /data/message; sleep 3600']
volumeMounts:
- name: shared-storage
mountPath: /data
volumes:
- name: shared-storage
emptyDir: {}

For sensitive temporary data or faster I/O:

apiVersion: v1
kind: Pod
metadata:
name: memory-backed
spec:
containers:
- name: app
image: busybox
command: ['sh', '-c', 'sleep 3600']
volumeMounts:
- name: tmpfs-volume
mountPath: /cache
volumes:
- name: tmpfs-volume
emptyDir:
medium: Memory # Uses RAM instead of disk
sizeLimit: 100Mi # Important! Limit memory usage

When to use Memory-backed emptyDir:

  • Temporary credentials that shouldn’t touch disk
  • High-speed caching
  • Scratch space for computation

Warning: Memory-backed volumes count against the container’s memory limit!

volumes:
- name: cache
emptyDir:
sizeLimit: 500Mi # Limit disk usage

If the pod exceeds the size limit, it will be evicted.

Pause and predict: A pod has two containers sharing an emptyDir volume. Container A writes 200Mi of cache data, then crashes. The kubelet restarts Container A on the same node. Is the 200Mi of data still there? What if the entire pod gets evicted?


hostPath mounts a file or directory from the host node’s filesystem into the pod.

┌─────────────────────────────────────────────────────────────┐
│ Node │
│ │
│ Node Filesystem Pod │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ /var/log/ │ │ Container │ │
│ │ └── pods/ │◄──────│ /host-logs/ ◄────┐ │ │
│ │ └── *.log │ │ │ │ │
│ └─────────────────┘ │ volumeMounts: │ │ │
│ │ mountPath: │ │ │
│ /data/ │ /host-logs ──┘ │ │
│ └── myapp/ ◄───────│ │ │
│ └── config │ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
apiVersion: v1
kind: Pod
metadata:
name: hostpath-example
spec:
containers:
- name: app
image: busybox
command: ['sh', '-c', 'ls -la /host-data; sleep 3600']
volumeMounts:
- name: host-volume
mountPath: /host-data
readOnly: true # Good practice for security
volumes:
- name: host-volume
hostPath:
path: /var/log # Path on the node
type: Directory # Must be a directory
TypeBehavior
"" (empty)No checks before mount
DirectoryOrCreateCreate directory if missing
DirectoryMust exist, must be directory
FileOrCreateCreate file if missing
FileMust exist, must be file
SocketMust exist, must be UNIX socket
CharDeviceMust exist, must be char device
BlockDeviceMust exist, must be block device

Why hostPath is dangerous:

# DANGEROUS - Never do this in production!
volumes:
- name: root-access
hostPath:
path: / # Access to entire node filesystem!
type: Directory

Risks:

  • Container escape to node
  • Reading sensitive node files (/etc/shadow, kubelet creds)
  • Writing malicious files to node
  • Modifying node configuration

Safe uses of hostPath:

  • DaemonSets that need node access (log collectors, monitoring agents)
  • Node-level debugging (temporary)
  • Docker socket access for CI/CD (use with extreme caution)

Stop and think: A developer proposes mounting hostPath: /var/run/docker.sock into their CI/CD pod to build container images. What specific security risks does this create, and what alternative approaches would you recommend?

Legitimate hostPath use - log collection:

apiVersion: apps/v1
kind: DaemonSet
metadata:
name: log-collector
spec:
selector:
matchLabels:
name: log-collector
template:
metadata:
labels:
name: log-collector
spec:
containers:
- name: collector
image: fluentd:latest
volumeMounts:
- name: varlog
mountPath: /var/log
readOnly: true # Read-only for safety
- name: containers
mountPath: /var/lib/docker/containers
readOnly: true
volumes:
- name: varlog
hostPath:
path: /var/log
type: Directory
- name: containers
hostPath:
path: /var/lib/docker/containers
type: Directory

Projected volumes combine multiple volume sources into a single directory. This is useful when you need configMaps, secrets, and downwardAPI data in one location.

┌──────────────────────────────────────────────────────────────┐
│ Projected Volume │
│ │
│ Sources: Mount Point: │
│ ┌─────────────┐ /etc/config/ │
│ │ ConfigMap A │─────┐ ├── app.conf (from A) │
│ └─────────────┘ │ ├── db.conf (from A) │
│ ┌─────────────┐ │ ├── password (from B) │
│ │ Secret B │─────┼────────→ ├── api-key (from B) │
│ └─────────────┘ │ ├── labels (from C) │
│ ┌─────────────┐ │ └── annotations (from C) │
│ │ DownwardAPI │─────┘ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
apiVersion: v1
kind: Pod
metadata:
name: projected-volume-demo
labels:
app: demo
version: v1
spec:
containers:
- name: app
image: busybox
command: ['sh', '-c', 'ls -la /etc/config; sleep 3600']
volumeMounts:
- name: all-config
mountPath: /etc/config
readOnly: true
volumes:
- name: all-config
projected:
sources:
# Source 1: ConfigMap
- configMap:
name: app-config
items:
- key: app.properties
path: app.conf
# Source 2: Secret
- secret:
name: app-secrets
items:
- key: password
path: credentials/password
# Source 3: Downward API
- downwardAPI:
items:
- path: labels
fieldRef:
fieldPath: metadata.labels
- path: cpu-limit
resourceFieldRef:
containerName: app
resource: limits.cpu

Modern Kubernetes uses projected service account tokens instead of legacy tokens:

apiVersion: v1
kind: Pod
metadata:
name: service-account-projection
spec:
serviceAccountName: my-service-account
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # Short-lived token
audience: api # Intended audience
Use CaseSources Combined
App config bundleconfigMap + secret
Pod identityserviceAccountToken + downwardAPI
Full config injectionconfigMap + secret + downwardAPI
Sidecar configMultiple configMaps

apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
nginx.conf: |
server {
listen 80;
location / {
root /usr/share/nginx/html;
}
}
---
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
volumeMounts:
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
volumes:
- name: config
configMap:
name: nginx-config
# Optional: select specific keys
items:
- key: nginx.conf
path: default.conf # Rename the file
apiVersion: v1
kind: Secret
metadata:
name: tls-certs
type: kubernetes.io/tls
data:
tls.crt: <base64-encoded-cert>
tls.key: <base64-encoded-key>
---
apiVersion: v1
kind: Pod
metadata:
name: tls-app
spec:
containers:
- name: app
image: nginx:1.25
volumeMounts:
- name: certs
mountPath: /etc/tls
readOnly: true
volumes:
- name: certs
secret:
secretName: tls-certs
defaultMode: 0400 # Restrictive permissions

5.3 Auto-Updates for ConfigMap/Secret Volumes

Section titled “5.3 Auto-Updates for ConfigMap/Secret Volumes”

When mounted as volumes (not env vars), ConfigMaps and Secrets update automatically:

┌──────────────────────────────────────────────────────────────┐
│ Update Behavior │
│ │
│ ConfigMap/Secret updated ───→ Volume updated (within ~1 min) │
│ │
│ ⚠️ Caveats: │
│ • Uses atomic symlink swap │
│ • subPath mounts do NOT auto-update │
│ • Application must detect and reload │
│ • kubelet sync period affects delay │
└──────────────────────────────────────────────────────────────┘

When you need to mount a single file into an existing directory:

volumeMounts:
- name: config
mountPath: /etc/nginx/nginx.conf # Single file
subPath: nginx.conf # Key from ConfigMap
readOnly: true

Warning: subPath mounts don’t receive updates when the ConfigMap/Secret changes!

Pause and predict: You mount a ConfigMap as a volume at /etc/config (without subPath). You then update the ConfigMap with kubectl edit. After a minute, you exec into the pod and cat /etc/config/app.conf. Do you see the old or new content? Now imagine you used a subPath mount instead — does your answer change?


MistakeProblemSolution
emptyDir for persistent dataData lost when pod deletedUse PersistentVolumeClaim
hostPath in productionSecurity vulnerabilityUse PVC or avoid entirely
No sizeLimit on emptyDirPod can fill node diskAlways set sizeLimit
subPath expecting updatesConfig changes not reflectedUse full mount or restart pod
Memory emptyDir without limitOOM killsSet sizeLimit, count against memory
hostPath type: ""No validation, silent failuresUse explicit type like Directory

A developer has a sidecar logging pod with two containers: a writer that produces logs to /logs/app.log and a reader that tails the log file. They use an emptyDir volume. The writer container crashes due to an OOM kill, but the pod stays running. The developer panics and says all logs are gone. Are they correct? What if the node itself reboots and the pod is rescheduled to a different node?

Answer

The developer is wrong about the first scenario. When a container crashes but the pod stays running, emptyDir data persists because emptyDir lifetime is tied to the pod, not individual containers. The kubelet restarts the container, and /logs/app.log is still there. However, if the node reboots and the pod is rescheduled to a different node, the emptyDir data is lost because emptyDir storage lives on the original node’s filesystem (or RAM). For logs that must survive pod rescheduling, they should use a PersistentVolumeClaim instead.

A team deploys a pod with emptyDir.medium: Memory and sizeLimit: 256Mi. The pod’s container has resources.limits.memory: 512Mi. During a load test, the container writes 200Mi of temp data to the emptyDir. Shortly after, the pod gets OOM-killed despite the application itself only using 350Mi of heap memory. What happened?

Answer

Memory-backed emptyDir counts against the container’s memory limit. The container was using 350Mi of heap memory plus 200Mi of tmpfs data in the emptyDir, totaling 550Mi — which exceeds the 512Mi memory limit. The kubelet saw total memory usage exceed the limit and OOM-killed the pod. The fix is to either increase the memory limit to account for emptyDir usage (e.g., 768Mi), reduce the sizeLimit on the emptyDir, or switch to disk-backed emptyDir if the data is not sensitive and speed is not critical.

During a security audit, a DaemonSet for log collection is flagged. It mounts hostPath: / with type "" (empty string) and no readOnly setting. The team argues they only read /var/log. Why did the auditor flag this, and how should the DaemonSet be reconfigured?

Answer

The auditor flagged it for three reasons: (1) mounting / gives the pod access to the entire node filesystem, including /etc/shadow, kubelet credentials, and other sensitive files — far more than /var/log; (2) type "" performs no validation, so the mount could follow symlinks or mount unexpected paths; (3) without readOnly: true, the container can write to the node filesystem, enabling container escape attacks. The fix: mount only the specific paths needed (/var/log and /var/lib/docker/containers), use type Directory for validation, and set readOnly: true on both volume mounts.

Your application needs its config file (app.conf), a database password, the pod’s own labels, and a short-lived service account token — all in /etc/app/. A junior developer creates four separate volume mounts. What single Kubernetes feature replaces all four, and what are the four source types it combines?

Answer

A projected volume combines all four sources into a single mount point. The four source types are: (1) configMap for the app.conf file, (2) secret for the database password, (3) downwardAPI for the pod’s labels, and (4) serviceAccountToken for a short-lived, audience-scoped token. This is cleaner than four separate mounts because it presents a unified /etc/app/ directory and simplifies the pod spec. Projected service account tokens are also more secure than legacy tokens because they are time-limited and audience-bound.

A team mounts a ConfigMap as a volume at /etc/nginx/conf.d/. They update the ConfigMap with a new nginx.conf. After 2 minutes, they exec into the pod and see the new config file. But nginx is still serving the old configuration. Separately, another team member used subPath to mount a single ConfigMap key as /etc/app/settings.yaml. After the same ConfigMap update, that file still shows the old content. Explain both behaviors and the fix for each.

Answer

For the nginx case: Kubernetes did auto-update the mounted files (within ~1 minute via atomic symlink swap), but nginx does not watch for file changes — it loads config at startup. The fix is to either restart the pod (kubectl rollout restart) or send nginx a reload signal (nginx -s reload). For the subPath case: subPath mounts never auto-update when the source ConfigMap changes — this is a fundamental limitation. The kubelet’s symlink-swap mechanism only works for full directory mounts. The fix is to either use a full directory mount instead of subPath, or restart the pod to pick up changes. This is a common exam trap — knowing the subPath update limitation is critical.

A StatefulSet’s pods keep losing their data on restart. The developer used emptyDir volumes for the database data directory. What is wrong with this design, what volume type should they use instead, and what Kubernetes resources need to be created to support it?

Answer

emptyDir is tied to the pod’s lifecycle — when the pod is deleted or rescheduled, the data is gone. For a database that needs persistent data, they should use a PersistentVolumeClaim instead. For StatefulSets specifically, they should use volumeClaimTemplates in the StatefulSet spec, which automatically creates a unique PVC for each replica. The required resources are: a StorageClass (or use the cluster default) to enable dynamic provisioning, and the volumeClaimTemplates section in the StatefulSet spec. Each pod will get its own PV that persists across restarts and rescheduling. The reclaim policy should be set to Retain for production databases to prevent accidental data loss.


Hands-On Exercise: Multi-Container Volume Sharing

Section titled “Hands-On Exercise: Multi-Container Volume Sharing”

Create a pod with two containers that share data through volumes. One container writes logs, another processes them.

Terminal window
# Create namespace
k create ns volume-lab
# Create the multi-container pod
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata:
name: log-processor
namespace: volume-lab
spec:
containers:
# Writer container - generates logs
- name: writer
image: busybox:1.36
command:
- sh
- -c
- |
i=0
while true; do
echo "\$(date): Log entry \$i" >> /logs/app.log
i=\$((i+1))
sleep 5
done
volumeMounts:
- name: log-volume
mountPath: /logs
# Reader container - processes logs
- name: reader
image: busybox:1.36
command:
- sh
- -c
- |
echo "Waiting for logs..."
sleep 10
tail -f /logs/app.log
volumeMounts:
- name: log-volume
mountPath: /logs
readOnly: true
volumes:
- name: log-volume
emptyDir:
sizeLimit: 50Mi
EOF
  1. Verify the pod is running:
Terminal window
k get pod log-processor -n volume-lab
  1. Check writer is creating logs:
Terminal window
k exec -n volume-lab log-processor -c writer -- cat /logs/app.log
  1. Check reader can see the logs:
Terminal window
k logs -n volume-lab log-processor -c reader
  1. Verify volume sharing:
Terminal window
# Write from writer
k exec -n volume-lab log-processor -c writer -- sh -c 'echo "TEST MESSAGE" >> /logs/app.log'
# Read from reader
k exec -n volume-lab log-processor -c reader -- tail -1 /logs/app.log
  1. Check emptyDir location on node (for understanding):
Terminal window
k get pod log-processor -n volume-lab -o jsonpath='{.status.hostIP}'
# emptyDir is at /var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~empty-dir/log-volume
  • Pod running with both containers
  • Writer creating log entries every 5 seconds
  • Reader can see logs written by writer
  • Data persists across container restarts (test with k exec ... -- kill 1)

Add a third container that monitors disk usage of the shared volume and writes warnings when usage exceeds 80%.

Terminal window
k delete ns volume-lab

Practice these scenarios for exam readiness:

Terminal window
# Task: Create a pod with emptyDir volume mounted at /cache
# Hint: k run cache-pod --image=busybox --dry-run=client -o yaml > pod.yaml
# Then add volumes section
Terminal window
# Task: Create emptyDir backed by RAM with 64Mi limit
# Key fields: emptyDir.medium: Memory, emptyDir.sizeLimit: 64Mi
Terminal window
# Task: Mount /var/log from host as read-only volume
# Important: Always use readOnly: true for hostPath when possible
Terminal window
# Task: Create projected volume combining:
# - ConfigMap "app-config"
# - Secret "app-secrets"
# Mount at /etc/app

Drill 5: ConfigMap Volume with Items (2 min)

Section titled “Drill 5: ConfigMap Volume with Items (2 min)”
Terminal window
# Task: Mount only "app.conf" key from ConfigMap as "config.yaml"
# Use configMap.items to select and rename
Terminal window
# Task: Mount single file from ConfigMap into /etc/myapp/config.yaml
# Without overwriting other files in /etc/myapp

Drill 7: Volume Sharing Between Containers (3 min)

Section titled “Drill 7: Volume Sharing Between Containers (3 min)”
Terminal window
# Task: Create pod with 2 containers sharing emptyDir
# Container 1 writes to /shared/data.txt
# Container 2 reads from /shared/data.txt

Drill 8: Debug Volume Mount Issues (2 min)

Section titled “Drill 8: Debug Volume Mount Issues (2 min)”
Terminal window
# Given: Pod stuck in ContainerCreating
# Task: Identify if it's a volume mount issue
# Commands: k describe pod, check Events section

Continue to Module 4.2: PersistentVolumes & PersistentVolumeClaims to learn about persistent storage that survives pod deletion.