05 · Container Logs & Debugging¶
When a container misbehaves, three commands cover almost every diagnosis:
docker logs (what did it print), docker exec (poke at it while it's
running), and docker inspect (what is its actual configuration and
state).
docker logs¶
docker logs myapp # everything captured so far
docker logs -f myapp # follow, like tail -f
docker logs --tail 100 myapp # last 100 lines only
docker logs --since 10m myapp # only the last 10 minutes
docker logs -t myapp # prefix each line with a timestamp
docker logs only shows what the container's PID 1 process wrote to
stdout/stderr — it is not a general-purpose log viewer for arbitrary
files the application might also write inside the container. An app that
logs only to /var/log/app.log and never to stdout will show nothing
here (fixable at the app's logging config, or by symlinking that file to
/dev/stdout).
docker exec — a shell inside a running container¶
docker exec -it myapp sh # interactive shell (bash if available)
docker exec myapp cat /etc/hosts # one-off command, no interactive session
docker exec myapp env # inspect the actual environment variables it sees
-it combines -i (keep stdin open) and -t (allocate a pseudo-TTY) —
both are needed for an interactive shell to behave like a normal
terminal; a one-off diagnostic command like cat needs neither.
docker inspect for configuration and state¶
docker inspect myapp
docker inspect -f '{{.State.Status}}' myapp
docker inspect -f '{{json .Config.Env}}' myapp
docker inspect -f '{{.NetworkSettings.IPAddress}}' myapp
--format/-f with Go template syntax extracts one field instead of
scrolling through hundreds of lines of JSON — invaluable when scripting
health checks or debugging why a container isn't reachable on the network
you expect.
Worked example: diagnosing a crash-looping container¶
docker ps -a --filter name=myapp
# STATUS shows "Restarting (1) 4 seconds ago" — crash-looping
docker logs --tail 50 myapp
# reveals the actual stack trace/error from the last crash
docker inspect -f '{{.State.ExitCode}}' myapp
# 1 — an application error, not a SIGKILL (which would show 137)
docker inspect -f '{{json .Config.Env}}' myapp | python3 -m json.tool
# confirms whether a required env var is actually set as expected
If the container restarts too fast to exec into it, temporarily
override its restart policy and command to keep it alive for inspection:
docker run -it --entrypoint sh myimage
# drops you into a shell instead of running the normal (crashing) CMD
docker stats for resource-related symptoms¶
Shows live CPU%, memory usage/limit, and network I/O — useful when the
symptom is "slow" or "OOM-killed" rather than "crashes immediately," and
complements docker inspect's static config with a runtime snapshot.
How It Actually Works¶
Where docker logs output actually comes from. The Docker daemon
doesn't read a container's stdout live off some shared pipe every time
you run docker logs — it captures stdout/stderr once, continuously, the
moment the container starts, via the configured logging driver
(default: json-file). Concretely, the container's stdout/stderr file
descriptors are connected to a pipe whose other end the daemon reads, and
the daemon writes each line to a JSON-lines file under
/var/lib/docker/containers/<id>/<id>-json.log (tagged with a
timestamp). docker logs is simply reading and formatting that file (or
tailing it, for -f) — which is also why logs survive a container
restart (same file, same container ID) but disappear entirely once you
docker rm it, and why an extremely chatty container with the default
driver can quietly fill your disk (module 07 covers alternative drivers).
What docker exec actually attaches to, mechanically. Unlike
docker run, which creates an entirely new PID/mount/network namespace
set, docker exec uses the setns() syscall to make a new process
join the existing namespaces of the target container's PID 1 — same
filesystem view (same mount namespace, so it sees the container's
overlayfs merged view, not the host's), same network namespace (same IP,
same open ports), same PID namespace (so ps inside the exec'd shell
shows the container's other processes, not the host's). This is why
docker exec feels like "getting a shell inside the container" despite
not being the original containerized process at all — it's a genuinely
separate process that happens to have joined all the same kernel
namespaces, which also means killing the docker exec shell has no
effect whatsoever on the container's actual PID 1 or its restart
behavior.
Exercise¶
Run docker run -d --name debug-demo alpine sh -c "echo starting; sleep 300".
Use docker logs debug-demo to see the startup message, docker exec -it
debug-demo sh to get a shell and confirm ps shows only that container's
processes (not your host's), then docker inspect -f
'{{.State.Pid}}' debug-demo to find its PID as seen from the host, and
compare it to the PID that process reports for itself inside the
container via docker exec debug-demo sh -c "echo \$\$".