Docker & ContainersXVII · LoggingStdout contract
Stdout and stderr — the container logging contract
What you'll learn
- Explain the container logging contract and what the platform gives you in return
- Trace a log line from the application write to the on-disk JSON entry
- Recognise the three ways an application breaks the contract: files, daemonising, and buffering
- Redirect file-based logs to stdout, and know when the symlink trick fails
- Explain why `-t` changes what you see in `docker logs`
Prerequisites
None — start here.
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
Docker’s logging model is one sentence: applications write to stdout and
stderr; the logging driver ships the bytes onward. That is the whole
contract. It is the twelve-factor position, and every production logging
pipeline on Docker — docker logs, journald, Loki, CloudWatch, Splunk —
assumes it holds.
The interesting part of this lesson is not the contract. It is the three distinct ways applications break it, only one of which is obvious.
What the contract buys you
- Capture without configuration. The driver tags every line with the container, the stream it came from, and an RFC3339Nano timestamp. You configure that once, on the host, not once per application.
- Rotation as a platform concern. One
daemon.jsonstanza bounds every container on the host. Nologrotateconfig inside any image. - Survival past the container. Logs leave the container’s filesystem, so
they outlive
docker rm. This is the property you care about at 03:00, when the container that failed is already gone and its replacement is healthy. - One shipping path. Change the destination for the whole fleet by changing the driver, without rebuilding a single image.
How a log line actually gets out
Break #1: logging to a file inside the container
The obvious violation. An application writes /var/log/app.log inside its own
filesystem.
That file lives on the container’s OverlayFS upper layer, which means:
- It is deleted with the container.
docker rmdestroys the evidence. - It grows against the same disk as everything else on the host, with no
driver-level rotation and no
docker system dfrow that counts it. - No logging driver can see it. Shipping it centrally requires a sidecar that shares the filesystem, or an application that ships its own logs.
docker logsreturns nothing, so the operator’s first instinct — “the container is producing no output, it must be hung” — points at the wrong failure.
The fix is to configure the application to write to stdout. Nearly every mainstream server can be told to:
| Software | Setting |
|---|---|
| nginx | access_log /dev/stdout; and error_log /dev/stderr; |
| Apache httpd | ErrorLog /proc/self/fd/2, CustomLog /proc/self/fd/1 common |
| PostgreSQL | logging_collector = off (the default) sends to stderr |
| Java / Logback | a ConsoleAppender instead of a FileAppender |
| Python | logging.StreamHandler() instead of FileHandler |
When the application genuinely cannot be configured — a vendor binary with a hardcoded path — the symlink trick is the fallback:
RUN mkdir -p /var/log/app \
&& ln -sf /dev/stdout /var/log/app/access.log \
&& ln -sf /dev/stderr /var/log/app/error.logBreak #2: the process daemonises
An application that forks into the background and detaches from its controlling terminal closes fds 0, 1 and 2 as part of daemonising. Under Docker this produces two symptoms at once: no logs, and the container exits immediately, because PID 1 returned.
The stock fixes are foreground flags, and every mainstream daemon has one:
nginx -g 'daemon off;'
httpd -DFOREGROUND
rsyslogd -n
sshd -D
php-fpm -F
The container’s entrypoint should be the application process itself, in exec form, so that the application is PID 1 and its stdout is the container’s stdout:
ENTRYPOINT ["nginx", "-g", "daemon off;"]
Shell form — ENTRYPOINT nginx -g 'daemon off;' — wraps the command in
/bin/sh -c, which stays PID 1 and makes the application a child. Output
still flows, because the child inherits fd 1, but signal handling breaks: the
shell does not forward SIGTERM, so docker stop waits out its full timeout
and then kills the container, and the application never gets to flush its
final log lines or close its connections cleanly. If you must use a wrapper
script, end it with exec so the application replaces the shell:
#!/bin/sh
set -eu
# ... setup that must happen before the app starts ...
exec /usr/local/bin/myapp --config /etc/myapp.toml
Break #3: buffering — the container that looks silent
This is the one that is worth the price of the lesson.
The C standard library chooses a buffering mode for stdout based on what stdout is connected to. Connected to a terminal, it is line-buffered: every newline triggers a flush. Connected to anything else — a file, a socket, or a pipe — it switches to fully buffered, typically in 4 KB or 8 KB blocks.
Under Docker, without -t, stdout is a pipe. So a program that prints
perfectly on your laptop prints nothing in docker logs until it has
accumulated a full block, or until it exits and the runtime flushes on
teardown.
$ docker run --rm python:3.12-slim python -c "import time
print('starting')
time.sleep(30)"(no output for 30 seconds, then:)
startingIllustrative output
The container is healthy. The application ran. The line was printed on time.
It sat in a userspace buffer inside the container for thirty seconds, and an
operator watching docker logs -f during an incident concluded the process
was hung.
The fixes, in order of preference:
| Fix | Where | Notes |
|---|---|---|
ENV PYTHONUNBUFFERED=1 | Dockerfile | Correct fix for Python. Set it in every Python image. |
python -u | command | Same effect, per invocation. |
stdbuf -oL -eL <cmd> | entrypoint | Works for any dynamically linked C program. |
setvbuf(stdout, NULL, _IOLBF, 0) | source | The real fix if you own the code. |
docker run -t | runtime | Works, and brings problems of its own — see below. |
Go and Rust are not affected: neither uses libc stdio for their standard
logging paths. Java’s System.out is line-flushed by default via a
PrintStream with autoflush, but a BufferedWriter wrapped around it by the
application is not. Node’s process.stdout is synchronous on pipes on
Linux, so it is safe but can block the event loop under heavy logging — a
different problem with the same root cause.
Verifying the contract holds
Three checks, each of which can fail:
CONTAINER=web
# 1. Is anything arriving at all, and how recently?
docker logs --timestamps --tail 5 "$CONTAINER"
# 2. Which stream is it on? docker logs writes container stdout to its own
# stdout and container stderr to its own stderr, so you can separate them.
echo '--- stdout only ---'
docker logs --tail 20 "$CONTAINER" 2>/dev/null
echo '--- stderr only ---'
docker logs --tail 20 "$CONTAINER" 2>&1 1>/dev/null
# 3. Is the application still writing to a file inside the container?
docker exec "$CONTAINER" sh -c 'find / -xdev -name "*.log" -size +1M 2>/dev/null' \
|| echo 'no shell in image; check with docker diff instead'
# 4. Whatever the image does, docker diff shows files it has created.
docker diff "$CONTAINER" | grep -E '^A .*\.log$' || echo 'no new .log files: contract holds'Check 4 is the one that distinguishes working from broken without any
assumption about the image. docker diff reports the container’s OverlayFS
upper layer — every file added (A), changed (C) or deleted (D) since the
container started. A growing .log file in that list is a contract violation,
stated by the platform rather than inferred.
Knowledge check
Knowledge check · 5 questions
Q1. A Python container prints a line every second, but `docker logs -f` shows nothing for minutes at a time and then dumps a block of lines. The most likely cause is:
Q2. You symlink an application log path to /dev/stdout in the Dockerfile. Weeks later the container goes silent although it is serving traffic normally. What most likely happened?
Q3. Which of these are consequences of running a container with `-t` in production? Select all that apply.
Q4. Without a TTY, stdout and stderr travel through separate pipes, so ordering between the two streams is not guaranteed.
Q5. An application that writes only to /var/log/app.log inside the container loses those logs when the container is removed.
Passing score: 75%. Answers are checked in this browser.