"""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, cmd, hand # ---- 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=[cmd(r"""k get sc"""), hand(r"""echo 'NAME PROVISIONER MODE' > {WS}/lesson-1.1/default.txt""", r"""From the `(default)` line: its name, `PROVISIONER` and `VOLUMEBINDINGMODE`.""", run=r"""kubectl get sc standard -o jsonpath='{.metadata.name} {.provisioner} {.volumeBindingMode}' > {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=[hand(r"""vim keep.yaml""", r"""No `kubectl create` for a StorageClass: paste the example from the docs page *Storage Classes*, and make it: ``` apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: keep provisioner: rancher.io/local-path reclaimPolicy: Retain volumeBindingMode: WaitForFirstConsumer ```""", run=r"""cat > keep.yaml <<'EOF' apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: keep provisioner: rancher.io/local-path reclaimPolicy: Retain volumeBindingMode: WaitForFirstConsumer EOF"""), cmd(r"""k apply -f keep.yaml k 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=[hand(r"""vim ledger.yaml""", r"""No `kubectl create` for a claim: paste the example from the docs page *Persistent Volumes*, and make it: ``` apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ledger spec: storageClassName: keep accessModes: [ReadWriteOnce] resources: requests: storage: 100Mi ```""", run=r"""cat > ledger.yaml <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ledger spec: storageClassName: keep accessModes: [ReadWriteOnce] resources: requests: storage: 100Mi EOF"""), cmd(r"""k -n store apply -f ledger.yaml k -n store describe pvc ledger | tail -4""", r"""The event's reason: `WaitForFirstConsumer`."""), cmd(r"""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=[cmd(r"""k -n store run bookkeeper --image=busybox:1.36 --dry-run=client -o yaml -- sh -c 'echo first > /data/entry.txt && sleep 86400' > bk.yaml"""), hand(r"""vim bk.yaml""", r"""Add the mount to the container, below `resources: {}`: ``` volumeMounts: - {name: data, mountPath: /data} ``` and the volume to the Pod's `spec:`, beside `restartPolicy:`: ``` volumes: - {name: data, persistentVolumeClaim: {claimName: ledger}} ```""", run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "data", "mountPath": "/data"}] | .spec.volumes = [{"name": "data", "persistentVolumeClaim": {"claimName": "ledger"}}]' bk.yaml"""), cmd(r"""k -n store apply -f bk.yaml k -n store get pvc ledger k get pv""", r"""Again once the Pod runs: the claim is `Bound`, and its volume has `RECLAIM POLICY` `Retain`.""")]), 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=[cmd(r"""k -n store get pvc ledger""", r"""`VOLUME` is the PersistentVolume's name."""), hand(r"""echo PV > {WS}/lesson-1.1/released.txt""", r"""`PV`: that name.""", run=r"""kubectl -n store get pvc ledger -o jsonpath='{.spec.volumeName}' > {WS}/lesson-1.1/released.txt"""), cmd(r"""k -n store delete po bookkeeper --now k -n store delete pvc ledger k get pv""", r"""The volume stays, `Released`.""")]), 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=[cmd(r"""k annotate sc standard storageclass.kubernetes.io/is-default-class=false --overwrite k annotate sc keep storageclass.kubernetes.io/is-default-class=true --overwrite k 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=[cmd(r"""k -n vols run pair --image=busybox:1.36 --dry-run=client -o yaml -- sh -c 'echo hello > /shared/msg && sleep 86400' > pair.yaml"""), hand(r"""vim pair.yaml""", r"""Name the container `writer` and give it the mount; add `reader` below it; add the volume: ``` containers: - name: writer image: busybox:1.36 args: [sh, -c, 'echo hello > /shared/msg && sleep 86400'] volumeMounts: - {name: shared, mountPath: /shared} - name: reader image: busybox:1.36 args: [sleep, '86400'] volumeMounts: - {name: shared, mountPath: /shared} volumes: - {name: shared, emptyDir: {}} ```""", run=r"""yq -i '.spec.containers[0].name = "writer" | .spec.containers[0].volumeMounts = [{"name": "shared", "mountPath": "/shared"}] | .spec.containers += [{"name": "reader", "image": "busybox:1.36", "args": ["sleep", "86400"], "volumeMounts": [{"name": "shared", "mountPath": "/shared"}]}] | .spec.volumes = [{"name": "shared", "emptyDir": {}}]' pair.yaml"""), cmd(r"""k -n vols apply -f pair.yaml k -n vols exec pair -c reader -- cat /shared/msg""", r"""Once it runs: `hello`.""")]), 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=[cmd(r"""k -n vols run scratch --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > scratch.yaml"""), hand(r"""vim scratch.yaml""", r"""Add the mount to the container, below `resources: {}`: ``` volumeMounts: - {name: tmp, mountPath: /scratch} ``` and the volume to the Pod's `spec:`, beside `restartPolicy:`: ``` volumes: - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 64Mi}} ```""", run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "tmp", "mountPath": "/scratch"}] | .spec.volumes = [{"name": "tmp", "emptyDir": {"medium": "Memory", "sizeLimit": "64Mi"}}]' scratch.yaml"""), cmd(r"""k -n vols apply -f scratch.yaml k -n vols exec scratch -- grep /scratch /proc/mounts""", r"""Once it runs: a `tmpfs`.""")]), 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=[cmd(r"""k -n vols run bundle --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > bundle.yaml"""), hand(r"""vim bundle.yaml""", r"""Add the mount to the container, below `resources: {}`: ``` volumeMounts: - {name: bundle, mountPath: /etc/bundle, readOnly: true} ``` and the volume to the Pod's `spec:`, beside `restartPolicy:` (the docs page *Projected Volumes* has an example): ``` volumes: - name: bundle projected: sources: - configMap: {name: app-config} - secret: {name: app-secret} - downwardAPI: items: - path: podname fieldRef: {fieldPath: metadata.name} ```""", run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "bundle", "mountPath": "/etc/bundle", "readOnly": true}] | .spec.volumes = [{"name": "bundle", "projected": {"sources": [{"configMap": {"name": "app-config"}}, {"secret": {"name": "app-secret"}}, {"downwardAPI": {"items": [{"path": "podname", "fieldRef": {"fieldPath": "metadata.name"}}]}}]}}]' bundle.yaml"""), cmd(r"""k -n vols apply -f bundle.yaml k -n vols exec bundle -- ls /etc/bundle""", r"""Once it runs: `mode`, `password` and `podname`.""")]), 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=[hand(r"""vim archive.yaml""", r"""No `kubectl create` for these: paste the PersistentVolume and the claim from the docs page *Configure a Pod to Use a PersistentVolume for Storage*, and make them: ``` 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} ```""", run=r"""cat > archive.yaml <<'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"""), cmd(r"""k apply -f archive.yaml k get pv archive k -n vols get pvc archive-claim""", r"""Both `Bound`, to each other.""")]), 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=[cmd(r"""k -n vols delete pvc archive-claim k get pv archive""", r"""`Released`."""), hand(r"""k edit pv archive""", r"""Delete the whole `claimRef:` block, then `:wq`. The volume is `Available` again.""", run=r"""kubectl patch pv archive --type json -p '[{"op":"remove","path":"/spec/claimRef"}]' """), hand(r"""vim claim2.yaml""", r"""The claim of `archive.yaml`, named `archive-claim-2`: ``` apiVersion: v1 kind: PersistentVolumeClaim metadata: name: archive-claim-2 namespace: vols spec: storageClassName: "" accessModes: [ReadWriteOncePod] resources: requests: {storage: 200Mi} ```""", run=r"""cat > claim2.yaml <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: archive-claim-2 namespace: vols spec: storageClassName: "" accessModes: [ReadWriteOncePod] resources: requests: {storage: 200Mi} EOF"""), cmd(r"""k apply -f claim2.yaml k 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=[hand(r"""vim gold.yaml""", r"""A claim with a `selector` (the docs page *Persistent Volumes* has one): ``` apiVersion: v1 kind: PersistentVolumeClaim metadata: name: gold-claim spec: storageClassName: manual accessModes: [ReadWriteOnce] resources: requests: {storage: 100Mi} selector: matchLabels: {tier: gold} ```""", run=r"""cat > gold.yaml <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: gold-claim spec: storageClassName: manual accessModes: [ReadWriteOnce] resources: requests: {storage: 100Mi} selector: matchLabels: {tier: gold} EOF"""), cmd(r"""k -n claims apply -f gold.yaml k get pv""", r"""`gold-claim` bound to `pv-large`.""")]), 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=[hand(r"""vim pinned.yaml""", r"""A claim that names its volume: ``` apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pinned spec: storageClassName: manual volumeName: pv-small accessModes: [ReadWriteOnce] resources: requests: {storage: 100Mi} ```""", run=r"""cat > pinned.yaml <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pinned spec: storageClassName: manual volumeName: pv-small accessModes: [ReadWriteOnce] resources: requests: {storage: 100Mi} EOF"""), cmd(r"""k -n claims apply -f pinned.yaml k -n claims run user --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > user.yaml"""), hand(r"""vim user.yaml""", r"""Add the mount to the container, below `resources: {}`: ``` volumeMounts: - {name: data, mountPath: /data} ``` and the volume to the Pod's `spec:`, beside `restartPolicy:`: ``` volumes: - {name: data, persistentVolumeClaim: {claimName: pinned}} ```""", run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "data", "mountPath": "/data"}] | .spec.volumes = [{"name": "data", "persistentVolumeClaim": {"claimName": "pinned"}}]' user.yaml"""), cmd(r"""k -n claims apply -f user.yaml k -n claims get pvc,po""")]), 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=[cmd(r"""k -n claims delete pvc pinned --wait=false k -n claims get pvc pinned k -n claims get pvc pinned -o yaml | grep -A1 finalizers"""), hand(r"""echo FINALIZER > {WS}/lesson-1.3/finalizer.txt""", r"""`FINALIZER`: the name under `finalizers:`.""", run=r"""kubectl -n claims get pvc pinned -o jsonpath='{.metadata.finalizers[0]}' > {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=[cmd(r"""k -n claims delete po user --now k -n claims get pvc k get pv pv-small""", r"""The claim is gone; `pv-small` is `Released`.""")]), 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=[hand(r"""vim db.yaml""", r"""No `kubectl create` for a StatefulSet: paste the example from the docs page *StatefulSets* (it comes with its headless Service), and make it: ``` 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} ```""", run=r"""cat > db.yaml <<'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"""), cmd(r"""k -n claims apply -f db.yaml k -n claims rollout status sts db k -n claims get pvc""", r"""`data-db-0` and `data-db-1`.""")]), 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=[cmd(r"""k -n claims scale sts db --replicas=1 k -n claims rollout status sts db k -n claims get pvc""", r"""Again in a moment: `data-db-1` is gone, `data-db-0` stays.""")]), 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=[cmd(r"""k get sc csi-hostpath-sc"""), hand(r"""k -n claims edit pvc growing""", r"""Make `storage:` under `spec.resources.requests` read `50Mi`, then `:wq`.""", run=r"""kubectl -n claims patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"50Mi"}}}}' """), cmd(r"""k -n claims get pvc growing""", r"""Again until `CAPACITY` shows `50Mi`.""")]), 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=[hand(r"""vim snap.yaml""", r"""Paste a VolumeSnapshot from the docs page *Volume Snapshots*, and make it: ``` apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: growing-snap spec: volumeSnapshotClassName: csi-hostpath-snapclass source: persistentVolumeClaimName: growing ```""", run=r"""cat > snap.yaml <<'EOF' apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: growing-snap spec: volumeSnapshotClassName: csi-hostpath-snapclass source: persistentVolumeClaimName: growing EOF"""), cmd(r"""k -n claims apply -f snap.yaml"""), hand(r"""k -n claims get volumesnapshot""", r"""Again until `READYTOUSE` is `true`.""", run=r"""kubectl -n claims wait --for=jsonpath='{.status.readyToUse}'=true volumesnapshot/growing-snap --timeout=120s"""), hand(r"""vim restored.yaml""", r"""A claim whose `dataSource` is the snapshot (the same docs page, *Provisioning Volumes from Snapshots*): ``` 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 ```""", run=r"""cat > restored.yaml <<'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"""), cmd(r"""k -n claims apply -f restored.yaml k -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]