Virtualization · Containers

Standard I/O, Terminal Allocation and Exit Status Propagation

How a runtime wires stdin, stdout and stderr for a container process, when a pseudo-terminal is allocated, and how the exit status travels back to the caller.

A process started in a container is started by a process outside it. Between the two there is a runtime, a fork, an execve across a namespace boundary, and a channel that carries the exit status back. The wiring of file descriptors across that boundary is what determines whether output appears line by line, whether Ctrl-C reaches the workload, and whether a failed command reports failure to whatever invoked it.

Three file descriptors, chosen by the caller

The OCI runtime specification describes the process’s stdio as three optional paths in the bundle’s config: stdin, stdout, and stderr. They are not necessarily the caller’s own descriptors. The runtime opens each path and installs the resulting descriptor as 0, 1, or 2 in the child before execve. A path of /dev/null discards, a path to a pipe connects to the caller, and a path to a file appends to it.

The indirection exists so that the runtime can be a separate process from the caller. A container started by an orchestrator is started by an agent, which itself has no terminal; the agent creates pipes, passes their write ends as paths in the bundle, and reads from the other ends. The bundle itself is described in From Image Manifest to Mounted Root Filesystem. The workload sees ordinary file descriptors and cannot tell whether the other end is a terminal, a socket, or a log file.

This matters because a program can query the descriptor. isatty(1) is true only if descriptor 1 refers to a terminal. Many programs change behaviour on that answer: colour output, progress bars, line buffering instead of block buffering, and interactive prompts. A container started with its output redirected to a log file gets block-buffered output, so a program that prints a message and then hangs shows nothing in the log until 4 KB accumulate or the process exits. The fix is not a container setting but a program one: flush explicitly, or run with a terminal.

Pipes have a second observable property. The default capacity of a pipe on Linux is 65,536 bytes, and a writer blocks when it is full. If the reader is a logging agent that has stalled, a chatty workload blocks on write to stdout. The container appears hung, and the cause is downstream of the process. Runtimes that attach a buffer to absorb bursts trade memory for the same reason.

When a pseudo-terminal is allocated

Asking for a terminal changes more than isatty. A pseudo-terminal pair consists of a master, held by the runtime, and a slave, installed as the process’s controlling terminal. The slave is a terminal device, so the line discipline applies: input is buffered until newline, Ctrl-C generates SIGINT rather than delivering a byte, Ctrl-Z generates SIGTSTP, and the terminal’s window size is queryable with TIOCGWINSZ.

Several consequences follow, and each one has caught an implementation.

Output is transformed. A terminal device translates \n to \r\n on output unless the terminal is in raw mode. Logs collected from a terminal-attached container therefore contain carriage returns that were never written by the program, which is why the same program’s output differs between attached and detached runs.

Echo is on by default. The line discipline echoes input back to the reader, so anything typed appears twice if both the terminal and the application print it.

EOF is a control character, not a close. With a terminal, Ctrl-D at the start of a line signals end of file to the reader; closing the master is a different event and delivers SIGHUP to the session leader.

The controlling terminal is a namespace-independent object. It belongs to a session, and the session is not namespaced. A container with a terminal has a session leader that is the process itself, and the terminal’s hangup behaviour on master close follows ordinary terminal rules.

For a service, a terminal is almost always wrong: it changes buffering, injects signal generation, and makes output non-deterministic. For an interactive shell it is required, because the shell and the programs it runs expect job control, which needs a controlling terminal. The choice is per container, made at creation time, and it cannot be changed by the workload.

Exit status across a namespace boundary

The child process’s exit status is not directly visible to the caller, because the caller is not its parent in the ordinary sense if the runtime inserted an intermediate process. The runtime waits for the child, reaps it, and then reports the status.

The encoding is the one wait uses: a status word whose low 7 bits are the terminating signal and whose next 8 bits are the exit code when the process exited normally. An exit code of 137 means the process was killed by signal 9, and 143 means signal 15. These are not chosen by the runtime; they are the shell convention of adding 128 to the signal number, which the runtime adopts so that a container killed by the OOM killer reports 137.

A process that exits normally with code 0 reports success; any other code reports failure. A process killed by a signal reports the signal, and there is no exit code. Runtimes that collapse this into a single integer lose the distinction between “the program returned 137” and “the program was killed”, which matters for retry logic: the first is a bug, the second may be a resource limit.

There is a subtler case. If the container’s PID 1 exits while other processes are still running, the kernel kills the rest with SIGKILL. Their statuses are never collected by anyone, since their parent is gone and the namespace is being destroyed. The runtime reports only PID 1’s status, so a workload whose init exits early while a worker is mid-write reports success and leaves a truncated file. This is why the process that should be PID 1 is the one whose lifetime bounds the workload’s usefulness, and why a shell wrapper that starts a daemon in the background and exits immediately is a mistake rather than a convenience. Which signals reach that process at all is decided by the PID namespace rules described in How Seven Namespaces Compose Into a Container.

Detach, attach, and the descriptor that outlives the process

A detached container has no caller holding its output. The runtime still needs somewhere for stdout to go, so it typically opens the log file or a pipe to a logging process and passes that. The container survives the runtime exiting because the process’s parent becomes the init of its PID namespace, not the runtime.

Attaching later means connecting to the existing streams. With a pipe, that requires the writing end to still be open somewhere and a reader to appear; a runtime that closed its copy of the read end after starting cannot reattach without a broker that holds both ends. This is the mechanism behind log drivers: a separate process holds the read ends and either writes to a file or forwards over a socket, so that attaching a client is a matter of subscribing to that process rather than reopening a pipe.

Closing the write end of stdout is a normal event that many programs mishandle. Writing to a closed pipe raises SIGPIPE, whose default action terminates the process. A program that logs after its consumer has gone dies with signal 13 and reports 141. Whether that is a fault depends on the program: yes | head relies on it, a server writing to a disconnected log collector should not.

Terminal-free supervision

The composition that avoids most of these problems is: no terminal, stdout and stderr to a pipe held by a supervisor, stdin from /dev/null, and a process that is PID 1 in its namespace with an explicit signal handler for SIGTERM. The supervisor reads until EOF, then reaps and reports the status. Output is block-buffered and therefore needs explicit flushing, which is why programs that log to a service usually write whole lines through an unbuffered or line-buffered path.

Interactive use is the mirror image: a terminal, the caller’s descriptors, and the runtime attached for the process’s lifetime. The two modes are not variants of one another; they differ in buffering, signal generation, and EOF semantics, and a program that works in one may behave visibly differently in the other.

The failure that connects both is the missing reader. Whatever holds the other end of stdout determines whether the process can make progress, whether it sees SIGPIPE, and whether its output survives. That end is part of the container’s definition as much as the image is, and it is the part most often left unspecified.

References

  1. termios(3) — Linux man-pages project
  2. pty(7) — Linux man-pages project
  3. wait(2) — Linux man-pages project
  4. OCI Runtime Specification — Process — Open Container Initiative