Add lessons: untimed guided labs, one per curriculum objective, on a host of their own, in the browser and in the terminal. Each step has a check, a hint and its commands; a lesson writes no history or scores, only a done mark on its objective. CKAD 2.1-2.4 (24 steps) and all 27 CKA objectives (135 steps) have one, and `./ksim lesson audit` proves that every step fails, is done by its own commands, then passes. Add CKA tasks 132-147 (cka/bank_course.py) on what a full CKA course teaches beyond the bank: a real upgrade from v1.36, a drain under a PodDisruptionBudget, QoS and eviction, in-place resize, kube-proxy's chains, encryption at rest, an operator, and CSI expansion, snapshots and clones (ensure_csi_hostpath). Move CKA and CKS to Kubernetes v1.37 and rebuild the hosts on Ubuntu 24.04, with kubelet, kubeadm and kubectl as held packages from pkgs.k8s.io; the desktop runs Ubuntu too. Hosts gain man pages, hexdump and xxd. Fix CKS Q4, which gave 3 of 12 points untouched, and CKA Q4 and Q49, whose solutions raced their new CRD. Audited on v1.37: CKA 147/147, CKAD 105/105, CKS 109/110 (Q103, a Falco task, failed to seed once and awaits a re-audit); lessons: 135 of 135 CKA steps and 24 of 24 CKAD steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
31 KiB
Python
590 lines
31 KiB
Python
"""CKA lessons of the Storage domain (1.1-1.3). See cka/lessons.py for what a lesson is."""
|
|
from sim.bank import NS, step
|
|
|
|
# ---- 1.1 StorageClasses and dynamic provisioning ---------------------------------------------
|
|
L11 = {
|
|
"id": 9101, "objective": "1.1", "workers": 0,
|
|
"title": "StorageClasses: who makes the volume, when, and what happens after",
|
|
"intro": r"""Nobody creates PersistentVolumes by hand on a cluster with a **provisioner**. A claim names a **StorageClass**, and the provisioner behind that class makes a volume to fit the claim. The class decides three things you will be asked about:
|
|
|
|
```
|
|
provisioner who makes the volume rancher.io/local-path, ebs.csi.aws.com, ...
|
|
volumeBindingMode when it is made Immediate | WaitForFirstConsumer
|
|
reclaimPolicy what happens when the claim goes Delete | Retain
|
|
```
|
|
|
|
`WaitForFirstConsumer` holds a claim `Pending` until a Pod uses it, so the volume is made on the node the Pod lands on. `Immediate` provisions at once, wherever. The reclaim policy is copied into each volume when it is made: changing the class later does not change the volumes it already made.
|
|
|
|
One class may carry the annotation `storageclass.kubernetes.io/is-default-class: "true"`. A claim that names no class gets that one.
|
|
|
|
This cluster has kind's class `standard` (local-path provisioner) and an empty Namespace `store`.""",
|
|
"setup": NS + r"""
|
|
mkns store
|
|
mkdir -p "$WS/lesson-1.1"
|
|
""",
|
|
"steps": [
|
|
step("Read the default class",
|
|
r"""`kubectl get storageclass` marks the default with `(default)`. Write its name, its provisioner and its binding mode, separated by spaces, to `{WS}/lesson-1.1/default.txt` - for example `fast ebs.csi.aws.com Immediate`.""",
|
|
check=r"""
|
|
want="$(j sc standard '{.metadata.name} {.provisioner} {.volumeBindingMode}')"
|
|
[ -n "$want" ] && eq "$(tr -s ' \t' ' ' < "$WS/lesson-1.1/default.txt" 2>/dev/null | tr -d '\r\n' | sed 's/ *$//')" "$want" """,
|
|
hint=r"""`kubectl get sc` shows all three columns. For exactly the values: `-o jsonpath='{.metadata.name} {.provisioner} {.volumeBindingMode}'`.""",
|
|
show=r"""kubectl get sc
|
|
kubectl get sc standard -o jsonpath='{.metadata.name} {.provisioner} {.volumeBindingMode}' > {WS}/lesson-1.1/default.txt
|
|
cat {WS}/lesson-1.1/default.txt"""),
|
|
step("A class that keeps its volumes",
|
|
r"""Create StorageClass `keep`: the same provisioner as `standard` (`rancher.io/local-path`), binding mode `WaitForFirstConsumer`, and reclaim policy **`Retain`**, so a volume outlives its claim.
|
|
|
|
There is no `kubectl create storageclass`: write the manifest. `kubectl get sc standard -o yaml` is a good start - drop the default annotation when you copy it.""",
|
|
check=r"""
|
|
eq "$(j sc keep '{.provisioner} {.reclaimPolicy} {.volumeBindingMode}')" "rancher.io/local-path Retain WaitForFirstConsumer" \
|
|
&& [ "$(j sc keep '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]""",
|
|
hint=r"""A StorageClass has no `spec`: `provisioner`, `reclaimPolicy` and `volumeBindingMode` sit at the top level, beside `metadata`. Its `apiVersion` is `storage.k8s.io/v1`.""",
|
|
show=r"""kubectl apply -f - <<'EOF'
|
|
apiVersion: storage.k8s.io/v1
|
|
kind: StorageClass
|
|
metadata:
|
|
name: keep
|
|
provisioner: rancher.io/local-path
|
|
reclaimPolicy: Retain
|
|
volumeBindingMode: WaitForFirstConsumer
|
|
EOF
|
|
kubectl get sc"""),
|
|
step("A claim that waits",
|
|
r"""In Namespace `store`, create PersistentVolumeClaim `ledger`: class `keep`, `ReadWriteOnce`, `100Mi`.
|
|
|
|
It stays `Pending`, and that is correct. `kubectl -n store describe pvc ledger` says why in its events; write the event's **reason** (one word) to `{WS}/lesson-1.1/why.txt`.""",
|
|
check=r"""
|
|
eq "$(j -n store pvc ledger '{.spec.storageClassName}')" keep && eq "$(j -n store pvc ledger '{.spec.resources.requests.storage}')" 100Mi \
|
|
&& grep -qx 'WaitForFirstConsumer' <(tr -d ' \r' < "$WS/lesson-1.1/why.txt" 2>/dev/null)""",
|
|
hint=r"""The events at the bottom of `describe` have a Reason column. A claim is namespaced; the class is not.""",
|
|
show=r"""kubectl -n store apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: ledger
|
|
spec:
|
|
storageClassName: keep
|
|
accessModes: [ReadWriteOnce]
|
|
resources:
|
|
requests:
|
|
storage: 100Mi
|
|
EOF
|
|
kubectl -n store describe pvc ledger | tail -4
|
|
echo WaitForFirstConsumer > {WS}/lesson-1.1/why.txt"""),
|
|
step("Give it a consumer",
|
|
r"""Run Pod `bookkeeper` in `store` (image `busybox:1.36`) that mounts claim `ledger` at `/data` and runs `sh -c 'echo first > /data/entry.txt && sleep 86400'`.
|
|
|
|
As soon as the Pod is scheduled the provisioner makes a volume on its node and the claim turns `Bound`. Look at the new PersistentVolume: `kubectl get pv`. Its reclaim policy came from the class.""",
|
|
check=r"""
|
|
eq "$(j -n store pvc ledger '{.status.phase}')" Bound || exit 1
|
|
pv=$(j -n store pvc ledger '{.spec.volumeName}'); eq "$(j pv "$pv" '{.spec.persistentVolumeReclaimPolicy}')" Retain || exit 1
|
|
eq "$($K -n store exec bookkeeper -- cat /data/entry.txt 2>/dev/null)" first""",
|
|
hint=r"""`kubectl run bookkeeper --image=busybox:1.36 --dry-run=client -o yaml -- sh -c '...'` gives you the Pod; add `volumes:` (with `persistentVolumeClaim.claimName`) at Pod level and `volumeMounts:` in the container.""",
|
|
show=r"""kubectl -n store apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: bookkeeper
|
|
spec:
|
|
containers:
|
|
- name: bookkeeper
|
|
image: busybox:1.36
|
|
command: [sh, -c, 'echo first > /data/entry.txt && sleep 86400']
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /data
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim:
|
|
claimName: ledger
|
|
EOF
|
|
kubectl -n store wait --for=condition=Ready pod/bookkeeper --timeout=120s
|
|
kubectl -n store get pvc ledger
|
|
kubectl get pv"""),
|
|
step("Delete the claim, keep the volume",
|
|
r"""Delete Pod `bookkeeper` and claim `ledger`. Because of `Retain`, the volume stays, with status `Released`: its data is still on disk, and no new claim can bind it until an admin says so.
|
|
|
|
Write the PersistentVolume's name to `{WS}/lesson-1.1/released.txt`.""",
|
|
check=r"""
|
|
[ -z "$(j -n store pvc ledger '{.metadata.name}')" ] || exit 1
|
|
pv=$(tr -d ' \r\n' < "$WS/lesson-1.1/released.txt" 2>/dev/null); [ -n "$pv" ] || exit 1
|
|
eq "$(j pv "$pv" '{.status.phase} {.spec.claimRef.namespace}/{.spec.claimRef.name}')" "Released store/ledger" """,
|
|
hint=r"""Read the volume name off the claim before you delete it (`kubectl -n store get pvc ledger -o jsonpath='{.spec.volumeName}'`), or find it afterwards in `kubectl get pv`: the CLAIM column still names `store/ledger`.""",
|
|
show=r"""kubectl -n store get pvc ledger -o jsonpath='{.spec.volumeName}' > {WS}/lesson-1.1/released.txt
|
|
kubectl -n store delete pod bookkeeper --grace-period=1
|
|
kubectl -n store delete pvc ledger
|
|
kubectl get pv"""),
|
|
step("Change the default class",
|
|
r"""Make `keep` the cluster's default class, and `standard` no longer the default. Two defaults would be ambiguous: set the annotation to `"true"` on one and `"false"` on the other.""",
|
|
check=r"""
|
|
eq "$(j sc keep '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" true \
|
|
&& [ "$(j sc standard '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]""",
|
|
hint=r"""`kubectl annotate sc NAME storageclass.kubernetes.io/is-default-class=true --overwrite`, or `kubectl patch` with a `metadata.annotations` map.""",
|
|
show=r"""kubectl annotate sc standard storageclass.kubernetes.io/is-default-class=false --overwrite
|
|
kubectl annotate sc keep storageclass.kubernetes.io/is-default-class=true --overwrite
|
|
kubectl get sc"""),
|
|
],
|
|
"outro": r"""You read a class, made one that keeps its volumes, watched a claim wait for its Pod, kept a volume past its claim, and moved the default.
|
|
|
|
**For the exam**
|
|
|
|
- A StorageClass has no `spec`; `provisioner`, `reclaimPolicy`, `volumeBindingMode` and `allowVolumeExpansion` are top-level fields.
|
|
- `WaitForFirstConsumer`: a claim is `Pending` until a Pod uses it - not a fault. Look at `describe pvc` before you "fix" anything.
|
|
- The reclaim policy is copied into the volume when it is made. To keep an existing volume, patch the **PV**: `kubectl patch pv NAME -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'`.
|
|
- Growing a claim: the class needs `allowVolumeExpansion: true` and a driver that can expand, then raise `spec.resources.requests.storage` on the claim - claims only grow. The local-path provisioner can do neither that nor snapshots; a CSI driver such as the hostpath driver behind `csi-hostpath-sc` can, and lesson 1.3 grows, snapshots and restores a claim on it.
|
|
- One default class. `kubectl get sc` shows `(default)`.
|
|
|
|
Next, prove it against the clock: **drill 1.1**.""",
|
|
}
|
|
|
|
# ---- 1.2 volume types, access modes and reclaim policies -------------------------------------
|
|
L12 = {
|
|
"id": 9102, "objective": "1.2", "workers": 0,
|
|
"title": "Volume types, access modes and reclaim policies",
|
|
"intro": r"""A Pod's `volumes:` list says where data comes from; each container's `volumeMounts:` says where it appears. The types you meet on the exam:
|
|
|
|
```
|
|
emptyDir a scratch directory that lives and dies with the Pod (medium: Memory = tmpfs)
|
|
hostPath a directory of the node - tied to that node
|
|
configMap keys of a ConfigMap as files secret the same for a Secret
|
|
projected several of those, plus downwardAPI and a ServiceAccount token, in ONE directory
|
|
persistentVolumeClaim a claim, bound to a PersistentVolume
|
|
```
|
|
|
|
A PersistentVolume has **access modes** - `ReadWriteOnce` (one *node* at a time), `ReadOnlyMany`, `ReadWriteMany`, `ReadWriteOncePod` (one *Pod*) - and a **reclaim policy**, `Delete` or `Retain`. A claim binds a volume whose modes, size and class fit it.
|
|
|
|
Namespace `vols` has ConfigMap `app-config` (key `mode`) and Secret `app-secret` (key `password`).""",
|
|
"setup": NS + r"""
|
|
mkns vols
|
|
$K -n vols create configmap app-config --from-literal=mode=prod >/dev/null
|
|
$K -n vols create secret generic app-secret --from-literal=password=s3cr3t >/dev/null
|
|
mkdir -p "$WS/lesson-1.2"
|
|
""",
|
|
"steps": [
|
|
step("Two containers, one emptyDir",
|
|
r"""Create Pod `pair` in `vols` with an `emptyDir` volume `shared`, mounted at `/shared` in two `busybox:1.36` containers:
|
|
|
|
- `writer`: `sh -c 'echo hello > /shared/msg && sleep 86400'`
|
|
- `reader`: `sleep 86400`
|
|
|
|
The reader sees what the writer wrote: an emptyDir is shared by every container of its Pod.""",
|
|
check=r"""eq "$($K -n vols exec pair -c reader -- cat /shared/msg 2>/dev/null)" hello \
|
|
&& [ -n "$(j -n vols pod pair '{.spec.volumes[?(@.name=="shared")].emptyDir}')" ]""",
|
|
hint=r"""Both containers list the same `volumeMounts` entry (`name: shared`); the Pod lists the volume once, as `emptyDir: {}`.""",
|
|
show=r"""kubectl -n vols apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: pair
|
|
spec:
|
|
containers:
|
|
- name: writer
|
|
image: busybox:1.36
|
|
command: [sh, -c, 'echo hello > /shared/msg && sleep 86400']
|
|
volumeMounts:
|
|
- {name: shared, mountPath: /shared}
|
|
- name: reader
|
|
image: busybox:1.36
|
|
command: [sleep, '86400']
|
|
volumeMounts:
|
|
- {name: shared, mountPath: /shared}
|
|
volumes:
|
|
- name: shared
|
|
emptyDir: {}
|
|
EOF
|
|
kubectl -n vols wait --for=condition=Ready pod/pair --timeout=120s
|
|
kubectl -n vols exec pair -c reader -- cat /shared/msg"""),
|
|
step("Scratch space in memory",
|
|
r"""Create Pod `scratch` in `vols` (`busybox:1.36`, `sleep 86400`) with an emptyDir at `/scratch` that is held in **memory**, at most `64Mi`.
|
|
|
|
Inside the Pod, `/proc/mounts` shows the difference: `/scratch` is a `tmpfs`. What is written there counts against the container's memory.""",
|
|
check=r"""
|
|
eq "$(j -n vols pod scratch '{.spec.volumes[0].emptyDir.medium}')" Memory && eq "$(j -n vols pod scratch '{.spec.volumes[0].emptyDir.sizeLimit}')" 64Mi \
|
|
&& $K -n vols exec scratch -- grep -q ' /scratch tmpfs ' /proc/mounts 2>/dev/null""",
|
|
hint=r"""`emptyDir: {medium: Memory, sizeLimit: 64Mi}`.""",
|
|
show=r"""kubectl -n vols apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: scratch
|
|
spec:
|
|
containers:
|
|
- name: scratch
|
|
image: busybox:1.36
|
|
command: [sleep, '86400']
|
|
volumeMounts:
|
|
- {name: tmp, mountPath: /scratch}
|
|
volumes:
|
|
- name: tmp
|
|
emptyDir: {medium: Memory, sizeLimit: 64Mi}
|
|
EOF
|
|
kubectl -n vols wait --for=condition=Ready pod/scratch --timeout=120s
|
|
kubectl -n vols exec scratch -- grep /scratch /proc/mounts"""),
|
|
step("One directory from three sources",
|
|
r"""Create Pod `bundle` in `vols` (`busybox:1.36`, `sleep 86400`) with **one** `projected` volume mounted at `/etc/bundle`, made of:
|
|
|
|
- ConfigMap `app-config` (gives the file `mode`),
|
|
- Secret `app-secret` (gives `password`),
|
|
- the downward API: the Pod's own name as the file `podname`.
|
|
|
|
Separate volumes would need three mount points; a projected volume puts them side by side.""",
|
|
check=r"""
|
|
x() { $K -n vols exec bundle -- cat "/etc/bundle/$1" 2>/dev/null; }
|
|
[ -n "$(j -n vols pod bundle '{.spec.volumes[0].projected}')" ] && eq "$(x mode)" prod && eq "$(x password)" s3cr3t && eq "$(x podname)" bundle""",
|
|
hint=r"""`projected: {sources: [...]}` - each source is `configMap: {name: ...}`, `secret: {name: ...}` or `downwardAPI: {items: [{path: podname, fieldRef: {fieldPath: metadata.name}}]}`. `kubectl explain pod.spec.volumes.projected.sources` lists them.""",
|
|
show=r"""kubectl -n vols apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: bundle
|
|
spec:
|
|
containers:
|
|
- name: app
|
|
image: busybox:1.36
|
|
command: [sleep, '86400']
|
|
volumeMounts:
|
|
- {name: bundle, mountPath: /etc/bundle, readOnly: true}
|
|
volumes:
|
|
- name: bundle
|
|
projected:
|
|
sources:
|
|
- configMap: {name: app-config}
|
|
- secret: {name: app-secret}
|
|
- downwardAPI:
|
|
items:
|
|
- path: podname
|
|
fieldRef: {fieldPath: metadata.name}
|
|
EOF
|
|
kubectl -n vols wait --for=condition=Ready pod/bundle --timeout=120s
|
|
kubectl -n vols exec bundle -- ls /etc/bundle"""),
|
|
step("A volume for exactly one Pod",
|
|
r"""Create PersistentVolume `archive`: `hostPath` `/srv/archive` (type `DirectoryOrCreate`), `200Mi`, access mode **`ReadWriteOncePod`**, reclaim policy `Retain`, and `storageClassName: ""`.
|
|
|
|
Then claim it: PVC `archive-claim` in `vols`, `ReadWriteOncePod`, `200Mi`, `storageClassName: ""`.
|
|
|
|
The empty class matters on both sides. A claim with no class at all gets the default class, and its provisioner would make a fresh volume instead of binding yours.""",
|
|
check=r"""
|
|
eq "$(j pv archive '{.spec.accessModes[0]} {.spec.persistentVolumeReclaimPolicy} {.spec.hostPath.path}')" "ReadWriteOncePod Retain /srv/archive" \
|
|
&& eq "$(j -n vols pvc archive-claim '{.status.phase} {.spec.volumeName}')" "Bound archive" """,
|
|
hint=r"""`storageClassName: ""` is spelled out, with the quotes, under `spec` of both objects. A PersistentVolume is cluster-wide: no namespace.""",
|
|
show=r"""kubectl apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolume
|
|
metadata:
|
|
name: archive
|
|
spec:
|
|
capacity: {storage: 200Mi}
|
|
accessModes: [ReadWriteOncePod]
|
|
persistentVolumeReclaimPolicy: Retain
|
|
storageClassName: ""
|
|
hostPath: {path: /srv/archive, type: DirectoryOrCreate}
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: archive-claim
|
|
namespace: vols
|
|
spec:
|
|
storageClassName: ""
|
|
accessModes: [ReadWriteOncePod]
|
|
resources:
|
|
requests: {storage: 200Mi}
|
|
EOF
|
|
kubectl get pv archive
|
|
kubectl -n vols get pvc archive-claim"""),
|
|
step("Hand a Released volume to a new claim",
|
|
r"""Delete claim `archive-claim`. The volume becomes `Released`: it still remembers its old claim in `spec.claimRef`, and nothing binds it while it does.
|
|
|
|
Make it `Available` again by removing `spec.claimRef` from the PV, then create claim `archive-claim-2` in `vols` (same modes, size and empty class). It binds `archive`, and the data on it is still there.""",
|
|
check=r"""
|
|
[ -z "$(j -n vols pvc archive-claim '{.metadata.name}')" ] && eq "$(j -n vols pvc archive-claim-2 '{.status.phase} {.spec.volumeName}')" "Bound archive" """,
|
|
hint=r"""`kubectl patch pv archive --type json -p '[{"op":"remove","path":"/spec/claimRef"}]'` - or `kubectl edit pv archive` and delete the `claimRef:` block.""",
|
|
show=r"""kubectl -n vols delete pvc archive-claim
|
|
kubectl get pv archive
|
|
kubectl patch pv archive --type json -p '[{"op":"remove","path":"/spec/claimRef"}]'
|
|
kubectl apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: archive-claim-2
|
|
namespace: vols
|
|
spec:
|
|
storageClassName: ""
|
|
accessModes: [ReadWriteOncePod]
|
|
resources:
|
|
requests: {storage: 200Mi}
|
|
EOF
|
|
kubectl get pv archive"""),
|
|
],
|
|
"outro": r"""You shared an emptyDir between containers, put one in memory, combined three sources in one projected volume, and took a `Retain` volume through `Bound`, `Released` and back.
|
|
|
|
**For the exam**
|
|
|
|
- `ReadWriteOnce` is one **node**, not one Pod; `ReadWriteOncePod` is one Pod. A claim's modes must be a subset of the volume's.
|
|
- `Retain` keeps the data and leaves the volume `Released`; to reuse it, remove `spec.claimRef` (or delete and re-create the PV). `Delete` removes the volume with its claim. `Recycle` is deprecated.
|
|
- `storageClassName: ""` binds a pre-made volume; leaving the field out means "the default class".
|
|
- configMap and secret volumes follow changes to their object after a short delay - but not when mounted with `subPath`, and environment variables never do.
|
|
- `kubectl explain pod.spec.volumes` lists every volume type; the docs page "Volumes" has an example of each.
|
|
|
|
Next, prove it against the clock: **drill 1.2**.""",
|
|
}
|
|
|
|
# ---- 1.3 PersistentVolumes and PersistentVolumeClaims ---------------------------------------
|
|
_PVS = r"""
|
|
$K apply -f - >/dev/null <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolume
|
|
metadata:
|
|
name: pv-small
|
|
labels: {tier: bronze}
|
|
spec:
|
|
capacity: {storage: 100Mi}
|
|
accessModes: [ReadWriteOnce]
|
|
persistentVolumeReclaimPolicy: Retain
|
|
storageClassName: manual
|
|
hostPath: {path: /srv/pv-small, type: DirectoryOrCreate}
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolume
|
|
metadata:
|
|
name: pv-large
|
|
labels: {tier: gold}
|
|
spec:
|
|
capacity: {storage: 1Gi}
|
|
accessModes: [ReadWriteOnce]
|
|
persistentVolumeReclaimPolicy: Retain
|
|
storageClassName: manual
|
|
hostPath: {path: /srv/pv-large, type: DirectoryOrCreate}
|
|
EOF
|
|
"""
|
|
|
|
L13 = {
|
|
"id": 9103, "objective": "1.3", "workers": 0,
|
|
"title": "Claims: binding the right volume, protection, and claims that come and go",
|
|
"intro": r"""A claim binds **one** volume, and keeps it until the claim is deleted. Among the volumes that fit - access modes, class, at least the requested size - Kubernetes picks the smallest. You can steer it:
|
|
|
|
```
|
|
selector: matchLabels on the volume's labels "one of the gold volumes"
|
|
volumeName: the volume's name "exactly this volume"
|
|
```
|
|
|
|
Claims are also protected: one that a Pod still uses is not removed until the Pod is gone. And a StatefulSet makes claims of its own, one per replica, from `volumeClaimTemplates` - its `persistentVolumeClaimRetentionPolicy` says whether they go when replicas go.
|
|
|
|
Some things only a **CSI driver** can do for a claim: grow it, snapshot it, make a new claim from a snapshot or from another claim. This cluster has the CSI hostpath driver and its class `csi-hostpath-sc` (expandable), as the exam's clusters do, besides kind's `standard`.
|
|
|
|
Two volumes are ready, both of class `manual`: `pv-small` (100Mi, label `tier=bronze`) and `pv-large` (1Gi, `tier=gold`). Namespace `claims` has claim `growing` (`csi-hostpath-sc`, 10Mi), used by Pod `grower`.""",
|
|
"setup": NS + _PVS + r"""
|
|
ensure_csi_hostpath || { echo "could not install the CSI hostpath driver" >&2; exit 1; }
|
|
mkns claims
|
|
mkdir -p "$WS/lesson-1.3"
|
|
$K -n claims apply -f - >/dev/null <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata: {name: growing}
|
|
spec: {storageClassName: csi-hostpath-sc, accessModes: [ReadWriteOnce], resources: {requests: {storage: 10Mi}}}
|
|
---
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata: {name: grower}
|
|
spec:
|
|
containers:
|
|
- {name: grower, image: busybox:1.36, command: [sh, -c, 'echo kept > /data/note.txt; sync; sleep 86400'], volumeMounts: [{name: d, mountPath: /data}]}
|
|
volumes: [{name: d, persistentVolumeClaim: {claimName: growing}}]
|
|
EOF
|
|
$K -n claims wait --for=condition=Ready pod/grower --timeout=180s >/dev/null
|
|
""",
|
|
"steps": [
|
|
step("Bind by label",
|
|
r"""Create claim `gold-claim` in `claims`: class `manual`, `ReadWriteOnce`, `100Mi` - and a **selector** for `tier=gold`.
|
|
|
|
Without the selector it would bind `pv-small`, the smallest volume that fits. With it, it binds `pv-large`.""",
|
|
check=r"""eq "$(j -n claims pvc gold-claim '{.status.phase} {.spec.volumeName}')" "Bound pv-large" """,
|
|
hint=r"""`spec.selector.matchLabels: {tier: gold}` on the claim.""",
|
|
show=r"""kubectl -n claims apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: gold-claim
|
|
spec:
|
|
storageClassName: manual
|
|
accessModes: [ReadWriteOnce]
|
|
resources:
|
|
requests: {storage: 100Mi}
|
|
selector:
|
|
matchLabels: {tier: gold}
|
|
EOF
|
|
kubectl get pv"""),
|
|
step("Bind by name, and use it",
|
|
r"""Create claim `pinned` in `claims` that binds `pv-small` **by name** (class `manual`, `ReadWriteOnce`, `100Mi`), and Pod `user` (`busybox:1.36`, `sleep 86400`) that mounts it at `/data`.""",
|
|
check=r"""eq "$(j -n claims pvc pinned '{.status.phase} {.spec.volumeName}')" "Bound pv-small" && eq "$(j -n claims pod user '{.status.phase}')" Running \
|
|
&& eq "$(j -n claims pod user '{.spec.volumes[0].persistentVolumeClaim.claimName}')" pinned""",
|
|
hint=r"""`spec.volumeName: pv-small` on the claim. The volume's class and modes must still match the claim's.""",
|
|
show=r"""kubectl -n claims apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: pinned
|
|
spec:
|
|
storageClassName: manual
|
|
volumeName: pv-small
|
|
accessModes: [ReadWriteOnce]
|
|
resources:
|
|
requests: {storage: 100Mi}
|
|
---
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: user
|
|
spec:
|
|
containers:
|
|
- name: user
|
|
image: busybox:1.36
|
|
command: [sleep, '86400']
|
|
volumeMounts:
|
|
- {name: data, mountPath: /data}
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim: {claimName: pinned}
|
|
EOF
|
|
kubectl -n claims wait --for=condition=Ready pod/user --timeout=120s
|
|
kubectl -n claims get pvc"""),
|
|
step("A claim in use does not go",
|
|
r"""Delete claim `pinned` without waiting (`--wait=false`). It does not disappear: `kubectl -n claims get pvc` shows it `Terminating`, held by a **finalizer**.
|
|
|
|
Write that finalizer's name to `{WS}/lesson-1.3/finalizer.txt`.""",
|
|
check=r"""
|
|
[ -n "$(j -n claims pvc pinned '{.metadata.deletionTimestamp}')" ] \
|
|
&& eq "$(tr -d ' \r\n' < "$WS/lesson-1.3/finalizer.txt" 2>/dev/null)" kubernetes.io/pvc-protection""",
|
|
hint=r"""`kubectl -n claims get pvc pinned -o jsonpath='{.metadata.finalizers}'`. Without `--wait=false`, `kubectl delete` would sit there until the claim is really gone.""",
|
|
show=r"""kubectl -n claims delete pvc pinned --wait=false
|
|
kubectl -n claims get pvc pinned
|
|
kubectl -n claims get pvc pinned -o jsonpath='{.metadata.finalizers[0]}' > {WS}/lesson-1.3/finalizer.txt
|
|
cat {WS}/lesson-1.3/finalizer.txt"""),
|
|
step("Let it go",
|
|
r"""Delete Pod `user`. With no Pod using it any more, the protection lifts and claim `pinned` is removed. `pv-small` (reclaim policy `Retain`) is now `Released`.""",
|
|
check=r"""[ -z "$(j -n claims pod user '{.metadata.name}')" ] && [ -z "$(j -n claims pvc pinned '{.metadata.name}')" ] && eq "$(j pv pv-small '{.status.phase}')" Released""",
|
|
hint=r"""`kubectl -n claims delete pod user`, then `kubectl -n claims get pvc` and `kubectl get pv`.""",
|
|
show=r"""kubectl -n claims delete pod user --grace-period=1
|
|
kubectl -n claims get pvc
|
|
kubectl get pv pv-small"""),
|
|
step("Claims made by a StatefulSet",
|
|
r"""Create a StatefulSet `db` in `claims` with 2 replicas (`busybox:1.36`, `sleep 86400`) and its headless Service `db`. Give it a `volumeClaimTemplates` entry `data` (`ReadWriteOnce`, `50Mi`, class `standard`) mounted at `/var/lib/db`, and a `persistentVolumeClaimRetentionPolicy` that **deletes** a replica's claim when the StatefulSet is scaled down and **retains** the claims when the StatefulSet is deleted.
|
|
|
|
Each replica gets a claim of its own: `data-db-0`, `data-db-1`.""",
|
|
check=r"""
|
|
eq "$(j -n claims sts db '{.spec.persistentVolumeClaimRetentionPolicy.whenScaled} {.spec.persistentVolumeClaimRetentionPolicy.whenDeleted}')" "Delete Retain" || exit 1
|
|
eq "$(j -n claims sts db '{.status.readyReplicas}')" 2 && eq "$(j -n claims pvc data-db-0 '{.status.phase}')" Bound && eq "$(j -n claims pvc data-db-1 '{.status.phase}')" Bound""",
|
|
hint=r"""`kubectl create deploy db --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400` is a start: change the kind, add `serviceName: db`, drop `strategy`, add `volumeClaimTemplates` beside `template`. The Service: `clusterIP: None`. `kubectl explain sts.spec.persistentVolumeClaimRetentionPolicy`.""",
|
|
show=r"""kubectl -n claims apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: db
|
|
spec:
|
|
clusterIP: None
|
|
selector: {app: db}
|
|
ports:
|
|
- port: 5432
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: db
|
|
spec:
|
|
serviceName: db
|
|
replicas: 2
|
|
selector:
|
|
matchLabels: {app: db}
|
|
persistentVolumeClaimRetentionPolicy:
|
|
whenScaled: Delete
|
|
whenDeleted: Retain
|
|
template:
|
|
metadata:
|
|
labels: {app: db}
|
|
spec:
|
|
containers:
|
|
- name: db
|
|
image: busybox:1.36
|
|
command: [sleep, '86400']
|
|
volumeMounts:
|
|
- {name: data, mountPath: /var/lib/db}
|
|
volumeClaimTemplates:
|
|
- metadata:
|
|
name: data
|
|
spec:
|
|
storageClassName: standard
|
|
accessModes: [ReadWriteOnce]
|
|
resources:
|
|
requests: {storage: 50Mi}
|
|
EOF
|
|
kubectl -n claims rollout status sts db --timeout=180s
|
|
kubectl -n claims get pvc"""),
|
|
step("Scale down, and the claim goes with the replica",
|
|
r"""Scale `db` to 1 replica. Pod `db-1` goes, and because `whenScaled` is `Delete`, so does `data-db-1`. `data-db-0` stays.
|
|
|
|
The default for both fields is `Retain`: without the policy, `data-db-1` would wait for the replica to come back.""",
|
|
check=r"""
|
|
eq "$(j -n claims sts db '{.spec.replicas}')" 1 && eq "$(j -n claims pvc data-db-0 '{.status.phase}')" Bound || exit 1
|
|
for i in $(seq 1 10); do [ -z "$(j -n claims pvc data-db-1 '{.metadata.name}')" ] && exit 0; sleep 3; done; exit 1""",
|
|
hint=r"""`kubectl -n claims scale sts db --replicas=1`, then `kubectl -n claims get pvc -w`.""",
|
|
show=r"""kubectl -n claims scale sts db --replicas=1
|
|
kubectl -n claims rollout status sts db --timeout=120s
|
|
kubectl -n claims get pvc"""),
|
|
step("Grow a claim in use",
|
|
r"""Claim `growing` is full. Its class allows expansion (`kubectl get sc csi-hostpath-sc`: `ALLOWVOLUMEEXPANSION true`), so make it **50Mi** - while Pod `grower` keeps running. Raise the request; the driver does the rest, and `status.capacity` follows.""",
|
|
check=r"""eq "$(j -n claims pvc growing '{.spec.resources.requests.storage} {.status.capacity.storage}')" "50Mi 50Mi" && eq "$(j -n claims pod grower '{.status.phase} {.status.containerStatuses[0].restartCount}')" "Running 0" """,
|
|
hint=r"""`kubectl -n claims edit pvc growing`, or `kubectl patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"50Mi"}}}}'`. A claim never shrinks; with `standard` (local-path) the same patch is refused.""",
|
|
show=r"""kubectl get sc csi-hostpath-sc
|
|
kubectl -n claims patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"50Mi"}}}}'
|
|
sleep 10
|
|
kubectl -n claims get pvc growing"""),
|
|
step("Snapshot it, and restore the snapshot",
|
|
r"""Take VolumeSnapshot `growing-snap` of claim `growing` (snapshot class `csi-hostpath-snapclass`), wait until it is ready to use, then create claim `restored` (`csi-hostpath-sc`, 50Mi) **from** the snapshot: its `dataSource` names the VolumeSnapshot. The new volume holds what `growing` held when the snapshot was taken.""",
|
|
check=r"""
|
|
eq "$(j -n claims volumesnapshot growing-snap '{.spec.source.persistentVolumeClaimName} {.status.readyToUse}')" "growing true" && eq "$(j -n claims pvc restored '{.spec.dataSource.kind}/{.spec.dataSource.name} {.status.phase}')" "VolumeSnapshot/growing-snap Bound" """,
|
|
hint=r"""`apiVersion: snapshot.storage.k8s.io/v1`, `kind: VolumeSnapshot`, `spec: {volumeSnapshotClassName: ..., source: {persistentVolumeClaimName: growing}}`. The claim: `dataSource: {apiGroup: snapshot.storage.k8s.io, kind: VolumeSnapshot, name: growing-snap}`. A clone is the same with `dataSource: {kind: PersistentVolumeClaim, name: growing}`.""",
|
|
show=r"""kubectl -n claims apply -f - <<'EOF'
|
|
apiVersion: snapshot.storage.k8s.io/v1
|
|
kind: VolumeSnapshot
|
|
metadata:
|
|
name: growing-snap
|
|
spec:
|
|
volumeSnapshotClassName: csi-hostpath-snapclass
|
|
source:
|
|
persistentVolumeClaimName: growing
|
|
EOF
|
|
kubectl -n claims wait --for=jsonpath='{.status.readyToUse}'=true volumesnapshot/growing-snap --timeout=120s
|
|
kubectl -n claims apply -f - <<'EOF'
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: restored
|
|
spec:
|
|
storageClassName: csi-hostpath-sc
|
|
accessModes: [ReadWriteOnce]
|
|
resources:
|
|
requests: {storage: 50Mi}
|
|
dataSource:
|
|
apiGroup: snapshot.storage.k8s.io
|
|
kind: VolumeSnapshot
|
|
name: growing-snap
|
|
EOF
|
|
kubectl -n claims get volumesnapshot,pvc"""),
|
|
],
|
|
"outro": r"""You steered binding with a selector and with a volume's name, watched claim protection hold a claim until its Pod went, let a StatefulSet's claims follow its replicas, and grew, snapshotted and restored a claim on a CSI class.
|
|
|
|
**For the exam**
|
|
|
|
- A claim binds the smallest volume that fits, unless `selector` or `volumeName` say otherwise. The class must match on both sides.
|
|
- A `Pending` claim: `describe pvc` - no volume fits (size, modes, class, selector), the class has no provisioner, or it waits for its first consumer.
|
|
- `Terminating` claim: some Pod still uses it (`kubernetes.io/pvc-protection`). Find the Pod, not the finalizer.
|
|
- StatefulSet claims are named `TEMPLATE-STATEFULSET-ORDINAL` and kept by default; `persistentVolumeClaimRetentionPolicy` (`whenScaled`, `whenDeleted`) can delete them.
|
|
- A Pod's `ephemeral.volumeClaimTemplate` makes a claim that lives and dies with the Pod - a generic ephemeral volume, named `POD-VOLUME`.
|
|
- Expansion, snapshots and clones need a CSI driver that does them: `kubectl get sc` (ALLOWVOLUMEEXPANSION), `kubectl get volumesnapshotclass`, `kubectl get csidrivers`.
|
|
|
|
Next, prove it against the clock: **drill 1.3**.""",
|
|
}
|
|
|
|
LESSONS = [L11, L12, L13]
|