Certifications · CKA · Practice Tasks

CKA Practice Tasks

The CKA has no multiple choice. You are handed a live cluster, a terminal, and a task — "drain this node and upgrade it," "this Service can't reach its Pods, fix it" — and you are graded entirely on the end state you leave behind, not on how you got there. Reading about kubeadm or NetworkPolicy does not build that reflex; only typing the commands, watching them fail, and fixing them does. This page is a bank of 20 realistic, timed practice tasks pulled from all five CKA domains, weighted the way the real exam is weighted: six on Troubleshooting (30%), five on Cluster Architecture, Installation & Configuration (25%), four on Services & Networking (20%), three on Workloads & Scheduling (15%), and two on Storage (10%). Every task ships with a full worked solution — the actual YAML or command sequence, plus the reasoning behind it — but the solution is the answer key, not the lesson. The lesson is the five minutes before you look at it.

☺ Explain it like I'm 10

Imagine a fire-drill instructor who never once asks you to describe how a fire extinguisher works. Instead they set a small, supervised fire, hand you the extinguisher, and start a stopwatch. You either put it out or you don't — reciting the theory afterward earns nothing. The CKA is that instructor. Every task on this page is a small, contained fire: a control plane that won't start, a Service nobody can reach, a node that quietly ran out of disk. You get a scenario, a goal, and a clock. The worked solution folded underneath each one is the report written after the fire is out — useful for checking your work, useless as the first thing you read.

🦫🐢Your hosts for this topic: Benny the Beaver & Timmy the Turtle — Benny sets every task and won't call one done until the "done when" check actually passes; Timmy holds the stopwatch and sends you back to redo the ones you rushed.

How to drill this bank

☺ Like you're 10: Try each task yourself first, with a timer running — only lift the flap once you're stuck or finished, or your brain learns "I can read a solution," not "I can do this."

Reading a worked solution feels like learning and mostly isn't: the exam never asks you to recognize correct YAML, it asks you to produce it, cold, under a clock. Four rules make this bank behave like the real thing instead of a comfortable read.

One — start cold. Empty terminal, no leftover manifests from a previous attempt, no tab open on the solution. That is exactly how the exam starts you; starting warm just inflates the score you write down.

Two — time-box to five to seven minutes per task. Close to the real per-task budget on a two-hour, 15–20-task paper. When the timer rings, stop, note where you stalled, and move on — abandoning a task on schedule is itself a scored skill.

Three — verify with the "done when" line, never with your eyes. A manifest that looks right and a manifest that kubectl describe confirms is running are different claims. Only the second one would have scored anything on the real exam.

Four — redo every miss the next day, cold. A task you got wrong is worth more than ten you got right the first time. Keep a short list; cross an item off only once it runs clean with no notes open.

◆ Key idea

Practice doc navigation, not memorization. The official Kubernetes documentation is open in the real exam, so getting to the page with the example you need in twenty seconds beats half-remembering a manifest from a video. While you drill, bookmark the exact doc page each task sends you to — the kubectl fluency baseline covers the imperative shortcuts that turn a stalled task into a finished one.

The five domains, weighted like the real exam

☺ Like you're 10: The exam isn't five equal slices, so this bank isn't either — the two biggest domains get the most tasks, on purpose.

These are the same five domains and weights published in the CNCF's Certified Kubernetes Administrator (CKA) Exam Curriculum, version 1.35, that the Troubleshooting blueprint page and the CKA study plan use. The task count below follows the same proportion: six tasks for a 30% domain, two for a 10% one.

🦊Troubleshooting — 6 tasks (T1–T6)
30%
🦉Cluster Architecture, Installation & Configuration — 5 tasks (C1–C5)
25%
🐦Services & Networking — 4 tasks (N1–N4)
20%
🦫Workloads & Scheduling — 3 tasks (W1–W3)
15%
🐘Storage — 2 tasks (S1–S2)
10%

The tasks also lean on each other the way a real cluster does — C5 installs metrics-server, which W3 and T4 both then depend on; N3 hardens a NetworkPolicy that T6 later has to debug. Work them roughly in order the first time through and the bank reads like one continuing incident, not twenty disconnected quizzes. Jump straight to a domain:

Cluster Architecture, Installation & Configuration — 25%

☺ Like you're 10: This is the "keep the building itself standing" domain — the wiring, the foundations, who's allowed to touch what.

Five tasks: bootstrapping and upgrading a cluster with kubeadm, backing up and restoring etcd, scoping RBAC to exactly one namespace, and installing a real add-on with Helm. Background reading: the D1 blueprint page, control-plane internals, and the kubeadm / Helm tool guides.

C1 · Bootstrap a control plane and join two workers with kubeadm

You have three bare Linux hosts — cp-1, worker-1, worker-2 — with a container runtime already installed and no cluster yet. Stand one up from nothing.

Your task:

  1. Initialize the control plane on cp-1 with pod network CIDR 10.244.0.0/16.
  2. Configure kubectl access for the admin user the init output names.
  3. Install a CNI that matches that CIDR so nodes can actually leave NotReady.
  4. Join both workers using the token and discovery hash kubeadm init printed.

Done when: kubectl get nodes lists all three nodes as Ready.

Show the worked solution
# on cp-1 — kubeadm's preflight checks still fail with swap on unless you've
# deliberately enabled the kubelet's swap support, so this is not optional
swapoff -a
kubeadm init --pod-network-cidr=10.244.0.0/16

# as the admin user the init output names
mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config

# install a CNI that matches the CIDR above — nodes stay NotReady with none installed
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

# on EACH worker — the exact command kubeadm init printed, token and hash included
kubeadm join cp-1:6443 --token <token> \
  --discovery-token-ca-cert-hash sha256:<hash>

# back on cp-1
kubectl get nodes -o wide

Why: a cluster with no CNI schedules Pods but never gets them networking, which shows up as nodes stuck NotReady and every Pod stuck Pending or ContainerCreating forever — a fresh kubeadm cluster deliberately ships with no default network plugin so you choose one. If a join token has expired by the time you get to a worker, kubeadm token create --print-join-command on cp-1 regenerates a fresh one without another full init.

C2 · Take an etcd snapshot, then restore from it

Before a risky change to the cluster, take a consistent backup of etcd — the only place Kubernetes state actually lives — and prove you can bring a cluster back from it.

Your task:

  1. Take a TLS-authenticated snapshot to /opt/backups/etcd-snapshot.db.
  2. Confirm the snapshot is valid before you trust it.
  3. Restore it into a fresh data directory and point etcd's static pod at that directory.

Done when: etcdctl snapshot status reports a valid hash and revision for the file, and after the restore, kubectl get pods -A on the recovered cluster shows the same workloads that existed when the snapshot was taken.

Show the worked solution
# take the snapshot — TLS material lives under /etc/kubernetes/pki/etcd on a kubeadm cluster
ETCDCTL_API=3 etcdctl snapshot save /opt/backups/etcd-snapshot.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

# sanity-check it before you trust it with anyone's cluster
etcdctl snapshot status /opt/backups/etcd-snapshot.db --write-out=table

# restore never talks to the running etcd — it writes a fresh data dir offline, so no --endpoints
# etcd 3.6 moved "snapshot restore" out of etcdctl and into etcdutl; check which binary your build ships
etcdutl snapshot restore /opt/backups/etcd-snapshot.db --data-dir /var/lib/etcd-restored

# point the etcd static pod manifest at the new data dir, then let the kubelet restart it
sed -i 's#/var/lib/etcd#/var/lib/etcd-restored#' /etc/kubernetes/manifests/etcd.yaml
crictl ps -a --name etcd   # confirm it actually restarted against the new directory

Why: etcd is the single source of truth behind every other Kubernetes object — the API server, the scheduler and every controller are effectively stateless caches on top of it. A restore is not "apply the file back"; it replaces the entire data directory offline, which is exactly why it needs no live endpoint and why the static pod has to be pointed at the new path afterward.

C3 · Upgrade a control plane and a worker across a minor version

Your cluster is on v1.34.x. Upgrade the control-plane node, then one worker, to v1.35.0 — without ever taking the whole cluster down and without losing the Pods already running on that worker.

Your task:

  1. Upgrade kubeadm, kubelet and kubectl on the control-plane node, applying the upgrade with kubeadm itself.
  2. Drain the worker, upgrade its kubeadm, kubelet and kubectl, then bring it back into rotation.

Done when: kubectl get nodes shows both nodes on v1.35.0 and Ready, and every Pod that was running on the worker before the drain is running again afterward.

Show the worked solution
# --- control-plane node first ---
apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.35.0-1.1 && apt-mark hold kubeadm
kubeadm upgrade plan
kubeadm upgrade apply v1.35.0

kubectl drain cp-1 --ignore-daemonsets
apt-mark unhold kubelet kubectl   # kubeadm holds these packages — skip this and the install below is a no-op
apt-get install -y kubelet=1.35.0-1.1 kubectl=1.35.0-1.1
apt-mark hold kubelet kubectl
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon cp-1

# --- then the worker, same shape but "kubeadm upgrade node" instead of "apply" ---
apt-mark unhold kubeadm && apt-get install -y kubeadm=1.35.0-1.1 && apt-mark hold kubeadm
kubeadm upgrade node

kubectl drain worker-1 --ignore-daemonsets --delete-emptydir-data
apt-mark unhold kubelet kubectl
apt-get install -y kubelet=1.35.0-1.1 kubectl=1.35.0-1.1
apt-mark hold kubelet kubectl
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon worker-1

kubectl get nodes -o wide

Why: kubeadm upgrade apply only runs on the first control-plane node — every other node, control-plane or worker, uses kubeadm upgrade node instead. The drain-before-upgrade-kubelet, uncordon-after order matters twice: skip the drain and you upgrade a kubelet out from under running Pods; forget the uncordon and the node silently never gets scheduled onto again, which is the single most common self-inflicted outage in this domain.

C4 · Scope a ServiceAccount to one namespace with RBAC

A CI pipeline needs a ServiceAccount called ci-deployer that can read Pods and manage Deployments inside namespace apps — and nothing anywhere else in the cluster.

Your task:

  1. Create the ServiceAccount in apps.
  2. Create a Role granting get/list/watch on pods and full control of deployments, scoped to apps.
  3. Bind it with a RoleBinding, not a ClusterRoleBinding.

Done when: kubectl auth can-i create deployments -n apps --as=system:serviceaccount:apps:ci-deployer returns yes, and the same check with -n default or against secrets returns no.

Show the worked solution
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci-deployer
  namespace: apps
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployer
  namespace: apps
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: deployer-binding
  namespace: apps
subjects:
  - kind: ServiceAccount
    name: ci-deployer
    namespace: apps
roleRef:
  kind: Role
  name: deployer
  apiGroup: rbac.authorization.k8s.io
kubectl apply -f rbac.yaml
kubectl auth can-i create deployments -n apps --as=system:serviceaccount:apps:ci-deployer   # yes
kubectl auth can-i create deployments -n default --as=system:serviceaccount:apps:ci-deployer # no — wrong namespace
kubectl auth can-i delete secrets -n apps --as=system:serviceaccount:apps:ci-deployer        # no — not in the Role

Why: a Role plus RoleBinding is namespace-scoped by construction — the same rules attached with a ClusterRoleBinding instead would grant them everywhere, which is the exact mistake this task exists to catch. kubectl auth can-i --as= is the fastest way to prove a permission boundary without deploying an actual Pod to test it from inside.

C5 · Install a cluster add-on with Helm and verify it

Nothing in this cluster reports live resource usage yet, and two later tasks in this bank need it to. Install metrics-server with Helm, with the flag it needs to trust self-signed kubelet certificates on a self-managed cluster.

Your task:

  1. Add the metrics-server Helm repository and update it.
  2. Install it into kube-system with --kubelet-insecure-tls set.
  3. Confirm it's actually serving metrics.

Done when: kubectl top nodes returns real CPU/memory numbers instead of an error.

Show the worked solution
helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/
helm repo update

helm install metrics-server metrics-server/metrics-server \
  -n kube-system \
  --set args={--kubelet-insecure-tls}

kubectl rollout status deploy/metrics-server -n kube-system
kubectl top nodes
kubectl top pods -A --sort-by=memory

Why: kubectl top and every HorizontalPodAutoscaler in the cluster depend on this one add-on — it isn't built in. --kubelet-insecure-tls is specifically an escape hatch for self-managed clusters whose kubelets present certificates metrics-server doesn't otherwise trust; a managed cloud cluster with properly chained kubelet certs usually doesn't need it, and the exam scenario should tell you which situation you're in.

Workloads & Scheduling — 15%

☺ Like you're 10: This is the "how many, which ones, and where do they sit" domain — the actual apps, not the building they live in.

Three tasks: tuning and rolling back a Deployment, wiring config in through ConfigMaps and Secrets, and controlling exactly where and how elastically Pods run. Background: the D2 blueprint page, scheduling & resource management, and autoscaling.

W1 · Tune a rolling update, then roll back to a specific revision

Deployment web runs six replicas of v1 cleanly. Roll out v2 with a tighter update strategy, then simulate a bad release and recover from it precisely — not with a blind "undo."

Your task:

  1. Set the rollout strategy to maxUnavailable: 1, maxSurge: 2.
  2. Roll out web:v2 and confirm it finishes cleanly.
  3. Roll out a broken tag, web:v3-typo, and watch the rollout stall.
  4. Roll back to the exact revision that was running v2, using its revision number.

Done when: kubectl rollout status deploy/web reports success, and the container image is back to web:v2.

Show the worked solution
kubectl patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxUnavailable":1,"maxSurge":2}}}}'

kubectl set image deploy/web web=web:v2
kubectl rollout status deploy/web

kubectl set image deploy/web web=web:v3-typo
kubectl rollout status deploy/web --timeout=20s   # times out — ImagePullBackOff, never converges
kubectl rollout history deploy/web                # find the revision number where the image was still v2

kubectl rollout undo deploy/web --to-revision=2   # use the number history actually showed you, not a guess
kubectl rollout status deploy/web
kubectl get deploy web -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'

Why: a bare kubectl rollout undo only steps back one revision, which is wrong the moment more than one rollout happened since the last good state — reading rollout history and targeting --to-revision explicitly is the only version of "roll back" that's actually correct here. maxUnavailable/maxSurge control how disruptive the rollout itself is allowed to be while it's in flight, independent of whether the new image is good.

W2 · ConfigMaps, Secrets, and making a running Pod notice the change

App billing reads DB_HOST from a ConfigMap as an environment variable and a database password from a Secret mounted as a file. After you edit the ConfigMap, the already-running Pods keep using the old value.

Your task:

  1. Create the ConfigMap (key DB_HOST) and Secret (key db-password).
  2. Wire the ConfigMap key in as an env var and mount the Secret as a volume at /etc/billing/secrets.
  3. Edit the ConfigMap's value, then make the running Pods actually pick it up.

Done when: after your fix, kubectl exec into a fresh billing Pod and echo $DB_HOST shows the new value.

Show the worked solution
apiVersion: v1
kind: ConfigMap
metadata: { name: billing-config, namespace: apps }
data:
  DB_HOST: db-primary.apps.svc.cluster.local
---
apiVersion: v1
kind: Secret
metadata: { name: billing-secret, namespace: apps }
type: Opaque
stringData:
  db-password: correct-horse-battery-staple
---
# in the Deployment's pod template
env:
  - name: DB_HOST
    valueFrom:
      configMapKeyRef: { name: billing-config, key: DB_HOST }
volumeMounts:
  - name: db-secret
    mountPath: /etc/billing/secrets
    readOnly: true
volumes:
  - name: db-secret
    secret: { secretName: billing-secret }
kubectl apply -f config.yaml
kubectl -n apps set env deploy/billing --from=configmap/billing-config --overwrite   # or via apply
kubectl -n apps patch configmap billing-config --type merge -p '{"data":{"DB_HOST":"db-replica.apps.svc.cluster.local"}}'
kubectl -n apps rollout restart deploy/billing        # new Pods, new env — the kubelet never hot-reloads env vars
kubectl -n apps rollout status deploy/billing
kubectl -n apps exec deploy/billing -- env | grep DB_HOST

Why: environment variables are injected once, at container start — editing a ConfigMap never touches a Pod that's already running, even though a mounted ConfigMap volume would eventually sync on its own. rollout restart is the standard fix: it replaces every Pod with a fresh one that reads the current ConfigMap at start time.

W3 · Pin, spread, and scale: affinity, a PodDisruptionBudget, and an HPA

billing must run only on nodes labeled workload=payments that carry taint dedicated=payments:NoSchedule, must never be allowed to drop below one running replica during a voluntary node drain, and should scale itself between 2 and 8 replicas under CPU load.

Your task:

  1. Add a toleration for the taint and a nodeAffinity requiring the label.
  2. Add a PodDisruptionBudget with minAvailable: 1.
  3. Add a HorizontalPodAutoscaler targeting 60% CPU, min 2 / max 8 (this needs C5's metrics-server already installed).

Done when: every billing Pod schedules only on the labeled, tainted node; kubectl get pdb shows the budget; and kubectl get hpa shows a real current CPU percentage, not <unknown>.

Show the worked solution
# in the Deployment's pod template
spec:
  tolerations:
    - key: dedicated
      operator: Equal
      value: payments
      effect: NoSchedule
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - { key: workload, operator: In, values: ["payments"] }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: billing-pdb, namespace: apps }
spec:
  minAvailable: 1
  selector: { matchLabels: { app: billing } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: billing-hpa, namespace: apps }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: billing }
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
kubectl label node worker-2 workload=payments
kubectl taint node worker-2 dedicated=payments:NoSchedule
kubectl apply -f billing-placement.yaml -f billing-pdb.yaml -f billing-hpa.yaml
kubectl get pods -n apps -l app=billing -o wide      # every one on worker-2
kubectl get pdb -n apps
kubectl get hpa -n apps -w                            # real % once load hits, not <unknown>

Why: the taint and the affinity rule are doing two different jobs that people conflate — the taint keeps other workloads off this node, the affinity keeps this workload from landing anywhere else; you need both or a stray Pod can still sneak on and billing can still schedule elsewhere. The PDB is what actually makes kubectl drain respect "never below one replica" instead of just evicting everything at once.

Services & Networking — 20%

☺ Like you're 10: This is the "how does a request actually get from one place to the right place" domain.

Four tasks: exposing a Deployment two different ways, routing with Ingress and with the newer Gateway API, and a default-deny NetworkPolicy that still leaves DNS working. Background: the D3 blueprint page, networking & the CNI, and the ingress-nginx / Cilium tool guides.

N1 · Expose a Deployment with ClusterIP and NodePort, and prove the endpoints

catalog (3 replicas, label app=catalog, container port 8080) needs internal access on port 80, and an externally reachable NodePort at 30080 for a health-check tool that lives outside the cluster.

Your task:

  1. Create a ClusterIP Service catalog-internal, port 80 → target 8080.
  2. Create a NodePort Service catalog-external, node port 30080 → port 80 → target 8080.
  3. Confirm both actually have live Pods behind them.

Done when: kubectl get endpointslices -l kubernetes.io/service-name=catalog-internal lists three ready addresses, and a curl from outside the cluster to any node's IP on :30080 succeeds.

Show the worked solution
apiVersion: v1
kind: Service
metadata: { name: catalog-internal, namespace: apps }
spec:
  selector: { app: catalog }
  ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: catalog-external, namespace: apps }
spec:
  type: NodePort
  selector: { app: catalog }
  ports: [{ port: 80, targetPort: 8080, nodePort: 30080 }]
kubectl apply -f catalog-svc.yaml
kubectl -n apps get endpointslices -l kubernetes.io/service-name=catalog-internal
kubectl -n apps get svc catalog-external -o wide
curl http://<any-node-ip>:30080/healthz

Why: a Service never routes traffic by itself — it's a stable name that kube-proxy programs into rules pointing at whatever Pods the selector currently matches, tracked live in an EndpointSlice. Checking the slice, not just the Service spec, is the only way to prove the wiring actually reaches real Pods rather than an empty selector.

N2 · Route two paths through one Ingress, with TLS

Route shop.example.internal/ to Service storefront and shop.example.internal/api to Service catalog-internal, both terminated with an existing TLS secret shop-tls.

Your task:

  1. Write one Ingress resource, class nginx, with both path rules.
  2. Attach the tls block referencing shop-tls.

Done when: kubectl describe ingress shop shows both backends resolved to real endpoints, not <error: endpoints not available>, and curl -k https://shop.example.internal/api reaches catalog-internal.

Show the worked solution
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  namespace: apps
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["shop.example.internal"]
      secretName: shop-tls
  rules:
    - host: shop.example.internal
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend: { service: { name: catalog-internal, port: { number: 80 } } }
          - path: /
            pathType: Prefix
            backend: { service: { name: storefront, port: { number: 80 } } }
kubectl apply -f shop-ingress.yaml
kubectl describe ingress shop -n apps
curl -k --resolve shop.example.internal:443:<ingress-controller-ip> https://shop.example.internal/api

Why: path order matters — /api has to be listed before the catch-all /, or the broader rule can shadow the more specific one depending on the controller's matching order. "Backends resolved" in describe output is the proof step people skip; an Ingress with a perfectly correct spec still routes nowhere if its target Service has no ready endpoints.

N3 · Default-deny a namespace, then open exactly two holes

Namespace payments must default-deny all ingress and egress. On top of that: allow ingress to billing on 8080 only from namespace frontend, and make sure DNS still works.

Your task:

  1. A default-deny NetworkPolicy selecting every Pod in payments, both directions.
  2. An allow rule: ingress to billing from Pods in frontend, TCP 8080 only.
  3. An allow rule: egress from every Pod in payments to CoreDNS, UDP and TCP 53.

Done when: a Pod in frontend can wget billing.payments:8080; a Pod in a third, unrelated namespace cannot; and from inside billing, nslookup kubernetes.default still resolves.

Show the worked solution
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: payments }
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-frontend-to-billing, namespace: payments }
spec:
  podSelector: { matchLabels: { app: billing } }
  policyTypes: [Ingress]
  ingress:
    - from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: frontend } } }]
      ports: [{ protocol: TCP, port: 8080 }]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: payments }
spec:
  podSelector: {}
  policyTypes: [Egress]
  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 }]
kubectl apply -f payments-netpol.yaml
kubectl -n frontend run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- billing.payments:8080
kubectl -n other-ns run tmp --rm -it --restart=Never --image=busybox:1.36 -- timeout 3 wget -qO- billing.payments:8080  # should fail
kubectl -n payments exec deploy/billing -- nslookup kubernetes.default

Why: NetworkPolicies are additive, never overriding — a Pod's effective rules are the union of every policy that selects it, which is exactly how a namespace-wide default-deny and a narrow allow rule coexist. The DNS rule is the one people forget: any policy naming an Egress section flips that Pod to deny-by-default on egress, including to CoreDNS, so without an explicit UDP/TCP 53 allow, every name lookup from payments silently breaks.

N4 · Route the same split with the Gateway API instead of Ingress

Reproduce N2's / and /api split, but expressed through the newer Gateway API rather than Ingress — the curriculum names it explicitly alongside Ingress.

Your task:

  1. Create a Gateway named shop-gw referencing an existing GatewayClass.
  2. Create an HTTPRoute attached to it with two path matches routing to storefront and catalog-internal.

Done when: kubectl get httproute shop-route -o yaml shows a status.parents[].conditions entry of type Accepted: True, and requests through the Gateway's address hit the correct backend per path.

Show the worked solution
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: shop-gw, namespace: apps }
spec:
  gatewayClassName: nginx
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes: { namespaces: { from: Same } }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: shop-route, namespace: apps }
spec:
  parentRefs: [{ name: shop-gw }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /api } }]
      backendRefs: [{ name: catalog-internal, port: 80 }]
    - matches: [{ path: { type: PathPrefix, value: / } }]
      backendRefs: [{ name: storefront, port: 80 }]
kubectl apply -f shop-gateway.yaml -f shop-route.yaml
kubectl get gateway shop-gw -n apps
kubectl get httproute shop-route -n apps -o yaml | grep -A3 conditions
GW_IP=$(kubectl get gateway shop-gw -n apps -o jsonpath='{.status.addresses[0].value}')
curl http://$GW_IP/api

Why: the Gateway API splits the role Ingress bundled into one object — a Gateway owns the listener (the "front door"), an HTTPRoute owns the routing rules, and they can be managed by entirely different teams. A route that never reaches Accepted: True usually means its parentRefs name doesn't match an actual Gateway, or the Gateway's controller isn't installed at all.

Storage — 10%

☺ Like you're 10: The smallest domain and the most mechanical one — mostly proving you know exactly when a disk gets created, and what happens to it after.

Two tasks: dynamic provisioning with a deliberate binding delay and a Retain policy, and a static-PV binding puzzle you have to diagnose before you can fix. Background: the D4 blueprint page and storage & the CSI.

S1 · Dynamic provisioning: WaitForFirstConsumer and a Retain policy

reports needs a 5Gi ReadWriteOnce volume that provisions only once a Pod actually needs it — so the scheduler picks a zone with capacity first — and the underlying disk must survive even if someone deletes the PVC by mistake.

Your task:

  1. Create a StorageClass with volumeBindingMode: WaitForFirstConsumer and reclaimPolicy: Retain.
  2. Create a PVC against it and a Pod that mounts it.
  3. Delete the PVC and show the underlying volume survives.

Done when: the PVC sits Pending until the Pod exists, then binds; and after you delete the PVC, kubectl get pv shows the volume as Released, not gone.

Show the worked solution
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: retained-ssd }
provisioner: ebs.csi.aws.com   # swap for whatever CSI driver the cluster actually runs
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
parameters: { type: gp3 }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: reports-data, namespace: apps }
spec:
  storageClassName: retained-ssd
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 5Gi } }
kubectl apply -f sc.yaml -f pvc.yaml
kubectl -n apps get pvc reports-data   # Pending — no consumer yet, by design

kubectl -n apps run reports --image=busybox:1.36 --restart=Never \
  --overrides='{"spec":{"containers":[{"name":"reports","image":"busybox:1.36","command":["sleep","3600"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}],"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"reports-data"}}]}}'
kubectl -n apps get pvc reports-data   # Bound, now that a Pod names it

kubectl -n apps delete pvc reports-data
kubectl get pv | grep retained-ssd     # Released, not deleted — the disk itself is still there

Why: WaitForFirstConsumer exists specifically so the scheduler's placement decision and the volume's zone match — provisioning immediately on PVC creation can pick a zone with no room for the Pod that eventually needs it. Retain trades convenience for safety: the disk survives claim deletion, but it also doesn't automatically become available to a new claim — that's a deliberate manual step, not a bug.

S2 · Diagnose a stuck PVC, then bind it to the right PV on purpose

A pre-provisioned PersistentVolume named local-data-pv already exists (10Gi, ReadWriteOnce, labeled tier: cache) alongside several other PVs of similar size. A PVC someone wrote isn't binding. Find out why, fix it, and make sure it binds to this PV specifically, not whichever one happens to match first.

Your task:

  1. Diagnose why the existing PVC won't bind.
  2. Fix the mismatch.
  3. Add a label selector so it targets local-data-pv by name, not by accident.

Done when: kubectl get pvc shows Bound, and kubectl get pvc <name> -o jsonpath='{.spec.volumeName}' is exactly local-data-pv.

Show the worked solution
kubectl -n apps describe pvc cache-claim
#   Events: ... waiting for a volume to be created ... — or ...
#   no persistent volumes available for this claim and no storage class is set
kubectl get pv local-data-pv -o yaml | grep -A2 accessModes   # PV offers ReadWriteOnce
kubectl -n apps get pvc cache-claim -o yaml | grep -A2 accessModes   # claim asked for ReadWriteMany — the mismatch
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: cache-claim, namespace: apps }
spec:
  accessModes: [ReadWriteOnce]      # fixed, to match what the PV actually offers
  resources: { requests: { storage: 10Gi } }
  selector:
    matchLabels: { tier: cache }    # pins the bind to local-data-pv, not any same-size PV
kubectl apply -f cache-claim.yaml
kubectl -n apps get pvc cache-claim
kubectl -n apps get pvc cache-claim -o jsonpath='{.spec.volumeName}{"\n"}'

Why: a PVC binds only to a PV whose accessModes, capacity, and (if set) storageClassName all satisfy the request — asking for ReadWriteMany against a ReadWriteOnce-only PV leaves it Pending forever with no error, only an event. A label selector is what turns "bind to some matching PV" into "bind to this one," which matters the instant more than one PV could otherwise qualify.

Troubleshooting — 30%

☺ Like you're 10: The biggest slice, and the one with the fewest new nouns — it's the same describe-then-logs-then-exec method, six different fires.

Six tasks, matching the domain's own weight: a dead node, a control plane that won't start, a crash loop, resource-usage triage, an empty Service, and a capstone that chains DNS, CoreDNS and a NetworkPolicy together. The method behind all six — describe before logs, logs before exec, node before app — is the subject of the D5 blueprint page and a troubleshooting methodology; run the Fix a Broken Cluster and Diagnose a Networking Failure drills for more of exactly this.

T1 · Recover a node stuck NotReady

worker-2 has been NotReady for ten minutes. Nothing about the node was physically touched. Find out why and bring it back — without a reboot.

Your task:

  1. Check the node's Conditions from kubectl first.
  2. SSH in and find out why the kubelet stopped reporting.
  3. Fix it and confirm the node rejoins on its own.

Done when: kubectl get node worker-2 shows Ready, with every pressure Condition back to False.

Show the worked solution
kubectl describe node worker-2 | grep -A6 Conditions
#   Ready   Unknown   ...  — the control plane has simply stopped hearing from this node's kubelet

ssh worker-2
systemctl status kubelet          # failed — exited immediately
journalctl -u kubelet -n 40 --no-pager
#   Error: failed to parse kubelet flag: unknown flag --max-pod --- (a bad edit landed here)
cat /var/lib/kubelet/kubeadm-flags.env
#   KUBELET_KUBEADM_ARGS="--container-runtime-endpoint=... --max-pod=200"   # should be --max-pods

sed -i 's/--max-pod=/--max-pods=/' /var/lib/kubelet/kubeadm-flags.env
systemctl daemon-reload && systemctl restart kubelet
systemctl status kubelet          # active (running)

Why: Ready: Unknown specifically means the control plane has lost contact with the node's kubelet entirely — different from NotReady with a named Condition like DiskPressure, where the kubelet is talking and actively reporting a real resource problem. A kubelet that fails to even start (bad flag, corrupt config) never gets the chance to report anything, which is exactly what Unknown looks like from the API side.

T2 · The API server itself won't come up

Nothing responds to kubectl at all — not slow, not erroring cleanly, just refused. Recover the control plane using only the control-plane node's own filesystem and logs.

Your task:

  1. SSH to the control-plane node — kubectl is useless here since it's the thing that's down.
  2. Find out why the kubelet won't keep kube-apiserver running as a static pod.
  3. Fix the manifest and let the kubelet restart it on its own.

Done when: from a machine with a valid kubeconfig, kubectl get nodes works again, and crictl ps on the node shows kube-apiserver Running.

Show the worked solution
ssh cp-1
journalctl -u kubelet -n 60 --no-pager | grep -i apiserver
crictl ps -a --name kube-apiserver
#   Exited (1) — crash, not "never scheduled"
crictl logs $(crictl ps -a --name kube-apiserver -q)
#   invalid argument "true1" for "--enable-admission-plugins" flag  — a typo, not "true"

cat /etc/kubernetes/manifests/kube-apiserver.yaml | grep enable-admission-plugins
vi /etc/kubernetes/manifests/kube-apiserver.yaml   # fix the typo'd flag value directly
# no "kubectl apply" exists for a static pod — the kubelet notices the file change on its own within seconds

crictl ps --name kube-apiserver   # Running
kubectl get nodes                  # from a machine with valid kubeconfig — works again

Why: the API server, etcd, scheduler and controller-manager run as static Pods the kubelet drives directly from manifest files, with no API server involved in that specific loop — which is precisely what makes a broken API server recoverable at all. A malformed static pod manifest produces no Kubernetes-level error anywhere kubectl could show it, because there's no API server yet to report one; the kubelet's own journal is the only place that failure is visible.

T3 · A CrashLoopBackOff with an empty-looking log

worker-svc Pods are stuck CrashLoopBackOff. kubectl logs on them shows nothing useful. Find the real cause.

Your task:

  1. Work the describe → logs → --previous sequence, in that order.
  2. Identify the actual misconfiguration.
  3. Fix it and confirm the Pod stays up.

Done when: kubectl get pods -l app=worker-svc shows Running with restarts no longer climbing after two minutes, and the Pod's address appears as ready in its EndpointSlice.

Show the worked solution
kubectl describe pod -l app=worker-svc -n apps | tail -20
#   Last State: Terminated, Reason: Error, Exit Code: 1
kubectl logs -n apps -l app=worker-svc                # the NEW container — hasn't failed yet, so: nothing useful
kubectl logs -n apps -l app=worker-svc --previous      # the one that actually died
#   FATAL: environment variable PORT is required but not set

kubectl -n apps get deploy worker-svc -o yaml | grep -A2 "name: PORT\|APP_PORT"
#   the ConfigMap key was renamed to APP_PORT in a recent edit; the Deployment still requests PORT
kubectl -n apps set env deploy/worker-svc --from=configmap/worker-svc-config --overwrite
kubectl -n apps rollout status deploy/worker-svc

Why: the single most common miss under time pressure is reading kubectl logs with no flags on a Pod that already restarted — you're looking at the brand-new container that hasn't failed yet, not the one that did. --previous targets the last terminated instance specifically, which is where a crash's actual reason lives.

T4 · Tell a scheduling problem apart from an eviction problem

Two Deployments are both "broken" in different ways at once: analytics Pods are stuck Pending; cache Pods keep restarting. Diagnose each correctly and fix it — the two causes are not the same, and the fix for one does nothing for the other.

Your task:

  1. Diagnose analytics using describe Events and kubectl top (from C5).
  2. Diagnose cache the same way, but read its container's last state, not its Events.
  3. Fix both, correctly matched to their actual cause.

Done when: both Deployments reach a steady Running state, and for each one you can name which of requests or limits was the actual cause.

Show the worked solution
# analytics — Pending
kubectl -n apps describe pod -l app=analytics | grep -A3 Events
#   0/3 nodes are available: 3 Insufficient cpu   — a REQUESTS problem: no node has room to reserve what was asked
kubectl top nodes
kubectl -n apps set resources deploy/analytics --requests=cpu=250m   # was over-requesting at 2 full CPUs
kubectl -n apps get pods -l app=analytics -w

# cache — CrashLoopBackOff
kubectl -n apps describe pod -l app=cache | grep -A2 "Last State"
#   Last State: Terminated, Reason: OOMKilled   — a LIMITS problem: usage exceeded what it was capped to
kubectl -n apps set resources deploy/cache --limits=memory=512Mi   # was capped at 64Mi
kubectl -n apps get pods -l app=cache -w

Why: requests only ever affect the scheduler's placement decision — an Insufficient cpu event means no node could fit the reservation. Limits only ever affect eviction — a container that exceeds its memory limit is OOMKilled by the kernel, not gently throttled. A question that names one and expects an answer about the other is testing exactly this asymmetry, and it's a favorite exam trap for a reason.

T5 · A Service with a correct spec and no endpoints

orders Service looks completely correct — right selector, right port — but nothing can reach it. Find the real mismatch and fix it.

Your task:

  1. Confirm the EndpointSlice is actually empty.
  2. Compare the Service's selector against what the Pods are really labeled.
  3. Fix the mismatch.

Done when: kubectl get endpointslices -l kubernetes.io/service-name=orders lists all three running Pods as ready addresses.

Show the worked solution
kubectl -n apps get svc orders -o wide
kubectl -n apps get endpointslices -l kubernetes.io/service-name=orders   # empty
kubectl -n apps describe svc orders | grep Selector                       # Selector: tier=backend,app=orders
kubectl -n apps get pods --show-labels | grep orders
#   orders-7d... app=orders                       — "tier=backend" is missing entirely

kubectl -n apps get deploy orders -o yaml | grep -B2 -A2 "app: orders"    # confirm the pod template's labels
kubectl -n apps patch deploy orders --type=json \
  -p '[{"op":"add","path":"/spec/template/metadata/labels/tier","value":"backend"}]'
kubectl -n apps rollout status deploy/orders
kubectl -n apps get endpointslices -l kubernetes.io/service-name=orders   # three ready addresses now

Why: a Service's own spec being correct is not the same claim as the Service actually working — the selector has to match Pods that currently exist, and a label dropped from the Deployment's pod template during an unrelated edit produces exactly this symptom: a perfectly valid Service pointing at nothing. Checking the EndpointSlice directly, rather than trusting the Service spec by eye, is what catches it.

T6 · Capstone: DNS breaks after a NetworkPolicy hardening pass

Right after N3's default-deny rollout, billing starts failing every outbound call with a DNS resolution error — even calls to services N3 was never meant to touch. Diagnose the chain rather than guessing: is it the app, DNS itself, or the policy?

Your task:

  1. Confirm from inside billing whether DNS resolves at all.
  2. Confirm CoreDNS itself is healthy.
  3. Inspect the NetworkPolicies selecting billing for what's actually missing.
  4. Fix it without loosening the original N3 restriction on app-to-app traffic.

Done when: kubectl exec into billing and nslookup kubernetes.default plus an external hostname both resolve, and a request from a namespace other than frontend is still blocked exactly as N3 intended.

Show the worked solution
kubectl -n payments exec deploy/billing -- nslookup kubernetes.default
#   ;; connection timed out; no servers could be reached

kubectl -n kube-system get pods -l k8s-app=kube-dns    # CoreDNS itself: Running, 2/2 — not the problem
kubectl -n payments get networkpolicy
kubectl -n payments describe networkpolicy default-deny
#   policyTypes: Egress   — selects every pod, denies all egress by default, no rule allows anything yet
kubectl -n payments get networkpolicy allow-dns-egress -o yaml   # ...does not exist — never applied
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: payments }
spec:
  podSelector: {}
  policyTypes: [Egress]
  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 }]
kubectl apply -f allow-dns-egress.yaml
kubectl -n payments exec deploy/billing -- nslookup kubernetes.default   # resolves now
kubectl -n other-ns run tmp --rm -it --restart=Never --image=busybox:1.36 -- timeout 3 wget -qO- billing.payments:8080
#   still refused — N3's ingress restriction is untouched

Why: this is the same trap as N3, arriving in reverse — a default-deny Egress policy that never got its DNS allow rule applied blocks every outbound name lookup, and the symptom (a generic DNS timeout) looks nothing like "NetworkPolicy," which is exactly why the middle two diagnostic steps — is DNS itself alive, what do the policies actually allow — matter more than guessing from the symptom alone.

🦫 Benny's-eye view

"People want to skip straight to the YAML. I make them read the 'Done when' line out loud before they type anything — if you can't say what proves the task finished, you don't actually know what you're building yet. Half of these tasks I've watched someone 'finish' with a manifest that looks perfect and does nothing, because they never ran the one command that would have shown them the EndpointSlice was still empty."

Score yourself, then build a mock

☺ Like you're 10: Marking your own work honestly is its own skill — the point isn't a number, it's finding out exactly which two or three things to redo tomorrow.

Partial credit is real on the actual exam, so score in bands rather than pass/fail, and read the pattern across all 20 rather than agonizing over any one task.

BandLooks likeWhat it means
CleanDone inside 5–7 minutes, "done when" passed first try, docs used only to confirm a field name.Exam-ready on this one. Move on.
SlowThe right answer, but only after the timer had already rung.A speed problem, not a knowledge gap — drill imperative kubectl shortcuts, not the concept again.
Wrong shapeYou produced something, but the "done when" check fails and you can't immediately see why.Re-read that task's linked blueprint page, then redo the exact task cold tomorrow.
BlankYou didn't recognize which object, field, or command the task even wanted.A genuine knowledge gap. Go to the domain page before you attempt it again.
🐢 Timmy's stopwatch drill

Once every task has been attempted at least once cold, sit all 20 back-to-back under a single two-hour timer for the closest rehearsal this bank offers — read every task first and bank the easy ones before returning to the expensive ones, since sequential order is a trap on the real paper too. When three assembled, all-new mock papers are ready on this course — Set 1, Set 2, and Set 3 — save those for once this bank stops holding any surprises, so they stay genuine dress rehearsals rather than a repeat of tasks you've already memorized.

This bank is timed practice, not the full study path — for the domain-weighted calendar it fits into, see the CKA study plan; for the seven hands-on drills that build the same muscles on a real cluster, see Hands-On Labs; and for the full certification ladder this exam sits on, see Kubernetes Certifications. CKA is also one of the required exams on the CNCF's Kubestronaut ladder — the sibling Golden Astronaut course covers the other nine certifications on that path. For a second telling of this same 30%-Troubleshooting exam from an adjacent angle, see Platform Engineering's CKA page and SRE's CKA page.

⚠ Verify officially before you book

This page is an independent, unofficial study resource — not affiliated with the CNCF or the Linux Foundation. The domain weights above come from the published CKA curriculum and the exam itself is performance-based, graded entirely on the cluster state you leave behind. Curriculum versions, task style, and logistics (price, duration, pass mark, permitted documentation) change over time — confirm current details on the official Linux Foundation CKA page and the CNCF certification page before you pay for anything, and see the CKA study plan for this course's full logistics table.

🎬 At the Pod Squad
🦫

Benny: Just finished N3 — the NetworkPolicy one. Took eleven minutes though, way over budget.

🐢

Timmy: Then it goes on the redo list. Cold, tomorrow. A slow correct answer scores the same as a wrong one when the clock runs out either way.

👺

Gizmo: Or just memorize which YAML goes with which task title. Twenty tasks, twenty answers, done by Thursday. 🤑

🐢

Timmy: The real exam won't hand you these exact twenty tasks, Gizmo. It'll hand you something that only looks like one of them.

🦊

Foxy: Which is the actual point. I don't need you to remember that N3 needs a DNS egress rule — I need you to notice any Pod that can't resolve names and go check its NetworkPolicy without being told to.

🦫

Benny: Fine. Redoing N3. Properly this time — no peeking at my own YAML from ten minutes ago.

🐢 Timmy's checkpoint

1. Which two domains does this bank weight most heavily, and how many tasks does each get? 2. In T3, why does kubectl logs on the current container show nothing useful, and which flag actually reveals the crash? 3. Why does C5's metrics-server installation matter for both W3 and T4? 4. What's the practical difference between reclaimPolicy: Retain and the default Delete, and which task demonstrates it? 5. In N3, why does a NetworkPolicy that only names an Egress section for app-to-app traffic still need a rule for UDP/TCP port 53? 6. What does this bank insist you verify with instead of your own eyes, and why does reading the solution first defeat the exercise?

Check your answers
  1. Troubleshooting (30%, 6 tasks) and Cluster Architecture, Installation & Configuration (25%, 5 tasks) — the same two domains the CKA itself weights heaviest.
  2. The current container hasn't failed yet — it's brand new after the last restart, so it has nothing to log. --previous targets the last terminated container instance, which is the one that actually crashed.
  3. W3's HorizontalPodAutoscaler and T4's diagnosis of both a scheduling and an eviction failure both depend on kubectl top and live resource metrics, which only exist once metrics-server (installed in C5) is running.
  4. Retain keeps the underlying volume around as Released after its PVC is deleted, rather than deleting the disk automatically; S1 demonstrates it by deleting the PVC and showing the PV survives instead of disappearing.
  5. Selecting a Pod with any NetworkPolicy that names Egress flips that Pod to deny-by-default on every egress direction, including lookups to CoreDNS — without an explicit UDP/TCP 53 allow rule, all outbound DNS from that Pod is silently blocked.
  6. The task's own "done when" command — the actual proof the cluster reached the required state — not a visual read of the YAML. Reading the solution first teaches recognition, not production, and the exam only ever grades production.