Kubernetes · Core

Kubernetes Workloads

ReplicaSets and why a stuck rollout is legible, DaemonSets and the update strategy that silently never rolls, Jobs and CronJobs that pile up on their own schedule, and the liveness probe that turns a dependency blip into an outage.

26 min read Level: core → advanced Kubernetes 01 / 05
The model

FROM INTENT TO A RUNNING CONTAINER

You declare what you want; a chain of controllers argues reality towards it. Knowing which link owns what is most of debugging.

YOU APPLYDeploymentDaemonSetJob / CronJobStatefulSetCONTROLLERdeployment-controllerdaemonset-controllerjob-controllercronjob-controllerOWNSReplicaSet (one per revision)CREATESPodSCHEDULEDkube-scheduler → nodeRUN BYkubeletcontainer runtimeGATED BYstartupProbereadinessProbe → EndpointslivenessProbe → restart

The probes at the bottom are not part of starting the pod — they decide, continuously, whether it gets traffic and whether it gets killed. That is why they cause outages disproportionate to their size.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat is inside the kubelet, the thing that actually starts containers?
KUBELETbound Pod specnode conditionscontainerlifecyclestatus back toapiserverSOURCES OF TRUTHapiserver watchpods bound to this nodestatic manifests/etc/kubernetes/manifestsPLEGrelists the runtimeTHE SYNC LOOPsyncPod per poddesired vs actualprobe workersstartup / readiness / livenesseviction manageron disk or memory pressurePLUGINS IT DRIVESCRI → containerdcreate / start / killCNIpod gets an IPCSImount volumes
The kubelet owns exactly one node and reconciles only the pods bound to it. Nothing here knows about Deployments — by the time the kubelet sees it, a workload is just a Pod with a node name on it.
ConnectionWhat happens between kubectl apply and a running container?
kubectl applyapiserverHTTPS :6443etcdwrite, then watch firesdeployment controllercreates ReplicaSetreplicaset controllercreates Podschedulersets spec.nodeNamekubelet on that nodewatch matchcontainerd →containerCRI
Eight hops, each one a separate controller reading and writing the same store. A pod stuck Pending has not reached the scheduler's binding hop; a pod stuck ContainerCreating is past it and stuck in CNI or CSI.
Core

THE FOUR WORKLOAD BEHAVIOURS

What each controller guarantees, and the field in each that causes the incident.

You write a Deployment; the deployment controller creates a ReplicaSet per revision and scales them against each other. The Deployment is the rollout strategy. The ReplicaSet is the thing that actually keeps N pods alive.

That two-layer split is why kubectl get rs is one of the most informative commands during a bad deploy. A rollout that is stuck shows it plainly: the new ReplicaSet has desired=3 current=1 ready=0 while the old one still has 3 ready. The Deployment just says "progressing".

The bits that surprise people

  • Old ReplicaSets are kept, scaled to zero. That is the rollback target. revisionHistoryLimit (default 10) decides how many, and setting it to 0 means you cannot rollout undo at all.
  • The selector is immutable. Change spec.selector on a live Deployment and the API rejects it — you delete and recreate, which is a real outage if you did not plan it.
  • Adoption is by label. A ReplicaSet adopts any matching pod without an owner. Two controllers with overlapping selectors will fight over the same pods indefinitely.
reading a stuck rollout properly
$ kubectl get rs -l app=api
NAME DESIRED CURRENT READY AGE
api-7d9f4b8c6d 3 1 0 6m <- new, not coming up
api-5c8a1f2e9b 3 3 3 9d <- old, still serving
the Deployment alone would only say 'ReplicaSetUpdated'. The rs tells you
that one new pod was created and never became ready — so look at THAT pod:
$ kubectl describe pod -l pod-template-hash=7d9f4b8c6d | tail -20
$ kubectl rollout undo deploy/api --to-revision=2

A DaemonSet runs one pod per matching node, and adds one automatically when a node joins. It is the shape for anything that is about the node: log collectors, CNI agents, node exporters, storage drivers.

Two behaviours differ from every other workload:

  • It tolerates more by default. DaemonSet pods get tolerations for node.kubernetes.io/not-ready, unreachable, disk-pressure and others automatically — because a node agent that evacuates itself the moment the node is unhealthy is useless precisely when you need it.
  • It ignores kubectl drain. Drain skips DaemonSet pods, which is why --ignore-daemonsets exists and why you pass it every time.

The update trap

updateStrategy: OnDelete means the DaemonSet will never roll a new image — it waits for you to delete each pod by hand. It is a legitimate choice for a storage driver you want to reboot deliberately, and a silent "our agent has been on the old version for eight months" for everything else.

why the agent did not update
$ kubectl get ds -n monitoring node-exporter -o jsonpath='{.spec.updateStrategy.type}'
OnDelete
there it is. RollingUpdate is what you almost always want:
$ kubectl patch ds node-exporter -n monitoring --type=merge \
-p '{"spec":{"updateStrategy":{"type":"RollingUpdate",
"rollingUpdate":{"maxUnavailable":1}}}}'
$ kubectl rollout status ds/node-exporter -n monitoring
and to confirm it is genuinely on every node it should be:
$ kubectl get ds -A -o custom-columns=\
NS:.metadata.namespace,NAME:.metadata.name,DESIRED:.status.desiredNumberScheduled,READY:.status.numberReady

A Job runs pods until a target number succeed. A CronJob creates Jobs on a schedule. Everything that goes wrong with them comes from four fields.

FieldMeansGets you when
completionsHow many successes end the JobUnset = 1, so a "batch" silently processes once
parallelismHow many pods at onceUnset = 1; your 6-hour job could have been 20 minutes
backoffLimitRetries before FailedDefault 6, with exponential backoff to 6 minutes — a fast-failing job takes ~20 min to give up
activeDeadlineSecondsWall-clock capUnset = a hung job runs forever, holding its resources

concurrencyPolicy is the CronJob field that causes incidents

The default is Allow. If a run takes longer than the interval, the next one starts anyway — and on a five-minute schedule with a run that has started taking eight minutes, you accumulate overlapping jobs until something saturates. Forbid skips the new run; Replace kills the old one. Both are usually more correct than the default.

Also: startingDeadlineSeconds. If the controller is down past that window the run is skipped, not queued — and if it is unset and the controller was down a long time, the CronJob can fire every missed run at once on recovery.

a CronJob that cannot pile up
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid # skip if the last one still runs
startingDeadlineSeconds: 120 # give up rather than stampede on recovery
successfulJobsHistoryLimit: 3 # default 3; 0 makes debugging impossible
failedJobsHistoryLimit: 3
jobTemplate:
spec:
activeDeadlineSeconds: 240 # hard stop, shorter than the interval
backoffLimit: 2
finding the pile-up you already have:
$ kubectl get jobs -A --sort-by=.metadata.creationTimestamp | tail -20
$ kubectl get jobs -A --field-selector status.successful=0

Three probes, three jobs, and conflating them is the most common self-inflicted Kubernetes outage there is:

ProbeOn failureQuestion it answers
startupProbeKeeps the others waitingHas it finished booting?
readinessProbeRemoved from EndpointsShould it get traffic right now?
livenessProbeContainer is killedIs it wedged beyond recovery?

Why a liveness probe on a dependency is dangerous

Point liveness at a /health that checks the database, and the moment the database has a bad thirty seconds every replica fails liveness and gets killed — simultaneously. You have converted a recoverable dependency blip into a full restart storm, and the restarts add load to the thing that was already struggling.

The rule that avoids it: liveness checks only what a restart can fix. A deadlocked event loop, yes. A database you do not own, never — that belongs in readiness, where failing simply takes the pod out of rotation until the dependency returns.

Slow starts belong in startupProbe, not in initialDelaySeconds

A long initialDelaySeconds on liveness delays detection for the whole life of the pod. A startupProbe with a generous failureThreshold gives a slow JVM five minutes to boot and then hands over to a tight liveness probe.

probes that do not cause the outage they are meant to prevent
startupProbe: # up to 5 min to boot, then get strict
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 5
failureThreshold: 60
readinessProbe: # dependencies live HERE
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
failureThreshold: 3
livenessProbe: # process health ONLY
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
failureThreshold: 3
is a restart loop actually a liveness kill? Exit 137 + this event = yes:
$ kubectl describe pod api-7d9f | grep -A2 'Liveness probe failed'
$ kubectl get pod api-7d9f -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
The restart-storm signature

Many replicas restarting within the same few seconds, all with Liveness probe failed and exit 137, while the application logs show nothing wrong — that is a dependency wobble being amplified by liveness. Move the dependency check to readiness before you tune any timeouts.

In practice

ADVANCED TROUBLESHOOTING

Nearly every workload symptom is one of four states, and the state tells you which object to look at. Guessing from the Deployment alone wastes the first ten minutes.

Pod stateWhat it meansLook at
PendingNever scheduled — no node fits, or quota refused itkubectl describe pod, the Events tail names the failed predicate
ContainerCreating > 2 minImage pull, volume attach or CNIEvents; then the node's kubelet log
CrashLoopBackOffIt starts and exitskubectl logs -p — the PREVIOUS container is the one that failed
Running but 0/1Readiness failing — no traffic reaches itdescribe for the probe message; the Service has no endpoint
Running, restarts climbingLiveness killing it, or OOMlastState.terminated.reasonError vs OOMKilled
Completed but rerunA Job that succeeded and the CronJob fired againconcurrencyPolicy and the job history

The one command that answers most of it

state, reason and exit code for everything unhealthy
$ kubectl get pods -A -o json | jq -r '.items[]
| select(.status.phase!="Running" and .status.phase!="Succeeded")
| "\(.metadata.namespace)/\(.metadata.name)\t\(.status.phase)"'
restart reasons across the cluster, which is where OOM hides:
$ kubectl get pods -A -o json | jq -r '.items[].status.containerStatuses[]?
| select(.restartCount>0)
| "\(.name)\t\(.restartCount)\t\(.lastState.terminated.reason // "-")"'
events are the real story and they expire in an hour — grab them first:
$ kubectl get events -A --sort-by=.lastTimestamp | tail -40
logs -p is the whole trick for CrashLoopBackOff

kubectl logs on a crash-looping pod shows the container that is starting now and usually prints nothing useful. kubectl logs -p shows the one that already died, which is the one holding the stack trace.

Reference

CHEATSHEET

CommandWhat it answers
kubectl get rs -l app=<x>Which revision is stuck, old versus new
kubectl rollout status deploy/<x> --timeout=5mBlock until it lands or fails
kubectl rollout history deploy/<x>Revisions available to roll back to
kubectl rollout undo deploy/<x> --to-revision=NGo back to a specific one
kubectl logs -p <pod>The container that actually crashed
kubectl get pod <p> -o jsonpath='{.status.containerStatuses[0].lastState}'Exit code and reason — Error vs OOMKilled
kubectl get ds -ADesired versus ready, per node agent
kubectl get jobs -A --sort-by=.metadata.creationTimestampCronJob pile-ups
kubectl get cronjob -ASchedules, last run and suspension
kubectl describe pod <p>Scheduling, probe and image-pull failures
kubectl get events -A --sort-by=.lastTimestampWhat just happened — expires in an hour
kubectl debug <pod> -it --image=busybox --target=<c>A shell beside a container with no shell