Drill — Debug a Stuck Pod
This drill hands you a throwaway cluster and one YAML file: five Deployments, applied in a single kubectl apply, and every single one of them stuck. Not stuck the same way, either — one's Pending because it asked for more memory than the cluster has, a second is Pending for an entirely different reason, a third can't pull its image, a fourth keeps crashing because it's missing something it needs to even start, and a fifth is being killed by its own memory limit. Nothing here depends on the capstone cluster or any lesson before it — bring your own kind, your own kubectl, and about thirty minutes. The skill isn't memorizing what CrashLoopBackOff means; it's the discipline of reading the cheapest signal first, on five objects that all look equally broken from kubectl get pods alone and are not equally broken underneath.
Five toy cars won't start. A rushed mechanic pops every hood and starts swapping parts at random, hoping something sticks. A good mechanic checks the same three things on every car, in the same order, before touching a wrench: is there gas in the tank, does the key even turn, does the engine make a sound when you turn it. One car turns out to have no gas. Another has gas but the key won't turn — wrong key. A third turns and sounds fine for two seconds, then dies — something inside is actually broken. Three different problems, three different fixes, and the mechanic knew which was which before opening a single panel, because they checked the cheap things first and let the answer tell them where to look next. That's this whole drill: five stuck Pods, and the win is reading each one correctly before you touch it, not guessing and hoping.
You need Docker, kind, and kubectl installed locally — nothing else. No cloud account, no registry, no image to build: every broken workload below runs on nginx or busybox, public images you already have pulled or can pull in seconds. This is a single-skill, self-contained drill — it does not reuse or require the six-part capstone cluster, and nothing you build here needs to survive past kind delete cluster at the end.
How this drill works
☺ Like you're 10: Break five things on purpose, all at once, then fix each one for real before moving to the next.
You'll stand up a bare single-node kind cluster, apply one manifest containing five Deployments that are each broken in a different, self-inflicted way, and work them one at a time using nothing but kubectl get, kubectl describe, and kubectl logs — Ring 0 through Ring 2 of the method a troubleshooting methodology lays out in full. This drill doesn't re-teach that method; it makes you actually run it, five times, against five objects that share nothing except the fact that kubectl get pods shows all five as "not Running." For each case: read the symptom, name your hypothesis before you open the reveal, then check it against the fix. A hypothesis you were talked into by an answer key teaches you less than one you defended yourself, even wrongly.
Set up the scratch cluster
☺ Like you're 10: One tiny throwaway cluster, built just for this — nothing you'd be sad to delete an hour from now.
A one-line, default, single-node kind cluster is all this drill needs — nothing here is about networking or multi-node topology, so skip the config file entirely:
kind create cluster --name debug --image kindest/node:v1.31.0
kubectl create namespace stuck-pods
kubectl config set-context --current --namespace=stuck-podsSave the broken bundle below as broken.yaml and apply it in one shot:
# broken.yaml — five Deployments, five self-inflicted problems, applied together
apiVersion: apps/v1
kind: Deployment
metadata:
name: ledger-writer
namespace: stuck-pods
labels: { app: ledger-writer }
spec:
replicas: 1
selector: { matchLabels: { app: ledger-writer } }
template:
metadata: { labels: { app: ledger-writer } }
spec:
containers:
- name: ledger-writer
image: nginx:1.27-alpine
resources:
requests: { cpu: "100m", memory: "64Gi" }
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: promo-scheduler
namespace: stuck-pods
labels: { app: promo-scheduler }
spec:
replicas: 1
selector: { matchLabels: { app: promo-scheduler } }
template:
metadata: { labels: { app: promo-scheduler } }
spec:
nodeSelector:
disktype: ssd
containers:
- name: promo-scheduler
image: nginx:1.27-alpine
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: receipts-api
namespace: stuck-pods
labels: { app: receipts-api }
spec:
replicas: 1
selector: { matchLabels: { app: receipts-api } }
template:
metadata: { labels: { app: receipts-api } }
spec:
containers:
- name: receipts-api
image: nginx:1.27-alpne # <- typo, baked in on purpose
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: session-cache
namespace: stuck-pods
labels: { app: session-cache }
spec:
replicas: 1
selector: { matchLabels: { app: session-cache } }
template:
metadata: { labels: { app: session-cache } }
spec:
containers:
- name: session-cache
image: busybox:1.36
command: ["sh", "-c"]
args:
- |
if [ -z "$CACHE_TOKEN" ]; then
echo "FATAL: CACHE_TOKEN is required, refusing to start" >&2
exit 1
fi
sleep 3600
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: report-builder
namespace: stuck-pods
labels: { app: report-builder }
spec:
replicas: 1
selector: { matchLabels: { app: report-builder } }
template:
metadata: { labels: { app: report-builder } }
spec:
containers:
- name: report-builder
image: busybox:1.36
command: ["sh", "-c"]
args:
- "dd if=/dev/zero of=/dev/shm/pad bs=1M count=200; sleep 3600"
resources:
requests: { memory: "10Mi" }
limits: { memory: "20Mi" }kubectl apply -f broken.yaml
kubectl get pods
# give it 60-90 seconds, then confirm all five are unhealthy in five different ways:
NAME READY STATUS RESTARTS AGE
ledger-writer-6bd9c7f9d4-k2m7p 0/1 Pending 0 75s
promo-scheduler-7f4d8b9c6-x9wjq 0/1 Pending 0 75s
receipts-api-5c8f6d4b7-ht2mn 0/1 ImagePullBackOff 0 75s
session-cache-84b6c9f5d-p4rkw 0/1 CrashLoopBackOff 3 75s
report-builder-9d5c7b8f6-m2vqz 0/1 CrashLoopBackOff 2 75sDone when: all five Pods are listed, none of them 1/1 Running. Nothing above is a trick or a race condition — every one of these is deterministic and will look exactly like this on any machine, every time.
Case 1 — ledger-writer: Pending, and it's not a scheduler bug
☺ Like you're 10: The Pod isn't stuck waiting in line — it's waiting for a seat that doesn't exist anywhere in the building.
Pending means exactly one thing at Ring 0: no container has been created yet. It does not tell you why. Ring 1 — events — usually does, for free, with no container required to be running at all:
kubectl describe pod -l app=ledger-writer | tail -6Before opening the reveal: what would make a Pod requesting only 100m CPU fail to schedule on any node at all?
Reveal: the events, and the fix
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 90s default-scheduler 0/1 nodes are available: 1 Insufficient memory.
preemption: 0/1 nodes are available: 1 No preemption
victims found for incoming pod.The CPU request is fine — it's memory: 64Gi that no realistic kind node (or most laptops) can ever satisfy. This isn't a scheduler malfunction; the scheduler is working exactly as designed, correctly refusing to place a Pod nothing can honor. A Pod's resources block is immutable once created, even while still Pending — so the fix isn't editing the live Pod, it's fixing the Deployment's template and letting the controller replace the Pod for you, the same reconciliation loop covered in the object model:
kubectl set resources deployment/ledger-writer -c=ledger-writer --requests=cpu=100m,memory=128Mi
kubectl get pods -l app=ledger-writer
# → a brand new Pod appears, and within seconds: 1/1 RunningCase 2 — promo-scheduler: also Pending, for a completely different reason
☺ Like you're 10: This one isn't asking for a seat too big for the room — it's asking for a room that doesn't exist at all.
kubectl get pods shows this one as Pending too — identical status string to Case 1, and that's the whole point of running this drill instead of just reading about it: the same three characters can mean two Pods have nothing in common except that neither has been scheduled yet.
kubectl describe pod -l app=promo-scheduler | tail -4Reveal: the events, and two valid fixes
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 70s default-scheduler 0/1 nodes are available: 1 node(s) didn't match
Pod's node affinity/selector.This Pod carries nodeSelector: { disktype: ssd }, and nothing in a default kind cluster carries that label. Two genuinely different fixes exist, and the right one depends on intent — not a detail kubectl can tell you, only the person who wrote the manifest can:
# Option A — the selector was copy-pasted from another environment and doesn't belong here:
kubectl patch deployment promo-scheduler --type=json \
-p='[{"op":"remove","path":"/spec/template/spec/nodeSelector"}]'
# Option B — the workload genuinely needs SSD-backed nodes, so label one to match instead:
kubectl get nodes -o name
kubectl label node debug-control-plane disktype=ssdEither one produces the same visible result — kubectl get pods -l app=promo-scheduler flips to 1/1 Running — but they encode opposite claims about whether the constraint was ever supposed to exist. Pick A for this drill; it matches the scenario (a stray selector, not a real hardware requirement).
Case 3 — receipts-api: ImagePullBackOff, and no shell to blame
☺ Like you're 10: A node was found, a spot was reserved — the delivery truck just can't find a package with that exact label on it.
Unlike the two Pending cases, this Pod was scheduled — a node exists and a container was about to start. Something after scheduling, before the container ever ran, is the blocker. Ring 1 again, no exec required:
kubectl describe pod -l app=receipts-api | tail -6Reveal: the events, and the fix
Events:
Type Warning Reason Age From Message
------- ------- ------ --- ---- -------
Normal Pulling 55s kubelet Pulling image "nginx:1.27-alpne"
Warning Failed 52s kubelet Failed to pull image "nginx:1.27-alpne": rpc error: code = NotFound
desc = failed to resolve reference "docker.io/library/nginx:1.27-alpne":
nginx:1.27-alpne: not found
Warning Failed 52s kubelet Error: ErrImagePull
Normal BackOff 40s kubelet Back-off pulling image "nginx:1.27-alpne"
Warning Failed 40s kubelet Error: ImagePullBackOffThe message spells out the exact typo — alpne instead of alpine — with no ambiguity and no guessing required. This is exactly why Ring 1 comes before Ring 3: exec-ing into a container that was never created is impossible anyway, and it wouldn't have told you anything logs and events didn't already say for free.
kubectl set image deployment/receipts-api receipts-api=nginx:1.27-alpine
kubectl get pods -l app=receipts-api
# → 1/1 Running within a few seconds — the pull succeeds immediately once the tag is realCase 4 — session-cache: CrashLoopBackOff, and the reason is in a log you have to catch fast
☺ Like you're 10: This one did start — it just took one look around, decided something was missing, and quit before it ever got comfortable.
Unlike Cases 1–3, a container here has actually run at least once — that's the whole definition of CrashLoopBackOff. That means Ring 2, logs, has something to say — but only if you ask for the crashed attempt specifically:
kubectl logs -l app=session-cache
# → often empty or "previous terminated container ... not found" if you're too slow —
# the current attempt hasn't printed anything new yet, and the last one is gone.
# Ask for the PREVIOUS container's logs explicitly instead:
kubectl logs -l app=session-cache --previousReveal: the log line, and the fix
FATAL: CACHE_TOKEN is required, refusing to startThe container itself is doing exactly what it was written to do: check for a required environment variable, and refuse to start without it — a defensive pattern, not a bug, and one you'll find in plenty of real production images. The fix is supplying what it's asking for:
kubectl set env deployment/session-cache CACHE_TOKEN=dev-local-token
kubectl get pods -l app=session-cache -w
# → STATUS cycles once more, restartCount stops climbing, then settles at 1/1 RunningWatch restartCount specifically once it's fixed, not just STATUS — a Pod can read Running for a few seconds between crashes while still climbing toward the next one. Give it a full minute before trusting it.
Case 5 — report-builder: also CrashLoopBackOff — but not the same failure wearing the same label
☺ Like you're 10: Two toy cars both won't start, but one's out of gas and the other's engine straight-up seized — same symptom on the dashboard, opposite problem underneath.
kubectl get pods shows this one as CrashLoopBackOff too, identical to Case 4 — and it is not the same failure. CrashLoopBackOff only ever describes kubelet's restart-frequency wrapper; the actual cause lives one field deeper, in lastState.terminated.reason:
kubectl get pod -l app=report-builder \
-o jsonpath='{.items[0].status.containerStatuses[0].lastState.terminated}'Reveal: the terminated state, and two honest fixes
{
"reason": "OOMKilled",
"exitCode": 137,
"startedAt": "2026-08-27T09:14:02Z",
"finishedAt": "2026-08-27T09:14:04Z"
}exitCode: 137 is 128 + 9 — SIGKILL — and reason: OOMKilled confirms exactly who sent it: the kernel's own OOM killer, fired inside this one container's cgroup the instant memory use crossed its 20Mi limit. The command writes 200MiB of zeros to /dev/shm, a tmpfs mount — and tmpfs pages are charged against the writing process's memory cgroup exactly like heap memory would be, so this hits the wall almost immediately, well under a second in. This is the identical trick a troubleshooting methodology's own "Try it" callout describes for reproducing OOMKilled on demand.
Two honest fixes exist here, and which one is correct depends on whether that dd line ever belonged in this manifest:
# Option A — it's debug cruft nobody meant to ship: remove the step entirely
kubectl patch deployment report-builder --type=json \
-p='[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["sleep 3600"]}]'
# Option B — the workload genuinely needs to buffer that much: give it the memory instead
kubectl set resources deployment/report-builder -c=report-builder --limits=memory=256MiRaising the limit alone, without ever asking why the container needs 200MiB for a job that's supposed to just sit and wait, is the same reflex the DevOps course's production-incident drill calls out under a different resource entirely — it buys headroom without ever confirming the number was legitimate. Option A is correct here; the dd was leftover debug scaffolding, not real work.
Verify: all five green, and staying green
☺ Like you're 10: "Fixed for a second" and "actually fixed" aren't the same thing — wait, then check again.
Once all five fixes are applied, confirm the whole namespace, not just one Pod at a time:
kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
ledger-writer 1/1 1 1 11m
promo-scheduler 1/1 1 1 11m
receipts-api 1/1 1 1 11m
session-cache 1/1 1 1 11m
report-builder 1/1 1 1 11mREADY 1/1 the instant after a fix only proves the new Pod started — it doesn't prove it stays started. Confirm restart counts are actually flat, not just recently reset, by checking twice, two minutes apart:
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.restartCount}{"\n"}{end}' \
2>/dev/null || kubectl get pods -o custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount
# run it, wait two minutes, run it again — every number should be identical both timesDone when: all five Deployments read 1/1, and every restart count is unchanged across two checks two minutes apart. When you're satisfied, tear the whole thing down — nothing here needs to outlive this drill:
kind delete cluster --name debugEverything above involves a container that's either not running yet or crashing outright. There's a third shape this drill deliberately left out: a container that's genuinely Running and never restarts, yet the Pod never reads 1/1. Build it yourself — deploy nginx:1.27-alpine (it listens on port 80) with a readinessProbe pointed at httpGet: { path: /, port: 8080 }, the wrong port on purpose. Watch kubectl get pod show 0/1 Running forever, then confirm with kubectl get pod -o jsonpath='{.status.conditions}' that ContainersReady is True while Ready is False — the exact combination a troubleshooting methodology calls the one worth memorizing on sight. Fix it by correcting the port, not by touching the container at all.
kubectl get pods lists all five Pods, none of them 1/1 Running.1/1 Running.1/1 Running.1/1 Running.1/1 Running and restartCount has stopped climbing.1/1 Running and lastState no longer shows a fresh OOMKilled.Benny the Beaver: Five Deployments, zero of them Running. I swear I tested every one of these before I shipped the bundle.
Foxy: You tested them on a cluster with more memory to spare than this laptop's got. Start with events, not guesses — the message usually just says the answer outright.
Gizmo the Gremlin: Or — hear me out — set every requests to zero and every limits.memory to unlimited. Nothing ever gets stuck again, ever. 😈
Timmy the Turtle: And then one runaway container starves the other four the first time it misbehaves for real. Fix the five actual reasons. None of them needed a bigger ceiling.
Benny the Beaver: Fair. ledger-writer's fixed — I asked for 64 gigs by accident, not 64 megs.
Foxy: session-cache's previous log says it flat-out wants a token I never gave it. That one's not Kubernetes being difficult — that one's on me.
Timmy the Turtle: Five for five, five different reasons. Not one fix worked twice — which is exactly the point of running all five instead of just one.
1. ledger-writer and promo-scheduler both sit at Pending — how do you tell which root cause you actually have, and why would kubectl logs tell you nothing about either one? 2. receipts-api goes ImagePullBackOff and session-cache goes CrashLoopBackOff — both look equally "stuck" in kubectl get pods. What one fact (has any container ever actually started) tells them apart, and which ring answers it? 3. Why does kubectl logs --previous matter specifically for session-cache, and what happens if you run kubectl logs without it right after a fresh restart? 4. report-builder shows the identical CrashLoopBackOff label as session-cache. What field, read at Ring 0, proves these are two structurally different failures wearing the same status string? 5. Why can't you kubectl edit a Pending Pod's resources.requests directly, and what does patching the Deployment instead actually accomplish? 6. Across all five fixes, what's the one thing every diagnosis had in common about where you looked before you touched anything?
Check your answers
- Both show identical
status.phase: Pending, which tells you nothing about the cause — only Ring 1 events distinguish them:ledger-writer's saysInsufficient memory,promo-scheduler's saysnode(s) didn't match Pod's node affinity/selector.kubectl logsis useless for either because logs require a container to have actually started running at least once, and neither Pod has ever gotten that far. - Whether any container in the Pod has ever actually started running, even once —
receipts-apinever has (the image never resolved),session-cachehas (it started, ran its check, and exited). Ring 1 events answer this directly:receipts-api's events never mention a container starting,session-cache's restart count and prior termination state confirm one did. - The moment kubelet restarts a crashed container, its logs are attached to the new attempt, not the one that actually failed — plain
kubectl logsreads the current, often-empty attempt.--previousasks specifically for the last terminated container's logs, which is where the actualFATAL: CACHE_TOKEN is requiredmessage lives. status.containerStatuses[0].lastState.terminated.reason— forsession-cacheit's an ordinary non-zero exit from the app's own check; forreport-builderit's specificallyOOMKilledwithexitCode: 137.CrashLoopBackOffonly ever describes kubelet's restart-frequency wrapper; this field is what actually tells the two apart.- A Pod's
spec.containers[].resourcesis immutable once the object is created, whether it'sPendingorRunning— Kubernetes doesn't special-case an unscheduled Pod. Patching the Deployment instead changes its Pod template, which the Deployment controller reconciles by creating a brand-new Pod with the corrected spec and retiring the old one — the same desired-state-versus-actual-state loop covered in the object model. - Every diagnosis started at the cheapest possible signal —
kubectl describe's events, which require nothing to execute and nothing to be running — before ever reaching for logs, and none of the five neededexecor a debug container at all. Ring 0 and Ring 1 solved all five cases in this drill; Ring 3 was never necessary.
Every fix in this drill reused a single discipline: read the cheapest signal first, and only escalate once it comes up empty — the full six-ring version of that method, plus the exact mechanism behind all five failure signatures used here, lives in a troubleshooting methodology. For the exam-paced version of the same four Ring 0–3 moves under a two-hour clock, see the Exam Blueprint's Troubleshooting domain. For the scheduling math behind why ledger-writer couldn't fit anywhere, see Scheduling & resource management. Ready for the messier, single-cluster version where every one of these failure types is mixed into one continuous outage instead of five neatly separated Deployments? Try Drill — Fix a Broken Cluster, or step back to the six-part capstone for the full continuity version.