Exec and Logs
Measured on Docker Desktop 4.81.0 / Engine 29.6.1 (macOS, linux/arm64), image nginx:1.31-alpine, 2026-09-30.
Opening a shell in a running container
Section titled “Opening a shell in a running container”docker exec runs a new process inside an already-running container. The most common use is opening an interactive shell:
docker exec -it <container> sh-ikeeps stdin open (interactive)-tallocates a pseudo-TTY (so you get a proper terminal prompt)shis the command to run — usebashonly if the image has it. Alpine-based images (nginx:1.31-alpine,node:24-alpine,alpine) ship BusyBoxshbut notbash.
Inside the shell you can inspect files, run commands, and debug problems — exactly as if you were on the host machine.
# Example sessiondocker run -d --name webserver nginx:1.31-alpinedocker exec -it webserver sh
# Inside the container:# / # cat /etc/os-release# / # ls /usr/share/nginx/html# / # exitChoosing the user, directory and environment
Section titled “Choosing the user, directory and environment”docker exec can override three things for the new process only (the container’s own environment is not changed):
$ docker exec -u nginx -w /etc/nginx -e GREETING=hello webserver sh -c 'id -un; pwd; echo $GREETING'nginx/etc/nginxhelloWithout -u the process runs as the container’s configured user (root for this image). -w sets the working directory and -e adds a variable for this one process. Exec’d processes see the same filesystem and the same network as PID 1; they are not visible in docker ps.
What the shell can’t do when the image has no shell
Section titled “What the shell can’t do when the image has no shell”$ docker exec webserver bash -c 'echo hi'OCI runtime exec failed: exec failed: unable to start container process: exec: "bash": executable file not found in $PATH$ echo $?127$ docker exec webserver sh -c 'echo hi'hi127 is the “command not found” code from the lifecycle lesson. Minimal images (distroless, scratch) have no sh either; there docker exec cannot open a shell at all and you debug with docker logs, docker cp, or a debug sidecar.
Viewing container logs
Section titled “Viewing container logs”Every container’s stdout and stderr are captured by a logging driver. The default is json-file (docker info --format '{{.LoggingDriver}}' printed json-file):
# Print all logs so fardocker logs webserver
# Follow (stream) new log lines in real time — like tail -fdocker logs -f webserver
# Show the last 20 linesdocker logs --tail 20 webserver
# Add timestampsdocker logs -t webserver
# Only lines from the last 30 secondsdocker logs --since 30s webserverReal output of docker logs --tail 2 -t (RFC 3339 timestamps, UTC):
2026-09-30T08:29:16.234130419Z 2026/09/30 08:29:16 [notice] 1#1: start worker process 392026-09-30T08:29:16.234618711Z 2026/09/30 08:29:16 [notice] 1#1: start worker process 40With a container that prints tick N once a second, docker logs --since 2s returned the last three ticks and --tail 1 only the last. Logs are the first place to look when a container misbehaves. If a container keeps restarting, docker logs <name> usually tells you exactly why.
Log files grow without limit by default
Section titled “Log files grow without limit by default”json-file has no size limit unless you set one: docker inspect -f '{{.HostConfig.LogConfig.Config}}' printed map[]. A chatty container fills the disk that holds /var/lib/docker (inside the VM on Docker Desktop; docker inspect -f '{{.LogPath}}' prints the path). Rotate with --log-opt:
#!/bin/shdocker run --name noisy --log-opt max-size=1k --log-opt max-file=2 alpine:3.24 \ sh -c 'for i in $(seq 1 200); do echo "line $i"; done'docker logs noisy | wc -ldocker logs noisy | head -1docker rm noisyReal result: the same loop with rotation kept 18 of 200 lines (from line 183 to line 200); the identical container without --log-opt kept all 200. The config is stored on the container: json-file map[max-file:2 max-size:1k]. In practice use something like max-size=10m max-file=3, or set it for every container in daemon.json (log-driver, log-opts), per the json-file driver docs. Note that rotated-away lines are gone: a limit is a trade of history for disk.
Inspecting processes with docker top
Section titled “Inspecting processes with docker top”docker top lists the processes running inside a container — without entering it:
docker top webserverReal output from this Mac:
UID PID PPID C STIME TTY TIME CMDroot 60551 60527 0 08:29 ? 00:00:00 nginx: master process nginx -g daemon off;statd 60587 60551 0 08:29 ? 00:00:00 nginx: worker processstatd 60588 60551 0 08:29 ? 00:00:00 nginx: worker process... # 11 workers in totalThree things differ from the tidy sample you may see elsewhere:
- The PIDs are the host’s (here the Desktop VM’s), not the container’s.
docker exec webserver psshows the same processes as PID1(master) and30,31, … (workers) — the PID namespace at work. - The user column is
statd, notnginx:docker topresolves the numeric uid (101) with the host’s/etc/passwd, where uid 101 happens to bestatd. Inside the container the same uid isnginx. Never trust the name indocker topfor security decisions; look at the uid. - There are 11 workers, one per CPU the VM has (
worker_processes auto), not one.
You can pass ps options: docker top webserver -o pid,user,args.
Running one-off commands
Section titled “Running one-off commands”You do not always need a full shell. docker exec can run any command directly:
# Check nginx configdocker exec webserver nginx -t
# View a filedocker exec webserver cat /etc/nginx/nginx.confnginx -t printed syntax is ok / test is successful.
exec vs attach
Section titled “exec vs attach”docker execstarts a new process. Leaving it (or Ctrl+C in it) ends only that process.docker attachconnects your terminal to the container’s existing PID 1 stdin/stdout/stderr. Signals go to PID 1.
Proven with a container whose PID 1 is a while true; do echo tick; sleep 1 loop: docker attach streamed tick 3, 4, 5…; sending SIGINT to the attach client (what Ctrl+C does) was proxied to PID 1 by default (--sig-proxy=true), and the container ended exited 130 (128 + 2, SIGINT). That is why the docs advise detaching with Ctrl-p Ctrl-q or attaching with --sig-proxy=false if you only want to look (docker attach reference).
# 1. Start nginx in the background
docker run -d --name webserver nginx:1.31-alpine
# 2. Check its logs
docker logs webserver
# 3. Run a one-off command inside it
docker exec webserver nginx -t
# 4. Open an interactive shell
# (type 'exit' when done)
docker exec -it webserver sh
# 5. Watch processes
docker top webserver
# 6. Follow logs in real time (Ctrl+C to stop)
docker logs -f webserverReading AI-generated code
Section titled “Reading AI-generated code”An AI-written “debug helper” for a team wiki:
🐛 Spot the bug
#!/bin/sh# AI: "open a shell in the api container (works for any image)"docker exec -it api bashShow the answer
The container is nginx:1.31-alpine (or node:24-alpine, or any Alpine image), which has no bash. Proven above: OCI runtime exec failed: ... exec: "bash": executable file not found in $PATH, exit code 127. The same helper also breaks in automation: with stdin not a terminal, -t is refused (echo hi | docker exec -it web sh printed cannot attach stdin to a TTY-enabled container because stdin is not a terminal, exit 1), while -i alone worked and printed hi. Pick the shell that exists and only ask for a TTY when there is one:
#!/bin/shset -euc=${1:?usage: shell.sh <container>}# Prefer bash if present, else fall back to sh; only allocate a TTY on a terminalsh_bin=$(docker exec "$c" sh -c 'command -v bash || command -v sh')if [ -t 0 ]; then exec docker exec -it "$c" "$sh_bin"else exec docker exec -i "$c" "$sh_bin"fiIf the image has no shell at all, docker exec fails the same way; use docker logs, docker cp, or run a debug container in the same network/PID namespace.
Mental Model
Section titled “Mental Model”A running container is one PID 1 plus whatever else the kernel puts in its namespaces. logs reads what PID 1 wrote to stdout/stderr (stored by the logging driver, outside the container’s filesystem). exec adds a process to the same namespaces. top looks at the same processes from the outside, with the host’s PIDs and the host’s user names. attach wires your terminal to PID 1 itself.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
docker exec -it live debugging | Immediate real state, fast for on-the-spot troubleshooting | Not persisted, no audit trail, and easy to forget what you changed |
docker logs + centralized logging | Keeps history, searchable and shareable with the team | Needs a logging driver or log aggregator set up |
json-file with max-size/max-file | Bounded disk use | Old lines are discarded (we kept 18 of 200) |
json-file unbounded (default) | Nothing is lost | A chatty container can fill the disk |
Common mistakes
Section titled “Common mistakes”- Using
docker execto patch files live inside a container instead of fixing the image or Dockerfile and rebuilding, so the change disappears the moment the container is removed. - Jumping straight to
docker execto debug before checkingdocker logs, when the logs usually already say exactly what went wrong. - Relying on
docker execas the primary debugging tool instead of proper logging/monitoring — it only works while the container is alive; a crashed container can no longer be exec’d into. - Copy-pasting
docker exec -it x bashfor images that only havesh. - Leaving
json-fileunbounded on a long-lived, verbose container.
The same split exists in Kubernetes: kubectl logs reads what the container wrote, kubectl exec runs a new process in it (kubectl reference). Different tool, same model.
Common Misconceptions
Section titled “Common Misconceptions”- “
docker execstarts another container.” It adds a process to the existing one; the container’sdocker psline does not change. - “The PIDs in
docker topare the container’s PIDs.” They are the host’s. Inside, the master is PID 1. - “Docker keeps only recent logs.” Not by default:
json-filekeeps everything until the container is removed, unless you setmax-size. - “Ctrl+C in
docker attachis harmless.” It is proxied to PID 1; the container exited 130.