5. Docker
Docker did not invent any of the parts in the last three chapters. What it did was put them behind one simple command, and add a way to ship software that the whole industry now uses.
The problem Docker solved
Before 2013, putting a program on a server meant installing its language, its libraries and its settings on that server, and hoping they matched the developer’s machine. Docker’s answer was to ship the program with its whole file system, as one thing you can copy, and to run it in namespaces so it did not see or break the rest of the server. “It works on my machine” became “it works in this image”. The isolation was a side benefit; the packaging was the revolution.
Images and layers
An image is a file system plus a little metadata: which command to run,
which user, which environment. It is stored as a stack of layers, each a
tar file of changes on top of the one below. Two images based on the same
python:3.12 share those lower layers on disk and on the network. A layer is
named by the hash of its content, so it can never change without getting a
new name. That is why an image pulled today by digest is the same, byte for
byte, as the one pulled next year.
myapp:1.0 otherapp:2.3
┌─────────────────┐ ┌─────────────────┐
│ COPY . /app │ │ COPY . /app │ each layer is named by
├─────────────────┤ ├─────────────────┤ the hash of its content:
│ pip install … │ │ pip install … │ sha256:9f…, sha256:77…
└────────┬────────┘ └────────┬────────┘
└────────────┬─────────────┘
┌────────▼────────┐
│ python:3.12 │ shared: stored and
├─────────────────┤ downloaded once
│ debian:bookworm │
└─────────────────┘
Why a sandbox brings its own files
A common first question: Python is already installed on my machine, so why
does a container need an image with another Python in it? The answer is that
a program is never one file. /usr/bin/python3 needs libpython, the C
library, about a thousand files of standard library, SSL certificates, time
zone data and more, spread over /usr, /lib and /etc. To run the host’s
Python inside a sandbox, you would have to show the sandbox all of those
host folders — and hiding the host’s files is the sandbox’s first job. An
image solves this: it is a complete, separate set of files, so the sandbox
can see everything it needs and nothing of the host.
USING THE HOST'S PYTHON USING AN IMAGE
─────────────────────── ──────────────
┌ sandbox ────────────────────────┐ ┌ sandbox ────────────────────────┐
│ /usr/bin/python3 ◀── host │ │ /usr/bin/python3 ◀── image │
│ /usr/lib/python3.12 ◀── host │ │ /usr/lib/python3.12 ◀── image │
│ /lib (libc, libssl) ◀── host │ │ /lib (libc, libssl) ◀── image │
│ /etc (certs, config) ◀── host │ │ /etc (certs, config) ◀── image │
│ app.py │ │ app.py │
└─────────────────────────────────┘ └─────────────────────────────────┘
the sandbox can read big parts of the sandbox sees a complete world,
the host, and they change with apt and none of the host's files
An image is also a promise
There is a second reason, just as important. The Python on your laptop is
3.12, the one on the server is 3.10, and apt upgrade can change either one
tomorrow. A program that works on one machine may fail on the other, and
you cannot tell why. An image named by its digest is the same bytes on every
machine, today and next year. And an image costs almost nothing per run: it
is downloaded once, stored once, and shared read-only by every container that
uses it.
The Dockerfile
A Dockerfile is a recipe for building an image, one step per line:
FROM python:3.12, RUN pip install requests, COPY . /app. Each step runs
in a temporary container and its changes become a new layer. It is simple and
very widely known. The cost is that every change of a dependency means a new
build, a new image and a new push. Chapter 6
shows how Zygo avoids this.
OCI: the standards
The Open Container Initiative turned Docker’s formats into open standards.
The image spec says how an image and its layers are stored; the
distribution spec says how a registry serves them; the runtime spec says
how a folder plus a config.json becomes a running container. Because of
OCI, an image built by Docker runs under Podman, Kubernetes, or Zygo, and
comes from any registry: Docker Hub, GitHub, your own. Zygo reads OCI images
and uses none of Docker’s own code.
A container is a process
Run docker run -d nginx and then ps -ef on the host: nginx is there, as a
normal process with a normal pid. There is no box and no small machine. The
kernel sees a process with its own namespaces, in a cgroup, with a seccomp
filter and an AppArmor profile — exactly the list at the end of
chapter 4. “Container” is a word for
that process plus the metadata Docker keeps about it.
The chain behind docker run
docker itself does almost nothing. It sends your request over a socket to
dockerd, the daemon, which runs as root. dockerd asks containerd to
create the container. containerd starts a containerd-shim, which runs
runc. runc does the real work — namespaces, cgroup, mounts, filters — then
execs your program and exits. The shim stays, for as long as the container
lives, to hold its input, output and exit code.
docker ─▶ dockerd ─▶ containerd ─▶ containerd-shim ─▶ runc ─▶ your program
(CLI) (root) (root) (stays) (exits)
Five programs and three hand-overs for one command. Each exists for a good reason — upgrades without stopping containers, Kubernetes, many clients — and each costs time.
Where Docker’s time goes
docker run of a small Python script takes about 300 to 1000 milliseconds.
The isolation itself — the part runc sets up — is around one millisecond of
that. The rest is the chain above, creating the container’s record and its
writable layer, setting up the network bridge, and then starting Python and
its imports from nothing. docker exec into a running container skips some
of this and still costs 50 to 100 ms. For a service that runs for weeks,
none of it matters. For a function that runs for 10 ms, it is almost all of
the cost.
one `docker run` of a 10 ms Python function, roughly
~250 ms ~1 ms ~220 ms ~10 ms
┌────────────────────────────────┬─┬───────────────────────────┬──┐
│ docker → dockerd → containerd │▒│ Python starts and imports │██│
│ → shim; record, layer, network │▒│ its modules from nothing │██│
└────────────────────────────────┴─┴───────────────────────────┴──┘
├────────────┼────────────┼─────────────┼────────────┼────────────┤
0 100 200 300 400 500 ms
▒ the isolation itself
██ your code
What a container leaves behind
A container is an object, not only a process. When the program exits, the
container stays in docker ps -a, with its writable layer on disk and its
logs in the daemon, until someone runs docker rm, or passed --rm. This is
right for services: you can restart them, read their logs, step inside. For
a job that runs once, it is something to clean up.
Docker’s defaults
Docker’s defaults are made for running software you chose. The root file
system is writable, fourteen capabilities are kept, the process runs as root
inside, the network is a bridge that can reach your local network, and there
is no memory, CPU or process limit unless you ask. You can make a container
very tight — --read-only --cap-drop ALL --network none --memory ... — but
you have to know every flag. For running code you did not write, the
defaults point the wrong way. The comparison
lists them side by side with Zygo’s.
Rootless Docker and Podman
The root daemon has always been Docker’s weak spot: access to its socket is
the same as root on the host. Rootless Docker runs the whole chain as a
normal user inside a user namespace. Podman goes further: no daemon at all,
each container started by the podman command itself, with a small helper,
conmon, staying behind like the shim. Both are real steps forward. Both
still create container objects and still start your program from nothing, so
the per-run cost stays in the hundreds of milliseconds.
What Docker is for
Docker is the right tool for building images, running long-lived services, connecting them with networks, publishing ports and restarting them when they fail. Zygo does none of that, and uses Docker’s images happily. The question this book cares about is narrower: what if the thing you run is a short function, called thousands of times, written by someone you do not fully trust? The next chapter is the answer Zygo gives.