Architecture: Docker

Codelooru Docker

You ship a service that works perfectly on your laptop. It hits staging and dies immediately. The stack trace points at a shared library your machine had and the staging box did not. Someone on the team says the words every engineer has heard: "works on my machine."

The old fix was a virtual machine. Package the whole operating system, ship the image, run it anywhere. It works, but you are shipping gigabytes of kernel and userland to solve a problem caused by one missing shared object. Boot time is measured in tens of seconds. You cannot fit many of them on one host.

Docker solves the same problem by shipping only the parts that differ. The kernel stays where it is. The application, its libraries, and its filesystem travel together as an image. What runs is not a virtual machine. It is an ordinary Linux process that has been lied to about the world it lives in.

This post walks the entire stack: the CLI you type into, the daemon that receives your request, the runtimes below it, the kernel primitives that make isolation real, and the image format that makes distribution cheap.


The Big Picture

Docker is not one program. It is a chain of components, each with a narrow job, connected by well-defined APIs. Understanding the chain is most of the battle, because almost every confusing Docker behavior becomes obvious once you know which layer is responsible for it.

At the top is the docker CLI. It is a thin HTTP client and nothing more. Below it, the Docker daemon (dockerd) exposes a REST API and handles the concerns Docker is famous for: images, networks, volumes, the build system. Below that, containerd manages the container lifecycle and pulls images. Below that, runc actually creates the container by making the right kernel calls. And at the bottom sits the Linux kernel, which provides the only real isolation in the entire stack.

CLIENT docker CLI HTTP client only Compose / SDK same REST API Registry Docker Hub, ECR, GHCR /var/run/docker.sock DAEMON dockerd BuildKit Networking Volumes Image mgmt pull / push gRPC RUNTIME containerd lifecycle, snapshots, content store containerd-shim parent of container PID runc exits after setup KERNEL Linux kernel namespaces cgroups v2 overlayfs capabilities, seccomp LSM (AppArmor / SELinux) Each layer talks only to its immediate neighbor. The kernel does the actual isolating.

The reason this chain exists in this shape is worth stating up front. Docker originally did everything in one monolithic daemon. That meant restarting the daemon killed every running container, and it meant Docker's opinions about images and networking were welded to the low-level runtime. The split into containerd and runc, and the standardization of both under the Open Container Initiative (OCI), decoupled those concerns. Kubernetes now talks to containerd directly and skips dockerd entirely. That is not Docker losing; that is the layering working as designed.


The Docker CLI

The docker binary is the least interesting component in the stack, and that is deliberate. When you type docker run nginx, the CLI does not create anything. It serializes your intent into an HTTP request and sends it to the daemon over a Unix domain socket at /var/run/docker.sock.

You can prove this to yourself without the CLI at all:

curl --unix-socket /var/run/docker.sock \
  -X POST "http://localhost/v1.45/containers/create?name=demo" \
  -H "Content-Type: application/json" \
  -d '{"Image": "nginx:alpine", "ExposedPorts": {"80/tcp": {}}}'

That is a container creation, no CLI involved. The response contains an ID; a follow-up POST to /containers/{id}/start starts it. Everything the CLI can do is exposed over this API, which is why Docker Compose, the Go and Python SDKs, and your IDE's Docker plugin all work without shelling out to the binary.

Two consequences follow from this design, and both matter operationally.

First, the daemon socket is root-equivalent. Anyone who can write to /var/run/docker.sock can start a privileged container that mounts the host root filesystem, which means they can do anything. Adding a user to the docker group is functionally granting them root. Mounting the socket into a container ("Docker-in-Docker" via socket passthrough) hands that container the keys to the host.

Second, because the API is HTTP, the daemon does not have to be local. Setting DOCKER_HOST=tcp://remote:2376 points your CLI at another machine. This is how remote build hosts and CI runners work. It is also why exposing the daemon on a TCP port without TLS client certificates is one of the most reliably exploited misconfigurations on the internet.


The Docker Daemon

dockerd is where Docker's opinions live. It owns four subsystems that containerd deliberately knows nothing about.

Image management

The daemon resolves image references, authenticates to registries, and decides what needs pulling. It maintains the local image cache and the mapping from human names (nginx:alpine) to content-addressed digests (sha256:...). The actual pulling and layer storage is delegated downward, but the naming, tagging, and cache logic is the daemon's.

The build system

When you run docker build, the daemon hands the work to BuildKit, which since Docker 23 is the default builder. BuildKit is a substantial piece of engineering in its own right, and it gets its own section below.

Networking

Every bridge, every veth pair, every iptables rule that makes -p 8080:80 work is created by the daemon through its network drivers. containerd is handed a network namespace and told to run the process in it; it does not know or care what that namespace is connected to.

Volumes

Named volumes, bind mounts, and volume drivers are daemon concerns. The daemon computes the final mount list and passes it down as part of the OCI spec.

Here is the important design point: dockerd and the containers it manages have separate lifecycles. If you restart the daemon, running containers keep running. This works because the daemon is not the parent process of your container. That job belongs to the shim.


containerd

containerd is a daemon that manages the complete container lifecycle on a machine: pulling images, unpacking them into filesystems, creating containers, supervising them, and tearing them down. It is a CNCF graduated project and is used directly by Kubernetes, by AWS Fargate, and by anyone who wants container execution without Docker's build and networking opinions.

It exposes a gRPC API on /run/containerd/containerd.sock, organized into services that map cleanly to the objects it manages.

The content store holds immutable, content-addressed blobs: image layers, manifests, and configs, keyed by their SHA-256 digest. Because the key is the hash of the content, two images that share a base layer share the blob on disk automatically. No deduplication pass is needed; deduplication is a property of the addressing scheme.

The snapshotter turns those immutable blobs into a usable filesystem. This is where the union filesystem lives, and it is central enough to Docker's economics that it gets its own section below.

The tasks service handles the running process: start, kill, exec, pause, resize the TTY, collect exit codes. This is the part that talks to the shim.

The shim

When containerd starts a container, it does not fork it as a child. It launches a containerd-shim process, and the shim becomes the parent of the container's init process. The shim then double-forks away from containerd, so it is reparented to PID 1 on the host and survives independently.

This is the mechanism that makes daemonless container survival work, and the reasoning is worth spelling out. In Linux, when a process exits, its children are reparented to PID 1 and, critically, someone must call wait() to collect the exit status or the process becomes a zombie. If containerd were the direct parent of every container, killing containerd would orphan every container and lose every exit code. Instead, the shim holds that role. Restart containerd and it reconnects to the still-running shims over their sockets, recovers state, and carries on. Your containers never noticed.

The shim also keeps the container's stdout and stderr pipes open. If it did not, and the daemon were the pipe holder, a daemon restart would send SIGPIPE to a container writing logs and kill it.


runc and the OCI Runtime Spec

runc is a small command-line tool that creates a container from a bundle on disk and then exits. That is the whole job. It is the reference implementation of the OCI Runtime Specification, and it is where the abstraction finally touches the kernel.

A bundle is two things: a root filesystem directory, and a config.json that describes exactly what the container should be. That JSON is the OCI runtime spec in concrete form, and reading one is the fastest way to understand what a container actually is.

{
  "ociVersion": "1.0.2",
  "process": {
    "terminal": false,
    "user": { "uid": 101, "gid": 101 },
    "args": ["nginx", "-g", "daemon off;"],
    "env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"],
    "cwd": "/",
    "capabilities": {
      "bounding": ["CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_SETUID"],
      "effective": ["CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_SETUID"]
    },
    "noNewPrivileges": true
  },
  "root": { "path": "rootfs", "readonly": false },
  "hostname": "a3f9c1e2b7d4",
  "linux": {
    "namespaces": [
      { "type": "pid" },
      { "type": "network" },
      { "type": "ipc" },
      { "type": "uts" },
      { "type": "mount" },
      { "type": "cgroup" }
    ],
    "resources": {
      "memory": { "limit": 536870912 },
      "cpu": { "quota": 50000, "period": 100000 }
    },
    "seccomp": { "defaultAction": "SCMP_ACT_ERRNO" }
  }
}

Every field here maps to a kernel feature. The namespaces array becomes clone() and unshare() flags. The resources block becomes writes to files under /sys/fs/cgroup. The capabilities list becomes capset(). The seccomp block becomes a BPF filter installed with prctl(). There is no container object in the kernel; there is only a process with a very specific set of restrictions applied to it, and this file is the list of restrictions.

runc applies them all, execs the container's entrypoint, and exits. The container process lives on, parented to the shim. This is why you will never see runc in ps next to a running container: its work is finished before your application prints its first log line.

Because the runtime interface is a spec rather than a binary, runc is swappable. gVisor (runsc) intercepts syscalls in a userspace kernel for stronger isolation. Kata Containers boots a real lightweight VM per container and presents it through the same interface. crun is a C rewrite of runc that is faster and uses less memory. Docker can be pointed at any of them with --runtime, and nothing above containerd needs to change.


The Kernel Primitives

Everything above this point is orchestration. The isolation itself is provided by three groups of Linux kernel features, and a container is precisely the intersection of them.

Namespaces: what a process can see

Namespaces partition kernel resources so that a process sees only its own slice. Each namespace type virtualizes one global resource.

  • pid gives the container its own process ID space. The container's entrypoint is PID 1 inside, while on the host it is some ordinary number like 24601. The container cannot see or signal host processes because they simply are not in its PID table.
  • net gives the container its own network stack: interfaces, routing table, iptables rules, socket bindings. Port 80 inside a container is a different port 80 from the host's.
  • mnt gives it its own mount table, which is what makes the container's root filesystem look like /.
  • uts isolates hostname and domain name, which is why hostname inside a container returns the container ID.
  • ipc isolates System V IPC and POSIX message queues, so shared memory segments do not leak between containers.
  • user maps UIDs and GIDs, allowing root inside the container to be an unprivileged UID on the host.
  • cgroup hides the host's cgroup hierarchy so a container cannot see the resource layout of its neighbors.
  • time (Linux 5.6+) virtualizes the system clock offsets.

The user namespace deserves emphasis because it is the one Docker does not enable by default. Without it, root inside a container is UID 0 on the host, restrained only by capabilities and seccomp. If a process escapes the other namespaces, it escapes as root. Enabling userns-remap in /etc/docker/daemon.json maps container root to a high, unprivileged host UID, so an escape lands the attacker as nobody. It is off by default because it breaks bind mounts with host-owned files and confuses some volume drivers, which tells you something honest about the tradeoff Docker chose.

cgroups: what a process can consume

Namespaces control visibility. Control groups control consumption. A process in a namespace still competes for the same physical CPU and RAM as everything else on the host, and without cgroups one container can starve every other.

Under cgroups v2 (the unified hierarchy, now the default on modern distributions), the interface is a filesystem. Docker's memory limit of 512 MB becomes, quite literally, a number written into a file:

# docker run --memory=512m --cpus=0.5 nginx

# becomes, inside the container's cgroup directory:
$ cat /sys/fs/cgroup/memory.max
536870912
$ cat /sys/fs/cgroup/cpu.max
50000 100000

The CPU line reads as "50,000 microseconds of CPU time per 100,000 microsecond period," which is half a core. Memory and CPU behave very differently when the limit is hit, and confusing them causes a lot of production pain.

Exceeding the CPU quota throttles. The process is descheduled until the next period begins. It gets slower; it does not die. Exceeding the memory limit kills. The kernel's OOM killer terminates a process in the cgroup, and because the container's entrypoint is usually the biggest memory consumer, that is usually what dies. Your container exits with code 137 (128 + SIGKILL), and this is the single most common mysterious container death in production.

cgroups also handle block I/O throttling (io.max) and PID limits (pids.max). The PID limit is a genuinely underused defense: a fork bomb inside a container with no pids.max will exhaust the host's process table and take down every other container on the machine.

Capabilities, seccomp, and LSMs: what a process can do

The final group narrows what syscalls the process may make at all.

Capabilities slice up root's power into roughly forty distinct privileges. A container running as root does not get all of them. Docker's default set drops the dangerous ones: no CAP_SYS_ADMIN (which would allow mounting filesystems and is effectively root), no CAP_SYS_MODULE (loading kernel modules), no CAP_SYS_PTRACE. It keeps benign ones like CAP_NET_BIND_SERVICE, which is why a container can bind to port 80 as root without being able to do much else.

--privileged grants every capability, disables seccomp, and unmasks /proc and /sys. It is not "a bit more access." It is the removal of essentially every barrier between the container and the host, and a privileged container should be treated as a host process with extra steps.

seccomp filters individual syscalls with a BPF program. Docker's default profile blocks around forty of the roughly three hundred available syscalls, targeting the ones with no legitimate use inside a container and a history of kernel exploits: kexec_load, init_module, bpf, ptrace, mount. The default profile is why so many published kernel exploits do not work against a default Docker container even when the kernel is vulnerable.

LSMs (AppArmor on Debian and Ubuntu, SELinux on RHEL and Fedora) add mandatory access control on top. Docker ships a default AppArmor profile that restricts writes to sensitive /proc and /sys paths.

Your process an ordinary Linux PID NAMESPACES what it can SEE pid ... own process table net ... own interfaces, ports mnt ... own filesystem tree uts ... own hostname ipc ... own shared memory user .. own UID mapping cgroup, time via clone() / unshare() CGROUPS v2 what it can CONSUME memory.max ... OOM kill (137) cpu.max ....... throttle, not kill io.max ........ block I/O cap pids.max ...... fork bomb guard a filesystem interface under /sys/fs/cgroup CAPS / SECCOMP / LSM what it can DO capabilities .. root, subdivided seccomp ....... ~40 syscalls blocked AppArmor ...... MAC on paths SELinux ....... MAC on labels --privileged removes all of this column A container is the intersection of all three. Remove one column and the isolation is not real.

The one thing this diagram should make obvious: there is no kernel object called a container. There is a process, and there are three orthogonal restriction mechanisms applied to it. Docker's job is to apply all three consistently, every time.


Images, Layers, and the Union Filesystem

The kernel primitives explain isolation. They do not explain why Docker made distribution cheap, which is arguably the bigger contribution. That comes from the image format.

A Docker image is not a disk image. It is an ordered stack of layers, each one a tarball of filesystem changes, plus a JSON manifest listing them and a JSON config describing how to run the result. Each layer is content-addressed by the SHA-256 digest of its contents.

Content addressing is what makes everything else work. If two images both build on debian:bookworm-slim, that base layer has the same digest in both, so it is stored once on disk and pulled once over the network. Push a new version of your app and only the changed layer moves. A 900 MB image whose top layer changed by 4 MB is a 4 MB push.

Copy-on-write with overlayfs

At runtime, those read-only layers must present as a single writable filesystem. That is the snapshotter's job, and on modern Linux it uses overlay2, which is a thin wrapper around the kernel's overlayfs.

overlayfs takes a set of read-only lower directories and one writable upper directory and presents a unified view. Reads search the upper directory first, then each lower directory in order. Writes always go to the upper directory. The rules are three:

  • Read an unmodified file and you get it straight from the lower layer with zero copying.
  • Modify a file and overlayfs copies the entire file up to the writable layer first, then applies your write. This is copy-up, and it happens on first write, per file, regardless of how small the change is.
  • Delete a file from a lower layer and it cannot actually be removed, because lower layers are immutable. Instead overlayfs writes a whiteout marker in the upper layer, and the unified view hides the file.

Two practical consequences fall directly out of these rules, and both explain behavior that otherwise looks like a bug.

First, copy-up makes containers a bad place for write-heavy workloads. A database that modifies a 10 GB data file will copy 10 GB up on the first write. This is exactly why databases in containers belong on volumes, which bypass overlayfs entirely and write straight to the host filesystem.

Second, whiteouts are why deleting a file in a later Dockerfile layer does not shrink the image. This Dockerfile produces an image containing the secret:

FROM debian:bookworm-slim
COPY secrets.tar.gz /tmp/
RUN tar xzf /tmp/secrets.tar.gz -C /opt/
RUN rm /tmp/secrets.tar.gz

The rm creates a whiteout in the fourth layer. The tarball is still sitting in the second layer, fully intact, and anyone who pulls the image can extract it with docker save. To actually remove it, the file must never exist in a committed layer: use a multi-stage build, or do the add and the delete inside a single RUN, or use a build-time secret mount.

UNIFIED VIEW (what the process sees) merged / .. app.conf (edited), /bin, /lib, no secrets.tar.gz UPPER (writable, per container) app.conf (copied up on first write) secrets.tar.gz -> whiteout marker (hides, does not delete) LOWER (read-only image layers, shared) L4 rm /tmp/secrets.tar.gz L3 tar xzf ... -C /opt/ L2 COPY secrets.tar.gz .. still here, still extractable L1 debian:bookworm-slim .. shared by every image using it The three rules READ ... straight from lower, free WRITE .. copy whole file up first DELETE . whiteout in upper only lower layers are immutable Two consequences 1. Write-heavy workloads pay copy-up. Use a volume. 2. rm in a later layer never shrinks the image or hides the secret. Copy-on-write: cheap to start, cheap to share, expensive to modify large files.

Why containers start in milliseconds

The union filesystem is the whole reason a container starts fast. Starting a VM means copying or allocating a disk image and booting a kernel. Starting a container means creating one empty writable directory, mounting the existing read-only layers underneath it, and calling clone(). Nothing is copied. The expensive work was done once, at pull time, and is shared by every container using that image.


BuildKit

The original Docker builder executed a Dockerfile top to bottom, one instruction per layer, sequentially. BuildKit replaces that with something considerably smarter, and understanding the difference changes how you write Dockerfiles.

BuildKit parses the Dockerfile into a DAG of build steps rather than a linear list. Steps with no dependency on each other run in parallel. In a multi-stage build where one stage compiles a Go binary and another builds frontend assets, both stages run concurrently, because BuildKit can see they do not depend on each other.

It also caches at a much finer granularity. A RUN step can mount a persistent cache directory that survives across builds:

# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /app ./cmd/server

FROM gcr.io/distroless/static
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

The two --mount=type=cache directives keep the Go module cache and compiler cache on the build host between builds. They are not baked into any layer, so they do not bloat the image, but a rebuild after changing one source file reuses everything. The COPY go.mod go.sum before COPY . . is the classic ordering trick: dependency downloads are cached until your dependency list actually changes, not every time you touch a source file.

The final stage copies one static binary into distroless, an image with no shell, no package manager, and no libc. The result is a few megabytes with a near-empty attack surface. There is nothing for an attacker to execute even if they achieve RCE, because there is no sh to spawn.

BuildKit also handles secrets properly. --mount=type=secret makes a file available during a single RUN step and never writes it to a layer, which is the correct answer to the whiteout problem described above.


Networking

The net namespace gives a container an empty network stack: a loopback interface and nothing else. Connecting it to anything is the daemon's job, and it does so through pluggable drivers under the Container Network Model.

bridge

The default. On daemon start, Docker creates a virtual switch on the host called docker0. For each container, it creates a veth pair, a virtual Ethernet cable with two ends. One end is placed in the container's namespace and renamed eth0; the other stays on the host and is plugged into docker0. The container gets an IP from a private subnet, typically 172.17.0.0/16.

Outbound traffic is masqueraded with an iptables rule in the POSTROUTING chain, so the container's private IP is rewritten to the host's IP on the way out. That is standard source NAT.

Inbound is the interesting one. When you write -p 8080:80, Docker inserts a DNAT rule into the DOCKER chain of the nat table:

$ sudo iptables -t nat -L DOCKER -n

Chain DOCKER (2 references)
target     prot opt source       destination
DNAT       tcp  --  0.0.0.0/0    0.0.0.0/0    tcp dpt:8080
           to:172.17.0.2:80

This rule sits in PREROUTING, which the kernel evaluates before the INPUT chain where most firewall rules live. That is the source of a genuinely dangerous and widely encountered surprise: a UFW rule denying traffic to port 8080 will not block a published Docker port, because the packet has already been redirected before UFW's rules are consulted. Publishing a port on a cloud host with a "firewall" configured this way exposes it to the internet. Binding explicitly to -p 127.0.0.1:8080:80 is the fix.

A user-defined bridge (docker network create) behaves like docker0 but adds an embedded DNS resolver at 127.0.0.11 inside each container, so containers can resolve each other by name. The default docker0 bridge does not do this. This is the entire reason Docker Compose works: Compose creates a user-defined bridge, and http://db:5432 resolves because of that embedded resolver.

host, none, overlay, macvlan

  • host skips the net namespace entirely. The container shares the host's stack, so there is no NAT and no port mapping, and a container binding port 80 binds the host's port 80. Fastest option, zero network isolation.
  • none gives the container a namespace with only loopback. Useful for batch jobs that should have no network access at all.
  • overlay spans hosts using VXLAN encapsulation. Container traffic is wrapped in UDP packets and tunneled between hosts, so containers on different machines share one logical L2 network. This is the Swarm networking model.
  • macvlan gives the container its own MAC address directly on the physical network. It appears to the rest of the LAN as a distinct physical machine, which is the escape hatch for legacy systems that need real network identities.
HOST NETWORK NAMESPACE container A eth0 = 172.17.0.2 own net namespace listening on :80 container B eth0 = 172.17.0.3 own net namespace resolves A by name (DNS 127.0.0.11) veth pair veth pair docker0 (virtual bridge) a software L2 switch on the host iptables (nat) OUT: POSTROUTING SNAT 172.17.x -> host IP IN: PREROUTING DNAT :8080 -> 172.17.0.2:80 runs BEFORE the INPUT chain eth0 (physical NIC) A UFW rule on INPUT will not block a published port. Publishing a port writes a DNAT rule that most host firewalls never see.

Storage and Volumes

Anything written to the container's writable layer dies with the container, and pays the copy-up cost while it lives. For persistent or write-heavy data, Docker offers two ways out of the union filesystem.

Bind mounts map a host path directly into the container. There is no abstraction and no overlayfs involvement; the container writes to the host filesystem at native speed. The container also gets whatever ownership and permissions the host path has, which is why bind mounts and UID mismatches are a perennial source of permission-denied errors.

Named volumes are managed by the daemon in /var/lib/docker/volumes/. The container still writes to the host filesystem directly, bypassing overlayfs, but Docker owns the path and the lifecycle. Volume drivers extend this to network storage, so a named volume can be an NFS share or an EBS volume, and the container never knows.

The rule that follows from copy-up: if a workload writes a lot, or writes to large files, it belongs on a volume. Postgres in a container with its data directory on the writable layer is slow and loses everything on docker rm. The same Postgres with its data directory on a named volume is fast and survives.


The Lifecycle of docker run

Everything above is now enough to trace a single command end to end. Here is what happens when you run docker run -d -p 8080:80 --memory=512m nginx:alpine on a machine that has never seen the image.

1. CLI to daemon. The CLI parses the flags, builds a JSON payload, and POSTs to /containers/create on the Unix socket. It creates nothing itself.

2. Image resolution. The daemon looks for nginx:alpine locally, does not find it, and asks the registry. It fetches the manifest, which lists the layer digests and the config digest.

3. Layer pull. The daemon checks each digest against containerd's content store. Layers already present (from any other image) are skipped, which is why the second image you pull from a shared base is so much faster. Missing layers are downloaded in parallel and stored by digest.

4. Snapshot preparation. The snapshotter unpacks the layers and prepares an overlay2 mount: the image layers as read-only lowers, and a fresh empty directory as the writable upper. This is where the container's root filesystem comes from.

5. Network setup. The daemon creates a network namespace, builds a veth pair, attaches one end to docker0, assigns 172.17.0.2, and writes the DNAT rule for 8080 -> 172.17.0.2:80 into iptables.

6. Spec generation. The daemon assembles the OCI config.json: entrypoint from the image config, the namespace list, the mount list, the memory limit of 536870912 bytes, the default capability set, and the default seccomp profile.

7. Handoff to containerd. The daemon calls containerd over gRPC with the bundle: the prepared rootfs plus the spec.

8. Shim launch. containerd starts a containerd-shim, which double-forks so it is no longer a child of containerd. The shim will be the parent of the container.

9. runc creates the container. The shim invokes runc create. runc calls clone() with the namespace flags, writes the cgroup limits into /sys/fs/cgroup, pivot_roots into the prepared rootfs, drops capabilities, installs the seccomp BPF filter, and execs nginx. Then runc exits.

10. Running. nginx is now PID 1 inside its PID namespace and PID 24601 on the host, parented to the shim. The shim holds its stdio pipes and will reap it. The container ID prints to your terminal.

CLI dockerd containerd shim runc kernel 1. POST /containers/create 2-3. resolve manifest, pull missing layers by digest 4. snapshotter prepares overlay2 rootfs 5. veth + docker0 + DNAT 8080 -> 172.17.0.2:80 6. generate OCI config.json (ns, cgroups, caps, seccomp) 7. gRPC: bundle 8. start shim (double-fork) 9. runc create clone() + cgroup writes + pivot_root + capset + seccomp + exec runc exits. Its job is done. 10. nginx runs, parented to shim Time flows downward. Note that runc is gone before the application serves its first request. The shim, not the daemon, is the container's parent. That is why dockerd can restart safely.

Failure Modes

Knowing the layers means knowing which one broke. Here are the failures you will actually meet, mapped to their layer.

Exit code 137: OOM kill

The cgroup memory limit was exceeded and the kernel killed the process. The container did not crash; it was executed. Check docker inspect for "OOMKilled": true to confirm. The subtle version of this bug: many runtimes do not read the cgroup limit. A JVM that predates container awareness sees the host's 64 GB and sizes its heap accordingly, then gets killed when the 512 MB cgroup limit bites. Modern JVMs handle this with UseContainerSupport, but Node, Python, and Go all have their own versions of the same trap.

Exit code 143: SIGTERM

docker stop sends SIGTERM, waits ten seconds, then sends SIGKILL. If your process ignores SIGTERM, it gets killed hard and drops in-flight requests. Signals only reach PID 1 in the namespace, and if PID 1 is a shell wrapper (ENTRYPOINT ["sh", "-c", "myapp"]) then the shell receives the signal and your app never does. Use the exec form of ENTRYPOINT so your process is PID 1.

Zombie processes

PID 1 has a special duty in Linux: it must reap orphaned children. Most applications are not written to be PID 1 and never call wait(). If your container spawns subprocesses that die, they become zombies and accumulate until the PID table fills. Docker's --init flag inserts a minimal init process (tini) as PID 1 to do the reaping.

CPU throttling that looks like a hang

Unlike memory, CPU limits do not kill. They throttle. A container at its cpu.max quota is descheduled for the rest of the period, which shows up as latency spikes and timeouts rather than a crash. Because nothing dies and nothing logs, this is one of the hardest container problems to diagnose. Check nr_throttled and throttled_time in cpu.stat.

Daemon restart

Thanks to the shim, restarting dockerd does not kill containers. But the daemon does hold the iptables rules and network state, so a daemon crash without a clean restart can leave stale rules or orphaned veth interfaces. Containers stay up; their connectivity may not.

Disk exhaustion

The most common operational failure and the least dramatic. Stopped containers keep their writable layers. Dangling images keep their layers. Unused volumes are never garbage collected, because Docker cannot know whether you still want the data. /var/lib/docker fills, and every subsequent write on the host fails. docker system df shows the breakdown; docker system prune reclaims it, but note that --volumes is not included by default precisely because deleting a database volume is not recoverable.

Container escape

Containers share the host kernel. A kernel vulnerability is a container escape, and no amount of Docker configuration changes that. This is the fundamental security difference from VMs, where the hypervisor provides a second boundary. The practical mitigations follow from the earlier sections: do not run --privileged, do not mount docker.sock into containers, do not run as root inside the container, enable userns-remap, keep the seccomp profile, and patch the kernel. If the threat model genuinely requires kernel isolation, replace runc with gVisor or Kata and accept the performance cost.


The Complete Picture

Every component and every mechanism described above, in one view.

CLIENT docker CLI Compose SDK / CI OCI Registry REST over docker.sock (root-equivalent) DAEMON dockerd BuildKit DAG, cache mounts Networking veth, bridge, iptables Volumes bind, named, drivers Images tags, auth, cache pull/push by digest gRPC RUNTIME containerd content store (by sha256) snapshotter (overlay2) tasks (start/kill/exec) containerd-shim parent of PID 1, holds stdio, reaps survives dockerd restart runc (OCI runtime) reads config.json, applies it, exits swappable: crun, gVisor, Kata KERNEL (the only real boundary; shared with the host) namespaces what it SEES cgroups v2 what it USES caps + seccomp what it DOES overlayfs what it READS ON DISK /var/lib/docker overlay2/ lowers (shared, RO) upper (per container, RW) volumes/ bypasses overlayfs survives docker rm containers/ json log files fills up silently stopped containers, dangling images, orphaned volumes docker system df The full stack. Every layer above the kernel exists to generate one config.json and hand it down.

Summary

Every layer in this stack exists to produce one artifact: a config.json that tells the kernel exactly how to constrain a single process. The CLI collects your intent. The daemon adds opinions about images, networks, and volumes. containerd turns content-addressed blobs into a filesystem and supervises the result. runc reads the spec, makes the syscalls, and disappears. Everything above the kernel is bookkeeping in service of that one file.

Which means the mental model that unlocks Docker is this: a container is a process, not a machine. Every behavior that seems strange follows directly from that fact. It dies at 512 MB because a cgroup file says so. It cannot be firewalled the way you expect because a DNAT rule ran before your firewall did. Deleting a file does not shrink the image because lower layers are immutable and all you wrote was a whiteout. Restarting the daemon does not kill it because the daemon was never its parent. And it can escape to the host in a way a VM cannot, because it was always sharing the host's kernel.

Docker did not invent namespaces, cgroups, or union filesystems. Those existed for years and were nearly unusable. What Docker built was the packaging: a content-addressed image format, a registry protocol, and a CLI that made all of it a single command. The isolation was already in the kernel. The distribution was the hard part.


Related on this blog: Architecture series



×