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.
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.
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.
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.
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
kubeadm bootstrap and upgrade, an etcd backup and restore, namespace-scoped RBAC, and a Helm-installed component.
D2 · 15% · 3 tasksWorkloads & Scheduling
Rolling-update tuning with a targeted rollback, ConfigMaps and Secrets, and placement with taints, affinity, a PDB and an HPA.
D3 · 20% · 4 tasksServices & Networking
ClusterIP/NodePort and EndpointSlices, Ingress with TLS, a default-deny NetworkPolicy, and the newer Gateway API.
D4 · 10% · 2 tasksStorage
Dynamic provisioning with WaitForFirstConsumer and a Retain reclaim policy, and a static PV/PVC binding puzzle.
D5 · 30% · 6 tasksTroubleshooting
A dead node, a broken static pod, a crash loop, requests vs. limits, an empty EndpointSlice, and a DNS-breaking policy.
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:
- Initialize the control plane on
cp-1with pod network CIDR10.244.0.0/16. - Configure
kubectlaccess for the admin user the init output names. - Install a CNI that matches that CIDR so nodes can actually leave
NotReady. - Join both workers using the token and discovery hash
kubeadm initprinted.
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:
- Take a TLS-authenticated snapshot to
/opt/backups/etcd-snapshot.db. - Confirm the snapshot is valid before you trust it.
- 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:
- Upgrade
kubeadm,kubeletandkubectlon the control-plane node, applying the upgrade withkubeadmitself. - Drain the worker, upgrade its
kubeadm,kubeletandkubectl, 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:
- Create the
ServiceAccountinapps. - Create a
Rolegrantingget/list/watchon pods and full control of deployments, scoped toapps. - Bind it with a
RoleBinding, not aClusterRoleBinding.
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.iokubectl 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:
- Add the metrics-server Helm repository and update it.
- Install it into
kube-systemwith--kubelet-insecure-tlsset. - 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=memoryWhy: 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:
- Set the rollout strategy to
maxUnavailable: 1,maxSurge: 2. - Roll out
web:v2and confirm it finishes cleanly. - Roll out a broken tag,
web:v3-typo, and watch the rollout stall. - 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:
- Create the ConfigMap (key
DB_HOST) and Secret (keydb-password). - Wire the ConfigMap key in as an env var and mount the Secret as a volume at
/etc/billing/secrets. - 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_HOSTWhy: 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:
- Add a toleration for the taint and a
nodeAffinityrequiring the label. - Add a
PodDisruptionBudgetwithminAvailable: 1. - Add a
HorizontalPodAutoscalertargeting 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:
- Create a
ClusterIPServicecatalog-internal, port 80 → target 8080. - Create a
NodePortServicecatalog-external, node port 30080 → port 80 → target 8080. - 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:
- Write one
Ingressresource, classnginx, with both path rules. - Attach the
tlsblock referencingshop-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:
- A default-deny
NetworkPolicyselecting every Pod inpayments, both directions. - An allow rule: ingress to
billingfrom Pods infrontend, TCP 8080 only. - An allow rule: egress from every Pod in
paymentsto 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:
- Create a
Gatewaynamedshop-gwreferencing an existingGatewayClass. - Create an
HTTPRouteattached to it with two path matches routing tostorefrontandcatalog-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/apiWhy: 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:
- Create a
StorageClasswithvolumeBindingMode: WaitForFirstConsumerandreclaimPolicy: Retain. - Create a PVC against it and a Pod that mounts it.
- 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 thereWhy: 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:
- Diagnose why the existing PVC won't bind.
- Fix the mismatch.
- Add a label selector so it targets
local-data-pvby 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 PVkubectl 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:
- Check the node's Conditions from
kubectlfirst. - SSH in and find out why the kubelet stopped reporting.
- 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:
- SSH to the control-plane node —
kubectlis useless here since it's the thing that's down. - Find out why the kubelet won't keep
kube-apiserverrunning as a static pod. - 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:
- Work the describe → logs →
--previoussequence, in that order. - Identify the actual misconfiguration.
- 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:
- Diagnose
analyticsusingdescribeEvents andkubectl top(from C5). - Diagnose
cachethe same way, but read its container's last state, not its Events. - 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:
- Confirm the EndpointSlice is actually empty.
- Compare the Service's selector against what the Pods are really labeled.
- 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 nowWhy: 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:
- Confirm from inside
billingwhether DNS resolves at all. - Confirm CoreDNS itself is healthy.
- Inspect the NetworkPolicies selecting
billingfor what's actually missing. - 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.
"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.
| Band | Looks like | What it means |
|---|---|---|
| Clean | Done inside 5–7 minutes, "done when" passed first try, docs used only to confirm a field name. | Exam-ready on this one. Move on. |
| Slow | The 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 shape | You 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. |
| Blank | You 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. |
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.
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.
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.
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
- Troubleshooting (30%, 6 tasks) and Cluster Architecture, Installation & Configuration (25%, 5 tasks) — the same two domains the CKA itself weights heaviest.
- The current container hasn't failed yet — it's brand new after the last restart, so it has nothing to log.
--previoustargets the last terminated container instance, which is the one that actually crashed. - W3's HorizontalPodAutoscaler and T4's diagnosis of both a scheduling and an eviction failure both depend on
kubectl topand live resource metrics, which only exist once metrics-server (installed in C5) is running. Retainkeeps the underlying volume around asReleasedafter 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.- Selecting a Pod with any NetworkPolicy that names
Egressflips 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. - 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.