Debugging Containers
Finding out what is wrong with a container: logs, exec and inspect, exit codes and OOMKilled, health checks, and debugging minimal images that have no shell.
A method for broken containers
The general approach is the one in a structured troubleshooting method: gather facts first, form a theory, test it, change one thing at a time. For containers, the facts come in a useful order, from the cheapest and safest to the most intrusive:
-
Status:
docker ps -a. Is it running, restarting, exited (with which code), or only created? -
Logs:
docker logs. What did the program say before it stopped? -
Configuration and state:
docker inspect. Which image, command, environment, mounts, networks and health status? -
Inside:
docker exec. Only when the container runs and the first three did not explain it. -
Around it:
docker events,docker stats, the host's logs and disk space.
The recorded sessions on this page come from a test machine (Docker Engine 28.2 on a 64-bit ARM Raspberry Pi).
Reading the status
docker ps shows only running containers; a crashed one
is only visible with -a:
$ docker run -d --name crash -e DB_HOST=db.kestrel.internal kestrel-meter-api:1.1 python -c 'import os,sys; print("connecting to", os.environ["DB_HOST"], flush=True); sys.exit("FATAL: database unreachable")'
$ docker ps -a --filter name=crash --format '{{.Names}} {{.Status}}'
195262544ec9cedaad545e57cd622e9821f6154b47c59dad9d50fb0fd8786f8f crash Exited (1) 2 seconds ago
The status words tell you where to look next:
| Status | Meaning | Next step |
|---|---|---|
Up 3 hours |
Running | Logs, health, then exec |
Up 3 hours (unhealthy) |
Running, but its health check fails | Health log |
Restarting (1) 4 seconds ago |
A restart policy keeps starting a crashing program | Logs (they cover all attempts), restart count |
Exited (1) 2 seconds ago |
The program stopped with this exit code | Exit code, then logs |
Created |
Never started, or held back by a dependency | The error of the start command, Compose output |
The exit codes in short (they are explained with recorded examples
on Running Containers, and the
128 + signal rule on
exit status): 0 is success; 1 and other low numbers come from the program
itself; 125 means docker run itself failed, 126 that
the command could not be executed and 127 that it was not found, so
in those three cases the program never ran; 137 is 128 + 9 (SIGKILL)
and 143 is 128 + 15 (SIGTERM).
When a service desk ticket lists several containers, a few lines of
Python turn the State part of
docker inspect into a first verdict for each:
import json
# The State part of `docker inspect` for six containers (trimmed).
containers = json.loads("""[
{"Name": "/db", "RestartCount": 14,
"State": {"Status": "restarting", "ExitCode": 1, "OOMKilled": false}},
{"Name": "/meter-api", "RestartCount": 0,
"State": {"Status": "exited", "ExitCode": 137, "OOMKilled": true}},
{"Name": "/billing-export", "RestartCount": 0,
"State": {"Status": "exited", "ExitCode": 137, "OOMKilled": false}},
{"Name": "/portal", "RestartCount": 0,
"State": {"Status": "running", "ExitCode": 0, "OOMKilled": false,
"Health": {"Status": "unhealthy", "FailingStreak": 3}}},
{"Name": "/report", "RestartCount": 0,
"State": {"Status": "exited", "ExitCode": 127, "OOMKilled": false}},
{"Name": "/export-test", "RestartCount": 0,
"State": {"Status": "exited", "ExitCode": 0, "OOMKilled": false}}
]""")
def verdict(c):
s = c["State"]
code = s["ExitCode"]
if s["Status"] == "restarting" or c["RestartCount"] > 5:
return f"restart loop ({c['RestartCount']} restarts): read docker logs"
if s["OOMKilled"]:
return "killed for exceeding its memory limit: raise --memory or fix the leak"
if s.get("Health", {}).get("Status") == "unhealthy":
return "runs, but fails its health check: read State.Health.Log"
if s["Status"] == "running":
return "running"
if code == 0:
return "finished normally"
if code in (125, 126, 127):
return f"never started (exit {code}): check the command and the image"
if code == 137:
return "got SIGKILL: docker kill, or it ignored SIGTERM on docker stop"
if code == 143:
return "stopped with SIGTERM"
return f"the application failed (exit {code}): read docker logs"
for c in containers:
print(f"{c['Name'][1:]:15} {verdict(c)}")
db restart loop (14 restarts): read docker logs meter-api killed for exceeding its memory limit: raise --memory or fix the leak billing-export got SIGKILL: docker kill, or it ignored SIGTERM on docker stop portal runs, but fails its health check: read State.Health.Log report never started (exit 127): check the command and the image export-test finished normally
Logs
docker logs prints what a container's main process wrote to its standard output and standard error. Docker captures both streams and keeps them, also after the container has stopped, until the container is removed. For the crashed container above:
$ docker logs crash
connecting to db.kestrel.internal FATAL: database unreachable
Two lines answer the ticket: the program tried
db.kestrel.internal and could not reach it. The next
question is a network or DNS one (see
Container Networking), not a
container one. The options you will use most:
| Command | Shows |
|---|---|
docker logs --tail 50 meter-api |
The last 50 lines |
docker logs -f meter-api |
Follows new lines as they arrive (Ctrl+C to stop) |
docker logs --since 10m meter-api |
Only the last ten minutes |
docker logs -t meter-api |
Adds Docker's own timestamp (UTC) to every line |
docker logs meter-api 2>&1 | grep -i error
|
Searches both streams (Docker replays stderr as stderr, so
the redirection is needed for grep)
|
With the default json-file logging driver, Docker
stores each line as one JSON object in a file under
/var/lib/docker/containers/ on the host. The default
does not rotate those files, which is how a chatty container fills a
disk; see
Running Containers for the
settings. Because each line is JSON, it is easy to analyze, for
example errors per minute (see also
log correlation):
import json
from collections import Counter
# Lines as the json-file log driver stores them on the host
raw = r"""{"log":"meter-api listening on :8000\n","stream":"stdout","time":"2026-10-06T12:20:41.120Z"}
{"log":"GET /readings 200\n","stream":"stdout","time":"2026-10-06T12:20:50.031Z"}
{"log":"ERROR database timeout after 5s\n","stream":"stderr","time":"2026-10-06T12:21:03.402Z"}
{"log":"ERROR database timeout after 5s\n","stream":"stderr","time":"2026-10-06T12:21:09.877Z"}
{"log":"GET /health 200\n","stream":"stdout","time":"2026-10-06T12:21:30.005Z"}
{"log":"ERROR database timeout after 5s\n","stream":"stderr","time":"2026-10-06T12:22:01.660Z"}
{"log":"ERROR connection pool exhausted\n","stream":"stderr","time":"2026-10-06T12:22:02.118Z"}"""
errors = Counter()
first = {}
for line in raw.splitlines():
entry = json.loads(line)
if entry["stream"] == "stderr" and entry["log"].startswith("ERROR"):
minute = entry["time"][11:16] # HH:MM, in UTC
errors[minute] += 1
first.setdefault(minute, entry["log"].strip())
for minute in sorted(errors):
print(f"{minute} UTC {errors[minute]} errors first: {first[minute]}")
12:21 UTC 2 errors first: ERROR database timeout after 5s 12:22 UTC 2 errors first: ERROR database timeout after 5s
Inspecting configuration and state
docker inspect prints
everything Docker knows about a container (or an image, volume or
network) as JSON: the command and environment it was started with,
its mounts, networks, limits, and its current state. The full output
runs to hundreds of lines; --format picks out fields
with a Go template:
$ docker inspect --format 'exit={{.State.ExitCode}} error={{.State.Error}} finished={{.State.FinishedAt}}' crash
$ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' crash
exit=1 error= finished=2026-10-06T12:24:34.559286341Z DB_HOST=db.kestrel.internal PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin GPG_KEY=7169605F62C751356D054A26A821E680E5FA6305 PYTHON_VERSION=3.13.16 PYTHON_SHA256=f4b1bfb3c79b5bb11b8d228a12504163b4c0dab4d679828d8f5f26b6cb6ab35d
State.Error is empty, so Docker itself had no trouble;
the program chose to exit. The environment shows the variable from
the command line plus those the Python base image sets (GPG_KEY
and PYTHON_SHA256 are public values used to verify the
Python download, not secrets). It also shows why real passwords do
not belong in environment variables: anyone who can run
docker inspect reads them. Fields worth knowing:
| Template | Answers |
|---|---|
{{.State.Status}} {{.State.ExitCode}}
{{.State.OOMKilled}}
|
How did it end? |
{{.RestartCount}} |
How often has the restart policy restarted it? |
{{json .State.Health}} |
What did the health check say? |
{{.Config.Image}} {{.Image}} |
Which tag, and which exact image ID, is it running? |
{{json .Mounts}} |
Where is its data? |
{{json .NetworkSettings.Networks}} |
Which networks and addresses does it have? |
{{.HostConfig.Memory}} |
Its memory limit in bytes (0 means no limit) |
docker inspect meter-api | jq '.[0].State' is an
alternative when you prefer
jq to Go
templates.
Looking inside
docker exec starts an extra
process in a running container, in the same namespaces as its main
process: the same file system, network and process list. With
-it it gives you an interactive shell (docker exec -it meter-api sh). It only works on a running container:
$ docker exec crash sh
Error response from daemon: container 195262544ec9cedaad545e57cd622e9821f6154b47c59dad9d50fb0fd8786f8f is not running
For a container that crashes at start, start a new container from the same image with a shell as its entry point. It has the same files and the same software, without the crashing program:
$ docker run --rm --name crash-shell --entrypoint sh kestrel-meter-api:1.1 -c 'ls -la /app; python --version'
total 16 drwxr-xr-x 1 root root 4096 Oct 6 12:20 . drwxr-xr-x 1 root root 4096 Oct 6 12:24 .. -rw-r--r-- 1 root root 966 Oct 6 12:20 app.py -rw-r--r-- 1 root root 17 Oct 6 12:20 requirements.txt Python 3.13.16
Add -it and leave out -c … for an
interactive shell. Without a terminal, for example in a script or a
CI job, -t fails:
$ docker exec -it meter-api sh
the input device is not a TTY
Several commands answer questions without a shell inside the
container at all. docker top lists its processes (with
host process IDs; STIME is the host's local time),
docker diff lists files changed in its writable layer
(A added, C changed,
D deleted), and docker cp copies files out
for a closer look:
$ docker top meter-api
$ docker exec meter-api sh -c 'echo debug > /tmp/trace.txt'; docker diff meter-api
$ docker cp meter-api:/app/app.py ./app-from-container.py && head -1 app-from-container.py
UID PID PPID C STIME TTY TIME CMD 10001 2409570 2409547 0 14:20 ? 00:00:00 python app.py C /tmp A /tmp/trace.txt """Kestrel Energy meter-api: a tiny demo service (fictional company)."""
Exit 137 and OOMKilled
A container with a memory limit (--memory 256m) that
tries to use more is stopped by the Linux kernel's out-of-memory
killer, which sends SIGKILL (see
the OOM killer). OOMKilled is the field in a
container's state (State.OOMKilled in
docker inspect) that Docker sets to
true when that happened because of the container's
memory limit. On a host with working memory limits, a meter-api with
a memory leak looks like this:
$ docker run -d --name meter-api --memory 128m registry.example.com/kestrel/meter-api:2.3.0
$ docker ps -a --filter name=meter-api --format 'table {{.Names}}\t{{.Status}}'
$ docker inspect --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' meter-api
3f9c1e7a52d04b8e96a1f0c3d7b25e4a8c6f1d09e2b7a3c5f8d4e6b1a9c0f2d7 NAMES STATUS meter-api Exited (137) 12 seconds ago exit=137 oom=true
Exit code 137 alone does not prove a memory problem. On the test
machine, two containers ended with 137 without any memory limit: one
was stopped with docker kill, the other was a process
that ignored the polite SIGTERM of docker stop, so
Docker sent SIGKILL after the 10-second grace period:
$ docker inspect --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' kill pid1
/kill exit=137 oom=false /pid1 exit=137 oom=false
Reading 137, then:
-
oom=true: the container hit its memory limit. Checkdocker statsfor its normal usage, then raise the limit or fix the leak. For Java, see JVM memory in containers. -
oom=falseand it took about ten seconds to stop: the program ignores SIGTERM, often because it runs as process 1 through a shell (see Running Containers). -
oom=falsewithout a stop or kill: look in the host's kernel log (journalctl -k | grep -i 'out of memory'). Without a container limit, the host itself can run out of memory and the kernel kills a process there.
A container does not always stop when memory runs out. If the kernel kills a child process, for example one Gunicorn worker of the meter-api, the main process starts a new worker and the container keeps running; only the application log mentions that a worker was killed. Repeated worker restarts in the application log are the clue.
When memory limits are silently ignored
A real-world gotcha: on the Raspberry Pi where this page's output
was recorded, the kernel boots with the memory controller of cgroups
switched off (cgroup_disable=memory, the Raspberry Pi
OS default on that machine). Docker warns, and then starts the
container anyway, without the limit:
$ docker info 2>&1 | grep WARNING
$ docker run --rm --pull never --name mem --memory 16m nginx:alpine sh -c 'cat /sys/fs/cgroup/memory.max 2>&1; ls /sys/fs/cgroup | head -5'
WARNING: No memory limit support WARNING: No swap limit support WARNING: Your kernel does not support memory limit capabilities or the cgroup is not mounted. Limitation discarded. cat: can't open '/sys/fs/cgroup/memory.max': No such file or directory cgroup.controllers cgroup.events cgroup.freeze cgroup.kill cgroup.max.depth
"Limitation discarded": the container runs without any memory limit,
and docker stats on that host shows
0B / 0B in the memory column. Anyone who reads only the
docker run command believes the container is limited to
16 MB. The warning appears once, at start, and is easy to miss in a
script's output. The same missing controller stopped a local
Kubernetes cluster from starting on that machine (see
kubectl and Troubleshooting). On Raspberry Pi OS the usual fix is to add
cgroup_enable=memory to the kernel command line in
/boot/firmware/cmdline.txt and reboot; plan that as a
change, because it restarts everything on the host.
Health checks
A running process is not necessarily a working service: the
meter-api can be up but unable to answer requests. The
HEALTHCHECK instruction
in a Dockerfile defines a command that Docker runs inside the
container at a regular interval to test it; exit code 0 means
healthy and 1 unhealthy. Compose's healthcheck setting
does the same per service (see
Docker Compose).
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
Without options, the defaults are an interval of 30 seconds, a
timeout of 30 seconds, three retries and no start period. The status
starts as starting, becomes healthy after
the first passing check, and unhealthy after
retries failures in a row. Failures during the start period
do not count, which gives a slow application time to start.
When a check fails, its own output is stored in the container's state. In the test run of Docker Compose with a health check on the wrong port:
$ docker inspect --format '{{.State.Health.Status}} streak={{.State.Health.FailingStreak}}' kestrel-unhealthy-meter-api-1
$ docker inspect --format '{{(index .State.Health.Log 0).ExitCode}} {{(index .State.Health.Log 0).Output}}' kestrel-unhealthy-meter-api-1
unhealthy streak=3 1 Traceback (most recent call last): … ConnectionRefusedError: [Errno 111] Connection refused … urllib.error.URLError: <urlopen error [Errno 111] Connection refused>
"Connection refused" while the service itself runs fine points at the check, not at the service: here it tried port 9999 instead of 8000. Plain Docker Engine only reports health; it does not restart an unhealthy container. Compose uses the status to hold back dependent services, and orchestrators replace unhealthy containers, Kubernetes with its own probes (see Health, Resources and Scaling). Keep the check cheap and local: it runs every interval, and a check that also tests the database turns a database hiccup into "every service unhealthy".
Debugging images without a shell
Minimal images, such as distroless images, contain the application
and its libraries but no shell and no tools (see
Building Good Images). That
is good for security and awkward for debugging:
docker exec meter-api sh then fails with an error that
contains executable file not found in $PATH.
The answer is to bring the tools in a second container that shares
the broken container's namespaces. It sees the same network (--network container:…) and the same processes (--pid container:…), but has
its own file system with the tools:
$ docker run --rm -it \
--network container:meter-api \
--pid container:meter-api \
busybox:1.37 sh
From that shell,
wget -qO- http://127.0.0.1:8000/healthz tests the
service from its own point of view and ps
shows its processes; the application's files are reachable under
/proc/1/root/ (with enough privileges). Use a debugging
image your organization trusts; images with network tools such as
tcpdump and dig are common. In Kubernetes,
kubectl debug does the same with an ephemeral container
(see
kubectl and Troubleshooting).
Events and common start-up errors
docker events streams what the daemon does: containers
created, started, dying, health status changes, executions. With
--since and --until it shows the history
of a time window, which reconstructs a restart loop or a colleague's
actions:
$ docker events --since 1791289474 --until $(date +%s) --filter container=crash --filter container=crash-shell --filter container=meter-api --format '{{.Type}} {{.Action}} {{.Actor.Attributes.name}}' | head -20
container create crash container start crash container die crash container create crash-shell container attach crash-shell container start crash-shell container die crash-shell container destroy crash-shell container destroy crash container top meter-api container exec_create: sh -c echo debug > /tmp/trace.txt meter-api container exec_start: sh -c echo debug > /tmp/trace.txt meter-api
(--since takes a Unix timestamp or a duration such as
30m.) The messages below cover most containers that
refuse to start. Where the wording was recorded on the test machine,
it is quoted exactly:
| Message contains | Usual cause | Check |
|---|---|---|
executable file not found in $PATH (recorded,
exit 127)
|
Misspelled command, or the program is not in the image |
docker inspect the image's
Entrypoint and Cmd
|
permission denied (recorded, exit 126) |
The command is not executable, or a file on a mount belongs to another user ID | Execute bit; user IDs on mounts |
exec format error |
The image was built for another processor architecture, such as an amd64 image on an arm64 host |
docker image inspect --format '{{.Architecture}}'; see
multi-platform images
|
port is already allocated |
Another container or program uses that host port |
ss -tlnp, docker ps; see
port in use
|
no space left on device |
The host's disk is full: images, logs, build cache |
docker system df, df -h; see
disk full
|
is not running (recorded) |
exec into a stopped container |
Logs; a new container with --entrypoint sh
|
Write what you found in the ticket as you go: the status, the exit code, the relevant log lines and the inspect fields. It turns the next handover into a two-minute read (see reading errors and logs).