Education › Certifications › Guided project

CKA practice cluster

Build a real two-node kubeadm cluster you own, then use it the way the CKA exam will use you: timed tasks across every domain, deliberate breakage you must diagnose, a kubeadm upgrade and an etcd restore performed until they are boring. Everything runs in local virtual machines with Multipass; no cloud account needed.

Associate about 6 hours 5 phases · 25 steps 0 / 25 done
What you will have at the end

A repeatable script that builds a kubeadm cluster (one control plane, one worker) in about ten minutes, a tasks/ folder of forty timed exam-style tasks with solutions and verification commands, a breaks/ folder of scripts that sabotage the cluster in exam-realistic ways, and a log of your times per task and per fix. By the end, the upgrade and the etcd backup-and-restore each take you under ten minutes without looking anything up.

Before you start
  • The DevOps track's Kubernetes module and the Certifications track's CKA guide (this is its practice half)
  • A laptop with 8 GB RAM free for two VMs (4 GB and 2 GB) and about 20 GB disk
  • Comfortable in a terminal and with vim or nano
Tools you will install
  • Multipass — Ubuntu VMs on macOS, Windows or Linux with one command ↗
  • kubeadm, kubelet, kubectl — the exam is built on kubeadm clusters; these are the tools it expects ↗
  • containerd — the container runtime kubeadm clusters use ↗
  • Calico (or Cilium) — a CNI that supports NetworkPolicy, which the exam tests ↗
  • etcdctl — backup and restore tasks ↗
Repository layout at the end
cka-lab/
├── cluster/
│   ├── up.sh              # create VMs, install runtime + kubeadm, init, join, CNI
│   ├── node-setup.sh      # runs inside each VM: containerd, kubeadm packages, sysctl
│   └── down.sh            # delete the VMs
├── tasks/
│   ├── 01-pods-deployments.md
│   ├── 02-services-ingress.md
│   ├── 03-storage.md
│   ├── 04-scheduling.md
│   ├── 05-rbac.md
│   ├── 06-networkpolicy.md
│   ├── 07-upgrade.md
│   ├── 08-etcd.md
│   └── solutions/
├── breaks/
│   ├── break-kubelet.sh
│   ├── break-apiserver.sh
│   ├── break-cni.sh
│   ├── break-service.sh
│   └── break-scheduler.sh
└── LOG.md                 # task, date, time taken, notes

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

Build the cluster

A kubeadm cluster with one control plane and one worker on a recent Kubernetes minor version, built by a script you can rerun in ten minutes.

  1. Install Multipass and create the two VMs. The control plane gets 2 CPUs and 4 GB; the worker 2 CPUs and 2 GB. Use the same Ubuntu LTS the exam environment uses (currently 24.04).
    bash
    brew install --cask multipass      # macOS; Windows: winget install Canonical.Multipass; Linux: snap install multipass
    multipass launch 24.04 --name cp --cpus 2 --memory 4G --disk 12G
    multipass launch 24.04 --name w1 --cpus 2 --memory 2G --disk 12G
    multipass list
    Check: Both VMs show Running with an IPv4 address.
  2. Write the per-node setup script: kernel modules and sysctl for networking, containerd with the systemd cgroup driver, and the Kubernetes apt repository pinned to the exam's minor version (adjust 1.35 to the version on the CKA FAQ when you do this).
    bash
    #!/bin/bash
    # cluster/node-setup.sh  (run as root inside each VM)
    set -euxo pipefail
    K8S_MINOR="1.35"
    
    cat <<'EOF' >/etc/modules-load.d/k8s.conf
    overlay
    br_netfilter
    EOF
    modprobe overlay && modprobe br_netfilter
    cat <<'EOF' >/etc/sysctl.d/k8s.conf
    net.bridge.bridge-nf-call-iptables  = 1
    net.bridge.bridge-nf-call-ip6tables = 1
    net.ipv4.ip_forward                 = 1
    EOF
    sysctl --system >/dev/null
    swapoff -a && sed -i '/ swap / s/^/#/' /etc/fstab
    
    apt-get update && apt-get install -y containerd apt-transport-https ca-certificates curl gpg
    mkdir -p /etc/containerd
    containerd config default >/etc/containerd/config.toml
    sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
    systemctl restart containerd && systemctl enable containerd
    
    curl -fsSL "https://pkgs.k8s.io/core:/stable:/v${K8S_MINOR}/deb/Release.key" | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v${K8S_MINOR}/deb/ /" >/etc/apt/sources.list.d/kubernetes.list
    apt-get update && apt-get install -y kubelet kubeadm kubectl
    apt-mark hold kubelet kubeadm kubectl
    systemctl enable kubelet
    The version pin matters twice: it matches the exam, and it leaves room for the upgrade task (you will deliberately install the previous patch release first in Phase 4).
  3. Write the orchestration script that copies the setup into both VMs, initialises the control plane, installs Calico, joins the worker and pulls the kubeconfig to your laptop.
    bash
    #!/bin/bash
    # cluster/up.sh
    set -euo pipefail
    for n in cp w1; do
      multipass transfer cluster/node-setup.sh "$n:/tmp/node-setup.sh"
      multipass exec "$n" -- sudo bash /tmp/node-setup.sh
    done
    
    CP_IP=$(multipass info cp --format json | python3 -c "import sys,json; print(json.load(sys.stdin)['info']['cp']['ipv4'][0])")
    multipass exec cp -- sudo kubeadm init --apiserver-advertise-address "$CP_IP" --pod-network-cidr 192.168.0.0/16
    multipass exec cp -- bash -c 'mkdir -p ~/.kube && sudo cp /etc/kubernetes/admin.conf ~/.kube/config && sudo chown $(id -u):$(id -g) ~/.kube/config'
    
    # CNI with NetworkPolicy support
    multipass exec cp -- kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/calico.yaml
    
    JOIN=$(multipass exec cp -- sudo kubeadm token create --print-join-command)
    multipass exec w1 -- sudo bash -c "$JOIN"
    
    mkdir -p ~/.kube
    multipass exec cp -- cat ~/.kube/config | sed "s/127.0.0.1/$CP_IP/" > ~/.kube/cka-lab.config
    echo "export KUBECONFIG=~/.kube/cka-lab.config"
    The kubeconfig rewrite lets kubectl on your laptop reach the API server through the VM's IP. Practise on the control plane VM itself too (multipass shell cp), because the exam terminal is a Linux host, not your laptop.
  4. Run it, then wait for both nodes to be Ready.
    bash
    chmod +x cluster/*.sh && ./cluster/up.sh
    export KUBECONFIG=~/.kube/cka-lab.config
    kubectl get nodes -o wide
    kubectl get pods -n kube-system
    Check: cp and w1 are Ready; all kube-system pods (including calico) are Running within a couple of minutes.
  5. The exam switches clusters with kubectl config use-context. Create a second named context on the same cluster so you build the habit of running the context line before every task.
    bash
    export KUBECONFIG=~/.kube/cka-lab.config
    kubectl config get-contexts
    kubectl config set-context lab-apps --cluster=kubernetes --user=kubernetes-admin --namespace=apps
    kubectl config use-context lab-apps && kubectl config current-context
    Check: current-context prints lab-apps; kubectl get pods without -n now targets the apps namespace.
  6. Write down.sh (multipass delete cp w1 && multipass purge) and prove the round trip: tear down and rebuild. Time it; that number is your safety net for every drill that goes wrong.
    Check: A full rebuild takes about ten minutes. Commit the cluster/ scripts to a new repository.
Phase 2

Exam habits and the core tasks

The aliases and speed techniques become reflexes, and you have timed results for the workloads, services, storage and scheduling domains.

  1. Every session starts on the control plane VM with the exam setup, typed from memory in under thirty seconds.
    bash
    multipass shell cp
    alias k=kubectl
    export do="--dry-run=client -o yaml"
    export now="--force --grace-period=0"
    source <(kubectl completion bash) && complete -o default -F __start_kubectl k
    printf 'set et ts=2 sw=2\n' > ~/.vimrc
  2. Create the task files. Each task states a namespace, a goal, a time budget and a verification command, exactly like the exam. Write the first ten workload tasks; the solution file holds the imperative command sequence.
    text
    # tasks/01-pods-deployments.md
    
    T1 (4 min)  In namespace `apps` (create it), create a Deployment `web` with 3 replicas of `nginx:1.27`,
                container port 80, and label `tier=frontend` on the pods.
                verify: k -n apps get deploy web -o jsonpath='{.status.readyReplicas}' -> 3
    
    T2 (3 min)  Scale `web` to 5, then roll its image to `nginx:1.27.3`, then roll back to the previous revision.
                verify: k -n apps rollout history deploy web shows 3 revisions; image is nginx:1.27
    
    T3 (5 min)  Create a ConfigMap `web-config` with key `INDEX=hello` and a Secret `web-secret` with key
                `TOKEN=s3cr3t`. Mount the ConfigMap at /etc/config and expose the Secret as env var TOKEN in `web`.
                verify: k -n apps exec deploy/web -- sh -c 'cat /etc/config/INDEX; echo $TOKEN'
    
    T4 (4 min)  Add a readiness probe (HTTP GET / on 80, every 5 s) and resource requests (100m CPU, 64Mi) with
                limits (200m, 128Mi) to `web`.
                verify: k -n apps get deploy web -o yaml | grep -A4 readinessProbe
    
    T5 (3 min)  Create a CronJob `report` in `apps` running `busybox:1.36` with command `date` every 5 minutes;
                keep 2 successful job histories.
                verify: k -n apps get cronjob report -o jsonpath='{.spec.successfulJobsHistoryLimit}' -> 2
    
    T6 (4 min)  Create a DaemonSet `node-agent` in `kube-system` running `busybox:1.36` with `sleep 3600`,
                tolerating the control-plane taint so it runs on both nodes.
                verify: k -n kube-system get ds node-agent -> DESIRED 2, READY 2
    
    T7 (4 min)  A pod `crash` in `apps` must run `busybox:1.36` with command `sh -c 'exit 1'` and restart policy
                Never. Create it, then find the exit code from its status.
                verify: k -n apps get pod crash -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}' -> 1
    
    T8 (5 min)  Create a HorizontalPodAutoscaler for `web`: min 2, max 6, target 60% CPU. Install metrics-server
                first if `k top nodes` fails (see solutions).
                verify: k -n apps get hpa web
    
    T9 (3 min)  Export the `web` Deployment to /root/web.yaml, delete it, recreate it from the file.
                verify: k -n apps get deploy web
    
    T10 (3 min) Find which node each `web` pod runs on and write the pod name and node, one per line, to /root/pods.txt.
                verify: cat /root/pods.txt shows 5 lines
    Solutions live in tasks/solutions/01.md; write them yourself the first time you solve each task, using generators (k create deploy ... $do > f.yaml) rather than hand-written YAML.
  3. Do T1–T10 with a timer and record each time in LOG.md. Anything over budget goes on a redo list.
    text
    # LOG.md
    | date       | task | budget | took  | notes                                   |
    |------------|------|--------|-------|-----------------------------------------|
    | 2026-09-22 | T1   | 4:00   | 2:10  |                                         |
    | 2026-09-22 | T3   | 5:00   | 7:40  | forgot envFrom vs env valueFrom syntax  |
    Check: Ten timed entries; a redo list of the tasks that ran over.
  4. Install the metrics server (the HPA task and kubectl top need it). On a kubeadm lab the kubelet serves a self-signed certificate, so the insecure-TLS flag is required.
    bash
    kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.2/components.yaml
    kubectl -n kube-system patch deploy metrics-server --type=json \
      -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
    sleep 30 && kubectl top nodes
    Check: kubectl top nodes prints CPU and memory for both nodes.
  5. Services and Ingress tasks. Install the ingress-nginx controller once (it is what most exam environments provide) and write tasks for ClusterIP, NodePort, endpoints debugging and an Ingress with two paths.
    bash
    kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.0/deploy/static/provider/baremetal/deploy.yaml
    kubectl -n ingress-nginx get pods -w   # until the controller is Running
    Tasks for 02-services-ingress.md: expose web as ClusterIP on 80→80 and curl it from a temporary pod; expose as NodePort and curl the worker's IP; create a Service whose selector is deliberately wrong and fix it by reading k get ep; create an Ingress shop routing / to web and /api to a second Deployment api (hashicorp/http-echo), and verify with curl -H 'Host: shop.example' http://W1_IP:NODEPORT/api.
  6. Storage and scheduling tasks: a hostPath PersistentVolume and matching claim, a StorageClass with volumeBindingMode: WaitForFirstConsumer, and scheduling with nodeSelector, node affinity, taints and tolerations, plus a pod that stays Pending until you fix the reason.
    yaml
    # tasks/solutions/03-pv.yaml (the shape to be able to write from memory)
    apiVersion: v1
    kind: PersistentVolume
    metadata:
      name: pv-data
    spec:
      capacity:
        storage: 1Gi
      accessModes: [ReadWriteOnce]
      persistentVolumeReclaimPolicy: Retain
      storageClassName: manual
      hostPath:
        path: /mnt/data
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: pvc-data
      namespace: apps
    spec:
      accessModes: [ReadWriteOnce]
      storageClassName: manual
      resources:
        requests:
          storage: 500Mi
    Tasks for 03-storage.md and 04-scheduling.md: bind the claim and mount it in a pod that writes a file; delete the pod and confirm the file persists on the node; taint w1 with env=prod:NoSchedule and make web tolerate it; add a nodeSelector nobody matches and diagnose the Pending event; use requiredDuringSchedulingIgnoredDuringExecution affinity for a label you add to cp.
  7. Add a Gateway API task, since the current curriculum lists it alongside Ingress: install a Gateway API implementation (the NGINX Gateway Fabric or Envoy Gateway quickstart), create a Gateway and an HTTPRoute sending shop.example/api to the api Service, and verify with curl exactly as for the Ingress task.
    Keep the task even if it takes longer the first time; HTTPRoute manifests are copied from the docs in the exam, and knowing which page to open is the skill.
Phase 3

RBAC and NetworkPolicy

You can create least-privilege access for a user and a service account and write a NetworkPolicy from memory, verifying both.

  1. RBAC tasks: create a ServiceAccount deployer in apps that may only get, list and update Deployments in apps; verify with auth can-i; then create a ClusterRole for reading nodes bound to a user ops.
    bash
    k -n apps create sa deployer
    k -n apps create role deploy-editor --verb=get,list,update --resource=deployments
    k -n apps create rolebinding deployer-binding --role=deploy-editor --serviceaccount=apps:deployer
    k auth can-i update deployments -n apps --as=system:serviceaccount:apps:deployer     # yes
    k auth can-i delete deployments -n apps --as=system:serviceaccount:apps:deployer     # no
    k create clusterrole node-reader --verb=get,list,watch --resource=nodes
    k create clusterrolebinding ops-nodes --clusterrole=node-reader --user=ops
    k auth can-i list nodes --as=ops                                                     # yes
    Check: The three auth can-i answers are yes, no, yes. Add a task to 05-rbac.md that requires a ClusterRole bound with a RoleBinding (namespace-scoped use of a cluster-wide role) and explain the difference in your solution notes.
  2. NetworkPolicy tasks: default deny in apps, then allow web to be reached only from pods labelled role=lb, and allow web egress only to DNS and to api on 8080. Verify each rule with a temporary pod before and after.
    yaml
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata:
      name: default-deny
      namespace: apps
    spec:
      podSelector: {}
      policyTypes: [Ingress, Egress]
    ---
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata:
      name: web-policy
      namespace: apps
    spec:
      podSelector:
        matchLabels: {tier: frontend}
      policyTypes: [Ingress, Egress]
      ingress:
        - from:
            - podSelector:
                matchLabels: {role: lb}
          ports: [{protocol: TCP, port: 80}]
      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}]
        - to:
            - podSelector:
                matchLabels: {app: api}
          ports: [{protocol: TCP, port: 8080}]
    Check: k -n apps run t --rm -it --image=busybox:1.36 --restart=Never -- wget -qO- --timeout=2 web fails; the same with --labels role=lb succeeds; from web, nslookup api works and wget api:8080 works while wget example.com times out.
  3. Write five more NetworkPolicy tasks with different selectors (namespace-only, ipBlock with except, port ranges) and solve them from the docs page for NetworkPolicy — the one page you are allowed to open in the exam. Time how long it takes to find and adapt the example.
    Check: Under three minutes per policy by the fifth one.
Phase 4

The long tasks: upgrade and etcd

A control-plane and worker upgrade and an etcd backup-and-restore, each performed three times until they take under ten minutes without notes.

  1. Set up the upgrade drill: rebuild the cluster with the previous patch release so there is something to upgrade to. In node-setup.sh change the install line to pin a specific older patch (for example kubelet=1.35.0-1.1 kubeadm=1.35.0-1.1 kubectl=1.35.0-1.1), rebuild, and confirm the version.
    bash
    ./cluster/down.sh && ./cluster/up.sh
    kubectl get nodes    # VERSION column shows v1.35.0 (or whatever you pinned)
    multipass exec cp -- apt-cache madison kubeadm | head -5   # the available newer patch versions
    Upgrading across a minor version (1.34 → 1.35) is the more realistic exam task and follows the same steps; do a patch upgrade first, then rebuild on the previous minor and do the minor upgrade.
  2. Perform the control-plane upgrade with a stopwatch running. This is the exact sequence; do not deviate from the order.
    bash
    # on your laptop or the cp node with KUBECONFIG set
    kubectl drain cp --ignore-daemonsets --delete-emptydir-data
    
    # on cp
    multipass shell cp
    sudo apt-mark unhold kubeadm && sudo apt-get update && sudo apt-get install -y kubeadm=1.35.1-1.1 && sudo apt-mark hold kubeadm
    sudo kubeadm upgrade plan
    sudo kubeadm upgrade apply v1.35.1 -y
    sudo apt-mark unhold kubelet kubectl && sudo apt-get install -y kubelet=1.35.1-1.1 kubectl=1.35.1-1.1 && sudo apt-mark hold kubelet kubectl
    sudo systemctl daemon-reload && sudo systemctl restart kubelet
    exit
    
    kubectl uncordon cp
    kubectl get nodes
    Check: cp shows the new version and is Ready. Record the time in LOG.md.
  3. Upgrade the worker the same way with kubeadm upgrade node instead of upgrade apply.
    bash
    kubectl drain w1 --ignore-daemonsets --delete-emptydir-data
    multipass exec w1 -- sudo bash -c 'apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.35.1-1.1 && apt-mark hold kubeadm && kubeadm upgrade node && apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.35.1-1.1 kubectl=1.35.1-1.1 && apt-mark hold kubelet kubectl && systemctl daemon-reload && systemctl restart kubelet'
    kubectl uncordon w1
    kubectl get nodes
    Check: Both nodes on the new version, both Ready, all apps pods back to Running.
  4. etcd backup: take a snapshot using the certificate paths from the etcd static pod manifest, then verify it. Read the paths from the manifest instead of memorising them.
    bash
    multipass shell cp
    sudo grep -E 'cert-file|key-file|trusted-ca-file|listen-client' /etc/kubernetes/manifests/etcd.yaml
    sudo apt-get install -y etcd-client 2>/dev/null || true   # or use the etcdctl binary from the etcd release
    sudo ETCDCTL_API=3 etcdctl snapshot save /opt/etcd-backup.db \
      --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
    sudo ETCDCTL_API=3 etcdctl snapshot status /opt/etcd-backup.db --write-out=table
    Check: The status table shows a hash, a revision and a total key count.
  5. Prove the restore matters: create a Deployment after-backup in apps, then restore the snapshot into a new data directory and repoint the etcd static pod at it. The Deployment created after the backup must disappear.
    bash
    kubectl -n apps create deploy after-backup --image=nginx:1.27
    
    multipass shell cp
    sudo ETCDCTL_API=3 etcdctl snapshot restore /opt/etcd-backup.db --data-dir /var/lib/etcd-restored
    sudo sed -i 's#path: /var/lib/etcd$#path: /var/lib/etcd-restored#' /etc/kubernetes/manifests/etcd.yaml
    # kubelet notices the manifest change and recreates the etcd pod; watch it come back
    sudo crictl ps | grep etcd
    exit
    
    kubectl -n apps get deploy      # after-backup is gone; web is back
    Check: after-backup no longer exists; the cluster is healthy. Time the whole backup-and-restore sequence and log it. Repeat the upgrade and restore drills twice more on fresh clusters.
Phase 5

Break it, fix it

Five realistic sabotage scripts and the procedure that finds each cause from symptoms, timed.

  1. Write the break scripts. Each one changes a single thing an exam scenario might; you run one without reading it, then diagnose from the symptoms.
    bash
    # breaks/break-kubelet.sh (run on w1 as root): wrong kubelet config path
    sed -i 's#/var/lib/kubelet/config.yaml#/var/lib/kubelet/config.yml#' /var/lib/kubelet/kubeadm-flags.env 2>/dev/null || \
      sed -i 's#config.yaml#config.yml#' /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf
    systemctl daemon-reload && systemctl restart kubelet
    
    # breaks/break-apiserver.sh (run on cp as root): a typo in a flag
    sed -i 's/--etcd-servers=/--etcd-server=/' /etc/kubernetes/manifests/kube-apiserver.yaml
    
    # breaks/break-scheduler.sh (run on cp as root): scheduler cannot start
    sed -i 's/kube-scheduler/kube-schedulerr/' /etc/kubernetes/manifests/kube-scheduler.yaml
    
    # breaks/break-service.sh (run anywhere with kubectl): selector mismatch
    kubectl -n apps patch svc web -p '{"spec":{"selector":{"tier":"frontendd"}}}'
    
    # breaks/break-cni.sh (run on w1 as root): CNI config removed
    mv /etc/cni/net.d /etc/cni/net.d.bak && systemctl restart kubelet
    Keep the scripts in the repo but do not memorise them; the value is in diagnosing from k get nodes, k describe, journalctl -u kubelet, crictl ps -a and crictl logs as the CKA guide's procedure describes.
  2. Run each break, diagnose, fix, and log the time. The expected symptoms and the tools that reveal the cause:
    text
    break-kubelet    symptom: w1 NotReady            find: journalctl -u kubelet on w1 -> 'config.yml: no such file'
                     fix: correct the path, daemon-reload, restart kubelet
    break-apiserver  symptom: kubectl times out       find: on cp: crictl ps -a (apiserver exited), crictl logs CID -> unknown flag
                     fix: correct the manifest; kubelet restarts the static pod
    break-scheduler  symptom: new pods stay Pending    find: k -n kube-system get pods (no scheduler), crictl ps -a on cp
                     with no events               fix: correct the image name in the manifest
    break-service    symptom: curl to web fails       find: k -n apps get ep web -> <none>; compare selector with pod labels
                     fix: patch the selector back
    break-cni        symptom: new pods on w1 stuck    find: k describe pod -> 'network plugin not ready'; ls /etc/cni/net.d on w1
                     ContainerCreating        fix: restore the directory, restart kubelet
    Check: All five fixed; each under ten minutes; the times in LOG.md.
  3. Write five more breaks of your own (a wrong --advertise-address, a missing kube-proxy DaemonSet, a full disk on a node via a large file, an expired-looking certificate path, a bad imagePullPolicy with a private image) and have a study partner run them for you — the diagnosis is only real when you do not know what changed.
    Check: Ten breaks total, each diagnosed with the procedure rather than by guessing.
  4. Run a full mock: rebuild the cluster on the previous minor version, then in one two-hour sitting do fifteen tasks drawn from every file plus one break, one upgrade and one etcd restore, with a timer and the docs as the only reference. Score yourself on end state.
    Check: Above 66 % of tasks fully correct in the time. If not, the LOG.md redo list tells you exactly what to drill before the real exam, and the killer.sh sessions that come with registration are the final check.
Help

Troubleshooting

kubeadm init fails with a preflight error about swap or bridge-nf-call-iptables
The node-setup script disables swap and sets the sysctls; if you rebuilt the VM without rerunning it, run it again. kubeadm init --ignore-preflight-errors=... hides the problem rather than fixing it.
Nodes stay NotReady after init
The CNI is not installed or not running yet. kubectl -n kube-system get pods should show calico pods; kubectl describe node cp shows network plugin not ready until they are Running.
The worker cannot join: token or connection refused
Tokens expire after 24 hours; generate a new join command with kubeadm token create --print-join-command on cp. Confirm the worker can reach the cp IP on 6443 (nc -zv CP_IP 6443).
kubectl from the laptop reports a certificate error for the VM IP
The API server certificate includes the advertise address given at init; if the VM's IP changed after a restart, either re-init or set --apiserver-cert-extra-sans. Practising from inside the cp VM avoids this entirely.
etcdctl not found
Install etcd-client from apt, or download the matching etcd release tarball and use its etcdctl; the exam environment provides the binary.
After the etcd restore, the API server stays down
Check that the hostPath in etcd.yaml points at the restored directory and that the directory is owned correctly; crictl logs on the etcd container shows the reason. If the manifest was edited with a typo, kubelet will not start the pod at all — journalctl -u kubelet says why.
Multipass VMs are slow or fail to start on macOS
Reduce the memory allocations, close other VMs, and check multipass get local.driver; on Apple Silicon the default driver is fine, on Intel Macs HyperKit or QEMU may need selecting.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →