If the CKA certifies the person who keeps the cluster alive, the CKAD certifies the person who builds on top of it. It is the certification of the profile that writes the bookings-api manifest, decides which probe to use, consumes a Secret without exposing it, tunes resources so the pod does not die under load, and ships a new version without cutting the service. You have already done all of that on Rutas Norte; this lesson reorders it into exam terms.

The CKAD has a quirk that makes it deceptively hard: the curriculum is simpler than the CKA's, but the exam is far more stressful. There are many tasks and very little time. The skill being measured is not just knowing Kubernetes: it is knowing Kubernetes fast. That is why half of this lesson is devoted to speed with kubectl, which is what separates those who pass from those who end up three tasks short.

Essential notice. Price, exact duration, number of tasks, pass mark and domain weighting change over time. What follows is indicative and reflects the time of writing. Always check the current official curriculum on the Linux Foundation / CNCF website (training.linuxfoundation.org, cncf.io/certification/ckad) before you enrol.

Contents

  1. What the CKAD certifies and how it differs from the CKA
  2. The exam format and why time is the enemy
  3. The curriculum domains and their indicative weights
  4. Full map: every CKAD objective and its lesson in this course
  5. The topics where people usually fail
  6. The speed toolbox
  7. Master table: what they will ask you → the fastest way to do it
  8. Eight CKAD-style tasks solved against the clock

  1. What the CKAD certifies and how it differs from the CKA

The CKAD attests that you know how to design, build, configure and deploy cloud-native applications on Kubernetes. It does not ask you how etcd is restored or how a node joins the cluster: it hands you a working cluster and asks you to get applications running correctly on it.

1.1 The exact border between CKA and CKAD

Question CKA? CKAD?
Restoring etcd, upgrading the cluster with kubeadm, fixing a stopped kubelet Yes No
Creating a PersistentVolume Yes Rarely (the PVC, yes)
Creating a Deployment with probes and resources Yes Yes, in more depth
Choosing between an initContainer and a sidecar Marginal Yes, central
Consuming a ConfigMap in all four possible ways Yes Yes, exhaustively
securityContext at container level Marginal Yes
Cluster RBAC Yes, central Only the basics of ServiceAccounts
NetworkPolicies and Ingress Yes Yes

In one sentence: the CKA runs the house, the CKAD furnishes the rooms. The overlap is large, but the angle of the questions is different.

1.2 CKA / CKAD / CKS comparison table

Criterion CKA CKAD CKS
Profile Administrator / SRE / platform Developer / application engineer Security engineer
Focus Cluster: nodes, control plane, etcd, networking, RBAC Applications: manifests, config, lifecycle Hardening, detection and response
Prerequisite None None Valid CKA mandatory
Indicative duration ~2 hours ~2 hours ~2 hours
Indicative no. of tasks 15-20 15-20 15-16
Indicative pass mark ~66 % ~66 % ~67 %
Perceived difficulty Medium-high (deep diagnosis) Medium, with extreme time pressure High (dense curriculum and external tools)
Main cause of failure Not knowing how to diagnose the control plane Running out of time Not knowing Falco, kube-bench, AppArmor
Documentation allowed kubernetes.io kubernetes.io kubernetes.io + Falco, Trivy and AppArmor

Recommended order by profile:

  • Developer: CKAD → CKA → (CKS if you specialise in security).
  • Operations / SRE / platform: CKA → CKS → (CKAD to close the circle).
  • From scratch, no experience: CKAD first; it is more accessible and lays the groundwork for the CKA.

1.3 Who benefits from it

Developers whose team deploys on Kubernetes and want to stop depending on the platform team; DevOps engineers who write manifests daily; QA or release profiles who manage deployments, probes and configuration.


  1. The exam format and why time is the enemy

Aspect Indicative description
Type 100 % hands-on, browser-based terminal, proctored
Duration Around two hours
Number of tasks Roughly between 15 and 20
Pass mark Around 66 %
Clusters Several, with a context switch per task
Scoring Per task, with partial marks
Documentation Only kubernetes.io/docs, in one additional tab
Attempts Usually includes a free second attempt
Validity Around two years

2.1 The arithmetic you have to internalise

120 minutes / 17 tasks                          = 7.0 min per task
- 10 minutes of final review                    = 6.4 min per task
- ~30 s reading the statement and switching context = 5.9 min per task

Six minutes to read, understand, execute and verify. Typing a Deployment manifest by hand in vim costs 4-5 minutes. That is the whole problem and the whole solution: whoever generates the YAML with --dry-run and tweaks it takes 90 seconds; whoever types it out in full does not finish the exam.

2.2 Switching context

Every task starts with its context and its namespace:

kubectl config use-context rutas-norte-dev
kubectl config set-context --current --namespace=rutas-norte-dev

Solving a task on the wrong cluster is worth zero. And in the CKAD nearly every task names a specific namespace. Pick a strategy and be consistent:

  • A: set the namespace on the context at the start of each task (faster, requires remembering to change it).
  • B: put -n <ns> on absolutely every command (slower, impossible to forget).

2.3 Partial marks: your safety net

A typical task chains three or four requirements ("create a pod with two containers sharing an emptyDir, the first writes and the second reads"). If you get the pod and the volume but miss the second container, you already score. Half a pod is worth more than no pod: never leave a task empty.


  1. The curriculum domains and their indicative weights

Split published at the time of writing; verify it on the official website.

Domain Indicative weight What it covers
Application design and build ~20 % Multi-container patterns, Jobs/CronJobs, application volumes, images
Application deployment ~20 % Deployments, strategies, rollouts and rollbacks, basic Helm and Kustomize
Observability and maintenance ~15 % Probes, logs, debugging, API versioning and deprecations
Application environment, configuration and security ~25 % ConfigMaps, Secrets, securityContext, ServiceAccounts, resources, CRDs, quotas
Services and networking ~20 % Services, NetworkPolicies, Ingress

The heaviest domain is environment, configuration and security. It is also the most underestimated because "it looks easy": it is not, when they ask you for the specific variant you never practised.


  1. Full map: every CKAD objective and its lesson in this course

4.1 Application design and build (~20 %)

Official objective Course lesson
Define, build and modify container images 08-05-image-security, 11-03-cicd-with-kubernetes
Choose and use multi-container patterns (sidecar, adapter, ambassador) 06-04-init-containers-sidecars-and-patterns
Understand Jobs and CronJobs 06-03-jobs-and-cronjobs
Use application volumes (emptyDir, PVC) 05-01-volumes, 05-03-persistent-volume-claims
Init containers 06-04-init-containers-sidecars-and-patterns
Choose the right resource: Deployment, Pod or StatefulSet 02-01-pods, 02-03-deployments, 06-01-statefulsets

4.2 Application deployment (~20 %)

Official objective Course lesson
Use Deployments and perform rolling updates 02-03-deployments, 02-04-updates-rollbacks-and-strategies
Rollbacks and revision history control 02-04-updates-rollbacks-and-strategies
Deployment strategies: blue-green and canary 11-04-blue-green-and-canary-strategies
Use Helm to deploy existing packages 10-03-helm
Use Kustomize for configuration variants 10-04-kustomize
Scale applications 02-03-deployments, 09-01-horizontal-pod-autoscaling

4.3 Observability and maintenance (~15 %)

Official objective Course lesson
Understand API versioning and deprecations 01-06-objects-yaml-manifests-and-declarative-model
Implement liveness, readiness and startup probes 07-01-health-checks-and-probes
Use built-in monitoring tools 07-02-metrics-server-and-kubectl-top
Use container logs 07-05-centralized-logging-with-efk, 07-06-debugging-and-cluster-events
Debug on Kubernetes 07-06-debugging-and-cluster-events

4.4 Application environment, configuration and security (~25 %)

Official objective Course lesson
Discover and use resources that extend Kubernetes (CRDs, operators) 06-06-custom-resource-definitions, 06-07-operators-and-the-controller-pattern
Authentication, authorization and admission control 08-01-role-based-access-control, 08-03-pod-security-policies-and-standards
Define requests, limits and quotas 03-04-resource-quotas-and-limits, 03-05-limitranges-and-qos-classes
Understand ConfigMaps 03-01-configmaps, 03-03-environment-variables
Create and consume Secrets 03-02-secrets
Understand ServiceAccounts 03-06-serviceaccounts-and-api-access
Application security contexts 08-02-security-contexts-and-hardening

4.5 Services and networking (~20 %)

Official objective Course lesson
Basic understanding of NetworkPolicies 04-06-network-policies
Provide and troubleshoot access from outside the cluster 04-02-service-types, 04-04-ingress-controllers
Use Services to expose applications 04-02-service-types, 04-03-internal-dns-and-service-discovery
Ingress and routing rules 04-04-ingress-controllers, 04-05-tls-and-certificates-with-cert-manager

  1. The topics where people usually fail

These eight blocks account for most of the failures.

5.1 Multi-container pods and initContainers

An initContainer runs and finishes before the main containers start; a sidecar runs in parallel for the whole life of the pod. Since Kubernetes 1.29 there is the native sidecar: an initContainer with restartPolicy: Always, which starts first and stays alive.

apiVersion: v1
kind: Pod
metadata:
  name: bookings-api-with-sidecar
  namespace: rutas-norte-dev
spec:
  initContainers:
  - name: wait-for-postgres         # classic initContainer: blocks until it finishes
    image: busybox:1.36
    command: ['sh', '-c', 'until nc -z bookings-postgres 5432; do sleep 2; done']
  - name: log-collector             # NATIVE sidecar (1.29+): never finishes
    image: busybox:1.36
    restartPolicy: Always
    command: ['sh', '-c', 'tail -F /var/log/api/app.log']
    volumeMounts:
    - { name: logs, mountPath: /var/log/api }
  containers:
  - name: api
    image: node:20-alpine
    command: ['sh', '-c', 'while true; do date >> /var/log/api/app.log; sleep 5; done']
    volumeMounts:
    - { name: logs, mountPath: /var/log/api }
  volumes:
  - name: logs
    emptyDir: {}

Typical mistakes: putting the sidecar under containers when they ask for it to start first; forgetting that the containers in a pod share the network (they see each other on localhost) but not the filesystem, which requires a shared volume.

5.2 Jobs and CronJobs

Field Meaning Where it goes
completions Successful runs required Job.spec
parallelism Concurrent pods Job.spec
backoffLimit Retries before the Job is declared failed Job.spec
activeDeadlineSeconds Maximum total time Job.spec
ttlSecondsAfterFinished Automatic deletion once finished Job.spec
schedule Cron expression CronJob.spec
concurrencyPolicy Allow / Forbid / Replace CronJob.spec
startingDeadlineSeconds Grace window to launch a delayed run CronJob.spec
suspend Pause the CronJob CronJob.spec
successfulJobsHistoryLimit Finished Jobs kept CronJob.spec
apiVersion: batch/v1
kind: CronJob
metadata: { name: occupancy-reports, namespace: rutas-norte-pro }
spec:
  schedule: "0 3 * * *"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1800
      template:
        spec:
          restartPolicy: OnFailure        # mandatory: never "Always"
          containers:
          - name: reports
            image: rutas-norte/reports:1.4

Classic mistake: leaving restartPolicy: Always in a Job template; the API rejects it. Second mistake: mixing up the levels, putting backoffLimit in CronJob.spec instead of in jobTemplate.spec.

5.3 Choosing the right probe

Probe What happens if it fails When to use it
livenessProbe The container is restarted Detecting hung processes
readinessProbe The pod leaves the Service endpoints The process is alive but cannot serve yet
startupProbe Restarts and disables liveness and readiness until it passes Slow starts (JVM, migrations)
    startupProbe:
      httpGet: { path: /healthz, port: 3000 }
      failureThreshold: 30
      periodSeconds: 5          # up to 150 s of headroom to start
    readinessProbe:
      httpGet: { path: /ready, port: 3000 }
      periodSeconds: 10
    livenessProbe:
      httpGet: { path: /healthz, port: 3000 }
      periodSeconds: 15
      failureThreshold: 3

Typical mistakes: using a livenessProbe where the statement says "must not receive traffic until..." (that is readiness); using huge initialDelaySeconds values instead of a startupProbe.

5.4 securityContext at container level

Field Pod Container
runAsUser, runAsGroup, runAsNonRoot Yes Yes (the container's wins)
fsGroup Yes No
seccompProfile Yes Yes
allowPrivilegeEscalation No Yes
readOnlyRootFilesystem No Yes
capabilities No Yes
privileged No Yes
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    fsGroup: 2000
  containers:
  - name: api
    image: rutas-norte/bookings-api:2.3
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
        add: ["NET_BIND_SERVICE"]

Classic mistake: putting capabilities or readOnlyRootFilesystem at pod level. The API rejects the manifest and you lose two minutes working out why.

5.5 ConfigMaps and Secrets: the four ways

The statement will ask you for one specific way. You have to know this table.

Way Syntax When they ask for it
Individual variable env[].valueFrom.configMapKeyRef "variable X takes the value of key Y"
All keys as variables envFrom[].configMapRef "all the keys as environment variables"
File in a volume volumes[].configMap + volumeMounts "mounted at /etc/config"
Only one key as a file volumes[].configMap.items "only the key app.conf, at /etc/config/app.conf"
    env:
    - name: LOG_LEVEL                      # 1. individual variable
      valueFrom:
        configMapKeyRef: { name: api-config, key: log_level }
    - name: DB_PASSWORD                    # 1b. from a Secret
      valueFrom:
        secretKeyRef: { name: bookings-postgres-credentials, key: password }
    envFrom:
    - configMapRef: { name: api-config }   # 2. all the keys
    - secretRef: { name: bookings-postgres-credentials }
    volumeMounts:
    - { name: config, mountPath: /etc/config, readOnly: true }
  volumes:
  - name: config
    configMap:
      name: api-config
      items:                               # 4. only one key (without items → way 3)
      - { key: app.conf, path: app.conf }

Imperative creation, which is what saves time:

k create configmap api-config --from-literal=log_level=info --from-literal=timeout=30
k create configmap file-config --from-file=app.conf
k create secret generic bookings-postgres-credentials \
  --from-literal=username=bookings --from-literal=password='S3cr3t!'
k create secret docker-registry regcred --docker-server=registry.rutasnorte.es \
  --docker-username=ci --docker-password=xxx
k create secret tls web-store-tls --cert=tls.crt --key=tls.key

5.6 resources and their real effect

Concept Effect
requests What the scheduler uses to pick a node. A guaranteed reservation.
limits The ceiling. CPU is throttled; memory triggers OOMKilled.
Only limits Kubernetes copies limits into requests
Nothing BestEffort class: first to die under pressure
QoS class Condition
Guaranteed Every container has requests equal to limits, for CPU and memory
Burstable There are requests but they do not match limits (or some are missing)
BestEffort Neither requests nor limits on any container

Typical question: "make the pod have Guaranteed QoS". Answer: identical CPU and memory requests and limits on every container.

5.7 Deployment updates and rollbacks

k set image deployment/bookings-api api=rutas-norte/bookings-api:2.4
k rollout status deployment/bookings-api
k rollout history deployment/bookings-api
k rollout history deployment/bookings-api --revision=3
k rollout undo deployment/bookings-api                  # to the PREVIOUS revision
k rollout undo deployment/bookings-api --to-revision=2

# Pause to group several changes into a single rollout
k rollout pause deployment/bookings-api
k set resources deployment/bookings-api -c=api --limits=memory=512Mi
k rollout resume deployment/bookings-api
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # extra pods allowed above replicas
      maxUnavailable: 0    # none may be missing → deployment with no downtime

5.8 NetworkPolicies

The number one failure is not understanding that a NetworkPolicy selecting a pod makes it isolated for the policyTypes declared: from then on, only what is explicitly allowed gets through.

# Deny everything in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-all, namespace: rutas-norte-pro }
spec:
  podSelector: {}          # {} = every pod in the namespace
  policyTypes: [Ingress, Egress]
---
# Allow bookings-api to talk to postgres and resolve DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-egress, namespace: rutas-norte-pro }
spec:
  podSelector:
    matchLabels: { app: bookings-api }
  policyTypes: [Egress]
  egress:
  - to:
    - podSelector:
        matchLabels: { app: bookings-postgres }
    ports:
    - { protocol: TCP, port: 5432 }
  - to:                          # DNS: you almost always have to allow it
    - namespaceSelector:
        matchLabels: { kubernetes.io/metadata.name: kube-system }
    ports:
    - { protocol: UDP, port: 53 }

The detail that costs points: the dash changes everything.

  # A) ONE entry with two selectors (AND): pods app=api IN namespaces environment=pro
  - namespaceSelector: { matchLabels: { environment: pro } }
    podSelector: { matchLabels: { app: api } }

  # B) TWO entries (OR): any pod in namespaces environment=pro, PLUS
  #    the app=api pods of the policy's own namespace
  - namespaceSelector: { matchLabels: { environment: pro } }
  - podSelector: { matchLabels: { app: api } }

  1. The speed toolbox

This is the section that decides the pass. Everything that follows is typed in the first 60 seconds.

6.1 The start-up block

alias k=kubectl
export do='--dry-run=client -o yaml'
export now='--force --grace-period=0'
source <(kubectl completion bash)
complete -o default -F __start_kubectl k

With that, k run api --image=nginx $do > api.yaml generates the skeleton and k delete pod api $now deletes it instantly.

6.2 --dry-run=client -o yaml: the universal generator

# Pods
k run web-store --image=nginx:1.27-alpine $do > pod.yaml
k run debug --image=busybox:1.36 $do --command -- sleep 3600 > debug.yaml
k run api --image=node:20 --labels=app=api,tier=backend --port=3000 \
  --env=LOG_LEVEL=debug $do > api.yaml

# Deployments and Services
k create deployment bookings-api --image=rutas-norte/api:2.3 --replicas=3 $do > deploy.yaml
k expose deployment bookings-api --port=80 --target-port=3000 --name=api-svc $do > svc.yaml
k create service clusterip api-svc --tcp=80:3000 $do > svc.yaml
k create service nodeport store-svc --tcp=80:80 --node-port=30080 $do > svc.yaml

# Configuration, jobs, Ingress and RBAC
k create configmap api-config --from-literal=level=info $do > cm.yaml
k create secret generic cred --from-literal=pass=xxx $do > sec.yaml
k create job migration --image=rutas-norte/migrate:1.0 $do > job.yaml
k create cronjob reports --image=rutas-norte/reports:1.4 --schedule="0 3 * * *" $do \
  -- /bin/sh -c "reports.sh" > cj.yaml
k create ingress store --rule="www.rutasnorte.es/*=web-store-svc:80" $do > ing.yaml
k create serviceaccount support $do > sa.yaml
k create role reader --verb=get,list --resource=pods $do > role.yaml
k create rolebinding reader-b --role=reader --serviceaccount=ns:support $do > rb.yaml
k create quota qa-quota --hard=cpu=4,memory=8Gi,pods=20 $do > quota.yaml
k create poddisruptionbudget api-pdb --selector=app=api --min-available=2 $do > pdb.yaml

What has NO imperative generator and must be copied from the documentation: PersistentVolume, PersistentVolumeClaim, NetworkPolicy, StorageClass, a full securityContext, probes with all their fields and any CRD.

6.3 kubectl explain: the documentation without leaving the terminal

k explain pod.spec.containers.livenessProbe --recursive
k explain pod.spec.securityContext --recursive
k explain cronjob.spec --recursive
k explain networkpolicy | head -3        # recall the correct apiVersion
k api-resources | grep -i policy

It is quicker than opening the browser for doubts along the lines of "was it failureThreshold or failureCount?".

6.4 Imperative kubectl run and create: the cases that come up

# One-shot pods, to check connectivity or DNS
k run test --image=busybox:1.36 --rm -it --restart=Never -- wget -qO- api-svc:80
k run dns  --image=busybox:1.36 --rm -it --restart=Never -- nslookup api-svc

# Modify existing objects without touching YAML
k scale deployment bookings-api --replicas=5
k autoscale deployment bookings-api --min=2 --max=10 --cpu-percent=70
k set image deployment/bookings-api api=rutas-norte/api:2.4
k set resources deployment/bookings-api -c=api --limits=cpu=500m,memory=512Mi
k set serviceaccount deployment/bookings-api support
k set env deployment/bookings-api LOG_LEVEL=debug
k set env deployment/bookings-api --from=configmap/api-config
k label pod web-store environment=pro --overwrite
k annotate deployment bookings-api kubernetes.io/change-cause="bump to 2.4"
k cp rutas-norte-pro/bookings-api-abc:/var/log/app.log ./app.log

6.5 Filters and output formats that save minutes

k get events -n rutas-norte-pro --sort-by=.lastTimestamp     # the most useful when debugging
k get pods -A --sort-by=.status.containerStatuses[0].restartCount
k get pods -o name
k get pod bookings-api-abc -o jsonpath='{.status.podIP}'
k get pods -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName,STATUS:.status.phase
k get pods --field-selector status.phase=Running
k get pods -l 'environment in (pre,pro)'
k get pods -l '!app'                                  # pods WITHOUT the app label
k logs -l app=bookings-api --tail=50 --all-containers=true
k logs bookings-api-abc -c sidecar --previous          # the container before the crash

6.6 Fast editing with vim

cat <<'EOF' > ~/.vimrc
set number
set expandtab
set tabstop=2
set shiftwidth=2
set softtabstop=2
set autoindent
set paste
EOF
Setting Why it matters
expandtab YAML forbids tabs; without this your manifest will not validate
tabstop/shiftwidth/softtabstop at 2 The standard indentation for manifests
autoindent Keeps the level when you press Enter
set paste Critical: stops autoindent from wrecking YAML pasted from the documentation
number API errors give you a line number
Action Command Action Command
Go to line N :N Copy / paste line yy / p
Search / next /text / n Undo / redo u / Ctrl+r
Delete line / N lines dd / Ndd Indent block V, select, > or <
Replace in the file :%s/old/new/g Save / quit without saving :wq / :q!

6.7 kubectl patch for one-off changes

# Replicas
k patch deployment bookings-api -p '{"spec":{"replicas":5}}'

# Image of a specific container (strategic merge)
k patch deployment bookings-api \
  -p '{"spec":{"template":{"spec":{"containers":[{"name":"api","image":"rutas-norte/api:2.4"}]}}}}'

# Type of a Service
k patch svc web-store-svc -p '{"spec":{"type":"NodePort"}}'

# JSON patch to replace one exact field
k patch pv pv-reports --type='json' \
  -p='[{"op":"replace","path":"/spec/persistentVolumeReclaimPolicy","value":"Retain"}]'

# Multi-line YAML (handy for lists)
k patch deployment notifications-worker --type='strategic' -p '
spec:
  template:
    spec:
      tolerations:
      - { key: dedicated, operator: Equal, value: batch, effect: NoSchedule }'

When patch is not enough, KUBE_EDITOR=vim k edit deployment bookings-api. Careful: some fields are immutable (a Deployment's selector, the spec of a created Job). If edit fails for that reason, the route is to export, delete and recreate:

k get job migration -o yaml > job.yaml   # edit, remove status and volatile metadata
k delete job migration && k apply -f job.yaml

  1. Master table: what they will ask you → the fastest way to do it

Typical statement Fastest way
"Create a pod X with image Y" k run X --image=Y
"...that runs sleep 3600" k run X --image=Y --command -- sleep 3600
"...with the label app=z" k run X --image=Y --labels=app=z
"...exposing port 8080" k run X --image=Y --port=8080
"...and deletes itself when it finishes" k run X --image=Y --rm -it --restart=Never -- <cmd>
"Create a Deployment with N replicas" k create deployment X --image=Y --replicas=N
"Scale it to M replicas" k scale deployment X --replicas=M
"Expose it internally on port 80" k expose deployment X --port=80 --target-port=8080
"Expose it with NodePort 30080" k create service nodeport X --tcp=80:8080 --node-port=30080
"Update the image to version Z" k set image deployment/X c=img:Z
"Undo the last deployment" k rollout undo deployment/X
"Go back to revision 2" k rollout undo deployment/X --to-revision=2
"Check the deployment status" k rollout status deployment/X
"Autoscale it between 2 and 10 at 70 % CPU" k autoscale deployment X --min=2 --max=10 --cpu-percent=70
"Create a ConfigMap with these keys" k create configmap X --from-literal=a=1 --from-literal=b=2
"Create a ConfigMap from a file" k create configmap X --from-file=app.conf
"Create a Secret with a username and password" k create secret generic X --from-literal=user=u --from-literal=pass=p
"Create a TLS Secret" k create secret tls X --cert=a.crt --key=a.key
"Decode the Secret value" k get secret X -o jsonpath='{.data.pass}' | base64 -d
"Inject all the keys as variables" k set env deployment/D --from=configmap/X
"Job with 5 runs, 2 in parallel" k create job X --image=Y $do and add completions: 5, parallelism: 2
"CronJob every 5 minutes" k create cronjob X --image=Y --schedule="*/5 * * * *" -- <cmd>
"Suspend the CronJob" k patch cronjob X -p '{"spec":{"suspend":true}}'
"Trigger the CronJob now" k create job manual --from=cronjob/X
"Ingress for host H and path /r" k create ingress X --class=nginx --rule="H/r*=svc:80"
"Create an SA and use it in the Deployment" k create sa X + k set serviceaccount deployment/D X
"Create a Role and its RoleBinding" k create role R --verb=get,list --resource=pods + k create rolebinding B --role=R --serviceaccount=ns:sa
"Check that it has permission" k auth can-i list pods --as=system:serviceaccount:ns:sa -n ns
"Create a resource quota" k create quota X --hard=cpu=4,memory=8Gi,pods=20
"Add a toleration or nodeSelector" Generate with $do and edit, or k patch
"Label the node" k label node node-1 disk=ssd
"Find the pod that restarts the most" k get pods -A --sort-by=.status.containerStatuses[0].restartCount
"Save the logs to a file" k logs X -c c > /opt/output.log
"Logs of the container that crashed" k logs X --previous
"Sort the pods by CPU" k top pods -n ns --sort-by=cpu
"Get a shell inside the container" k exec -it X -c c -- sh
"Count the pods with the label" k get pods -l app=x --no-headers | wc -l
"Write the pod name to a file" k get pods -l app=x -o jsonpath='{.items[0].metadata.name}' > /opt/r.txt
"Delete the pod immediately" k delete pod X $now

  1. Eight CKAD-style tasks solved against the clock

Rutas Norte scenarios, timer running, only kubernetes.io open.

Task 1 — Multi-container pod with a shared volume (~6 %, target: 6 min)

rutas-norte-dev. Create a Pod occupancy-reports with two containers sharing an emptyDir called data mounted at /data. The generator container (busybox:1.36) writes the date to /data/occupancy.log every 5 seconds; the reader container (same image) prints that file to its standard output.

apiVersion: v1
kind: Pod
metadata: { name: occupancy-reports, namespace: rutas-norte-dev }
spec:
  containers:
  - name: generator
    image: busybox:1.36
    command: ['sh', '-c', 'while true; do date >> /data/occupancy.log; sleep 5; done']
    volumeMounts: [{ name: data, mountPath: /data }]
  - name: reader
    image: busybox:1.36
    command: ['sh', '-c', 'tail -F /data/occupancy.log']
    volumeMounts: [{ name: data, mountPath: /data }]
  volumes:
  - name: data
    emptyDir: {}
k logs occupancy-reports -c reader -n rutas-norte-dev
Wed Aug  6 09:14:22 UTC 2026
Wed Aug  6 09:14:27 UTC 2026

The trap: tail -F (uppercase) waits for the file to exist; tail -f fails if the generator has not created it yet. And the volume has to be mounted in both containers.

Task 2 — ConfigMap and Secret consumed in two ways (~6 %, target: 5 min)

rutas-norte-dev. Create the ConfigMap api-config with log_level=debug and max_connections=50, and the Secret bookings-postgres-credentials with username=bookings and password=Nort3!2026. Create a Pod bookings-api (nginx:1.27-alpine) that receives all the ConfigMap keys as environment variables and mounts the Secret as files at /etc/secrets.

k create configmap api-config --from-literal=log_level=debug \
  --from-literal=max_connections=50 -n rutas-norte-dev
k create secret generic bookings-postgres-credentials --from-literal=username=bookings \
  --from-literal=password='Nort3!2026' -n rutas-norte-dev
k run bookings-api --image=nginx:1.27-alpine $do -n rutas-norte-dev > api.yaml
spec:
  containers:
  - name: bookings-api
    image: nginx:1.27-alpine
    envFrom:
    - configMapRef: { name: api-config }
    volumeMounts:
    - { name: secrets, mountPath: /etc/secrets, readOnly: true }
  volumes:
  - name: secrets
    secret: { secretName: bookings-postgres-credentials }
k exec bookings-api -n rutas-norte-dev -- env | grep -E 'log_level|max_connections'
k exec bookings-api -n rutas-norte-dev -- cat /etc/secrets/username
log_level=debug
max_connections=50
bookings

The trap: envFrom is a sibling of env, not a child. And the password contains !: you must wrap it in single quotes so bash does not interpret it.

Task 3 — CronJob with a concurrency policy (~6 %, target: 5 min)

rutas-norte-pro. Create a CronJob nightly-reports at 3:00 every day, image busybox:1.36, command echo report generated. No overlapping runs, at most 2 retries, and only 3 successful runs in the history.

k create cronjob nightly-reports --image=busybox:1.36 --schedule="0 3 * * *" \
  -n rutas-norte-pro $do -- /bin/sh -c "echo report generated" > cj.yaml
spec:
  schedule: "0 3 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: nightly-reports
            image: busybox:1.36
            command: ["/bin/sh", "-c", "echo report generated"]
k create job test --from=cronjob/nightly-reports -n rutas-norte-pro
k logs job/test -n rutas-norte-pro
report generated

The trap: the levels. backoffLimit in jobTemplate.spec; concurrencyPolicy and successfulJobsHistoryLimit in CronJob.spec. And launching the Job with --from=cronjob/... is the way to verify without waiting until 3 in the morning.

Task 4 — Well-chosen probes (~7 %, target: 6 min)

rutas-norte-pre. The bookings-api Deployment takes up to 90 seconds to start. Configure it so that (a) it receives no traffic until it answers GET /ready on 3000; (b) it restarts if GET /healthz fails 3 times in a row; (c) neither of those two probes acts during start-up.

        startupProbe:
          httpGet: { path: /healthz, port: 3000 }
          periodSeconds: 10
          failureThreshold: 12        # 12 x 10s = 120 s of headroom
        readinessProbe:
          httpGet: { path: /ready, port: 3000 }
          periodSeconds: 10
        livenessProbe:
          httpGet: { path: /healthz, port: 3000 }
          periodSeconds: 10
          failureThreshold: 3
k rollout status deployment/bookings-api -n rutas-norte-pre
k describe pod -l app=bookings-api -n rutas-norte-pre | grep -E 'Liveness|Readiness|Startup'

The trap: the key is point (c). The answer is startupProbe, which suspends liveness and readiness until it passes. Solving it with initialDelaySeconds: 90 does not meet the statement precisely.

Task 5 — Rolling update with no downtime, and rollback (~7 %, target: 7 min)

rutas-norte-pro. The web-store Deployment must be updated without any pod going unavailable. Configure the strategy, update the image to nginx:1.27.2-alpine recording the cause, and then roll back to the previous revision.

k patch deployment web-store -n rutas-norte-pro -p '
spec:
  strategy:
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }'

k set image deployment/web-store web-store=nginx:1.27.2-alpine -n rutas-norte-pro
k annotate deployment web-store -n rutas-norte-pro \
  kubernetes.io/change-cause="update to nginx 1.27.2" --overwrite
k rollout status deployment/web-store -n rutas-norte-pro
k rollout history deployment/web-store -n rutas-norte-pro
REVISION  CHANGE-CAUSE
1         <none>
2         update to nginx 1.27.2
k rollout undo deployment/web-store -n rutas-norte-pro
k get deployment web-store -n rutas-norte-pro \
  -o jsonpath='{.spec.template.spec.containers[0].image}'
nginx:1.27-alpine

The trap: the container name in set image does not have to match the Deployment's. Check it first with -o jsonpath='{.spec.template.spec.containers[*].name}'.

Task 6 — Restrictive securityContext (~6 %, target: 5 min)

rutas-norte-pro. Create a Pod notifications-worker (busybox:1.36, sleep 3600) that runs as user 10001 and group 3000, with no privilege escalation, a read-only root filesystem and all kernel capabilities dropped.

apiVersion: v1
kind: Pod
metadata: { name: notifications-worker, namespace: rutas-norte-pro }
spec:
  securityContext:
    runAsUser: 10001
    runAsGroup: 3000
    runAsNonRoot: true
  containers:
  - name: worker
    image: busybox:1.36
    command: ["sleep", "3600"]
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities: { drop: ["ALL"] }
k exec notifications-worker -n rutas-norte-pro -- id
k exec notifications-worker -n rutas-norte-pro -- touch /test
uid=10001 gid=3000
touch: /test: Read-only file system

The trap: runAsUser can go on the pod or on the container; allowPrivilegeEscalation, readOnlyRootFilesystem and capabilities only on the container.

Task 7 — Service, Ingress and verification (~7 %, target: 7 min)

rutas-norte-pro. The web-store Deployment listens on port 80. Expose it with a ClusterIP Service web-store-svc and create an Ingress store-ingress routing www.rutasnorte.es/ to that Service, with the IngressClass nginx.

k expose deployment web-store --name=web-store-svc --port=80 --target-port=80 \
  -n rutas-norte-pro
k create ingress store-ingress -n rutas-norte-pro --class=nginx \
  --rule="www.rutasnorte.es/*=web-store-svc:80"

k describe ingress store-ingress -n rutas-norte-pro | grep -A4 Rules
k get endpoints web-store-svc -n rutas-norte-pro
Rules:
  Host                Path  Backends
  ----                ----  --------
  www.rutasnorte.es   /     web-store-svc:80 (10.244.1.9:80,10.244.2.4:80)

The trap: the /* generates pathType: Prefix; without the asterisk you would get Exact, which only matches the literal root. If the backends come back empty, the Service is not selecting any pods.

Task 8 — Diagnosing a pod that will not start (~7 %, target: 6 min)

rutas-norte-dev. The redis-cache pod has spent minutes without reaching Running. Find the cause, fix it, and record the reason in /opt/diagnosis.txt.

k get pods -n rutas-norte-dev
NAME          READY   STATUS                       RESTARTS   AGE
redis-cache   0/1     CreateContainerConfigError   0          4m
k describe pod redis-cache -n rutas-norte-dev | tail -6
Events:
  Warning  Failed  2m (x12 over 4m)  kubelet
    Error: configmap "redis-config" not found
k create configmap redis-config --from-literal=maxmemory=256mb -n rutas-norte-dev
k get pods -n rutas-norte-dev
echo "The pod referenced the ConfigMap redis-config, which does not exist in the namespace" \
  > /opt/diagnosis.txt

The trap: telling the statuses apart. This table covers almost the whole CKAD debugging domain:

Status Usual cause
ImagePullBackOff / ErrImagePull Nonexistent image, mistyped tag or registry credentials
CreateContainerConfigError Missing ConfigMap/Secret, or a key that does not exist
CrashLoopBackOff The process starts and dies: look at k logs --previous
Pending No node satisfies requests/affinity/taints, or an unbound PVC
Prolonged ContainerCreating A volume that will not mount or a CNI with problems
OOMKilled in lastState The memory limit is too low

Common Mistakes and Tips

Mistake Consequence Prevention
Writing the YAML by hand from scratch You run out of time $do + edit
Pasting YAML into vim without set paste Staircase indentation, invalid manifest ~/.vimrc in minute one
Forgetting the task's namespace The object does not score set-context --current --namespace=
Not switching context Zero points First command, always
Confusing liveness with readiness Half a task lost "traffic" → readiness; "restart" → liveness
restartPolicy: Always in a Job The API rejects it OnFailure or Never
capabilities at pod level Invalid manifest Container level only
backoffLimit in CronJob.spec Field ignored or rejected It goes in jobTemplate.spec
Getting stuck 15 minutes on one task You lose 3 easy tasks 8-minute cap, then move on
Not verifying what you created You think you scored and you did not A closing get/describe/exec
apply over immutable fields Cryptic error Export, delete and recreate

Tips that make the difference:

  1. Always time yourself. Practising without a clock does not prepare you for the CKAD.
  2. Memorise the start-up block. It is 20 seconds that give you back 20 minutes.
  3. Read the statement looking for the key noun: "must not receive traffic" → readiness; "all the keys" → envFrom; "only this key" → items.
  4. Practise copy and paste in the browser terminal: the shortcuts are different.
  5. Know where the pages with no imperative generator are: PV/PVC, NetworkPolicy, securityContext and probes.
  6. Always do the part you know. Partial marks are real.
  7. The last 10 minutes are for reviewing, not for attempting a new task.

Exercises

Exercise 1 — Five tasks in fifteen minutes

On a practice cluster with the namespace rutas-norte-dev, solve the following in 15 timed minutes:

  1. Deployment web-store with nginx:1.27-alpine, 4 replicas, exposed as NodePort 30080.
  2. ConfigMap web-config with environment=dev and cache=on, injected as variables into that Deployment.
  3. A Job db-migration with busybox:1.36 and echo migrated, 3 completions and 2 in parallel.
  4. An HPA for web-store between 2 and 8 replicas at 70 % CPU.
  5. Write to /opt/pods.txt the name of every pod in the namespace, sorted by name.

Exercise 2 — The complete multi-container task

In rutas-norte-pre, create a Pod bookings-api-full that brings together everything hard about the CKAD, in 10 minutes:

  • An initContainer wait-for-db that blocks until bookings-postgres:5432 answers.
  • A container api with nginx:1.27-alpine, readiness and liveness probes on / port 80, requests of 100m/128Mi and limits of 200m/256Mi.
  • A native sidecar logs (an initContainer with restartPolicy: Always) doing tail -F on a shared file.
  • An emptyDir shared between api and logs mounted at /var/log/nginx.
  • A securityContext with runAsNonRoot, allowPrivilegeEscalation: false and drop: ["ALL"].

Exercise 3 — Blind diagnosis

Ask someone else (or a script) to break three things in rutas-norte-dev without telling you which: a Deployment with a nonexistent image, a pod referencing a Secret that does not exist, a Service with the wrong selector, a pod with limits.memory set too low, or a Pending pod caused by an impossible nodeSelector.

Find and fix all three in 12 minutes, leaving the symptom and cause of each one in /opt/diagnosis.txt.


Solutions

Solution to Exercise 1

# 1 (≈90 s)
k create deployment web-store --image=nginx:1.27-alpine --replicas=4
k create service nodeport web-store --tcp=80:80 --node-port=30080

# 2 (≈60 s)
k create configmap web-config --from-literal=environment=dev --from-literal=cache=on
k set env deployment/web-store --from=configmap/web-config

# 3 (≈150 s): generate and add completions/parallelism
k create job db-migration --image=busybox:1.36 $do -- /bin/sh -c "echo migrated" > job.yaml
spec:
  completions: 3
  parallelism: 2
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: db-migration
        image: busybox:1.36
        command: ["/bin/sh", "-c", "echo migrated"]
k apply -f job.yaml
# 4 (≈30 s)
k autoscale deployment web-store --min=2 --max=8 --cpu-percent=70
# 5 (≈30 s)
k get pods --sort-by=.metadata.name -o name > /opt/pods.txt

The trick in point 2 is k set env --from=configmap/..., which inserts the envFrom without touching the YAML: writing it by hand would have cost two minutes more. And watch out for point 1: create service nodeport web-store inherits the selector app=web-store because it shares its name with the Deployment; verify it with k get endpoints web-store before calling the task done.

Solution to Exercise 2

apiVersion: v1
kind: Pod
metadata: { name: bookings-api-full, namespace: rutas-norte-pre }
spec:
  securityContext: { runAsNonRoot: true, runAsUser: 10001 }
  initContainers:
  - name: wait-for-db
    image: busybox:1.36
    command: ['sh', '-c', 'until nc -z bookings-postgres 5432; do sleep 2; done']
    securityContext:
      allowPrivilegeEscalation: false
      capabilities: { drop: ["ALL"] }
  - name: logs
    image: busybox:1.36
    restartPolicy: Always            # native sidecar (1.29+)
    command: ['sh', '-c', 'tail -F /var/log/nginx/access.log']
    volumeMounts: [{ name: nginx-logs, mountPath: /var/log/nginx }]
    securityContext:
      allowPrivilegeEscalation: false
      capabilities: { drop: ["ALL"] }
  containers:
  - name: api
    image: nginx:1.27-alpine
    ports: [{ containerPort: 80 }]
    resources:
      requests: { cpu: 100m, memory: 128Mi }
      limits:   { cpu: 200m, memory: 256Mi }
    readinessProbe:
      httpGet: { path: /, port: 80 }
      initialDelaySeconds: 5
    livenessProbe:
      httpGet: { path: /, port: 80 }
      periodSeconds: 15
      failureThreshold: 3
    volumeMounts: [{ name: nginx-logs, mountPath: /var/log/nginx }]
    securityContext:
      allowPrivilegeEscalation: false
      capabilities: { drop: ["ALL"] }
  volumes:
  - name: nginx-logs
    emptyDir: {}

Practical warning: nginx:1.27-alpine with runAsNonRoot and user 10001 will not start as it is, because it needs to write to /var/cache/nginx and /var/run. In the exam this does not matter (the manifest is graded, not the execution), but in real life you would have to mount an emptyDir on those directories or use nginxinc/nginx-unprivileged. It is exactly the detail you saw in 08-02.

Solution to Exercise 3

The methodology is what you practise here:

k get pods -n rutas-norte-dev -o wide                    # 1. what is not Running
k describe pod <name> -n rutas-norte-dev | tail -20      # 2. the events: 80 % of cases
k logs <name> -n rutas-norte-dev --previous              # 3. if it starts and dies
k get endpoints -n rutas-norte-dev                       # 4. if the problem is the Service
k get pods --show-labels -n rutas-norte-dev              #    do they match the selector?
k get events -n rutas-norte-dev --sort-by=.lastTimestamp | tail -20
Observed symptom Cause Fix
ErrImagePull, event manifest unknown Nonexistent tag k set image deployment/X c=image:valid-tag
CreateContainerConfigError Missing Secret/ConfigMap Create it with k create secret/configmap
Service with no endpoints The selector does not match the labels k patch svc X -p '{"spec":{"selector":{"app":"correct"}}}'
OOMKilled in lastState.terminated.reason limits.memory too low k set resources deployment/X -c=c --limits=memory=256Mi
Pending with didn't match node selector Impossible nodeSelector Label the node or drop the selector
cat <<'EOF' > /opt/diagnosis.txt
1. web-store: ErrImagePull due to nonexistent tag nginx:9.9 -> corrected to 1.27-alpine
2. bookings-api: CreateContainerConfigError, the Secret bookings-postgres-credentials was missing -> created
3. redis-cache-svc: no endpoints, selector app=redis against label app=redis-cache -> corrected
EOF

Classic mistake in this exercise: deleting the broken pods to "restart them". If they belong to a Deployment, they are recreated identically: you have to fix the cause in the template or the missing object.


Conclusion

The CKAD does not reward whoever knows the most Kubernetes: it rewards whoever solves more correct tasks per minute. Its curriculum is a familiar face from this course —Deployments, ConfigMaps, Secrets, probes, Jobs, Services, NetworkPolicies, securityContext— but with a timer overhead that turns fluency with kubectl into the decisive skill.

The essentials of this lesson:

  • The CKAD certifies building applications on the cluster, not administering it. The border with the CKA lies at etcd, nodes and the control plane, which are not covered here.
  • The heaviest domain is environment, configuration and security (~25 %): ConfigMaps, Secrets, resources, QoS and securityContext.
  • The arithmetic rules: around 6 minutes per task. Typing YAML by hand is incompatible with passing.
  • The toolbox is non-negotiable: alias k, $do, autocompletion, a .vimrc with set paste, kubectl explain --recursive and kubectl patch.
  • The eight topics in section 5 —multi-container pods, Jobs, probes, securityContext, the four ways of consuming configuration, resources, rollouts and NetworkPolicies— account for most of the failures.
  • The table in section 7 is your training cheat sheet: typical statement → fastest command.
  • Always check the current official curriculum on the Linux Foundation / CNCF website before enrolling.

With the CKA covering administration and the CKAD covering development, one vertex remains: security. In the next lesson we tackle the CKS, the most demanding of the three, the only one that requires a valid CKA to sit, and the one that will ask you to handle with confidence tools you already met in module 8: kube-bench, Trivy, Falco, AppArmor, seccomp, Pod Security Admission and admission policies.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved