How Docker works under the hood

· updated

Type this and hit enter:

docker run -p 8080:80 nginx

A few hundred milliseconds later there’s a web server answering on port 8080. It feels like a tiny virtual machine booted up. It didn’t. Nothing booted. No guest kernel, no BIOS, no hypervisor. What actually happened is that a normal Linux process started — the same way every process on the box starts — and the kernel was handed a list of instructions about what that process is allowed to see and use. That’s the whole trick, and everything below is just the machinery that arranges it.

Here’s the one sentence to keep in your head the entire way down:

A container is a process. Isolation isn’t a box the process lives in — it’s a set of restrictions the kernel applies to a process that’s already running on the host.

Get that and the rest is plumbing. Let’s follow the command down through every hop.

The one command, five hops down

The docker you type is a thin client. It doesn’t create containers, doesn’t touch namespaces, doesn’t pull images. It serialises your command into an HTTP request and sends it to a daemon. From there the work is handed down a chain of increasingly specialised programs, each doing one job and delegating the rest — until, at the bottom, a single execve turns a pile of files into a running process.

One docker run command travels from the CLI to dockerd to containerd to a per-container shim and runc, which sets up namespaces and cgroups and execs your process on the shared host kernel; dockerd pulls the image from a registry if it is not cached What actually happens when you run $ docker run -p 8080:80 nginx docker CLI — the client builds an HTTP request; it starts nothing itself REST API over the Unix socket /var/run/docker.sock dockerd — the Docker daemon images · networks · volumes · builds · auth resolves the image, then hands the container off Registry Docker Hub · ECR · GHCR pulled only if not cached gRPC over a local socket containerd — the container supervisor unpacks image layers · prepares the snapshot (overlayfs) assembles the OCI bundle · tracks container state spawns a shim per container, then calls the runtime OCI bundle = config.json + rootfs/ containerd-shim → runc runc sets up namespaces · cgroups · capabilities · seccomp clone() a new process, pivot_root, then execve your program runc exits immediately — the lightweight shim stays as the parent Your container = one ordinary, fenced-in process PID 1 inside its own namespaces · a writable layer over the image every syscall goes straight to the host kernel One shared Linux kernel — no guest OS, no hypervisor namespaces cgroups capabilities overlayfs veth + iptables
The path of a single docker run. Each program does one job and delegates the rest: the CLI only talks HTTP; dockerd owns images and networks; containerd unpacks layers and supervises lifecycle; a per-container shim plus runc does the actual kernel setup and then gets out of the way. What's left is a process the host kernel fences in with namespaces and cgroups.

1 — docker (the CLI) just makes a phone call

The CLI’s entire job is to translate docker run … into an HTTP request against the Docker Engine API and print back what it hears. By default it dials the daemon over a Unix domain socket at /var/run/docker.sock (it can also speak TCP to a remote host — that’s all DOCKER_HOST changes). Nothing about isolation happens here. You could curl --unix-socket /var/run/docker.sock http:/v1.45/containers/json and get the same answers; the CLI is a convenience over a REST API.

2 — dockerd (the daemon) owns the “big” objects

dockerd is the long-running background service, and it’s where all the Docker-flavoured concepts live: images, the local image cache, networks, volumes, builds, and registry authentication. When your nginx image isn’t already on disk, it’s dockerd that reaches out to the registry (Docker Hub by default, or ECR/GHCR/etc.), authenticates, and pulls the layers. But dockerd does not create the container itself. It resolves everything the container needs and then hands the actual create-and-run job down to containerd over a gRPC API. This split is deliberate: you can restart dockerd and your running containers survive, because the thing supervising them lives one level lower.

3 — containerd (the supervisor) turns an image into a bundle

containerd is a full container runtime in its own right — it’s what Kubernetes talks to directly, no Docker involved. Its job here is to take the pulled image and get it ready to run: it unpacks the layers, uses a snapshotter to stack them into a root filesystem (overlayfs, usually), and assembles an OCI bundle — a directory containing a config.json (the spec: which binary to run, env vars, mounts, the namespaces and cgroup limits to apply) and a rootfs/ (that stacked filesystem). Then containerd manages the container’s whole life: start, stop, pause, delete, and reporting status back up. For each container it starts, it spawns a small shim process and asks the shim to invoke the low-level runtime.

4 — runc (the runtime) does the one privileged thing

runc is the reference implementation of the OCI runtime spec — a small C-and-Go program whose only job is: given an OCI bundle, create the container process exactly as config.json describes. This is the moment all the isolation is actually built:

Then it execves your program (nginx) as PID 1 of that namespace — and runc exits. It’s a setup tool, not a supervisor. The containerd-shim stays behind as the container’s parent: it keeps stdio and the exit code, and — crucially — it means containerd (and dockerd above it) can be restarted or upgraded without killing your running containers.

5 — what’s left is just… a process

There is no “container object” running anywhere. Run ps aux on the host and you’ll see the nginx process sitting right there next to everything else, same as any daemon. docker exec doesn’t “enter a box” — it starts a new process and joins it to the same namespaces (via setns). The isolation is entirely a property the kernel enforces per-process; take the restrictions away and it’s an ordinary program.

The other half of the trick: the filesystem

Isolation explains why the process can’t see the host. It doesn’t explain why the container has nginx and a Debian userland inside it while the host might be running something else entirely. That’s the image, and it’s built from stacked, read-only layers plus one thin writable layer on top — a union filesystem, almost always overlayfs.

A container filesystem is a stack of read-only image layers with one thin writable copy-on-write layer on top; overlayfs merges them into a single tree, and many containers share the same read-only layers while each gets its own writable layer A container's filesystem: read-only image layers + one thin writable layer One container, one overlay Writable layer — container layer (copy-on-write) new & changed files land here; gone on docker rm COPY nginx.conf, site/ → read-only layer RUN apt-get install nginx → read-only layer FROM debian:stable-slim (base rootfs) ↑ image = read-only, content-addressed, shared overlayfs mount the process sees ONE merged filesystem at / One image, many containers container A · writable container B · writable Same read-only image layers stored once on disk, mounted into every container nginx · libs · base rootfs — never copied deduplicated by content hash Copy-on-write read a file → straight from the shared layer write a file → copied up into your writable layer first
Why containers start in milliseconds and cost almost nothing to run: the heavy parts of the image are read-only layers shared by every container, mounted — not copied. Each container adds only a thin writable layer. Reads come straight from the shared layers; the first write to a file copies it up into the writable layer (copy-on-write). Start a hundred containers from one image and you store the image once.

Two consequences fall straight out of this. Startup is cheap because nothing is copied — starting a container is a mount plus a process, not a boot. And containers are ephemeral by default: everything written at runtime lives in that top writable layer, which is deleted with the container. Data you want to keep goes in a volume — a host directory (or managed mount) bind-mounted into the container, deliberately outside the layer stack so it survives docker rm.

So why isn’t it a VM?

Because there’s no second kernel. A virtual machine virtualises hardware: the hypervisor gives each guest fake CPUs, fake memory, fake devices, and the guest boots its own full kernel on top. That’s strong isolation, but it costs a boot, a gigabyte of guest OS, and a constant virtualization tax. A container virtualises the operating system’s view instead: every container’s process calls the one host kernel directly, and the kernel just answers each call according to that process’s namespaces and cgroups.

That’s the trade in one line. A VM is a computer running on your computer; a container is a process pretending it’s the only one on yours. The VM gives you a real kernel boundary — run untrusted or multi-tenant workloads, mix operating systems. The container gives you density and speed — hundreds to a host, up in milliseconds — at the price of a shared kernel (which is also why “runs on Linux” is load-bearing: Docker on macOS or Windows quietly runs a Linux VM, and the containers live inside that).

The port, briefly

The -p 8080:80 in the command is a small world of its own. The container gets its own network namespace with a private IP, connected to a host bridge (docker0) by a veth pair — a virtual cable with one end in the container and one on the host. Publishing a port installs a NAT rule (via iptables, plus a small userland docker-proxy helper) that forwards host :8080 to the container’s :80. From inside, the process just thinks it’s listening on port 80 on its own interface; the kernel’s networking stack does the rest.

TL;DR: docker run doesn’t boot anything — it starts a Linux process and tells the kernel to fence it in. The docker CLI is a thin client that sends an HTTP request to dockerd, which owns images/networks/volumes and pulls from a registry if needed, then delegates to containerd, which unpacks the image into an OCI bundle and supervises lifecycle, then calls runc, which creates the process with namespaces (what it sees), cgroups (what it uses), and capabilities/seccomp (what it may do) — and then exits, leaving a shim as the parent so the daemons can restart without killing containers. The filesystem is stacked read-only image layers + one thin writable copy-on-write layer (overlayfs), so images are shared and containers are ephemeral. No guest kernel, no hypervisor: every syscall hits the one shared host kernel — which is exactly why containers are dense and fast, and why the isolation is only as strong as that kernel boundary.

Sources

← docker