Inspect, logs and exec

Read a container's output, run commands inside it and pull facts from its config, even with no shell.

Beginner13 min · lesson 6 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (2 files, 1 KB): inspectlogs.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/inspectlogs.tar.gz && tar -xzf inspectlogs.tar.gz, which creates ~/lab/inspectlogs/. SHA-256: 7755b9c741141cf3bd1d1d169e5f5c6b72e6a0a42a31dd3688dad5ae7e12668f

When a container misbehaves or disappears, Docker still holds what you need to find out why: the process output, the exit status and the full configuration, kept until the container is removed. Three commands read that evidence. docker logs replays what the process printed, docker exec runs a command inside a container that is still running, and docker inspect prints what Docker recorded about it. None of them needs a rebuild.

Use the main lab VM (secopslog-docker). Unpack the lesson files into ~/lab/inspectlogs; the only file you need from them is the noshell/ folder used near the end.

Something to look at

Start an nginx web server in the background and publish it on the VM's loopback address only, as in "docker run in depth". The long hex string is the container ID; docker ps shows its first 12 characters next to the name.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker run -d --name lab-web -p 127.0.0.1:8080:80 nginx:1.30-alpine
5ae49ceee669e5b594b0d65848523768c4627ad80ffef6c43655c0ac2fe43866
$ docker ps --filter name=lab-web
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 5ae49ceee669 nginx:1.30-alpine "/docker-entrypoint.…" 2 seconds ago Up 2 seconds 127.0.0.1:8080->80/tcp lab-web

docker logs: what the process printed

A container's main process writes to standard output (stdout) and standard error (stderr) like any other program. Docker's logging driver captures both streams, so docker logs can replay them at any time, also after the container has stopped. The first block below comes from the image's entrypoint script, which prepares the configuration; the [notice] lines come from nginx itself. The middle of the output is shortened here.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker logs lab-web
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration ... /docker-entrypoint.sh: Configuration complete; ready for start up 2026/10/07 18:23:46 [notice] 1#1: using the "epoll" event method 2026/10/07 18:23:46 [notice] 1#1: nginx/1.30.5 ... 2026/10/07 18:23:46 [notice] 1#1: start worker process 33

Send one request and read only the newest line. --tail N limits the replay to the last N lines, and --timestamps prefixes each line with the time Docker received it, which matters when the application's own log lines carry no time or a different time zone. docker logs -f keeps following the output live until you press Ctrl+C.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
200
$ docker logs --tail 1 lab-web
172.17.0.1 - - [07/Oct/2026:18:23:48 +0000] "GET / HTTP/1.1" 200 896 "-" "curl/8.18.0" "-"
$ docker logs --timestamps --tail 2 lab-web
2026-10-07T18:23:46.566530413Z 2026/10/07 18:23:46 [notice] 1#1: start worker process 33 2026-10-07T18:23:48.787749831Z 172.17.0.1 - - [07/Oct/2026:18:23:48 +0000] "GET / HTTP/1.1" 200 896 "-" "curl/8.18.0" "-"

One detail trips people up when they search logs with a pipe. docker logs writes the container's stdout to your stdout and the container's stderr to your stderr, keeping them separate. nginx writes its [notice] lines to stderr, so a plain pipe into grep never sees them. They go straight to the terminal, and grep -c counts 0. Redirect stderr into the pipe with 2>&1 and the count is right.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker logs lab-web | grep -c notice
2026/10/07 18:23:46 [notice] 1#1: using the "epoll" event method ... 2026/10/07 18:23:46 [notice] 1#1: start worker process 33 0
$ docker logs lab-web 2>&1 | grep -c notice
10

A container that exits at once

A common report is "the deploy succeeded, but docker ps is empty". This container runs a shell command with a typo in it. docker run -d prints an ID and returns, which only means the container was created and started. Plain docker ps lists running containers, so the dead one is missing. docker ps -a lists every container, including stopped ones, and its STATUS column carries the exit code.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker run -d --name lab-typo alpine:3.22 sh -c 'ecoh hello'
3a6494779f2392dd2f15971f1722486d0413eee7d448f0801869f1b75163b827
$ docker ps --filter name=lab-typo
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
$ docker ps -a --filter name=lab-typo
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 3a6494779f23 alpine:3.22 "sh -c 'ecoh hello'" 1 second ago Exited (127) 1 second ago lab-typo
$ docker logs lab-typo
sh: ecoh: not found

Exited (127) and sh: ecoh: not found tell the same story: the shell could not find a program called ecoh. Read the exit code before you read anything else, because it tells you which layer failed. The table repeats the codes from "Images, containers and the core commands" and adds where to look next; the labs in this course produce all of them except the out-of-memory case.

docker exec: run a command inside

docker exec starts an extra process inside a running container, in the same filesystem, network and process namespaces as the main process. Use it for yes-or-no questions: which user the server runs as, whether a config file contains what you expect, which processes are alive. In the nginx image the master process runs as root and the workers as the nginx user. The listen [::]:80; line was added by the entrypoint script you saw in the logs.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker exec lab-web whoami
root
$ docker exec lab-web head -n 4 /etc/nginx/conf.d/default.conf
server { listen 80; listen [::]:80; server_name localhost;
$ docker exec lab-web ps
PID USER TIME COMMAND 1 root 0:00 nginx: master process nginx -g daemon off; 30 nginx 0:00 nginx: worker process 31 nginx 0:00 nginx: worker process 32 nginx 0:00 nginx: worker process 33 nginx 0:00 nginx: worker process 45 root 0:00 ps
$ docker exec lab-typo ls
Error response from daemon: container 3a6494779f2392dd2f15971f1722486d0413eee7d448f0801869f1b75163b827 is not running

The last command shows the limit: exec needs a running container, so it cannot help with lab-typo. For an interactive shell, add -i (keep stdin open) and -t (allocate a terminal) and name a shell the image contains. Type exit to leave; the container keeps running.

terminal
docker exec -it lab-web sh
Watch out
Anything you change through docker exec lives in that container's writable layer and disappears when the container is replaced, which happens on every redeploy. Use exec to find the problem, then fix it in the Dockerfile, the run options or a mounted file.

docker inspect: what Docker recorded

docker inspect prints the container's full record as JSON: the command it ran, its state, its mounts, its network settings, its restart policy. The full output is a few hundred lines, so read it once with head to see the shape, then pull single values out with -f (or --format). The argument is a Go template: {{.State.Status}} walks from the top of the JSON into State, then Status. It works on stopped containers too, which is how you read lab-typo's exit code after the fact.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker inspect lab-web | head -n 12
[ { "Id": "5ae49ceee669e5b594b0d65848523768c4627ad80ffef6c43655c0ac2fe43866", "Created": "2026-10-07T18:23:46.364229273Z", "Path": "/docker-entrypoint.sh", "Args": [ "nginx", "-g", "daemon off;" ], "State": { "Status": "running",
$ docker inspect -f '{{.State.Status}}' lab-web
running
$ docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' lab-typo
exited 127

Older guides read a container's IP address from .NetworkSettings.IPAddress. Docker 29 removed that top-level field, and the template fails. A container can be attached to several networks and has one address per network, so the addresses live under .NetworkSettings.Networks, keyed by network name. range loops over that map. If you prefer jq (installed in the lab VM), the same path works there.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker inspect -f '{{.NetworkSettings.IPAddress}}' lab-web
template parsing error: template: :1:18: executing "" at <.NetworkSettings.IPAddress>: map has no entry for key "IPAddress"
# The top-level IPAddress field was removed in Docker 29; this is the error you get from old scripts.
$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lab-web
172.17.0.2
$ docker inspect lab-web | jq -r '.[0].NetworkSettings.Networks[].IPAddress'
172.17.0.2
$ docker inspect -f '{{json .HostConfig.PortBindings}}' lab-web
{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"8080"}]}

172.17.0.2 is an address on Docker's default bridge network; yours may differ, and it can change when the container is recreated, which is why "Container networking basics" connects containers by name instead. The port bindings confirm what docker ps summarised: container port 80 is published on 127.0.0.1:8080 only.

Where the logs are kept

The two values below explain a disk that fills up on a busy host. This engine uses the default json-file logging driver, and map[] means no options were set, so there is no max-size and no rotation. Every line the container prints is appended to one JSON file under /var/lib/docker/containers/<id>/ (readable only by root) until the container is removed. The file is deleted with the container, which is why docker rm throws away the evidence you were about to read.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker inspect -f '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}' lab-web
json-file map[]
$ sudo ls -l $(docker inspect -f '{{.LogPath}}' lab-web)
-rw-r----- 1 root root 2860 Oct 7 23:53 /var/lib/docker/containers/5ae49ceee669e5b594b0d65848523768c4627ad80ffef6c43655c0ac2fe43866/5ae49ceee669e5b594b0d65848523768c4627ad80ffef6c43655c0ac2fe43866-json.log

Production hosts set rotation in the daemon configuration or per container; "Logging and log drivers" in Docker in depth covers max-size, max-file and the local driver.

When the image has no shell

Minimal images often ship without sh, ls or ps, because every tool left out is one an attacker cannot use either. The lesson files build such an image: one static binary and one HTML file on top of an empty base, standing in for a distroless application. You do not need to follow the two-stage Dockerfile yet; "Writing a Dockerfile" introduces the instructions and "BuildKit builds: stages, cache and mounts" in Docker in depth the stages.

noshell/Dockerfile
# A stand-in for a distroless application image: one static binary, no shell, no package manager.
# Stage 1 fetches a statically linked busybox; stage 2 starts from an empty image (scratch).
FROM alpine:3.22 AS tools
RUN apk add --no-cache busybox-static
FROM scratch
COPY --from=tools /bin/busybox.static /sleep
COPY index.html /www/index.html
ENTRYPOINT ["/sleep", "3600"]
ubuntu@secopslog-docker:~/lab/inspectlogs/noshell · Docker 29.8.2
$ docker build -q -t lab-noshell:1 .
sha256:6fc6f123e559789d50ea00a4838b2d3a0a578bb04a6b1db0a3e06a827e3fb841
ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker run -d --name lab-noshell lab-noshell:1
6a63c6dc0bb8ce1c9c474d51662aa2ea4b09b53cf719e629ffd6cd75aed3177e
$ docker exec lab-noshell sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

Exit code 127 again, this time because sh does not exist in the image. Trying bash will not help either. Two approaches work on any Docker Engine. The first starts a throwaway debug container from an image that has tools and joins it to the target's namespaces: --pid container:lab-noshell shares the process list, so the target's main process is PID 1 in the debug container too, and --network container:lab-noshell shares its network, so localhost means the target. Through /proc/1/root the debug container can also read the target's files.

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker run --rm --pid container:lab-noshell --network container:lab-noshell alpine:3.22 ps
PID USER TIME COMMAND 1 root 0:00 /sleep 3600 14 root 0:00 ps
$ docker run --rm --pid container:lab-noshell alpine:3.22 ls /proc/1/root/www
index.html
$ docker cp lab-noshell:/www/index.html ./copied.html && cat copied.html
Successfully copied 32B (transferred 2.05kB) to /home/ubuntu/lab/inspectlogs/copied.html <h1>no shell in this image</h1>

The second is docker cp, which copies files out of (or into) a container from the outside and needs nothing inside the image. Docker Desktop adds a docker debug command that automates the first approach; it is part of Desktop, not of Docker Engine, so it is not in the lab VM. "Minimal bases: distroless, scratch, static and Alpine" in Advanced container security goes further with debug images.

A container misbehaves: which command first
Something is wrong
start with docker ps -a
it exited
docker logs <name>
last lines plus the exit code
running, wrong behaviour
docker exec <name> <cmd>
check files, users, processes
need config or state
docker inspect -f
ports, mounts, networks, exit code
no shell in the image
debug container or docker cp
--pid / --network container:<name>
Logs answer most incidents. Reach for exec and inspect when the output does not explain the failure.

Clean up

ubuntu@secopslog-docker:~/lab/inspectlogs · Docker 29.8.2
$ docker rm -f lab-web lab-typo lab-noshell && docker rmi lab-noshell:1
lab-web lab-typo lab-noshell Untagged: lab-noshell:1 Deleted: sha256:6fc6f123e559789d50ea00a4838b2d3a0a578bb04a6b1db0a3e06a827e3fb841
Quick check
01A container started with docker run -d is missing from docker ps a few seconds later. What do you run first to learn why it stopped?
Incorrect — exec needs a running container. This one has exited, so exec fails with "is not running".
Incorrect — Removing the container deletes its log file and its recorded exit code, the evidence you need.
Correct — ps -a shows the stopped container and its exit code, and logs replays what the process printed before it died.
Incorrect — That field no longer exists in Docker 29 and the template fails. A network address would not explain an exit anyway.
02A script that worked on an older Docker now fails on Docker 29 with map has no entry for key "IPAddress". It runs docker inspect -f '{{.NetworkSettings.IPAddress}}' api. What is the fix?
Correct — Docker 29 removed the top-level field; each network the container joins has its own entry under Networks.
Incorrect — inspect does report addresses, under .NetworkSettings.Networks. Relying on a tool inside the image also fails on minimal images.
Incorrect — The field is gone from the API output in Docker 29 for every container, so a restart changes nothing.
Incorrect — --format json prints the whole object, but the object no longer has a top-level IPAddress, so there is nothing to restore.
03docker exec -it api sh fails with exec: "sh": executable file not found in $PATH, yet the service still answers requests. How do you look at its processes and files?
Incorrect — An image without sh almost never has bash. Minimal images leave both out on purpose.
Incorrect — There is no sh in the image, so that container fails to start with exit code 127 as well.
Incorrect — That changes the thing you are investigating and costs a deploy. Debug the running container first.
Correct — The debug container brings its own tools and shares the target's namespaces; docker cp needs nothing inside the image.

Try this

Run docker exec -it lab-web sh on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

If you keep one thing from inspect, logs and exec, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related