CKAD in 30 Days | Day 2: Workloads
Deployment, StatefulSet, DaemonSet, Job, or CronJob: how to pick the right one and create it fast
This is Day 2 of CKAD in 30 Days. Yesterday was container images: how to build, push, and reference them. Today is about what runs the image: workload resources.
The CKAD curriculum names this skill explicitly:
Choose and use the right workload resource (Deployment, DaemonSet, CronJob, etc.).
The exam will not ask “what is a Deployment.” It will say “run three replicas of this app in this namespace” and watch whether you reach for Deployment or Pod. It will say “run this script every hour” and watch whether you create a Job and a schedule, or a CronJob. Picking the wrong resource costs you the task.
So this chapter is about picking right first, then creating fast.
The lesson
1. The decision tree
Start here every time you need to run something on Kubernetes:
Does the app run forever?
├── Yes
│ ├── Needs stable name + storage?
│ │ └── StatefulSet
│ └── Stateless, many replicas?
│ └── Deployment
└── No, it finishes
├── Run on a schedule?
│ └── CronJob
└── Run once (or a fixed number)?
└── Job
Need exactly one Pod on every node?
└── DaemonSet
95% of CKAD workload questions are answered by walking this tree.
The tree is also what will keep you calm in the exam. Instead of thinking “what should I use for this?” you walk down three questions and arrive at the answer every time.
2. Pod: the smallest unit
A Pod is one or more containers that share network and storage. It is the atom of Kubernetes.
apiVersion: v1
kind: Pod
metadata:
name: hello
spec:
containers:
- name: web
image: nginx:1.25Fast-create:
kubectl run hello --image=nginx:1.25Pods are rarely used alone in real work. They have no self-healing. If a Pod dies, it stays dead. If the node reboots, it is gone forever.
You will use Pods directly on the exam only when the task says “create a Pod.” Otherwise reach for a Deployment.
3. Deployment: stateless app, your default
The 90% choice. A long-running stateless app with many identical replicas.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.25Fast-create:
kubectl create deployment web --image=nginx:1.25 --replicas=3What a Deployment gives you that a bare Pod does not:
- Self-healing. If a Pod dies, the Deployment creates a new one.
- Rolling updates. Change the image and Pods roll one at a time (covered on Day 9).
- Rollback. Go back to the previous image with one command.
- Scaling. More replicas, fewer replicas, in one command.
4. StatefulSet: stable identity + sticky storage
For apps that need stable names and storage that follows the Pod across restarts. Databases, Kafka, Elasticsearch, ZooKeeper.
Each Pod gets:
- A stable name:
web-0,web-1,web-2, in order. - A sticky PersistentVolumeClaim. Deleting the Pod does not delete its storage.
- Ordered startup and shutdown.
Kubernetes has no kubectl create statefulset command. The trick:
kubectl create deployment db --image=postgres:16 \
--dry-run=client -o yaml > sts.yamlThen edit sts.yaml:
- Change
kind: Deploymenttokind: StatefulSet. TheapiVersionstaysapps/v1. - Remove
spec.strategy(StatefulSet usesupdateStrategyinstead). - Add
spec.serviceName: db-headless(a Headless Service for stable DNS, which you create separately withclusterIP: None). - Add
spec.volumeClaimTemplates:for the per-Pod storage.
A minimum StatefulSet:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db-headless
replicas: 3
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1GiEach replica gets its own PVC called data-db-0, data-db-1, data-db-2.
5. DaemonSet: one Pod per node
For things that must run on every node: log collectors (fluentd, filebeat), monitoring agents (node-exporter), CNI plugins (calico, cilium), CSI drivers.
No imperative command. Same trick as StatefulSet:
kubectl create deployment agent --image=fluentd \
--dry-run=client -o yaml > ds.yamlThen edit:
- Change
kind: Deploymenttokind: DaemonSet. - Remove
spec.replicas(DaemonSet decides this automatically). - Remove
spec.strategy(DaemonSet uses its own update strategy).
A minimum DaemonSet:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: agent
spec:
selector:
matchLabels:
app: agent
template:
metadata:
labels:
app: agent
spec:
containers:
- name: fluentd
image: fluentdAs nodes join the cluster, Kubernetes schedules an agent Pod on them. As nodes leave, it removes them.
6. Job: run a finite task
For things that start, do work, and finish. Database migrations. One-off data processing. A cleanup script.
kubectl create job hello --image=busybox -- echo hiFive fields the exam tests:
apiVersion: batch/v1
kind: Job
metadata:
name: workers
spec:
completions: 10
parallelism: 3
backoffLimit: 4
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 60
template:
spec:
restartPolicy: OnFailure
containers:
- name: worker
image: busybox
command: ["sh", "-c", "echo working; sleep 1"]What each field does:
completions: how many successful Pod runs you want in total.parallelism: how many Pods can run at the same time.backoffLimit: how many failed Pods before the Job gives up.activeDeadlineSeconds: hard timeout on the whole Job.ttlSecondsAfterFinished: auto-delete the Job this many seconds after it finishes.
The gotcha: a Job’s Pod template cannot use restartPolicy: Always. Only OnFailure or Never. Kubernetes rejects the Job if you try.
7. CronJob: a Job on a schedule
Standard cron syntax (minute, hour, day of month, month, day of week).
kubectl create cronjob tick --image=busybox \
--schedule="*/5 * * * *" -- echo tickFull YAML:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
suspend: false
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: my-backup-tool:1.0What each field does:
schedule: standard cron syntax.concurrencyPolicy:Allow(default),Forbid(skip if the last one is still running), orReplace(kill the running one, start a new one).successfulJobsHistoryLimit: how many successful Jobs to keep around. Default 3.failedJobsHistoryLimit: same for failed Jobs. Default 1.suspend: set totrueto pause the schedule without deleting the CronJob.
Trigger a run now (great for testing):
kubectl create job manual-run --from=cronjob/nightly-backup8. The imperative shortcuts, side by side
# alias k=kubectl (preconfigured in the exam environment)# Deployment
k create deployment web --image=nginx:1.25 --replicas=3# Job
k create job hello --image=busybox -- echo hi# CronJob
k create cronjob tick --image=busybox --schedule="*/5 * * * *" -- echo hi# StatefulSet, DaemonSet - no imperative command, use dry-run on a Deployment
k create deployment X --image=Y --dry-run=client -o yaml > X.yaml
# then edit kind
Quick reference card
# Fastest paths
k create deploy web --image=nginx:1.25 --replicas=3
k run app --image=busybox --restart=Never -- echo hi # a bare Pod
k create job hello --image=busybox -- echo hi
k create cronjob tick --image=busybox --schedule="*/5 * * * *" -- echo tick# Trigger a CronJob run manually
k create job run-$(date +%s) --from=cronjob/NAME# Generate YAML then edit for StatefulSet / DaemonSet
k create deploy X --image=Y --dry-run=client -o yaml > X.yaml
Mental model: Deployment for stateless, StatefulSet for stateful, DaemonSet for per-node, Job for finite, CronJob for scheduled.
Practice tasks
All exam-shape. Specific resources. Specific constraints. Try each one before reading the solution.
Task 1 — Pick the right workload
For each of the following, choose the right workload resource. No YAML needed, just the name.
- A log collector that must run on every node in the cluster.
- A nightly database backup script.
- A stateless web API with 5 replicas behind a Service.
- A PostgreSQL cluster with 3 nodes, each with its own 10Gi volume that must survive Pod restarts.
- A one-off script to migrate data from the old database to the new one.
Solution
- DaemonSet: one Pod on every node, scales with the cluster.
- CronJob: runs on a schedule.
- Deployment: stateless, many replicas, the default choice.
- StatefulSet: stable identity plus sticky PVCs that follow each Pod.
- Job: runs once to completion.
Why this is the exam shape: picking the resource is half the answer. If you create a Deployment to run a one-off script, you fail the task even if the YAML is otherwise perfect.
Task 2 — Create a Deployment imperatively
Create a Deployment named web in the namespace production with the image nginx:1.25 and 4 replicas. The namespace does not exist yet.
Get Nadun Kumarasinghe’s stories in your inbox
Join Medium for free to get updates from this writer.
Solution
kubectl create namespace production
kubectl -n production create deployment web --image=nginx:1.25 --replicas=4Verify:
kubectl -n production get deploy web
kubectl -n production get pods -l app=webWhy this is the exam shape: the namespace does not exist. If you skip the first command, the Deployment command errors out. The task gives you a specific namespace, so always use -n <namespace> or --namespace.
Task 3 — Create a Job that runs 6 times, 3 at a time
Create a Job named workers using image busybox. It must successfully complete 6 times. Up to 3 Pods may run at the same time. The Job must give up after 4 total Pod failures. Each Pod should run sh -c "echo done".
Solution
The imperative command cannot set completions and parallelism. Generate the YAML, then edit:
kubectl create job workers --image=busybox \
--dry-run=client -o yaml -- sh -c "echo done" > workers.yamlEdit workers.yaml to add the three fields:
apiVersion: batch/v1
kind: Job
metadata:
name: workers
spec:
completions: 6
parallelism: 3
backoffLimit: 4
template:
spec:
restartPolicy: OnFailure
containers:
- name: workers
image: busybox
command: ["sh", "-c", "echo done"]Apply:
kubectl apply -f workers.yaml
kubectl get jobs workers -wWhy this is the exam shape: three fields that the imperative command does not expose. You must know they exist and where to put them. A restartPolicy of OnFailure or Never is required, because a Job cannot use Always.
Task 4 — Create a CronJob that does not overlap itself
Create a CronJob named nightly-sync that runs every 15 minutes using image busybox and the command sh -c "sleep 20; echo done".
Runs must never overlap. If one run is still going when the next is scheduled, the next must skip.
Keep only the 2 most recent successful Jobs and the 1 most recent failed Job in history.
Solution
Generate the YAML first:
kubectl create cronjob nightly-sync --image=busybox \
--schedule="*/15 * * * *" \
--dry-run=client -o yaml -- sh -c "sleep 20; echo done" > cron.yamlEdit to add concurrencyPolicy and the history limits:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-sync
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: nightly-sync
image: busybox
command: ["sh", "-c", "sleep 20; echo done"]Apply and verify:
bash
kubectl apply -f cron.yaml
kubectl get cronjob nightly-sync
kubectl get jobs --watchWhy this is the exam shape: three fields that look optional but completely change behavior. Without concurrencyPolicy: Forbid, slow runs overlap. Without the history limits you fall back to the defaults (3 successful, 1 failed), which is not what the task asked for.
Task 5 — Convert a Deployment YAML into a DaemonSet
A Deployment YAML is at /root/agent-deploy.yaml. It creates a fluentd log collector.
Convert it into a DaemonSet named fluentd-agent so that it runs one Pod on every node in the cluster. The DaemonSet must land in the logging namespace.
Do not delete the original file.
Solution
Copy the file first so the original is untouched:
cp /root/agent-deploy.yaml /root/agent-daemon.yamlEdit /root/agent-daemon.yaml:
- Change
kind: Deploymenttokind: DaemonSet. - Change
metadata.nametofluentd-agent. - Add
metadata.namespace: logging(or apply with-n logging). - Remove
spec.replicas(DaemonSet does not use it). - Remove
spec.strategyif present.
Create the namespace and apply:
kubectl create namespace logging
kubectl apply -f /root/agent-daemon.yamlVerify one Pod per node:
kubectl -n logging get pods -o wide
kubectl get nodesThe number of Pods should equal the number of nodes. On some clusters the control-plane node has a taint, so it gets no Pod unless you add a toleration.
Why this is the exam shape: a Deployment-to-DaemonSet conversion is a common exam task. If you leave replicas or strategy in, Kubernetes rejects the YAML.
Task 6 — Create a CronJob with full specification
Create a CronJob named backup-job in namespace default with the following specification:
- Schedule: run every 30 minutes (
*/30 * * * *) - Image:
busybox:latest - Container command:
echo "Backup completed" - Set
successfulJobsHistoryLimit: 3 - Set
failedJobsHistoryLimit: 2 - Set
activeDeadlineSeconds: 300 - Use
restartPolicy: Never
Tip: use kubectl explain cronjob.spec to find the correct field names.
Solution
Generate the base YAML:
kubectl create cronjob backup-job --image=busybox:latest \
--schedule="*/30 * * * *" \
--dry-run=client -o yaml -- echo "Backup completed" > backup-job.yamlEdit backup-job.yaml and add the four extra fields. The full result:
apiVersion: batch/v1
kind: CronJob
metadata:
name: backup-job
namespace: default
spec:
schedule: "*/30 * * * *"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 2
jobTemplate:
spec:
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: Never
containers:
- name: backup-job
image: busybox:latest
command: ["echo", "Backup completed"]Apply:
kubectl apply -f backup-job.yamlVerify every field landed correctly:
kubectl get cronjob backup-job -o yaml | grep -E "schedule|History|DeadlineSeconds|restartPolicy"Why this is the exam shape: seven specific constraints in one task. If any one is wrong, you lose points. activeDeadlineSeconds goes on jobTemplate.spec (the Job’s deadline), not on the CronJob itself. If memory fails, kubectl explain cronjob.spec.jobTemplate.spec shows you where it goes. Note also where restartPolicy lives: inside the Pod template, not on the CronJob.
Task 7 — Create a Pod and save a kubectl command to a file
Create a single Pod of image httpd:2.4.41-alpine in namespace default. The Pod should be named pod1 and the container should be named pod1-container.
Your manager would like to run a command manually on occasion to output the status of that exact Pod. Write a command that does this into /opt/course/2/pod1-status-command.sh. The command should use kubectl.
Solution
Create the Pod. The imperative command names the Pod, but the container ends up with the same name as the Pod. To rename the container, generate YAML and edit:
kubectl run pod1 --image=httpd:2.4.41-alpine \
--dry-run=client -o yaml > pod1.yamlEdit pod1.yaml to rename the container:
apiVersion: v1
kind: Pod
metadata:
name: pod1
namespace: default
spec:
containers:
- name: pod1-container
image: httpd:2.4.41-alpineApply:
kubectl apply -f pod1.yamlWrite the status command to the file. Use > to create a file with content:
mkdir -p /opt/course/2
echo "kubectl get pod pod1 -o yaml" > /opt/course/2/pod1-status-command.sh
chmod +x /opt/course/2/pod1-status-command.shVerify:
cat /opt/course/2/pod1-status-command.sh
/opt/course/2/pod1-status-command.sh | head -20Why this is the exam shape: it is a two-part task. If you only create the Pod and forget the script, half the points are gone. The script content is intentionally simple. The exam is testing whether you read the whole task and whether you can save a command to a file. Simpler variants of the kubectl command also pass: kubectl describe pod pod1, kubectl get pod pod1, kubectl get pod pod1 -o wide.
Task 8 — Job template with nine specific constraints
Team Neptune needs a Job template located at /opt/course/3/job.yaml.
This Job should:
- run image
busybox:1.31.0, - execute
sleep 2 && echo done, - be in namespace
neptune, - run a total of 3 times,
- execute 2 runs in parallel,
- have each Pod labelled with
id: awesome-job, - be named
neb-new-job, - have the container named
neb-new-job-container.
Start the Job and check its history.
Solution
Create the namespace if it does not exist, then generate the base YAML:
kubectl create namespace neptune
mkdir -p /opt/course/3kubectl -n neptune create job neb-new-job \
--image=busybox:1.31.0 \
--dry-run=client -o yaml \
-- sh -c "sleep 2 && echo done" > /opt/course/3/job.yamlEdit /opt/course/3/job.yaml to add the extra fields and labels:
apiVersion: batch/v1
kind: Job
metadata:
name: neb-new-job
namespace: neptune
spec:
completions: 3
parallelism: 2
template:
metadata:
labels:
id: awesome-job
spec:
restartPolicy: Never
containers:
- name: neb-new-job-container
image: busybox:1.31.0
command: ["sh", "-c", "sleep 2 && echo done"]Apply and watch:
kubectl apply -f /opt/course/3/job.yaml
kubectl -n neptune get jobs
kubectl -n neptune get pods -l id=awesome-jobCheck the history (the Pods created by the Job, including completed ones):
kubectl -n neptune get pods -l id=awesome-job -o wide
kubectl -n neptune describe job neb-new-jobWhy this is the exam shape: nine specific constraints in one task, counting the file path. It is easy to miss the label on the Pod template (spec.template.metadata.labels, not metadata.labels on the Job itself). It is easy to misname the container or forget the namespace. The exam gives you a path for the YAML file, so use that path, not your own.
The imperative shortcut only gets you about 40% of the way. The rest is hand-edited. This is the realistic exam experience: generate the shell, then fill in the fields the task asked for.
Gotchas you would hit in the exam
- StatefulSet and DaemonSet have no imperative command. Generate a Deployment YAML with
--dry-run=client -o yaml, then editkind. Nobody expects you to type the YAML from memory. - Jobs cannot use
restartPolicy: Always. OnlyOnFailureorNever. The error message if you try is cryptic. - A CronJob without
concurrencyPolicyallows overlapping runs. For most tasks, you wantForbid. - DaemonSet does not take
replicasorstrategy. Remove them when converting from a Deployment. --from=cronjob/NAMEtriggers a one-off Job that uses the CronJob’s template. Perfect for testing without waiting for the next scheduled run.- Namespace matters. If the task says “in namespace X,” use
-n Xon every command. Resources in different namespaces do not see each other.









