Certifications · CKAD · Mock Exam · Set 1

CKAD Mock Exam · Set 1

This is a full, timed sitting of the CKAD in miniature: fifteen performance-based tasks against a live cluster, one unbroken 120-minute clock, and nothing graded except the state you leave behind — no partial credit for YAML you meant to apply but never did. Where a later paper in this course leans deliberately into one domain, Set 1 is built to track the official CNCF curriculum's own domain weights as closely as fifteen whole-number tasks allow: 25 points on Application Environment, Configuration & Security, 20 each on Design & Build, Deployment, and Services & Networking, and 15 on Observability & Maintenance — the same 25/20/20/20/15 split the real exam uses. That means a good score here is a genuine read on exam readiness, not just a score on this one paper. Every task carries an objective done when check and a worked solution folded away until you've actually attempted it — mark yourself honestly, total the sheet against the published 66% pass mark, and let the domain breakdown, not the raw number, choose what you study next.

☺ Explain it like I'm 10

Imagine a school cooking exam with five stations — chopping, plating, seasoning, cleaning as you go, and reading the recipe card correctly — and the stations aren't worth the same number of points, because the real chef's test never scores them equally either. A fair practice run gives you a station worth a quarter of your grade and a station worth a sixth, in the same proportions as the real thing, so getting good at this practice run actually means something on test day. That's this paper: five stations, unequal in size on purpose, matching the real test's own sizes instead of a made-up ideal. If you did well here, you'd likely do about that well for real — that's the entire point of building it this way instead of just picking fifteen tasks at random.

🦫🐢Your hosts for this topic: Benny the Beaver & Timmy the Turtle — Benny built or rebuilt every workload on this paper before it shipped, so he knows exactly where a rushed candidate cuts corners; Timmy owns the single biggest domain on the sheet, Environment, Configuration & Security, and won't let a task pass just because the app happens to run.

Before you start — exam conditions

☺ Like you're 10: A practice fire drill only teaches you something if you run it like the real one — one timer, no peeking, nobody helping.

Build a disposable cluster beforehand — a multi-node kind cluster or minikube with at least 4 CPUs and 6GB of RAM allocated covers everything on this paper, since CKAD assumes a working cluster and never asks you to build one. Install a CNI that actually enforces NetworkPolicyCilium or Calico, never the default bridge alone — and make sure ingress-nginx and Helm are already installed; N2 and D3 need them respectively. A metrics-server is not required for any task on this specific paper. Start one 120-minute timer and don't pause it. Keep only kubernetes.io/docs and, since this is CKAD, the Helm documentation open in a second tab — no search engine, no AI assistant, no notes from a previous attempt. Read all fifteen tasks first; five minutes spent doing that is the highest-return five minutes of the sitting, because it's the only way to know which tasks share setup (several here build on each other's namespaces and objects) before you commit to an order.

⚠ The flag-and-move rule

When a task passes its budgeted minutes below with no passing done-when check, stop, write one line about where you stalled, leave your partial work exactly as it is, and move to the next task. CKAD's task list skews toward "build this correctly" rather than "diagnose what's broken," which makes it tempting to keep polishing a manifest that's 90% right — but the real exam pays the same zero for a manifest that's 90% right as for one that's 10% right. Timmy's rule, unchanged from every other paper in this course: you are not paid to finish tasks, you are paid to bank points.

Your time budget and domain weights

☺ Like you're 10: The clock gets sliced up the same way the real exam's score does — the biggest slice goes to configuration and security, not to the flashiest-looking tasks.

Compare this table with the same five percentages on the CKAD exam page and the study plan: 25/20/20/20/15, summing to exactly 100. Minutes below are sized to match points one-for-one, which is itself a small piece of exam craft worth noticing — if a task is worth twice as many points as another, it should get roughly twice the time, not an equal share just because there are fifteen boxes to fill.

DomainTasksPointsShare of paperMinutes
🔑 Application Environment, Configuration & SecurityC1–C32525%25
🧩 Application Design and BuildB1–B32020%20
🚢 Application DeploymentD1–D32020%20
🐦 Services and NetworkingN1–N32020%20
🔭 Application Observability and MaintenanceO1–O31515%15
Total15 tasks100100%100 + 5 read + 15 verify
120 minutes · 15 tasks · 100 points · pass at 66 read 5m Design & Build B1–B3 · 20m · 20pts Deployment D1–D3 · 20m · 20pts Environment, Config & Security C1–C3 · 25m · 25pts Services & Net. N1–N3 · 20m · 20pts Observability & Maint. O1–O3 · 15m · 15pts verify 15m Every domain block is exactly as wide as its official exam weight — no domain is over- or under-played. Block width is illustrative — the table above has the exact minutes.

Application Design and Build — B1 to B3 (20 points)

☺ Like you're 10: This block is "make the right kind of thing, with the right pieces inside it" — a Pod that waits for what it needs, and a Pod that already knows how to save its work.

Background: The Object Model, the Workloads & Scheduling blueprint page, and Capstone Part 2, which drills this exact multi-container shape hands-on.

B1 · An init container, a sidecar, and a shared volume (8 pts)

In namespace build (create it), write a Pod named orders-app with three containers: an init container wait-for-db that blocks until a Service named orders-db answers on port 5432; a main container app running myorg/orders-api:1.4.0, listening on 8080, writing its access log to /var/log/app/access.log; and a sidecar log-shipper running busybox:1.36 that tails that same file to stdout. The main container and the sidecar must share the log directory through a volume.

Done when: kubectl get pod orders-app -n build shows 2/2 Ready (init containers never count toward that ratio) and kubectl logs orders-app -n build -c log-shipper streams lines as app writes them.

Show the worked solution
apiVersion: v1
kind: Pod
metadata:
  name: orders-app
  namespace: build
spec:
  initContainers:
    - name: wait-for-db
      image: busybox:1.36
      command: ["sh", "-c", "until nc -z orders-db 5432; do echo waiting; sleep 2; done"]
  containers:
    - name: app
      image: myorg/orders-api:1.4.0
      ports: [{ containerPort: 8080 }]
      volumeMounts:
        - { name: logs, mountPath: /var/log/app }
    - name: log-shipper
      image: busybox:1.36
      command: ["sh", "-c", "touch /var/log/app/access.log; tail -f /var/log/app/access.log"]
      volumeMounts:
        - { name: logs, mountPath: /var/log/app }
  volumes:
    - name: logs
      emptyDir: {}

Why: an init container runs to completion before any regular container starts, which is exactly the ordering a hard dependency needs — the scheduler never gets to mark app Running until wait-for-db exits 0. The sidecar pattern here is deliberately simple: an emptyDir is node-local, ephemeral, shared storage between containers in the same Pod — cheap, fast, and gone the moment the Pod is. touch-ing the file first matters because tail -f on a file that doesn't exist yet exits immediately rather than waiting for it to appear.

B2 · A CronJob with sane failure and history limits (6 pts)

Create a CronJob nightly-report in namespace build, image busybox:1.36, schedule 0 2 * * *, command that echoes a report line. Configure it so a single failed run doesn't retry forever, overlapping runs never happen, and only the last 3 completed Jobs stick around.

Done when: kubectl get cronjob nightly-report -n build -o yaml shows concurrencyPolicy: Forbid, and manually triggering kubectl create job --from=cronjob/nightly-report manual-test-1 -n build produces a Job that reaches Complete.

Show the worked solution
apiVersion: batch/v1
kind: CronJob
metadata: { name: nightly-report, namespace: build }
spec:
  schedule: "0 2 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: busybox:1.36
              command: ["sh", "-c", "echo \"report generated at $(date)\""]

Why: restartPolicy: Never plus a small backoffLimit is what stops a genuinely broken report job from retrying dozens of times before giving up — the Job controller counts failed Pods against that limit and marks the Job Failed once it's exhausted, instead of looping indefinitely. concurrencyPolicy: Forbid matters because a report job that occasionally runs long shouldn't ever have two copies writing the same output at once; Allow is the (risky) default. kubectl create job --from=cronjob/... is the fast, exam-realistic way to test a CronJob's Pod template without waiting for 2am.

B3 · A Pod consuming an existing PVC and a ConfigMap volume (6 pts)

Namespace build already has a Bound PVC shared-cache and a ConfigMap app-settings containing a key settings.yaml. Create Pod cache-reader, image busybox:1.36, that mounts the PVC read-write at /cache and mounts the ConfigMap read-only at /etc/app.

Done when: the Pod is Running, kubectl exec cache-reader -n build -- touch /cache/probe succeeds, and kubectl exec cache-reader -n build -- cat /etc/app/settings.yaml matches the ConfigMap's content exactly.

Show the worked solution
apiVersion: v1
kind: Pod
metadata: { name: cache-reader, namespace: build }
spec:
  containers:
    - name: reader
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      volumeMounts:
        - { name: cache, mountPath: /cache }
        - { name: settings, mountPath: /etc/app, readOnly: true }
  volumes:
    - name: cache
      persistentVolumeClaim: { claimName: shared-cache }
    - name: settings
      configMap: { name: app-settings }

Why: a persistentVolumeClaim volume source is a Pod's side of a PVC that already exists and is already Bound — the Pod doesn't create or provision anything, it just requests to mount what's there, which is precisely why this is a Design & Build competency and not a Storage one on this exam's own domain split. Mounting a ConfigMap as a volume, rather than as an env var, is what gets you a real file on disk at a predictable path — the right choice whenever the consuming process expects to read a config file, not an environment variable.

🦆 Dot's-eye view

"None of this reads as 'Kubernetes trivia' to me once I'm actually shipping. An init container that waits for a database is just... the thing that stops my app from crash-looping for the two minutes after a fresh deploy while the database catches up. A sidecar that tails a log file is the reason our log aggregator sees anything at all from an app that was never written to log anywhere but a local file. I didn't learn these because they're exam objectives — I learned the exam objectives because I kept needing exactly these things."

Application Deployment — D1 to D3 (20 points)

☺ Like you're 10: This block is "get the new version out there without breaking anything" — three different ways of swapping something running for something better.

Background: Workloads & Scheduling, the Helm tool guide, and Capstone Part 2.

D1 · Zero-downtime rolling update, then a clean rollback (7 pts)

Deployment checkout (3 replicas, image myorg/checkout:2.0.0) must never drop below 3 Ready Pods during a rollout and must never run more than 1 extra Pod at once. Configure that, then update the image to the broken tag myorg/checkout:2.1.0-badtag (its readiness probe never passes), watch the rollout stall, and roll back cleanly.

Done when: kubectl get deploy checkout shows strategy.rollingUpdate of maxSurge: 1, maxUnavailable: 0, and after kubectl rollout undo, kubectl rollout status deploy/checkout reports success with image back at 2.0.0.

Show the worked solution
kubectl patch deploy checkout --type=json -p='[
  {"op":"replace","path":"/spec/strategy/rollingUpdate/maxSurge","value":1},
  {"op":"replace","path":"/spec/strategy/rollingUpdate/maxUnavailable","value":0}
]'
kubectl set image deploy/checkout checkout=myorg/checkout:2.1.0-badtag
kubectl rollout status deploy/checkout --timeout=30s      # times out — new pods never go Ready
kubectl rollout undo deploy/checkout
kubectl rollout status deploy/checkout                    # Success
kubectl get deploy checkout -o jsonpath='{.spec.template.spec.containers[0].image}'

Why: maxUnavailable: 0 is the actual zero-downtime guarantee — it tells the rollout it may never take an existing, working Pod down until a new, ready one has replaced it, while maxSurge: 1 caps how many extra Pods it's allowed to run to make that possible. Because maxUnavailable is 0, a rollout to a bad image can never take down the old, working Pods — it just stalls with the old Pods still serving traffic, which is exactly the safety net this setting exists to provide. kubectl rollout undo reverts to the previous ReplicaSet's Pod template, not to whatever the live state happens to be, so it's a clean, complete rollback in one command.

D2 · Blue/green traffic swap via a Service selector only (7 pts)

Deployments web-blue (labeled version: blue) and web-green (labeled version: green) are both already Running in namespace build. Service web currently selects version: blue. Cut traffic over to green — no Pods may be restarted or recreated.

Done when: kubectl get endpoints web -n build lists only web-green Pod IPs, and neither Deployment's Pods have restarted (kubectl get pods -n build restart counts unchanged).

Show the worked solution
kubectl get pods -n build -l app=web --show-labels     # confirm green pods already Ready
kubectl patch svc web -n build --type=merge -p '{"spec":{"selector":{"version":"green"}}}'
kubectl get endpoints web -n build

Why: a Service's selector is the only thing tying it to a set of Pods — the Endpoints (or EndpointSlice) controller recomputes membership continuously from whatever currently matches that selector, so changing the label a Service looks for redirects live traffic in one API call, with zero Pod churn. This is the entire blue/green mechanism on Kubernetes: both versions run simultaneously the whole time, and "cutover" and "rollback" are both just the same one-line Service patch, pointed at a different value.

D3 · Helm install, then upgrade, with values overrides (6 pts)

A Helm chart is available at ./charts/webapp. Install it as release webapp into namespace shop (create the namespace) with image.tag=2.1.0 and replicaCount=3. Then upgrade the same release to image.tag=2.2.0, keeping the replica count.

Done when: helm status webapp -n shop shows STATUS: deployed at REVISION: 2, and kubectl get deploy webapp -n shop -o jsonpath='{.spec.template.spec.containers[0].image}' ends in 2.2.0.

Show the worked solution
kubectl create namespace shop
helm install webapp ./charts/webapp -n shop \
  --set image.tag=2.1.0 --set replicaCount=3
helm status webapp -n shop                        # REVISION: 1

helm upgrade webapp ./charts/webapp -n shop \
  --reuse-values --set image.tag=2.2.0
helm status webapp -n shop                        # REVISION: 2
kubectl rollout status deploy/webapp -n shop
kubectl get deploy webapp -n shop -o jsonpath='{.spec.template.spec.containers[0].image}'

Why: --reuse-values is the detail most candidates miss under time pressure — without it, helm upgrade re-renders the chart against only the flags on this command, silently dropping replicaCount=3 back to the chart's default. Helm tracks each install/upgrade as a numbered revision independent of the underlying Deployment's own rollout history, which is why helm status and kubectl rollout status are both worth checking — one confirms the release recorded correctly, the other confirms the Pods actually came up. See the Helm tool guide for the full revision and rollback model.

Application Environment, Configuration & Security — C1 to C3 (25 points)

☺ Like you're 10: This is the "only touch what you're allowed to touch, only use what you were given, and don't run as the most powerful user by accident" block — and it's worth more than any other single block on the sheet.

Background: RBAC & Admission Control, Scheduling & Resource Management, Security: Defense in Depth, and DevSecOps's Kubernetes Security Deep Dive for the attacker's-eye version of C3.

C1 · A scoped ServiceAccount, mounted and proven (9 pts)

In namespace batch, create ServiceAccount report-runner that may only get, list and watch Pods and read Pod logs — nothing else, and nothing outside batch. Launch a Pod report-check that runs as this ServiceAccount, not default.

Done when: kubectl auth can-i get pods -n batch --as=system:serviceaccount:batch:report-runner returns yes; kubectl auth can-i delete pods -n batch --as=system:serviceaccount:batch:report-runner and kubectl auth can-i get pods -A --as=system:serviceaccount:batch:report-runner both return no; kubectl get pod report-check -n batch -o jsonpath='{.spec.serviceAccountName}' prints report-runner.

Show the worked solution
apiVersion: v1
kind: ServiceAccount
metadata: { name: report-runner, namespace: batch }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: pod-reader, namespace: batch }
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: pod-reader-binding, namespace: batch }
subjects:
  - kind: ServiceAccount
    name: report-runner
    namespace: batch
roleRef: { kind: Role, name: pod-reader, apiGroup: rbac.authorization.k8s.io }
---
apiVersion: v1
kind: Pod
metadata: { name: report-check, namespace: batch }
spec:
  serviceAccountName: report-runner
  containers:
    - { name: idle, image: busybox:1.36, command: ["sh","-c","sleep 3600"] }

Why: a namespaced Role bound with a RoleBinding is what keeps a grant inside batch — a ClusterRole bound with a ClusterRoleBinding would reach every namespace in the cluster, well past what a report runner needs. pods/log is its own subresource in the RBAC model, separate from pods itself, which is a common gap: a ServiceAccount that can list Pods but wasn't separately granted pods/log can see that a Pod exists but not read anything it wrote. kubectl auth can-i --as re-runs the real authorization check by impersonation — it's the fastest way to prove a boundary without minting and testing a real token.

C2 · A ResourceQuota and LimitRange that actually bind (8 pts)

Namespace batch needs a ResourceQuota capping the namespace at requests.cpu: 2 and requests.memory: 2Gi total, and a LimitRange giving any container that specifies no resources a default request of 100m CPU / 128Mi memory and a default limit of 250m CPU / 256Mi memory. Then create Pod quiet-worker with no resources specified at all, and attempt to create Pod greedy-worker explicitly requesting 3 CPU.

Done when: quiet-worker is Running with the LimitRange defaults visible in kubectl get pod quiet-worker -n batch -o yaml, and creating greedy-worker is rejected with an exceeded quota error.

Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata: { name: batch-quota, namespace: batch }
spec:
  hard: { requests.cpu: "2", requests.memory: 2Gi }
---
apiVersion: v1
kind: LimitRange
metadata: { name: batch-defaults, namespace: batch }
spec:
  limits:
    - type: Container
      defaultRequest: { cpu: 100m, memory: 128Mi }
      default: { cpu: 250m, memory: 256Mi }
kubectl run quiet-worker -n batch --image=busybox:1.36 -- sleep 3600
kubectl get pod quiet-worker -n batch -o jsonpath='{.spec.containers[0].resources}'
# {"limits":{"cpu":"250m","memory":"256Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}

kubectl run greedy-worker -n batch --image=busybox:1.36 \
  --requests=cpu=3 -- sleep 3600
# Error from server (Forbidden): pods "greedy-worker" is forbidden:
# exceeded quota: batch-quota, requested: requests.cpu=3, used: requests.cpu=100m, limited: requests.cpu=2

Why: once a ResourceQuota tracks requests.cpu or requests.memory in a namespace, the API server starts rejecting any Pod there that doesn't declare those fields — that's exactly the trap C2 sets, and the LimitRange is the fix: it injects default requests/limits onto containers that specify none, so quiet-worker is admitted with real numbers instead of being silently blocked. greedy-worker fails at admission time, before the Pod object is even created — the quota is enforced on write, not by killing an already-running Pod later.

C3 · A hardened SecurityContext, plus a CRD lookup (8 pts)

Deployment payments-api currently runs with no securityContext at all. Harden it: containers must run as non-root UID 10001, with a read-only root filesystem, all Linux capabilities dropped, and no privilege escalation. Separately, a CRD may or may not already be installed in this cluster — determine whether a PaymentGateway custom resource type exists, and if it does, list its spec fields without reading any source code.

Done when: kubectl get pod -n shop -l app=payments-api -o jsonpath='{.items[0].spec.containers[0].securityContext}' shows all four settings correctly, the Pod is Running, and either kubectl explain paymentgateway.spec prints a field list or you can show kubectl api-resources | grep -i paymentgateway returning nothing, proving the type doesn't exist.

Show the worked solution
spec:
  template:
    spec:
      containers:
        - name: api
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            capabilities: { drop: ["ALL"] }
kubectl patch deploy payments-api -n shop --type=json -p='[{
  "op":"add","path":"/spec/template/spec/containers/0/securityContext",
  "value":{"runAsNonRoot":true,"runAsUser":10001,"readOnlyRootFilesystem":true,
           "allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}}
}]'
kubectl api-resources | grep -i paymentgateway
kubectl explain paymentgateway.spec       # only if the CRD is installed

Why: a readOnlyRootFilesystem alongside capabilities.drop: [ALL] is the pairing most likely to break a container that was never tested against it — an app that writes a temp file or a PID file to its own root filesystem will now fail at runtime, not at apply time, which is why real hardening work always includes an emptyDir mounted at whatever path the app actually needs to write to. kubectl explain works against any CRD the moment its CustomResourceDefinition is registered, because the API server serves OpenAPI schema for CRDs the same way it does for built-in types — no source access needed, which is the whole point of the "discover and use resources that extend Kubernetes" competency this half of the task maps to.

Services and Networking — N1 to N3 (20 points)

☺ Like you're 10: This block is "get traffic to the right place, and only the traffic that's allowed" — a broken address book, a signpost with two destinations, and a locked door with exactly one key that fits.

Background: Networking & the CNI, the Services & Networking blueprint page, and the networking-failure drill.

N1 · A Service that routes to nothing (7 pts)

Service catalog in namespace shop exists and Pods behind Deployment catalog are Running, but kubectl get endpoints catalog -n shop shows no addresses at all, and requests to it time out.

Done when: kubectl get endpoints catalog -n shop lists every catalog Pod IP, and kubectl run tester -n shop --image=busybox:1.36 --rm -it --restart=Never -- wget -qO- catalog.shop.svc.cluster.local returns a response.

Show the worked solution
kubectl get pods -n shop -l app=catalog --show-labels    # actual label: app.kubernetes.io/name=catalog
kubectl get svc catalog -n shop -o yaml | grep -A3 selector
#   selector: { app: catalog }        # doesn't match either key or value on the pods

kubectl patch svc catalog -n shop --type=merge \
  -p '{"spec":{"selector":{"app.kubernetes.io/name":"catalog"}}}'
kubectl get endpoints catalog -n shop

Why: a Service's selector and a Pod's labels have to match exactly, key and value both — there's no fuzzy matching, and a Service whose selector matches zero Pods is not an error state as far as the API server is concerned, it's simply a Service with an empty Endpoints object, which is why nothing about the Service object itself looks broken. This is, per the CKAD exam page, the single most common self-inflicted cause of a "nothing can reach this app" task — check the selector against the Pod's actual labels before assuming anything more exotic is wrong.

N2 · Ingress routing two paths to two backends (7 pts)

Two Services already exist in namespace shop: storefront-svc and admin-svc, both on port 80. Create one Ingress on host market.internal that sends /admin to admin-svc and everything else to storefront-svc.

Done when: curl -H "Host: market.internal" http://<ingress-ip>/admin/dashboard reaches admin-svc, and curl -H "Host: market.internal" http://<ingress-ip>/ reaches storefront-svc.

Show the worked solution
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: market, namespace: shop }
spec:
  ingressClassName: nginx
  rules:
    - host: market.internal
      http:
        paths:
          - path: /admin
            pathType: Prefix
            backend: { service: { name: admin-svc, port: { number: 80 } } }
          - path: /
            pathType: Prefix
            backend: { service: { name: storefront-svc, port: { number: 80 } } }

Why: path specificity, not declaration order in the YAML file, is what most ingress controllers use to choose a match — but writing the more specific /admin rule before the catch-all / keeps the manifest readable and matches how ingress-nginx documents its own precedence rules. There's no rewrite here because neither backend needs its prefix stripped — this is the simpler sibling of the rewrite-target pattern used when a backend doesn't expect the path prefix it's reached through.

N3 · Default-deny, then one specific cross-namespace allow (6 pts)

Namespace payments currently allows ingress from anywhere. Lock it down so only Pods labeled role: gateway in namespace checkout can reach payments Pods, and only on tcp/9443.

Done when: a request from a role: gateway Pod in checkout to a payments Pod on 9443 succeeds, and the same request from any Pod in payments itself, or from an unlabeled Pod in checkout, times out.

Show the worked solution
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: payments }
spec:
  podSelector: {}
  policyTypes: [Ingress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-checkout-gateway, namespace: payments }
spec:
  podSelector: {}
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: checkout }
          podSelector:
            matchLabels: { role: gateway }
      ports: [{ protocol: TCP, port: 9443 }]

Why: a single entry inside one from list item that combines a namespaceSelector and a podSelector is an AND, not an OR — it matches only Pods that are labeled role: gateway and live in a namespace labeled checkout, which is the narrow, correct target here. Putting those two selectors in separate list items instead would match either condition on its own — any Pod in checkout regardless of its labels, or any role: gateway Pod in any namespace — a much wider hole than intended. Every namespace since Kubernetes 1.21 carries the immutable label kubernetes.io/metadata.name, which is what makes a reliable cross-namespace namespaceSelector possible without asking anyone to label the namespace by hand first.

Application Observability and Maintenance — O1 to O3 (15 points)

☺ Like you're 10: This block asks whether you can tell when something is actually broken versus just still waking up, and whether you can find a clue that isn't sitting in the obvious log file.

Background: Observability on Kubernetes, A Troubleshooting Methodology, and the stuck-pod drill.

O1 · Probes that survive a slow, real cold start (6 pts)

Deployment inventory-api has no probes configured. The app can take up to 30 seconds to become ready after container start, exposes /healthz (liveness) and /readyz (readiness) on port 8080, and once ready, a genuine hang should be caught within about 15 seconds.

Done when: after a fresh rollout, kubectl get pods -l app=inventory-api shows 0 restarts through the full cold start, and the Pod reaches Ready on its own once the app is actually up — not killed mid-startup.

Show the worked solution
spec:
  template:
    spec:
      containers:
        - name: api
          startupProbe:
            httpGet: { path: /healthz, port: 8080 }
            failureThreshold: 18
            periodSeconds: 2          # up to 36s of startup grace before liveness even starts
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 5
            failureThreshold: 3       # ~15s of real hang before a restart

Why: a startupProbe exists specifically to solve this shape of problem — while it's still failing, kubectl and the kubelet hold off on running the liveness probe at all, so a slow-but-healthy cold start never gets mistaken for a hung process and killed mid-boot. Without it, a candidate is forced into an awkward compromise: either a lenient liveness failureThreshold that tolerates the 30-second cold start but then also tolerates a 30-second real hang, or a strict one that kills every single cold start. The startup probe removes that trade-off entirely — cold start gets its own generous budget, and steady-state liveness can stay tight.

O2 · A CrashLoopBackOff with nothing useful in the visible logs (5 pts)

Pod worker in namespace batch is CrashLoopBackOff. kubectl logs worker shows nothing — the crash happens before the app ever writes a line. Find the actual cause and fix it.

Done when: worker reaches Running and its restart count stops climbing for at least two restart-backoff intervals.

Show the worked solution
kubectl logs worker -n batch --previous          # still empty — crash predates any app output
kubectl describe pod worker -n batch | tail -15
#   Last State: Terminated, Reason: Error, Exit Code: 1
#   Events: ...Back-off restarting failed container

# an ephemeral debug container shares the pod's network/filesystem view for live poking,
# but the fastest read here is the deployment's own env block:
kubectl get deploy worker -n batch -o yaml | grep -A6 env:
#   - name: REPORT_BUCKET
#     valueFrom:
#       secretKeyRef: { name: worker-secrets, key: bucket-name }   # key doesn't exist in the Secret

kubectl get secret worker-secrets -n batch -o jsonpath='{.data}'   # confirms: no "bucket-name" key
kubectl patch secret worker-secrets -n batch --type=merge \
  -p '{"stringData":{"bucket-name":"reports-prod"}}'
kubectl rollout restart deploy/worker -n batch

Why: a container whose command references an environment variable sourced from a secretKeyRef that doesn't exist fails at container-creation time, before the entrypoint process — and therefore the app's own logging — ever runs, which is exactly why kubectl logs comes back empty. kubectl describe's Events and Last State are the layer above application logs, and for exactly this class of failure they're the only layer with anything to say. This is the same "check describe before assuming the app itself is at fault" discipline the troubleshooting methodology page builds in depth for the CKA side of this ladder.

O3 · A deprecated API version blocking apply (4 pts)

A saved manifest for a PodDisruptionBudget uses apiVersion: policy/v1beta1. Applying it against this cluster fails outright. Fix the manifest so it applies cleanly, preserving the same minAvailable: 2 and selector.

Done when: kubectl apply -f pdb.yaml succeeds with no error, and kubectl get pdb worker-pdb -n batch shows ALLOWED DISRUPTIONS computed correctly.

Show the worked solution
apiVersion: policy/v1          # was policy/v1beta1 — removed from the API server entirely
kind: PodDisruptionBudget
metadata: { name: worker-pdb, namespace: batch }
spec:
  minAvailable: 2
  selector:
    matchLabels: { app: worker }

Why: policy/v1beta1 for PodDisruptionBudget was removed from the API server starting with Kubernetes 1.25 — not merely marked deprecated, but genuinely gone, so applying it doesn't emit a warning, it fails with a no matches for kind or similar error. kubectl explain poddisruptionbudget --api-version=policy/v1 or kubectl api-resources against the live cluster is the fastest way to confirm the correct current group/version during the exam itself, rather than trusting a manifest's own header or an old tutorial. Watching for exactly this — a manifest written against an API version the running cluster no longer serves — is its own named competency on this domain, "understand API deprecations," separate from anything about the object's actual fields.

Score yourself

☺ Like you're 10: Add up the points, but look harder at which domain your misses cluster in — that tells you exactly what to study next, which matters more than the total.

Mark only after attempting all fifteen. Full credit only when the done-when check actually passes on your own cluster — a manifest you believe is correct but never applied, or a fix you never verified, scores nothing, exactly as the real exam grades it.

TaskDomainPointsYour score
B1 · init container + sidecarDesign & Build8
B2 · CronJob with limitsDesign & Build6
B3 · PVC + ConfigMap volumeDesign & Build6
D1 · zero-downtime rollout + rollbackDeployment7
D2 · blue/green via Service selectorDeployment7
D3 · Helm install & upgradeDeployment6
C1 · scoped ServiceAccountEnvironment, Config & Security9
C2 · ResourceQuota + LimitRangeEnvironment, Config & Security8
C3 · hardened SecurityContext + CRDEnvironment, Config & Security8
N1 · broken Service selectorServices & Networking7
N2 · Ingress path routingServices & Networking7
N3 · default-deny + scoped allowServices & Networking6
O1 · startup/readiness/liveness probesObservability & Maintenance6
O2 · silent CrashLoopBackOffObservability & Maintenance5
O3 · deprecated API migrationObservability & Maintenance4
TotalAll five domains100

The real CKAD's published pass mark is 66% — this paper uses the same number as a study reference. Below that here, re-run the matching material rather than re-reading this page's answer key on its own: B1–B3 point back to Capstone Part 2, D1–D3 to the Helm guide and Workloads & Scheduling, C1–C3 to the RBAC-hardening drill and Capstone Part 5, N1–N3 to the networking-failure drill, and O1–O3 to the stuck-pod drill and Observability on Kubernetes.

⚠ Verify officially before you book

This is an independent, unofficial study resource — not affiliated with the CNCF or the Linux Foundation. The 120-minute duration, 66% pass mark, task count, price (commonly cited around USD $445), 2-year validity and permitted-documentation allowlist referenced on this page all change over time. Confirm current details on the official Linux Foundation CKAD page and the CNCF certification page before you pay for anything. See Platform Engineering's CKAD page for the same exam from a platform-engineer's angle, and the Golden Astronaut course if CKAD is one stop on your way to the full Kubestronaut or Golden Kubestronaut ladder.

🎬 At the Pod Squad
🦫

Benny: N1 got me for four whole minutes. The Service object looked completely normal — I kept re-reading it instead of checking the actual Pod labels.

🐢

Timmy: That's the trap. A Service with a selector matching nothing isn't broken from the Service's own point of view — it's working exactly as configured. The bug only shows up when you compare it against the Pods.

👺

Gizmo: Or just skip the comparison — delete the Service and recreate it with kubectl expose. Way faster. 🤑

🐢

Timmy: kubectl expose pulls the selector straight from whatever you point it at — if you point it at the same broken Deployment, you get the exact same mismatch back under a new name. It doesn't fix anything, it just costs you the two minutes it took to run.

🐰

Remy: C1's RBAC YAML took me ninety seconds because I've typed that Role/RoleBinding shape so many times it doesn't need thinking about anymore. That's fifteen minutes of the clock I got to spend somewhere that actually needed it.

🐘

Ellie: And O2 is the one that punishes anyone who only ever reads kubectl logs and stops there. The app never got a chance to log anything — the story was entirely in Events and the Secret it depended on.

🦫

Benny: Different failure every time, same fix every time, though — check the thing right next to the thing that's broken before assuming the broken thing itself is lying to you.

🐢 Timmy's checkpoint

1. Why does 2/2 Ready on Pod orders-app in B1 never include the init container in that count? 2. In D1, what does setting maxUnavailable: 0 actually guarantee during a rollout to a broken image? 3. In C1, why does a ServiceAccount need pods/log granted separately from pods itself? 4. What's the difference between putting a namespaceSelector and a podSelector in the same from list item in N3 versus two separate items — and which one did the task actually need? 5. Why does a startupProbe in O1 remove the trade-off between tolerating a slow cold start and catching a real hang quickly? 6. In O3, why does the manifest fail outright rather than just showing a deprecation warning?

Check your answers
  1. Init containers run to completion, in order, before any regular container even starts — the Ready condition and its N/M count only ever track the regular, long-running containers in the Pod spec, since a finished init container has nothing left to be "ready."
  2. It guarantees the rollout can never take an existing, working Pod down until a replacement Pod has already passed its readiness check — so a broken new image simply stalls the rollout with the old Pods still serving traffic, instead of ever dipping below the desired replica count.
  3. pods/log is a distinct RBAC subresource from pods — a grant on one doesn't imply the other, so a ServiceAccount can be authorized to see that a Pod exists (via pods) while still being denied permission to read anything it logged.
  4. Combined in one list item, the two selectors are ANDed together — matching only Pods with the right label in the right namespace. Split into two separate items, they're ORed — matching either condition alone, a much wider hole. N3 needed the combined, ANDed form.
  5. While the startupProbe is still failing, the kubelet holds off running the liveness probe entirely — so the slow cold start gets its own generous, separate budget, and once startup succeeds, liveness can use a tight interval tuned for catching a real hang, without that tightness ever threatening the cold start.
  6. policy/v1beta1 for PodDisruptionBudget wasn't merely deprecated — it was removed from the API server starting with Kubernetes 1.25, so the server has no handler left to accept it at all, and the request fails immediately rather than succeeding with a warning.

That's Set 1. Because this paper is weighted exactly like the official curriculum, a clean pass here is a fair signal you're close to ready — but sit it more than once, on freshly rebuilt clusters, before you trust a single score. For a second full paper to check your score holds up on new tasks, see Set 2; for shorter, single-topic reps instead of a full timed sitting, see CKAD Practice Tasks; and for the full week-by-week plan this paper sits at the end of, see the CKAD study plan.

📄 The CKAD practice papers

Set 1 (you are here) · Set 2. Both are worth 100 points against the same 66% reference; see the CKAD study plan for when to sit each one, and CKAD Practice Tasks for untimed, single-domain reps in between.