Skip to main content
RunBook Academy

Docker & ContainersXVII Β· LoggingFailure lab

Failure lab β€” unbounded logs fill the disk

Intermediate⏱ ~35 minπŸ§ͺ Lab requireddockersudo

What you'll learn

  • Reproduce unbounded log growth in a controlled, bounded way
  • Measure the growth rate and calculate time-to-full
  • Demonstrate that `rm` on an open log file frees no space and `truncate` does
  • Prove that a daemon-level rotation change leaves the existing container uncapped
  • Verify the fix with an assertion that fails when the fix is absent

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-12

Not yet marked complete on this device.

The scenario is real and it is the one you will actually meet: somebody deploys a service with the log level left at DEBUG, including request bodies. Twenty-four hours later /var/lib/docker is at 100% and every workload on the host is failing for reasons that look unrelated to logging.

This lab reproduces it deliberately. Every stage ends in a check whose output tells you whether the stage worked, rather than whether the command ran.

Stage 0 β€” establish the baseline

You cannot detect growth without a starting point. Record one.

Read-only / Safebaseline
df -h /var/lib/docker | tee /var/tmp/loglab-baseline.txt

sudo du -sh /var/lib/docker/containers 2>/dev/null | tee -a /var/tmp/loglab-baseline.txt

docker info --format 'driver={{.LoggingDriver}}'

If docker info already reports something other than json-file, or if you have log-opts in daemon.json, move them aside first β€” otherwise Stage 1 will not reproduce the fault, and a lab that cannot fail teaches nothing.

Stage 1 β€” reproduce

Start a container that logs continuously with no rotation configured. Note that no --log-opt is given: this is the stock configuration, and that is the finding.

Service impact possiblethe log bomb
docker run -d --name logbomb --log-driver json-file alpine:3.20 sh -c '
i=0
while :; do
i=$((i+1))
echo "$(date -Iseconds) level=debug seq=$i msg=request_completed path=/api/v1/orders status=200 duration_ms=42 body=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
done'

docker ps --filter name=logbomb --format 'table {{.Names}}\t{{.Status}}'

Stage 2 β€” measure, do not guess

The instinct at this point is to watch df -h until something happens. That wastes the most useful thirty seconds of the incident. Measure the rate instead, and turn it into a time-to-full figure you can act on.

Read-only / Safegrowth rate
CONTAINER=logbomb
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"

A=$(sudo stat -c %s "$LOGFILE")
sleep 30
B=$(sudo stat -c %s "$LOGFILE")

RATE=$(( (B - A) / 30 ))
echo "growth: $RATE bytes/sec  ($(( RATE * 86400 / 1024 / 1024 )) MB/day)"

FREE=$(df --output=avail -B1 /var/lib/docker | tail -1)
echo "free: $((FREE / 1024 / 1024)) MB"
echo "time to full: $(( FREE / (RATE > 0 ? RATE : 1) / 3600 )) hours"
Read-only / Safeexpected shape
$ bash /var/tmp/loglab-rate.sh
growth: 412719 bytes/sec  (33966 MB/day)
free: 38402 MB
time to full: 25 hours

Illustrative output

That single number β€” hours to full β€” is what turns a vague β€œthe disk is filling” into a decision about whether you have time to find the root cause or must reclaim space right now.

Read-only / Safefind the real consumer
sudo du -sh /var/lib/docker/containers/* | sort -h | tail -5

Stage 3 β€” confirm the diagnosis before acting

Two questions, two commands. Both answers must be consistent before you touch anything.

Read-only / Safeconfirm
CONTAINER=logbomb

# Q1: what log configuration is this container actually running with?
docker inspect --format \
'{{.Name}} driver={{.HostConfig.LogConfig.Type}} opts={{.HostConfig.LogConfig.Config}}' \
"$CONTAINER"

# Q2: does the file size match the consumption you measured?
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
sudo ls -lh /var/lib/docker/containers/"$CID"/"$CID"-json.log*

An opts=map[] in the first answer is the diagnosis: the container has no rotation whatsoever. If the map is populated and the file is still enormous, you have a different problem β€” a single log line larger than max-size, or a different consumer entirely β€” and the remedy below is the wrong one.

Stage 4 β€” the recovery that does not work, and the one that does

This stage exists because the intuitive command is the wrong one, and the reason is not obvious from anything Docker prints.

Destructivedemonstrate why rm fails
CONTAINER=logbomb
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"

BEFORE=$(df --output=avail -B1 /var/lib/docker | tail -1)
sudo rm -f "$LOGFILE"
sleep 5
AFTER=$(df --output=avail -B1 /var/lib/docker | tail -1)

echo "freed by rm: $(( (AFTER - BEFORE) / 1024 / 1024 )) MB"

# The inode is gone from the directory but not from the filesystem.
DOCKERD=$(pgrep -x dockerd | head -1)
sudo ls -l /proc/"$DOCKERD"/fd 2>/dev/null | grep -F 'json.log' | grep -F '(deleted)'
Read-only / Safewhat rm actually did
$ bash /var/tmp/loglab-rm.sh
freed by rm: 0 MB
l-wx------ 1 root root 64 Aug 12 09:41 27 -> /var/lib/docker/containers/9c7e.../9c7e...-json.log (deleted)

Illustrative output

Zero megabytes freed, and the file descriptor is still open on a deleted inode. The container keeps logging into it, the space keeps growing, and you can no longer measure it with du on the path because the path no longer exists. You have made the incident harder to diagnose without making it smaller.

Now do it correctly. Note the evidence capture first β€” the log is the only record of whatever caused the incident, and this step destroys it.

Destructivereclaim correctly
CONTAINER=logbomb

# The rm above removed the path, so recreate the container to restore a sane
# state before demonstrating the correct recovery.
docker rm -f "$CONTAINER" > /dev/null
docker run -d --name "$CONTAINER" --log-driver json-file alpine:3.20 sh -c '
while :; do echo "$(date -Iseconds) level=debug msg=filler"; done'
sleep 20

CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"

sudo tail -c 2M "$LOGFILE" > /var/tmp/loglab-evidence.json

BEFORE=$(df --output=avail -B1 /var/lib/docker | tail -1)
sudo truncate -s 0 "$LOGFILE"
AFTER=$(df --output=avail -B1 /var/lib/docker | tail -1)

echo "freed by truncate: $(( (AFTER - BEFORE) / 1024 / 1024 )) MB"
docker logs --tail 2 "$CONTAINER"

The last line is the assertion that matters: docker logs still returns output after the truncation, which proves the daemon’s descriptor survived and the container is logging normally.

Stage 5 β€” fix it, and verify the fix

  1. Set rotation at the daemon level. Add log-opts with max-size and max-file to /etc/docker/daemon.json. Every value must be a quoted string.
  2. Validate the JSON before it reaches the daemon. A malformed daemon.json means the daemon will not start, on a host full of running containers.
  3. Reload rather than restart. A reload re-reads the configuration without disturbing running containers.
  4. Observe that the running container is still uncapped. This is the step everyone skips, and it is why the incident recurs.
  5. Recreate the workload. Remove and run again, or use compose with force-recreate. A restart is not sufficient because it reuses the same container object.
  6. Run the audit and confirm every container reports a populated options map.
  7. Fix the application. Rotation bounds the damage; it does not stop a service logging request bodies at DEBUG in production.
Configuration changeapply rotation
sudo tee /etc/docker/daemon.json.new > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
  "max-size": "10m",
  "max-file": "3"
}
}
EOF

sudo python3 -m json.tool /etc/docker/daemon.json.new > /dev/null \
&& sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json \
&& sudo systemctl reload docker

Now the step that proves the point. The container is still running, the daemon has reloaded, and nothing about this container changed:

Read-only / Safethe half-fix, exposed
$ docker inspect --format '{{.HostConfig.LogConfig.Config}}' logbomb
map[]

Illustrative output

Recreate, then assert. This script exits non-zero when the fix has not landed, which is what makes it a verification rather than a demonstration:

Read-only / Safeverification that can fail
docker rm -f logbomb > /dev/null
docker run -d --name logbomb alpine:3.20 sh -c 'while :; do echo filler; done' > /dev/null

FAIL=0
for C in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$C")
OPTS=$(docker inspect --format '{{.HostConfig.LogConfig.Config}}' "$C")
DRIVER=$(docker inspect --format '{{.HostConfig.LogConfig.Type}}' "$C")
case "$DRIVER" in
  journald|local|none) continue ;;
esac
case "$OPTS" in
  *max-size*) echo "ok   $NAME $OPTS" ;;
  *)          echo "FAIL $NAME has no max-size"; FAIL=1 ;;
esac
done
exit "$FAIL"

local and journald are excluded deliberately: local is bounded by its own defaults and journald is bounded by journald.conf, so demanding a max-size on them would produce a false failure. A check that cries wolf gets turned off, and then it protects nothing.

Confirm the ceiling actually holds by letting the container run past it:

Read-only / Safeprove the ceiling
CID=$(docker inspect --format '{{.Id}}' logbomb)
sleep 300
TOTAL=$(sudo du -cb /var/lib/docker/containers/"$CID"/"$CID"-json.log* | tail -1 | cut -f1)
echo "total log bytes: $TOTAL (ceiling: $((10 * 1024 * 1024 * 3)))"
[ "$TOTAL" -le $((10 * 1024 * 1024 * 3 + 1048576)) ] \
&& echo 'PASS: rotation is bounding the log' \
|| echo 'FAIL: log exceeded the configured ceiling'

Stage 6 β€” tear down

Destructiveteardown
docker rm -f logbomb 2>/dev/null || true
rm -f /var/tmp/loglab-evidence.json /var/tmp/loglab-baseline.txt

df -h /var/lib/docker

The rm-deleted inode from Stage 4 was released when that container was removed. Confirm with df that free space matches your Stage 0 baseline; if it does not, some descriptor is still open and a daemon restart will release it.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. A running container has a 5 GB json-file log and the disk is critical. What is the fastest safe way to reclaim the space?

  2. Q2. You run `rm` on a running container log file and `df` reports no change. Why?

  3. Q3. Which command finds the real disk consumer when container logs are the cause?

  4. Q4. You add max-size and max-file to daemon.json and reload. Which statements are true? Select all that apply.

  5. Q5. `docker logs --tail 0 -f CONTAINER` reduces the disk space the container log consumes.

Passing score: 75%. Answers are checked in this browser.