Docker & ContainersXXXIII Β· Incident ResponseEvidence
Evidence capture β what docker restart destroys
What you'll learn
- State exactly what each recovery action destroys
- Run a capture in order of volatility before mutating anything
- Preserve a broken container filesystem for later analysis
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The triage lesson said: stabilise first, investigate after. That is right, and it hides a trap. Most stabilisation actions are also evidence-destruction actions, and the evidence they destroy is the only thing that would have let you explain the incident afterwards.
The resolution is not to investigate before stabilising. It is to spend ninety seconds collecting before stabilising. Collection is not investigation β you are not reading any of it yet β and ninety seconds is affordable in almost every incident.
What each action destroys
| Action | Container filesystem | Process and memory state | docker logs | Crash state in inspect |
|---|---|---|---|---|
docker pause | kept | kept, frozen | kept | kept |
docker stop | kept | gone | kept | recorded |
docker restart | kept | gone | kept | overwritten by the new run |
docker rm | gone | gone | gone | gone |
docker compose down | gone | gone | gone | gone |
docker compose down -v | gone, and the volumes | gone | gone | gone |
| Host reboot | kept | gone | kept | overwritten if it restarts |
Three rows deserve emphasis.
docker restart is the most common first action and it silently
overwrites the crash state. ExitCode, OOMKilled, Error and
FinishedAt describe the most recent exit; once the container is
running again they describe nothing useful. The single most valuable
fact in an OOM incident β "OOMKilled": true β has a lifetime that
ends at your first restart.
docker rm and docker compose down remove the container, and with
it the log file the json-file driver was writing. docker logs on
a removed container is not a permissions problem or a retention
problem; there is nothing there.
And docker compose down -v removes the volumes. That is data, not
evidence, and it is the one action on the table that cannot be
recovered by any amount of care afterwards.
Capture in order of volatility
Collect the things that disappear fastest, first. Process and memory state vanish the moment anything stops; filesystem state survives until the container is removed; configuration survives indefinitely.
#!/usr/bin/env bash
# Usage: ./capture.sh CONTAINER_NAME
set -uo pipefail
CTR="${1:?usage: capture.sh CONTAINER}"
OUT="/var/tmp/incident-$(date -u +%Y%m%dT%H%M%SZ)-$CTR"
mkdir -p "$OUT"
# --- Most volatile: live process and network state -------------------
docker top "$CTR" > "$OUT/top.txt" 2>&1
docker stats --no-stream "$CTR" > "$OUT/stats.txt" 2>&1
PID=$(docker inspect --format '{{.State.Pid}}' "$CTR" 2>/dev/null || true)
PID=${PID:-0}
if [ "$PID" -gt 0 ]; then
nsenter -t "$PID" -n ss -tanp > "$OUT/sockets.txt" 2>&1
cat "/proc/$PID/status" > "$OUT/proc-status.txt" 2>&1
cat "/proc/$PID/limits" > "$OUT/proc-limits.txt" 2>&1
fi
# --- Container state and history -------------------------------------
docker inspect "$CTR" > "$OUT/inspect.json" 2>&1
docker logs --timestamps "$CTR" > "$OUT/logs.txt" 2>&1
docker diff "$CTR" > "$OUT/diff.txt" 2>&1
docker events --since 2h --until "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--filter "container=$CTR" > "$OUT/events.txt" 2>&1
# --- Host context ----------------------------------------------------
dmesg -T | tail -200 > "$OUT/dmesg.txt" 2>&1
journalctl -u docker --since '2 hours ago' --no-pager \
> "$OUT/dockerd.txt" 2>&1
df -h > "$OUT/df.txt" 2>&1
free -m > "$OUT/free.txt" 2>&1
docker network ls -q | xargs -r docker network inspect \
> "$OUT/networks.json" 2>&1
echo "captured to $OUT"Put that script on every host now, not during the incident. The version you write while a service is down will be worse than this one and will take twenty minutes you did not have.
Read the four fields that decide the shape of the incident
docker inspect proj-api-1 --format \
'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} err={{.State.Error}} finished={{.State.FinishedAt}} restarts={{.RestartCount}}'$ docker inspect proj-api-1 --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}'exit=137 oom=trueIllustrative output
Exit codes carry meaning worth memorising:
| Code | Meaning |
|---|---|
| 0 | the process exited normally β often a configuration bug, not a crash |
| 1 | generic application error; read the logs |
| 125 | the docker run invocation itself failed |
| 126 | the command exists but could not be invoked, often a permission or non-executable file |
| 127 | the command was not found in the image |
| 137 | 128 + 9, killed by SIGKILL β check OOMKilled and dmesg |
| 143 | 128 + 15, terminated by SIGTERM β usually somebody or something stopped it deliberately |
exit=137 with oom=false and no dmesg OOM line usually means the
grace period expired during a docker stop and the daemon escalated
to SIGKILL β a shutdown bug, not a memory bug. The two look
identical in docker ps and have completely different fixes.
Preserving the filesystem
docker diff lists every path that differs from the image β files
written, changed and deleted since the container started. It is
quick, small, and often enough on its own: a config file that should
not have changed, a lock file that should not exist, a log directory
that filled the layer.
For anything more, snapshot the whole filesystem into an image:
# Freeze the container while the snapshot is taken (the default),
# so the filesystem is internally consistent.
docker commit proj-api-1 forensics/proj-api:incident-4471
# Then investigate at leisure, without touching the original.
docker run --rm -it --network none --entrypoint sh \
forensics/proj-api:incident-4471Two limits to state plainly. docker commit captures the containerβs
writable layer, not its memory and not its volumes β a heap dump has
to be taken separately, and anything under a volume mount is
excluded. And it pauses the container while it works unless you pass
--no-pause, which is usually what you want for consistency but is
a brief additional service impact you should expect.
For a single artefact, docker cp is faster and does not pause
anything:
docker cp proj-api-1:/app/logs/error.log /var/tmp/incident-4471/
docker cp proj-api-1:/tmp/heapdump.hprof /var/tmp/incident-4471/When it is a security incident
Ordinary evidence capture and forensic evidence capture differ in what happens to the artefacts afterwards.
- Do not restart, and do not remove. A running compromised container is a source of evidence; a restarted one is not.
- Isolate rather than stop, so the process state survives β the containment lesson covers docker network disconnect and docker pause.
- Hash every artefact as you collect it and record the hashes separately:
sha256sumover the capture directory, written somewhere the host cannot alter. - Copy the capture off the host immediately. A compromised host is not a place to store evidence about its own compromise.
- Record who captured what, when, in UTC, and with which command. Reconstructed timelines are worth much less than contemporaneous ones.
- Escalate before cleaning. The decision to rebuild the host belongs to whoever owns the security response, not to the engineer who found it.
Sanity check
Knowledge check Β· 4 questions
Q1. Which single field, lost the moment you run docker restart, most often identifies the cause of an unexplained container death?
Q2. A container is stuck and you need to preserve its filesystem for later analysis without removing it. Which command does that?
Q3. Which of these destroy the output of docker logs for a container using the json-file driver? Select all that apply.
Q4. An exit code of 137 with OOMKilled false can indicate that a docker stop grace period expired and the daemon escalated to SIGKILL.
Passing score: 75%. Answers are checked in this browser.