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
- What the CKAD certifies and how it differs from the CKA
- The exam format and why time is the enemy
- The curriculum domains and their indicative weights
- Full map: every CKAD objective and its lesson in this course
- The topics where people usually fail
- The speed toolbox
- Master table: what they will ask you → the fastest way to do it
- Eight CKAD-style tasks solved against the clock
- 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.
- 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 taskSix 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-devSolving 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.
- 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.
- 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 |
- 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.4Classic 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: 3Typical 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.key5.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-apispec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # extra pods allowed above replicas
maxUnavailable: 0 # none may be missing → deployment with no downtime5.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 } }
- 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 kWith 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.yamlWhat 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 policyIt 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.log6.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 crash6.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
- 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 |
- 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 Podoccupancy-reportswith two containers sharing anemptyDircalleddatamounted at/data. Thegeneratorcontainer (busybox:1.36) writes the date to/data/occupancy.logevery 5 seconds; thereadercontainer (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: {}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 ConfigMapapi-configwithlog_level=debugandmax_connections=50, and the Secretbookings-postgres-credentialswithusername=bookingsandpassword=Nort3!2026. Create a Podbookings-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.yamlspec:
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/usernameThe 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 CronJobnightly-reportsat 3:00 every day, imagebusybox:1.36, commandecho 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.yamlspec:
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-proThe 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. Thebookings-apiDeployment takes up to 90 seconds to start. Configure it so that (a) it receives no traffic until it answersGET /readyon 3000; (b) it restarts ifGET /healthzfails 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: 3k 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. Theweb-storeDeployment must be updated without any pod going unavailable. Configure the strategy, update the image tonginx:1.27.2-alpinerecording 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-prok 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}'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 Podnotifications-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 /testThe 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. Theweb-storeDeployment listens on port 80. Expose it with a ClusterIP Serviceweb-store-svcand create an Ingressstore-ingressroutingwww.rutasnorte.es/to that Service, with the IngressClassnginx.
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-proRules:
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. Theredis-cachepod has spent minutes without reachingRunning. Find the cause, fix it, and record the reason in/opt/diagnosis.txt.
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.txtThe 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:
- Always time yourself. Practising without a clock does not prepare you for the CKAD.
- Memorise the start-up block. It is 20 seconds that give you back 20 minutes.
- Read the statement looking for the key noun: "must not receive traffic" → readiness; "all the keys" →
envFrom; "only this key" →items. - Practise copy and paste in the browser terminal: the shortcuts are different.
- Know where the pages with no imperative generator are: PV/PVC, NetworkPolicy, securityContext and probes.
- Always do the part you know. Partial marks are real.
- 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:
- Deployment
web-storewithnginx:1.27-alpine, 4 replicas, exposed as NodePort 30080. - ConfigMap
web-configwithenvironment=devandcache=on, injected as variables into that Deployment. - A Job
db-migrationwithbusybox:1.36andecho migrated, 3 completions and 2 in parallel. - An HPA for
web-storebetween 2 and 8 replicas at 70 % CPU. - Write to
/opt/pods.txtthe 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-dbthat blocks untilbookings-postgres:5432answers. - A container
apiwithnginx:1.27-alpine, readiness and liveness probes on/port 80,requestsof 100m/128Mi andlimitsof 200m/256Mi. - A native sidecar
logs(an initContainer withrestartPolicy: Always) doingtail -Fon a shared file. - An
emptyDirshared betweenapiandlogsmounted at/var/log/nginx. - A
securityContextwithrunAsNonRoot,allowPrivilegeEscalation: falseanddrop: ["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.yamlspec:
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.txtThe 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
EOFClassic 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.vimrcwithset paste,kubectl explain --recursiveandkubectl 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
