diff --git a/CKAD_STUDY_GUIDE.md b/CKAD_STUDY_GUIDE.md new file mode 100644 index 0000000..664deac --- /dev/null +++ b/CKAD_STUDY_GUIDE.md @@ -0,0 +1,1033 @@ +# CKAD study guide + +The 24 objectives of the Certified Kubernetes Application Developer curriculum, in its own words and order: what to know, the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks in this simulator that practise each one. + +_Generated by `./exam --track ckad notes all --md` from `tracks/ckad/curriculum.py` - edit that file, not this one._ + +``` +./exam objectives # the path and your progress +./exam study 3.2 # one objective: these notes, then its tasks, no clock, solutions open +./exam drill 3.2 # the same tasks against the clock +``` + +## Application Design and Build (20%) + +### 1.1 Define, build and modify container images + +`./exam study 1.1` (or `images`) + +**Know** + +- Read and change a Dockerfile: FROM, COPY, RUN, ENV, ARG (build time only), WORKDIR, USER, LABEL, EXPOSE, ENTRYPOINT, CMD. +- ENTRYPOINT is the program, CMD its default arguments. In a Pod, `command` replaces ENTRYPOINT and `args` replaces CMD. +- Build, tag, list, inspect, run, save and remove images. The exam terminal has docker or podman - the verbs are the same. +- A tag is only a pointer: one image can carry several tags, and rebuilding under an old tag moves it. + +**Fast** + +``` +podman build -t localhost/app:v1 . # -f path/Dockerfile --build-arg KEY=val +podman tag localhost/app:v1 localhost/app:latest +podman images | grep app +podman inspect localhost/app:v1 --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Env}}' +podman run --rm localhost/app:v1 [args replacing CMD] # -d --name x keeps it running; podman logs x +podman save --format oci-archive -o app.tar localhost/app:v1 # docker save -o app.tar ... +podman rmi localhost/app:v0 +``` + +**Traps** + +- An ARG is gone at run time unless you copy it into an ENV (`ARG V` then `ENV APP_VERSION=$V`). +- `podman save` writes a docker-archive unless you pass `--format oci-archive`. +- Build before you edit when a task wants the old version kept under its own tag. +- imagePullPolicy defaults to Always for `:latest` (or no tag) and IfNotPresent for any other tag. + +**Docs** + +- +- + +**Tasks** + +- Q13 Build, change and export a container image +- Q60 Entrypoint, default argument and a non-root user +- Q41 Build with a build argument, run it detached _(Simulator Set A (killer.sh-style))_ + +### 1.2 Choose and use the right workload resource (Deployment, DaemonSet, CronJob, etc.) + +`./exam study 1.2` (or `workloads`) + +**Know** + +- Choose by behaviour: Deployment (interchangeable replicas, rolling updates), StatefulSet (stable name, DNS and storage per replica), DaemonSet (one Pod per node), Job (run to completion), CronJob (Jobs on a schedule), bare Pod (one-off). +- Job: completions, parallelism, backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished; restartPolicy must be Never or OnFailure. +- CronJob: schedule, startingDeadlineSeconds, concurrencyPolicy (Allow, Forbid, Replace), suspend, successfulJobsHistoryLimit, failedJobsHistoryLimit. +- DaemonSet: no replicas; a toleration for node-role.kubernetes.io/control-plane:NoSchedule puts it on control-plane nodes too. + +**Fast** + +``` +k create deploy web --image=nginx:1.27 --replicas=3 $do > deploy.yaml +k create job render --image=busybox:1.36 $do -- sh -c 'echo done' > job.yaml +k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' $do -- sh -c 'echo digest' > cj.yaml +k create job digest-manual --from=cronjob/digest # run a CronJob once, now +k run tmp --image=busybox:1.36 --restart=Never --rm -it -- sh +# DaemonSet has no generator: create a Deployment with $do, change kind, delete replicas/strategy/status +k explain cronjob.spec.jobTemplate.spec --recursive | less +``` + +**Traps** + +- Three nested specs in a CronJob: spec (schedule, startingDeadlineSeconds) > jobTemplate.spec (completions, activeDeadlineSeconds) > template.spec (containers, restartPolicy). +- A Job's Pod template is immutable - delete the Job and create it again instead of editing it. +- A StatefulSet needs `serviceName` naming a headless Service (clusterIP: None) before its Pods get their own DNS names. + +**Docs** + +- +- +- + +**Tasks** + +- Q3 CronJob with deadlines +- Q14 Parallel batch Job +- Q15 DaemonSet on every node +- Q39 A suspended CronJob and a manual run _(Simulator Set A (killer.sh-style))_ +- Q38 A Pod and a status one-liner _(Simulator Set A (killer.sh-style))_ +- Q44 From a single Pod to a hardened Deployment _(Simulator Set A (killer.sh-style))_ +- Q56 Stable DNS names for a StatefulSet _(Simulator Set A (killer.sh-style))_ + +### 1.3 Understand multi-container Pod design patterns (e.g. sidecar, init and others) + +`./exam study 1.3` (or `multicontainer`) + +**Know** + +- Containers of a Pod share its network (they reach each other on localhost) and any volume both mount - that is all a sidecar, adapter or ambassador is. +- Init containers run one at a time, in order, each to completion, before any app container starts. +- A native sidecar is an init container with `restartPolicy: Always`: it starts before the app containers, keeps running beside them, and does not keep a Job from completing. +- Address one container with `-c`: `k logs POD -c NAME`, `k exec POD -c NAME -- ...`. Status `Init:0/1` means an init container is still running. + +**Fast** + +``` +k run app --image=nginx:1.27 $do > pod.yaml # then add the second container by hand +spec: + initContainers: + - {name: wait, image: busybox:1.36, command: [sh, -c, "until wget -qO- -T2 http://backend; do sleep 2; done"]} + - {name: shipper, image: busybox:1.36, restartPolicy: Always, command: [sh, -c, "tail -F /logs/app.log"], + volumeMounts: [{name: logs, mountPath: /logs}]} # native sidecar + containers: + - {name: app, image: nginx:1.27, volumeMounts: [{name: logs, mountPath: /var/log/app}]} + volumes: [{name: logs, emptyDir: {}}] +k logs app -c shipper; k logs app --all-containers --prefix +``` + +**Traps** + +- `kubectl run` creates one container only - generate the Pod and add the others in the YAML. +- A Service with no endpoints still resolves in DNS, so an init container waiting on `nslookup` is not waiting for the app. Wait on a real request. +- A classic sidecar that never exits keeps a Job running for ever; make it a native sidecar. + +**Docs** + +- +- + +**Tasks** + +- Q1 Adapter sidecar +- Q2 Init container waits for a dependency +- Q16 Native sidecar lets a Job finish +- Q40 An init container writes the page _(Simulator Set A (killer.sh-style))_ + +### 1.4 Utilize persistent and ephemeral volumes + +`./exam study 1.4` (or `volumes`) + +**Know** + +- Ephemeral: emptyDir (lives as long as the Pod; `medium: Memory` is tmpfs, `sizeLimit` caps it), and the configMap, secret, downwardAPI and projected volumes. +- Persistent: a PersistentVolumeClaim asks for storage (accessModes, resources.requests.storage, storageClassName); a StorageClass provisions the PersistentVolume, or a pre-made one binds. +- Access modes: ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod. +- volumeMounts: mountPath, readOnly, and subPath to mount one file or directory without hiding the rest of the target directory. + +**Fast** + +``` +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: {name: data} +spec: + accessModes: [ReadWriteOnce] + storageClassName: standard + resources: {requests: {storage: 100Mi}} +--- # in the Pod spec: +volumes: + - {name: data, persistentVolumeClaim: {claimName: data}} + - {name: scratch, emptyDir: {medium: Memory, sizeLimit: 32Mi}} +containers: + - volumeMounts: [{name: data, mountPath: /data}, {name: scratch, mountPath: /scratch}] +k get pvc,pv; k describe pvc data # Pending? the Events say why +``` + +**Traps** + +- There is no `kubectl create pvc` - copy the claim from the docs. +- With a WaitForFirstConsumer StorageClass (`standard` here) a claim stays Pending until a Pod uses it. That is not an error. +- emptyDir survives a container restart but not a new Pod. To prove data persists, delete the Pod and read the file again. +- A non-root container cannot write to a fresh volume unless the Pod sets `securityContext.fsGroup`. + +**Docs** + +- +- +- + +**Tasks** + +- Q17 Persistent and ephemeral volumes +- Q10 Projected volume: config, secret and token +- Q52 Two users, one volume: fsGroup _(Simulator Set A (killer.sh-style))_ +- Q40 An init container writes the page _(Simulator Set A (killer.sh-style))_ +- Q50 An immutable Secret, one file into a busy directory _(Simulator Set A (killer.sh-style))_ + +## Application Deployment (20%) + +### 2.1 Use Kubernetes primitives to implement common deployment strategies (e.g. blue/green or canary) + +`./exam study 2.1` (or `strategies`) + +**Know** + +- Blue/green: two Deployments, and the Service selector decides which one gets all the traffic. Cut over by changing the selector; roll back by changing it back. +- Canary: two Deployments whose Pods share the label the Service selects. Traffic splits by the number of ready Pods: 1 canary beside 4 stable is 20%. +- A Deployment's own strategy is RollingUpdate (default) or Recreate (every old Pod stops first - downtime). + +**Fast** + +``` +k get svc web -o wide; k get pods --show-labels +k set selector svc web 'app=web,version=green' # blue/green cut-over +k scale deploy web-stable --replicas=4 # canary ratio: 4 stable + 1 canary = 20% +k get endpointslices -l kubernetes.io/service-name=web -o wide +k get pods -l app=web -L version,track +``` + +**Traps** + +- Wait until the new Deployment is fully available before you switch the selector. +- `kubectl set selector` replaces the whole selector - name every label you want in it. +- A canary only shares traffic if the Service selector leaves out the label that tells the two versions apart. + +**Docs** + +- +- + +**Tasks** + +- Q5 Blue/green cutover +- Q18 Canary by replica ratio + +### 2.2 Understand Deployments and how to perform rolling updates + +`./exam study 2.2` (or `rollouts`) + +**Know** + +- Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default). +- minReadySeconds (how long a Pod must be Ready to count as available), revisionHistoryLimit (old ReplicaSets kept), progressDeadlineSeconds. +- `kubectl rollout`: status, history, undo, pause, resume, restart. + +**Fast** + +``` +k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE +k rollout status deploy/web +k rollout history deploy/web # --revision=3 shows that revision's template +k rollout undo deploy/web # --to-revision=2 +k rollout restart deploy/web # same spec, new Pods +k annotate deploy/web kubernetes.io/change-cause='pick 1.27' +k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}' +``` + +**Traps** + +- `--record` is deprecated: write the kubernetes.io/change-cause annotation yourself. +- An undo makes a new revision out of the old one, so the active revision number is not the one you rolled back to. +- A rollout stuck on a bad image does not fail by itself - `rollout status` just waits, and the old Pods keep serving. +- maxSurge and maxUnavailable cannot both be 0. + +**Docs** + +- + +**Tasks** + +- Q4 Roll forward, then back +- Q19 Rolling update without losing capacity +- Q42 Back to the last good revision _(Simulator Set A (killer.sh-style))_ +- Q44 From a single Pod to a hardened Deployment _(Simulator Set A (killer.sh-style))_ + +### 2.3 Use the Helm package manager to deploy existing packages + +`./exam study 2.3` (or `helm`) + +**Know** + +- A chart is the package, a release is one installation of it in a Namespace, and every install, upgrade or rollback adds a revision. +- Values: the chart's defaults (`helm show values`), overridden with `-f file.yaml` and `--set key=value`; `helm get values` shows what a release was given. +- Life cycle: repo add/update, search, install, upgrade, rollback, uninstall, list, history, status. + +**Fast** + +``` +helm repo add NAME URL && helm repo update; helm search repo nginx --versions +helm show values CHART > values.yaml +helm install REL CHART -n NS --create-namespace -f values.yaml --set replicaCount=2 # --version X +helm upgrade REL CHART -n NS --reuse-values --set replicaCount=4 +helm list -n NS # -A for every Namespace; --failed, --pending filter by status +helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS +helm get values REL -n NS # --all adds the defaults; helm get manifest REL -n NS +``` + +**Traps** + +- Releases live in a Namespace: without `-n` Helm looks in `default` and finds nothing. +- An upgrade without `--reuse-values` (or the same `-f`/`--set` again) puts every value back to the chart's default. +- A rollback is a new revision, not a step back in the numbering. +- Helm 3's `helm list` leaves out pending releases unless you add `-a`; Helm 4 (on this machine) lists every status and has no `-a`. `--pending` and `--failed` work in both. + +**Docs** + +- +- + +**Tasks** + +- Q6 Helm release lifecycle +- Q21 Helm: values file and an upgrade that keeps values +- Q43 Helm housekeeping _(Simulator Set A (killer.sh-style))_ + +### 2.4 Kustomize + +`./exam study 2.4` (or `kustomize`) + +**Know** + +- A kustomization.yaml lists `resources` (files or other kustomizations) and transforms them: namespace, namePrefix, nameSuffix, labels, images, replicas, patches. +- Generators build ConfigMaps and Secrets from files, env files or literals, add a content hash to the name and rewrite every reference to it. +- Base and overlays: an overlay lists `../../base` under resources and changes only what differs. +- `kubectl kustomize DIR` prints the result; `kubectl apply -k DIR` applies it. + +**Fast** + +``` +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: [../../base] # or files: [deployment.yaml, service.yaml] +namespace: prod +namePrefix: prod- +labels: [{pairs: {env: prod}, includeSelectors: false}] +images: [{name: nginx, newTag: "1.27"}] +replicas: [{name: web, count: 3}] +patches: [{path: patch.yaml}] # a partial Deployment with the same name +configMapGenerator: [{name: settings, envs: [settings.env]}] +secretGenerator: [{name: token, literals: [token=abc]}] +generatorOptions: {disableNameSuffixHash: true} +k kustomize DIR | less; k apply -k DIR; k diff -k DIR +``` + +**Traps** + +- Always read the render before you apply it. +- `images`, `replicas` and patches name things as the base does, before any prefix or suffix. +- Refer to a generated ConfigMap by its plain name - Kustomize appends the hash everywhere it is used. +- `namespace:` does not create the Namespace. + +**Docs** + +- + +**Tasks** + +- Q20 Kustomize overlay +- Q61 A kustomization that does not build +- Q45 Kustomize: a patch and a generated ConfigMap _(Simulator Set A (killer.sh-style))_ + +## Application Observability and Maintenance (15%) + +### 3.1 Understand API deprecations + +`./exam study 3.1` (or `deprecations`) + +**Know** + +- Every object is a kind in an API group at a version (apps/v1 Deployment). Versions are deprecated, keep being served for some releases, then are removed. +- What this cluster serves: `kubectl api-resources` (the APIVERSION column), `kubectl api-versions`, `kubectl explain KIND`. +- A removed version fails with `no matches for kind "X" in version "g/v"`; a deprecated one still works and prints `Warning: ... is deprecated`. +- Migrating is the new apiVersion plus whatever changed shape: an Ingress backend becomes `service: {name, port: {number}}` with a required pathType, and an apps/v1 Deployment needs `spec.selector`. + +**Fast** + +``` +k api-resources | grep -i -E 'cronjob|ingress' # NAME SHORTNAMES APIVERSION NAMESPACED KIND +k api-versions | grep batch +k explain hpa | head -4 # GROUP, KIND, VERSION +k apply -f old.yaml --dry-run=server # the error or warning, without changing anything +# removed, and what replaced them: +# extensions/v1beta1, networking.k8s.io/v1beta1 Ingress -> networking.k8s.io/v1 (1.22) +# extensions/v1beta1, apps/v1beta1, apps/v1beta2 Deployment -> apps/v1 (1.16) +# batch/v1beta1 CronJob -> batch/v1 (1.25) +# policy/v1beta1 PodDisruptionBudget -> policy/v1 (1.25) +# autoscaling/v2beta1, v2beta2 HorizontalPodAutoscaler -> autoscaling/v2 (1.25, 1.26) +``` + +**Traps** + +- The `kubectl convert` plugin is usually not installed - know the Ingress and Deployment rewrites by hand. +- `kubectl get` shows an object in the API's preferred version whatever version it was written with. +- A warning is not an error: the object was created. Read stderr. + +**Docs** + +- +- + +**Tasks** + +- Q22 Manifests written for removed APIs +- Q58 Removed, and deprecated but still served + +### 3.2 Implement probes and health checks + +`./exam study 3.2` (or `probes`) + +**Know** + +- Liveness: failing restarts the container. Readiness: failing takes the Pod out of Service endpoints, no restart. Startup: holds the other two off until it has passed once. +- Handlers: httpGet (path, port), tcpSocket (port), exec (command), grpc (port). +- Timing: initialDelaySeconds (0), periodSeconds (10), timeoutSeconds (1), failureThreshold (3), successThreshold (1). A startup probe allows failureThreshold x periodSeconds to come up. + +**Fast** + +``` +containers: + - name: app + startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12} # 60 s to start + readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5, failureThreshold: 1} + livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10} + # exec: readinessProbe: {exec: {command: [cat, /tmp/ready]}, periodSeconds: 5} +k describe pod POD | grep -E 'Liveness|Readiness|Startup' +k get events --field-selector reason=Unhealthy +k explain pod.spec.containers.livenessProbe +``` + +**Traps** + +- No kubectl flag adds a probe: generate the YAML and edit it, or `kubectl edit`. +- Probes belong to a container, not to the Pod spec. +- Restarts climbing with `Unhealthy` and `Killing` events but nothing wrong in the logs: the liveness probe is wrong or too impatient, not the app. +- Running, 0/1 Ready and no restarts: the readiness probe is failing. + +**Docs** + +- +- + +**Tasks** + +- Q7 Three kinds of probe +- Q62 Health checks that check the wrong thing +- Q46 Probes that match the app's warm-up _(Simulator Set A (killer.sh-style))_ + +### 3.3 Use built-in CLI tools to monitor Kubernetes applications + +`./exam study 3.3` (or `monitoring`) + +**Know** + +- `kubectl get` output: -o wide, yaml, json, name, jsonpath=, custom-columns=; narrowed with -l (labels), --field-selector, -n / -A; ordered with --sort-by. +- `kubectl top pod|node` shows live CPU and memory from metrics-server; `--containers` splits a Pod up. +- `kubectl describe` for the story of one object, `kubectl get events` for the Namespace. + +**Fast** + +``` +k top pod -A --sort-by=memory; k top pod --containers; k top node +k get pods -o wide --sort-by=.spec.nodeName +k get pods -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image +k get pod POD -o jsonpath='{.status.phase}' +k get pods -l tier=web --field-selector=status.phase!=Running -o name +k get pods -A --sort-by='.status.containerStatuses[0].restartCount' +k get events --sort-by=.lastTimestamp; k events --types=Warning --for pod/POD +``` + +**Traps** + +- `kubectl get --sort-by` sorts ascending (the largest is last); `kubectl top --sort-by` sorts descending (the largest is first). +- `kubectl top` shows usage, not requests or limits, and needs about a minute after a Pod starts. +- Quote jsonpath for the shell: '{.items[*].metadata.name}'. + +**Docs** + +- +- + +**Tasks** + +- Q23 Find the heaviest Pods with kubectl top +- Q63 An inventory with kubectl alone +- Q48 Restart and event forensics _(Simulator Set A (killer.sh-style))_ +- Q38 A Pod and a status one-liner _(Simulator Set A (killer.sh-style))_ + +### 3.4 Utilize container logs + +`./exam study 3.4` (or `logs`) + +**Know** + +- `kubectl logs` shows what a container wrote to stdout and stderr. The kubelet keeps it on the node, and it goes when the Pod is deleted. +- Pick the container with -c (init containers too), or take --all-containers; --previous is the run before the last restart. +- Narrow with --tail=N, --since=5m, --timestamps; follow with -f; by label with -l. + +**Fast** + +``` +k logs POD -c CONTAINER # --all-containers --prefix +k logs POD --previous # the crashed run +k logs POD --tail=20 --since=10m --timestamps +k logs -l app=web --prefix --tail=5 # every Pod with the label +k logs deploy/web; k logs job/render # one Pod of it +k logs POD | grep ERROR > errors.log +``` + +**Traps** + +- With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything). +- `kubectl logs deploy/x` reads one Pod, not all replicas. +- --previous fails when the container has never restarted. + +**Docs** + +- +- + +**Tasks** + +- Q24 Logs of a container that keeps crashing +- Q64 One replica is logging errors +- Q49 RBAC, proven by the app's own log _(Simulator Set A (killer.sh-style))_ +- Q54 Expose a Pod and prove it from inside _(Simulator Set A (killer.sh-style))_ +- Q39 A suspended CronJob and a manual run _(Simulator Set A (killer.sh-style))_ + +### 3.5 Debugging in Kubernetes + +`./exam study 3.5` (or `debugging`) + +**Know** + +- Always the same order: get (status), describe (Events at the bottom), logs (--previous), then exec or debug. +- Pending: nothing can schedule it (resources, taints, nodeSelector, an unbound claim). ImagePullBackOff / ErrImagePull: wrong image or tag. CreateContainerConfigError: a ConfigMap, Secret or key it references is missing. +- CrashLoopBackOff: the process exits - read the previous log and lastState.terminated (reason, exitCode). OOMKilled (137): the memory limit is too low. +- A Deployment with no Pods at all: describe its ReplicaSet - a quota, a LimitRange or Pod Security admission is rejecting them. + +**Fast** + +``` +k get pods -o wide; k describe pod POD | tail -20 +k get pod POD -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}' +k get pod POD -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' # .exitCode +k logs POD --previous; k exec -it POD -- sh +k debug -it POD --image=busybox:1.36 --target=CONTAINER # ephemeral container, sees its processes +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://SVC:PORT +k describe rs -l app=web --show-events | tail; k get events --sort-by=.lastTimestamp +k get pod POD -o yaml > pod.yaml && vi pod.yaml && k replace --force -f pod.yaml +``` + +**Traps** + +- Most of a running Pod is immutable (the image is not): fix a bare Pod with `replace --force`, fix a Deployment's Pods in the Deployment. +- Fix what the task names and nothing else - graders check that the ConfigMap, Service or Namespace you were told to leave is unchanged. +- After fixing a Deployment wait for the rollout; old Pods linger in Terminating. +- `kubectl describe` with a selector that matches several objects leaves their Events out - add `--show-events`, or describe one by name. + +**Docs** + +- +- +- + +**Tasks** + +- Q8 Diagnose and fix a broken Pod +- Q25 Two faults behind a stuck Deployment +- Q26 OOMKilled +- Q36 A Service that routes nowhere +- Q47 Debugging a container with no shell _(Simulator Set A (killer.sh-style))_ +- Q48 Restart and event forensics _(Simulator Set A (killer.sh-style))_ + +## Application Environment, Configuration and Security (25%) + +### 4.1 Discover and use resources that extend Kubernetes (CRD, Operators) + +`./exam study 4.1` (or `crds`) + +**Know** + +- A CustomResourceDefinition adds a kind to the API; its objects (custom resources) work with every kubectl verb. An operator is a CRD plus a controller acting on those objects. +- Discover: `k get crd`, `k api-resources --api-group=GROUP` (kind, short names, namespaced or not), `k explain KIND.spec`. +- The CRD's schema validates custom resources: required fields, enum values, minimum and maximum. The controller reports back in `.status`. + +**Fast** + +``` +k get crd | grep -i backup # the CRD's name is PLURAL.GROUP +k api-resources --api-group=data.example.com +k explain backup.spec # --recursive for the whole tree +k get backups -A; k get bkp NAME -o yaml; k describe backup NAME +k get crd backups.data.example.com -o yaml | less # served versions, schema, enum, maximum +k apply -f cr.yaml --dry-run=server # every validation error, nothing created +``` + +**Traps** + +- A custom resource's apiVersion is the CRD's GROUP/VERSION, never apiextensions.k8s.io/v1. +- Field names come from the CRD's schema - check with `kubectl explain` instead of guessing. +- Deleting a CRD deletes every object of that kind. + +**Docs** + +- +- +- + +**Tasks** + +- Q27 Discover and use a custom resource +- Q59 A custom resource the API server rejects +- Q58 Removed, and deprecated but still served + +### 4.2 Understand authentication, authorization and admission control + +`./exam study 4.2` (or `access`) + +**Know** + +- Every request passes authentication (who is it: a user certificate or token, a ServiceAccount token), authorization (may they: RBAC), then admission control (change or reject it: LimitRanger, ResourceQuota, PodSecurity). +- RBAC: a Role or ClusterRole lists verbs on resources (and subresources such as pods/log, deployments/scale); a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts. +- Pod Security admission: Namespace labels pod-security.kubernetes.io/enforce|warn|audit = privileged | baseline | restricted. +- `Forbidden: User ... cannot list` is authorization. `violates PodSecurity`, `exceeded quota` and `maximum memory usage` are admission. + +**Fast** + +``` +k create role ci --verb=get,list,watch --resource=pods,pods/log -n NS +k create role ci --verb=get,update,patch --resource=deployments.apps,deployments.apps/scale -n NS +k create rolebinding ci --role=ci --serviceaccount=NS:SA -n NS # --user=jane --clusterrole=view +k auth can-i list pods -n NS --as=system:serviceaccount:NS:SA +k auth can-i --list -n NS --as=system:serviceaccount:NS:SA +k get ns NS --show-labels +# what `restricted` wants from every container: +securityContext: {allowPrivilegeEscalation: false, runAsNonRoot: true, capabilities: {drop: [ALL]}, + seccompProfile: {type: RuntimeDefault}} +``` + +**Traps** + +- The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`. +- `can-i create pods/exec` asks about a Pod named exec; use `can-i create pods --subresource=exec`. +- Admission rejects Pods, not Deployments: the Deployment exists and shows 0 Pods, and the reason is in its ReplicaSet's events. + +**Docs** + +- +- +- + +**Tasks** + +- Q28 RBAC for a CI ServiceAccount +- Q29 Pod Security admission blocks a Deployment +- Q49 RBAC, proven by the app's own log _(Simulator Set A (killer.sh-style))_ +- Q53 Pods rejected by the Namespace's limits _(Simulator Set A (killer.sh-style))_ + +### 4.3 Understand requests, limits, quotas + +`./exam study 4.3` (or `quotas`) + +**Know** + +- Requests are what the scheduler reserves on a node. Limits are the ceiling: CPU above it is throttled, memory above it gets the container OOMKilled. +- A ResourceQuota caps a Namespace's totals (requests.cpu, limits.memory, pods, count/...). Once it covers CPU or memory, every new Pod must state them. +- A LimitRange gives containers a default request and limit and enforces a min and max per container, at admission. + +**Fast** + +``` +k create quota compute-quota -n NS --hard=requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi,pods=6 +k describe quota -n NS # Used against Hard +k describe limitrange -n NS +apiVersion: v1 +kind: LimitRange +metadata: {name: container-defaults} +spec: + limits: + - type: Container + defaultRequest: {cpu: 100m, memory: 128Mi} # the request a container gets + default: {cpu: 250m, memory: 256Mi} # the LIMIT a container gets + max: {cpu: 500m, memory: 512Mi} +``` + +**Traps** + +- `default` is the default limit and `defaultRequest` the default request - they are easy to swap. +- Quotas and LimitRanges only act on Pods created afterwards. +- The rejection is not on the Deployment: `k describe rs` shows `exceeded quota` or `maximum memory usage per Container`. + +**Docs** + +- +- + +**Tasks** + +- Q30 Quota with LimitRange defaults +- Q26 OOMKilled +- Q53 Pods rejected by the Namespace's limits _(Simulator Set A (killer.sh-style))_ + +### 4.4 Understand ConfigMaps + +`./exam study 4.4` (or `configmaps`) + +**Know** + +- Create from literals, files, directories or env files. Each key becomes an environment variable (configMapKeyRef, envFrom) or a file (a configMap volume; `items` picks keys). +- Mounted files follow a changed ConfigMap after a short delay. Environment variables and subPath mounts never do - restart the Pods. +- A Pod that references a missing ConfigMap or key waits in CreateContainerConfigError unless the reference is `optional: true`. + +**Fast** + +``` +k create cm app --from-literal=TIER=backend --from-file=app.properties # --from-file=KEY=path --from-env-file=x.env +env: + - {name: APP_TIER, valueFrom: {configMapKeyRef: {name: app, key: TIER}}} +envFrom: [{configMapRef: {name: app}}] +volumes: [{name: cfg, configMap: {name: app, items: [{key: app.properties, path: app.properties}]}}] +volumeMounts: [{name: cfg, mountPath: /etc/app}] # one file into a full directory: add subPath: app.properties +k set env deploy/web --from=configmap/app; k exec POD -- env | grep TIER +k rollout restart deploy/web # pick up a change +``` + +**Traps** + +- `--from-file` makes one key named after the file; `--from-env-file` makes one key per KEY=VALUE line. +- A volume mounted on a directory hides what the image had there. +- `immutable: true` cannot be edited or undone - delete the ConfigMap and create it again. + +**Docs** + +- +- + +**Tasks** + +- Q32 ConfigMap as an env var and as a file +- Q10 Projected volume: config, secret and token +- Q25 Two faults behind a stuck Deployment +- Q51 A ConfigMap-driven nginx that needs a restart _(Simulator Set A (killer.sh-style))_ +- Q45 Kustomize: a patch and a generated ConfigMap _(Simulator Set A (killer.sh-style))_ + +### 4.5 Define resource requirements + +`./exam study 4.5` (or `resources`) + +**Know** + +- Per container: resources.requests and resources.limits for cpu (cores or millicores: 250m) and memory (Mi, Gi). +- QoS class: Guaranteed (every container has requests equal to limits for both CPU and memory), Burstable (some are set), BestEffort (none). BestEffort is evicted first. +- A container with a limit but no request gets a request equal to the limit. + +**Fast** + +``` +k set resources deploy/web -c app --requests=cpu=250m,memory=128Mi --limits=cpu=250m,memory=128Mi +resources: + requests: {cpu: 100m, memory: 64Mi} + limits: {cpu: 250m, memory: 128Mi} +k get pod POD -o jsonpath='{.status.qosClass}' +k describe node NODE | grep -A8 'Allocated resources' +``` + +**Traps** + +- `kubectl run` has no --requests or --limits any more: generate the YAML, or use `kubectl set resources` on a workload. +- Guaranteed needs every container of the Pod, sidecars included. +- Memory is Mi, not m: `128m` is a fraction of a byte. +- A request no node can fit leaves the Pod Pending with `Insufficient cpu` or `Insufficient memory`. + +**Docs** + +- +- + +**Tasks** + +- Q31 Resource requirements for Guaranteed QoS +- Q26 OOMKilled +- Q15 DaemonSet on every node +- Q53 Pods rejected by the Namespace's limits _(Simulator Set A (killer.sh-style))_ + +### 4.6 Create & consume Secrets + +`./exam study 4.6` (or `secrets`) + +**Know** + +- A Secret's `data` is base64 - an encoding, not encryption. `stringData` takes plain text when you write one. +- Types: Opaque (generic), kubernetes.io/tls, kubernetes.io/dockerconfigjson, kubernetes.io/service-account-token. +- Consumed like a ConfigMap: secretKeyRef, envFrom secretRef, or a secret volume (`secretName`, `defaultMode`, `items`). + +**Fast** + +``` +k create secret generic api --from-literal=username=svc --from-literal=password='S3cr3t!' +k create secret tls web-tls --cert=tls.crt --key=tls.key +k create secret docker-registry regcred --docker-server=REG --docker-username=U --docker-password=P +k get secret api -o jsonpath='{.data.password}' | base64 -d +env: [{name: API_USER, valueFrom: {secretKeyRef: {name: api, key: username}}}] +volumes: [{name: creds, secret: {secretName: api, defaultMode: 0400}}] +volumeMounts: [{name: creds, mountPath: /etc/creds, readOnly: true}] +``` + +**Traps** + +- A secret volume names it with `secretName`; a configMap volume uses `name`. +- Encoding by hand: `echo -n value | base64`, or the newline becomes part of the secret. +- defaultMode is octal in YAML (0400) and shows as decimal in JSON (256). + +**Docs** + +- +- + +**Tasks** + +- Q33 Secrets: decode one, consume another +- Q10 Projected volume: config, secret and token +- Q37 Ingress with two paths and TLS +- Q50 An immutable Secret, one file into a busy directory _(Simulator Set A (killer.sh-style))_ + +### 4.7 Understand ServiceAccounts + +`./exam study 4.7` (or `serviceaccounts`) + +**Know** + +- Every Pod runs as a ServiceAccount (`default` if you name none); its short-lived token is mounted at /var/run/secrets/kubernetes.io/serviceaccount. +- Choose it with spec.serviceAccountName. Stop the mount with `automountServiceAccountToken: false` on the ServiceAccount or on the Pod (the Pod wins). +- Tokens: `kubectl create token` for a short-lived one; a Secret of type kubernetes.io/service-account-token annotated with the account's name for a long-lived one. +- A ServiceAccount can do nothing until RBAC binds a Role to it. + +**Fast** + +``` +k create sa ci -n NS +k set serviceaccount deploy/web ci -n NS # = spec.template.spec.serviceAccountName +k patch sa ci -n NS -p '{"automountServiceAccountToken": false}' +k create token ci -n NS --duration=1h +k auth can-i list pods -n NS --as=system:serviceaccount:NS:ci +apiVersion: v1 +kind: Secret +metadata: {name: ci-token, annotations: {kubernetes.io/service-account.name: ci}} +type: kubernetes.io/service-account-token +``` + +**Traps** + +- No token Secret is made for a new ServiceAccount (since 1.24) - create it yourself when a task wants one. +- serviceAccountName cannot be changed on a running Pod; on a Deployment it starts a rollout. +- A Pod naming a ServiceAccount that does not exist is never created - see the ReplicaSet's events. + +**Docs** + +- +- + +**Tasks** + +- Q34 ServiceAccount without an automounted token +- Q28 RBAC for a CI ServiceAccount +- Q10 Projected volume: config, secret and token +- Q49 RBAC, proven by the app's own log _(Simulator Set A (killer.sh-style))_ + +### 4.8 Understand Application Security (SecurityContexts, Capabilities, etc.) + +`./exam study 4.8` (or `security`) + +**Know** + +- Pod level (spec.securityContext): runAsUser, runAsGroup, runAsNonRoot, fsGroup, supplementalGroups, seccompProfile - for every container. +- Container level adds allowPrivilegeEscalation, privileged, readOnlyRootFilesystem and capabilities, and overrides the Pod level. +- Capabilities are written without the CAP_ prefix: NET_BIND_SERVICE, NET_ADMIN, SYS_TIME. Drop ALL, then add back what the app needs. + +**Fast** + +``` +spec: + securityContext: {runAsUser: 1000, runAsGroup: 1000, runAsNonRoot: true, fsGroup: 2000} + containers: + - name: app + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]} +k exec POD -- id +k explain pod.spec.securityContext; k explain pod.spec.containers.securityContext +``` + +**Traps** + +- capabilities exist only on a container, fsGroup only on the Pod. +- runAsNonRoot with an image that runs as root and no runAsUser: CreateContainerConfigError. +- readOnlyRootFilesystem breaks an app that writes to /tmp or /var/cache - mount an emptyDir there. + +**Docs** + +- + +**Tasks** + +- Q9 SecurityContext and capabilities +- Q29 Pod Security admission blocks a Deployment +- Q44 From a single Pod to a hardened Deployment _(Simulator Set A (killer.sh-style))_ +- Q52 Two users, one volume: fsGroup _(Simulator Set A (killer.sh-style))_ + +## Services and Networking (20%) + +### 5.1 Demonstrate basic understanding of NetworkPolicies + +`./exam study 5.1` (or `netpol`) + +**Know** + +- Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through. +- Policies only allow, and they add up: there is no deny rule and no ordering. +- Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR. +- Default deny: `podSelector: {}` with a policyType and no rules. Locking down egress also needs DNS allowed (UDP and TCP 53). + +**Fast** + +``` +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: db-allow, namespace: NS} +spec: + podSelector: {matchLabels: {app: db}} + policyTypes: [Ingress] + ingress: + - from: + - podSelector: {matchLabels: {app: api}} # OR + - namespaceSelector: {matchLabels: {purpose: monitoring}} + ports: [{protocol: TCP, port: 5432}] +# egress that keeps DNS: egress: [{ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]}, ...] +k describe netpol db-allow -n NS +k exec CLIENT -- wget -qO- -T2 http://db:5432 # test it with real traffic +``` + +**Traps** + +- There is no `kubectl create networkpolicy` - copy the skeleton from the docs. +- `- podSelector: ...` followed by `namespaceSelector:` in the same item is AND; a second `- namespaceSelector:` is OR. +- A missing or empty `ingress` denies everything; `ingress: [{}]` allows everything. +- The policy lives in the Namespace of the Pods it selects, and a Namespace is matched by its labels (kubernetes.io/metadata.name=NAME always exists). + +**Docs** + +- + +**Tasks** + +- Q12 Restrict a namespace with NetworkPolicy +- Q35 Egress lockdown that keeps DNS working +- Q55 Two sources, one port _(Simulator Set A (killer.sh-style))_ + +### 5.2 Provide and troubleshoot access to applications via services + +`./exam study 5.2` (or `services`) + +**Know** + +- A Service selects Pods by label and forwards `port` to `targetPort` (a number, or the name of a container port). +- Types: ClusterIP (inside the cluster), NodePort (30000-32767 on every node), LoadBalancer, ExternalName; headless (`clusterIP: None`) gives each Pod its own DNS record. +- DNS names: SVC, SVC.NS, SVC.NS.svc.cluster.local. +- Troubleshoot in this order: are there endpoints, does the selector match the Pod labels, are the Pods Ready, is targetPort the port the container listens on. + +**Fast** + +``` +k expose deploy web --port=8080 --target-port=80 # --name=web-svc --type=NodePort +k create svc nodeport web-ext --tcp=80:80 --node-port=30080 +k get svc,endpointslices -o wide; k describe svc web # Endpoints: = nothing selected +k get pods -l app=web --show-labels +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://web:8080 +k port-forward svc/web 8080:80 +``` + +**Traps** + +- `kubectl expose` copies the workload's selector; `kubectl create svc` selects app=, which may match nothing. +- `expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML. +- A Service without endpoints still resolves in DNS; the connection is what fails. + +**Docs** + +- +- +- + +**Tasks** + +- Q11 Expose a Deployment two ways +- Q36 A Service that routes nowhere +- Q54 Expose a Pod and prove it from inside _(Simulator Set A (killer.sh-style))_ +- Q56 Stable DNS names for a StatefulSet _(Simulator Set A (killer.sh-style))_ + +### 5.3 Use Ingress rules to expose applications + +`./exam study 5.3` (or `ingress`) + +**Know** + +- An Ingress routes HTTP(S) by host and path to Services of its own Namespace; an ingress controller, chosen with ingressClassName, does the work. +- Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name. +- TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate. + +**Fast** + +``` +k create ingress shop --class=nginx \ + --rule="shop.example.com/api*=api:8080,tls=shop-tls" --rule="shop.example.com/*=web:80,tls=shop-tls" +k create secret tls shop-tls --cert=tls.crt --key=tls.key +k get ingress,ingressclass; k describe ingress shop # a missing backend shows as +k explain ingress.spec.rules.http.paths +``` + +**Traps** + +- In --rule a trailing * means pathType Prefix; without it the path is Exact. +- The backend port is the Service's port, not the container's. +- No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it. + +**Docs** + +- + +**Tasks** + +- Q37 Ingress with two paths and TLS +- Q11 Expose a Deployment two ways +- Q57 An Ingress pointing at the wrong backends _(Simulator Set A (killer.sh-style))_ + diff --git a/CKA_SOLUTIONS_EXPLAINED.md b/CKA_SOLUTIONS_EXPLAINED.md index 9306696..d14eac6 100644 --- a/CKA_SOLUTIONS_EXPLAINED.md +++ b/CKA_SOLUTIONS_EXPLAINED.md @@ -40,7 +40,7 @@ This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the fi | Operator Namespace Creator | Cluster Architecture | *no equivalent here* — this bank doesn't cover writing controller logic (arguably CKAD/dev territory more than CKA) | | Static Pod Move | Cluster Architecture | Q05, Q46 | | Cluster Setup and Node Join | Cluster Architecture | Q40 (token) + Q67 (independently verifying the CA discovery hash and real in-cluster API endpoint) — the token/crypto half only, see gap note | -| Cluster Upgrade | Cluster Architecture | *no equivalent here* — see gap note below | +| Cluster Upgrade | Cluster Architecture | Q109 (the drain and uncordon around it) — the upgrade itself has no equivalent here, see gap note below | | Cluster Certificate Management | Cluster Architecture | Q06, Q65 | ### Real gaps worth knowing about @@ -524,6 +524,100 @@ The exam-day skill this table is really pointing at, regardless of which platfor --- +### Q107 · Kernel Prerequisites for a Kubernetes Node +- **Concept**: before `kubeadm` runs, every machine needs the `overlay` and `br_netfilter` kernel modules and three sysctls (`net.bridge.bridge-nf-call-iptables`, `net.bridge.bridge-nf-call-ip6tables`, `net.ipv4.ip_forward`) so that bridged Pod traffic passes through iptables and the node routes between Pods. `modprobe` and `sysctl -w` change the running kernel only; the files under `/etc/modules-load.d` and `/etc/sysctl.d` are what make the settings survive a reboot — which is what this task grades. Swap and the cgroup driver are the other two preflight classics: kubeadm refuses a node with active swap unless the kubelet says `failSwapOn: false`, and the kubelet's `cgroupDriver` must match the runtime's. +- **Solution**: + ```bash + {SHELL_W3} + printf 'overlay\nbr_netfilter\n' > /etc/modules-load.d/k8s.conf + cat > /etc/sysctl.d/k8s.conf < -q) + crictl inspect | grep -E '"pid"|logPath' + ``` +- **Gotchas**: `crictl ps` truncates IDs to 13 characters — `--no-trunc` (or `kubectl get pod -o jsonpath='{.status.containerStatuses[0].containerID}'`, minus the `containerd://` prefix) gives the full one. The PID is the process on the *node*; inside the container that process is PID 1. The log path under `/var/log/pods/__/` is the file `kubectl logs` reads. + +--- + +### Q109 · Drain the Control-Plane Node and Bring It Back +- **Concept**: `kubectl drain` is cordon plus eviction — the first half of every node upgrade or maintenance window. It leaves DaemonSet Pods (they would be recreated at once) and static Pods (the kubelet owns them), and it refuses to go on when it meets emptyDir data or Pods without a controller until you say so explicitly. The node then stays `SchedulingDisabled` until `kubectl uncordon`. A Pod that can only run on the drained node (here: a nodeSelector for the control plane) sits in `Pending` for the duration. +- **Solution**: + ```bash + kubectl drain {CP} --ignore-daemonsets --delete-emptydir-data + kubectl get nodes > {WS}/q109/drained.txt + kubectl -n get pod -l app= # Pending + echo Pending > {WS}/q109/phase.txt + kubectl uncordon {CP} + kubectl -n rollout status deploy + ``` +- **Gotchas**: `--force` is only for bare Pods and deletes them for good — do not add it by reflex. CoreDNS usually sits on the control-plane node in a fresh kubeadm cluster, so the drain moves it to the workers; it does not move back after the uncordon, and that is fine. Save `kubectl get nodes` *before* you uncordon — the file is the proof the node was drained. + +--- + +### Q110 · Prepare the Join of a Second Control-Plane Node +- **Concept**: a joining control-plane node needs the cluster's CA, front-proxy CA, etcd CA and service-account keys. `kubeadm init phase upload-certs --upload-certs` encrypts them with a one-off *certificate key* and stores them for two hours in the Secret `kubeadm-certs`; `kubeadm join --control-plane --certificate-key ` downloads and decrypts them. All of this presupposes a `controlPlaneEndpoint` — the one stable address (a load balancer or VIP in production) that every node and kubeconfig uses instead of one particular API server. +- **Solution**: + ```bash + kubectl -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint + {SHELL_CP} + kubeadm init phase upload-certs --upload-certs # last line: the certificate key + kubeadm token create --print-join-command --certificate-key + ``` +- **Gotchas**: without `--certificate-key` (or `--control-plane`) `--print-join-command` prints a *worker's* join line. A key made with `kubeadm certs certificate-key` alone is just a random key — it only works if the certificates were uploaded *with* it (`upload-certs --certificate-key `), so the simplest route is to let `upload-certs` generate it and copy what it prints. + +--- + +### Q111 · Who Leads, and Who Is in etcd +- **Concept**: in a highly-available control plane every kube-apiserver serves at once, but only one kube-scheduler and one kube-controller-manager act — they elect a leader through a `Lease` in `kube-system`, and the holder identity is `_`. etcd elects its own leader and needs a majority of members to accept writes (3 members survive the loss of 1, 5 of 2). *Stacked* means an etcd member runs on each control-plane node, *external* that etcd has hosts of its own. +- **Solution**: + ```bash + kubectl get nodes -l node-role.kubernetes.io/control-plane + kubectl -n kube-system get pods -l component=etcd -o wide + kubectl -n kube-system get lease kube-scheduler kube-controller-manager + {SHELL_CP} + export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379 ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt \ + ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key + etcdctl member list -w table + etcdctl endpoint status -w table # IS LEADER + ``` +- **Gotchas**: write the *node* name for the two leases, not the whole holder identity — the uuid changes with every restart of the component. `ETCDCTL_API=3` is not needed any more; etcdctl 3.6 only warns about it. `endpoint status --cluster` asks every member through its advertised client URL, which fails when a node's address has changed; without `--cluster` you ask the local member, whose answer already names the leader. + +--- + +### Q112 · CNI, CSI and CRI: What This Cluster Plugs In +- **Concept**: three interfaces keep networking, storage and container execution out of Kubernetes itself. **CRI**: the kubelet's socket to the runtime. **CNI**: for every Pod sandbox the runtime runs the plugin chain of the first configuration file in `/etc/cni/net.d` (binaries in `/opt/cni/bin`) — here `ptp` wires the veth pair, its `host-local` IPAM plugin hands out an address from the node's Pod CIDR, and `portmap` serves hostPorts. **CSI**: a storage driver registers a `CSIDriver` object and a StorageClass names it as `provisioner`. +- **Solution**: + ```bash + {SHELL_W1} + ls /etc/cni/net.d/ && cat /etc/cni/net.d/*.conflist + grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml + exit + kubectl get csidrivers + kubectl get sc + ``` +- **Gotchas**: `cni-plugins` asks for the `type` of each plugin in the chain, in order — not the network's `name` (`kindnet`) and not the IPAM plugin, which has its own line. "No resources found" for CSIDrivers is the answer `0`: the local-path provisioner of this cluster is a plain external provisioner, not a CSI driver. + +--- + ## Domain 2: Workloads & Scheduling (15%) ### Q07 · Rolling Update Strategy & Scaling @@ -939,6 +1033,38 @@ The exam-day skill this table is really pointing at, regardless of which platfor --- +### Q105 · Autoscale on CPU, Scale Down Calmly +- **Concept**: a HorizontalPodAutoscaler compares a metric with a target and sets the replica count between `minReplicas` and `maxReplicas`. CPU *utilisation* is usage divided by the container's CPU **request**, so a container without one can never be scaled on it. `behavior` shapes the change: `scaleDown.stabilizationWindowSeconds` makes the HPA act on the highest recommendation of that window (no flapping), and `policies` cap how fast replicas go — here one Pod per minute. +- **Solution**: + ```bash + kubectl -n set resources deploy -c web --requests=cpu=100m + kubectl -n autoscale deploy --min= --max= --cpu=% # older kubectl: --cpu-percent= + kubectl -n edit hpa + # spec: + # behavior: + # scaleDown: + # stabilizationWindowSeconds: + # policies: [{type: Pods, value: 1, periodSeconds: 60}] + kubectl -n get hpa + ``` +- **Gotchas**: `kubectl autoscale` cannot write `behavior` — edit the object afterwards or write the `autoscaling/v2` YAML from the start. `TARGETS` shows `` until metrics-server delivers data (it may not be installed here), but `minReplicas` is enforced immediately either way — which is what the last check looks at. `set resources` without `-c` changes every container; with it, only `web`. + +--- + +### Q106 · Health Checks That Check the Wrong Thing +- **Concept**: the kubelet acts on probes, not on the application: a failing **liveness** probe gets the container killed and restarted, a failing **readiness** probe keeps the Pod out of every Service's endpoints without restarting it. So an application that is perfectly healthy looks broken when its probes point at the wrong port or path. The event text tells the two apart: `connection refused` is a port nobody listens on, `HTTP probe failed with statuscode: 404` is a path the server does not have. +- **Solution**: + ```bash + kubectl -n describe pod -l app= --show-events | grep -E 'Liveness|Readiness|Unhealthy|Killing' + echo Unhealthy > {WS}/q106/reason.txt + kubectl -n get cm -conf -o yaml # listen ; the liveness and readiness locations + kubectl -n edit deploy # livenessProbe port, readinessProbe path + kubectl -n rollout status deploy + ``` +- **Gotchas**: `--show-events` costs nothing on the CKA's kubectl 1.35, which shows Events anyway, and is required from kubectl 1.36 on, where `describe` with a selector that matches several Pods leaves them out. Keep the probes HTTP and keep their timings — the graders compare them. Fixing only the readiness path produces Pods that turn Ready and are killed ten seconds later, so wait and look at `RESTARTS` before you move on. + +--- + ## Domain 3: Services & Networking (20%) ### Q11 · NetworkPolicy (Ingress Restriction) @@ -1237,6 +1363,20 @@ The exam-day skill this table is really pointing at, regardless of which platfor --- +### Q113 · Pod to Pod, Across Nodes +- **Concept**: the Kubernetes network model in one task — every Pod has its own address, taken from its node's Pod CIDR (`.spec.podCIDR`), and any Pod reaches any other Pod on any node at that address without NAT; the CNI plugin is what makes it true. A Service is not involved at all. The exception is a `hostNetwork: true` Pod, which lives in the node's own network namespace: its address is the node's, outside every Pod CIDR, and it shares the node's ports. +- **Solution**: + ```bash + kubectl -n get pods -o wide + kubectl -n get pod -o jsonpath='{.status.podIP} {.spec.nodeName}' > {WS}/q113/web.txt + kubectl -n exec -- wget -qO- -T3 http:// > {WS}/q113/direct.html + kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.spec.podCIDR}{"\n"}{end}' > {WS}/q113/pod-cidrs.txt + echo > {WS}/q113/outside.txt; echo hostNetwork > {WS}/q113/why.txt + ``` +- **Gotchas**: `pod-cidrs.txt` wants *every* node, the control plane included. Compare the odd Pod's IP with `kubectl get nodes -o wide` and you see it is its node's INTERNAL-IP. Pod addresses change whenever a Pod is replaced, which is the whole reason Services exist — never hard-code one outside an exercise like this. + +--- + ## Domain 4: Storage (10%) ### Q15 · PersistentVolume, PVC & Pod Mounting @@ -1645,3 +1785,30 @@ The exam-day skill this table is really pointing at, regardless of which platfor kubectl -n kube-system get pods -l component=kube-apiserver ``` - **Gotchas**: don't reach for `kubectl describe`/`logs` first here — they need the very component that's down, so they'll just hang or refuse to connect. `crictl` talks to containerd directly on the node and works regardless of API server state, making it (along with `journalctl -u kubelet`) your only real diagnostic tool until the fix lands. Once fixed, double-check the *other* static pods (etcd, scheduler, controller-manager) are still healthy — it's easy to nick something else while editing manifests under pressure with no `kubectl` to sanity-check against. + +--- + +### Q103 · Find the Heaviest Pods with `kubectl top` +- **Concept**: `kubectl top` reads the metrics API (`metrics.k8s.io`), which metrics-server serves from what each kubelet reports. It shows *usage* — not requests or limits, which are in `kubectl describe node` under "Allocated resources". `--sort-by=cpu|memory` puts the largest first; `--containers` splits a Pod into its containers; `-A` covers every Namespace. +- **Solution**: + ```bash + kubectl top pod -n --sort-by=cpu --no-headers | head -1 | awk '{print $1}' > {WS}/q103/cpu.txt + kubectl top pod -n --sort-by=memory --no-headers | head -1 | awk '{print $1}' > {WS}/q103/memory.txt + echo 'kubectl top node' > {WS}/q103/nodes.sh + echo 'kubectl top pod -A --sort-by=memory' > {WS}/q103/pods.sh + ``` +- **Gotchas**: give metrics-server about a minute after the Pods start — until then `top` says `metrics not available yet` for them. `Metrics API not available` means metrics-server itself is missing or not Ready (that is Q23's scenario, which is why the two never share a session). The scripts run in a plain shell: spell out `kubectl`, an alias `k` does not exist there. + +--- + +### Q104 · One Replica Is Logging Errors +- **Concept**: `kubectl logs` reads one container's stdout/stderr. With `-l` it reads every Pod of a label at once — but then shows only the last 10 lines per Pod unless `--tail` says otherwise, and you cannot tell the Pods apart without `--prefix`. An init container's log is read like any other container's, with `-c`, long after it has finished. +- **Solution**: + ```bash + kubectl -n logs -l app= --prefix --tail=-1 | grep ERROR | head -3 # [pod/-N/router] ERROR ... + echo -N > {WS}/q104/bad-pod.txt + kubectl -n logs -N | grep ERROR > {WS}/q104/errors.log + kubectl -n logs -0 -c migrate # schema version ... is current + echo "kubectl -n logs -l app= --prefix --tail=2" > {WS}/q104/tail.sh + ``` +- **Gotchas**: `errors.log` must hold the ERROR lines and nothing else — redirecting the whole log fails the check. `kubectl logs statefulset/` (or `deploy/...`) picks *one* Pod; only a selector covers them all. `--tail=2` with `--prefix` gives two lines per Pod, each starting `[pod//]`. diff --git a/CKA_STUDY_GUIDE.md b/CKA_STUDY_GUIDE.md new file mode 100644 index 0000000..bd989b6 --- /dev/null +++ b/CKA_STUDY_GUIDE.md @@ -0,0 +1,1250 @@ +# CKA study guide + +The 27 objectives of the Certified Kubernetes Administrator curriculum, in its own words and order: what to know, the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks in this simulator that practise each one. + +_Generated by `./exam --track cka notes all --md` from `tracks/cka/curriculum.py` - edit that file, not this one._ + +``` +./exam objectives # the path and your progress +./exam study 3.2 # one objective: these notes, then its tasks, no clock, solutions open +./exam drill 3.2 # the same tasks against the clock +``` + +## Storage (10%) + +### 1.1 Implement storage classes and dynamic volume provisioning + +`./exam study 1.1` (or `storageclasses`) + +**Know** + +- A StorageClass names a provisioner and its settings. A claim that asks for the class gets a PersistentVolume made for it - that is dynamic provisioning. +- Fields: provisioner, reclaimPolicy (Delete by default, or Retain), volumeBindingMode (Immediate or WaitForFirstConsumer), allowVolumeExpansion, parameters. +- The default class carries the annotation storageclass.kubernetes.io/is-default-class: "true"; a claim with no storageClassName gets it. `storageClassName: ""` asks for no class at all. +- A StatefulSet's volumeClaimTemplates make one claim per replica (data-web-0, data-web-1 ...). + +**Fast** + +``` +k get sc # (default) marks the default class +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: {name: fast-local} +provisioner: rancher.io/local-path +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +--- +k patch sc standard -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' +k patch sc fast-local -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' +k get pvc,pv; k describe pvc data # the Events say why a claim is Pending +``` + +**Traps** + +- With WaitForFirstConsumer a claim stays Pending until a Pod uses it. That is not an error. +- Leave exactly one class marked default: take the annotation off the old one as well as putting it on the new one. +- provisioner, reclaimPolicy and volumeBindingMode cannot be edited - delete the class and create it again. + +**Docs** + +- +- +- + +**Tasks** + +- Q16 Default StorageClass and dynamic provisioning +- Q36 Data survives the pod +- Q59 Migrate data between claims +- Q99 Per-replica storage for a StatefulSet _(Simulator Set D (killer.sh-style))_ + +### 1.2 Configure volume types, access modes and reclaim policies + +`./exam study 1.2` (or `volumes`) + +**Know** + +- Volume types you meet: hostPath (a directory of whatever node the Pod lands on), local (a disk of one node, needs nodeAffinity), emptyDir, nfs, csi, and the configMap / secret / projected volumes. +- Access modes: ReadWriteOnce (one node), ReadOnlyMany, ReadWriteMany, ReadWriteOncePod (one Pod). A claim binds only to a volume that offers its mode. +- The reclaim policy is on the PersistentVolume: Retain keeps the volume and its data when the claim goes (it becomes Released), Delete removes both. + +**Fast** + +``` +apiVersion: v1 +kind: PersistentVolume +metadata: {name: pv-data} +spec: + capacity: {storage: 1Gi} + accessModes: [ReadWriteOnce] + persistentVolumeReclaimPolicy: Retain + storageClassName: manual + hostPath: {path: /mnt/data} +--- +k patch pv pv-data -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' +k patch pv pv-data --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]' # Released -> Available +k get pv -o custom-columns=NAME:.metadata.name,MODES:.spec.accessModes,RECLAIM:.spec.persistentVolumeReclaimPolicy,STATUS:.status.phase,CLAIM:.spec.claimRef.name +``` + +**Traps** + +- A Released volume never binds again by itself: remove its claimRef first. +- A `local` volume needs spec.nodeAffinity; a hostPath volume does not pin the Pod to the node that has the data. +- The local-path provisioner of this cluster only does ReadWriteOnce and ReadWriteOncePod - a ReadWriteMany claim stays Pending. + +**Docs** + +- +- +- + +**Tasks** + +- Q15 PersistentVolume, claim and pod +- Q35 Re-bind a Released PersistentVolume +- Q58 Bind the right local volume +- Q68 Provision geo-fence persistent storage _(Simulator Set C)_ +- Q72 Migrate ride history to a Retain volume _(Simulator Set C)_ +- Q102 A claim that can never bind _(Simulator Set D (killer.sh-style))_ + +### 1.3 Manage persistent volumes and persistent volume claims + +`./exam study 1.3` (or `claims`) + +**Know** + +- A claim binds to one volume, and only if class, access mode and size fit (the volume may be larger); `volumeName` or a selector narrows it to one volume. +- Phases: a volume goes Available, Bound, Released; a claim Pending, Bound. +- A Pod mounts a claim of its own Namespace: volumes[].persistentVolumeClaim.claimName, then volumeMounts. +- A claim in use is protected: deleting it waits (Terminating) until no Pod references it, finished Pods included. + +**Fast** + +``` +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: {name: pvc-data, namespace: storage} +spec: + accessModes: [ReadWriteOnce] + storageClassName: manual # omit for the default class + resources: {requests: {storage: 500Mi}} +--- # in the Pod: +volumes: [{name: data, persistentVolumeClaim: {claimName: pvc-data}}] +containers: [{..., volumeMounts: [{name: data, mountPath: /data}]}] +k get pvc,pv -A; k describe pvc pvc-data # Used By: and Events: +k describe pod POD | tail # unbound claim -> the Pod stays Pending +``` + +**Traps** + +- There is no `kubectl create pvc` or `create pv` - copy both from the docs. +- For a hand-made volume the claim's storageClassName has to equal the volume's exactly; leaving it out means the default class, not none. +- Apart from growing the request (and only if the class allows expansion) a claim cannot be edited. +- To move data between claims, run a Pod or Job that mounts both. + +**Docs** + +- +- + +**Tasks** + +- Q15 PersistentVolume, claim and pod +- Q36 Data survives the pod +- Q35 Re-bind a Released PersistentVolume +- Q58 Bind the right local volume +- Q59 Migrate data between claims +- Q68 Provision geo-fence persistent storage _(Simulator Set C)_ +- Q72 Migrate ride history to a Retain volume _(Simulator Set C)_ +- Q81 Fix the fare calculation pods _(Simulator Set C)_ +- Q99 Per-replica storage for a StatefulSet _(Simulator Set D (killer.sh-style))_ +- Q102 A claim that can never bind _(Simulator Set D (killer.sh-style))_ + +## Troubleshooting (30%) + +### 2.1 Troubleshoot clusters and nodes + +`./exam study 2.1` (or `nodes`) + +**Know** + +- Start from the cluster: `k get nodes`, then `k describe node` - Conditions, Taints, Unschedulable, Events. +- A NotReady node is nearly always its kubelet: on the node, `systemctl status kubelet` and `journalctl -u kubelet` name the cause. +- What the kubelet needs: its unit and drop-in (which binary, which flags), /var/lib/kubelet/config.yaml, /etc/kubernetes/kubelet.conf (its kubeconfig), its certificates in /var/lib/kubelet/pki, and a running container runtime. +- Ready but SchedulingDisabled is a cordon, not a fault: `k uncordon`. + +**Fast** + +``` +k get nodes -o wide; k describe node NODE | sed -n '/Conditions/,/Addresses/p' +ssh NODE +systemctl status kubelet; journalctl -u kubelet --no-pager | tail -30 +systemctl cat kubelet # the unit and its drop-ins: ExecStart, EnvironmentFile +cat /var/lib/kubelet/config.yaml; cat /var/lib/kubelet/kubeadm-flags.env +systemctl daemon-reload; systemctl enable --now kubelet; systemctl restart kubelet +systemctl status containerd; crictl ps -a +k uncordon NODE; k describe node NODE | grep -i taint +``` + +**Traps** + +- A kubelet that was stopped is often disabled too: `enable --now`, or it is gone again after a reboot. +- After changing a unit file nothing happens until `systemctl daemon-reload`. +- There may be more than one fault. After every fix read the journal again instead of assuming you are done. + +**Docs** + +- +- +- + +**Tasks** + +- Q17 Node NotReady +- Q39 kubelet misconfiguration +- Q60 Worker node: three things wrong +- Q92 Kubelet unit points at nothing _(Simulator Set D (killer.sh-style))_ +- Q90 Event forensics around kube-proxy _(Simulator Set D (killer.sh-style))_ +- Q95 The kubelet's two certificates _(Simulator Set D (killer.sh-style))_ + +### 2.2 Troubleshoot cluster components + +`./exam study 2.2` (or `components`) + +**Know** + +- kube-apiserver, kube-scheduler, kube-controller-manager and etcd run as static Pods: the kubelet starts whatever is in /etc/kubernetes/manifests and restarts it when the file changes. +- Read the symptom: Pods stay Pending with no events - scheduler. Deployments make no ReplicaSet or Pods - controller-manager. kubectl is refused - API server (or etcd behind it). +- With the API server up: `k -n kube-system get pods`, `logs`, `describe`. With it down: `crictl ps -a`, `crictl logs`, /var/log/pods and the kubelet's journal on the node. + +**Fast** + +``` +k -n kube-system get pods; k -n kube-system logs kube-scheduler-NODE; k -n kube-system describe pod kube-scheduler-NODE +ssh CONTROL-PLANE +ls /etc/kubernetes/manifests; vi /etc/kubernetes/manifests/kube-scheduler.yaml +crictl ps -a | grep -E 'kube-|etcd'; crictl logs CONTAINER-ID +ls /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver/ # its logs when kubectl cannot ask for them +journalctl -u kubelet --no-pager | grep -i -E 'manifest|apiserver' | tail +``` + +**Traps** + +- The faults are small: a misspelt image, binary or flag, a wrong path in a volume or certificate flag, a wrong port in a probe. +- Do not leave a backup copy inside the manifests directory - the kubelet runs every file there. +- After saving a manifest give the kubelet up to a minute before deciding the fix did not work. + +**Docs** + +- +- +- + +**Tasks** + +- Q20 Control plane component failure +- Q38 Deployments never create pods +- Q61 Two control-plane components down +- Q66 Nothing works: kube-apiserver itself is down +- Q97 Stop the scheduler, schedule by hand _(Simulator Set D (killer.sh-style))_ +- Q86 How the control plane is run _(Simulator Set D (killer.sh-style))_ + +### 2.3 Monitor cluster and application resource usage + +`./exam study 2.3` (or `usage`) + +**Know** + +- `kubectl top node|pod` shows live CPU and memory from metrics-server through the metrics API (metrics.k8s.io); `--containers` splits a Pod up, `--sort-by=cpu|memory` orders it. +- `k describe node` shows what is allocated - the sum of requests and limits - which is not what is used. +- `kubectl get` sorts and shapes everything else: --sort-by, -o custom-columns, -o jsonpath, -A. + +**Fast** + +``` +k top node; k top pod -A --sort-by=memory | head; k top pod -n NS --containers +k describe node NODE | grep -A8 'Allocated resources' +k get pods -A --sort-by='.status.containerStatuses[0].restartCount' +k -n kube-system get deploy metrics-server; k get apiservice v1beta1.metrics.k8s.io +k -n kube-system logs deploy/metrics-server +``` + +**Traps** + +- `Metrics API not available` means metrics-server is missing or not Ready (on this cluster it needs --kubelet-insecure-tls). +- `kubectl top --sort-by` puts the largest first; `kubectl get --sort-by` puts it last. +- A new Pod has no metrics for about a minute. + +**Docs** + +- +- +- + +**Tasks** + +- Q103 Find the heaviest Pods with kubectl top +- Q23 metrics-server repair and HPA +- Q53 Namespace audit to exact files +- Q85 Investigate vehicle tracker health _(Simulator Set C)_ +- Q89 Sorting done by kubectl _(Simulator Set D (killer.sh-style))_ + +### 2.4 Manage and evaluate container output streams + +`./exam study 2.4` (or `logs`) + +**Know** + +- `kubectl logs` shows what a container wrote to stdout and stderr. The runtime keeps it on the node under /var/log/pods/NAMESPACE_POD_UID/CONTAINER/, and it goes when the Pod is deleted. +- Pick the container with -c (init containers too) or take --all-containers; --previous is the run before the last restart. +- Narrow with --tail=N, --since=10m, --timestamps; follow with -f; take every Pod of a label with -l and tell them apart with --prefix. +- On a node, without the API server: `crictl logs CONTAINER-ID`. + +**Fast** + +``` +k logs POD -c CONTAINER # --all-containers --prefix +k logs POD --previous # the crashed run +k logs POD --tail=50 --since=10m --timestamps +k logs -l app=web --prefix --tail=5 # every Pod with the label +k logs deploy/web; k logs job/render # one Pod of it +k logs POD | grep ERROR > errors.log +crictl ps -a; crictl logs --tail=20 CONTAINER-ID; ls /var/log/pods/ +``` + +**Traps** + +- With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything). +- `kubectl logs deploy/x` reads one Pod, not all replicas. +- --previous fails when the container has never restarted. + +**Docs** + +- +- +- + +**Tasks** + +- Q19 CrashLoopBackOff diagnosis +- Q104 One replica is logging errors +- Q8 Multi-container pod with shared volume +- Q85 Investigate vehicle tracker health _(Simulator Set C)_ + +### 2.5 Troubleshoot services and networking + +`./exam study 2.5` (or `netdebug`) + +**Know** + +- Work outwards from the Pod: is it Ready, does the Service have endpoints (selector against Pod labels), is targetPort the port the container listens on, does the name resolve, does a NetworkPolicy stand in the way. +- DNS is a Service like any other: the CoreDNS Pods, the kube-dns Service and its endpoints, the Corefile in ConfigMap coredns. +- Underneath: kube-proxy (a DaemonSet) turns Services into rules on every node, and the CNI plugin connects the Pods. + +**Fast** + +``` +k get svc,endpointslices -o wide; k describe svc SVC # Endpoints: = nothing selected +k get pods -l KEY=VALUE --show-labels -o wide +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- sh # wget -qO- -T3 http://SVC:PORT ; nslookup SVC.NS.svc.cluster.local +k -n kube-system get pods -l k8s-app=kube-dns -o wide; k -n kube-system logs -l k8s-app=kube-dns +k -n kube-system get cm coredns -o yaml; k -n kube-system get svc,endpointslices -l k8s-app=kube-dns +k -n kube-system get ds kube-proxy; k -n kube-system logs ds/kube-proxy +k get netpol -A +``` + +**Traps** + +- A Service with no endpoints still resolves - it is the connection that fails. +- Test names with the fully qualified form (SVC.NS.svc.cluster.local): busybox's nslookup misreports short ones. +- A NetworkPolicy in kube-system can cut DNS off for the whole cluster. +- Fix what is broken and leave the rest alone - the graders check what you did not have to touch. + +**Docs** + +- +- + +**Tasks** + +- Q18 Broken deployment and service +- Q62 Application down for four reasons +- Q37 Cluster DNS is broken +- Q57 DNS down: three faults +- Q73 Fix ETA service connectivity _(Simulator Set C)_ +- Q101 Resolves everything except the cluster _(Simulator Set D (killer.sh-style))_ +- Q70 Fix the driver onboarding app _(Simulator Set C)_ +- Q90 Event forensics around kube-proxy _(Simulator Set D (killer.sh-style))_ + +## Workloads & Scheduling (15%) + +### 3.1 Understand application deployments and how to perform rolling update and rollbacks + +`./exam study 3.1` (or `rollouts`) + +**Know** + +- Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default). +- Every rollout is a revision. `kubectl rollout`: status, history, undo, pause, resume, restart. +- minReadySeconds, revisionHistoryLimit and progressDeadlineSeconds tune how careful and how patient a rollout is. + +**Fast** + +``` +k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE +k scale deploy/web --replicas=4 +k rollout status deploy/web +k rollout history deploy/web; k rollout history deploy/web --revision=2 # that revision's template +k rollout undo deploy/web --to-revision=2 +k rollout pause deploy/web; k rollout resume deploy/web +k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}' +``` + +**Traps** + +- To go back to a particular image, look at each revision's template first - "the previous one" is not always the one asked for. +- An undo makes a new revision out of the old one, so the numbers keep climbing. +- A paused Deployment records changes but rolls nothing out until it is resumed. +- maxSurge and maxUnavailable cannot both be 0. + +**Docs** + +- + +**Tasks** + +- Q7 Rolling update & scaling +- Q51 Rollback to a specific revision, then pause + +### 3.2 Use ConfigMaps and Secrets to configure applications + +`./exam study 3.2` (or `config`) + +**Know** + +- Both hold keys. A key becomes an environment variable (configMapKeyRef / secretKeyRef, or every key at once with envFrom) or a file (a configMap / secret volume; `items` picks and renames keys). +- Secret data is base64 - an encoding, not encryption. Types: Opaque, kubernetes.io/tls, kubernetes.io/dockerconfigjson. +- Mounted files follow a change after a short delay. Environment variables and subPath mounts never do: restart the Pods. +- `immutable: true` freezes one for good. To change the content, make a new object and point the workload at it. + +**Fast** + +``` +k create cm app-config --from-literal=APP_MODE=production --from-file=app.properties +k create secret generic db-creds --from-literal=username=admin --from-literal=password='S3cr3t!' +k create secret tls web-tls --cert=tls.crt --key=tls.key +k get secret db-creds -o jsonpath='{.data.password}' | base64 -d +env: + - {name: APP_MODE, valueFrom: {configMapKeyRef: {name: app-config, key: APP_MODE}}} + - {name: DB_PASS, valueFrom: {secretKeyRef: {name: db-creds, key: password}}} +envFrom: [{configMapRef: {name: app-config}}, {secretRef: {name: db-creds}}] +volumes: [{name: creds, secret: {secretName: db-creds, defaultMode: 0400}}] +k set env deploy/web --from=configmap/app-config; k rollout restart deploy/web +``` + +**Traps** + +- A secret volume names it with `secretName`; a configMap volume uses `name`. +- A Pod that references a missing ConfigMap, Secret or key waits in CreateContainerConfigError. +- `--from-file` makes one key named after the file; `--from-env-file` makes one key per line. + +**Docs** + +- +- +- + +**Tasks** + +- Q10 ConfigMap and Secret consumption +- Q29 Immutable ConfigMap rotation +- Q79 Surge pricing configuration _(Simulator Set C)_ +- Q93 Secrets as a read-only volume and renamed env vars _(Simulator Set D (killer.sh-style))_ +- Q84 Enforce TLS 1.3 on the booking portal _(Simulator Set C)_ + +### 3.3 Configure workload autoscaling + +`./exam study 3.3` (or `autoscaling`) + +**Know** + +- A HorizontalPodAutoscaler (autoscaling/v2) keeps a workload between minReplicas and maxReplicas by a metric - usually average CPU or memory utilisation, which is usage divided by the container's request. +- It reads the metrics API, so it needs metrics-server, and the containers need a request for the resource it scales on. +- `behavior` shapes the change: scaleUp and scaleDown each take a stabilizationWindowSeconds and policies (so many Pods or Percent per periodSeconds). + +**Fast** + +``` +k set resources deploy web --requests=cpu=100m +k autoscale deploy web --min=2 --max=6 --cpu=60% # kubectl before 1.34: --cpu-percent=60 +k get hpa; k describe hpa web +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: {name: web} +spec: + scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: web} + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: {name: cpu, target: {type: Utilization, averageUtilization: 60}} + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: [{type: Pods, value: 1, periodSeconds: 60}] +``` + +**Traps** + +- TARGETS ``: no metrics-server, or no request on the containers. +- `kubectl autoscale` cannot write `behavior` - edit the object or write the YAML. +- minReplicas is enforced at once, even without metrics. + +**Docs** + +- +- +- + +**Tasks** + +- Q105 Autoscale on CPU, scale down calmly +- Q23 metrics-server repair and HPA +- Q82 Autoscale the route planner on memory _(Simulator Set C)_ + +### 3.4 Understand the primitives used to create robust, self-healing, application deployments + +`./exam study 3.4` (or `selfhealing`) + +**Know** + +- Controllers put back what is missing: a Deployment its replicas, a DaemonSet one Pod per node, a StatefulSet named replicas with their own storage, a Job its completions (backoffLimit retries), a CronJob its Jobs. +- Probes tell the kubelet about health: liveness restarts the container, readiness takes the Pod out of Service endpoints, startup holds both off until the app is up. +- Init containers run to completion, in order, before the app starts; a native sidecar (an init container with restartPolicy: Always) keeps running beside it. +- A PodDisruptionBudget (minAvailable or maxUnavailable) limits how many Pods a drain may take away at once. + +**Fast** + +``` +containers: + - name: app + startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12} + readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5} + livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10} + # exec: {exec: {command: [cat, /tmp/ready]}} +k create job render --image=busybox:1.36 -- sh -c 'echo done' +k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' -- date; k create job run-1 --from=cronjob/digest +k create pdb web --selector=app=web --min-available=2 +k create deploy x --image=busybox:1.36 $do > ds.yaml # DaemonSet: change kind, drop replicas / strategy / status +k describe pod POD | grep -E 'Liveness|Readiness|Startup'; k get events --field-selector reason=Unhealthy +``` + +**Traps** + +- Probes belong to a container. No kubectl flag adds one - edit the YAML. +- Restarts with Unhealthy and Killing events but a clean log: the liveness probe is wrong, not the app. +- A DaemonSet reaches control-plane nodes only with a toleration for node-role.kubernetes.io/control-plane. +- A Job's Pod template cannot be edited: delete the Job and create it again. + +**Docs** + +- +- +- +- + +**Tasks** + +- Q106 Health checks that check the wrong thing +- Q8 Multi-container pod with shared volume +- Q28 Init container prepares content +- Q26 DaemonSet on every node +- Q27 CronJob with a manual run +- Q31 Parallel Job +- Q52 Native sidecar with probes and an exact report +- Q53 Namespace audit to exact files +- Q76 Add an init container to the trip logger _(Simulator Set C)_ +- Q88 Ready only when its dependency answers _(Simulator Set D (killer.sh-style))_ +- Q70 Fix the driver onboarding app _(Simulator Set C)_ + +### 3.5 Configure Pod admission and scheduling (limits, node affinity, etc.) + +`./exam study 3.5` (or `scheduling`) + +**Know** + +- Requests are what the scheduler reserves, limits what the node enforces. A LimitRange gives containers defaults and bounds; a ResourceQuota caps a Namespace's totals - both act at admission. +- Where a Pod may go: nodeSelector, node affinity (required or preferred), Pod affinity and anti-affinity with a topologyKey, topologySpreadConstraints, or nodeName, which skips the scheduler. +- Taints keep Pods off a node (NoSchedule, PreferNoSchedule, NoExecute); a toleration lets a Pod through. +- A PriorityClass orders Pods when room is short; the Pod names it in priorityClassName. + +**Fast** + +``` +k label node NODE disktype=ssd +k taint node NODE dedicated=gpu:NoSchedule; k taint node NODE dedicated- # the trailing - removes it +tolerations: [{key: dedicated, operator: Equal, value: gpu, effect: NoSchedule}] +affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: [{matchExpressions: [{key: disktype, operator: In, values: [ssd]}]}] + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - {labelSelector: {matchLabels: {app: web}}, topologyKey: kubernetes.io/hostname} +k create priorityclass high --value=100000 --description='critical workloads' +k create quota team -n NS --hard=pods=5,requests.cpu=1,limits.memory=2Gi +k describe pod POD | tail # FailedScheduling: 0/4 nodes are available: ... +``` + +**Traps** + +- A toleration lets a Pod onto a tainted node but does not send it there - pair it with a nodeSelector or affinity. +- Once a quota covers CPU or memory, every new Pod must state them or get them from a LimitRange. +- In a LimitRange, `default` is the limit and `defaultRequest` the request. +- Scheduling fields of a running Pod cannot be changed: change the Deployment, or delete and recreate the Pod. + +**Docs** + +- +- +- +- + +**Tasks** + +- Q9 Scheduling with node affinity and resources +- Q25 Taints and tolerations +- Q24 PriorityClass +- Q32 ResourceQuota and LimitRange +- Q50 Spread, tolerate, prioritise — under a quota +- Q78 Priority for the rider support portal _(Simulator Set C)_ +- Q96 A Pod for the control plane only _(Simulator Set D (killer.sh-style))_ +- Q81 Fix the fare calculation pods _(Simulator Set C)_ +- Q97 Stop the scheduler, schedule by hand _(Simulator Set D (killer.sh-style))_ + +## Cluster Architecture, Installation & Configuration (25%) + +### 4.1 Manage role based access control (RBAC) + +`./exam study 4.1` (or `rbac`) + +**Know** + +- A Role (one Namespace) or ClusterRole (everywhere, and cluster-scoped resources) lists verbs on resources; a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts. +- A RoleBinding may point at a ClusterRole: the rules then apply in that one Namespace only. +- A user is a certificate: CN is the name, O the groups. Create a key and CSR, submit a CertificateSigningRequest, approve it, put the certificate in a kubeconfig. +- An aggregated ClusterRole has no rules of its own: the controller fills them in from every ClusterRole carrying the label it selects. + +**Fast** + +``` +k create role deploy-manager -n NS --verb=get,list,watch,create,update,patch,delete --resource=deployments.apps +k create rolebinding deploy-bot -n NS --role=deploy-manager --serviceaccount=NS:deploy-bot # --user=jane --group=devs +k create clusterrole node-reader --verb=get,list,watch --resource=nodes +k create clusterrolebinding node-reader --clusterrole=node-reader --serviceaccount=NS:SA +k auth can-i create deployments -n NS --as=system:serviceaccount:NS:deploy-bot; k auth can-i --list --as=jane -n NS +openssl genrsa -out jane.key 2048; openssl req -new -key jane.key -subj '/CN=jane/O=developers' -out jane.csr +# CertificateSigningRequest: spec.request = base64 -w0 of the .csr, signerName kubernetes.io/kube-apiserver-client, usages [client auth] +k certificate approve jane; k get csr jane -o jsonpath='{.status.certificate}' | base64 -d > jane.crt +k config set-credentials jane --client-certificate=jane.crt --client-key=jane.key --embed-certs +``` + +**Traps** + +- "Exactly these permissions" also means finding what is already granted: `k get rolebindings,clusterrolebindings -A -o wide | grep NAME`. +- Deployments are in the group apps: --resource=deployments.apps; a subresource is deployments.apps/scale, pods/log. +- The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`. +- Nodes, PersistentVolumes and Namespaces are cluster-scoped: they need a ClusterRole and a ClusterRoleBinding. + +**Docs** + +- +- + +**Tasks** + +- Q1 RBAC for a deployment bot +- Q33 Aggregated ClusterRole +- Q6 Certificates: expiry check and user CSR +- Q44 Exact RBAC with an existing over-grant +- Q47 User onboarding to a working kubeconfig +- Q75 Cluster-wide read access for the auth team _(Simulator Set C)_ +- Q91 API resources and a Role census _(Simulator Set D (killer.sh-style))_ + +### 4.2 Prepare underlying infrastructure for installing a Kubernetes cluster + +`./exam study 4.2` (or `infra`) + +**Know** + +- Every machine needs, before kubeadm: a container runtime (containerd) using the same cgroup driver as the kubelet (systemd), the kernel modules overlay and br_netfilter, the sysctls net.bridge.bridge-nf-call-iptables and net.ipv4.ip_forward, and swap off or accounted for. +- kubeadm, kubelet and kubectl come from the package repository of one minor version (pkgs.k8s.io) and are held, so that a system update does not move them. +- Also: a unique hostname, MAC address and product_uuid per node, and the ports open - 6443 (API), 10250 (kubelet), 2379-2380 (etcd). + +**Fast** + +``` +printf 'overlay\nbr_netfilter\n' > /etc/modules-load.d/k8s.conf; modprobe overlay; modprobe br_netfilter +cat > /etc/sysctl.d/k8s.conf < +- + +**Tasks** + +- Q107 Kernel prerequisites for a Kubernetes node +- Q108 The container runtime behind a Pod +- Q41 Component versions + +### 4.3 Create and manage Kubernetes clusters using kubeadm + +`./exam study 4.3` (or `kubeadm`) + +**Know** + +- `kubeadm init` makes the control plane: certificates in /etc/kubernetes/pki, kubeconfigs in /etc/kubernetes, static Pod manifests in /etc/kubernetes/manifests, then CoreDNS and kube-proxy. A CNI plugin is yours to add. +- `kubeadm join` adds a node with a bootstrap token and the hash of the cluster CA; `kubeadm token create --print-join-command` writes the whole line. +- What the cluster was built with is in ConfigMap kubeadm-config (kube-system). `kubeadm certs check-expiration` lists every certificate it manages. +- A static Pod is a manifest in the kubelet's staticPodPath; it shows up in the API as NAME-NODE and can only be changed or removed through the file. + +**Fast** + +``` +kubeadm init --pod-network-cidr=10.244.0.0/16 --control-plane-endpoint=HOST:6443 +kubeadm token create --ttl 2h --print-join-command; kubeadm token list +openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex # the CA hash +kubeadm certs check-expiration; openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -enddate +k -n kube-system get cm kubeadm-config -o yaml +grep staticPodPath /var/lib/kubelet/config.yaml; k run web --image=nginx:1.27 $do > /etc/kubernetes/manifests/web.yaml +``` + +**Traps** + +- Tokens expire (24 hours unless you say otherwise) - make a new one rather than hunting for the old. +- Check staticPodPath before you write a static Pod: it is not always /etc/kubernetes/manifests. +- Deleting a static Pod with kubectl only deletes its mirror; the kubelet puts it straight back. + +**Docs** + +- +- +- +- + +**Tasks** + +- Q40 kubeadm join token +- Q5 Static pod +- Q6 Certificates: expiry check and user CSR +- Q67 Prepare a node join without shell access to the control plane +- Q46 Static pod on a relocated manifest path +- Q94 Static Pod on the control plane behind a Service _(Simulator Set D (killer.sh-style))_ +- Q95 The kubelet's two certificates _(Simulator Set D (killer.sh-style))_ +- Q80 Plan the control-plane upgrade _(Simulator Set C)_ + +### 4.4 Manage the lifecycle of Kubernetes clusters + +`./exam study 4.4` (or `lifecycle`) + +**Know** + +- Upgrade one minor version at a time, control plane first: the kubeadm package, `kubeadm upgrade plan` and `apply`, drain, the kubelet and kubectl packages, restart the kubelet, uncordon. Workers: `kubeadm upgrade node` in place of `apply`. +- Maintenance on a node: `kubectl drain` cordons it and evicts its Pods; `kubectl uncordon` gives it back. +- etcd: `etcdctl snapshot save` backs it up; `etcdutl snapshot restore --data-dir` unpacks a snapshot into a new directory that etcd's manifest must then point at. +- `kubeadm certs renew all` renews the control-plane certificates; the static Pods have to be restarted to load them. + +**Fast** + +``` +k drain NODE --ignore-daemonsets --delete-emptydir-data; k uncordon NODE +apt-get install -y kubeadm='1.36.*'; kubeadm upgrade plan; kubeadm upgrade apply v1.36.x # workers: kubeadm upgrade node +apt-get install -y kubelet='1.36.*' kubectl='1.36.*'; systemctl daemon-reload; systemctl restart kubelet +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 snapshot save /opt/backup.db +etcdutl snapshot restore /opt/backup.db --data-dir /var/lib/etcd-restore # then etcd.yaml: the hostPath of volume etcd-data +kubeadm certs renew all; kubeadm certs check-expiration +``` + +**Traps** + +- drain stops at DaemonSet Pods (--ignore-daemonsets), at emptyDir data (--delete-emptydir-data) and at bare Pods (--force, which deletes them for good). +- A drained node stays cordoned until you uncordon it. +- With etcd 3.6, as here, restore exists only in etcdutl - `etcdctl snapshot restore` is gone. +- Restoring etcd takes the whole cluster back to the snapshot: everything created since is lost. + +**Docs** + +- +- +- +- + +**Tasks** + +- Q109 Drain the control-plane node and bring it back +- Q2 etcd snapshot +- Q65 Renew control-plane certificates +- Q45 etcd restore +- Q80 Plan the control-plane upgrade _(Simulator Set C)_ + +### 4.5 Implement and configure a highly-available control plane + +`./exam study 4.5` (or `ha`) + +**Know** + +- Highly available means several control-plane nodes (three as a rule) behind one stable address - the controlPlaneEndpoint, a load balancer or virtual IP, fixed at `kubeadm init`. +- etcd is either stacked (a member on every control-plane node) or external (hosts of its own). It needs a majority to accept writes: 3 members survive the loss of 1, 5 of 2. +- More control-plane nodes join with `kubeadm join --control-plane --certificate-key KEY`; the key comes from `kubeadm init phase upload-certs --upload-certs` and is good for two hours. +- Every kube-apiserver serves at once. Of the schedulers and controller-managers only the leader acts - they elect it through a Lease in kube-system. + +**Fast** + +``` +k get nodes -l node-role.kubernetes.io/control-plane +k -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint +kubeadm init --control-plane-endpoint=LB:6443 --upload-certs +kubeadm init phase upload-certs --upload-certs # prints the certificate key +kubeadm token create --print-join-command --certificate-key KEY # ... --control-plane --certificate-key KEY +k -n kube-system get lease kube-scheduler kube-controller-manager # HOLDER = NODE_uuid +etcdctl member list -w table; etcdctl endpoint status -w table; etcdctl endpoint health # with the four etcd flags +k -n kube-system get pods -l component=etcd -o wide +``` + +**Traps** + +- A cluster initialised without a controlPlaneEndpoint cannot simply be given a second control-plane node. +- An even number of etcd members tolerates no more failures than the odd number below it. +- The certificate key expires after two hours - upload the certificates again rather than reusing an old key. + +**Docs** + +- +- + +**Tasks** + +- Q110 Prepare the join of a second control-plane node +- Q111 Who leads, and who is in etcd +- Q86 How the control plane is run _(Simulator Set D (killer.sh-style))_ +- Q87 Cluster facts to an exact file _(Simulator Set D (killer.sh-style))_ + +### 4.6 Use Helm and Kustomize to install cluster components + +`./exam study 4.6` (or `helmkustomize`) + +**Know** + +- Helm: a chart is the package, a release one installation of it in a Namespace, and each install, upgrade or rollback a revision. Values come from the chart, then -f files, then --set. +- `helm template` renders a chart without installing it; `helm show values` prints its defaults; `helm get values` what a release was given. +- Kustomize: a kustomization.yaml lists resources and changes them - namespace, namePrefix, labels, images, replicas, patches - and generates ConfigMaps and Secrets. An overlay lists a base and changes only what differs. + +**Fast** + +``` +helm repo add NAME URL && helm repo update; helm search repo NAME --versions +helm install REL CHART -n NS --create-namespace --set replicaCount=2 -f values.yaml # --version X +helm upgrade REL CHART -n NS --reuse-values --set image.tag=1.27 +helm template REL CHART -n NS --set replicaCount=3 > manifest.yaml +helm list -A; helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS +k kustomize DIR | less; k apply -k DIR +# kustomization.yaml: resources: [../../base] namespace: prod namePrefix: prod- images: [{name: nginx, newTag: "1.27"}] +# replicas: [{name: web, count: 3}] patches: [{path: patch.yaml}] configMapGenerator: [{name: cfg, literals: [k=v]}] +``` + +**Traps** + +- Releases live in a Namespace: without -n Helm looks in `default`. +- An upgrade without --reuse-values (or the same -f and --set again) puts every value back to the chart's default. +- In a kustomization, `images`, `replicas` and patches use the names of the base, before any prefix. +- `namespace:` in a kustomization does not create the Namespace. + +**Docs** + +- +- +- + +**Tasks** + +- Q21 Helm release install and upgrade +- Q3 Kustomize overlay +- Q48 Kustomize with generators and patches +- Q69 Render a chart, then install it _(Simulator Set C)_ +- Q98 Helm release housekeeping _(Simulator Set D (killer.sh-style))_ + +### 4.7 Understand extension interfaces (CNI, CSI, CRI, etc.) + +`./exam study 4.7` (or `interfaces`) + +**Know** + +- CRI: the kubelet drives the container runtime (containerd, CRI-O) through a socket; `crictl` speaks the same protocol, so it works on a node when kubectl does not. +- CNI: for every Pod the runtime calls the plugins named in /etc/cni/net.d (binaries in /opt/cni/bin) to give it an address and wire it up. The plugin - kindnet, Calico, Cilium, Flannel - usually runs as a DaemonSet. +- CSI: a storage driver runs as Pods, registers a CSIDriver object, and a StorageClass names it as provisioner. + +**Fast** + +``` +k get nodes -o wide # CONTAINER-RUNTIME column +crictl version; crictl pods; crictl ps -a; crictl images; crictl inspect CONTAINER-ID +grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml; cat /etc/crictl.yaml +ls /etc/cni/net.d; cat /etc/cni/net.d/*; ls /opt/cni/bin +k -n kube-system get ds # the CNI's DaemonSet beside kube-proxy +k get csidrivers; k get csinodes; k get sc # PROVISIONER +``` + +**Traps** + +- Not every CNI plugin enforces NetworkPolicy (Flannel does not; kindnet in this cluster does). +- Without a CNI plugin the nodes stay NotReady: `cni plugin not initialized`. +- The runtime takes the first configuration file in /etc/cni/net.d, in alphabetical order. + +**Docs** + +- +- +- + +**Tasks** + +- Q112 CNI, CSI and CRI: what this cluster plugs in +- Q108 The container runtime behind a Pod +- Q41 Component versions +- Q63 Cluster facts, exactly +- Q77 Audit the CNI, then lock a namespace down _(Simulator Set C)_ +- Q87 Cluster facts to an exact file _(Simulator Set D (killer.sh-style))_ + +### 4.8 Understand CRDs, install and configure operators + +`./exam study 4.8` (or `crds`) + +**Know** + +- A CustomResourceDefinition adds a kind to the API: group, names (kind, plural, singular, shortNames), scope (Namespaced or Cluster) and versions, each with an OpenAPI v3 schema. +- The schema validates custom resources - types, required fields, enum, minimum, maximum, defaults - and feeds `kubectl explain`. +- An operator is a CRD plus a controller (a Deployment with RBAC) that acts on those objects. Installing one means applying its manifests or its Helm chart, then creating a custom resource. + +**Fast** + +``` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: {name: backups.data.example.com} # PLURAL.GROUP +spec: + group: data.example.com + scope: Namespaced + names: {kind: Backup, plural: backups, singular: backup, shortNames: [bk]} + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [schedule] + properties: + schedule: {type: string} + retention: {type: integer, minimum: 1, default: 7} +k get crd; k api-resources --api-group=data.example.com; k explain backup.spec --recursive +``` + +**Traps** + +- The CRD's name must be PLURAL.GROUP, and exactly one version has `storage: true`. +- A custom resource's apiVersion is GROUP/VERSION of the CRD, not apiextensions.k8s.io/v1. +- `default` and validation only work inside a structural schema: every level needs its `type`. +- Deleting a CRD deletes every object of that kind. + +**Docs** + +- +- +- + +**Tasks** + +- Q4 CustomResourceDefinition +- Q34 Discover a CRD field with kubectl explain +- Q49 CRD with validation and defaults +- Q91 API resources and a Role census _(Simulator Set D (killer.sh-style))_ + +## Services & Networking (20%) + +### 5.1 Understand connectivity between Pods + +`./exam study 5.1` (or `connectivity`) + +**Know** + +- Every Pod has its own IP address, and any Pod reaches any other Pod on any node at that address without NAT. The CNI plugin makes that true. +- Each node hands out Pod addresses from its own range (spec.podCIDR), a slice of the cluster's Pod CIDR; Services get theirs from the separate service CIDR. +- A hostNetwork Pod lives in the node's network: it has the node's address and shares its ports. +- Containers of one Pod share a network namespace and talk over localhost. + +**Fast** + +``` +k get pods -o wide -A; k get nodes -o custom-columns=NODE:.metadata.name,PODCIDR:.spec.podCIDR,IP:.status.addresses[0].address +k exec POD -- wget -qO- -T3 http://POD-IP:PORT +k -n kube-system get cm kubeadm-config -o yaml | grep -E 'podSubnet|serviceSubnet' +k -n kube-system get pods -o wide | grep -E 'kube-proxy|kindnet'; k -n kube-system get cm kube-proxy -o yaml | grep mode +``` + +**Traps** + +- A Pod's address changes whenever the Pod is replaced - that is what Services are for. +- Reaching a Pod by IP proves the network; reaching it by Service name also needs endpoints and DNS. +- If only the Pods of one node are unreachable, look at that node's CNI and kube-proxy Pods. + +**Docs** + +- +- + +**Tasks** + +- Q113 Pod to Pod, across nodes +- Q63 Cluster facts, exactly +- Q71 Expose and verify the dispatch API _(Simulator Set C)_ +- Q77 Audit the CNI, then lock a namespace down _(Simulator Set C)_ +- Q87 Cluster facts to an exact file _(Simulator Set D (killer.sh-style))_ + +### 5.2 Define and enforce Network Policies + +`./exam study 5.2` (or `netpol`) + +**Know** + +- Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through. +- Policies only allow, and they add up: there is no deny rule and no ordering. +- Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR. +- Default deny: `podSelector: {}` with the policyTypes and no rules. An egress lock-down also has to allow DNS (UDP and TCP 53). + +**Fast** + +``` +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: backend-policy, namespace: NS} +spec: + podSelector: {matchLabels: {app: backend}} + policyTypes: [Ingress, Egress] + ingress: + - from: [{podSelector: {matchLabels: {app: frontend}}}] + ports: [{protocol: TCP, port: 8080}] + egress: + - to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}}] + ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] +k describe netpol backend-policy -n NS; k exec CLIENT -- wget -qO- -T2 http://backend:8080 +``` + +**Traps** + +- There is no `kubectl create networkpolicy` - copy the skeleton from the docs. +- `namespaceSelector` and `podSelector` in the same item: Pods with that label in those Namespaces. In two items: either. +- A missing `ingress` denies everything; `ingress: [{}]` allows everything. +- A Namespace is matched by its labels; kubernetes.io/metadata.name=NAME is always there. + +**Docs** + +- + +**Tasks** + +- Q11 NetworkPolicy +- Q42 Egress-only NetworkPolicy +- Q54 Zero-trust namespace +- Q83 NetworkPolicy for the safety monitor _(Simulator Set C)_ +- Q100 Namespace AND pod selector, enforced _(Simulator Set D (killer.sh-style))_ +- Q77 Audit the CNI, then lock a namespace down _(Simulator Set C)_ + +### 5.3 Use ClusterIP, NodePort, LoadBalancer service types and endpoints + +`./exam study 5.3` (or `services`) + +**Know** + +- A Service selects Pods by label and forwards `port` to `targetPort` - a number, or the name of a container port. +- ClusterIP is reachable inside the cluster; NodePort adds a port (30000-32767) on every node; LoadBalancer adds an external address from the cloud; ExternalName is a DNS alias; headless (clusterIP: None) gives every Pod its own DNS record. +- The Pods behind a Service are its endpoints, kept in EndpointSlices; only Ready Pods are in them. + +**Fast** + +``` +k expose deploy shop --name=shop-svc --port=80 --target-port=80 --type=NodePort +k create svc nodeport shop-svc --tcp=80:80 --node-port=30080 # then check the selector +k create svc externalname legacy --external-name=db.example.com +k get svc,endpointslices -o wide; k describe svc shop-svc # Endpoints: +k get pods -l app=shop --show-labels +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://shop-svc +``` + +**Traps** + +- `kubectl expose` copies the workload's selector; `kubectl create svc` selects app=, which may match nothing. +- `expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML. +- A LoadBalancer stays where nothing provides one; it still works as a NodePort. +- A headless Service for a StatefulSet has to carry the name in the StatefulSet's serviceName. + +**Docs** + +- +- + +**Tasks** + +- Q12 NodePort Service +- Q30 Named ports +- Q43 Headless and ExternalName services +- Q64 The context's namespace +- Q71 Expose and verify the dispatch API _(Simulator Set C)_ +- Q73 Fix ETA service connectivity _(Simulator Set C)_ +- Q94 Static Pod on the control plane behind a Service _(Simulator Set D (killer.sh-style))_ + +### 5.4 Use the Gateway API to manage Ingress traffic + +`./exam study 5.4` (or `gateway`) + +**Know** + +- Three kinds, three owners: a GatewayClass names the controller, a Gateway opens listeners (port, protocol, hostname, which routes may attach), an HTTPRoute attaches to a Gateway (parentRefs) and sends requests to Services (backendRefs). +- An HTTPRoute rule matches on path, header, method or query, and may split traffic between backends by weight - that is a canary. +- The kinds are CRDs (gateway.networking.k8s.io): they have to be installed, and a controller has to implement them. + +**Fast** + +``` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: web-gw, namespace: NS} +spec: + gatewayClassName: example + listeners: [{name: http, protocol: HTTP, port: 80, hostname: shop.example.com}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: web, namespace: NS} +spec: + parentRefs: [{name: web-gw}] + hostnames: [shop.example.com] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: v1, port: 80, weight: 90}, {name: v2, port: 80, weight: 10}] +k get gatewayclass,gateway,httproute -A; k describe httproute web -n NS +``` + +**Traps** + +- A route in another Namespace attaches only if the listener's allowedRoutes lets it. +- A header match belongs in `matches`, beside or instead of the path - and several rules are tried most specific first. +- No Gateway controller runs in this simulator, so these tasks are graded on the objects. + +**Docs** + +- +- + +**Tasks** + +- Q22 Gateway API HTTPRoute +- Q56 Gateway API canary with header match +- Q74 Fleet dashboard canary with Gateway API _(Simulator Set C)_ + +### 5.5 Know how to use Ingress controllers and Ingress resources + +`./exam study 5.5` (or `ingress`) + +**Know** + +- An Ingress routes HTTP(S) by host and path to Services of its own Namespace. It does nothing without an ingress controller, chosen with ingressClassName (an IngressClass names the controller). +- Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name. +- TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate. + +**Fast** + +``` +k create ingress app-ingress -n NS --class=nginx \ + --rule='shop.example.com/api*=api:8080,tls=shop-tls' --rule='shop.example.com/*=web:80,tls=shop-tls' +openssl req -x509 -nodes -newkey rsa:2048 -days 365 -subj '/CN=shop.example.com' -keyout tls.key -out tls.crt +k create secret tls shop-tls -n NS --cert=tls.crt --key=tls.key +k get ingress,ingressclass -A; k describe ingress app-ingress -n NS +``` + +**Traps** + +- In --rule a trailing * means pathType Prefix; without it the path is Exact. +- The backend port is the Service's port, not the container's. +- No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it. + +**Docs** + +- +- + +**Tasks** + +- Q13 Ingress routing +- Q55 TLS Ingress with a generated certificate + +### 5.6 Understand and use CoreDNS + +`./exam study 5.6` (or `coredns`) + +**Know** + +- CoreDNS is a Deployment in kube-system behind the Service kube-dns; its configuration, the Corefile, is in ConfigMap coredns: the `kubernetes` plugin answers for cluster.local, `forward` sends the rest upstream. +- Names: SVC.NS.svc.cluster.local for a Service, POD.SVC.NS.svc.cluster.local for the Pods of a headless Service. +- A Pod's /etc/resolv.conf points at the kube-dns address and lists the search domains that make short names work. dnsPolicy decides: ClusterFirst (default), Default (the node's resolver), None with dnsConfig. + +**Fast** + +``` +k -n kube-system get deploy coredns; k -n kube-system get pods -l k8s-app=kube-dns -o wide +k -n kube-system get svc kube-dns; k -n kube-system get endpointslices -l k8s-app=kube-dns +k -n kube-system get cm coredns -o yaml; k -n kube-system edit cm coredns +k -n kube-system logs -l k8s-app=kube-dns; k -n kube-system rollout restart deploy coredns +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- nslookup kubernetes.default.svc.cluster.local +k exec POD -- cat /etc/resolv.conf +``` + +**Traps** + +- A typo in the Corefile puts CoreDNS in CrashLoopBackOff - its log names the plugin and the line. +- DNS can be broken in several places at once: the Corefile, the Deployment (replicas, image), the kube-dns Service (selector, ports), a NetworkPolicy. +- A Pod with dnsPolicy: Default resolves the internet but not cluster names. + +**Docs** + +- +- +- + +**Tasks** + +- Q14 Cluster DNS +- Q37 Cluster DNS is broken +- Q57 DNS down: three faults +- Q101 Resolves everything except the cluster _(Simulator Set D (killer.sh-style))_ + diff --git a/README.md b/README.md index 23dbb01..680d2ed 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ Entry point is `./exam` (renamed from `cka-sim` so it can't collide with the dir Hands-on exam tasks graded **per check** against a real cluster, an exam clock, a browser front end modelled on the real PSI exam, attempt history, and drills that rotate through the -domains before you sit a full exam. Tracks: **CKA** (102 tasks: a random pool of 43 normal + -24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (37 pool tasks, at -least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (a -9-task starter bank in the same format). Each track runs on the Kubernetes version its real exam -runs — CKA and CKS on **v1.35**, CKAD on **v1.37** (see [Clusters and versions](#clusters-and-versions)). +domains before you sit a full exam. Tracks: **CKA** (113 tasks: a random pool of 54 normal + +24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (44 pool tasks, plus +a 20-task killer.sh-style Set A) and CKS (a 9-task starter bank in the same format). Each track +runs on the Kubernetes version its real exam runs — CKA and CKS on **v1.35**, CKAD on **v1.37** +(see [Clusters and versions](#clusters-and-versions)). + +CKA and CKAD are also laid out as a **study path**: the curriculum objective by objective, each +with notes, at least two pool tasks, and tracked progress from first reading to exam conditions +(see [Study path](#study-path-the-curriculum-objective-by-objective)). ## How a session is built @@ -104,7 +108,10 @@ The same state is available from the terminal if you prefer: ./exam drill weak | drill 2 17 20 | drill TSHOOT NET | drill setd ./exam start [--hard] # full exam (gated until all domains drilled; --force) ./exam sim c|d # a fixed 18/17-task simulator set, exam rules, 120 min -./exam study [DOMAIN|weak|N..] # no clock, solutions visible, nothing recorded +./exam objectives # CKA/CKAD: the curriculum as a path, with your progress +./exam next # start whatever is next on that path +./exam study [OBJ|DOMAIN|weak|N..] # no clock, solutions visible, kept out of the history +./exam notes [OBJ|DOMAIN|all] # study notes of an objective (3.2 or probes) ./exam list | show N | timer | status # during ./exam end # submit ./exam weak | history # afterwards @@ -113,21 +120,69 @@ The same state is available from the terminal if you prefer: ./teardown.sh [cka|ckad|cks|legacy] # delete clusters + state (history kept; default all) ``` +## Study path: the curriculum, objective by objective + +`tracks/cka/curriculum.py` and `tracks/ckad/curriculum.py` hold the exam curricula in their own +words and order - 27 objectives for CKA, 24 for CKAD. Every objective has study notes (what to +know, the fastest commands, the traps, the documentation pages the exam lets you open) and lists +the tasks that practise it; every task of the bank is listed under at least one objective, and +every objective has at least two pool tasks. + +``` +./exam objectives # every objective, its tasks, where you are, and the next step +./exam next # start that next step +./exam notes 3.2 # the notes of one objective, by id or key (3.2, probes); `all`, or a domain +./exam study 3.2 # those notes, then its tasks: no clock, solutions open (--all adds simulator-set tasks) +./exam drill 3.2 # the same tasks against the clock, no solution until a task is graded +``` + +Each objective goes through two stages, and the path ends in the mocks: + +1. **Study** - solve every task of the objective once, with the notes and the reference solutions + open. The objective is then *studied* (`~`). +2. **Clock** - pass the same tasks as a timed drill (6 minutes per task, new parameters). When the + latest attempt at every task is a full pass, the objective is *complete* (`✓`). +3. **Mock exam** (`./exam start`) once every objective is complete, then the fixed + **simulator sets** (`./exam sim a` for CKAD, `c` and `d` for CKA) - exam rules, no feedback + until you submit. + +`./exam next` always starts the first open step in that order; `./exam objectives` shows the whole +ladder. An objective with many tasks takes several sittings: each brings up to five tasks you have +not solved yet. The CKA path walks the domains Workloads, Networking, Storage, Architecture and +then Troubleshooting - it is 30% of the exam and builds on all the others (`STUDY_ORDER` in its +curriculum.py); CKAD follows the curriculum's order. Nothing is locked: study or drill any +objective at any time. + +Progress is tracked in two places. Timed attempts are in `history-.jsonl`, as always, and +the objective map reads them - so what you have already drilled counts. Tasks solved in study mode +and the date each objective was first studied and first completed are in `study-.json`, +next to the history (it survives `./teardown.sh` the same way); study results never reach the +statistics, the weak list or the exam gate. `./exam end` says which objectives a session got over +a line and what comes next. + +The browser UI has the same thing in the lobby: the ladder, *Continue the path*, and *Study an +objective* (the full list with notes and progress). During a study session, and in a drill once a +task has been graded, the notes of the task's objective sit under its reference solution. + +`CKA_STUDY_GUIDE.md` and `CKAD_STUDY_GUIDE.md` are the notes of every objective as one document, +generated with `./exam --track notes all --md`. + ## CKAD track `./exam --track ckad ...` (every command takes it), on its own Kubernetes v1.37 cluster (`./setup.sh ckad`). A mock draws **16** tasks in 120 minutes, weighted like the exam: Design and Build 3, Deployment 3, Observability 3, Environment and -Security 4, Networking 3, from a pool of 37 (8/7/7/10/5), so no two mocks are the same set. Every -line of the curriculum has at least one task: image builds, Jobs, DaemonSets, native sidecars, +Security 4, Networking 3, from a pool of 44 (9/8/11/11/5), so no two mocks are the same set. Every +line of the curriculum has at least two pool tasks: image builds, Jobs, DaemonSets, native sidecars, volumes; blue/green, canary, rolling-update limits, Helm, Kustomize; API deprecations, probes, `kubectl top`, logs, debugging (CrashLoopBackOff, OOMKilled, config errors); CRDs, RBAC, Pod Security admission, quotas and LimitRanges, QoS, ConfigMaps, Secrets, ServiceAccounts; NetworkPolicy (tested with real traffic), Services, Ingress with TLS. -A few tasks need more than kubectl on this machine: `podman` (Q13 builds an image), `helm` -(Q6, Q21), and network access the first time Q23 runs on a fresh cluster (it installs -metrics-server). Drill a domain with `./exam --track ckad drill ENV`, as for CKA. +A few tasks need more than kubectl on this machine: `podman` (Q13, Q41 and Q60 build images), +`helm` (Q6, Q21, Q43), and network access the first time Q23 runs on a fresh cluster (it installs +metrics-server). Drill a domain with `./exam --track ckad drill ENV`, as for CKA, or an objective +with `./exam --track ckad drill 4.3`. **Simulator Set A** (`./exam --track ckad sim a`): 20 original killer.sh-style tasks, 140 points, 120 minutes, exam rules - a fixed set (ids 38-57) kept out of the random pool, so reruns are @@ -179,13 +234,13 @@ etcd 3.6, as the exam does: `etcdutl` restores and inspects snapshots, `etcdctl` ## CKA bank -| Domain | Normal (43) | Hard (24) | +| Domain | Normal (54) | Hard (24) | |---|---|---| -| Architecture | RBAC, etcd snapshot, Kustomize, CRD, static pod, certs/CSR, Helm, aggregated ClusterRole, `kubectl explain` on a CRD, kubeadm join token, component versions | exact RBAC with an over-grant to remove, **etcd restore**, static pod on a relocated path, user → self-contained kubeconfig, Kustomize generators + patches, CRD validation/defaults, renew all control-plane certs | -| Workloads | rolling update, sidecar, node affinity + resources, ConfigMap/Secret, metrics-server repair + HPA, PriorityClass, taints, DaemonSet, CronJob, init container, immutable ConfigMap rotation, parallel Job, quota + LimitRange | anti-affinity + toleration + priority under a quota, rollback to a specific revision then pause, native sidecar + probes + exact report, cluster audit to exact files, work in the context's namespace | -| Networking | NetworkPolicy, NodePort, Ingress, cluster DNS, Gateway API, named ports, egress-only policy, headless + ExternalName | zero-trust namespace (4 policies + trap), TLS Ingress with generated cert + fingerprint, Gateway canary with header match and weights, DNS down three ways | +| Architecture | RBAC, etcd snapshot, Kustomize, CRD, static pod, certs/CSR, Helm, aggregated ClusterRole, `kubectl explain` on a CRD, kubeadm join token, component versions, kernel prerequisites of a node, the runtime behind a Pod (`crictl`), draining the control-plane node, the join of a second control-plane node, leader leases + etcd members, CNI/CSI/CRI inventory | exact RBAC with an over-grant to remove, **etcd restore**, static pod on a relocated path, user → self-contained kubeconfig, Kustomize generators + patches, CRD validation/defaults, renew all control-plane certs | +| Workloads | rolling update, sidecar, node affinity + resources, ConfigMap/Secret, metrics-server repair + HPA, PriorityClass, taints, DaemonSet, CronJob, init container, immutable ConfigMap rotation, parallel Job, quota + LimitRange, HPA with a scale-down policy, probes that check the wrong thing | anti-affinity + toleration + priority under a quota, rollback to a specific revision then pause, native sidecar + probes + exact report, cluster audit to exact files, work in the context's namespace | +| Networking | NetworkPolicy, NodePort, Ingress, cluster DNS, Gateway API, named ports, egress-only policy, headless + ExternalName, Pod-to-Pod across nodes | zero-trust namespace (4 policies + trap), TLS Ingress with generated cert + fingerprint, Gateway canary with header match and weights, DNS down three ways | | Storage | PV/PVC/pod, default StorageClass, re-bind a Released PV, data survives the pod | bind the right local volume by selector, migrate data between claims via a Job | -| Troubleshooting | node NotReady, broken deployment + service, CrashLoopBackOff, scheduler down, CoreDNS misconfig, controller-manager down, kubelet config drift | worker with three faults (+ uncordon check), two control-plane components down, app down for four reasons fixed in place (UID check), cluster facts exactly | +| Troubleshooting | node NotReady, broken deployment + service, CrashLoopBackOff, scheduler down, CoreDNS misconfig, controller-manager down, kubelet config drift, `kubectl top`, logs by selector and of an init container | worker with three faults (+ uncordon check), two control-plane components down, app down for four reasons fixed in place (UID check), cluster facts exactly | Disruptive tasks (anything that breaks a node or control-plane component) are seeded last and can block other tasks until fixed — read everything first, as in the real exam. Tasks that damage @@ -205,10 +260,10 @@ were retired in favour of these two; their last versions are in git history.) ## Adding tasks or a new track -Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_sim_*}.py`; a task is: +Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_objectives,bank_sim_*}.py`; a task is: ```python -{"id": 103, "domain": "NET", "difficulty": "normal", # or "hard" - ids stay contiguous +{"id": 114, "domain": "NET", "difficulty": "normal", # or "hard" - ids stay contiguous "params": lambda r: {"ns": ns_pick(r, "svc"), "port": r.choice([8080, 9090])}, "title": "...", "task": "...markdown; %{ns} and %{port} are substituted; `code` copies on click...", "setup": "bash that seeds the scenario (may use %{ns})", @@ -224,3 +279,8 @@ and helpers `j '{jsonpath}'` and `eq a b`. Task text may use `{WS}`, session draws, `TRACK["k8s"]` the Kubernetes minor (and so the cluster) the track runs on, and `TRACK["exam_contexts"]` whether it owns the `k8s-c*` contexts. For CKS, add to `QUESTIONS` in its track; `TRACK` already carries the domain table and pass mark. + +In a track with a `curriculum.py`, list a new task under the objective(s) it practises ("tasks"). +`./exam objectives` warns about a task that no objective lists, a listed id that does not exist, +and an objective left with fewer than two pool tasks. A track without that file simply has no +objective commands (CKS today). diff --git a/exam b/exam index a67b779..c0bb737 100755 --- a/exam +++ b/exam @@ -19,7 +19,7 @@ from collections.abc import Callable from datetime import datetime from typing import Any -VERSION = "2026.09.07.1" +VERSION = "2026.10.05.1" ROOT = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, ROOT) @@ -42,6 +42,7 @@ HISTORY_FILE = "" WEAK_FILE = "" STUDY_FILE = "" # tasks solved in study mode (the objective checklist reads it; history never does) OBJECTIVES = [] # the track's curriculum, objective by objective (tracks//curriculum.py), if it has one +STUDY_ORDER = [] # domains in the order the path walks them, when that differs from the curriculum's own K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35" CLUSTER = "" # kind cluster for that minor CONTEXT = "" @@ -71,11 +72,12 @@ def load_track(track_id): track, questions = mod.TRACK, list(mod.QUESTIONS) try: cur = importlib.import_module(f"tracks.{track_id}.curriculum") - objectives = list(importlib.reload(cur).OBJECTIVES) + cur = importlib.reload(cur) + objectives, study_order = list(cur.OBJECTIVES), list(getattr(cur, "STUDY_ORDER", [])) except ModuleNotFoundError as e: if e.name != f"tracks.{track_id}.curriculum": raise - objectives = [] # no objective map for this track: study/drill by domain only + objectives, study_order = [], [] # no objective map for this track: study/drill by domain only for q in questions: q["weight"] = sum(c["pts"] for c in q["checks"]) q.setdefault("difficulty", "normal") @@ -92,7 +94,7 @@ def load_track(track_id): STATE_FILE=os.path.join(ROOT, ".state", f"state-{track_id}.json"), HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"), WEAK_FILE=os.path.join(ROOT, f"weak-spots-{track_id}.md"), - STUDY_FILE=os.path.join(ROOT, f"study-{track_id}.json"), OBJECTIVES=objectives, + STUDY_FILE=os.path.join(ROOT, f"study-{track_id}.json"), OBJECTIVES=objectives, STUDY_ORDER=study_order, # The CKA_* variables point every track at your own cluster instead (see the README). K8S=track["k8s"], CLUSTER=cluster, CONTEXT=os.environ.get("CKA_CONTEXT", f"kind-{cluster}"), @@ -672,6 +674,27 @@ def objective_tasks(o, with_sets=False): def objectives_of(qid): return [o for o in OBJECTIVES if qid in o["tasks"]] +def objective_domains(): + """Domain codes in the order the curriculum lists them (TRACK["domains"] may order them otherwise).""" + return list(dict.fromkeys(o["domain"] for o in OBJECTIVES)) + +def path_objectives(): + """Objectives in the order the path walks them: the curriculum's order, unless the track names + a STUDY_ORDER of domains (CKA leaves Troubleshooting for last - it builds on all the rest).""" + order = [d for d in STUDY_ORDER if d in DOMAINS] + [d for d in objective_domains() if d not in STUDY_ORDER] + return sorted(OBJECTIVES, key=lambda o: order.index(o["domain"])) # stable: curriculum order within a domain + +def objective_session(o, how, with_sets=False, n=None): + """The tasks of one sitting on an objective: what is still open first ("study": never solved; + "drill": not yet passed against the clock), at most n, never two that break the same thing. + A big objective therefore takes several sittings, each on new ground.""" + agg, studied = stats(), load_study() + closed = ((lambda q: task_solved(q["id"], agg, studied)) if how == "study" + else (lambda q: task_state(q["id"], agg, studied) == "passed")) + qs = objective_tasks(o, with_sets) + todo = [q for q in qs if not closed(q)] or qs # nothing open: go over all of it again + return no_conflicts(todo, n or DRILL_SIZE), len(todo) + def load_progress(): """study-.json, next to the history so it survives ./teardown.sh as the history does: tasks task id -> its latest result in a study session ("passed" sticks once earned) @@ -746,7 +769,7 @@ def next_objective(agg=None, studied=None): every objective is done.""" agg = stats() if agg is None else agg studied = load_study() if studied is None else studied - for o in OBJECTIVES: + for o in path_objectives(): state = objective_state(o, agg, studied) if state != "done": return o, ("drill" if state == "studied" else "study") @@ -863,8 +886,8 @@ def cmd_objectives(args): shown = " ".join(c[1] for c in pool) + (dim(" [") + " ".join(c[1] for c in fixed) + dim("]") if fixed else "") rows.append((o, plain, shown)) tw = max(28, min(max(len(o["text"]) for o in OBJECTIVES), cols - 26 - max(len(r[1]) for r in rows))) - for d, name in DOMAINS.items(): - print(bold(f"\n{name}")) + for d in objective_domains(): + print(bold(f"\n{DOMAINS[d]}")) for o, plain, shown in rows: if o["domain"] != d: continue @@ -948,11 +971,11 @@ def notes_markdown(objs): f"_Generated by `./exam --track {TRACK_ID} notes all --md` from `tracks/{TRACK_ID}/curriculum.py` - edit that file, not this one._", "", "```", f"./exam objectives # the path and your progress", "./exam study 3.2 # one objective: these notes, then its tasks, no clock, solutions open", "./exam drill 3.2 # the same tasks against the clock", "```", ""] - for d, name in DOMAINS.items(): + for d in objective_domains(): mine = [o for o in objs if o["domain"] == d] if not mine: continue - L += [f"## {name}", ""] + L += [f"## {DOMAINS[d]}", ""] for o in mine: L += [f"### {o['id']} {o['text']}", "", f"`./exam study {o['id']}` (or `{o['key']}`)", "", "**Know**", ""] L += [f"- {x}" for x in o["know"]] @@ -1217,9 +1240,10 @@ if $K -n kube-system get deploy metrics-server >/dev/null 2>&1 \ -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 || true fi -# cordons: Q60 cordons W2, and a drain task left half-done leaves a node cordoned. A cordon left -# behind fails any later task that needs every worker (Q26's DaemonSet coverage, Q50's spread). -for n in "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done +# cordons: Q60 cordons W2, Q109 has the control-plane node drained, and a drain task left half-done +# leaves a node cordoned. A cordon left behind fails any later task that needs every worker (Q26's +# DaemonSet coverage, Q50's spread) or a Pod on the control-plane node (Q96, Q109 itself). +for n in "$CP" "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done # Deliberately NOT restored, and why: # etcd (Q45) and certs (Q65). Neither setup damages anything - Q45 only takes a snapshot @@ -1328,6 +1352,7 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, if not qs: die(f"Track '{TRACK_ID}' has no questions yet.") preflight() + update_milestones() # stamp what the history already proves, so this session only announces its own os.makedirs(WORKSPACE, exist_ok=True) rng = random.Random() params_by_id = {str(q["id"]): make_params(q, rng) for q in qs} @@ -1381,7 +1406,7 @@ def finish_session(st, log: Callable[..., Any] = _log_msg): else: entry = record_attempt(st) # objectives this session got over a line: shown by ./exam end and by the results page - st["milestones"] = milestone_lines(update_milestones()) + st["milestones"] = st.get("milestones", []) + milestone_lines(update_milestones()) save_state(st) return entry @@ -1441,8 +1466,11 @@ def cmd_drill(args): elif a.isdigit(): ids.append(q_by_id(int(a))["id"]) elif find_objective(a): - ids += [q["id"] for q in objective_tasks(find_objective(a))] + sitting, open_n = objective_session(find_objective(a), "drill") + ids += [q["id"] for q in sitting] objective = objective or find_objective(a)["id"] + if open_n > len(sitting): + print(dim(f" {find_objective(a)['id']}: {len(sitting)} of its {open_n} open tasks in this sitting - run it again for the rest.")) else: die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), a set " f"({', '.join('set' + k for k in SETS) or 'none'})" @@ -1485,12 +1513,14 @@ def cmd_study(args): obj = find_objective(rest[0]) else: agg, studied = stats(), load_study() - obj = next((o for o in OBJECTIVES if objective_state(o, agg, studied) in ("new", "started")), None) + obj = next((o for o in path_objectives() if objective_state(o, agg, studied) in ("new", "started")), None) if obj is None: die("Every objective has been studied - name one to revisit (./exam objectives lists them),\n" f" or carry on up the path: {next_step(agg, studied)['cmd']} (./exam next)") - qs = no_conflicts(split_solo(objective_tasks(obj, with_sets))[0], len(obj["tasks"])) + qs, open_n = objective_session(obj, "study", with_sets, n if "--count" in args else None) print(bold(f"Study: {obj['id']} {obj['text']}") + dim(f" {DOMAINS[obj['domain']]}")) + if open_n > len(qs): + print(dim(f" {len(qs)} of its {open_n} open tasks in this sitting - run the same command again for the rest.")) elif rest: qs = no_conflicts(split_solo([q_by_id(int(a)) if a.isdigit() else die( f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), 'weak'" @@ -1566,7 +1596,10 @@ def cmd_grade(args): save_state(st) record_study(st, set(ids)) if untimed(st): - for line in milestone_lines(update_milestones()): + reached = milestone_lines(update_milestones()) + if reached: # kept with the session, so ./exam end and the results page show them again + st["milestones"] = st.get("milestones", []) + reached; save_state(st) + for line in reached: print(green(line)) if args[0] == "all": print_score(st) @@ -1788,7 +1821,10 @@ class Api(http.server.BaseHTTPRequestHandler): elif mode == "exam": begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard, other_ok=False) else: - if body.get("domain") in DOMAINS: + objective = find_objective(body["objective"]) if body.get("objective") else None + if objective: + qs, _ = objective_session(objective, "study" if body.get("study") else "drill", bool(body.get("all"))) + elif body.get("domain") in DOMAINS: qs = pick(body["domain"], DRILL_SIZE, hard, rng) elif body.get("campaign"): _, qs = campaign_batch(hard, rng) @@ -1801,7 +1837,8 @@ class Api(http.server.BaseHTTPRequestHandler): qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids)) per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard, - untimed_session=bool(body.get("study")), other_ok=False) + untimed_session=bool(body.get("study")), other_ok=False, + objective=objective and objective["id"]) except SystemExit as e: Api.seed_error = (getattr(e, "msg", None) or "\n".join(l for l in Api.log_lines if l.strip()) or "start failed - see the terminal running ./exam web") @@ -1829,7 +1866,7 @@ class Api(http.server.BaseHTTPRequestHandler): st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st) record_study(st, {q["id"]}) if untimed(st): - update_milestones() + st["milestones"] = st.get("milestones", []) + milestone_lines(update_milestones()); save_state(st) elif p.path == "/api/end": if not st.get("ended_at"): if not kubectl_ok(): # grading an unreachable cluster would record a 0% attempt @@ -1849,7 +1886,9 @@ class Api(http.server.BaseHTTPRequestHandler): "ttyd": shutil.which("ttyd") is not None, "shell": Api.shell, "setup_log": Api.log_lines, "questions": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "weight": q["weight"], "difficulty": q["difficulty"], "task": task_text(q, session_params(st, q)), "checks": [{"name": c["name"], "pts": c["pts"]} for c in q["checks"]], - "solution": render(str(q.get("solution", "")), session_params(st, q)) if solution_allowed(st, int(q["id"])) else None} + "solution": render(str(q.get("solution", "")), session_params(st, q)) if solution_allowed(st, int(q["id"])) else None, + # which objective a task belongs to is a hint, so it follows the solution's rule + "objectives": [o["id"] for o in objectives_of(q["id"])] if solution_allowed(st, int(q["id"])) else []} for q in qs], "bank": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "difficulty": q["difficulty"]} for q in QUESTIONS], "tracks": TRACKS, "track_k8s": track_k8s(), @@ -1858,6 +1897,7 @@ class Api(http.server.BaseHTTPRequestHandler): "seeding": Api.seeding, "seed_error": Api.seed_error, "session": None, } + payload.update(self.path_payload()) if st: earned, total, pct = score(st) payload["session"] = {"mode": st.get("mode"), "hard": st.get("hard", False), "started_at": st["started_at"], "duration": st.get("duration", EXAM_SECONDS), @@ -1865,10 +1905,29 @@ class Api(http.server.BaseHTTPRequestHandler): "over_time": st.get("over_time", False), "flags": st.get("flags", []), "paused": bool(st.get("paused_at")), "paused_total": paused_total(st), "untimed": untimed(st), "notes": st.get("notes", ""), + "objective": st.get("objective"), "milestones": st.get("milestones", []), "results": st["results"], "score": {"earned": earned, "total": total, "pct": pct, "pass_pct": PASS_PCT}, "domains": self.domain_scores(st), "server_time": time.time()} return payload + def path_payload(self) -> dict[str, Any]: + """The curriculum as the UI shows it: every objective with its notes, tasks and state, how + many are studied and done, and the next step on the path. Empty for a track without one.""" + if not OBJECTIVES: + return {"objectives": [], "path": None} + agg, studied = stats(), load_study() + objs = [{"id": o["id"], "key": o["key"], "domain": o["domain"], "text": o["text"], "state": objective_state(o, agg, studied), + "know": o["know"], "fast": o["fast"].strip("\n"), "traps": o["traps"], "docs": o["docs"], + "tasks": [{"id": q["id"], "title": q["title"], "set": q.get("set"), "state": task_state(q["id"], agg, studied)} + for q in objective_tasks(o, True)]} for o in OBJECTIVES] + step = next_step(agg, studied); full, fixed = mock_scores() + states = [o["state"] for o in objs] + return {"objectives": objs, + "path": {"total": len(objs), "done": states.count("done"), "studied": states.count("done") + states.count("studied"), + "mock_best": max(full) if full else None, "set_best": {k: (max(v) if v else None) for k, v in fixed.items()}, + "next": {"kind": step["kind"], "cmd": step["cmd"], "why": step["why"], "set": step.get("set"), + "objective": step["objective"]["id"] if step.get("objective") else None}}} + def domain_scores(self, st: dict[str, Any]) -> dict[str, Any]: qs = session_questions(st); out = {} st_results: dict[str, Any] = st.get("results", {}) diff --git a/tracks/cka/bank_objectives.py b/tracks/cka/bank_objectives.py new file mode 100644 index 0000000..3250978 --- /dev/null +++ b/tracks/cka/bank_objectives.py @@ -0,0 +1,638 @@ +"""CKA tasks 103-113: pool tasks for the curriculum objectives the bank covered thinly or not at all. + +curriculum.py maps every task to the objectives of the CKA curriculum and turns the bank into a +study path (./exam objectives). Mapping the first 102 tasks left eight objectives with fewer than +two pool tasks; these close them: + + 2.3 Monitor cluster and application resource usage 103 kubectl top + 2.4 Manage and evaluate container output streams 104 logs by selector, of one replica, of an init container + 3.3 Configure workload autoscaling 105 an HPA on CPU with a scale-down policy + 3.4 Robust, self-healing deployments 106 probes that check the wrong thing + 4.2 Prepare underlying infrastructure 107 kernel modules and sysctls 108 the runtime behind a Pod + 4.4 Manage the lifecycle of clusters 109 drain the control-plane node and bring it back + 4.5 Highly-available control plane 110 the join of a second control-plane node 111 leaders and etcd members + 4.7 Extension interfaces (CNI, CSI, CRI) 112 what this cluster plugs in (and 108) + 5.1 Connectivity between Pods 113 Pod to Pod across nodes, Pod CIDRs, hostNetwork + +103, 104 and 106 are the CKAD tasks for the same skill (kubectl top, logs, probes) under CKA ids: +the objective is word for word the same, so is the scenario. Everything else is CKA ground - nodes, +kubeadm, etcd, the runtime. All normal difficulty, all in the random pool; nothing here breaks a +node or a control-plane component (109 cordons the control-plane node while you work on it and has +you uncordon it; the session baseline uncordons it if you do not). +""" +from tracks.cka.common import C, NS, ns_pick, name_pick +from tracks.ckad import bank_extra as _ckad_extra, bank_more as _ckad_more + + +def _port(bank, src_id, new_id, domain, **over): + """A CKAD task that serves a CKA objective as it is: same scenario and graders, CKA id and paths.""" + src = next(q for q in bank.QUESTIONS if q["id"] == src_id) + old, new = f"q{src_id}", f"q{new_id}" + q = dict(src, id=new_id, domain=domain, **over) + for k in ("task", "setup", "solution"): + q[k] = src[k].replace(old, new) + q["checks"] = [dict(c, script=c["script"].replace(old, new), name=c["name"].replace(old, new)) for c in src["checks"]] + return q + + +ETCDCTL = ("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") + +QUESTIONS = [ +# ---------------------------------------------------------------- TSHOOT ---- +# needs a working metrics API, which Q23 breaks on purpose: never in one session with it +_port(_ckad_extra, 23, 103, "TSHOOT", breaks="metrics-server"), +_port(_ckad_more, 64, 104, "TSHOOT"), +# ------------------------------------------------------------------ WORK ---- +{ + "id": 105, "domain": "WORK", "title": "Autoscale on CPU, scale down calmly", + "params": lambda r: {"ns": ns_pick(r, "scale"), "dep": name_pick(r, "render"), "min": r.choice([2, 3]), + "max": r.choice([6, 8, 10]), "cpu": r.choice([50, 60, 70]), "win": r.choice([120, 180, 300])}, + "task": """ +Deployment `%{dep}` in Namespace `%{ns}` has to scale with its load. + +1. An autoscaler that works on CPU utilisation needs a CPU request to measure against. Give the + container `web` a CPU request of `100m` and leave everything else in the Deployment as it is. +2. Create HorizontalPodAutoscaler `%{dep}` for the Deployment: at least **%{min}** replicas, at + most **%{max}**, target average CPU utilisation **%{cpu}%**. +3. Scaling down must be calm: a stabilisation window of **%{win}** seconds, and at most **1 Pod + removed per 60 seconds**. Leave scaling up at its defaults. +""", + "setup": NS + r""" +mkns %{ns} +$K -n %{ns} delete hpa --all >/dev/null 2>&1 +$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: {name: %{dep}, namespace: %{ns}, labels: {app: %{dep}}} +spec: + replicas: 1 + selector: {matchLabels: {app: %{dep}}} + template: + metadata: {labels: {app: %{dep}}} + spec: + containers: + - name: web + image: nginx:1.27 + ports: [{containerPort: 80}] + resources: {requests: {memory: 32Mi}} +EOF +$K -n %{ns} rollout status deploy %{dep} --timeout=150s >/dev/null +""", + "checks": [ + C("container web requests cpu 100m, the rest of it unchanged", 2, r""" +eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].name}/{.spec.template.spec.containers[0].image}/{.spec.template.spec.containers[0].resources.requests.cpu}/{.spec.template.spec.containers[0].resources.requests.memory}')" \ + "web/nginx:1.27/100m/32Mi" """), + C("HPA %{dep}: Deployment %{dep}, %{min}-%{max} replicas, %{cpu}% average CPU utilisation", 2, r""" +$K -n %{ns} get hpa %{dep} -o json 2>/dev/null | python3 -c ' +import sys, json +s = json.load(sys.stdin)["spec"]; t = s["scaleTargetRef"]; m = s.get("metrics") or [] +cpu = [x["resource"]["target"] for x in m if x.get("type") == "Resource" and x["resource"]["name"] == "cpu"] +ok = (t["kind"], t["name"]) == ("Deployment", "%{dep}") and (s.get("minReplicas"), s["maxReplicas"]) == (%{min}, %{max}) \ + and len(m) == 1 and len(cpu) == 1 and cpu[0].get("type") == "Utilization" and cpu[0].get("averageUtilization") == %{cpu} +sys.exit(0 if ok else 1)'"""), + C("scale-down: %{win} s stabilisation window, at most 1 Pod per 60 s", 2, r""" +$K -n %{ns} get hpa %{dep} -o json 2>/dev/null | python3 -c ' +import sys, json +d = (json.load(sys.stdin)["spec"].get("behavior") or {}).get("scaleDown") or {} +p = d.get("policies") or [] +ok = d.get("stabilizationWindowSeconds") == %{win} and len(p) == 1 \ + and (p[0].get("type"), p[0].get("value"), p[0].get("periodSeconds")) == ("Pods", 1, 60) +sys.exit(0 if ok else 1)'"""), + C("the autoscaler has taken the Deployment to at least %{min} replicas", 1, r""" +$K -n %{ns} get hpa %{dep} >/dev/null 2>&1 || exit 1 +for i in $(seq 1 20); do + [ "$(j -n %{ns} deploy %{dep} '{.spec.replicas}')" -ge %{min} ] 2>/dev/null && exit 0; sleep 3 +done; exit 1"""), + ], + "solution": """ +k -n %{ns} set resources deploy %{dep} -c web --requests=cpu=100m +k -n %{ns} autoscale deploy %{dep} --min=%{min} --max=%{max} --cpu=%{cpu}% # kubectl before 1.34: --cpu-percent=%{cpu} +k -n %{ns} edit hpa %{dep} # `autoscale` cannot write behaviour - add under spec: +# behavior: +# scaleDown: +# stabilizationWindowSeconds: %{win} +# policies: +# - {type: Pods, value: 1, periodSeconds: 60} +k -n %{ns} get hpa %{dep} # MINPODS %{min}, MAXPODS %{max}; REPLICAS climbs to %{min} +k -n %{ns} describe hpa %{dep} +# The whole object, for when you write it instead: +# apiVersion: autoscaling/v2 +# kind: HorizontalPodAutoscaler +# metadata: {name: %{dep}, namespace: %{ns}} +# spec: +# scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: %{dep}} +# minReplicas: %{min} +# maxReplicas: %{max} +# metrics: +# - type: Resource +# resource: {name: cpu, target: {type: Utilization, averageUtilization: %{cpu}}} +# behavior: {scaleDown: {stabilizationWindowSeconds: %{win}, policies: [{type: Pods, value: 1, periodSeconds: 60}]}} +# Utilisation is usage divided by the REQUEST, so without a CPU request the HPA shows +# and never scales on load. It does enforce minReplicas at once, metrics or not. TARGETS stays +# here until metrics-server is installed and has a minute of data. +""", +}, +_port(_ckad_more, 62, 106, "WORK"), +# ------------------------------------------------------------------ ARCH ---- +{ + "id": 107, "domain": "ARCH", "title": "Kernel prerequisites for a Kubernetes node", + "params": lambda r: {"name": r.choice(["k8s", "kubernetes", "99-kubernetes-cri"])}, + "task": """ +Node `{W3}` is to be prepared the way a machine is before `kubeadm` puts Kubernetes on it. Reach +it with `{SHELL_W3}`. + +1. Make the kernel modules `overlay` and `br_netfilter` load at every boot: list them, one per + line, in `/etc/modules-load.d/%{name}.conf`. +2. Make these kernel parameters permanent in `/etc/sysctl.d/%{name}.conf`: + `net.bridge.bridge-nf-call-iptables = 1`, `net.bridge.bridge-nf-call-ip6tables = 1` and + `net.ipv4.ip_forward = 1`. +3. Write what the node reports right now to `{WS}/q107/node.txt`, in exactly this format: + +``` +ip_forward= +swap= +fail-swap-on= +cgroup-driver= +``` + +(The nodes of this simulator are containers, so a module cannot really be loaded here: the two +files and the report are what is graded.) +""", + "setup": r""" +rm -rf "$WS/q107"; mkdir -p "$WS/q107" +nx "$W3" 'rm -f /etc/modules-load.d/%{name}.conf /etc/sysctl.d/%{name}.conf' /dev/null" /dev/null" /dev/null && swap=on +fso=$(nx "$W3" "awk '/^failSwapOn:/{print \$2}' /var/lib/kubelet/config.yaml" /dev/null | grep .)" "$want" """), + ], + "solution": """ +{SHELL_W3} +printf 'overlay\\nbr_netfilter\\n' > /etc/modules-load.d/%{name}.conf +cat > /etc/sysctl.d/%{name}.conf < {WS}/q107/node.txt < +fail-swap-on= +cgroup-driver= +EOF +# `sysctl -w` and `modprobe` alone are gone after a reboot - the files are what makes them stick. +# kubeadm's preflight refuses a node with swap on unless the kubelet is told to tolerate it +# (failSwapOn: false), and the kubelet's cgroup driver has to match the container runtime's +# (SystemdCgroup = true in /etc/containerd/config.toml goes with cgroupDriver: systemd). +""", +}, +{ + "id": 108, "domain": "ARCH", "title": "The container runtime behind a Pod", + "params": lambda r: {"ns": ns_pick(r, "runtime"), "pod": name_pick(r, "probe")}, + "task": """ +Pod `%{pod}` in Namespace `%{ns}` runs on node `{W2}`. Look at it from the node's side, through +the container runtime (`{SHELL_W2}`, then `crictl`), and write `{WS}/q108/runtime.txt` in exactly +this format: + +``` +runtime= +socket= +container-id= +pid= +log= +``` +""", + "setup": NS + r""" +mkns %{ns}; rm -rf "$WS/q108"; mkdir -p "$WS/q108" +$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: v1 +kind: Pod +metadata: {name: %{pod}, namespace: %{ns}} +spec: + terminationGracePeriodSeconds: 1 + nodeSelector: {kubernetes.io/hostname: "{W2}"} + containers: + - name: app + image: busybox:1.36 + command: [sh, -c, "echo 'runtime probe started'; sleep 86400"] +EOF +$K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=150s >/dev/null +""", + "checks": [ + C("runtime and socket lines", 2, r""" +ver=$(nx "$W2" "crictl version 2>/dev/null" /dev/null | sed 's/[[:space:]]*$//' | grep -qx "runtime=$name $rv" && tr -d ' \t\r' < "$f" | grep -qx "socket=$sock" """), + C("container-id is the full ID of container app", 2, r""" +cid=$($K -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].containerID}' 2>/dev/null | sed 's#^[a-z-]*://##') +[ "${#cid}" = 64 ] && tr -d ' \t\r' < "$WS/q108/runtime.txt" 2>/dev/null | grep -qx "container-id=$cid" """), + C("pid is that container's process on the node", 2, r""" +cid=$($K -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].containerID}' 2>/dev/null | sed 's#^[a-z-]*://##') +pid=$(nx "$W2" "crictl inspect -o go-template --template '{{.info.pid}}' $cid 2>/dev/null" /dev/null | grep -qx "pid=$pid" """), + C("log is the container's log file on the node", 1, r""" +cid=$($K -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].containerID}' 2>/dev/null | sed 's#^[a-z-]*://##') +log=$(nx "$W2" "crictl inspect -o go-template --template '{{.status.logPath}}' $cid 2>/dev/null" /dev/null | grep -qx "log=$log" """), + ], + "solution": """ +k -n %{ns} get pod %{pod} -o wide # on {W2} +{SHELL_W2} +crictl version # RuntimeName: containerd RuntimeVersion: v2.x.y +grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml # unix:///run/containerd/containerd.sock +crictl pods --name %{pod} # the Pod sandbox +crictl ps --no-trunc --name app --pod $(crictl pods --name %{pod} -q) # full container ID +crictl inspect | grep -E '"pid"|logPath' # .info.pid and .status.logPath +# or: crictl inspect -o go-template --template '{{.info.pid}} {{.status.logPath}}' +exit +cat > {WS}/q108/runtime.txt < +socket=unix:///run/containerd/containerd.sock +container-id=<64 hex characters> +pid= +log=/var/log/pods/%{ns}_%{pod}_/app/0.log +EOF +# The kubelet drives the runtime over the CRI socket, and crictl speaks the same protocol - which +# is why it still works when the API server does not. `kubectl get pod -o +# jsonpath='{.status.containerStatuses[0].containerID}'` shows the same ID with a containerd:// +# prefix; /var/log/pods/... is the file `kubectl logs` reads. +""", +}, +{ + # drains the control-plane node: CoreDNS moves off it, so never beside the tasks that break DNS + "id": 109, "domain": "ARCH", "title": "Drain the control-plane node and bring it back", "breaks": "coredns", + "params": lambda r: {"ns": ns_pick(r, "maint"), "dep": name_pick(r, "beacon")}, + "task": """ +The control-plane node `{CP}` is about to be upgraded and has to be emptied first. + +1. Drain `{CP}`: it must be unschedulable, and the only Pods left on it are static Pods and Pods + of DaemonSets. Deployment `%{dep}` in Namespace `%{ns}` has a Pod with an `emptyDir` volume on + that node - losing that volume is accepted. +2. While the node is drained, save the output of `kubectl get nodes` to `{WS}/q109/drained.txt`, + and write the phase the Pod of `%{dep}` is in now to `{WS}/q109/phase.txt`. +3. The upgrade is over: make the node schedulable again and wait until `%{dep}` has its Pod back. +""", + "setup": NS + r""" +mkns %{ns}; rm -rf "$WS/q109"; mkdir -p "$WS/q109" +$K uncordon "$CP" >/dev/null 2>&1 +$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1 +$K -n %{ns} delete pod -l app=%{dep} --wait=true --grace-period=1 >/dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: {name: %{dep}, namespace: %{ns}, labels: {app: %{dep}}} +spec: + replicas: 1 + selector: {matchLabels: {app: %{dep}}} + template: + metadata: {labels: {app: %{dep}}} + spec: + terminationGracePeriodSeconds: 2 + nodeSelector: {kubernetes.io/hostname: "{CP}"} + tolerations: + - {key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule} + volumes: [{name: scratch, emptyDir: {}}] + containers: + - name: beacon + image: busybox:1.36 + command: [sh, -c, "date > /scratch/started; sleep 86400"] + volumeMounts: [{name: scratch, mountPath: /scratch}] +EOF +$K -n %{ns} rollout status deploy %{dep} --timeout=150s >/dev/null +$K -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[0].metadata.uid}' > "$WS/.q109-uid" +""", + "checks": [ + C("drained.txt shows {CP} as SchedulingDisabled", 2, r""" +grep -Eq "^$CP[[:space:]]+Ready,SchedulingDisabled([[:space:]]|\$)" "$WS/q109/drained.txt" 2>/dev/null"""), + C("phase.txt holds Pending", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q109/phase.txt" 2>/dev/null)" Pending"""), + C("the Pod of %{dep} was evicted, and its replacement runs on {CP} again", 2, r""" +old=$(cat "$WS/.q109-uid" 2>/dev/null); [ -n "$old" ] || exit 1 +for i in $(seq 1 20); do + new=$($K -n %{ns} get pod -l app=%{dep} -o jsonpath='{range .items[?(@.status.phase=="Running")]}{.metadata.uid} {.spec.nodeName}{"\n"}{end}' 2>/dev/null | head -1) + [ -n "$new" ] && break; sleep 3 +done +[ -n "$new" ] && [ "${new%% *}" != "$old" ] && [ "${new##* }" = "$CP" ]"""), + C("{CP} is schedulable again and CoreDNS is fully available", 2, r""" +old=$(cat "$WS/.q109-uid" 2>/dev/null); [ -n "$old" ] || exit 1 +$K -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[*].metadata.uid}' 2>/dev/null | grep -q "$old" && exit 1 +[ "$(j node "$CP" '{.spec.unschedulable}')" != true ] || exit 1 +for i in $(seq 1 20); do + [ "$(j -n kube-system deploy coredns '{.status.availableReplicas}')" = "$(j -n kube-system deploy coredns '{.spec.replicas}')" ] && exit 0; sleep 3 +done; exit 1"""), + ], + "solution": """ +k drain {CP} --ignore-daemonsets --delete-emptydir-data +# without the flags it stops at: cannot delete DaemonSet-managed Pods ... / cannot delete Pods with local storage ... +k get nodes > {WS}/q109/drained.txt # {CP} Ready,SchedulingDisabled control-plane +k -n %{ns} get pod -l app=%{dep} # Pending: it may only run on {CP}, and that node is cordoned +echo Pending > {WS}/q109/phase.txt +k uncordon {CP} +k -n %{ns} rollout status deploy %{dep} +k get nodes; k -n kube-system get deploy coredns # CoreDNS moved to the workers during the drain and stays there +# drain = cordon + evict. It leaves DaemonSet Pods (they would come straight back) and static Pods, +# refuses Pods with emptyDir data until you accept the loss, and refuses bare Pods until --force - +# which deletes them for good. The node stays cordoned until you uncordon it: a real upgrade +# (kubeadm upgrade apply, then the kubelet package) happens between those two commands. +""", +}, +{ + "id": 110, "domain": "ARCH", "title": "Prepare the join of a second control-plane node", + "task": """ +The cluster is getting a second control-plane node. Prepare what the new machine needs - on the +control-plane node, `{SHELL_CP}` - without joining anything. + +1. Write the address every node uses to reach the API server - the cluster's control-plane + endpoint, as kubeadm has it configured - to `{WS}/q110/endpoint.txt`. +2. Upload the control-plane certificates to the cluster, so that a joining control-plane node can + fetch them. +3. Save the complete `kubeadm join` command a new **control-plane** node has to run - with a new + token, the CA certificate hash and the certificate key from step 2 - to + `{WS}/q110/join-control-plane.sh`, on one line. +""", + "setup": r""" +rm -rf "$WS/q110"; mkdir -p "$WS/q110" +$K -n kube-system delete secret kubeadm-certs --ignore-not-found >/dev/null 2>&1 +date +%s > "$WS/.q110-t0" +""", + "checks": [ + C("endpoint.txt holds the control-plane endpoint", 1, r""" +want=$($K -n kube-system get cm kubeadm-config -o jsonpath='{.data.ClusterConfiguration}' 2>/dev/null | awk '/^controlPlaneEndpoint:/{print $2}' | tr -d '"') +[ -n "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q110/endpoint.txt" 2>/dev/null)" "$want" """), + C("the control-plane certificates were uploaded (Secret kubeadm-certs)", 2, r""" +ct=$(j -n kube-system secret kubeadm-certs '{.metadata.creationTimestamp}'); [ -n "$ct" ] || exit 1 +[ "$(date -d "$ct" +%s)" -ge "$(( $(cat "$WS/.q110-t0" 2>/dev/null || echo 9999999999) - 5 ))" ]"""), + C("join command for a control-plane node: endpoint, CA hash, --control-plane, a certificate key", 2, r""" +f="$WS/q110/join-control-plane.sh" +ep=$($K -n kube-system get cm kubeadm-config -o jsonpath='{.data.ClusterConfiguration}' 2>/dev/null | awk '/^controlPlaneEndpoint:/{print $2}' | tr -d '"') +hash=$(nx "$CP" "openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex | sed 's/^.* //'" /dev/null | head -1) +echo "$l" | grep -q "kubeadm join $ep " && echo "$l" | grep -q -- "--discovery-token-ca-cert-hash sha256:$hash" \ + && echo "$l" | grep -Eq -- '--control-plane( |$)' && echo "$l" | grep -Eq -- '--certificate-key [0-9a-f]{64}( |$)' """), + C("its token is a real, unexpired bootstrap token", 2, r""" +tok=$(grep -oE -- '--token [a-z0-9]{6}\.[a-z0-9]{16}' "$WS/q110/join-control-plane.sh" 2>/dev/null | head -1 | awk '{print $2}'); [ -n "$tok" ] || exit 1 +exp=$(j -n kube-system secret bootstrap-token-${tok%%.*} '{.data.expiration}' | base64 -d 2>/dev/null); [ -n "$exp" ] || exit 1 +[ "$(date -d "$exp" +%s)" -gt "$(date +%s)" ]"""), + ], + "solution": """ +k -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint # {CP}:6443 +echo {CP}:6443 > {WS}/q110/endpoint.txt +{SHELL_CP} +kubeadm init phase upload-certs --upload-certs +# [upload-certs] Storing the certificates in Secret "kubeadm-certs" in the "kube-system" Namespace +# [upload-certs] Using certificate key: +# <64 hex characters> +kubeadm token create --print-join-command --certificate-key +# kubeadm join {CP}:6443 --token ... --discovery-token-ca-cert-hash sha256:... --control-plane --certificate-key ... +exit +# paste that line into {WS}/q110/join-control-plane.sh +# A control-plane node needs the cluster's CA, front-proxy CA, etcd CA and service-account keys. +# upload-certs puts them, encrypted with the certificate key, into the Secret kubeadm-certs for +# two hours; `kubeadm join --control-plane --certificate-key` downloads and decrypts them. All of +# it only works because the cluster was initialised with a controlPlaneEndpoint - the one stable +# address (a load balancer or VIP in a real HA cluster) that every node and kubeconfig points at. +""", +}, +{ + "id": 111, "domain": "ARCH", "title": "Who leads, and who is in etcd", + "task": """ +Before a second control-plane node is added, record how the control plane is laid out today. +Write `{WS}/q111/ha.txt` in exactly this format: + +``` +control-plane-nodes= +etcd-topology= +etcd-members= +etcd-leader= +scheduler-leader= +controller-manager-leader= +``` + +`stacked` means etcd runs on the control-plane nodes themselves, `external` that the API server +uses etcd hosts of their own. `etcdctl` is on the control-plane node (`{SHELL_CP}`); etcd's +certificates are under `/etc/kubernetes/pki/etcd`. +""", + "setup": r""" +rm -rf "$WS/q111"; mkdir -p "$WS/q111" +ensure_etcd_tools >/dev/null 2>&1 || { echo "etcdctl could not be installed on $CP - run ./etcd-tools.sh"; exit 1; } +""", + "checks": [ + C("control-plane-nodes and etcd-topology", 2, r""" +n=$($K get nodes -l node-role.kubernetes.io/control-plane --no-headers 2>/dev/null | grep -c .) +topo=external; [ "$($K -n kube-system get pods -l component=etcd,tier=control-plane --no-headers 2>/dev/null | grep -c .)" -ge 1 ] && topo=stacked +f=$(tr -d ' \t\r' < "$WS/q111/ha.txt" 2>/dev/null) +[ "$n" -ge 1 ] && echo "$f" | grep -qx "control-plane-nodes=$n" && echo "$f" | grep -qx "etcd-topology=$topo" """), + C("etcd-members and etcd-leader", 3, r""" +E='""" + ETCDCTL + r"""' +out=$( { nx "$CP" "$E endpoint status -w json 2>/dev/null" /dev/null" /dev/null) +[ -n "$out" ] || exit 1 +f=$(tr -d ' \t\r' < "$WS/q111/ha.txt" 2>/dev/null) +echo "$f" | grep -qx "etcd-members=${out%% *}" && echo "$f" | grep -qx "etcd-leader=${out##* }" """), + C("scheduler-leader and controller-manager-leader", 2, r""" +s=$(j -n kube-system lease kube-scheduler '{.spec.holderIdentity}' | cut -d_ -f1) +c=$(j -n kube-system lease kube-controller-manager '{.spec.holderIdentity}' | cut -d_ -f1) +f=$(tr -d ' \t\r' < "$WS/q111/ha.txt" 2>/dev/null) +[ -n "$s" ] && [ -n "$c" ] && echo "$f" | grep -qx "scheduler-leader=$s" && echo "$f" | grep -qx "controller-manager-leader=$c" """), + ], + "solution": """ +k get nodes -l node-role.kubernetes.io/control-plane # how many +k -n kube-system get pods -l component=etcd -o wide # an etcd static Pod on the control-plane node = stacked +k -n kube-system get lease kube-scheduler kube-controller-manager # HOLDER is _ +{SHELL_CP} +export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379 ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt \\ + ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key +etcdctl member list -w table # one line per member: ID, NAME, peer and client URLs +etcdctl endpoint status -w table # IS LEADER (add --cluster to ask every member) +exit +cat > {WS}/q111/ha.txt < +cni-plugins= +cni-ipam= +cri-socket= +csi-drivers= +default-provisioner= +``` + +Reach the node with `{SHELL_W1}`. +""", + "setup": r""" +rm -rf "$WS/q112"; mkdir -p "$WS/q112" +""", + "checks": [ + C("cni-config, cni-plugins and cni-ipam", 3, r""" +conf=$(nx "$W1" "ls /etc/cni/net.d/*.conflist /etc/cni/net.d/*.conf 2>/dev/null | sort | head -1" /dev/null) +[ -n "$out" ] || exit 1 +f=$(tr -d ' \t\r' < "$WS/q112/interfaces.txt" 2>/dev/null) +echo "$f" | grep -qx "cni-config=$conf" && echo "$f" | grep -qx "cni-plugins=${out%% *}" && echo "$f" | grep -qx "cni-ipam=${out##* }" """), + C("cri-socket", 2, r""" +sock=$(nx "$W1" "awk '/^containerRuntimeEndpoint:/{print \$2}' /var/lib/kubelet/config.yaml" /dev/null | grep -qx "cri-socket=$sock" """), + C("csi-drivers and default-provisioner", 2, r""" +n=$($K get csidrivers --no-headers 2>/dev/null | grep -c .) +prov=$($K get sc -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.provisioner}{"\n"}{end}' 2>/dev/null | head -1) +[ -n "$prov" ] || exit 1 +f=$(tr -d ' \t\r' < "$WS/q112/interfaces.txt" 2>/dev/null) +echo "$f" | grep -qx "csi-drivers=$n" && echo "$f" | grep -qx "default-provisioner=$prov" """), + ], + "solution": """ +{SHELL_W1} +ls /etc/cni/net.d/ # 10-kindnet.conflist - the kubelet's runtime takes the first file +cat /etc/cni/net.d/10-kindnet.conflist # "plugins": [{"type": "ptp", "ipam": {"type": "host-local"}}, {"type": "portmap"}] +ls /opt/cni/bin # the plugin binaries those types name +grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml +exit +k get csidrivers # No resources found = 0 +k get sc # the (default) class and its PROVISIONER +cat > {WS}/q112/interfaces.txt < `. +2. From Pod `%{client}`, which runs on another node, request `http://` directly and save + the response to `{WS}/q113/direct.html`. +3. Write the Pod CIDR of every node of the cluster to `{WS}/q113/pod-cidrs.txt`, one node per + line as ` `. +4. One Pod in `%{ns}` has an address that lies in none of those ranges. Write its name to + `{WS}/q113/outside.txt` and the Pod spec field that causes it to `{WS}/q113/why.txt`. +""", + "setup": NS + r""" +mkns %{ns}; rm -rf "$WS/q113"; mkdir -p "$WS/q113" +$K -n %{ns} delete pod --all --wait=true --grace-period=1 >/dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: v1 +kind: Pod +metadata: {name: %{web}, namespace: %{ns}, labels: {app: web}} +spec: + nodeSelector: {kubernetes.io/hostname: "{W1}"} + containers: [{name: web, image: "nginx:1.27", ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Pod +metadata: {name: %{client}, namespace: %{ns}, labels: {app: client}} +spec: + terminationGracePeriodSeconds: 1 + nodeSelector: {kubernetes.io/hostname: "{W2}"} + containers: [{name: client, image: "busybox:1.36", command: [sleep, "86400"]}] +--- +apiVersion: v1 +kind: Pod +metadata: {name: %{edge}, namespace: %{ns}, labels: {app: edge}} +spec: + terminationGracePeriodSeconds: 1 + hostNetwork: true + nodeSelector: {kubernetes.io/hostname: "{W3}"} + containers: [{name: edge, image: "busybox:1.36", command: [sleep, "86400"]}] +--- +apiVersion: v1 +kind: Pod +metadata: {name: spare-%{edge}, namespace: %{ns}, labels: {app: spare}} +spec: + terminationGracePeriodSeconds: 1 + nodeSelector: {kubernetes.io/hostname: "{W3}"} + containers: [{name: spare, image: "busybox:1.36", command: [sleep, "86400"]}] +EOF +$K -n %{ns} wait --for=condition=Ready pod --all --timeout=150s >/dev/null +""", + "checks": [ + C("web.txt holds the Pod's IP and node", 1, r""" +want=$(j -n %{ns} pod %{web} '{.status.podIP} {.spec.nodeName}') +[ -n "$want" ] && eq "$(tr -s ' \t' ' ' < "$WS/q113/web.txt" 2>/dev/null | tr -d '\r' | sed 's/^ *//; s/ *$//' | grep .)" "$want" """), + C("direct.html holds the page fetched from the Pod's IP", 2, r"""grep -q 'Welcome to nginx' "$WS/q113/direct.html" 2>/dev/null"""), + C("pod-cidrs.txt lists every node with its Pod CIDR", 2, r""" +want=$($K get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.spec.podCIDR}{"\n"}{end}' 2>/dev/null | sort) +[ "$(echo "$want" | grep -c .)" -ge 2 ] || exit 1 +eq "$(tr -s ' \t' ' ' < "$WS/q113/pod-cidrs.txt" 2>/dev/null | tr -d '\r' | sed 's/^ *//; s/ *$//' | grep . | sort)" "$want" """), + C("outside.txt names %{edge}, why.txt names hostNetwork", 2, r""" +eq "$(tr -d '[:space:]' < "$WS/q113/outside.txt" 2>/dev/null | sed 's#^pod/##')" %{edge} && grep -qi 'hostNetwork' "$WS/q113/why.txt" 2>/dev/null"""), + ], + "solution": """ +k -n %{ns} get pods -o wide # IP and NODE of every Pod +k -n %{ns} get pod %{web} -o jsonpath='{.status.podIP} {.spec.nodeName}' > {WS}/q113/web.txt +k -n %{ns} exec %{client} -- wget -qO- -T3 http://$(cut -d' ' -f1 {WS}/q113/web.txt) > {WS}/q113/direct.html +k get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.spec.podCIDR}{"\\n"}{end}' > {WS}/q113/pod-cidrs.txt +# %{edge}'s IP is its node's own address (compare with `k get nodes -o wide`), not one from 10.244.x.0/24 +echo %{edge} > {WS}/q113/outside.txt +k -n %{ns} get pod %{edge} -o yaml | grep hostNetwork +echo hostNetwork > {WS}/q113/why.txt +# Every Pod gets its own address from its node's Pod CIDR, and any Pod reaches any other Pod on +# any node at that address without NAT - that is the whole network model, and the CNI plugin is +# what makes it true. A hostNetwork Pod opts out: it lives in the node's network namespace and +# shares its address and ports. Pod addresses change with every new Pod, which is why Services exist. +""", +}, +] diff --git a/tracks/cka/curriculum.py b/tracks/cka/curriculum.py new file mode 100644 index 0000000..cdd7914 --- /dev/null +++ b/tracks/cka/curriculum.py @@ -0,0 +1,840 @@ +"""CKA curriculum as a study path: the 27 objectives of the five domains, in the curriculum's own +words and order (CKA_Curriculum_v1.35.pdf, cncf/curriculum), each with what to know, the fastest +commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it. + +./exam objectives the path, with your progress per objective +./exam notes 4.4 the notes for one objective (id or key: 4.4, lifecycle) +./exam study 4.4 those notes, then its tasks with no clock and solutions visible +./exam drill 4.4 the same tasks against the clock +./exam next whatever is next on the path + +"tasks" lists every task that really exercises the objective - the plain pool tasks first, then +the hard ones, then those of the fixed simulator sets (68-85 Set C, 86-102 Set D), which stay out +of objective sessions unless you ask for them (--all). A task may sit under several objectives. +Every task must be listed at least once and every objective needs two pool tasks - +`./exam objectives` says so when that stops being true (tasks 103-113 exist because of that rule). + +An objective with many tasks takes several sittings: each one brings up to five tasks you have not +solved yet, and never two that break the same part of the cluster. +""" + +# The path walks the domains in this order, not the curriculum's: Troubleshooting is 30% of the +# exam and every one of its tasks assumes you already know how the healthy thing is built. +STUDY_ORDER = ["WORK", "NET", "STOR", "ARCH", "TSHOOT"] + +OBJECTIVES = [ +# ------------------------------------------------------------------- 1. Storage (10%) ---- +{ + "id": "1.1", "key": "storageclasses", "domain": "STOR", + "text": "Implement storage classes and dynamic volume provisioning", + "tasks": [16, 36, 59, 99], + "know": [ + "A StorageClass names a provisioner and its settings. A claim that asks for the class gets a PersistentVolume made for it - that is dynamic provisioning.", + "Fields: provisioner, reclaimPolicy (Delete by default, or Retain), volumeBindingMode (Immediate or WaitForFirstConsumer), allowVolumeExpansion, parameters.", + "The default class carries the annotation storageclass.kubernetes.io/is-default-class: \"true\"; a claim with no storageClassName gets it. `storageClassName: \"\"` asks for no class at all.", + "A StatefulSet's volumeClaimTemplates make one claim per replica (data-web-0, data-web-1 ...).", + ], + "fast": """ +k get sc # (default) marks the default class +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: {name: fast-local} +provisioner: rancher.io/local-path +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +--- +k patch sc standard -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' +k patch sc fast-local -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' +k get pvc,pv; k describe pvc data # the Events say why a claim is Pending +""", + "traps": [ + "With WaitForFirstConsumer a claim stays Pending until a Pod uses it. That is not an error.", + "Leave exactly one class marked default: take the annotation off the old one as well as putting it on the new one.", + "provisioner, reclaimPolicy and volumeBindingMode cannot be edited - delete the class and create it again.", + ], + "docs": ["https://kubernetes.io/docs/concepts/storage/storage-classes/", + "https://kubernetes.io/docs/concepts/storage/dynamic-provisioning/", + "https://kubernetes.io/docs/tasks/administer-cluster/change-default-storage-class/"], +}, +{ + "id": "1.2", "key": "volumes", "domain": "STOR", + "text": "Configure volume types, access modes and reclaim policies", + "tasks": [15, 35, 58, 68, 72, 102], + "know": [ + "Volume types you meet: hostPath (a directory of whatever node the Pod lands on), local (a disk of one node, needs nodeAffinity), emptyDir, nfs, csi, and the configMap / secret / projected volumes.", + "Access modes: ReadWriteOnce (one node), ReadOnlyMany, ReadWriteMany, ReadWriteOncePod (one Pod). A claim binds only to a volume that offers its mode.", + "The reclaim policy is on the PersistentVolume: Retain keeps the volume and its data when the claim goes (it becomes Released), Delete removes both.", + ], + "fast": """ +apiVersion: v1 +kind: PersistentVolume +metadata: {name: pv-data} +spec: + capacity: {storage: 1Gi} + accessModes: [ReadWriteOnce] + persistentVolumeReclaimPolicy: Retain + storageClassName: manual + hostPath: {path: /mnt/data} +--- +k patch pv pv-data -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' +k patch pv pv-data --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]' # Released -> Available +k get pv -o custom-columns=NAME:.metadata.name,MODES:.spec.accessModes,RECLAIM:.spec.persistentVolumeReclaimPolicy,STATUS:.status.phase,CLAIM:.spec.claimRef.name +""", + "traps": [ + "A Released volume never binds again by itself: remove its claimRef first.", + "A `local` volume needs spec.nodeAffinity; a hostPath volume does not pin the Pod to the node that has the data.", + "The local-path provisioner of this cluster only does ReadWriteOnce and ReadWriteOncePod - a ReadWriteMany claim stays Pending.", + ], + "docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/", + "https://kubernetes.io/docs/concepts/storage/volumes/", + "https://kubernetes.io/docs/tasks/administer-cluster/change-pv-reclaim-policy/"], +}, +{ + "id": "1.3", "key": "claims", "domain": "STOR", + "text": "Manage persistent volumes and persistent volume claims", + "tasks": [15, 36, 35, 58, 59, 68, 72, 81, 99, 102], + "know": [ + "A claim binds to one volume, and only if class, access mode and size fit (the volume may be larger); `volumeName` or a selector narrows it to one volume.", + "Phases: a volume goes Available, Bound, Released; a claim Pending, Bound.", + "A Pod mounts a claim of its own Namespace: volumes[].persistentVolumeClaim.claimName, then volumeMounts.", + "A claim in use is protected: deleting it waits (Terminating) until no Pod references it, finished Pods included.", + ], + "fast": """ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: {name: pvc-data, namespace: storage} +spec: + accessModes: [ReadWriteOnce] + storageClassName: manual # omit for the default class + resources: {requests: {storage: 500Mi}} +--- # in the Pod: +volumes: [{name: data, persistentVolumeClaim: {claimName: pvc-data}}] +containers: [{..., volumeMounts: [{name: data, mountPath: /data}]}] +k get pvc,pv -A; k describe pvc pvc-data # Used By: and Events: +k describe pod POD | tail # unbound claim -> the Pod stays Pending +""", + "traps": [ + "There is no `kubectl create pvc` or `create pv` - copy both from the docs.", + "For a hand-made volume the claim's storageClassName has to equal the volume's exactly; leaving it out means the default class, not none.", + "Apart from growing the request (and only if the class allows expansion) a claim cannot be edited.", + "To move data between claims, run a Pod or Job that mounts both.", + ], + "docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/", + "https://kubernetes.io/docs/tutorials/configuration/configure-persistent-volume-storage/"], +}, +# ------------------------------------------------------------ 2. Troubleshooting (30%) ---- +{ + "id": "2.1", "key": "nodes", "domain": "TSHOOT", + "text": "Troubleshoot clusters and nodes", + "tasks": [17, 39, 60, 92, 90, 95], + "know": [ + "Start from the cluster: `k get nodes`, then `k describe node` - Conditions, Taints, Unschedulable, Events.", + "A NotReady node is nearly always its kubelet: on the node, `systemctl status kubelet` and `journalctl -u kubelet` name the cause.", + "What the kubelet needs: its unit and drop-in (which binary, which flags), /var/lib/kubelet/config.yaml, /etc/kubernetes/kubelet.conf (its kubeconfig), its certificates in /var/lib/kubelet/pki, and a running container runtime.", + "Ready but SchedulingDisabled is a cordon, not a fault: `k uncordon`.", + ], + "fast": """ +k get nodes -o wide; k describe node NODE | sed -n '/Conditions/,/Addresses/p' +ssh NODE +systemctl status kubelet; journalctl -u kubelet --no-pager | tail -30 +systemctl cat kubelet # the unit and its drop-ins: ExecStart, EnvironmentFile +cat /var/lib/kubelet/config.yaml; cat /var/lib/kubelet/kubeadm-flags.env +systemctl daemon-reload; systemctl enable --now kubelet; systemctl restart kubelet +systemctl status containerd; crictl ps -a +k uncordon NODE; k describe node NODE | grep -i taint +""", + "traps": [ + "A kubelet that was stopped is often disabled too: `enable --now`, or it is gone again after a reboot.", + "After changing a unit file nothing happens until `systemctl daemon-reload`.", + "There may be more than one fault. After every fix read the journal again instead of assuming you are done.", + ], + "docs": ["https://kubernetes.io/docs/tasks/debug/debug-cluster/", + "https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/"], +}, +{ + "id": "2.2", "key": "components", "domain": "TSHOOT", + "text": "Troubleshoot cluster components", + "tasks": [20, 38, 61, 66, 97, 86], + "know": [ + "kube-apiserver, kube-scheduler, kube-controller-manager and etcd run as static Pods: the kubelet starts whatever is in /etc/kubernetes/manifests and restarts it when the file changes.", + "Read the symptom: Pods stay Pending with no events - scheduler. Deployments make no ReplicaSet or Pods - controller-manager. kubectl is refused - API server (or etcd behind it).", + "With the API server up: `k -n kube-system get pods`, `logs`, `describe`. With it down: `crictl ps -a`, `crictl logs`, /var/log/pods and the kubelet's journal on the node.", + ], + "fast": """ +k -n kube-system get pods; k -n kube-system logs kube-scheduler-NODE; k -n kube-system describe pod kube-scheduler-NODE +ssh CONTROL-PLANE +ls /etc/kubernetes/manifests; vi /etc/kubernetes/manifests/kube-scheduler.yaml +crictl ps -a | grep -E 'kube-|etcd'; crictl logs CONTAINER-ID +ls /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver/ # its logs when kubectl cannot ask for them +journalctl -u kubelet --no-pager | grep -i -E 'manifest|apiserver' | tail +""", + "traps": [ + "The faults are small: a misspelt image, binary or flag, a wrong path in a volume or certificate flag, a wrong port in a probe.", + "Do not leave a backup copy inside the manifests directory - the kubelet runs every file there.", + "After saving a manifest give the kubelet up to a minute before deciding the fix did not work.", + ], + "docs": ["https://kubernetes.io/docs/tasks/debug/debug-cluster/", + "https://kubernetes.io/docs/concepts/overview/components/", + "https://kubernetes.io/docs/tasks/configure-pod-container/static-pod/"], +}, +{ + "id": "2.3", "key": "usage", "domain": "TSHOOT", + "text": "Monitor cluster and application resource usage", + "tasks": [103, 23, 53, 85, 89], + "know": [ + "`kubectl top node|pod` shows live CPU and memory from metrics-server through the metrics API (metrics.k8s.io); `--containers` splits a Pod up, `--sort-by=cpu|memory` orders it.", + "`k describe node` shows what is allocated - the sum of requests and limits - which is not what is used.", + "`kubectl get` sorts and shapes everything else: --sort-by, -o custom-columns, -o jsonpath, -A.", + ], + "fast": """ +k top node; k top pod -A --sort-by=memory | head; k top pod -n NS --containers +k describe node NODE | grep -A8 'Allocated resources' +k get pods -A --sort-by='.status.containerStatuses[0].restartCount' +k -n kube-system get deploy metrics-server; k get apiservice v1beta1.metrics.k8s.io +k -n kube-system logs deploy/metrics-server +""", + "traps": [ + "`Metrics API not available` means metrics-server is missing or not Ready (on this cluster it needs --kubelet-insecure-tls).", + "`kubectl top --sort-by` puts the largest first; `kubectl get --sort-by` puts it last.", + "A new Pod has no metrics for about a minute.", + ], + "docs": ["https://kubernetes.io/docs/tasks/debug/debug-cluster/resource-metrics-pipeline/", + "https://kubernetes.io/docs/tasks/debug/debug-cluster/resource-usage-monitoring/", + "https://kubernetes.io/docs/reference/kubectl/quick-reference/"], +}, +{ + "id": "2.4", "key": "logs", "domain": "TSHOOT", + "text": "Manage and evaluate container output streams", + "tasks": [19, 104, 8, 85], + "know": [ + "`kubectl logs` shows what a container wrote to stdout and stderr. The runtime keeps it on the node under /var/log/pods/NAMESPACE_POD_UID/CONTAINER/, and it goes when the Pod is deleted.", + "Pick the container with -c (init containers too) or take --all-containers; --previous is the run before the last restart.", + "Narrow with --tail=N, --since=10m, --timestamps; follow with -f; take every Pod of a label with -l and tell them apart with --prefix.", + "On a node, without the API server: `crictl logs CONTAINER-ID`.", + ], + "fast": """ +k logs POD -c CONTAINER # --all-containers --prefix +k logs POD --previous # the crashed run +k logs POD --tail=50 --since=10m --timestamps +k logs -l app=web --prefix --tail=5 # every Pod with the label +k logs deploy/web; k logs job/render # one Pod of it +k logs POD | grep ERROR > errors.log +crictl ps -a; crictl logs --tail=20 CONTAINER-ID; ls /var/log/pods/ +""", + "traps": [ + "With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything).", + "`kubectl logs deploy/x` reads one Pod, not all replicas.", + "--previous fails when the container has never restarted.", + ], + "docs": ["https://kubernetes.io/docs/concepts/cluster-administration/logging/", + "https://kubernetes.io/docs/reference/kubectl/quick-reference/", + "https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/"], +}, +{ + "id": "2.5", "key": "netdebug", "domain": "TSHOOT", + "text": "Troubleshoot services and networking", + "tasks": [18, 62, 37, 57, 73, 101, 70, 90], + "know": [ + "Work outwards from the Pod: is it Ready, does the Service have endpoints (selector against Pod labels), is targetPort the port the container listens on, does the name resolve, does a NetworkPolicy stand in the way.", + "DNS is a Service like any other: the CoreDNS Pods, the kube-dns Service and its endpoints, the Corefile in ConfigMap coredns.", + "Underneath: kube-proxy (a DaemonSet) turns Services into rules on every node, and the CNI plugin connects the Pods.", + ], + "fast": """ +k get svc,endpointslices -o wide; k describe svc SVC # Endpoints: = nothing selected +k get pods -l KEY=VALUE --show-labels -o wide +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- sh # wget -qO- -T3 http://SVC:PORT ; nslookup SVC.NS.svc.cluster.local +k -n kube-system get pods -l k8s-app=kube-dns -o wide; k -n kube-system logs -l k8s-app=kube-dns +k -n kube-system get cm coredns -o yaml; k -n kube-system get svc,endpointslices -l k8s-app=kube-dns +k -n kube-system get ds kube-proxy; k -n kube-system logs ds/kube-proxy +k get netpol -A +""", + "traps": [ + "A Service with no endpoints still resolves - it is the connection that fails.", + "Test names with the fully qualified form (SVC.NS.svc.cluster.local): busybox's nslookup misreports short ones.", + "A NetworkPolicy in kube-system can cut DNS off for the whole cluster.", + "Fix what is broken and leave the rest alone - the graders check what you did not have to touch.", + ], + "docs": ["https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/", + "https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/"], +}, +# ------------------------------------------------- 3. Workloads & Scheduling (15%) ---- +{ + "id": "3.1", "key": "rollouts", "domain": "WORK", + "text": "Understand application deployments and how to perform rolling update and rollbacks", + "tasks": [7, 51], + "know": [ + "Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default).", + "Every rollout is a revision. `kubectl rollout`: status, history, undo, pause, resume, restart.", + "minReadySeconds, revisionHistoryLimit and progressDeadlineSeconds tune how careful and how patient a rollout is.", + ], + "fast": """ +k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE +k scale deploy/web --replicas=4 +k rollout status deploy/web +k rollout history deploy/web; k rollout history deploy/web --revision=2 # that revision's template +k rollout undo deploy/web --to-revision=2 +k rollout pause deploy/web; k rollout resume deploy/web +k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}' +""", + "traps": [ + "To go back to a particular image, look at each revision's template first - \"the previous one\" is not always the one asked for.", + "An undo makes a new revision out of the old one, so the numbers keep climbing.", + "A paused Deployment records changes but rolls nothing out until it is resumed.", + "maxSurge and maxUnavailable cannot both be 0.", + ], + "docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"], +}, +{ + "id": "3.2", "key": "config", "domain": "WORK", + "text": "Use ConfigMaps and Secrets to configure applications", + "tasks": [10, 29, 79, 93, 84], + "know": [ + "Both hold keys. A key becomes an environment variable (configMapKeyRef / secretKeyRef, or every key at once with envFrom) or a file (a configMap / secret volume; `items` picks and renames keys).", + "Secret data is base64 - an encoding, not encryption. Types: Opaque, kubernetes.io/tls, kubernetes.io/dockerconfigjson.", + "Mounted files follow a change after a short delay. Environment variables and subPath mounts never do: restart the Pods.", + "`immutable: true` freezes one for good. To change the content, make a new object and point the workload at it.", + ], + "fast": """ +k create cm app-config --from-literal=APP_MODE=production --from-file=app.properties +k create secret generic db-creds --from-literal=username=admin --from-literal=password='S3cr3t!' +k create secret tls web-tls --cert=tls.crt --key=tls.key +k get secret db-creds -o jsonpath='{.data.password}' | base64 -d +env: + - {name: APP_MODE, valueFrom: {configMapKeyRef: {name: app-config, key: APP_MODE}}} + - {name: DB_PASS, valueFrom: {secretKeyRef: {name: db-creds, key: password}}} +envFrom: [{configMapRef: {name: app-config}}, {secretRef: {name: db-creds}}] +volumes: [{name: creds, secret: {secretName: db-creds, defaultMode: 0400}}] +k set env deploy/web --from=configmap/app-config; k rollout restart deploy/web +""", + "traps": [ + "A secret volume names it with `secretName`; a configMap volume uses `name`.", + "A Pod that references a missing ConfigMap, Secret or key waits in CreateContainerConfigError.", + "`--from-file` makes one key named after the file; `--from-env-file` makes one key per line.", + ], + "docs": ["https://kubernetes.io/docs/concepts/configuration/configmap/", + "https://kubernetes.io/docs/concepts/configuration/secret/", + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/"], +}, +{ + "id": "3.3", "key": "autoscaling", "domain": "WORK", + "text": "Configure workload autoscaling", + "tasks": [105, 23, 82], + "know": [ + "A HorizontalPodAutoscaler (autoscaling/v2) keeps a workload between minReplicas and maxReplicas by a metric - usually average CPU or memory utilisation, which is usage divided by the container's request.", + "It reads the metrics API, so it needs metrics-server, and the containers need a request for the resource it scales on.", + "`behavior` shapes the change: scaleUp and scaleDown each take a stabilizationWindowSeconds and policies (so many Pods or Percent per periodSeconds).", + ], + "fast": """ +k set resources deploy web --requests=cpu=100m +k autoscale deploy web --min=2 --max=6 --cpu=60% # kubectl before 1.34: --cpu-percent=60 +k get hpa; k describe hpa web +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: {name: web} +spec: + scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: web} + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: {name: cpu, target: {type: Utilization, averageUtilization: 60}} + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: [{type: Pods, value: 1, periodSeconds: 60}] +""", + "traps": [ + "TARGETS ``: no metrics-server, or no request on the containers.", + "`kubectl autoscale` cannot write `behavior` - edit the object or write the YAML.", + "minReplicas is enforced at once, even without metrics.", + ], + "docs": ["https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/", + "https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale-walkthrough/", + "https://kubernetes.io/docs/concepts/workloads/autoscaling/"], +}, +{ + "id": "3.4", "key": "selfhealing", "domain": "WORK", + "text": "Understand the primitives used to create robust, self-healing, application deployments", + "tasks": [106, 8, 28, 26, 27, 31, 52, 53, 76, 88, 70], + "know": [ + "Controllers put back what is missing: a Deployment its replicas, a DaemonSet one Pod per node, a StatefulSet named replicas with their own storage, a Job its completions (backoffLimit retries), a CronJob its Jobs.", + "Probes tell the kubelet about health: liveness restarts the container, readiness takes the Pod out of Service endpoints, startup holds both off until the app is up.", + "Init containers run to completion, in order, before the app starts; a native sidecar (an init container with restartPolicy: Always) keeps running beside it.", + "A PodDisruptionBudget (minAvailable or maxUnavailable) limits how many Pods a drain may take away at once.", + ], + "fast": """ +containers: + - name: app + startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12} + readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5} + livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10} + # exec: {exec: {command: [cat, /tmp/ready]}} +k create job render --image=busybox:1.36 -- sh -c 'echo done' +k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' -- date; k create job run-1 --from=cronjob/digest +k create pdb web --selector=app=web --min-available=2 +k create deploy x --image=busybox:1.36 $do > ds.yaml # DaemonSet: change kind, drop replicas / strategy / status +k describe pod POD | grep -E 'Liveness|Readiness|Startup'; k get events --field-selector reason=Unhealthy +""", + "traps": [ + "Probes belong to a container. No kubectl flag adds one - edit the YAML.", + "Restarts with Unhealthy and Killing events but a clean log: the liveness probe is wrong, not the app.", + "A DaemonSet reaches control-plane nodes only with a toleration for node-role.kubernetes.io/control-plane.", + "A Job's Pod template cannot be edited: delete the Job and create it again.", + ], + "docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/", + "https://kubernetes.io/docs/concepts/workloads/controllers/deployment/", + "https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/", + "https://kubernetes.io/docs/tasks/run-application/configure-pdb/"], +}, +{ + "id": "3.5", "key": "scheduling", "domain": "WORK", + "text": "Configure Pod admission and scheduling (limits, node affinity, etc.)", + "tasks": [9, 25, 24, 32, 50, 78, 96, 81, 97], + "know": [ + "Requests are what the scheduler reserves, limits what the node enforces. A LimitRange gives containers defaults and bounds; a ResourceQuota caps a Namespace's totals - both act at admission.", + "Where a Pod may go: nodeSelector, node affinity (required or preferred), Pod affinity and anti-affinity with a topologyKey, topologySpreadConstraints, or nodeName, which skips the scheduler.", + "Taints keep Pods off a node (NoSchedule, PreferNoSchedule, NoExecute); a toleration lets a Pod through.", + "A PriorityClass orders Pods when room is short; the Pod names it in priorityClassName.", + ], + "fast": """ +k label node NODE disktype=ssd +k taint node NODE dedicated=gpu:NoSchedule; k taint node NODE dedicated- # the trailing - removes it +tolerations: [{key: dedicated, operator: Equal, value: gpu, effect: NoSchedule}] +affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: [{matchExpressions: [{key: disktype, operator: In, values: [ssd]}]}] + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - {labelSelector: {matchLabels: {app: web}}, topologyKey: kubernetes.io/hostname} +k create priorityclass high --value=100000 --description='critical workloads' +k create quota team -n NS --hard=pods=5,requests.cpu=1,limits.memory=2Gi +k describe pod POD | tail # FailedScheduling: 0/4 nodes are available: ... +""", + "traps": [ + "A toleration lets a Pod onto a tainted node but does not send it there - pair it with a nodeSelector or affinity.", + "Once a quota covers CPU or memory, every new Pod must state them or get them from a LimitRange.", + "In a LimitRange, `default` is the limit and `defaultRequest` the request.", + "Scheduling fields of a running Pod cannot be changed: change the Deployment, or delete and recreate the Pod.", + ], + "docs": ["https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/", + "https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/", + "https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/", + "https://kubernetes.io/docs/concepts/policy/resource-quotas/"], +}, +# ------------------ 4. Cluster Architecture, Installation & Configuration (25%) ---- +{ + "id": "4.1", "key": "rbac", "domain": "ARCH", + "text": "Manage role based access control (RBAC)", + "tasks": [1, 33, 6, 44, 47, 75, 91], + "know": [ + "A Role (one Namespace) or ClusterRole (everywhere, and cluster-scoped resources) lists verbs on resources; a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts.", + "A RoleBinding may point at a ClusterRole: the rules then apply in that one Namespace only.", + "A user is a certificate: CN is the name, O the groups. Create a key and CSR, submit a CertificateSigningRequest, approve it, put the certificate in a kubeconfig.", + "An aggregated ClusterRole has no rules of its own: the controller fills them in from every ClusterRole carrying the label it selects.", + ], + "fast": """ +k create role deploy-manager -n NS --verb=get,list,watch,create,update,patch,delete --resource=deployments.apps +k create rolebinding deploy-bot -n NS --role=deploy-manager --serviceaccount=NS:deploy-bot # --user=jane --group=devs +k create clusterrole node-reader --verb=get,list,watch --resource=nodes +k create clusterrolebinding node-reader --clusterrole=node-reader --serviceaccount=NS:SA +k auth can-i create deployments -n NS --as=system:serviceaccount:NS:deploy-bot; k auth can-i --list --as=jane -n NS +openssl genrsa -out jane.key 2048; openssl req -new -key jane.key -subj '/CN=jane/O=developers' -out jane.csr +# CertificateSigningRequest: spec.request = base64 -w0 of the .csr, signerName kubernetes.io/kube-apiserver-client, usages [client auth] +k certificate approve jane; k get csr jane -o jsonpath='{.status.certificate}' | base64 -d > jane.crt +k config set-credentials jane --client-certificate=jane.crt --client-key=jane.key --embed-certs +""", + "traps": [ + "\"Exactly these permissions\" also means finding what is already granted: `k get rolebindings,clusterrolebindings -A -o wide | grep NAME`.", + "Deployments are in the group apps: --resource=deployments.apps; a subresource is deployments.apps/scale, pods/log.", + "The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`.", + "Nodes, PersistentVolumes and Namespaces are cluster-scoped: they need a ClusterRole and a ClusterRoleBinding.", + ], + "docs": ["https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/"], +}, +{ + "id": "4.2", "key": "infra", "domain": "ARCH", + "text": "Prepare underlying infrastructure for installing a Kubernetes cluster", + "tasks": [107, 108, 41], + "know": [ + "Every machine needs, before kubeadm: a container runtime (containerd) using the same cgroup driver as the kubelet (systemd), the kernel modules overlay and br_netfilter, the sysctls net.bridge.bridge-nf-call-iptables and net.ipv4.ip_forward, and swap off or accounted for.", + "kubeadm, kubelet and kubectl come from the package repository of one minor version (pkgs.k8s.io) and are held, so that a system update does not move them.", + "Also: a unique hostname, MAC address and product_uuid per node, and the ports open - 6443 (API), 10250 (kubelet), 2379-2380 (etcd).", + ], + "fast": """ +printf 'overlay\\nbr_netfilter\\n' > /etc/modules-load.d/k8s.conf; modprobe overlay; modprobe br_netfilter +cat > /etc/sysctl.d/k8s.conf </dev/null | openssl dgst -sha256 -hex # the CA hash +kubeadm certs check-expiration; openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -enddate +k -n kube-system get cm kubeadm-config -o yaml +grep staticPodPath /var/lib/kubelet/config.yaml; k run web --image=nginx:1.27 $do > /etc/kubernetes/manifests/web.yaml +""", + "traps": [ + "Tokens expire (24 hours unless you say otherwise) - make a new one rather than hunting for the old.", + "Check staticPodPath before you write a static Pod: it is not always /etc/kubernetes/manifests.", + "Deleting a static Pod with kubectl only deletes its mirror; the kubelet puts it straight back.", + ], + "docs": ["https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/", + "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-token/", + "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-join/", + "https://kubernetes.io/docs/tasks/configure-pod-container/static-pod/"], +}, +{ + "id": "4.4", "key": "lifecycle", "domain": "ARCH", + "text": "Manage the lifecycle of Kubernetes clusters", + "tasks": [109, 2, 65, 45, 80], + "know": [ + "Upgrade one minor version at a time, control plane first: the kubeadm package, `kubeadm upgrade plan` and `apply`, drain, the kubelet and kubectl packages, restart the kubelet, uncordon. Workers: `kubeadm upgrade node` in place of `apply`.", + "Maintenance on a node: `kubectl drain` cordons it and evicts its Pods; `kubectl uncordon` gives it back.", + "etcd: `etcdctl snapshot save` backs it up; `etcdutl snapshot restore --data-dir` unpacks a snapshot into a new directory that etcd's manifest must then point at.", + "`kubeadm certs renew all` renews the control-plane certificates; the static Pods have to be restarted to load them.", + ], + "fast": """ +k drain NODE --ignore-daemonsets --delete-emptydir-data; k uncordon NODE +apt-get install -y kubeadm='1.36.*'; kubeadm upgrade plan; kubeadm upgrade apply v1.36.x # workers: kubeadm upgrade node +apt-get install -y kubelet='1.36.*' kubectl='1.36.*'; systemctl daemon-reload; systemctl restart kubelet +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 snapshot save /opt/backup.db +etcdutl snapshot restore /opt/backup.db --data-dir /var/lib/etcd-restore # then etcd.yaml: the hostPath of volume etcd-data +kubeadm certs renew all; kubeadm certs check-expiration +""", + "traps": [ + "drain stops at DaemonSet Pods (--ignore-daemonsets), at emptyDir data (--delete-emptydir-data) and at bare Pods (--force, which deletes them for good).", + "A drained node stays cordoned until you uncordon it.", + "With etcd 3.6, as here, restore exists only in etcdutl - `etcdctl snapshot restore` is gone.", + "Restoring etcd takes the whole cluster back to the snapshot: everything created since is lost.", + ], + "docs": ["https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/", + "https://kubernetes.io/docs/tasks/administer-cluster/safely-drain-node/", + "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/", + "https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-certs/"], +}, +{ + "id": "4.5", "key": "ha", "domain": "ARCH", + "text": "Implement and configure a highly-available control plane", + "tasks": [110, 111, 86, 87], + "know": [ + "Highly available means several control-plane nodes (three as a rule) behind one stable address - the controlPlaneEndpoint, a load balancer or virtual IP, fixed at `kubeadm init`.", + "etcd is either stacked (a member on every control-plane node) or external (hosts of its own). It needs a majority to accept writes: 3 members survive the loss of 1, 5 of 2.", + "More control-plane nodes join with `kubeadm join --control-plane --certificate-key KEY`; the key comes from `kubeadm init phase upload-certs --upload-certs` and is good for two hours.", + "Every kube-apiserver serves at once. Of the schedulers and controller-managers only the leader acts - they elect it through a Lease in kube-system.", + ], + "fast": """ +k get nodes -l node-role.kubernetes.io/control-plane +k -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint +kubeadm init --control-plane-endpoint=LB:6443 --upload-certs +kubeadm init phase upload-certs --upload-certs # prints the certificate key +kubeadm token create --print-join-command --certificate-key KEY # ... --control-plane --certificate-key KEY +k -n kube-system get lease kube-scheduler kube-controller-manager # HOLDER = NODE_uuid +etcdctl member list -w table; etcdctl endpoint status -w table; etcdctl endpoint health # with the four etcd flags +k -n kube-system get pods -l component=etcd -o wide +""", + "traps": [ + "A cluster initialised without a controlPlaneEndpoint cannot simply be given a second control-plane node.", + "An even number of etcd members tolerates no more failures than the odd number below it.", + "The certificate key expires after two hours - upload the certificates again rather than reusing an old key.", + ], + "docs": ["https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/high-availability/", + "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/ha-topology/"], +}, +{ + "id": "4.6", "key": "helmkustomize", "domain": "ARCH", + "text": "Use Helm and Kustomize to install cluster components", + "tasks": [21, 3, 48, 69, 98], + "know": [ + "Helm: a chart is the package, a release one installation of it in a Namespace, and each install, upgrade or rollback a revision. Values come from the chart, then -f files, then --set.", + "`helm template` renders a chart without installing it; `helm show values` prints its defaults; `helm get values` what a release was given.", + "Kustomize: a kustomization.yaml lists resources and changes them - namespace, namePrefix, labels, images, replicas, patches - and generates ConfigMaps and Secrets. An overlay lists a base and changes only what differs.", + ], + "fast": """ +helm repo add NAME URL && helm repo update; helm search repo NAME --versions +helm install REL CHART -n NS --create-namespace --set replicaCount=2 -f values.yaml # --version X +helm upgrade REL CHART -n NS --reuse-values --set image.tag=1.27 +helm template REL CHART -n NS --set replicaCount=3 > manifest.yaml +helm list -A; helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS +k kustomize DIR | less; k apply -k DIR +# kustomization.yaml: resources: [../../base] namespace: prod namePrefix: prod- images: [{name: nginx, newTag: "1.27"}] +# replicas: [{name: web, count: 3}] patches: [{path: patch.yaml}] configMapGenerator: [{name: cfg, literals: [k=v]}] +""", + "traps": [ + "Releases live in a Namespace: without -n Helm looks in `default`.", + "An upgrade without --reuse-values (or the same -f and --set again) puts every value back to the chart's default.", + "In a kustomization, `images`, `replicas` and patches use the names of the base, before any prefix.", + "`namespace:` in a kustomization does not create the Namespace.", + ], + "docs": ["https://helm.sh/docs/intro/using_helm/", "https://helm.sh/docs/helm/helm_template/", + "https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/"], +}, +{ + "id": "4.7", "key": "interfaces", "domain": "ARCH", + "text": "Understand extension interfaces (CNI, CSI, CRI, etc.)", + "tasks": [112, 108, 41, 63, 77, 87], + "know": [ + "CRI: the kubelet drives the container runtime (containerd, CRI-O) through a socket; `crictl` speaks the same protocol, so it works on a node when kubectl does not.", + "CNI: for every Pod the runtime calls the plugins named in /etc/cni/net.d (binaries in /opt/cni/bin) to give it an address and wire it up. The plugin - kindnet, Calico, Cilium, Flannel - usually runs as a DaemonSet.", + "CSI: a storage driver runs as Pods, registers a CSIDriver object, and a StorageClass names it as provisioner.", + ], + "fast": """ +k get nodes -o wide # CONTAINER-RUNTIME column +crictl version; crictl pods; crictl ps -a; crictl images; crictl inspect CONTAINER-ID +grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml; cat /etc/crictl.yaml +ls /etc/cni/net.d; cat /etc/cni/net.d/*; ls /opt/cni/bin +k -n kube-system get ds # the CNI's DaemonSet beside kube-proxy +k get csidrivers; k get csinodes; k get sc # PROVISIONER +""", + "traps": [ + "Not every CNI plugin enforces NetworkPolicy (Flannel does not; kindnet in this cluster does).", + "Without a CNI plugin the nodes stay NotReady: `cni plugin not initialized`.", + "The runtime takes the first configuration file in /etc/cni/net.d, in alphabetical order.", + ], + "docs": ["https://kubernetes.io/docs/concepts/extend-kubernetes/compute-storage-net/network-plugins/", + "https://kubernetes.io/docs/concepts/containers/cri/", + "https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/"], +}, +{ + "id": "4.8", "key": "crds", "domain": "ARCH", + "text": "Understand CRDs, install and configure operators", + "tasks": [4, 34, 49, 91], + "know": [ + "A CustomResourceDefinition adds a kind to the API: group, names (kind, plural, singular, shortNames), scope (Namespaced or Cluster) and versions, each with an OpenAPI v3 schema.", + "The schema validates custom resources - types, required fields, enum, minimum, maximum, defaults - and feeds `kubectl explain`.", + "An operator is a CRD plus a controller (a Deployment with RBAC) that acts on those objects. Installing one means applying its manifests or its Helm chart, then creating a custom resource.", + ], + "fast": """ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: {name: backups.data.example.com} # PLURAL.GROUP +spec: + group: data.example.com + scope: Namespaced + names: {kind: Backup, plural: backups, singular: backup, shortNames: [bk]} + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [schedule] + properties: + schedule: {type: string} + retention: {type: integer, minimum: 1, default: 7} +k get crd; k api-resources --api-group=data.example.com; k explain backup.spec --recursive +""", + "traps": [ + "The CRD's name must be PLURAL.GROUP, and exactly one version has `storage: true`.", + "A custom resource's apiVersion is GROUP/VERSION of the CRD, not apiextensions.k8s.io/v1.", + "`default` and validation only work inside a structural schema: every level needs its `type`.", + "Deleting a CRD deletes every object of that kind.", + ], + "docs": ["https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/", + "https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/", + "https://kubernetes.io/docs/concepts/extend-kubernetes/operator/"], +}, +# -------------------------------------------------- 5. Services & Networking (20%) ---- +{ + "id": "5.1", "key": "connectivity", "domain": "NET", + "text": "Understand connectivity between Pods", + "tasks": [113, 63, 71, 77, 87], + "know": [ + "Every Pod has its own IP address, and any Pod reaches any other Pod on any node at that address without NAT. The CNI plugin makes that true.", + "Each node hands out Pod addresses from its own range (spec.podCIDR), a slice of the cluster's Pod CIDR; Services get theirs from the separate service CIDR.", + "A hostNetwork Pod lives in the node's network: it has the node's address and shares its ports.", + "Containers of one Pod share a network namespace and talk over localhost.", + ], + "fast": """ +k get pods -o wide -A; k get nodes -o custom-columns=NODE:.metadata.name,PODCIDR:.spec.podCIDR,IP:.status.addresses[0].address +k exec POD -- wget -qO- -T3 http://POD-IP:PORT +k -n kube-system get cm kubeadm-config -o yaml | grep -E 'podSubnet|serviceSubnet' +k -n kube-system get pods -o wide | grep -E 'kube-proxy|kindnet'; k -n kube-system get cm kube-proxy -o yaml | grep mode +""", + "traps": [ + "A Pod's address changes whenever the Pod is replaced - that is what Services are for.", + "Reaching a Pod by IP proves the network; reaching it by Service name also needs endpoints and DNS.", + "If only the Pods of one node are unreachable, look at that node's CNI and kube-proxy Pods.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/", + "https://kubernetes.io/docs/concepts/cluster-administration/networking/"], +}, +{ + "id": "5.2", "key": "netpol", "domain": "NET", + "text": "Define and enforce Network Policies", + "tasks": [11, 42, 54, 83, 100, 77], + "know": [ + "Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through.", + "Policies only allow, and they add up: there is no deny rule and no ordering.", + "Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR.", + "Default deny: `podSelector: {}` with the policyTypes and no rules. An egress lock-down also has to allow DNS (UDP and TCP 53).", + ], + "fast": """ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: backend-policy, namespace: NS} +spec: + podSelector: {matchLabels: {app: backend}} + policyTypes: [Ingress, Egress] + ingress: + - from: [{podSelector: {matchLabels: {app: frontend}}}] + ports: [{protocol: TCP, port: 8080}] + egress: + - to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}}] + ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] +k describe netpol backend-policy -n NS; k exec CLIENT -- wget -qO- -T2 http://backend:8080 +""", + "traps": [ + "There is no `kubectl create networkpolicy` - copy the skeleton from the docs.", + "`namespaceSelector` and `podSelector` in the same item: Pods with that label in those Namespaces. In two items: either.", + "A missing `ingress` denies everything; `ingress: [{}]` allows everything.", + "A Namespace is matched by its labels; kubernetes.io/metadata.name=NAME is always there.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/network-policies/"], +}, +{ + "id": "5.3", "key": "services", "domain": "NET", + "text": "Use ClusterIP, NodePort, LoadBalancer service types and endpoints", + "tasks": [12, 30, 43, 64, 71, 73, 94], + "know": [ + "A Service selects Pods by label and forwards `port` to `targetPort` - a number, or the name of a container port.", + "ClusterIP is reachable inside the cluster; NodePort adds a port (30000-32767) on every node; LoadBalancer adds an external address from the cloud; ExternalName is a DNS alias; headless (clusterIP: None) gives every Pod its own DNS record.", + "The Pods behind a Service are its endpoints, kept in EndpointSlices; only Ready Pods are in them.", + ], + "fast": """ +k expose deploy shop --name=shop-svc --port=80 --target-port=80 --type=NodePort +k create svc nodeport shop-svc --tcp=80:80 --node-port=30080 # then check the selector +k create svc externalname legacy --external-name=db.example.com +k get svc,endpointslices -o wide; k describe svc shop-svc # Endpoints: +k get pods -l app=shop --show-labels +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://shop-svc +""", + "traps": [ + "`kubectl expose` copies the workload's selector; `kubectl create svc` selects app=, which may match nothing.", + "`expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML.", + "A LoadBalancer stays where nothing provides one; it still works as a NodePort.", + "A headless Service for a StatefulSet has to carry the name in the StatefulSet's serviceName.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/service/", + "https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/"], +}, +{ + "id": "5.4", "key": "gateway", "domain": "NET", + "text": "Use the Gateway API to manage Ingress traffic", + "tasks": [22, 56, 74], + "know": [ + "Three kinds, three owners: a GatewayClass names the controller, a Gateway opens listeners (port, protocol, hostname, which routes may attach), an HTTPRoute attaches to a Gateway (parentRefs) and sends requests to Services (backendRefs).", + "An HTTPRoute rule matches on path, header, method or query, and may split traffic between backends by weight - that is a canary.", + "The kinds are CRDs (gateway.networking.k8s.io): they have to be installed, and a controller has to implement them.", + ], + "fast": """ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: web-gw, namespace: NS} +spec: + gatewayClassName: example + listeners: [{name: http, protocol: HTTP, port: 80, hostname: shop.example.com}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: web, namespace: NS} +spec: + parentRefs: [{name: web-gw}] + hostnames: [shop.example.com] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: v1, port: 80, weight: 90}, {name: v2, port: 80, weight: 10}] +k get gatewayclass,gateway,httproute -A; k describe httproute web -n NS +""", + "traps": [ + "A route in another Namespace attaches only if the listener's allowedRoutes lets it.", + "A header match belongs in `matches`, beside or instead of the path - and several rules are tried most specific first.", + "No Gateway controller runs in this simulator, so these tasks are graded on the objects.", + ], + "docs": ["https://gateway-api.sigs.k8s.io/guides/user-guides/http-routing/", + "https://kubernetes.io/docs/concepts/services-networking/gateway/"], +}, +{ + "id": "5.5", "key": "ingress", "domain": "NET", + "text": "Know how to use Ingress controllers and Ingress resources", + "tasks": [13, 55], + "know": [ + "An Ingress routes HTTP(S) by host and path to Services of its own Namespace. It does nothing without an ingress controller, chosen with ingressClassName (an IngressClass names the controller).", + "Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name.", + "TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate.", + ], + "fast": """ +k create ingress app-ingress -n NS --class=nginx \\ + --rule='shop.example.com/api*=api:8080,tls=shop-tls' --rule='shop.example.com/*=web:80,tls=shop-tls' +openssl req -x509 -nodes -newkey rsa:2048 -days 365 -subj '/CN=shop.example.com' -keyout tls.key -out tls.crt +k create secret tls shop-tls -n NS --cert=tls.crt --key=tls.key +k get ingress,ingressclass -A; k describe ingress app-ingress -n NS +""", + "traps": [ + "In --rule a trailing * means pathType Prefix; without it the path is Exact.", + "The backend port is the Service's port, not the container's.", + "No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/ingress/", + "https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/"], +}, +{ + "id": "5.6", "key": "coredns", "domain": "NET", + "text": "Understand and use CoreDNS", + "tasks": [14, 37, 57, 101], + "know": [ + "CoreDNS is a Deployment in kube-system behind the Service kube-dns; its configuration, the Corefile, is in ConfigMap coredns: the `kubernetes` plugin answers for cluster.local, `forward` sends the rest upstream.", + "Names: SVC.NS.svc.cluster.local for a Service, POD.SVC.NS.svc.cluster.local for the Pods of a headless Service.", + "A Pod's /etc/resolv.conf points at the kube-dns address and lists the search domains that make short names work. dnsPolicy decides: ClusterFirst (default), Default (the node's resolver), None with dnsConfig.", + ], + "fast": """ +k -n kube-system get deploy coredns; k -n kube-system get pods -l k8s-app=kube-dns -o wide +k -n kube-system get svc kube-dns; k -n kube-system get endpointslices -l k8s-app=kube-dns +k -n kube-system get cm coredns -o yaml; k -n kube-system edit cm coredns +k -n kube-system logs -l k8s-app=kube-dns; k -n kube-system rollout restart deploy coredns +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- nslookup kubernetes.default.svc.cluster.local +k exec POD -- cat /etc/resolv.conf +""", + "traps": [ + "A typo in the Corefile puts CoreDNS in CrashLoopBackOff - its log names the plugin and the line.", + "DNS can be broken in several places at once: the Corefile, the Deployment (replicas, image), the kube-dns Service (selector, ports), a NetworkPolicy.", + "A Pod with dnsPolicy: Default resolves the internet but not cluster names.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/", + "https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/", + "https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/"], +}, +] diff --git a/tracks/cka/questions.py b/tracks/cka/questions.py index 3ce2ded..cb1bc09 100644 --- a/tracks/cka/questions.py +++ b/tracks/cka/questions.py @@ -639,10 +639,12 @@ $K -n default delete pod sched-probe --wait=false >/dev/null 2>&1 ] # ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 44-67 (hard) in bank_hard, -# ---- 68-85 / 86-102 in the two fixed simulator sets. Numbering is contiguous - keep it that way. -from tracks.cka import bank_extra, bank_hard, bank_sim_c, bank_sim_d +# ---- 68-85 / 86-102 in the two fixed simulator sets, 103-113 (pool tasks for the objectives the +# ---- curriculum map found thin, see curriculum.py) in bank_objectives. Numbering is contiguous - +# ---- keep it that way. +from tracks.cka import bank_extra, bank_hard, bank_objectives, bank_sim_c, bank_sim_d QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS - + bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS) + + bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS + bank_objectives.QUESTIONS) QUESTIONS.sort(key=lambda q: q["id"]) _ids = [q["id"] for q in QUESTIONS] assert len(_ids) == len(set(_ids)), "duplicate question ids" diff --git a/tracks/ckad/bank_more.py b/tracks/ckad/bank_more.py new file mode 100644 index 0000000..8d99a5d --- /dev/null +++ b/tracks/ckad/bank_more.py @@ -0,0 +1,741 @@ +"""CKAD tasks 58-64: a second pool task for every curriculum objective that had only one. + +curriculum.py turns the bank into a study path, one objective at a time, and an objective with a +single task is studied once and never drilled on anything new. Each task here takes its objective +from another side than the first one does: API deprecations (a warning, not a removal), CRDs (a +custom resource the API server rejects), images (ENTRYPOINT, CMD and USER), Kustomize (one that +does not build), probes (checks that are wrong, not missing), kubectl output (custom columns and +selectors), logs (a selector, a prefix and an init container). + +Same format as questions.py; every grader reads the live cluster or the files the task asks for. +""" +from tracks.cka.common import C, NS, ns_pick, name_pick + +QUESTIONS = [ +# ------------------------------------------------------------------- OBS ---- +{ + "id": 58, "domain": "OBS", "title": "Removed, and deprecated but still served", + "params": lambda r: {"ns": ns_pick(r, "migrate"), "app": name_pick(r, "catalog"), "pct": r.choice([10, 25, 50])}, + "task": """ +`{WS}/q58` holds two manifests written for an older cluster. + +1. `deployment.yaml` no longer applies. Correct the file - same name, Namespace, replicas, labels + and container - so that it applies to this cluster, and apply it. +2. `flag.yaml` still applies, but the API server answers with a deprecation warning. Write the + warning to `{WS}/q58/warning.txt`, change the file to the version the warning points to, and + apply it. +3. Write the API version this cluster serves for each of the kinds `CronJob`, + `HorizontalPodAutoscaler`, `EndpointSlice` and `PriorityClass` to `{WS}/q58/served.txt`, one + per line as ` `. +""", + "setup": NS + r""" +mkns %{ns}; rm -rf "$WS/q58"; mkdir -p "$WS/q58" +$K -n %{ns} delete deploy %{app} --ignore-not-found >/dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: {name: featureflags.rollout.example.com} +spec: + group: rollout.example.com + scope: Namespaced + names: {plural: featureflags, singular: featureflag, kind: FeatureFlag, shortNames: [ff]} + versions: + - name: v1beta1 + served: true + storage: false + deprecated: true + deprecationWarning: "rollout.example.com/v1beta1 FeatureFlag is deprecated and will stop being served; use rollout.example.com/v1 FeatureFlag" + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [enabled] + properties: + enabled: {type: boolean} + percent: {type: integer, minimum: 0, maximum: 100} + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [enabled] + properties: + enabled: {type: boolean} + percent: {type: integer, minimum: 0, maximum: 100} +EOF +$K wait --for=condition=Established crd/featureflags.rollout.example.com --timeout=60s >/dev/null +$K -n %{ns} delete featureflags.rollout.example.com --all --wait=true >/dev/null 2>&1 +cat > "$WS/q58/deployment.yaml" <<'EOF' +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: %{app} + namespace: %{ns} + labels: {app: %{app}} +spec: + replicas: 2 + template: + metadata: + labels: {app: %{app}, tier: web} + spec: + containers: + - name: web + image: nginx:1.27 + ports: [{containerPort: 80}] +EOF +cat > "$WS/q58/flag.yaml" <<'EOF' +apiVersion: rollout.example.com/v1beta1 +kind: FeatureFlag +metadata: + name: %{app}-checkout + namespace: %{ns} +spec: + enabled: true + percent: %{pct} +EOF +""", + "checks": [ + C("deployment.yaml is apps/v1 and applies; Deployment %{app} has 2 available with its labels", 2, r""" +f="$WS/q58/deployment.yaml" +grep -Eq '^apiVersion:[[:space:]]*apps/v1[[:space:]]*$' "$f" 2>/dev/null || exit 1 +$K apply --dry-run=server -f "$f" >/dev/null 2>&1 || exit 1 +d="$K -n %{ns} get deploy %{app}" +eq "$($d -o jsonpath='{.spec.replicas}/{.spec.template.metadata.labels.app}/{.spec.template.metadata.labels.tier}/{.spec.template.spec.containers[0].name}/{.spec.template.spec.containers[0].image}' 2>/dev/null)" \ + "2/%{app}/web/web/nginx:1.27" || exit 1 +for i in $(seq 1 30); do [ "$($d -o jsonpath='{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""), + C("warning.txt holds the deprecation warning", 1, r""" +grep -q 'rollout.example.com/v1beta1 FeatureFlag is deprecated' "$WS/q58/warning.txt" 2>/dev/null"""), + C("flag.yaml uses rollout.example.com/v1, applies without a warning, and FeatureFlag %{app}-checkout exists", 2, r""" +f="$WS/q58/flag.yaml" +grep -Eq '^apiVersion:[[:space:]]*rollout\.example\.com/v1[[:space:]]*$' "$f" 2>/dev/null || exit 1 +out=$($K apply --dry-run=server -f "$f" 2>&1) || exit 1 +echo "$out" | grep -qi deprecated && exit 1 +eq "$(j -n %{ns} featureflags.rollout.example.com %{app}-checkout '{.spec.enabled}/{.spec.percent}')" "true/%{pct}" """), + C("served.txt: the four kinds with the versions this cluster serves", 2, r""" +want=$($K api-resources --no-headers 2>/dev/null | awk '{k=$NF; if (k=="CronJob" || k=="HorizontalPodAutoscaler" || k=="EndpointSlice" || k=="PriorityClass") print k, $(NF-2)}' | sort) +[ "$(echo "$want" | grep -c .)" = 4 ] || exit 1 +got=$(tr -s ' \t' ' ' < "$WS/q58/served.txt" 2>/dev/null | sed 's/^ *//; s/ *$//' | tr -d '\r' | grep . | sort) +eq "$got" "$want" """), + ], + "solution": """ +k apply -f {WS}/q58/deployment.yaml --dry-run=server # no matches for kind "Deployment" in version "extensions/v1beta1" +k api-resources | grep -w Deployment # apps/v1 +vi {WS}/q58/deployment.yaml +# apiVersion: apps/v1 +# spec: +# selector: {matchLabels: {app: %{app}}} <- apps/v1 requires it; the old API made it up from the template labels +k apply -f {WS}/q58/deployment.yaml + +k apply -f {WS}/q58/flag.yaml --dry-run=server 2> {WS}/q58/warning.txt +cat {WS}/q58/warning.txt # Warning: rollout.example.com/v1beta1 FeatureFlag is deprecated ...; use rollout.example.com/v1 FeatureFlag +sed -i 's#rollout.example.com/v1beta1#rollout.example.com/v1#' {WS}/q58/flag.yaml +k apply -f {WS}/q58/flag.yaml # no warning any more + +k api-resources | grep -w -E 'CronJob|HorizontalPodAutoscaler|EndpointSlice|PriorityClass' # APIVERSION column +cat > {WS}/q58/served.txt </dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: {name: %{dep}-conf, namespace: %{ns}} +data: + default.conf: | + server { + listen %{port}; + # liveness endpoint + location = /%{live} { return 200 "alive\n"; } + # readiness endpoint + location = /%{ready} { return 200 "ready\n"; } + location / { return 404; } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: %{dep}, namespace: %{ns}, labels: {app: %{dep}}} +spec: + replicas: 2 + selector: {matchLabels: {app: %{dep}}} + template: + metadata: {labels: {app: %{dep}}} + spec: + containers: + - name: gateway + image: nginx:1.27 + ports: [{containerPort: %{port}}] + volumeMounts: [{name: conf, mountPath: /etc/nginx/conf.d}] + livenessProbe: + httpGet: {path: /%{live}, port: 80} + periodSeconds: 5 + failureThreshold: 2 + readinessProbe: + httpGet: {path: /status, port: %{port}} + periodSeconds: 5 + volumes: [{name: conf, configMap: {name: %{dep}-conf}}] +EOF +$K -n %{ns} get cm %{dep}-conf -o jsonpath='{.data.default\.conf}' | md5sum | cut -d' ' -f1 > "$WS/.q62-conf.md5" +""", + "checks": [ + C("reason.txt holds Unhealthy", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q62/reason.txt" 2>/dev/null)" Unhealthy"""), + C("liveness: GET /%{live} on port %{port}, every 5 s, 2 failures", 2, r""" +$K -n %{ns} get deploy %{dep} -o json 2>/dev/null | python3 -c ' +import sys, json +c = json.load(sys.stdin)["spec"]["template"]["spec"]["containers"][0] +p = c.get("livenessProbe") or {}; h = p.get("httpGet") or {} +named = {x.get("name"): x.get("containerPort") for x in c.get("ports", [])} +port = named.get(h.get("port"), h.get("port")) +sys.exit(0 if (h.get("path"), port, p.get("periodSeconds"), p.get("failureThreshold")) == ("/%{live}", %{port}, 5, 2) else 1)'"""), + C("readiness: GET /%{ready} on port %{port}, every 5 s", 2, r""" +$K -n %{ns} get deploy %{dep} -o json 2>/dev/null | python3 -c ' +import sys, json +c = json.load(sys.stdin)["spec"]["template"]["spec"]["containers"][0] +p = c.get("readinessProbe") or {}; h = p.get("httpGet") or {} +named = {x.get("name"): x.get("containerPort") for x in c.get("ports", [])} +port = named.get(h.get("port"), h.get("port")) +sys.exit(0 if (h.get("path"), port, p.get("periodSeconds")) == ("/%{ready}", %{port}, 5) else 1)'"""), + C("2/2 Ready and still not restarted 12 s later; ConfigMap untouched", 2, r""" +d="$K -n %{ns} get deploy %{dep}"; ok= +for i in $(seq 1 20); do [ "$($d -o jsonpath='{.status.readyReplicas}/{.status.updatedReplicas}' 2>/dev/null)" = 2/2 ] && { ok=1; break; }; sleep 3; done +[ -n "$ok" ] || exit 1 +sleep 12 +$K -n %{ns} get pods -l app=%{dep} -o json | python3 -c ' +import sys, json +pods = [p for p in json.load(sys.stdin)["items"] if not p["metadata"].get("deletionTimestamp")] +cs = [s for p in pods for s in p.get("status", {}).get("containerStatuses", [])] +sys.exit(0 if len(pods) == 2 and len(cs) == 2 and all(s["ready"] and s["restartCount"] == 0 for s in cs) else 1)' || exit 1 +eq "$($K -n %{ns} get cm %{dep}-conf -o jsonpath='{.data.default\.conf}' | md5sum | cut -d' ' -f1)" "$(cat "$WS/.q62-conf.md5" 2>/dev/null)" """), + ], + "solution": """ +k -n %{ns} get pods # RESTARTS climbing, 0/1 READY +k -n %{ns} describe pod -l app=%{dep} --show-events | grep -E 'Liveness|Readiness|Unhealthy|Killing' +# Liveness: http-get http://:80/%{live} ... Readiness: http-get http://:%{port}/status ... +# Warning Unhealthy Liveness probe failed: Get "http://10.244.x.y:80/%{live}": ... connect: connection refused +# Warning Unhealthy Readiness probe failed: HTTP probe failed with statuscode: 404 +# Normal Killing Container gateway failed liveness probe, will be restarted +# (kubectl 1.36 and later leave the Events out when a selector matches several Pods - hence --show-events) +echo Unhealthy > {WS}/q62/reason.txt +k -n %{ns} get cm %{dep}-conf -o yaml # listen %{port}; /%{live} = liveness, /%{ready} = readiness +k -n %{ns} edit deploy %{dep} +# livenessProbe.httpGet: {path: /%{live}, port: %{port}} (was port 80: connection refused -> restart) +# readinessProbe.httpGet: {path: /%{ready}, port: %{port}} (was /status: 404 -> never Ready) +k -n %{ns} rollout status deploy %{dep} +k -n %{ns} get pods # 1/1 Running, RESTARTS 0 +# "connection refused" is a port problem, a status code is a path problem. A failing liveness +# probe restarts the container; a failing readiness probe only keeps the Pod out of the Service. +""", +}, +{ + "id": 63, "domain": "OBS", "title": "An inventory with kubectl alone", + "params": lambda r: {"ns": ns_pick(r, "stock"), "busy": r.choice(["W1", "W2", "W3"]), "tier": r.choice(["web", "api"])}, + "task": """ +Namespace `%{ns}` runs a handful of Pods; some of them never started. + +1. Write a command into `{WS}/q63/inventory.sh` that prints one line per Pod of `%{ns}` in exactly + three columns, under the headers `POD`, `NODE` and `IMAGE`: the Pod's name, the node it runs on + and the image of its first container. +2. Write the names of the Pods labelled `tier=%{tier}` that are **not** in phase `Running` to + `{WS}/q63/not-running.txt`, one per line. +3. Write the name of the node that runs the most Pods of `%{ns}` to `{WS}/q63/busiest.txt`. +""", + "setup": NS + r""" +mkns %{ns}; rm -rf "$WS/q63"; mkdir -p "$WS/q63" +$K -n %{ns} delete pod --all --wait=true --grace-period=1 >/dev/null 2>&1 +busy=$%{busy}; others=$(printf '%s\n' "$W1" "$W2" "$W3" | grep -vx "$busy") +o1=$(echo "$others" | sed -n 1p); o2=$(echo "$others" | sed -n 2p) +pod() { # name tier image nodeSelector-key nodeSelector-value + cat </dev/null +$K -n %{ns} wait --for=condition=Ready pod/stock-web-1 pod/stock-web-2 pod/stock-api-1 pod/stock-web-3 pod/stock-api-2 --timeout=150s >/dev/null +""", + "checks": [ + C("inventory.sh prints POD, NODE and IMAGE for every Pod of %{ns}", 3, r""" +f="$WS/q63/inventory.sh"; [ -s "$f" ] || exit 1 +out=$(cd / && bash "$f" 2>/dev/null) || exit 1 +want=$($K -n %{ns} get pods --no-headers -o 'custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image' | awk '{print $1, $2, $3}' | sort) +[ "$(echo "$want" | grep -c .)" -ge 8 ] || exit 1 +eq "$(echo "$out" | head -1 | awk '{print $1, $2, $3, NF}')" "POD NODE IMAGE 3" || exit 1 +echo "$out" | tail -n +2 | awk 'NF && NF != 3 {bad = 1} END {exit bad}' || exit 1 +eq "$(echo "$out" | tail -n +2 | awk 'NF {print $1, $2, $3}' | sort)" "$want" """), + C("not-running.txt: the tier=%{tier} Pods that are not Running", 2, r""" +want=$($K -n %{ns} get pods -l tier=%{tier} --field-selector='status.phase!=Running' -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | sort) +[ -n "$want" ] || exit 1 +eq "$(tr -d ' \t\r' < "$WS/q63/not-running.txt" 2>/dev/null | sed 's#^pod/##' | grep . | sort)" "$want" """), + C("busiest.txt names the node with the most Pods", 2, r""" +want=$($K -n %{ns} get pods -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | grep . | sort | uniq -c | sort -rn | awk 'NR==1 {print $2}') +[ -n "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q63/busiest.txt" 2>/dev/null)" "$want" """), + ], + "solution": """ +echo "kubectl -n %{ns} get pods -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image" > {WS}/q63/inventory.sh +bash {WS}/q63/inventory.sh # a Pod that was never scheduled shows as its node +k -n %{ns} get pods -l tier=%{tier} --field-selector='status.phase!=Running' -o name | cut -d/ -f2 > {WS}/q63/not-running.txt +k -n %{ns} get pods -o wide --no-headers | awk '{print $7}' | sort | uniq -c | sort -rn # NODE is column 7 of -o wide +echo > {WS}/q63/busiest.txt +# -l filters on labels, --field-selector on a few fields of the object itself (status.phase, +# spec.nodeName, metadata.name ...), and both run on the server. custom-columns takes +# HEADER:jsonpath pairs without the {} that -o jsonpath needs. +""", +}, +{ + "id": 64, "domain": "OBS", "title": "One replica is logging errors", + "params": lambda r: {"ns": ns_pick(r, "trace"), "app": name_pick(r, "router"), "bad": r.choice([0, 1, 2]), + "code": r.choice([502, 503, 504]), "ver": r.choice(["2024.11", "2025.03", "2025.07"])}, + "task": """ +The three Pods of StatefulSet `%{app}` in Namespace `%{ns}` all log the requests they route, and +one of them logs errors as well. + +1. Write the name of the Pod that logs `ERROR` lines to `{WS}/q64/bad-pod.txt`. +2. Save that Pod's `ERROR` lines, and nothing else, to `{WS}/q64/errors.log`. +3. Every Pod ran an init container `migrate` before it started. Write the schema version it + reported to `{WS}/q64/schema.txt`. +4. Write a command into `{WS}/q64/tail.sh` that prints the last **2** log lines of every Pod of + the StatefulSet in one go, each line prefixed with the Pod it comes from. +""", + "setup": NS + r""" +mkns %{ns}; rm -rf "$WS/q64"; mkdir -p "$WS/q64" +$K -n %{ns} delete statefulset %{app} --ignore-not-found --wait=true >/dev/null 2>&1 +$K -n %{ns} delete pod -l app=%{app} --wait=true --grace-period=1 >/dev/null 2>&1 +$K apply -f - >/dev/null <<'EOF' +apiVersion: v1 +kind: Service +metadata: {name: %{app}, namespace: %{ns}} +spec: {clusterIP: None, selector: {app: %{app}}, ports: [{port: 80}]} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: {name: %{app}, namespace: %{ns}} +spec: + serviceName: %{app} + replicas: 3 + podManagementPolicy: Parallel + selector: {matchLabels: {app: %{app}}} + template: + metadata: {labels: {app: %{app}}} + spec: + terminationGracePeriodSeconds: 2 + initContainers: + - name: migrate + image: busybox:1.36 + command: [sh, -c, "echo 'checking schema'; echo 'schema version %{ver} is current'"] + containers: + - name: router + image: busybox:1.36 + command: + - sh + - -c + - | + trap 'exit 0' TERM + i=0 + while true; do + i=$((i+1)) + echo "INFO request $i routed" + if [ "${HOSTNAME##*-}" = "%{bad}" ] && [ $((i % 3)) = 0 ]; then + echo "ERROR upstream timeout code=%{code} request=$i" + fi + sleep 2 & wait $! + done +EOF +$K -n %{ns} rollout status statefulset %{app} --timeout=150s >/dev/null +""", + "checks": [ + C("bad-pod.txt names %{app}-%{bad}", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q64/bad-pod.txt" 2>/dev/null | sed 's#^pod/##')" %{app}-%{bad}"""), + C("errors.log holds its ERROR lines and nothing else", 2, r""" +f="$WS/q64/errors.log"; [ "$(grep -c . "$f" 2>/dev/null)" -ge 1 ] || exit 1 +[ -z "$(grep . "$f" | grep -Ev 'ERROR upstream timeout code=%{code} request=[0-9]+[[:space:]]*$')" ]"""), + C("schema.txt holds %{ver}", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q64/schema.txt" 2>/dev/null)" %{ver}"""), + C("tail.sh prints the last 2 lines of all three Pods, prefixed with the Pod", 2, r""" +f="$WS/q64/tail.sh"; [ -s "$f" ] || exit 1 +out=$(cd / && bash "$f" 2>/dev/null) || exit 1 +for n in 0 1 2; do [ "$(echo "$out" | grep -c "^\[pod/%{app}-$n/router\] ")" = 2 ] || exit 1; done"""), + ], + "solution": """ +k -n %{ns} logs -l app=%{app} --prefix --tail=-1 | grep ERROR | head -3 # [pod/%{app}-%{bad}/router] ERROR ... +echo %{app}-%{bad} > {WS}/q64/bad-pod.txt +k -n %{ns} logs %{app}-%{bad} | grep ERROR > {WS}/q64/errors.log +k -n %{ns} logs %{app}-0 -c migrate # schema version %{ver} is current +echo %{ver} > {WS}/q64/schema.txt +echo "kubectl -n %{ns} logs -l app=%{app} --prefix --tail=2" > {WS}/q64/tail.sh +bash {WS}/q64/tail.sh +# With a selector, kubectl logs shows only the last 10 lines of each Pod unless --tail says +# otherwise (-1 = everything), and --prefix says which Pod and container a line came from. +# An init container's log is read like any other container's: -c NAME. +""", +}, +# ------------------------------------------------------------------- ENV ---- +{ + "id": 59, "domain": "ENV", "title": "A custom resource the API server rejects", + "params": lambda r: (lambda eng, pair: { + "ns": ns_pick(r, "cache"), "name": name_pick(r, "sessions"), "eng": eng, "engcap": eng.capitalize(), + "mem": r.choice([256, 512, 1024]), "maxr": r.choice([3, 5, 7]), "bad1": pair[0], "bad2": pair[1]})( + r.choice(["redis", "valkey", "memcached"]), r.sample(["catalog", "ratelimit", "search", "tokens"], 2)), + "task": """ +An operator for cache clusters runs in this cluster. Its CustomResourceDefinition belongs to the +API group `cache.example.com`. + +1. Write the short name kubectl accepts for that kind to `{WS}/q59/shortname.txt`. +2. The operator reports the state of every cache cluster in `status.phase`. Write each cache + cluster in the **whole cluster** whose phase is `Degraded` to `{WS}/q59/degraded.txt`, one per + line as `/`. +3. `{WS}/q59/cache.yaml` is meant to create cache cluster `%{name}` in Namespace `%{ns}`, but the + API server rejects it. Correct the manifest and apply it: engine `%{eng}`, `%{mem}` MB of + memory per node, and as many replicas as the CRD allows. +""", + "setup": NS + r""" +rm -rf "$WS/q59"; mkdir -p "$WS/q59" +$K apply -f - >/dev/null <<'EOF' +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: {name: cacheclusters.cache.example.com} +spec: + group: cache.example.com + scope: Namespaced + names: {plural: cacheclusters, singular: cachecluster, kind: CacheCluster, shortNames: [cc]} + versions: + - name: v1 + served: true + storage: true + subresources: {status: {}} + additionalPrinterColumns: + - {name: Engine, type: string, jsonPath: .spec.engine} + - {name: Replicas, type: integer, jsonPath: .spec.replicas} + - {name: Phase, type: string, jsonPath: .status.phase} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [engine, replicas, memoryMb] + properties: + engine: {type: string, enum: [redis, valkey, memcached], description: "Cache engine"} + replicas: {type: integer, minimum: 1, maximum: %{maxr}, description: "Number of cache nodes"} + memoryMb: {type: integer, minimum: 64, maximum: 4096, description: "Memory per node, in MB"} + status: + type: object + properties: + phase: {type: string} +EOF +$K wait --for=condition=Established crd/cacheclusters.cache.example.com --timeout=60s >/dev/null +mkns %{ns}; mkns %{ns}-shared +$K delete cacheclusters.cache.example.com --all -A --wait=true >/dev/null 2>&1 +mk() { # namespace name engine replicas memoryMb + phase=Ready; case "$2" in %{bad1}|%{bad2}) phase=Degraded ;; esac + printf 'apiVersion: cache.example.com/v1\nkind: CacheCluster\nmetadata: {name: %s, namespace: %s}\nspec: {engine: %s, replicas: %s, memoryMb: %s}\n' \ + "$2" "$1" "$3" "$4" "$5" | $K apply -f - >/dev/null + $K -n "$1" patch cacheclusters.cache.example.com "$2" --subresource=status --type=merge -p "{\"status\":{\"phase\":\"$phase\"}}" >/dev/null +} +mk %{ns} catalog redis 2 256 +mk %{ns}-shared ratelimit memcached 1 128 +mk %{ns} search valkey 3 512 +mk %{ns}-shared tokens redis 1 64 +cat > "$WS/q59/cache.yaml" <<'EOF' +apiVersion: cache.example.com/v1alpha1 +kind: CacheCluster +metadata: + name: %{name} + namespace: %{ns} +spec: + engine: %{engcap} + replicas: 12 + memory: %{mem} +EOF +""", + "checks": [ + C("shortname.txt holds cc", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q59/shortname.txt" 2>/dev/null)" cc"""), + C("degraded.txt lists exactly the Degraded cache clusters", 2, r""" +want=$($K get cacheclusters.cache.example.com -A -o jsonpath='{range .items[?(@.status.phase=="Degraded")]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}' 2>/dev/null | sort) +[ "$(echo "$want" | grep -c .)" = 2 ] || exit 1 +eq "$(tr -d ' \t\r' < "$WS/q59/degraded.txt" 2>/dev/null | grep . | sort)" "$want" """), + C("CacheCluster %{name}: engine %{eng}, memoryMb %{mem}", 2, r""" +eq "$(j -n %{ns} cacheclusters.cache.example.com %{name} '{.spec.engine}/{.spec.memoryMb}')" "%{eng}/%{mem}" """), + C("it runs the most replicas the CRD allows", 1, r""" +max=$(j crd cacheclusters.cache.example.com '{.spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.replicas.maximum}') +[ -n "$max" ] && eq "$(j -n %{ns} cacheclusters.cache.example.com %{name} '{.spec.replicas}')" "$max" """), + C("cache.yaml itself now applies", 1, r""" +f="$WS/q59/cache.yaml"; grep -Eq '^[[:space:]]*name:[[:space:]]*%{name}[[:space:]]*$' "$f" 2>/dev/null \ + && $K apply --dry-run=server -f "$f" >/dev/null 2>&1"""), + ], + "solution": """ +k api-resources --api-group=cache.example.com # cacheclusters cc cache.example.com/v1 true CacheCluster +echo cc > {WS}/q59/shortname.txt +k get cc -A # the PHASE column is status.phase +k get cc -A --no-headers | awk '$NF == "Degraded" {print $1 "/" $2}' > {WS}/q59/degraded.txt + +k apply -f {WS}/q59/cache.yaml --dry-run=server # one complaint after another: +# no matches for kind "CacheCluster" in version "cache.example.com/v1alpha1" -> only v1 is served +# unknown field "spec.memory" -> the field is memoryMb +# spec.engine: Unsupported value: "%{engcap}": supported values: "redis", "valkey", "memcached" +# spec.replicas: Invalid value: 12: spec.replicas in body should be less than or equal to %{maxr} +k explain cc.spec # engine (enum: redis, valkey, memcached), memoryMb, replicas - all required +k get crd cacheclusters.cache.example.com -o yaml | grep -n -A1 'maximum' +vi {WS}/q59/cache.yaml +# apiVersion: cache.example.com/v1 +# spec: {engine: %{eng}, replicas: %{maxr}, memoryMb: %{mem}} +k apply -f {WS}/q59/cache.yaml +# A CRD's schema is the contract: required fields, enum values and bounds are all in it, and +# --dry-run=server shows every violation without creating anything. .status belongs to the +# operator - you read it, you do not write it. +""", +}, +# ---------------------------------------------------------------- DESIGN ---- +{ + "id": 60, "domain": "DESIGN", "title": "Entrypoint, default argument and a non-root user", + "params": lambda r: {"img": name_pick(r, r.choice(["reporter", "exporter", "notifier"])), "arg": r.choice(["daily", "weekly", "hourly"]), + "arg2": r.choice(["audit", "billing", "usage"]), "team": r.choice(["payments", "search", "identity"])}, + "task": """ +`{WS}/q60/image` is the build context of `localhost/%{img}:1.0`, which is already built on this +machine (use `podman`). That image runs as root, and running it with an argument fails. + +1. Change the **Dockerfile** so that + - `/app/report.sh` is the image's entrypoint and `%{arg}` its default argument, + - the image runs as the non-root user `10001`, + - the image carries the label `team=%{team}`, + + and build it as `localhost/%{img}:1.1`. +2. Run `1.1` once with its defaults and write the output to `{WS}/q60/default.txt`. Run it once + more with the argument `%{arg2}` instead and write that output to `{WS}/q60/%{arg2}.txt`. +3. Remove the old image `localhost/%{img}:1.0` from this machine. +""", + "setup": r""" +command -v podman >/dev/null || { echo "podman is not installed on this machine - the task needs it"; exit 1; } +podman rmi -f localhost/%{img}:1.0 localhost/%{img}:1.1 >/dev/null 2>&1 || true +rm -rf "$WS/q60"; mkdir -p "$WS/q60/image" +cat > "$WS/q60/image/Dockerfile" <<'EOF' +FROM docker.io/library/busybox:1.36 +COPY report.sh /app/report.sh +RUN chmod 0755 /app/report.sh +CMD ["/app/report.sh", "all"] +EOF +cat > "$WS/q60/image/report.sh" <<'EOF' +#!/bin/sh +echo "%{img} report: ${1:-none} (uid $(id -u))" +EOF +podman build -q -t localhost/%{img}:1.0 "$WS/q60/image" >/dev/null 2>&1 \ + || { echo "could not build localhost/%{img}:1.0 - is docker.io/library/busybox:1.36 available to podman?"; exit 1; } +""", + "checks": [ + C("1.1 runs /app/report.sh with %{arg} by default, and with another argument when given one", 2, r""" +eq "$(podman run --rm localhost/%{img}:1.1 2>/dev/null | cut -d'(' -f1)" "%{img} report: %{arg} " || exit 1 +eq "$(podman run --rm localhost/%{img}:1.1 probe 2>/dev/null | cut -d'(' -f1)" "%{img} report: probe " """), + C("1.1 runs as user 10001 and carries the label team=%{team}", 2, r""" +podman run --rm localhost/%{img}:1.1 2>/dev/null | grep -q '(uid 10001)$' || exit 1 +eq "$(podman image inspect localhost/%{img}:1.1 --format '{{index .Config.Labels "team"}}' 2>/dev/null)" %{team}"""), + C("the Dockerfile itself sets ENTRYPOINT, USER and LABEL", 1, r""" +f="$WS/q60/image/Dockerfile" +grep -Eiq '^ENTRYPOINT' "$f" 2>/dev/null && grep -Eiq '^USER[[:space:]]+10001' "$f" && grep -Eiq '^LABEL.*team' "$f" """), + C("default.txt and %{arg2}.txt hold the two outputs", 1, r""" +eq "$(tr -d '\r' < "$WS/q60/default.txt" 2>/dev/null)" "%{img} report: %{arg} (uid 10001)" \ + && eq "$(tr -d '\r' < "$WS/q60/%{arg2}.txt" 2>/dev/null)" "%{img} report: %{arg2} (uid 10001)" """), + C("localhost/%{img}:1.0 is gone, 1.1 is there", 1, r""" +podman image exists localhost/%{img}:1.1 && ! podman image exists localhost/%{img}:1.0"""), + ], + "solution": """ +cd {WS}/q60/image +podman run --rm localhost/%{img}:1.0 %{arg2} # fails: with only a CMD, the argument replaces the whole command +vi Dockerfile +# FROM docker.io/library/busybox:1.36 +# LABEL team=%{team} +# COPY report.sh /app/report.sh +# RUN chmod 0755 /app/report.sh +# USER 10001 +# ENTRYPOINT ["/app/report.sh"] +# CMD ["%{arg}"] +podman build -t localhost/%{img}:1.1 . +podman run --rm localhost/%{img}:1.1 > {WS}/q60/default.txt # %{img} report: %{arg} (uid 10001) +podman run --rm localhost/%{img}:1.1 %{arg2} > {WS}/q60/%{arg2}.txt +podman rmi localhost/%{img}:1.0 +podman inspect localhost/%{img}:1.1 --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Labels}}' +# ENTRYPOINT is the program, CMD its default arguments, and what follows the image name on the +# command line replaces CMD only. Both need the exec form (a JSON array): a shell-form ENTRYPOINT +# ignores CMD and arguments. In a Pod the same pair is `command` (ENTRYPOINT) and `args` (CMD). +# USER goes after the RUN steps that need root. +""", +}, +# ---------------------------------------------------------------- DEPLOY ---- +{ + "id": 61, "domain": "DEPLOY", "title": "A kustomization that does not build", + "params": lambda r: {"ns": ns_pick(r, "shop"), "sfx": r.choice(["eu", "us", "apac"]), "tok": name_pick(r, "tok"), "n": r.choice([2, 3])}, + "task": """ +`{WS}/q61/app` holds a kustomization for the app `storefront`, but +`kubectl kustomize {WS}/q61/app` fails. + +1. Make it build. Two things are wrong. The Deployment and the Service are fine as they are - do + not change `deployment.yaml` or `service.yaml`. +2. Extend `kustomization.yaml` so that the result + - goes into Namespace `%{ns}`, + - has every resource name end in `-%{sfx}`, + - runs image `nginx:1.27`, + - includes a generated Secret `storefront-token` whose key `token` is `%{tok}`, **without** a + hash at the end of its name. +3. Apply it. The Deployment must come up with the %{n} replicas its patch asks for. +""", + "setup": NS + r""" +mkns %{ns} +$K -n %{ns} delete deploy/storefront-%{sfx} svc/storefront-%{sfx} secret/storefront-token-%{sfx} --ignore-not-found >/dev/null 2>&1 +rm -rf "$WS/q61"; mkdir -p "$WS/q61/app" +cat > "$WS/q61/app/deployment.yaml" <<'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: {name: storefront, labels: {app: storefront}} +spec: + replicas: 1 + selector: {matchLabels: {app: storefront}} + template: + metadata: {labels: {app: storefront}} + spec: + containers: + - name: web + image: nginx:1.25 + ports: [{containerPort: 80}] + env: + - name: API_TOKEN + valueFrom: {secretKeyRef: {name: storefront-token, key: token}} +EOF +cat > "$WS/q61/app/service.yaml" <<'EOF' +apiVersion: v1 +kind: Service +metadata: {name: storefront, labels: {app: storefront}} +spec: {selector: {app: storefront}, ports: [{port: 80, targetPort: 80}]} +EOF +cat > "$WS/q61/app/replicas-patch.yaml" <<'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: store-front +spec: + replicas: %{n} +EOF +cat > "$WS/q61/app/kustomization.yaml" <<'EOF' +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deploy.yaml + - service.yaml +patches: + - path: replicas-patch.yaml +EOF +(cd "$WS/q61/app" && md5sum deployment.yaml service.yaml) > "$WS/.q61-app.md5" +""", + "checks": [ + C("builds: Deployment and Service storefront-%{sfx} in %{ns}; %{n} replicas of nginx:1.27", 3, r""" +$K kustomize "$WS/q61/app" 2>/dev/null | $K create --dry-run=client -o json -f - 2>/dev/null | python3 -c ' +import sys, json +s = sys.stdin.read(); dec = json.JSONDecoder(); i = 0; items = [] +while i < len(s): + while i < len(s) and s[i].isspace(): i += 1 + if i >= len(s): break + o, i = dec.raw_decode(s, i); items.extend(o.get("items", [o])) +dep = [x for x in items if x["kind"] == "Deployment"]; svc = [x for x in items if x["kind"] == "Service"] +ok = len(dep) == 1 and len(svc) == 1 and all(x["metadata"].get("namespace") == "%{ns}" for x in items) \ + and dep[0]["metadata"]["name"] == "storefront-%{sfx}" and svc[0]["metadata"]["name"] == "storefront-%{sfx}" \ + and dep[0]["spec"]["replicas"] == %{n} and dep[0]["spec"]["template"]["spec"]["containers"][0]["image"] == "nginx:1.27" +sys.exit(0 if ok else 1)'"""), + C("generated Secret storefront-token-%{sfx} (no hash) holds the token, and the Deployment refers to it", 2, r""" +$K kustomize "$WS/q61/app" 2>/dev/null | $K create --dry-run=client -o json -f - 2>/dev/null | python3 -c ' +import sys, json, base64 +s = sys.stdin.read(); dec = json.JSONDecoder(); i = 0; items = [] +while i < len(s): + while i < len(s) and s[i].isspace(): i += 1 + if i >= len(s): break + o, i = dec.raw_decode(s, i); items.extend(o.get("items", [o])) +sec = [x for x in items if x["kind"] == "Secret"]; dep = [x for x in items if x["kind"] == "Deployment"] +ok = len(sec) == 1 and len(dep) == 1 and sec[0]["metadata"]["name"] == "storefront-token-%{sfx}" \ + and base64.b64decode(sec[0].get("data", {}).get("token", "")).decode() == "%{tok}" \ + and dep[0]["spec"]["template"]["spec"]["containers"][0]["env"][0]["valueFrom"]["secretKeyRef"]["name"] == "storefront-token-%{sfx}" +sys.exit(0 if ok else 1)'"""), + C("applied: %{n} Pods available, and they see API_TOKEN=%{tok}", 2, r""" +d="$K -n %{ns} get deploy storefront-%{sfx}"; ok= +for i in $(seq 1 30); do [ "$($d -o jsonpath='{.status.availableReplicas}' 2>/dev/null)" = %{n} ] && { ok=1; break; }; sleep 3; done +[ -n "$ok" ] || exit 1 +eq "$($K -n %{ns} exec deploy/storefront-%{sfx} -- printenv API_TOKEN 2>/dev/null | tr -d '\r')" %{tok}"""), + C("deployment.yaml and service.yaml left as they were", 1, r""" +[ -n "$($K kustomize "$WS/q61/app" 2>/dev/null)" ] && (cd "$WS/q61/app" && md5sum -c --quiet "$WS/.q61-app.md5" >/dev/null 2>&1)"""), + ], + "solution": """ +cd {WS}/q61/app +k kustomize . # ... deploy.yaml: no such file or directory -> the file is deployment.yaml +k kustomize . # no matches for Id Deployment...store-front; failed to find unique target for patch +sed -i 's/name: store-front/name: storefront/' replicas-patch.yaml # a patch finds its target by kind and name +# (or leave the file and name the target in kustomization.yaml: target: {kind: Deployment, name: storefront}) +cat > kustomization.yaml <. +""", +}, +] + +# written grouped by domain, listed by number +QUESTIONS.sort(key=lambda q: q["id"]) diff --git a/tracks/ckad/curriculum.py b/tracks/ckad/curriculum.py index 629239a..29b0c28 100644 --- a/tracks/ckad/curriculum.py +++ b/tracks/ckad/curriculum.py @@ -376,13 +376,14 @@ k get pod POD -o jsonpath='{.status.containerStatuses[0].lastState.terminated.re k logs POD --previous; k exec -it POD -- sh k debug -it POD --image=busybox:1.36 --target=CONTAINER # ephemeral container, sees its processes k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://SVC:PORT -k describe rs -l app=web | tail; k get events --sort-by=.lastTimestamp +k describe rs -l app=web --show-events | tail; k get events --sort-by=.lastTimestamp k get pod POD -o yaml > pod.yaml && vi pod.yaml && k replace --force -f pod.yaml """, "traps": [ "Most of a running Pod is immutable (the image is not): fix a bare Pod with `replace --force`, fix a Deployment's Pods in the Deployment.", "Fix what the task names and nothing else - graders check that the ConfigMap, Service or Namespace you were told to leave is unchanged.", "After fixing a Deployment wait for the rollout; old Pods linger in Terminating.", + "`kubectl describe` with a selector that matches several objects leaves their Events out - add `--show-events`, or describe one by name.", ], "docs": ["https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/", "https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/", diff --git a/tracks/ckad/questions.py b/tracks/ckad/questions.py index 35dd7f3..8705fbb 100644 --- a/tracks/ckad/questions.py +++ b/tracks/ckad/questions.py @@ -1,5 +1,6 @@ -"""CKAD track: 37 pool tasks, at least one per competency in the CKAD curriculum (1-12 here, 13-37 -in bank_extra.py), plus Simulator Set A (38-57, bank_sim_a.py). Same format as the CKA banks. +"""CKAD track: 44 pool tasks, at least two per objective of the CKAD curriculum (1-12 here, 13-37 +in bank_extra.py, 58-64 in bank_more.py), plus Simulator Set A (38-57, bank_sim_a.py). Same format +as the CKA banks. curriculum.py maps every task to the objectives it practises (./exam objectives). CKAD is a build-things exam: less cluster surgery, more "produce the right object, fast". The clock is the adversary, so these are mostly single-object tasks graded exactingly. @@ -25,7 +26,7 @@ TRACK = { "NET": "Services and Networking (20%)", }, # 16 tasks in 120 minutes, weighted like the exam (20/20/15/25/20 %). The pool per domain is - # 8/7/7/10/5, so no two mocks are the same set. + # 9/8/11/11/5, so no two mocks are the same set. "quota": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3}, "quota_hard": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3}, # fixed simulator sets (./exam sim a) - excluded from random sampling @@ -588,6 +589,7 @@ EOF # 13-37: the rest of the curriculum, one or more tasks per competency the starter bank left out. # 38-57: Simulator Set A, a fixed killer.sh-style mock. Numbering is contiguous - keep it that way. -from tracks.ckad import bank_extra, bank_sim_a # noqa: E402 +# 58-64: a second pool task for each objective that had one (see curriculum.py). +from tracks.ckad import bank_extra, bank_more, bank_sim_a # noqa: E402 -QUESTIONS = QUESTIONS + bank_extra.QUESTIONS + bank_sim_a.QUESTIONS +QUESTIONS = QUESTIONS + bank_extra.QUESTIONS + bank_sim_a.QUESTIONS + bank_more.QUESTIONS diff --git a/web/index.html b/web/index.html index 0459503..f9d3004 100644 --- a/web/index.html +++ b/web/index.html @@ -129,6 +129,29 @@ .pill.pick:hover { border-color:var(--accent); color:var(--accent-hover); } .pill.done.pick:hover { color:var(--ok); } .pill.done { background:var(--ok-soft); border-color:var(--ok); color:var(--ok); } + /* ---- curriculum: objective list in the lobby, notes beside a task ---- */ + .notes h4 { margin: 12px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; } + .notes ul { margin: 0; padding-left: 18px; } .notes li { margin: 3px 0; } + .notes code { background: var(--pane-2); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); color: #c9d4f2; } + .notes pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.55 var(--mono); background: var(--bg-alt); border: 1px solid var(--line); padding: 10px 12px; border-radius: 6px; color: #c9d4f2; } + .notes a { overflow-wrap: anywhere; } + #objNotes { margin-top: 14px; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px; font-size: 13px; background: var(--pane-2); } + #objNotes summary { cursor: pointer; font-weight: 600; } + #objList { max-height: 56vh; overflow-y: auto; margin-top: 8px; padding-right: 4px; } + #objList h3 { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 600; } + details.obj { border-top: 1px solid var(--line); padding: 7px 0; font-size: 13px; } + details.obj > summary { cursor: pointer; display: grid; grid-template-columns: 16px 30px 1fr auto; gap: 8px; align-items: baseline; list-style: none; } + details.obj > summary::-webkit-details-marker { display: none; } + details.obj > summary:hover .otext { color: var(--accent-hover); } + details.obj[open] > summary { margin-bottom: 6px; } + details.obj .oid { font-family: var(--mono); color: var(--muted); } + .mk { font-family: var(--mono); font-weight: 600; text-align: center; } + .st-done, .st-passed { color: var(--ok); } .st-studied, .st-started { color: var(--warn); } .st-failed { color: var(--danger); } .st-new { color: var(--muted); } + .chips { font: 12px var(--mono); white-space: nowrap; color: var(--muted); } + .chips span { margin-left: 6px; } + .ladder { display: grid; grid-template-columns: auto 1fr auto; gap: 2px 12px; font-size: 13px; margin: 0 0 14px; padding: 10px 12px; + background: var(--pane-2); border: 1px solid var(--line); border-radius: 8px; } + .ladder .here { color: var(--accent-hover); font-weight: 600; } .ladder .muted { color: var(--muted); } .hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; } @media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s, border-color .15s; } } @media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } } @@ -156,6 +179,7 @@
+
@@ -181,7 +205,9 @@
+
+ @@ -195,6 +221,11 @@
+