A container is a process

What Docker actually does to your program, measured on Docker 29: PIDs, build cache, shutdown signals and the OOM killer.

Part 2 of 2 · Containers from zero5 min read
host every other process container: sees only itself PID 1 PID 33785 same process

New to containers? Start with part 1, “Containers from zero”.

Most of us learned containers as “a lightweight VM”. It’s a useful first picture, and it’s wrong in ways that cost real time: slow builds, slow deploys, and services that die without leaving a single log line.

So I stopped reading about it and measured. Four small experiments, one python:3.13-slim image, Docker Desktop 4.54 with Engine 29.1.2. Every number in this post comes from the scripts at the end, and you can run them yourself.

1. One process, two PIDs

I started a container that only sleeps, then asked two questions about the same process:

Run it yourself
c=$(docker run -d python:3.13-slim python -c "import time; time.sleep(600)")
docker inspect -f '{{.State.Pid}}' $c        # the host's view
docker exec $c cat /proc/1/cmdline           # the container's view
host PID:      33785
container PID 1 cmdline: python -c import time; time.sleep(600)

One process with two numbers. The host sees PID 33785 next to everything else it runs. Inside, the same process is PID 1 and sees nothing else.

There is no guest operating system per container. The kernel puts the process in a few namespaces, which limit what it can see (process IDs, network interfaces, the filesystem tree), and in a cgroup, which limits what it can use (CPU and memory). That’s the whole trick.

Two consequences follow from “it’s just a process”, and the next three sections are both of them.

2. Your Dockerfile order is your build time

An image is a stack of layers, one per instruction, and each layer is cached. The rule that matters is: when a layer changes, every layer after it is rebuilt.

Here are two Dockerfiles with the same four dependencies (FastAPI, uvicorn, SQLAlchemy, psycopg). The only difference is the order:

# A: code first
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
# B: dependencies first
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

I built both, changed one line of main.py, and rebuilt. Guess first:

PredictAfter a one-line change to main.py, how long does each rebuild take?

Run it yourself
./cache.sh
A  COPY . .        rebuilt
 pip install     rebuilt (100.4 s)
 total           104.9 s

B  COPY req        CACHED
 pip install     CACHED
 COPY . .        rebuilt
 total           0.6 s
A: code first FROM pythoncached WORKDIR /appcached COPY . .rebuilt pip installrebuilt104.9 s B: dependencies first FROM pythoncached COPY requirementscached pip installcached COPY . .rebuilt0.6 s
Same four dependencies, same change. Only the order differs.

In A, COPY . . includes main.py, so the change invalidates that layer and drags pip install down with it. In B, requirements.txt didn’t change, so the expensive layer stays cached and only the last cheap copy runs.

The pip time depends on your network: the whole rebuild took about 24 s on my first run. But the shape doesn’t change. Order layers from “changes rarely” to “changes every commit”.

3. docker stop is a request, not a kill

docker stop sends SIGTERM to PID 1 and waits for a grace period. If the process is still alive when the period ends, Docker sends SIGKILL, which cannot be caught.

I ran the same small Python loop five ways, each with docker stop -t 10. Guess the slow ones first:

PredictCMD ["python", "app.py"], exec form, no signal handler. How long does docker stop -t 10 take?

Run it yourself
./run.sh
setup                     what is PID 1                   stop     exit
script, no exec           sh /w/start.sh                  10.2 s   137
CMD shell form            sh -c python /w/app.py          10.2 s   137
exec form, no handler     python /w/app.py                10.2 s   137
exec form + handler       python /w/app.py                 0.1 s     0
--init (tini)             /sbin/docker-init -- python …    0.1 s   143
SIGTERMI have 10 seconds
SIGTERM is a letter. PID 1 has to choose to read it.

Three things in that table surprised me.

The shell form really does put sh in charge. CMD python app.py becomes sh -c "python app.py", and sh stayed PID 1 (I checked /proc/1/cmdline). It doesn’t pass SIGTERM on, so your app is never told to stop.

The exec form alone is not enough. CMD ["python", "app.py"] makes Python PID 1, so it receives SIGTERM, and it still waited the full 10 seconds. The kernel gives PID 1 no default signal handlers. For any other process, SIGTERM’s default action is “terminate”. For PID 1, an unhandled SIGTERM does nothing.

Exit 137 means “killed”, not “crashed”. 137 = 128 + 9, and 9 is SIGKILL. 143 = 128 + 15, a clean exit on SIGTERM.

There are two fixes, and you want at least one:

import signal, sys
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))  # finish work, then exit

or docker run --init (in Compose, init: true), which puts a tiny init process (tini) at PID 1. Tini forwards the signal to your app, and your app is no longer PID 1, so the default action applies again.

Why it matters: a process that is SIGKILLed never finishes what it was doing. Think of a transaction half-committed, a message half-processed, a request the client will retry. Every deploy and every scale-down sends this signal.

Check your grace period. Docker documents a 10-second default. My Docker Desktop created every container with StopTimeout=1, even through the raw API, so a plain docker stop killed after 1.2 s. Check yours:

docker inspect -f '{{.Config.StopTimeout}}' <container>

Kubernetes uses its own value, terminationGracePeriodSeconds, which defaults to 30 s.

4. Exit 137 has no stack trace

The memory limit is the cgroup from section 1. I gave a container 128 MiB and a loop that allocates 10 MB at a time and prints the total:

Run it yourself
docker run --memory 128m --memory-swap 128m python:3.13-slim python -c "
a = []
while True:
  a.append(bytearray(10_000_000)); print(len(a) * 10, 'MB', flush=True)"
last log lines:   100 MB / 110 MB / 120 MB
docker inspect:   ExitCode=137  OOMKilled=true
137 OOM killer
No traceback, no “out of memory”. The logs just stop.

The logs simply stop: no error, no traceback, no “out of memory”. When the cgroup goes over its limit, the kernel’s OOM killer sends SIGKILL, and a SIGKILLed process can’t write anything.

So when a service restarts with 137 and the logs look clean, don’t read the logs harder. Ask Docker:

docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' <container>

OOMKilled=true means memory. false with 137 means someone else sent SIGKILL. Often that’s the grace period from section 3 running out.

What to take away

  • A container is a process with a filtered view, so everything you know about processes still applies.
  • Order Dockerfile layers by how often they change.
  • Handle SIGTERM, or run with --init. The exec form alone doesn’t save you.
  • 137 means “killed”. docker inspect tells you by whom.

Run it yourself

The scripts are short. run.sh does the PID, stop and memory experiments. cache.sh does the build cache test with a change BuildKit has never seen. Each prints the raw output used above.

./run.sh      # sections 1, 3, 4
./cache.sh    # section 2

Download all the scripts as a zip, or read them first: run.sh, cache.sh, and the raw output.

Measured 2026-10-16 on Docker Desktop 4.54.0, Engine 29.1.2, image python:3.13-slim.

New posts by email

One email when a post ships, about twice a month. Nothing else, and one click to leave.

Latest: A container is a process · RSS