Kubernetes in Depth · Every incident, worked the same way

A Troubleshooting Methodology

The Exam Blueprint's Troubleshooting page teaches the sequence CKA rewards under a two-hour clock — wide view, describe, logs, exec — and that sequence is genuinely correct as far as it goes. It just stops at the container boundary, because exam tasks are pre-scoped to one broken object and real incidents usually aren't. This page generalizes that sequence into a method built to scale: work outward in six rings, starting at the cheapest possible signal — the object's own declared and observed state — and only escalate to a more invasive, more expensive ring once the cheaper one has actually told you something. It also spends the space an exam page can't afford: decoding the five failure signatures every Kubernetes operator eventually recognizes on sight — CrashLoopBackOff, ImagePullBackOff, Pending, Evicted, OOMKilled — precisely enough that seeing the label tells you exactly which hypothesis to test first, not just that something, somewhere, is wrong.

☺ Explain it like I'm 10

Imagine you've lost your keys somewhere in the house. Tearing every room apart at once wastes the whole afternoon. Instead you search in rings: first, exactly where you remember setting them down — that's checking the object's own status before touching anything else. Nothing there? Ask if anyone else in the house noticed something — that's events, what the house itself already recorded. Still nothing? Retrace your own steps out loud, in order — that's logs. Only once all of that comes up empty do you start opening drawers — that's getting inside and looking directly. And if the whole house lost power, stop hunting for keys in the dark — go check the breaker box instead. That's the node. Every Kubernetes incident works the same way: start close, widen only once the ring you're on comes up empty.

🦊Your host for this topic: Foxy — the instinct that notices something's off a full minute before anyone else does, and doesn't stop until the proof matches the hunch.

Work outward, ring by ring: the general method

☺ Like you're 10: Start exactly where the thing lives, and only widen the search once that spot comes up empty.

Every Kubernetes incident, from a single stuck Pod to a cluster-wide outage, can be attacked with the same six-ring model, each ring strictly more expensive and more invasive than the one before it. Ring 0 is the object's own declared and observed state — spec versus status — read with a single API call, nothing executed, nothing touched. Ring 1 is events: what the control plane itself already recorded while trying to reconcile the object, also free to read. Ring 2 is logs: what the workload itself said, which requires a container to have actually run at some point. Ring 3 is live process state — exec into the container, or attach a debug container alongside it — genuinely invasive, and the first ring that can itself perturb what you're investigating. Ring 4 widens past the single object entirely: is this Pod's failure actually every Pod on this node failing. Ring 5 widens further still: is a controller, an admission webhook, or the API server itself the real root cause, visible only once you correlate failures across nodes and namespaces.

Work outward: six rings, cheapest signal first 0 1 2 3 4 5 Ring 0 — the object itself spec vs status, phase & conditions — nothing executes Ring 1 — events what the control plane already recorded Ring 2 — logs what the workload itself said, --previous too Ring 3 — live process exec in, or an ephemeral debug container Ring 4 — the node is every Pod on this node failing, not just one Ring 5 — the control plane correlate across nodes — a controller, or the API Rings 0–3 are the blueprint's four exam stages, renamed; rings 4–5 are what a real incident adds once one object stops explaining itself.

Reading status precisely: conditions before logs

☺ Like you're 10: The Pod is already telling you exactly where to look — you just have to read the right field before the wrong one.

status.phasePending, Running, Succeeded, Failed, Unknown — is the coarsest signal a Pod exposes, and on its own it's nearly useless: a Running Pod can still be completely unable to serve traffic. The real information lives in status.conditions[], four of them, evaluated in a fixed order, each independently True, False, or Unknown with its own reason. The first condition reporting False, reading top to bottom, is the only one worth acting on.

ConditionFalse meansCheck next
PodScheduledNo node assigned yetRing 1 events — FailedScheduling, and why
InitializedAn init container hasn't finished, or failedRing 2 logs on that specific init container, by name
ContainersReadyA regular container isn't Running yet, or keeps exitingRing 0 containerStatuses[].state — its reason
ReadyContainers are up, but the readiness probe is failingRing 2/3 — read the probe's own failure, then run it by hand
kubectl get pod <pod> -n <ns> -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.reason}{"\n"}{end}'

kubectl get pod <pod> -n <ns> -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.state}{"\t"}{.restartCount}{"\n"}{end}'
◆ Key idea

The one combination worth memorizing on sight: ContainersReady: True next to Ready: False means every container in the Pod is genuinely running — this Pod's actual problem is a failing readiness probe, not a crash, and no amount of reading crash logs will find it. That distinction is exactly why the four conditions are ordered: nothing downstream is meaningfully evaluable once an earlier one is already false, so the first False you see, reading top to bottom, is the only one that matters right now.

Events: the cluster's own incident log — read it before it's gone

☺ Like you're 10: The cluster wrote down what it tried to do the moment it tried it — read that note before you go looking for clues yourself.

Ring 1 is the control plane's own contemporaneous account of what it attempted against this object, and it's worth reading before logs specifically because it's cheaper: no container has to be running, no shell has to work, for an Event to exist. Every Event carries a controlled-vocabulary reason, a type of Normal or Warning, and a human-readable message naming the actual blocker — which is exactly what turns a vague "it's Pending" into an actionable "Insufficient cpu" or "0/3 nodes match node affinity."

kubectl get events -n <ns> --sort-by='.lastTimestamp'
kubectl get events -n <ns> --field-selector involvedObject.name=<pod>,involvedObject.kind=Pod
kubectl get events -n <ns> -o json | jq -r '.items[] | select(.type=="Warning") | "\(.lastTimestamp)\t\(.reason)\t\(.message)"'
ReasonTypeWhat it means
FailedSchedulingWarningNo node currently satisfies requests, affinity, or taints — the exact blocker is in the message
Pulling / PulledNormalImage pull started / finished — Pulling with no later Pulled is the tell for a stuck pull
BackOffWarningkubelet is waiting out its exponential delay before the next restart or pull attempt
UnhealthyWarningA liveness or readiness probe just failed — the message names which one, and why
Evicted (as status.reason on the Pod, not only an Event)The kubelet's node-pressure eviction manager removed this Pod outright

kube-apiserver garbage-collects Event objects after --event-ttl, one hour by default — on a real incident, read them promptly, because unlike logs shipped to an aggregator they don't survive to a postmortem on their own. And the row count you see isn't a literal event count: an identical, repeated event mutates a single object's count field and lastTimestamp instead of creating a new object each time, so a Pod restarting every 40 seconds for two hours shows up as one row with count: 180, not 180 rows.

◆ Key idea

Events are a debugging convenience, not an audit trail — the default one-hour TTL and object-level deduplication mean an Event you didn't read within the hour is simply gone, with nothing left behind to prove it ever fired. If an incident needs a durable, queryable record after the fact, that's a job for a real logging and metrics pipeline, not kubectl get events — see Observability on Kubernetes for shipping Events themselves out via an event exporter, alongside logs and metrics.

The five failure signatures, decoded

☺ Like you're 10: Five scary-looking words, and every one of them has one exact, boring mechanical meaning once you know where to look.

These five status strings account for the overwhelming majority of "why is my Pod broken" questions, exam or otherwise, and they're routinely used loosely — as if Pending always means one thing, or CrashLoopBackOff is itself a cause rather than a symptom. It isn't. Each row below names what's literally happening at the kubelet or kernel level, and the one check that confirms you have the right one.

SignatureWhat's literally happeningConfirm it's this one
CrashLoopBackOffkubelet's own restart-delay wrapper around a container that has already run at least once and keeps exiting — the delay doubles from 10s toward a 5-minute cap, and resets after roughly 10 minutes of stabilitydescribe's Events show Back-off restarting failed container; restartCount is climbing
ImagePullBackOff / ErrImagePullA node was assigned, but the container runtime can't produce an image for it — ErrImagePull is the immediate one-shot failure, ImagePullBackOff is the same backoff wrapper applied to the retriesEvents show Failed to pull image, with the runtime's own error (auth, tag, network) inline
PendingThe Pod exists in etcd but no container has been created — covers two different situations: not yet scheduled at all, or scheduled and stuck before startingspec.nodeName empty = never scheduled (Events: FailedScheduling); set = scheduled but blocked (image pull, PVC binding, an init container)
EvictedThe kubelet's own eviction manager reclaimed the Pod under node MemoryPressure/DiskPressure/PIDPressure — a policy decision, not a kernel-level kill, and the Pod is fully removed, not restarted in placestatus.reason == Evicted (read fast — often garbage-collected soon); a controller recreates it elsewhere, this node isn't retried
OOMKilledThe kernel's own OOM killer fired inside that one container's cgroup the instant its memory limit was crossed — immediate SIGKILL, no grace period — and kubelet restarts it per restartPolicy, which is why this is usually a reason inside CrashLoopBackOff, not a status of its ownlastState.terminated.reason == OOMKilled, exit code 137
Exit codeWhat it means
0Clean exit — a problem only if restartPolicy expected the process to keep running
1Generic application error — read the app's own logs, not Kubernetes, for the real reason
137 (128 + 9)SIGKILL — almost always OOMKilled, occasionally a manual kill -9 or a probe-triggered kill
143 (128 + 15)SIGTERM — a normal, requested shutdown; check whether it finished before terminationGracePeriodSeconds ran out
Which of the five signatures is this? A discriminator flowchart Pod isn't Ready — which of the five signatures is this? Has any container in this Pod ever actually started running, even once? no yes Is a node assigned yet? check spec.nodeName Is the Pod object still here at all (its phase)? no yes no — Failed yes — exists PENDING unschedulable, or still admitting IMAGEPULLBACKOFF scheduled, image won't produce EVICTED kubelet reclaimed it under pressure Restarting the same container — does lastState say OOMKilled? yes no / other OOMKILLED cgroup limit hit, SIGKILL (exit 137) CRASHLOOPBACKOFF any other repeating exit CrashLoopBackOff is the umbrella — OOMKilled is a reason that so often wears it, the two get treated as separate when one is a special case of the other.
⚠ CrashLoopBackOff is a wrapper, not a diagnosis

CrashLoopBackOff only ever describes kubelet's opinion about restart frequency — it fires identically whether the container is being OOMKilled, panicking on startup, failing a liveness probe, or exiting because a config file is missing. Treating it as the answer instead of the question is the single most common miss past the CKA level: the real cause is always one level down, in lastState.terminated.reason and the --previous logs, and OOMKilled specifically is common enough as that reason that it's worth checking first, before reading a single log line.

✎ Try it

On a scratch cluster, deploy a container with resources.limits.memory: "20Mi" and a command that allocates well past that (any language's equivalent of grabbing 200MiB works). Watch it go RunningOOMKilledCrashLoopBackOff in front of you, then confirm the exact chain with kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' — you should see reason: OOMKilled and exitCode: 137 in the same object, proving the two labels describe the same event from two different layers. Then work the ring model end to end, deliberately, on a cluster someone else broke on purpose, in Drill: Debug a Stuck Pod or Drill: Fix a Broken Cluster.

Past exec: ephemeral debug containers when there's no shell

☺ Like you're 10: When there's no shell to get into, bring your own toolbox and set it down right next to the thing you're fixing — don't touch the thing itself.

Ring 3 assumes kubectl exec works, and on a minimal or distroless image it often doesn't — there's no shell, no coreutils, sometimes not even a static binary to exec into, and the error is a flat OCI runtime exec failed: exec: "sh": executable file not found in $PATH. Rebuilding the image with debug tools just to diagnose it is exactly backwards — it changes the very thing you're trying to observe. Ephemeral containers solve this without touching the Pod's own spec: kubectl debug attaches a brand-new container into the already-running Pod, and with --target it shares the named container's process namespace, so a full-featured debug image can ps, walk /proc/<pid>/root, and inspect the target's filesystem and network namespace from the outside — with nothing already running there restarted or rebuilt.

kubectl debug -it reporting-worker -n payments \
  --image=busybox:1.36 --target=app -- sh
# inside the ephemeral container: ps shows the target container's real processes too
ls -la /proc/1/root/                          # the target's actual filesystem, read live, no rebuild
cat /proc/1/root/etc/resolv.conf              # e.g. confirm DNS config with no shell in that container at all

The same tool reaches Ring 4 without SSH: kubectl debug node/<node> schedules a privileged Pod onto that exact node with the host filesystem mounted at /host, which is the fastest way to run journalctl or inspect kubelet when you don't have (or don't want) direct node access.

kubectl debug node/<node> -it --image=busybox:1.36
# drops into a privileged pod scheduled on that node, host filesystem mounted at /host
chroot /host systemctl status kubelet
chroot /host journalctl -u kubelet -n 60 --no-pager

# older clusters without ephemeral-container support: copy the Pod instead, swapping one image
kubectl debug reporting-worker -n payments \
  --copy-to=reporting-worker-debug --container=app --image=busybox:1.36 -it -- sh

The two techniques trade off differently: --target attaches alongside a live container without disturbing it, ideal for read-only inspection; --copy-to creates a genuinely new Pod, heavier but necessary when you need to actually change a container's command or arguments rather than just look at it from next door. See kubectl for the rest of the fluency baseline this builds on.

When the ring widens: correlated failures, the node, and the control plane

☺ Like you're 10: If the same symptom shows up on five unrelated things at once, stop blaming any one of them individually.

The hardest instinct to build is recognizing early that a Pod-shaped symptom isn't a Pod-shaped cause. One failing Pod deserves the rings above, in order. A dozen Pods failing identically, spanning unrelated namespaces and owned by unrelated teams, deserves a completely different first move: group by node before you investigate a single one of them.

# Pending or crashing Pods clustering on one node = a node problem, not a dozen app problems
kubectl get pods -A -o wide --field-selector=status.phase!=Running,status.phase!=Succeeded \
  | awk '{print $8}' | sort | uniq -c | sort -rn

# the SAME reason across DIFFERENT nodes and namespaces = ring 5 — a controller, webhook, or the API
kubectl get events -A --field-selector type=Warning -o json \
  | jq -r '.items[].reason' | sort | uniq -c | sort -rn

A node-shaped cluster of failures routes back into the node's own Conditions and its kubelet's journal — the exact mechanics are covered in the blueprint's Troubleshooting page and, for what's actually running there, Control Plane Internals. A cluster-wide cause that isn't the node at all — an admission webhook rejecting broadly, a CNI regression, CoreDNS itself failing — is exactly what Networking & the CNI and Services & Networking cover from the networking angle, and RBAC/admission failures in bulk are RBAC & Admission Control's territory specifically.

🦊 Foxy's-eye view

"People treat 'which ring am I on' as a feeling, and it isn't — it's just: what's the cheapest thing I haven't checked yet. I've watched an engineer SSH into a node to read dmesg before they'd even run kubectl get events on the Pod that was actually failing. The ring order isn't there for tidiness — every ring you skip, you usually end up checking anyway, just later, after you've already spent a more expensive one for nothing. The real skill is noticing early when a Pod-shaped symptom is actually node-shaped or cluster-shaped, and that's a pattern you get from ring 4 and ring 5, not from staring harder at ring 0."

This page is deliberately not scoped to the CKA clock — for the exam-paced version of rings 0 through 3, go back to the Exam Blueprint's Troubleshooting domain. For the same discipline generalized past Kubernetes entirely — the identical "cheapest signal first" instinct applied across workloads, networking, and delivery pipelines — Platform Engineering's Triage Playbook is worth reading as a second, complementary framing. And once a ring-5 incident is confirmed and real people need to be paged and coordinated, not just diagnosed, that's SRE's incident management & on-call territory — the technical method on this page and the human process on that one are two different, both-necessary halves of the same incident.

🎬 At the Pod Squad
🦊

Foxy: reporting-worker just flipped to CrashLoopBackOff — restart count's already at 6 and climbing. Something's wrong, I just don't know what yet.

👺

Gizmo the Gremlin: Easy — delete the Pod and let the ReplicaSet make a fresh one. New Pod, clean slate, problem gone. 🤑

🦊

Foxy: A fresh Pod with the same image, the same config, and the same memory limit is going to do the exact same thing it just did. I want to know what it did, not make it do it again somewhere else.

🦫

Benny the Beaver: lastState.terminated.reason says OOMKilled. Exit code 137. It's not crashing on its own — it's getting killed.

🦥

Sol the Sloth: ...which means the real question isn't "why did it crash," it's "why does it need more than its limit to do this job." Slower question, better one.

🐘

Ellie the Elephant: Pulling up the last hour of its memory usage now. Climbed steadily instead of spiking once means a leak, not a one-off — and the fix is different either way.

🦊

Foxy: Ring 0 gave us the reason, ring 4's usage graph gives us the pattern. Neither one needed us to touch the Pod at all.

🐢 Timmy's checkpoint

1. Name the six rings in order, and what's specifically cheap about checking ring 0 before anything else. 2. A Pod's ContainersReady condition is True but Ready is False. What does that combination specifically tell you, and where do you look next? 3. Why can kubectl get events show a single row with count: 347 instead of 347 separate rows, and why does that matter for reading them quickly? 4. A Pod has never been assigned a node. Is that necessarily Pending, and how do you tell it apart from ImagePullBackOff using spec alone? 5. Explain precisely why OOMKilled usually appears wrapped inside CrashLoopBackOff rather than as a status of its own. 6. kubectl exec fails on a distroless container with "executable file not found." What's the fix that doesn't require rebuilding the image? 7. Twelve Pending Pods, spanning eight unrelated namespaces, all landed on the same node in the last ten minutes. Which ring does that symptom actually belong to, and why?

Check your answers
  1. Ring 0, the object's own spec/status (phase, conditions, reason); Ring 1, events; Ring 2, logs; Ring 3, live process (exec or an ephemeral debug container); Ring 4, the node; Ring 5, the control plane. Ring 0 is cheapest because it's a single API read against data Kubernetes already has — nothing runs, nothing is invoked, nothing can fail on the way.
  2. Every container in the Pod is genuinely up and running — the problem isn't a crash at all, it's a failing readiness probe. Reading crash logs won't find it; check the probe's own configuration and run it by hand from inside the container.
  3. Kubernetes deduplicates identical repeated Events into one object, incrementing its count field and updating lastTimestamp instead of creating a new object each time — so the visible row count under-represents how often something actually happened, and a low row count doesn't mean a low-severity or rare event.
  4. Not necessarily — check spec.nodeName. Empty means it was never scheduled (a true Pending, check Events for FailedScheduling); a node name already set means it was scheduled and got stuck afterward, which is the ImagePullBackOff/ErrImagePull family, not unscheduled Pending.
  5. CrashLoopBackOff is kubelet's generic backoff wrapper around any container that keeps exiting after it already ran once — it fires the same way regardless of the actual cause. OOMKilled is one specific cause, recorded in lastState.terminated.reason, that happens to trigger that same generic wrapper; the status describes the restart pattern, the reason describes what actually killed it.
  6. Attach an ephemeral debug container with kubectl debug <pod> --image=<debug-image> --target=<container> — it shares the target container's process namespace so a full-featured image can inspect it from alongside, without modifying or rebuilding the original container at all.
  7. Ring 4, the node — twelve unrelated Pods across eight namespaces failing identically and landing on one node is a strong signal the node itself is the actual cause (capacity, taints, a kubelet problem), not twelve coincidental application-level failures each deserving its own Pod-level investigation.