skip to content

A feature-flag service's Python print() startup logs never reach the log collector when its container is killed 45 seconds into a cold start — what is happening, and what do you change?

level: seniorimportance: should knowfreq 40%

answer

  1. The environment changed, the code did not
  2. What is stdout attached to in there?
  3. Why did the traceback survive but not the prints?
  4. One environment variable, set in the image

basics

~20 s

Inside a container, stdout is a pipe rather than a terminal, so CPython block-buffers it and the startup lines sit in process memory. A hard kill discards them. Set PYTHONUNBUFFERED=1 in the image, or run the interpreter with -u.

solid answer

~40 s

The service's stdout is a pipe to the runtime's log collector, not a terminal, so CPython chose block buffering at startup and the startup lines never left the process. Two symptoms follow from one cause: when the platform hard-kills the container at the 45-second deadline the buffer is discarded outright, and when the service instead exits cleanly — an import failure raising at startup, say — the traceback goes out line by line on `sys.stderr` (line-buffered since 3.9) while the stdout breadcrumbs are flushed in one blob during interpreter shutdown, so the collector timestamps them *after* the failure they preceded. The fix is a stream-level setting, not `flush=True` at every call site: `PYTHONUNBUFFERED=1` in the image environment, or `python -u`. Both cover stdout and stderr and neither touches stdin.

code

console · 2 lines
console
python3 -c "import sys; print(sys.stdout.write_through, sys.stderr.line_buffering)" | cat
PYTHONUNBUFFERED=1 python3 -c "import sys; print(sys.stdout.write_through, sys.stderr.line_buffering)" | cat

go deeper

for a junior

Recall that output can be buffered and that setting PYTHONUNBUFFERED=1 makes a service's print() lines show up promptly when its output is collected rather than shown in a terminal.

for a middle

Explain why the container changes anything: stdout is a pipe, so CPython block-buffers it at startup, while stderr stays line-buffered. Name the -u and PYTHONUNBUFFERED equivalents and what each covers.

for a senior

Diagnose from the evidence. Point at the stderr-arrives-stdout-does-not asymmetry, distinguish the hard-kill case from the misordered clean-exit case, and choose a stream-level fix over per-call flushes, with its syscall cost stated.

for a principal

Own it as a platform default rather than one team's bug fix: unbuffered or line-buffered standard streams baked into base images, a convention for which stream diagnostics use, and startup deadlines set from evidence instead of guesswork.

## Why a container changes the behaviour at all Nothing about the code changed between the developer's laptop and the platform — what changed is what file descriptor 1 is attached to. Locally it is a terminal, so CPython gives `sys.stdout` line buffering at interpreter startup and every `print()` is visible instantly. Under a container runtime, stdout is a pipe read by the log collector. That is not a terminal, so the same interpreter chooses **block buffering**, and output accumulates in the process's own memory until the block fills, something flushes it, or the interpreter finalizes. A cold start emits very little text — a handful of breadcrumbs about configuration, connections and cache warm-up. That is nowhere near the block size, so *none* of it leaves the process during those 45 seconds. ## Two failure shapes, one cause **Hard kill.** When the platform's startup deadline expires it kills the container. Interpreter finalization never runs, so the buffered text is never handed to the kernel and is simply gone. From the collector's point of view the service produced no logs at all before dying, which is the most misleading possible signal: it makes the process look hung before it printed anything, when in fact it printed plenty. **Clean exit.** Suppose the real defect is a circular import that raises at startup. Now the interpreter *does* finalize: it prints the traceback to `sys.stderr`, which is line-buffered even when redirected (Python 3.9 changed this; before, redirected stderr was fully buffered), so those lines arrive immediately. The stdout breadcrumbs are flushed later, during shutdown, in a single blob. A collector that merges both streams by arrival time now shows the failure first and the steps leading up to it afterwards. Engineers read that ordering literally and chase the wrong subsystem. That asymmetry is also the diagnostic tell: *stderr arrives, stdout does not* means buffering, not a broken logging path. ## The fix, and why it belongs in the environment `PYTHONUNBUFFERED=1` set in the image's environment forces both standard streams unbuffered for every process the image starts. Preferring the environment variable over `-u` on the command line matters in practice, because entrypoints are frequently a shell wrapper, a supervisor or a process manager that re-invokes the interpreter; a variable is inherited through all of that, while a flag lives on exactly one command line. `-u` is the equivalent when you do own the invocation. Neither has any effect on stdin. Since Python 3.7 the unbuffered setting reaches the text layer too, so `sys.stdout.write_through` becomes `True` and `line_buffering` reads `False` — every write goes straight down. ## The alternatives, and their limits * **`sys.stdout.reconfigure(line_buffering=True)`** as the first statement of the entrypoint. Cheaper than fully unbuffered — one syscall per line rather than per write — but it runs *after* the imports at the top of that module, so anything printed by an import that fails is still governed by the original policy. For a startup bug specifically, that is the wrong side of the problem. * **`print(..., flush=True)` everywhere.** It works and it does not scale: it is a per-call decision spread across the codebase, and one forgotten call is one lost breadcrumb, in the exact code path you were trying to observe. * **Route diagnostics to `sys.stderr`.** Reasonable in its own right — stderr's line buffering makes it the low-latency stream — but it does not fix stdout for whatever still writes there. ## What unbuffering costs One `write` syscall per write call. For startup breadcrumbs and ordinary service logging that is invisible. For a process emitting a high-rate stream of short lines it is measurable, and line buffering is then the better setting: it bounds latency to one line while still coalescing partial writes. Bulk data being piped onward should keep the block buffer entirely. ## Closing the loop on the incident Once the output survives, the 45-second cold start becomes debuggable: the breadcrumbs show where the time goes and whether the deadline is too tight or the startup work is too slow. Worth checking at the same time: whether anything in the startup path calls `os._exit()` or installs a signal handler that exits without letting finalization run — both skip the flush even when you have not been hard-killed — and whether a native extension writes to the descriptor directly, in which case its output is ordered by its own buffering, not Python's.

  • The traceback did reach the collector. Why did that output survive when the print() lines did not?
    It went to `sys.stderr`, which has been line-buffered even when redirected since Python 3.9, so each line is handed to the kernel as it is written. `sys.stdout` was block-buffered, so its lines were still in process memory. If the process is hard-killed they are discarded; if it exits cleanly they are flushed late, during finalization, and land after the traceback in the merged stream.
  • Why prefer PYTHONUNBUFFERED=1 in the image over adding -u to the command?
    Because the variable is inherited by every process started in the container, including whatever an entrypoint shell script, supervisor or process manager re-execs. A flag applies to exactly one command line and quietly stops applying the moment someone wraps the entrypoint. They have the same effect on the interpreter; the environment variable is simply harder to lose.
  • When would you choose line buffering over fully unbuffered for a service?
    When the process emits a high volume of short writes. Unbuffered means at least one syscall per write call, including partial writes within a line; line buffering bounds latency to a single line while still coalescing those partials. For startup breadcrumbs the difference is irrelevant, so unbuffered is the simpler default; for a chatty hot path, `sys.stdout.reconfigure(line_buffering=True)` is the better trade.

The process was writing postcards and dropping them in an outbox that only gets emptied when it is full. Killing the process burns the outbox; nobody ever sees what was in it.

saying these in an interview costs you the question

  • Blames the log collector rather than stream buffering
  • Adds flush=True at every call site as the fix
  • Assumes stdout is a terminal inside a container
  • Thinks an in-process reconfigure catches import-time output
  • Believes unbuffered output is free of syscall cost
  • Claims PYTHONUNBUFFERED also affects stdin

context