Internals & debugging
The "why is it dying?" toolkit: what a container actually is (namespaces, cgroups, union FS), the OCI/containerd stack under Docker, why processes get OOMKilled, how to debug a crashing or distroless container, and the PID 1 / signal / zombie-reaping traps that make apps hang on stop.
Learning objectives
- Explain a container as namespaces + cgroups + a union filesystem — not a VM.
- Trace the OCI stack: Docker/CLI → containerd → runc → your process.
- Set memory/CPU limits and diagnose an
OOMKilled(exit 137) container. - Debug crashing and distroless containers with logs, inspect, and ephemeral debug containers.
- Handle PID 1 correctly: forward signals, reap zombies, and shut down gracefully.
docker/kubectl inspection labs are labeled and need a container runtime; command surfaces drift, so verify against current docs.1 · What a container actually is advanced
A container is not a lightweight VM. It is an ordinary Linux process that the kernel has been told to lie to: namespaces give it a private view of PIDs, mounts, network, users, and hostname; cgroups cap how much CPU/memory/IO it can use; and a union filesystem (overlayfs) stacks read-only image layers under a thin writable layer. Same kernel as the host — that is why containers are cheap and why an escape lands you on the host.
| Mechanism | What it isolates / limits | You feel it as |
|---|---|---|
| PID namespace | process tree; app sees itself as PID 1 | ps shows only your procs |
| Mount namespace | filesystem view | your own / |
| Network namespace | interfaces, ports, routes | container's own eth0/localhost |
| User namespace | UID/GID mapping | root inside ≠ root on host (if enabled) |
| cgroups | CPU, memory, IO ceilings | throttling and OOMKilled |
| overlayfs | layered image + writable top | layers, and lost writes on restart |
2 · The OCI / containerd stack under Docker advanced
"Docker" is several layers. The Docker CLI talks to the dockerd daemon, which delegates to containerd (the container lifecycle manager), which uses runc (the low-level OCI runtime) to actually create the namespaces/cgroups and exec your process. Kubernetes skips Docker entirely and talks to containerd via the CRI. Knowing the layers tells you which logs to read when something breaks.
docker run hangs, the problem may be in dockerd or containerd, not your image. On Kubernetes there is no dockerd at all — the kubelet drives containerd directly, so "Docker-specific" advice about the daemon simply doesn't apply. The OCI image and runtime specs are what make an image built by Docker runnable by containerd, Podman, etc.3 · Resource limits & OOMKilled (exit 137) professional
cgroups enforce a hard memory ceiling. When a process tries to exceed it, the kernel's OOM killer terminates it with SIGKILL — the container exits 137 (128 + signal 9) and shows OOMKilled. This is the most misread container failure: the app didn't crash, it was killed for using too much memory. The fix is a right-sized limit and a request, not a bigger try/except.
oom.sh# Cap memory and CPU. Exceeding --memory gets the process SIGKILL'd (exit 137).
docker run --memory=256m --cpus=0.5 myapp:1.4.2
# After it dies, read WHY — OOMKilled and the exit code are recorded here:
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Error}}' <container>
# -> true 137
# See real-time usage to right-size the limit:
docker stats --no-stream <container>
# Verify field paths/flags against current Docker docs.
exit_triage.pydef diagnose(exit_code, oom_killed):
if oom_killed or exit_code == 137:
return "OOMKilled — hit the cgroup memory limit; raise limit or cut memory use"
if exit_code == 0:
return "clean exit (PID 1 returned 0) — check restart policy if it shouldn't stop"
if exit_code == 143:
return "SIGTERM (128+15) — graceful stop requested; fine if shutdown was clean"
if exit_code == 139:
return "SIGSEGV (128+11) — native crash / segfault in the process"
if exit_code == 1:
return "app error — read the app logs; the program itself returned non-zero"
if exit_code == 126 or exit_code == 127:
return "entrypoint not executable / not found — check the CMD path & +x bit"
return f"exit {exit_code} — read logs; map 128+N to signal N if > 128"
for ec, oom in [(137, True), (143, False), (1, False), (127, False)]:
print(ec, "->", diagnose(ec, oom))
137 -> OOMKilled — hit the cgroup memory limit; raise limit or cut memory use
143 -> SIGTERM (128+15) — graceful stop requested; fine if shutdown was clean
1 -> app error — read the app logs; the program itself returned non-zero
127 -> entrypoint not executable / not found — check the CMD path & +x bit
OOMKilled — SIGKILL cannot be caught. Either the limit is too low for real usage (raise the request/limit) or the app leaks/over-buffers (fix the memory use). On Kubernetes, set both a memory request and limit.4 · Debugging a crashing container professional
A container that crash-loops gives you a shrinking window to inspect it. The toolkit, in order: read the logs (including the previous crashed instance), inspect the config and last state, exec a shell if the image has one, and — for a distroless or crash-looping container with no shell — attach an ephemeral debug container that brings its own tools into the target's namespaces.
debug.sh# 1) Logs — and the logs of the PREVIOUS, already-dead instance:
docker logs <container>
kubectl logs <pod> --previous # the crashed instance's logs, K8s
# 2) Inspect — exit code, OOM flag, last state, mounts, env:
docker inspect <container> --format '{{json .State}}'
# 3) Exec a shell IF the image has one (won't work on distroless):
docker exec -it <container> sh
# 4) No shell / crash-looping? Attach an EPHEMERAL debug container that shares
# the target's process + network namespaces and brings its own tools:
kubectl debug -it <pod> --image=busybox --target=<container>
# Command surfaces evolve (esp. kubectl debug) — verify against current docs.
| Symptom | First look | Likely cause |
|---|---|---|
| Exits 137 immediately | inspect OOMKilled | memory limit too low |
| Exits 127 on start | logs | entrypoint path wrong / not +x |
| Crash-loops after N sec | logs --previous | failed dependency / bad config |
| "Healthy" but no traffic | inspect health + ports | wrong port / failing probe |
Hangs on stop | signals / PID 1 | SIGTERM not forwarded (§6) |
exec … sh fails exactly when you need it. An ephemeral/debug container joins the running container's namespaces with a separate image that has your tools, so you keep the hardening and still debug.5 · Healthchecks — liveness vs readiness professional
A healthcheck lets the platform know whether to send traffic (readiness) or restart the container (liveness). Getting them wrong causes two classic outages: a too-aggressive liveness probe restarts a container that was merely slow (a restart storm), and a missing readiness probe sends traffic to a container that isn't warmed up yet.
health.yml# Dockerfile HEALTHCHECK (Docker/Compose):
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/healthz')"
# Kubernetes separates the two intents (put in the Pod spec):
# readinessProbe -> remove from load balancer until ready (don't restart)
# livenessProbe -> restart the container if it stays unhealthy
readinessProbe:
httpGet: { path: /healthz, port: 8000 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /livez, port: 8000 }
initialDelaySeconds: 20 # give slow starts room before restarting
periodSeconds: 10
failureThreshold: 3
6 · PID 1, signals & zombie reaping tech-lead
Your process runs as PID 1 inside the container, and PID 1 is special: the kernel does not install default signal handlers for it, and it is responsible for reaping orphaned child processes (zombies). Two consequences bite in production: (1) if PID 1 ignores SIGTERM, docker stop waits then SIGKILLs it (slow, unclean shutdown); (2) a shell-form CMD makes the shell PID 1, which doesn't forward signals to your app.
Dockerfile.pid1# WRONG: shell-form makes /bin/sh PID 1; it doesn't forward SIGTERM to python.
# CMD python app.py
# RIGHT: exec-form makes YOUR process PID 1 so it receives signals directly.
CMD ["python", "app.py"]
# If your app spawns children (workers, subprocesses), PID 1 must reap zombies.
# Either handle it in-app, or add a tiny init as PID 1:
# docker run --init myapp # Docker's built-in init (tini)
# or bake tini into the image and use it as the entrypoint. Verify current syntax.
graceful_shutdown.pyimport signal, sys, time
shutting_down = False
def handle_term(signum, frame):
global shutting_down
shutting_down = True
print(f"received signal {signum} -> draining, then exit", flush=True)
# PID 1 gets NO default handlers — you MUST install them or SIGTERM is ignored.
signal.signal(signal.SIGTERM, handle_term) # docker stop / k8s sends SIGTERM first
signal.signal(signal.SIGINT, handle_term) # Ctrl-C
def serve_one_request():
time.sleep(0.1) # pretend to handle in-flight work
while not shutting_down:
serve_one_request()
print("finished in-flight work, exiting 0", flush=True)
sys.exit(0) # clean exit -> container exit code 0, fast stop (no SIGKILL wait)
received signal 15 -> draining, then exit
finished in-flight work, exiting 0
CMD python app.py (shell form) runs /bin/sh -c as PID 1; the shell often doesn't forward SIGTERM, so your app never gets the graceful-stop signal and is SIGKILLed after the grace period. Always use exec form: CMD ["python", "app.py"], and add --init if you spawn child processes.✓ Checkpoint — you can move on when you can…
- Explain a container as namespaces + cgroups + union FS, and name the OCI stack layers.
- Diagnose an
OOMKilled(137) vs an app error (1) vs SIGTERM (143) from the exit code. - Debug a crash-looping and a distroless container (logs --previous, inspect, ephemeral debug).
- Split liveness vs readiness so a bad dependency stops traffic instead of restarting the pod.
- Use exec-form CMD, handle SIGTERM, and add an init to reap zombies.
Knowledge check check yourself
A container exits with code 137 and OOMKilled: true. A teammate adds a broad try/except around the code and redeploys. Why won't that help, and what actually fixes it?
Show answer
After switching to a distroless base, docker exec -it <container> sh fails and you can't get a shell in a crash-looping container. What two techniques let you still debug it without un-hardening the image?
Show answer
docker logs / kubectl logs --previous — plus docker inspect for the exit code, OOM flag, and last state; you don't need a shell for either. (2) Attach an ephemeral debug container (kubectl debug --image=busybox --target=…) that joins the target's namespaces with a separate tool-laden image, so you keep the shell-free hardened image and still get a debugging environment. Verify the exact command against current docs.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Engineers who think a container is a tiny VM write the wrong bugfixes. The correct model — a process with a restricted view — is what makes failures explainable.
Your task: State the three kernel mechanisms behind a container and map each to something you observe, and explain why an escape lands on the host.
Requirements:
- Name namespaces (isolated view), cgroups (limits), and the union filesystem (layers)
- Map PID namespace to "app sees itself as PID 1"
- Map cgroups to throttling / OOMKilled
- Explain that containers share the host kernel, so an escape is on the host
💡 Hint: It is not a VM — same kernel, just a process the kernel lies to about the world.
Show solution
A container is a normal Linux process with a restricted view:
- Namespaces isolate its view of PIDs, mounts, network, users, hostname — which is why the app sees itself as PID 1 and has its own
/andeth0. - cgroups cap CPU/memory/IO — which is why it gets throttled or
OOMKilledat a ceiling it didn't set. - A union filesystem (overlayfs) stacks read-only image layers under a thin writable layer — which is why writes vanish on restart.
Because it shares the host kernel (not a VM), a container escape puts the attacker directly on the host — which is why CD6's runtime least-privilege matters.
Context: "Docker" is several programs. Knowing which one owns which job tells you where to look when a container won't start, and why Kubernetes doesn't need Docker at all.
Your task: List the layers from the CLI down to your running process and say what each does, then explain why Kubernetes can drop Docker.
Requirements:
- Order: docker CLI → dockerd → containerd → runc → your process
- Say containerd manages lifecycle and runc creates namespaces/cgroups and execs the process
- Explain that Kubernetes talks to containerd via the CRI, no dockerd
- Note the OCI image + runtime specs make images portable across runtimes
💡 Hint: The low-level runtime that actually calls the kernel is runc; everything above it is orchestration and API surface.
Show solution
Top to bottom:
- docker CLI — the client you type into.
- dockerd — the daemon that receives API calls.
- containerd — manages the container lifecycle (pull, start, stop).
- runc — the OCI runtime that actually creates the namespaces/cgroups and
execs your process. - your process — runs as PID 1 inside.
Kubernetes' kubelet talks to containerd directly via the CRI, so there is no dockerd in a modern cluster. The OCI image and runtime specs are why an image built by Docker runs unchanged under containerd, Podman, or CRI-O.
Context: On-call, the fastest signal is the exit code. A lookup that maps codes to causes turns a cryptic number into a next action.
Your task: Write diagnose(exit_code, oom_killed) that maps the common container exit codes to a human cause and next step.
Requirements:
- 137 or oom_killed → OOMKilled (cgroup memory limit)
- 143 → SIGTERM (graceful stop), 139 → SIGSEGV (native crash)
- 1 → app error (read logs), 126/127 → entrypoint not executable / not found
- Explain the 128 + N convention for signal-caused exits
💡 Hint: Exit codes above 128 encode a terminating signal as 128 + signal number.
Show solution
def diagnose(exit_code, oom_killed):
if oom_killed or exit_code == 137:
return "OOMKilled — cgroup memory limit; raise limit or cut memory use"
if exit_code == 0: return "clean exit — check restart policy"
if exit_code == 143: return "SIGTERM (128+15) — graceful stop"
if exit_code == 139: return "SIGSEGV (128+11) — native crash"
if exit_code == 1: return "app error — read the app logs"
if exit_code in (126, 127): return "entrypoint not executable/not found"
return f"exit {exit_code} — map 128+N to signal N if > 128"Exit codes above 128 encode the terminating signal as 128 + N: 137 = 128+9 (SIGKILL, the OOM killer), 143 = 128+15 (SIGTERM), 139 = 128+11 (SIGSEGV). That convention lets you read the cause straight off the number.
Context: You hardened the image with distroless (CD6), and now it crash-loops in production with no shell to exec into. You must diagnose it without giving up the hardening.
Your task: Give the ordered debug sequence for a shell-free, crash-looping container, and explain how an ephemeral debug container works.
Requirements:
- Read the previous instance's logs (
logs --previous) - Inspect exit code, OOM flag, and last state (no shell needed)
- Note
exec shfails on distroless (no shell) - Attach an ephemeral debug container sharing the target's namespaces with a tool image
💡 Hint: You don't need a shell to read logs or inspect state; when you truly need tools, bring them in a separate image that joins the target's namespaces.
Show solution
# 1) crashed instance's logs:
kubectl logs <pod> --previous
# 2) exit code / OOM / last state — no shell needed:
docker inspect <container> --format '{{json .State}}'
# 3) exec fails on distroless (no /bin/sh):
docker exec -it <container> sh # -> not found
# 4) bring your own tools into the target's namespaces:
kubectl debug -it <pod> --image=busybox --target=<container>Logs and inspect need no shell, so they work on distroless. An ephemeral debug container runs a separate tool-laden image (busybox) but joins the crashing container's process and network namespaces via --target, so you can inspect its files, ports, and processes without adding a shell to the hardened image. Verify kubectl debug syntax against current docs.
Context: A team put a full dependency check in their liveness probe. A 30-second database blip restarted every pod at once, turning a minor blip into a full outage.
Your task: Design the two probes so a bad dependency stops traffic instead of restart-looping the pods, and explain the failure the team hit.
Requirements:
- Make liveness cheap and local (process is up), restart only on real deadlock
- Make readiness check the app is warmed and dependencies are usable
- Give liveness an
initialDelaySecondsso slow starts aren't killed - Explain why dependency checks belong in readiness, not liveness
💡 Hint: Liveness answers "should I restart this?"; readiness answers "should I send it traffic?" — a dependency outage is a traffic decision, not a restart decision.
Show solution
readinessProbe: # remove from LB if deps unusable (no restart)
httpGet: { path: /healthz, port: 8000 }
periodSeconds: 5
livenessProbe: # restart only if the process itself is wedged
httpGet: { path: /livez, port: 8000 }
initialDelaySeconds: 20
periodSeconds: 10
failureThreshold: 3The team put a DB check in liveness, so a transient DB blip failed the probe and Kubernetes restarted every pod — amplifying the blip into an outage. Liveness must be cheap and local (is the process wedged?); dependency health belongs in readiness, which just pulls the pod out of the load balancer until the dependency recovers — no restarts.
Context: A service takes the full 30-second grace period to stop on every deploy, dropping in-flight requests, and its logs show growing defunct child processes. You must make it shut down gracefully and stop leaking zombies.
Your task: Diagnose the PID-1 signal and zombie-reaping problems and fix both: exec-form CMD, a SIGTERM handler that drains, and an init to reap children.
Requirements:
- Explain that shell-form CMD makes
/bin/shPID 1 and it doesn't forward SIGTERM - Switch to exec form so the app is PID 1 and receives signals
- Install a SIGTERM handler that drains in-flight work then exits 0
- Add
--init(or bake tini) so PID 1 reaps orphaned children - Explain why exit 0 gives a fast stop while ignoring SIGTERM forces a slow SIGKILL
💡 Hint: Two separate PID-1 duties: receiving/forwarding signals (fixed by exec form + a handler) and reaping zombies (fixed by an init).
Show solution
Two PID-1 problems. Signals: a shell-form CMD python app.py makes /bin/sh PID 1, which doesn't forward SIGTERM — so the app never drains and is SIGKILLed after the 30s grace period. Fix with exec form plus a handler:
CMD ["python", "app.py"]import signal, sys
shutting_down = False
def handle_term(sig, frame):
global shutting_down; shutting_down = True
signal.signal(signal.SIGTERM, handle_term)
while not shutting_down:
serve_one_request()
sys.exit(0) # clean, fast stop — no SIGKILL waitZombies: PID 1 must reap orphaned children; a plain app process usually doesn't, so defunct processes accumulate. Run with an init that does the reaping:
docker run --init myapp # Docker's built-in tini as PID 1With exec form + a SIGTERM handler the app drains and exits 0 immediately (fast deploys, no dropped requests); an init cleans up child zombies. Ignoring SIGTERM instead forces the platform to wait out the grace period and SIGKILL — slow and unclean. Verify --init/tini usage against current Docker docs.