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:
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 viewhost 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?
./cache.shA 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
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.shsetup 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
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:
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
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 inspecttells 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.