Kubernetes · lesson 6 of 10
Intermediate
6Advanced
1Also part of Kubernetes
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Deployment Strategies Compared: Rolling, Blue-Green, and Canary from DevOps
- + GitOps in Practice: Git as the Source of Truth from DevOps
- + Kubernetes Pod Security Admission: Enforcing the Restricted Profile from Cloud Security
- + CIS Kubernetes Benchmark Checks with kube-bench in kind from Cloud Security
ConfigMaps, Secrets, and Storage
Keep config out of images with ConfigMaps and Secrets, share files via emptyDir, and keep data through Pod deletion with a PVC, all run on minikube.
By SkillByExample · Updated
The examples run on a local minikube cluster (Kubernetes v1.35). Generated names, IDs, timestamps and ages will differ on yours.
ConfigMaps
A ConfigMap stores non-sensitive configuration as key-value pairs, so the same image runs with different settings in each environment.
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: "production"
LOG_LEVEL: "info"
MAX_CONNECTIONS: "100"
config.json: |
{
"featureFlags": {
"darkMode": true,
"betaApi": false
}
}
kubectl apply -f configmap.yaml
kubectl get configmap app-config
kubectl describe configmap app-config
$ kubectl apply -f configmap.yaml
configmap/app-config created
$ kubectl get configmap app-config
NAME DATA AGE
app-config 4 0s
$ kubectl describe configmap app-config
Name: app-config
Namespace: default
Labels: <none>
Annotations: <none>
Data
====
APP_ENV:
----
production
LOG_LEVEL:
----
info
MAX_CONNECTIONS:
----
100
config.json:
----
{
"featureFlags": {
"darkMode": true,
"betaApi": false
}
}
BinaryData
====
Events: <none>
DATA 4 counts keys, and a whole JSON document is a single key. Every value is a string, which
is why "100" is quoted in the YAML. A bare 100 would be rejected.
You can also build ConfigMaps without writing YAML, from a file or from literals. The file:
# nginx.conf
server {
listen 80;
location / { return 200 "ok\n"; }
}
kubectl create configmap nginx-conf --from-file=nginx.conf
kubectl create configmap db-config --from-literal=DB_HOST=postgres --from-literal=DB_PORT=5432
kubectl get configmap nginx-conf -o jsonpath='{.data}'; echo
kubectl get configmap db-config -o jsonpath='{.data}'; echo
$ kubectl create configmap nginx-conf --from-file=nginx.conf
configmap/nginx-conf created
$ kubectl create configmap db-config --from-literal=DB_HOST=postgres --from-literal=DB_PORT=5432
configmap/db-config created
$ kubectl get configmap nginx-conf -o jsonpath='{.data}'; echo
{"nginx.conf":"server {\n listen 80;\n location / { return 200 \"ok\\n\"; }\n}\n"}
$ kubectl get configmap db-config -o jsonpath='{.data}'; echo
{"DB_HOST":"postgres","DB_PORT":"5432"}
--from-file uses the file name as the key and the whole file as the value. --from-literal
makes one key per flag. Even 5432 comes back as the string "5432".
Using ConfigMap as Environment Variables
envFrom injects every key; env with configMapKeyRef picks one key and can rename it. The Pod just prints what it received.
# env-demo.yaml
apiVersion: v1
kind: Pod
metadata:
name: env-demo
spec:
restartPolicy: Never
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "env | sort | grep -E 'APP_ENV|LOG_LEVEL|MAX_|SPECIFIC|config'"]
envFrom:
- configMapRef:
name: app-config # inject all keys as env vars
env:
- name: SPECIFIC_KEY # inject a single key
valueFrom:
configMapKeyRef:
name: app-config
key: LOG_LEVEL
kubectl apply -f env-demo.yaml
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/env-demo
kubectl logs env-demo
$ kubectl apply -f env-demo.yaml
pod/env-demo created
$ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/env-demo
pod/env-demo condition met
$ kubectl logs env-demo
APP_ENV=production
LOG_LEVEL=info
MAX_CONNECTIONS=100
SPECIFIC_KEY=info
config.json={
SPECIFIC_KEY holds the value of LOG_LEVEL under a different name. config.json was injected
too, even though it is an unusual variable name. Recent Kubernetes versions accept any printable
name without =, while older ones skipped such keys and recorded an event. Its value is the
multi-line JSON, and only the first line contains config, so grep printed just {. Use files,
not environment variables, for structured config.
Using ConfigMap as a Mounted File
Mounted as a volume, each key becomes a file named after the key.
# mount-demo.yaml
apiVersion: v1
kind: Pod
metadata:
name: mount-demo
spec:
volumes:
- name: config-vol
configMap:
name: app-config
containers:
- name: app
image: busybox:1.36
command: ["sleep", "3600"]
volumeMounts:
- name: config-vol
mountPath: /etc/config # each key becomes a file
kubectl apply -f mount-demo.yaml
kubectl wait --for=condition=Ready pod/mount-demo
kubectl exec mount-demo -- ls /etc/config
kubectl exec mount-demo -- cat /etc/config/config.json
kubectl exec mount-demo -- ls -la /etc/config
$ kubectl apply -f mount-demo.yaml
pod/mount-demo created
$ kubectl wait --for=condition=Ready pod/mount-demo
pod/mount-demo condition met
$ kubectl exec mount-demo -- ls /etc/config
APP_ENV
LOG_LEVEL
MAX_CONNECTIONS
config.json
$ kubectl exec mount-demo -- cat /etc/config/config.json
{
"featureFlags": {
"darkMode": true,
"betaApi": false
}
}
$ kubectl exec mount-demo -- ls -la /etc/config
total 12
drwxrwxrwx 3 root root 4096 Oct 10 20:29 .
drwxr-xr-x 1 root root 4096 Oct 10 20:29 ..
drwxr-xr-x 2 root root 4096 Oct 10 20:29 ..2026_10_10_20_29_26.472617166
lrwxrwxrwx 1 root root 31 Oct 10 20:29 ..data -> ..2026_10_10_20_29_26.472617166
lrwxrwxrwx 1 root root 14 Oct 10 20:29 APP_ENV -> ..data/APP_ENV
lrwxrwxrwx 1 root root 16 Oct 10 20:29 LOG_LEVEL -> ..data/LOG_LEVEL
lrwxrwxrwx 1 root root 22 Oct 10 20:29 MAX_CONNECTIONS -> ..data/MAX_CONNECTIONS
lrwxrwxrwx 1 root root 18 Oct 10 20:29 config.json -> ..data/config.json
config.json arrives as a real file with its formatting intact. ls -la shows how updates work:
each key is a symlink through ..data into a timestamped directory. When the ConfigMap changes,
the kubelet writes a new directory and swaps the ..data link in one step, so the app never
reads a half-written set of files. Practice task 1 shows this.
Secrets
A Secret has the same shape as a ConfigMap, but its data values are base64-encoded and
Kubernetes treats the object as sensitive.
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
data:
DB_PASSWORD: cGFzc3dvcmQxMjM= # base64("password123")
API_KEY: c3VwZXJzZWNyZXQ= # base64("supersecret")
echo -n "password123" | base64
kubectl apply -f secret.yaml
kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 --decode; echo
$ echo -n "password123" | base64
cGFzc3dvcmQxMjM=
$ kubectl apply -f secret.yaml
secret/db-secret created
$ kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 --decode; echo
password123
-n matters: without it, echo adds a newline and the encoded value becomes
cGFzc3dvcmQxMjMK. That trailing K is a newline baked into the password. The decode step shows that base64 is an encoding, not
encryption. Anyone who can get the Secret can read it.
Creating the Secret imperatively keeps plaintext out of files you might commit, but create
refuses to overwrite:
kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret
kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml
kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml | kubectl apply -f -
$ kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret
error: failed to create secret secrets "db-secret" already exists
$ kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml
apiVersion: v1
data:
API_KEY: c3VwZXJzZWNyZXQ=
DB_PASSWORD: cGFzc3dvcmQxMjM=
kind: Secret
metadata:
name: db-secret
$ kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml | kubectl apply -f -
secret/db-secret configured
--dry-run=client -o yaml only prints the object, and its base64 matches secret.yaml exactly.
Piping that into kubectl apply -f - is the usual create-or-update idiom.
A TLS Secret holds a certificate and key under the fixed names tls.crt and tls.key. A
self-signed pair is enough to try it:
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=myapp.local" -keyout tls.key -out tls.crt
kubectl create secret tls my-tls --cert=tls.crt --key=tls.key
kubectl get secret my-tls
kubectl describe secret my-tls | tail -5
$ openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=myapp.local" -keyout tls.key -out tls.crt
Generating a RSA private key
.................................+++++
.........................+++++
writing new private key to 'tls.key'
-----
$ kubectl create secret tls my-tls --cert=tls.crt --key=tls.key
secret/my-tls created
$ kubectl get secret my-tls
NAME TYPE DATA AGE
my-tls kubernetes.io/tls 2 0s
$ kubectl describe secret my-tls | tail -5
Data
====
tls.crt: 1138 bytes
tls.key: 1732 bytes
The type kubernetes.io/tls is what an Ingress tls: section expects. describe shows only
sizes, never values. That hides them from screen output but is not access control.
Using Secrets in Pods
Secrets are consumed exactly like ConfigMaps, with secretRef and secretKeyRef.
# secret-env.yaml
apiVersion: v1
kind: Pod
metadata:
name: secret-env
spec:
restartPolicy: Never
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "echo DB_PASSWORD=$DB_PASSWORD; echo API_KEY=$API_KEY"]
envFrom:
- secretRef:
name: db-secret # inject all secret keys as env vars
env:
- name: DB_PASSWORD # or pick one key explicitly
valueFrom:
secretKeyRef:
name: db-secret
key: DB_PASSWORD
kubectl apply -f secret-env.yaml
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/secret-env
kubectl logs secret-env
$ kubectl apply -f secret-env.yaml
pod/secret-env created
$ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/secret-env
pod/secret-env condition met
$ kubectl logs secret-env
DB_PASSWORD=password123
API_KEY=supersecret
The container sees decoded plaintext, and the kubelet does the base64 decoding. That is also why
logging environment variables, as this demo does on purpose, leaks secrets into
kubectl logs. When a key arrives through both envFrom and env, the explicit env entry wins.
Volumes and Persistent Storage
emptyDir — Temporary Shared Storage
An emptyDir lives as long as the Pod, and every container in the Pod can mount it, which
makes it the usual way for sidecars to share files.
# emptydir.yaml
apiVersion: v1
kind: Pod
metadata:
name: shared-scratch
spec:
volumes:
- name: scratch
emptyDir: {}
containers:
- name: writer
image: busybox:1.36
command: ["sh", "-c", "echo hello > /data/msg; sleep 3600"]
volumeMounts:
- name: scratch
mountPath: /data
- name: reader
image: busybox:1.36
command: ["sh", "-c", "sleep 2; cat /data/msg; sleep 3600"]
volumeMounts:
- name: scratch
mountPath: /data
kubectl apply -f emptydir.yaml
kubectl wait --for=condition=Ready pod/shared-scratch
kubectl logs shared-scratch -c reader
kubectl exec shared-scratch -c reader -- sh -c 'echo from-reader >> /data/msg'
kubectl exec shared-scratch -c writer -- cat /data/msg
$ kubectl apply -f emptydir.yaml
pod/shared-scratch created
$ kubectl wait --for=condition=Ready pod/shared-scratch
pod/shared-scratch condition met
$ kubectl logs shared-scratch -c reader
hello
$ kubectl exec shared-scratch -c reader -- sh -c 'echo from-reader >> /data/msg'
$ kubectl exec shared-scratch -c writer -- cat /data/msg
hello
from-reader
Both containers see one directory: the reader printed the writer’s hello, and the writer sees
the line the reader appended. The kubelet starts a Pod’s containers in spec order but doesn’t wait for
one to be ready before starting the next, so the reader waits two seconds before reading. Without the sleep 2 it can run cat before the file exists.
-c picks the container for logs and exec in a multi-container Pod.
PersistentVolume and PersistentVolumeClaim
A PersistentVolumeClaim (PVC) asks for storage by size and access mode. A StorageClass’s provisioner creates the matching PersistentVolume (PV) for you.
# pvc.yaml — request storage (you don't manage where it comes from)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce # one node can read/write at a time
resources:
requests:
storage: 5Gi
storageClassName: standard # use "standard" for minikube
kubectl apply -f pvc.yaml
kubectl get pvc
kubectl describe pvc postgres-pvc
kubectl get pv
$ kubectl apply -f pvc.yaml
persistentvolumeclaim/postgres-pvc created
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
postgres-pvc Bound pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d 5Gi RWO standard <unset> 1s
$ kubectl describe pvc postgres-pvc
Name: postgres-pvc
Namespace: default
StorageClass: standard
Status: Bound
Volume: pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d
Labels: <none>
Annotations: pv.kubernetes.io/bind-completed: yes
pv.kubernetes.io/bound-by-controller: yes
volume.beta.kubernetes.io/storage-provisioner: k8s.io/minikube-hostpath
volume.kubernetes.io/storage-provisioner: k8s.io/minikube-hostpath
Finalizers: [kubernetes.io/pvc-protection]
Capacity: 5Gi
Access Modes: RWO
VolumeMode: Filesystem
Used By: <none>
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Provisioning 1s k8s.io/minikube-hostpath_minikube_cc7e1a55-3e3a-495f-8fb9-ad027076b39f External provisioner is provisioning volume for claim "default/postgres-pvc"
Normal ExternalProvisioning 1s persistentvolume-controller Waiting for a volume to be created either by the external provisioner 'k8s.io/minikube-hostpath' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
Normal ProvisioningSucceeded 1s k8s.io/minikube-hostpath_minikube_cc7e1a55-3e3a-495f-8fb9-ad027076b39f Successfully provisioned volume pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d 5Gi RWO Delete Bound default/postgres-pvc standard <unset> 2s
Bound means a volume exists and is reserved for this claim. The events show minikube’s
hostpath provisioner creating the PV on demand, and the PV is named after the claim’s UID.
RECLAIM POLICY Delete means deleting the PVC also deletes the PV and its data. Used By: <none> changes once a Pod mounts the claim.
A Pod mounts the claim by name. This one appends a timestamp on every start, so a second Pod shows whether the first one’s data survived:
# pvc-demo.yaml — a Pod that appends to a file on the claimed volume
apiVersion: v1
kind: Pod
metadata:
name: pvc-demo
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-pvc # reference the PVC
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "date >> /data/boots.log; cat /data/boots.log; sleep 3600"]
volumeMounts:
- name: data
mountPath: /data
kubectl apply -f pvc-demo.yaml
kubectl wait --for=condition=Ready pod/pvc-demo
kubectl logs pvc-demo
kubectl delete pod pvc-demo
kubectl apply -f pvc-demo.yaml
kubectl wait --for=condition=Ready pod/pvc-demo
kubectl logs pvc-demo
$ kubectl apply -f pvc-demo.yaml
pod/pvc-demo created
$ kubectl wait --for=condition=Ready pod/pvc-demo
pod/pvc-demo condition met
$ kubectl logs pvc-demo
Sat Oct 10 20:30:19 UTC 2026
$ kubectl delete pod pvc-demo
pod "pvc-demo" deleted from default namespace
$ kubectl apply -f pvc-demo.yaml
pod/pvc-demo created
$ kubectl wait --for=condition=Ready pod/pvc-demo
pod/pvc-demo condition met
$ kubectl logs pvc-demo
Sat Oct 10 20:30:19 UTC 2026
Sat Oct 10 20:30:55 UTC 2026
The second Pod is a new object with a fresh container filesystem, yet its log starts with the
first Pod’s timestamp: the file lives on the PV, not in the Pod. With an emptyDir the second log
would hold one line. The delete is slow because the container’s PID 1 is sh, which installs no
SIGTERM handler, and the kernel drops unhandled signals sent to PID 1. The kubelet waits out the
30-second grace period, then sends SIGKILL.
Clear out the demo Pods before the next example, then the claim:
kubectl delete pod env-demo mount-demo secret-env shared-scratch pvc-demo --now
kubectl delete pvc postgres-pvc
kubectl get pv
$ kubectl delete pod env-demo mount-demo secret-env shared-scratch pvc-demo --now
pod "env-demo" deleted from default namespace
pod "mount-demo" deleted from default namespace
pod "secret-env" deleted from default namespace
pod "shared-scratch" deleted from default namespace
pod "pvc-demo" deleted from default namespace
$ kubectl delete pvc postgres-pvc
persistentvolumeclaim "postgres-pvc" deleted from default namespace
$ kubectl get pv
No resources found
--now skips the grace period. Deleting the PVC also removed its PV, which is the Delete
reclaim policy at work, and boots.log went with it.
Access Modes
| Mode | Abbreviation | Meaning |
|---|---|---|
ReadWriteOnce | RWO | Single node read/write |
ReadOnlyMany | ROX | Multiple nodes read-only |
ReadWriteMany | RWX | Multiple nodes read/write |
Which modes you can get depends on the provisioner. minikube’s hostpath volumes and most cloud block disks are RWO. RWX needs a shared filesystem such as NFS or a cloud file service.
Complete Database Deployment Example
Postgres takes its database and user from a ConfigMap, its password from a Secret, and keeps its data directory on a PVC.
# postgres-full.yaml
---
apiVersion: v1
kind: Secret
metadata:
name: pg-secret
data:
POSTGRES_PASSWORD: cGFzc3dvcmQ= # "password"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: pg-config
data:
POSTGRES_DB: myapp
POSTGRES_USER: appuser
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pg-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: pg-pvc
containers:
- name: postgres
image: postgres:15
envFrom:
- configMapRef:
name: pg-config
- secretRef:
name: pg-secret
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
ports:
- containerPort: 5432
kubectl apply -f postgres-full.yaml
kubectl rollout status deployment/postgres
kubectl get pods,pvc,svc
$ kubectl apply -f postgres-full.yaml
secret/pg-secret created
configmap/pg-config created
persistentvolumeclaim/pg-pvc created
deployment.apps/postgres created
$ kubectl rollout status deployment/postgres
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
$ kubectl get pods,pvc,svc
NAME READY STATUS RESTARTS AGE
pod/postgres-65566cc6db-q2swk 1/1 Running 0 2s
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
persistentvolumeclaim/pg-pvc Bound pvc-bd5e9927-8d01-4b3a-ab99-9b38aedc72dd 2Gi RWO standard <unset> 2s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 15m
pg-pvc has no storageClassName, so it got the cluster’s default class (standard on
minikube). The only Service is the API server’s kubernetes, because this file defines none.
Other Pods would need a ClusterIP Service in front of port 5432, as in the
Services lesson.
The Pod is Running before Postgres finishes initialising. If psql reports a refused
connection, wait a few seconds. Then check that the ConfigMap and Secret took effect and that
data outlives the Pod:
kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT current_user, current_database();"
kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "CREATE TABLE notes(body text); INSERT INTO notes VALUES ('survives restarts');"
kubectl delete pod -l app=postgres
kubectl rollout status deployment/postgres
kubectl get pods -l app=postgres
kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT * FROM notes;"
$ kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT current_user, current_database();"
current_user | current_database
--------------+------------------
appuser | myapp
(1 row)
$ kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "CREATE TABLE notes(body text); INSERT INTO notes VALUES ('survives restarts');"
CREATE TABLE
INSERT 0 1
$ kubectl delete pod -l app=postgres
pod "postgres-65566cc6db-q2swk" deleted from default namespace
$ kubectl rollout status deployment/postgres
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
$ kubectl get pods -l app=postgres
NAME READY STATUS RESTARTS AGE
postgres-65566cc6db-wjcpf 1/1 Running 0 2s
$ kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT * FROM notes;"
body
-------------------
survives restarts
(1 row)
appuser and myapp exist only because the image’s entrypoint read POSTGRES_USER and
POSTGRES_DB from the ConfigMap on first start. The replacement Pod (wjcpf) mounted the same
PVC, found an initialised data directory, and skipped initialisation, so the table and row were
still there. A Deployment is fine for one replica on an RWO volume. To run several database
replicas, each with its own volume, use a StatefulSet.
Practice
- With
mount-demorunning, changeLOG_LEVELtodebugand watch the mounted file follow.
Solution
kubectl apply -f mount-demo.yaml
kubectl wait --for=condition=Ready pod/mount-demo
kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL; echo
kubectl patch configmap app-config -p '{"data":{"LOG_LEVEL":"debug"}}'; date +%T
# re-run the cat every few seconds until it changes:
date +%T; kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL
$ kubectl apply -f mount-demo.yaml
pod/mount-demo created
$ kubectl wait --for=condition=Ready pod/mount-demo
pod/mount-demo condition met
$ kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL; echo
info
$ kubectl patch configmap app-config -p '{"data":{"LOG_LEVEL":"debug"}}'; date +%T
configmap/app-config patched
02:02:35
$ date +%T; kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL
02:03:42
debug
The file changed about a minute after the patch, without restarting the container. The kubelet
syncs mounted ConfigMaps on its own period plus a cache delay. An environment variable from the
same key would still say info until the Pod is recreated.
- Mount only the
config.jsonkey, as/etc/app/settings.json, in a Pod that lists the directory and prints the file.
Solution
# items-demo.yaml
apiVersion: v1
kind: Pod
metadata:
name: items-demo
spec:
restartPolicy: Never
volumes:
- name: config-vol
configMap:
name: app-config
items:
- key: config.json
path: settings.json # only this key, under a new name
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "ls /etc/app; cat /etc/app/settings.json"]
volumeMounts:
- name: config-vol
mountPath: /etc/app
$ kubectl apply -f items-demo.yaml
pod/items-demo created
$ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/items-demo
pod/items-demo condition met
$ kubectl logs items-demo
settings.json
{
"featureFlags": {
"darkMode": true,
"betaApi": false
}
}
With items, only the listed keys are projected, and path renames the file. APP_ENV and the
other keys are absent.
- Read
API_KEYback out ofdb-secretin plaintext.
Solution
kubectl get secret db-secret -o jsonpath='{.data.API_KEY}' | base64 --decode; echo
$ kubectl get secret db-secret -o jsonpath='{.data.API_KEY}' | base64 --decode; echo
supersecret
Read access to a Secret is read access to its plaintext, which is why RBAC on secrets matters
more than the encoding.
- Delete
pg-pvcwhile Postgres is still using it. What happens, and what lets the delete finish?
Solution
kubectl delete pvc pg-pvc --wait=false
kubectl get pvc pg-pvc
kubectl scale deployment postgres --replicas=0
kubectl wait --for=delete pvc/pg-pvc
kubectl get pvc,pv
$ kubectl delete pvc pg-pvc --wait=false
persistentvolumeclaim "pg-pvc" deleted from default namespace
$ kubectl get pvc pg-pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
pg-pvc Terminating pvc-bd5e9927-8d01-4b3a-ab99-9b38aedc72dd 2Gi RWO standard <unset> 2m2s
$ kubectl scale deployment postgres --replicas=0
deployment.apps/postgres scaled
$ kubectl wait --for=delete pvc/pg-pvc
persistentvolumeclaim/pg-pvc condition met
$ kubectl get pvc,pv
No resources found
The kubernetes.io/pvc-protection finalizer keeps a claim in Terminating while any Pod still
mounts it, so a running database never loses its disk underneath it. Once the Pod was gone, the
claim finished deleting and, with the Delete reclaim policy, the PV and the notes table went
with it.
Clean up the rest of the lesson’s objects:
kubectl delete -f postgres-full.yaml --ignore-not-found
kubectl delete pod mount-demo items-demo --now
kubectl delete configmap app-config nginx-conf db-config
kubectl delete secret db-secret my-tls
$ kubectl delete -f postgres-full.yaml --ignore-not-found
secret "pg-secret" deleted from default namespace
configmap "pg-config" deleted from default namespace
deployment.apps "postgres" deleted from default namespace
$ kubectl delete pod mount-demo items-demo --now
pod "mount-demo" deleted from default namespace
pod "items-demo" deleted from default namespace
$ kubectl delete configmap app-config nginx-conf db-config
configmap "app-config" deleted from default namespace
configmap "nginx-conf" deleted from default namespace
configmap "db-config" deleted from default namespace
$ kubectl delete secret db-secret my-tls
secret "db-secret" deleted from default namespace
secret "my-tls" deleted from default namespace