Workload Controllers
Learn how Kubernetes controllers like Deployments, StatefulSets, DaemonSets, and Jobs maintain desired state and handle reconciliation.
The Reconciliation Loop
Every Kubernetes controller runs the same basic loop:
┌─────────────────────────────────────────┐
│ Controller loop │
│ │
│ 1. Observe — read current state │
│ 2. Diff — compare to desired state │
│ 3. Act — create/update/delete │
│ 4. Repeat — forever │
└─────────────────────────────────────────┘
You never tell Kubernetes “start 3 pods”. You declare replicas: 3 and the controller figures out what to do.
Deployment (Stateless Apps)
The controller you’ll use most. Manages rolling updates and rollbacks.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 3
selector:
matchLabels:
app: web-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: web-app
spec:
containers:
- name: web
image: nginx:1.25
ports:
- containerPort: 80
# Watch the reconciliation happen
kubectl apply -f deployment.yaml
kubectl get replicasets -w
# Manually delete a pod — Deployment recreates it immediately
kubectl delete pod <pod-name>
kubectl get pods -w # new pod appears within seconds
ReplicaSet (Managed by Deployment)
You rarely create ReplicaSets directly, but understanding them helps.
# See the ReplicaSets a Deployment manages
kubectl get replicasets -l app=web-app
# After updating image tag, Deployment creates a NEW ReplicaSet
# Old RS scales to 0, new RS scales up
kubectl rollout history deployment/web-app
kubectl get replicasets # you'll see two: old (0 pods) and new
StatefulSet (Stateful Apps)
For workloads that need stable pod identity and ordered startup/shutdown — databases, message queues.
# statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres-headless # must reference a headless service
replicas: 3
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: pg-secret
key: POSTGRES_PASSWORD
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # each pod gets its OWN PVC
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
# Required headless service for stable DNS
apiVersion: v1
kind: Service
metadata:
name: postgres-headless
spec:
clusterIP: None
selector:
app: postgres
ports:
- port: 5432
kubectl apply -f statefulset.yaml
# Pods are named with ordinal index: postgres-0, postgres-1, postgres-2
kubectl get pods -l app=postgres
# Each gets a stable DNS name:
# postgres-0.postgres-headless.default.svc.cluster.local
# postgres-1.postgres-headless.default.svc.cluster.local
# Each pod gets its own PVC
kubectl get pvc
Key differences from Deployment:
| Deployment | StatefulSet | |
|---|---|---|
| Pod names | Random suffix | Stable ordinal (app-0, app-1) |
| Startup order | Parallel | Ordered (0 → 1 → 2) |
| Storage | Shared or none | Each pod gets its own PVC |
| DNS | Service only | Individual pod DNS names |
DaemonSet (One Pod per Node)
Ensures exactly one Pod runs on every node. Ideal for log collectors, monitoring agents, network plugins.
# daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: log-collector
spec:
selector:
matchLabels:
app: log-collector
template:
metadata:
labels:
app: log-collector
spec:
containers:
- name: fluentd
image: fluent/fluentd:v1.16
volumeMounts:
- name: varlog
mountPath: /var/log
volumes:
- name: varlog
hostPath:
path: /var/log
kubectl apply -f daemonset.yaml
# One pod per node — scales automatically when nodes are added/removed
kubectl get daemonset
kubectl get pods -l app=log-collector -o wide # shows which node each runs on
Job (Run-to-Completion)
Runs one or more pods to successful completion — database migrations, batch processing, one-time scripts.
# job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
spec:
completions: 1
backoffLimit: 3 # retry up to 3 times on failure
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: my-app:1.0
command: ["python", "manage.py", "migrate"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: DATABASE_URL
kubectl apply -f job.yaml
kubectl get jobs
kubectl logs job/db-migrate
CronJob (Scheduled Jobs)
# cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 2 * * *" # every day at 2 AM (cron syntax)
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: my-backup-tool:1.0
command: ["./backup.sh"]
kubectl apply -f cronjob.yaml
kubectl get cronjobs
# Manually trigger the job
kubectl create job backup-now --from=cronjob/nightly-backup
# See job runs
kubectl get jobs
Controller Comparison
| Controller | Use case | Pods have stable identity? | Persistent storage per pod? |
|---|---|---|---|
| Deployment | Stateless web/API servers | No | No |
| StatefulSet | Databases, queues | Yes | Yes |
| DaemonSet | Node-level agents (logging, monitoring) | No | No |
| Job | One-off batch tasks, migrations | No | No |
| CronJob | Scheduled recurring tasks | No | No |
Learning Outcomes
You can:
- Explain the reconciliation loop and why controllers are central to Kubernetes
- Choose the right controller for stateless apps, databases, node agents, and batch jobs
- Write Deployment, StatefulSet, DaemonSet, Job, and CronJob manifests
- Understand why StatefulSets are required for databases
- Observe reconciliation with
kubectl get pods -wandkubectl get replicasets