kubestronaut-sim/cka/lessons_work.py
vumas3r 0e5f7686ee feat: add guided lessons; move CKA and CKS to v1.37 on Ubuntu hosts
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>
2026-10-20 20:15:17 -06:00

673 lines
45 KiB
Python

"""CKA lessons of the Workloads and Scheduling domain (3.1-3.5). See cka/lessons.py for what a lesson is."""
from sim.bank import NS, step
# ---- 3.1 rolling updates and rollbacks --------------------------------------------------------
L31 = {
"id": 9301, "objective": "3.1", "workers": 1,
"title": "Rollouts: strategy, a release that fails, undo, and pause",
"intro": r"""Every change to a Deployment's Pod **template** is a new **revision**: a new ReplicaSet, filled while the old one is emptied. Changes elsewhere - `replicas`, the strategy - are not revisions.
```
strategy: RollingUpdate maxSurge: extra Pods allowed maxUnavailable: missing Pods allowed
strategy: Recreate all old Pods go first, then the new ones come - a gap, on purpose
kubectl rollout status | history [--revision=N] | undo [--to-revision=N] | pause | resume | restart
```
A failed release does not roll itself back: the rollout stops, and with `maxUnavailable: 0` the old Pods keep serving until you act.
Namespace `rollout` runs Deployment `api`: `nginx:1.26`, 4 replicas.""",
"setup": NS + r"""
mkns rollout
mkdir -p "$WS/lesson-3.1"
$K -n rollout create deploy api --image=nginx:1.26 --replicas=4 >/dev/null
$K -n rollout rollout status deploy api --timeout=180s >/dev/null
""",
"steps": [
step("Never fewer than four",
r"""Change the update strategy of `api` so that an update first adds **one** Pod and never takes one away before its replacement is ready: `maxSurge: 1`, `maxUnavailable: 0`.""",
check=r"""eq "$(j -n rollout deploy api '{.spec.strategy.type} {.spec.strategy.rollingUpdate.maxSurge} {.spec.strategy.rollingUpdate.maxUnavailable}')" "RollingUpdate 1 0" """,
hint=r"""`kubectl -n rollout edit deploy api`, or `kubectl patch` with `{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}`.""",
show=r"""kubectl -n rollout patch deploy api -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
kubectl -n rollout get deploy api -o jsonpath='{.spec.strategy}'; echo"""),
step("A release with a reason",
r"""Update `api` to `nginx:1.27`, and record why: the annotation `kubernetes.io/change-cause` on the Deployment, set to `upgrade to 1.27`. `kubectl rollout history` shows it beside the revision.""",
check=r"""
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1
eq "$(j -n rollout deploy api '{.metadata.annotations.kubernetes\.io/change-cause}')" "upgrade to 1.27" || exit 1
eq "$(j -n rollout deploy api '{.status.updatedReplicas} {.status.availableReplicas}')" "4 4" """,
hint=r"""`kubectl set image deploy/api nginx=nginx:1.27` (the container is called `nginx`), then `kubectl annotate deploy api kubernetes.io/change-cause='...'`. The `--record` flag is gone.""",
show=r"""kubectl -n rollout set image deploy/api nginx=nginx:1.27
kubectl -n rollout annotate deploy api kubernetes.io/change-cause='upgrade to 1.27' --overwrite
kubectl -n rollout rollout status deploy api --timeout=180s
kubectl -n rollout rollout history deploy api"""),
step("A release that cannot start",
r"""Now a mistake: set the image of `api` to `nginx:1.27-broken`, a tag that does not exist.
Watch what happens: one new Pod (the surge) fails to pull, and the rollout stops there. Because `maxUnavailable` is `0`, all four `nginx:1.27` Pods keep serving. `kubectl rollout status` would wait for ever (here, until `progressDeadlineSeconds` marks it failed).""",
check=r"""
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.27-broken || exit 1
[ "$(j -n rollout deploy api '{.status.availableReplicas}')" = 4 ] || exit 1
$K -n rollout get pods -l app=api --no-headers 2>/dev/null | grep -Eq 'ErrImagePull|ImagePullBackOff' """,
hint=r"""`kubectl set image` again. `kubectl -n rollout get pods` shows the Pod that cannot pull; `kubectl -n rollout get rs` shows the two ReplicaSets side by side.""",
show=r"""kubectl -n rollout set image deploy/api nginx=nginx:1.27-broken
sleep 10
kubectl -n rollout get pods
kubectl -n rollout get rs"""),
step("Undo",
r"""Roll `api` back to the revision before - `nginx:1.27` - and let the rollout finish.""",
check=r"""
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1
eq "$(j -n rollout deploy api '{.status.updatedReplicas} {.status.availableReplicas} {.status.replicas}')" "4 4 4" """,
hint=r"""`kubectl rollout undo deploy/api`. It does not create a ReplicaSet: it scales the old one back up, and that revision gets a new number.""",
show=r"""kubectl -n rollout rollout undo deploy/api
kubectl -n rollout rollout status deploy api --timeout=180s
kubectl -n rollout rollout history deploy api"""),
step("Back to a revision by number",
r"""Look at the history, then go back to the revision that ran **`nginx:1.26`**, by its number. `kubectl rollout history deploy api --revision=N` shows what a revision's template was.""",
check=r"""
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.26 || exit 1
eq "$(j -n rollout deploy api '{.status.updatedReplicas} {.status.availableReplicas} {.status.replicas}')" "4 4 4" """,
hint=r"""Revision 1 is where the Deployment started. `kubectl rollout undo deploy/api --to-revision=1`.""",
show=r"""kubectl -n rollout rollout history deploy api
kubectl -n rollout rollout history deploy api --revision=1 | grep Image
kubectl -n rollout rollout undo deploy/api --to-revision=1
kubectl -n rollout rollout status deploy api --timeout=180s"""),
step("Two changes, one rollout",
r"""Each `set` command starts a rollout of its own. To make two changes at once, **pause** the Deployment, make them - image `nginx:1.27`, and an environment variable `MODE=prod` in the container - then **resume**: one new revision, one rollout.""",
check=r"""
[ "$(j -n rollout deploy api '{.spec.paused}')" != true ] || exit 1
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].env[?(@.name=="MODE")].value}')" prod || exit 1
eq "$(j -n rollout deploy api '{.status.updatedReplicas} {.status.availableReplicas} {.status.replicas}')" "4 4 4" """,
hint=r"""`kubectl rollout pause deploy/api`, `kubectl set image ...`, `kubectl set env deploy/api MODE=prod`, `kubectl rollout resume deploy/api`.""",
show=r"""kubectl -n rollout rollout pause deploy/api
kubectl -n rollout set image deploy/api nginx=nginx:1.27
kubectl -n rollout set env deploy/api MODE=prod
kubectl -n rollout rollout resume deploy/api
kubectl -n rollout rollout status deploy api --timeout=180s
kubectl -n rollout rollout history deploy api"""),
],
"outro": r"""You tuned the strategy, released with a recorded cause, watched a broken release stall safely, undid it, went back to a numbered revision, and batched two changes into one rollout.
**For the exam**
- `kubectl set image deploy/NAME CONTAINER=IMAGE` - the container's name, not the image's.
- `rollout undo` goes to the previous revision; `--to-revision=N` to any in `rollout history`. The history keeps `revisionHistoryLimit` old ReplicaSets (10 by default).
- `maxSurge` / `maxUnavailable` take numbers or percentages; `maxUnavailable: 0` keeps full capacity during an update. `Recreate` has no such fields.
- `rollout restart` makes new Pods with an unchanged spec (it stamps an annotation into the template).
- A stuck rollout: `kubectl rollout status` waits, `describe deploy` shows `Progressing=False` after `progressDeadlineSeconds`.
Next, prove it against the clock: **drill 3.1**.""",
}
# ---- 3.2 ConfigMaps and Secrets ---------------------------------------------------------------
_ENC_CHECK = r"""
nx "$CP" 'ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/conf/db --print-value-only 2>/dev/null | head -c 64' </dev/null | grep -aq '^k8s:enc:aescbc:v1:'"""
L32 = {
"id": 9302, "objective": "3.2", "workers": 1,
"title": "ConfigMaps and Secrets: create, consume, update, and encrypt",
"intro": r"""A ConfigMap holds configuration, a Secret holds the confidential part of it - and both reach a container the same three ways:
```
env: - name: X valueFrom: {configMapKeyRef | secretKeyRef} one key, renamed if you like
envFrom: - configMapRef | secretRef every key, as it is called
volumes: - configMap | secret every key as a file
```
Only a mounted volume follows later changes (after a short delay, and never with `subPath`); environment variables are read once, when the container starts.
A Secret is **base64-encoded, not encrypted**: anyone who may read it, or read etcd, has it. Encryption at rest is the API server's job, set up with an `EncryptionConfiguration`.
Files for this lesson are in `{WS}/lesson-3.2/`; Namespace `conf` is empty.""",
"setup": NS + r"""
mkns conf
mkdir -p "$WS/lesson-3.2"
printf 'log.level=info\ncache.size=128\n' > "$WS/lesson-3.2/app.properties"
printf 'DB_USER=ledger\nDB_PASS=t0ps3cret\n' > "$WS/lesson-3.2/db.env"
""",
"steps": [
step("A ConfigMap from a file and a literal",
r"""Create ConfigMap `app-config` in `conf` with two keys: `app.properties`, holding the file `{WS}/lesson-3.2/app.properties`, and `MODE` with the value `prod`.""",
check=r"""
eq "$(j -n conf cm app-config '{.data.MODE}')" prod && $K -n conf get cm app-config -o jsonpath='{.data.app\.properties}' | grep -q '^cache.size=128$'""",
hint=r"""`kubectl create configmap NAME --from-file=PATH --from-literal=KEY=VALUE`. With `--from-file`, the key is the file's name.""",
show=r"""kubectl -n conf create configmap app-config --from-file={WS}/lesson-3.2/app.properties --from-literal=MODE=prod
kubectl -n conf describe cm app-config"""),
step("A Secret from an env file",
r"""Create Secret `db` (type `Opaque`) in `conf` from `{WS}/lesson-3.2/db.env`, one key per line of the file: `DB_USER` and `DB_PASS`. Then read `DB_PASS` back, decoded.""",
check=r"""
eq "$(j -n conf secret db '{.type}')" Opaque && eq "$(j -n conf secret db '{.data.DB_USER}' | base64 -d)" ledger && eq "$(j -n conf secret db '{.data.DB_PASS}' | base64 -d)" t0ps3cret""",
hint=r"""`kubectl create secret generic NAME --from-env-file=PATH`. Reading one key: `-o jsonpath='{.data.DB_PASS}' | base64 -d`.""",
show=r"""kubectl -n conf create secret generic db --from-env-file={WS}/lesson-3.2/db.env
kubectl -n conf get secret db -o jsonpath='{.data.DB_PASS}' | base64 -d; echo"""),
step("Consume both",
r"""Run Pod `app` in `conf` (`busybox:1.36`, `sleep 86400`) that gets:
- every key of Secret `db` as an environment variable (`envFrom`),
- the variable `APP_MODE` from key `MODE` of ConfigMap `app-config`,
- ConfigMap `app-config` as files under `/etc/app`.""",
check=r"""
x() { $K -n conf exec app -- "$@" 2>/dev/null; }
eq "$(x printenv DB_PASS)" t0ps3cret && eq "$(x printenv APP_MODE)" prod && x cat /etc/app/app.properties | grep -q '^log.level=info$'""",
hint=r"""`envFrom: [{secretRef: {name: db}}]`, `env: [{name: APP_MODE, valueFrom: {configMapKeyRef: {name: app-config, key: MODE}}}]`, and a `configMap` volume.""",
show=r"""kubectl -n conf apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: busybox:1.36
command: [sleep, '86400']
envFrom:
- secretRef: {name: db}
env:
- name: APP_MODE
valueFrom:
configMapKeyRef: {name: app-config, key: MODE}
volumeMounts:
- {name: config, mountPath: /etc/app}
volumes:
- name: config
configMap: {name: app-config}
EOF
kubectl -n conf wait --for=condition=Ready pod/app --timeout=120s
kubectl -n conf exec app -- sh -c 'printenv DB_USER APP_MODE; cat /etc/app/app.properties'"""),
step("Change the configuration, live",
r"""Change `cache.size` to `256` in ConfigMap `app-config` - edit the file and re-create the ConfigMap's key from it, or `kubectl edit`.
Then watch `/etc/app/app.properties` inside Pod `app`: within a minute the kubelet replaces the file. `APP_MODE` would not change, even if you edited `MODE`: the environment is fixed at start.""",
check=r"""$K -n conf exec app -- cat /etc/app/app.properties 2>/dev/null | grep -q '^cache.size=256$'""",
hint=r"""Regenerate and apply: `kubectl create configmap app-config --from-file=... --from-literal=MODE=prod --dry-run=client -o yaml | kubectl apply -f -` - the usual way to update a ConfigMap from files.""",
show=r"""sed -i 's/^cache.size=.*/cache.size=256/' {WS}/lesson-3.2/app.properties
kubectl -n conf create configmap app-config --from-file={WS}/lesson-3.2/app.properties --from-literal=MODE=prod --dry-run=client -o yaml | kubectl apply -f -
sleep 30
kubectl -n conf exec app -- cat /etc/app/app.properties"""),
step("A TLS Secret",
r"""Make a self-signed certificate for `shop.example.com` (`openssl` is on this host), and store it as Secret `shop-tls` in `conf`, of type `kubernetes.io/tls` - the type an Ingress or Gateway expects. Its keys are `tls.crt` and `tls.key`.""",
check=r"""
eq "$(j -n conf secret shop-tls '{.type}')" kubernetes.io/tls || exit 1
j -n conf secret shop-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'shop.example.com'""",
hint=r"""`openssl req -x509 -newkey rsa:2048 -nodes -keyout tls.key -out tls.crt -days 30 -subj /CN=shop.example.com`, then `kubectl create secret tls NAME --cert=tls.crt --key=tls.key`.""",
show=r"""cd {WS}/lesson-3.2
openssl req -x509 -newkey rsa:2048 -nodes -keyout tls.key -out tls.crt -days 30 -subj /CN=shop.example.com 2>/dev/null
kubectl -n conf create secret tls shop-tls --cert=tls.crt --key=tls.key
kubectl -n conf get secret shop-tls
cd"""),
step("Encrypt Secrets at rest",
r"""Read Secret `db` straight out of etcd on `{CP}`, and you see `t0ps3cret` in plain text. Make the API server encrypt Secrets before it stores them:
1. Write an `EncryptionConfiguration` to `/etc/kubernetes/enc/enc.yaml`: for resource `secrets`, provider `aescbc` with one key `key1` (32 random bytes, base64), then `identity: {}` so that what is still unencrypted can be read.
2. Start the API server with `--encryption-provider-config=/etc/kubernetes/enc/enc.yaml`, and mount `/etc/kubernetes/enc` into its Pod (a `hostPath` volume): it runs in a container and sees only what is mounted.
3. Once it is back, rewrite Secret `db`, so that it is stored again - encrypted this time.
Edit a copy of the manifest outside `/etc/kubernetes/manifests` and copy it back: the kubelet runs every file in that directory, backups too.""",
check=_ENC_CHECK + r""" && eq "$(j -n conf secret db '{.data.DB_PASS}' | base64 -d)" t0ps3cret""",
hint=r"""The docs page "Encrypting Confidential Data at Rest" has the file. Stored values of encrypted Secrets start with `k8s:enc:aescbc:v1:key1:`: `sudo ETCDCTL_API=3 etcdctl ... get /registry/secrets/conf/db | od -c | head` (or `hexdump -C`). Rewriting: `kubectl get secret db -o json | kubectl replace -f -`. If the API server does not come back, `sudo crictl ps -a` and the logs under `/var/log/pods/kube-system_kube-apiserver-*` say why.""",
show=r"""sudo mkdir -p /etc/kubernetes/enc
sudo tee /etc/kubernetes/enc/enc.yaml >/dev/null <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: [secrets]
providers:
- aescbc:
keys:
- name: key1
secret: $(head -c 32 /dev/urandom | base64)
- identity: {}
EOF
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /tmp/kube-apiserver.yaml
sudo yq -i '.spec.containers[0].command += ["--encryption-provider-config=/etc/kubernetes/enc/enc.yaml"]
| .spec.containers[0].volumeMounts += [{"name": "enc", "mountPath": "/etc/kubernetes/enc", "readOnly": true}]
| .spec.volumes += [{"name": "enc", "hostPath": {"path": "/etc/kubernetes/enc", "type": "DirectoryOrCreate"}}]' /tmp/kube-apiserver.yaml
sudo cp /tmp/kube-apiserver.yaml /etc/kubernetes/manifests/kube-apiserver.yaml
sleep 20
for i in $(seq 1 40); do kubectl get --raw /readyz >/dev/null 2>&1 && break; sleep 3; done
kubectl -n conf get secret db -o json | kubectl replace -f -
sudo ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key \
get /registry/secrets/conf/db | od -c | head -4"""),
],
"outro": r"""You built a ConfigMap and Secrets from files, consumed them three ways, updated configuration in a running Pod, and turned on encryption at rest for Secrets.
**For the exam**
- `kubectl create configmap|secret generic NAME --from-literal --from-file --from-env-file`; `kubectl create secret tls|docker-registry`. Generate, then apply: `--dry-run=client -o yaml | kubectl apply -f -`.
- `immutable: true` on a ConfigMap or Secret: no more changes, only delete and re-create - and Pods must restart to see the new one.
- Mounted keys follow updates; `subPath` mounts and environment variables do not.
- Encryption at rest: `--encryption-provider-config` and a mount for the file. The **first** provider encrypts; the others are tried when reading. `identity` first means "not encrypted". Existing Secrets stay as they are until rewritten (`kubectl get secrets -A -o json | kubectl replace -f -`).
Next, prove it against the clock: **drill 3.2**.""",
}
# ---- 3.3 autoscaling --------------------------------------------------------------------------
L33 = {
"id": 9303, "objective": "3.3", "workers": 1,
"title": "Autoscaling: the HorizontalPodAutoscaler, and resizing a Pod in place",
"intro": r"""Three kinds of autoscaling exist, and only one is built in:
```
HorizontalPodAutoscaler more or fewer Pods, from metrics built in (needs metrics-server)
VerticalPodAutoscaler bigger or smaller requests per Pod an add-on, with its own CRDs
Cluster Autoscaler more or fewer nodes an add-on, per cloud
```
An HPA compares each Pod's usage with its **request**: 50% CPU means half of what the Pod requested. A Pod without a request gives it nothing to compare against. The HPA checks every 15 seconds and waits before scaling down (`behavior.scaleDown.stabilizationWindowSeconds`, five minutes unless you say otherwise).
On v1.37 a running Pod's CPU and memory can also be changed **in place**, through its `resize` subresource, without a new Pod.
metrics-server runs here. In Namespace `scale`, Deployment `api` (nginx) requests 100m CPU and idles; Deployment `cruncher` requests 50m and burns CPU.""",
"setup": NS + r"""
ensure_metrics_server || { echo "metrics-server did not become ready" >&2; exit 1; }
mkns scale
$K -n scale apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: api}
spec:
replicas: 1
selector: {matchLabels: {app: api}}
template:
metadata: {labels: {app: api}}
spec:
containers:
- name: nginx
image: nginx:1.27
resources: {requests: {cpu: 100m, memory: 64Mi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: cruncher}
spec:
replicas: 1
selector: {matchLabels: {app: cruncher}}
template:
metadata: {labels: {app: cruncher}}
spec:
containers:
- name: cruncher
image: busybox:1.36
command: [sh, -c, 'while true; do :; done']
resources: {requests: {cpu: 50m, memory: 16Mi}, limits: {cpu: 150m, memory: 32Mi}}
EOF
$K -n scale rollout status deploy api --timeout=180s >/dev/null && $K -n scale rollout status deploy cruncher --timeout=180s >/dev/null
""",
"steps": [
step("An HPA in one command",
r"""Make `api` scale between **2** and **6** replicas, aiming at **50%** CPU. Within a minute the HPA shows a current value - `kubectl -n scale get hpa` - and `api` runs at least 2 replicas.""",
check=r"""
eq "$(j -n scale hpa api '{.spec.minReplicas} {.spec.maxReplicas} {.spec.metrics[0].resource.name} {.spec.metrics[0].resource.target.averageUtilization}')" "2 6 cpu 50" || exit 1
[ -n "$(j -n scale hpa api '{.status.currentMetrics[0].resource.current.averageUtilization}')" ] && [ "$(j -n scale deploy api '{.status.readyReplicas}')" -ge 2 ] 2>/dev/null""",
hint=r"""`kubectl autoscale deploy api --min=2 --max=6 --cpu=50%` (kubectl before 1.34: `--cpu-percent=50`). `<unknown>` in the TARGETS column means no metrics yet - or a Pod without a CPU request.""",
show=r"""kubectl -n scale autoscale deploy api --min=2 --max=6 --cpu=50%
sleep 45
kubectl -n scale get hpa api"""),
step("Scale down calmly",
r"""Give the HPA of `api` a `behavior`: when scaling **down**, wait **120** seconds of stable recommendations (`stabilizationWindowSeconds`), and remove at most **1** Pod per **60** seconds.
`kubectl autoscale` cannot do that; edit the HPA (`autoscaling/v2`).""",
check=r"""
eq "$(j -n scale hpa api '{.spec.behavior.scaleDown.stabilizationWindowSeconds}')" 120 || exit 1
eq "$(j -n scale hpa api '{.spec.behavior.scaleDown.policies[0].type} {.spec.behavior.scaleDown.policies[0].value} {.spec.behavior.scaleDown.policies[0].periodSeconds}')" "Pods 1 60" """,
hint=r"""Under `spec`: `behavior: {scaleDown: {stabilizationWindowSeconds: 120, policies: [{type: Pods, value: 1, periodSeconds: 60}]}}`. `kubectl explain hpa.spec.behavior.scaleDown`.""",
show=r"""kubectl -n scale patch hpa api --type merge -p '{"spec":{"behavior":{"scaleDown":{"stabilizationWindowSeconds":120,"policies":[{"type":"Pods","value":1,"periodSeconds":60}]}}}}'
kubectl -n scale describe hpa api | grep -A4 Behavior"""),
step("Watch it scale up",
r"""Write an HPA for `cruncher` as a manifest (`autoscaling/v2`): 1 to **4** replicas at **60%** average CPU. `cruncher` uses about three times its request, so the HPA soon adds Pods: wait until it wants more than one.""",
check=r"""
eq "$(j -n scale hpa cruncher '{.spec.maxReplicas} {.spec.metrics[0].resource.target.averageUtilization}')" "4 60" || exit 1
[ "$(j -n scale hpa cruncher '{.status.desiredReplicas}')" -gt 1 ] 2>/dev/null""",
hint=r"""`kubectl autoscale ... --dry-run=client -o yaml` writes the manifest for you. `kubectl -n scale get hpa cruncher -w` shows it react; `describe hpa` lists its decisions as events.""",
show=r"""kubectl -n scale apply -f - <<'EOF'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cruncher
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cruncher
minReplicas: 1
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
EOF
sleep 60
kubectl -n scale get hpa cruncher"""),
step("Resize a running Pod",
r"""Run Pod `sizer` in `scale` (`busybox:1.36`, `sleep 86400`) with CPU request `100m` and limit `200m`, memory request and limit `64Mi`.
Then give it more CPU **without replacing it**: request `150m`, limit `300m`, through the Pod's `resize` subresource. The container keeps running - its restart count stays 0 - and the Pod's status shows the new values once the kubelet has applied them.""",
check=r"""
eq "$(j -n scale pod sizer '{.spec.containers[0].resources.requests.cpu} {.spec.containers[0].resources.limits.cpu}')" "150m 300m" || exit 1
eq "$(j -n scale pod sizer '{.status.containerStatuses[0].resources.limits.cpu} {.status.containerStatuses[0].restartCount}')" "300m 0" """,
hint=r"""`kubectl patch pod sizer --subresource resize --patch '{"spec":{"containers":[{"name":"sizer","resources":{...}}]}}'`. A plain `kubectl edit` of a Pod's resources is refused; the subresource is the way in. A container's `resizePolicy` decides whether a change needs a restart.""",
show=r"""kubectl -n scale run sizer --image=busybox:1.36 --overrides='{"spec":{"containers":[{"name":"sizer","image":"busybox:1.36","command":["sleep","86400"],"resources":{"requests":{"cpu":"100m","memory":"64Mi"},"limits":{"cpu":"200m","memory":"64Mi"}}}]}}'
kubectl -n scale wait --for=condition=Ready pod/sizer --timeout=120s
kubectl -n scale patch pod sizer --subresource resize --patch '{"spec":{"containers":[{"name":"sizer","resources":{"requests":{"cpu":"150m"},"limits":{"cpu":"300m"}}}]}}'
sleep 5
kubectl -n scale get pod sizer -o jsonpath='{.status.containerStatuses[0].resources}{"\n"}{.status.containerStatuses[0].restartCount}{"\n"}'"""),
],
"outro": r"""You made HPAs both ways, slowed down their scale-down, watched one scale up, and resized a running Pod without replacing it.
**For the exam**
- `kubectl autoscale deploy NAME --min --max --cpu=N%` for the simple case; `autoscaling/v2` YAML for memory, several metrics, or `behavior`.
- The HPA needs metrics-server **and** requests on the target's containers. `kubectl describe hpa` explains `<unknown>`.
- Utilization is relative to the request; `averageValue` is absolute (`target: {type: AverageValue, averageValue: 200Mi}`).
- In-place resize: `kubectl patch pod NAME --subresource resize ...`; `resizePolicy` (`NotRequired` / `RestartContainer`) per resource. Deployments still replace Pods when their template changes.
- VPA and the Cluster Autoscaler are add-ons: know what they do, and that the VPA should not fight an HPA over the same metric.
Next, prove it against the clock: **drill 3.3**.""",
}
# ---- 3.4 robust, self-healing deployments ------------------------------------------------------
L34 = {
"id": 9304, "objective": "3.4", "workers": 1,
"title": "Self-healing: ReplicaSets, probes, disruption budgets, Jobs",
"intro": r"""Kubernetes heals by comparing what is with what should be, over and over:
```
ReplicaSet too few Pods? make more (a Deployment manages them)
livenessProbe container stuck? restart it
readinessProbe not ready? take it out of the Service's endpoints
startupProbe slow to start? hold the other probes until it is up
PodDisruptionBudget how many Pods a voluntary disruption (drain, eviction) may take
Job / CronJob run to completion, retry on failure, on a schedule
```
Namespace `heal` runs Deployment `web` (nginx, 3 replicas) and Deployment `api` (nginx, 2 replicas), whose Pods never become ready.""",
"setup": NS + r"""
mkns heal
mkdir -p "$WS/lesson-3.4"
$K -n heal create deploy web --image=nginx:1.27 --replicas=3 >/dev/null
$K -n heal apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: api}
spec:
replicas: 2
selector: {matchLabels: {app: api}}
template:
metadata: {labels: {app: api}}
spec:
containers:
- name: nginx
image: nginx:1.27
readinessProbe:
httpGet: {path: /healthz, port: 80}
periodSeconds: 5
EOF
$K -n heal rollout status deploy web --timeout=180s >/dev/null
$K -n heal get pods -l app=web -o jsonpath='{.items[*].metadata.uid}' > "$PRIV/lesson-3.4.uids"
""",
"steps": [
step("Delete a Pod, get a Pod",
r"""Delete one Pod of Deployment `web`. Its ReplicaSet notices that it has 2 of 3, and makes a new one at once.
Write the name of that ReplicaSet to `{WS}/lesson-3.4/rs.txt`. `kubectl get pod POD -o yaml` names it under `ownerReferences`.""",
check=r"""
rs=$(tr -d ' \r\n' < "$WS/lesson-3.4/rs.txt" 2>/dev/null); [ -n "$rs" ] || exit 1
eq "$(j -n heal rs "$rs" '{.metadata.ownerReferences[0].name} {.status.readyReplicas}')" "web 3" || exit 1
now=$(j -n heal pods -l app=web '{.items[*].metadata.uid}'); for u in $now; do grep -q "$u" "$PRIV/lesson-3.4.uids" || exit 0; done; exit 1""",
hint=r"""`kubectl -n heal delete pod $(kubectl -n heal get pod -l app=web -o name | head -1)`. Pods of a Deployment are named DEPLOYMENT-RSHASH-RANDOM; the middle part is the ReplicaSet's suffix.""",
show=r"""kubectl -n heal delete $(kubectl -n heal get pod -l app=web -o name | head -1)
kubectl -n heal get pods -l app=web
kubectl -n heal get pod -l app=web -o jsonpath='{.items[0].metadata.ownerReferences[0].name}' > {WS}/lesson-3.4/rs.txt
kubectl -n heal get rs"""),
step("A readiness probe that asks the wrong thing",
r"""The Pods of `api` run, but none is `Ready`: `kubectl -n heal describe pod -l app=api` shows the readiness probe failing with `404`. nginx has no `/healthz`. Make the probe ask for `/` instead, and both Pods become ready.""",
check=r"""eq "$(j -n heal deploy api '{.spec.template.spec.containers[0].readinessProbe.httpGet.path}')" / && eq "$(j -n heal deploy api '{.status.readyReplicas}')" 2""",
hint=r"""A JSON patch on `/spec/template/spec/containers/0/readinessProbe/httpGet/path`, or `kubectl edit deploy api`. The fix is a change of template: a rollout.""",
show=r"""kubectl -n heal describe pod -l app=api | grep -m2 Readiness
kubectl -n heal patch deploy api --type json -p '[{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe/httpGet/path","value":"/"}]'
kubectl -n heal rollout status deploy api --timeout=120s"""),
step("A liveness probe that restarts",
r"""Run Pod `fragile` in `heal` (`busybox:1.36`) with the command `sh -c 'touch /tmp/healthy; sleep 20; rm /tmp/healthy; sleep 86400'` and a **liveness** probe that runs `cat /tmp/healthy` every 5 seconds, starting after 5.
After 20 seconds the file is gone, the probe fails three times, and the kubelet restarts the container - which makes the file again. Wait for the first restart.""",
check=r"""
eq "$(j -n heal pod fragile '{.spec.containers[0].livenessProbe.exec.command[1]}')" /tmp/healthy || exit 1
[ "$(j -n heal pod fragile '{.status.containerStatuses[0].restartCount}')" -ge 1 ] 2>/dev/null""",
hint=r"""`livenessProbe: {exec: {command: [cat, /tmp/healthy]}, initialDelaySeconds: 5, periodSeconds: 5}`. `kubectl -n heal get pod fragile -w` shows RESTARTS go up; `describe` shows `Liveness probe failed`.""",
show=r"""kubectl -n heal apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: fragile
spec:
containers:
- name: fragile
image: busybox:1.36
command: [sh, -c, 'touch /tmp/healthy; sleep 20; rm /tmp/healthy; sleep 86400']
livenessProbe:
exec:
command: [cat, /tmp/healthy]
initialDelaySeconds: 5
periodSeconds: 5
EOF
sleep 50
kubectl -n heal get pod fragile"""),
step("A budget for disruptions",
r"""Create PodDisruptionBudget `web-pdb` in `heal`: at least **2** Pods of `app=web` must stay available.
Delete Pod `fragile` first: it has done its job, and `drain` refuses a Pod that no controller would re-create (unless `--force`, which deletes it for good).
Then try to drain `{W1}`, where all three `web` Pods run: `kubectl drain {W1} --ignore-daemonsets --timeout=30s`. It evicts what it may and refuses the rest - "would violate the pod's disruption budget". Save its output in `{WS}/lesson-3.4/drain.txt`, then make `{W1}` schedulable again.""",
check=r"""
eq "$(j -n heal pdb web-pdb '{.spec.minAvailable} {.spec.selector.matchLabels.app}')" "2 web" || exit 1
grep -qi 'disruption budget' "$WS/lesson-3.4/drain.txt" 2>/dev/null && [ "$(j node "$W1" '{.spec.unschedulable}')" != true ]""",
hint=r"""`kubectl create pdb web-pdb --selector=app=web --min-available=2`. Drain prints the refusals on stderr: `2>&1 | tee FILE`. `kubectl uncordon {W1}` afterwards - drain cordons first.""",
show=r"""kubectl -n heal create pdb web-pdb --selector=app=web --min-available=2
kubectl -n heal get pdb web-pdb
kubectl -n heal delete pod fragile --grace-period=1
kubectl drain {W1} --ignore-daemonsets --delete-emptydir-data --timeout=30s 2>&1 | tee {WS}/lesson-3.4/drain.txt | tail -3
kubectl uncordon {W1}"""),
step("A Job that runs to completion",
r"""Create Job `batch` in `heal`: `busybox:1.36` running `sh -c 'echo processed; sleep 2'`, **3** completions, **2** at a time, giving up after **2** retries (`backoffLimit`). Wait until it is complete.""",
check=r"""eq "$(j -n heal job batch '{.spec.completions} {.spec.parallelism} {.spec.backoffLimit} {.status.succeeded}')" "3 2 2 3" """,
hint=r"""`kubectl create job batch --image=busybox:1.36 --dry-run=client -o yaml -- sh -c '...'`, then add `completions`, `parallelism` and `backoffLimit` under `spec`. `kubectl wait --for=condition=complete job/batch`.""",
show=r"""kubectl -n heal create job batch --image=busybox:1.36 --dry-run=client -o yaml -- sh -c 'echo processed; sleep 2' > {WS}/lesson-3.4/batch.yaml
yq -i '.spec.completions = 3 | .spec.parallelism = 2 | .spec.backoffLimit = 2' {WS}/lesson-3.4/batch.yaml
kubectl -n heal apply -f {WS}/lesson-3.4/batch.yaml
kubectl -n heal wait --for=condition=complete job/batch --timeout=120s
kubectl -n heal get pods -l job-name=batch"""),
step("A CronJob, and a run on demand",
r"""Create CronJob `report` in `heal`: every 30 minutes (`*/30 * * * *`), `busybox:1.36` running `date`, never two runs at once (`concurrencyPolicy: Forbid`), keeping the last **2** successful Jobs.
Do not wait half an hour: start a Job `report-now` from the CronJob yourself, and let it finish.""",
check=r"""
eq "$(j -n heal cronjob report '{.spec.schedule} {.spec.concurrencyPolicy} {.spec.successfulJobsHistoryLimit}')" "*/30 * * * * Forbid 2" || exit 1
eq "$(j -n heal job report-now '{.metadata.annotations.cronjob\.kubernetes\.io/instantiate} {.status.succeeded}')" "manual 1" """,
hint=r"""`kubectl create cronjob report --image=busybox:1.36 --schedule='*/30 * * * *' --dry-run=client -o yaml -- date`, then the two fields. `kubectl create job report-now --from=cronjob/report`.""",
show=r"""kubectl -n heal create cronjob report --image=busybox:1.36 --schedule='*/30 * * * *' --dry-run=client -o yaml -- date > {WS}/lesson-3.4/report.yaml
yq -i '.spec.concurrencyPolicy = "Forbid" | .spec.successfulJobsHistoryLimit = 2' {WS}/lesson-3.4/report.yaml
kubectl -n heal apply -f {WS}/lesson-3.4/report.yaml
kubectl -n heal create job report-now --from=cronjob/report
kubectl -n heal wait --for=condition=complete job/report-now --timeout=120s"""),
],
"outro": r"""You watched a ReplicaSet replace a Pod, fixed a readiness probe, saw a liveness probe restart a container, stopped a drain with a disruption budget, and ran a Job and a CronJob.
**For the exam**
- Readiness decides **traffic** (endpoints); liveness decides **restarts**; a startup probe holds both off until the app has started. Probes: `httpGet`, `tcpSocket`, `exec`, `grpc`.
- A PDB has `minAvailable` **or** `maxUnavailable`. It stops evictions - a drain, or anything else that uses the Eviction API - never a `kubectl delete pod`, and never a node that just dies.
- Jobs: `completions`, `parallelism`, `backoffLimit`, `activeDeadlineSeconds`, `ttlSecondsAfterFinished`; a Job's Pods use `restartPolicy: Never` or `OnFailure`.
- CronJobs: `schedule` in cron syntax (UTC unless `timeZone`), `concurrencyPolicy`, `suspend`, history limits; `kubectl create job NAME --from=cronjob/CJ` to test one.
Next, prove it against the clock: **drill 3.4**.""",
}
# ---- 3.5 Pod admission and scheduling ---------------------------------------------------------
L35 = {
"id": 9305, "objective": "3.5", "workers": 2,
"title": "Scheduling: QoS, affinity, spreading, taints, defaults, and nodeName",
"intro": r"""The scheduler puts a Pod on a node that passes every **filter** and scores best:
```
requests does it fit? (requests, not limits, and not usage)
nodeSelector / nodeAffinity may it go there? (node labels)
podAffinity / podAntiAffinity near or away from other Pods (their labels, per topologyKey)
taints and tolerations is the node closed to it? cordon = the unschedulable taint
```
`nodeName` skips the scheduler entirely. Requests and limits also give each Pod a **QoS class**, which decides who is evicted first when a node runs short: `BestEffort`, then `Burstable`, then `Guaranteed`. A **LimitRange** fills in requests and limits for Pods that bring none.
This cluster has two workers, `{W1}` and `{W2}`. Namespace `sched` is empty.""",
"setup": NS + r"""
mkns sched
""",
"steps": [
step("Three QoS classes",
r"""Create three Pods in `sched`, each `busybox:1.36` running `sleep 86400`:
- `gold`: requests **equal** to limits, for CPU (`100m`) and memory (`64Mi`) → `Guaranteed`
- `silver`: only a memory request of `32Mi` → `Burstable`
- `bronze`: no resources at all → `BestEffort`""",
check=r"""
for p in gold:Guaranteed silver:Burstable bronze:BestEffort; do eq "$(j -n sched pod "${p%%:*}" '{.status.qosClass}')" "${p##*:}" || exit 1; done""",
hint=r"""`kubectl run NAME --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400`, then `resources:` in the container. `.status.qosClass` shows the result.""",
show=r"""kubectl -n sched run bronze --image=busybox:1.36 -- sleep 86400
kubectl -n sched run silver --image=busybox:1.36 --overrides='{"spec":{"containers":[{"name":"silver","image":"busybox:1.36","command":["sleep","86400"],"resources":{"requests":{"memory":"32Mi"}}}]}}'
kubectl -n sched run gold --image=busybox:1.36 --overrides='{"spec":{"containers":[{"name":"gold","image":"busybox:1.36","command":["sleep","86400"],"resources":{"requests":{"cpu":"100m","memory":"64Mi"},"limits":{"cpu":"100m","memory":"64Mi"}}}]}}'
kubectl -n sched get pods -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass"""),
step("Only on SSD nodes",
r"""Label `{W2}` with `disk=ssd`. Then run Pod `fast` in `sched` (`busybox:1.36`, `sleep 86400`) with a **required** node affinity for `disk In (ssd)`. It lands on `{W2}` - and if no node had the label, it would stay `Pending`.""",
check=r"""
eq "$(j node "$W2" '{.metadata.labels.disk}')" ssd && eq "$(j -n sched pod fast '{.spec.nodeName}')" "$W2" \
&& eq "$(j -n sched pod fast '{.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key}')" disk""",
hint=r"""`kubectl label node {W2} disk=ssd`. The affinity block: `affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[].matchExpressions[]` with `key`, `operator: In`, `values`. The docs page "Assign Pods to Nodes using Node Affinity" has it to copy.""",
show=r"""kubectl label node {W2} disk=ssd
kubectl -n sched apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: fast
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- {key: disk, operator: In, values: [ssd]}
containers:
- name: fast
image: busybox:1.36
command: [sleep, '86400']
EOF
kubectl -n sched get pod fast -o wide"""),
step("One replica per node",
r"""Create Deployment `spread` in `sched` (2 replicas, `busybox:1.36`, `sleep 86400`, label `app=spread`) whose Pods **refuse** to share a node with each other: a required pod anti-affinity on `app=spread`, with `topologyKey: kubernetes.io/hostname`. One Pod lands on each worker; a third replica would stay `Pending`.""",
check=r"""
eq "$(j -n sched deploy spread '{.status.readyReplicas}')" 2 || exit 1
eq "$(j -n sched deploy spread '{.spec.template.spec.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].topologyKey}')" kubernetes.io/hostname || exit 1
[ "$($K -n sched get pods -l app=spread -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | sort -u | grep -c .)" = 2 ]""",
hint=r"""`podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution: [{labelSelector: {matchLabels: {app: spread}}, topologyKey: kubernetes.io/hostname}]` in the Pod template. `topologySpreadConstraints` is the softer, counting way to spread.""",
show=r"""kubectl -n sched apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: spread
spec:
replicas: 2
selector:
matchLabels: {app: spread}
template:
metadata:
labels: {app: spread}
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels: {app: spread}
topologyKey: kubernetes.io/hostname
containers:
- name: spread
image: busybox:1.36
command: [sleep, '86400']
EOF
kubectl -n sched rollout status deploy spread --timeout=120s
kubectl -n sched get pods -l app=spread -o wide"""),
step("A node for batch work only",
r"""Taint `{W1}` with `dedicated=batch:NoSchedule`: from now on only Pods that tolerate it are scheduled there (Pods already running stay - `NoExecute` would evict them).
Then run Pod `nightly` in `sched` (`busybox:1.36`, `sleep 86400`) that tolerates the taint **and** is placed on `{W1}` with a `nodeSelector` on `kubernetes.io/hostname`. A toleration only opens the door; it does not lead the Pod there.""",
check=r"""
[ -n "$($K get node "$W1" -o jsonpath='{range .spec.taints[*]}{.key}={.value}:{.effect}{"\n"}{end}' | grep -x 'dedicated=batch:NoSchedule')" ] || exit 1
eq "$(j -n sched pod nightly '{.spec.nodeName} {.status.phase}')" "$W1 Running" && eq "$(j -n sched pod nightly '{.spec.tolerations[?(@.key=="dedicated")].value}')" batch""",
hint=r"""`kubectl taint node {W1} dedicated=batch:NoSchedule`. The toleration: `{key: dedicated, operator: Equal, value: batch, effect: NoSchedule}`.""",
show=r"""kubectl taint node {W1} dedicated=batch:NoSchedule
kubectl -n sched apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: nightly
spec:
nodeSelector:
kubernetes.io/hostname: {W1}
tolerations:
- {key: dedicated, operator: Equal, value: batch, effect: NoSchedule}
containers:
- name: nightly
image: busybox:1.36
command: [sleep, '86400']
EOF
kubectl -n sched get pod nightly -o wide"""),
step("Defaults for Pods that bring none",
r"""Create LimitRange `defaults` in `sched`: containers that say nothing get a **request** of `50m` CPU and `32Mi` memory and a **limit** of `200m` and `128Mi`.
Then run Pod `plain` (`busybox:1.36`, `sleep 86400`, no resources) and look at what it got. A LimitRange acts when a Pod is created: Pods that already exist keep what they had.""",
check=r"""
eq "$(j -n sched limitrange defaults '{.spec.limits[0].type}')" Container || exit 1
eq "$(j -n sched pod plain '{.spec.containers[0].resources.requests.cpu} {.spec.containers[0].resources.requests.memory} {.spec.containers[0].resources.limits.cpu} {.spec.containers[0].resources.limits.memory}')" "50m 32Mi 200m 128Mi" """,
hint=r"""`spec.limits: [{type: Container, defaultRequest: {cpu: 50m, memory: 32Mi}, default: {cpu: 200m, memory: 128Mi}}]` - `default` is the limit.""",
show=r"""kubectl -n sched apply -f - <<'EOF'
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
spec:
limits:
- type: Container
defaultRequest: {cpu: 50m, memory: 32Mi}
default: {cpu: 200m, memory: 128Mi}
EOF
kubectl -n sched run plain --image=busybox:1.36 -- sleep 86400
kubectl -n sched get pod plain -o jsonpath='{.spec.containers[0].resources}'; echo"""),
step("Past the scheduler",
r"""Cordon `{W2}`. Then create Pod `manual` in `sched` (`busybox:1.36`, `sleep 86400`) with **`nodeName: {W2}`**. It runs there anyway: `nodeName` hands the Pod straight to that node's kubelet, and cordoning only tells the *scheduler* to stay away.
Leave `{W2}` cordoned.""",
check=r"""eq "$(j node "$W2" '{.spec.unschedulable}')" true && eq "$(j -n sched pod manual '{.spec.nodeName} {.status.phase}')" "$W2 Running" """,
hint=r"""`kubectl cordon {W2}`, then a Pod manifest with `spec.nodeName`. Without a running scheduler, this is also how you place a Pod by hand.""",
show=r"""kubectl cordon {W2}
kubectl -n sched run manual --image=busybox:1.36 --overrides='{"spec":{"nodeName":"{W2}"}}' -- sleep 86400
kubectl -n sched get pod manual -o wide
kubectl get nodes"""),
],
"outro": r"""You produced all three QoS classes, steered Pods with node affinity, kept replicas apart, reserved a node with a taint, set defaults with a LimitRange, and bypassed the scheduler.
**For the exam**
- `Pending` with `FailedScheduling`: the event lists why each node was rejected - insufficient cpu/memory, a taint not tolerated, affinity not matched, unschedulable.
- Requests decide placement; limits are enforced at run time (CPU is throttled, memory over the limit is `OOMKilled`).
- `required...` affinity filters, `preferred...` only scores. Pod (anti-)affinity needs a `topologyKey`.
- Taint effects: `NoSchedule`, `PreferNoSchedule`, `NoExecute` (evicts running Pods without a toleration). Remove one with a trailing `-`: `kubectl taint node N dedicated=batch:NoSchedule-`.
- ResourceQuota caps a Namespace's total and then **requires** requests/limits on new Pods; a LimitRange supplies them. PriorityClasses decide who is preempted.
Next, prove it against the clock: **drill 3.5**.""",
}
LESSONS = [L31, L32, L33, L34, L35]