Why this matters
A container is a sealed box: no SSH into it, often no shell in it. When one misbehaves you still need answers - what did it print, what settings did it really get, what happened to it and when, how much memory is it using. Docker has one command for each question. This lesson is the toolbox.
What you need to know already: stdout, stderr and
2>&1(6.17); pipes andgrep -c(7.1); host PIDs and/proc(3.14); how 11.1 reads status and exit codes.
logs: stdout and stderr, kept apart
Every process has two output streams: stdout (normal output) and stderr (errors), as in 6.17. Docker captures what PID 1 writes to those two - nothing else. An app that logs to /var/log/app.log inside the container has empty docker logs (the official nginx image links its log files to /dev/stdout and /dev/stderr for exactly this reason).
docker logs web everything so far
docker logs --tail 50 web only the last 50 lines
docker logs -f web follow: keep printing new lines (Ctrl+C stops following)
docker logs --since 10m web only the last 10 minutes (or --since 2026-09-23T10:00:00)
docker logs -t web prefix each line with its timestamp
-f works like tail -f and journalctl -f (2.30).
The two streams stay separate on your side too, which catches people:
# web = the crash-looping nginx of the mission below
docker logs web | grep -c emerg
0
docker logs web 2>&1 | grep -c emerg
1
nginx writes its error log to stderr, and a pipe | only carries stdout - the stderr lines went straight to your screen and skipped grep. 2>&1 ("send stream 2 where stream 1 goes", 6.17) first, every time you grep container logs.
exec: run something in a running container
docker exec CONTAINER COMMAND starts an extra process inside a running container - same namespaces, same filesystem:
docker exec web nginx -t one command (here: nginx checks its config)
docker exec -it web sh an interactive shell (-i stdin, -t terminal)
docker exec -u 0 web id as another user (-u 0 = root)
docker exec -w /etc/nginx web ls in another working directory (-w)
docker exec -e DEBUG=1 web env with an extra environment variable (-e)
exec needs a running container and a program that exists in the image:
$ docker run -d --name tiny alpine:3.20 sleep 600
$ docker exec -it tiny bash
OCI runtime exec failed: exec failed: unable to start container process: exec: "bash": executable file not found in $PATH: unknown
Alpine has no bash - try sh. Distroless images have neither; use the borrowed-tools and nsenter techniques from chapter 10 (10.38).
inspect --format: the scriptable view
docker inspect NAME prints everything Docker knows about a container as one big JSON document (JSON as in 7.11 with jq). -f / --format takes a Go template - a small text-template language: {{ ... }} marks a value to print, and .State.Status is a path into the JSON, like .State.Status in jq. A handful of paths cover almost every question:
docker inspect -f '{{.State.Status}} {{.State.ExitCode}} {{.State.OOMKilled}}' api
docker inspect -f '{{.RestartCount}}' api how often it was restarted
docker inspect -f '{{.State.Pid}}' api host PID of its PID 1
docker inspect -f '{{json .Config.Env}}' api the environment it really got
docker inspect -f '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}' api its limits
docker inspect -f '{{json .Mounts}}' api volumes and bind mounts
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' api
docker inspect -f '{{json .State.Health}}' api healthcheck results
Three template words: json prints any value as JSON; range loops over a list or map (like .[] in jq); index reaches into maps with awkward keys ({{index .Config.Labels "com.docker.compose.service"}}). You can always fall back to docker inspect api | jq ....
A trap: {{.NetworkSettings.IPAddress}} is only filled for the default bridge network. On a network you created (11.15) it is empty; use the range form.
events: what happened, in order
docker events is Docker's own timeline - every create, start, die, restart, oom:
# api = the OOM-looping container from the limits mission
docker events --since 30m --until now --filter container=api
2026-09-23T10:02:11.482113000+00:00 container start 4f1e2d3c... (image=api:2, name=api)
2026-09-23T10:02:14.905210000+00:00 container oom 4f1e2d3c... (image=api:2, name=api)
2026-09-23T10:02:14.911002000+00:00 container die 4f1e2d3c... (exitCode=137, image=api:2, name=api)
2026-09-23T10:02:15.012344000+00:00 container restart 4f1e2d3c... (name=api)
Each line: timestamp, object type, event, ID, and details in brackets. oom right before die with exitCode=137 is the whole story in two lines.
--since 30mstart 30 minutes ago;--until nowstop at now. Without--untilit keeps following forever (Ctrl+C), like-f.--filter container=apionly this container;--filter event=dieonly deaths.
stats and top
$ docker stats --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
4f1e2d3c4b5a api 98.50% 498.2MiB / 512MiB 97.30% 1.2kB / 894B 4.1MB / 0B 38
docker stats is a live top for containers; --no-stream prints one snapshot and exits. Columns:
- CPU % - as a percentage of one core (200% = two cores busy)
- MEM USAGE / LIMIT - memory used against the container's memory limit (the host's RAM if it has none)
- NET I/O - bytes received / sent over the network
- BLOCK I/O - bytes read / written to disk
- PIDS - number of processes and threads inside
A container pinned near its memory limit is one allocation away from exit 137.
docker top api lists its processes with host PIDs and host user names - which is how you notice that uid 1000 inside is learner outside (4.3).
diff and cp
docker diff api files the container changed: A added, C changed, D deleted
docker cp api:/app/logs/app.log . copy a file out (works on stopped containers too)
docker cp fix.conf api:/etc/app/ copy a file in (not a substitute for rebuilding)
diff compares the container's writable layer (10.3) with its image.
Getting into an image that will not stay up
exec needs a running container. If it exits at start, replace what it runs:
docker run --rm -it --entrypoint sh api:2 a shell instead of the app
docker run --rm --entrypoint cat api:2 /app/config.yml print one file
docker run --rm -it --entrypoint sh api:2 -c 'env; ls -la /app'
--entrypoint takes a single program; its arguments go after the image name.
What you can now do
- read a container's logs without losing stderr
- pull any fact out of
docker inspectwith a template - rebuild a timeline with
docker events, and watch usage withdocker stats