Skip to content

4. The architecture

docker run nginx looks like one program doing one thing. It is five programs in a chain, and the chain is worth knowing because every one of the links is separately replaceable — which is exactly what the rest of the industry did.

docker CLI a thin HTTP client. Does almost nothing.
│ HTTP over a unix socket
│ /var/run/docker.sock
▼
dockerd THE DAEMON. Builds, networks, volumes, the API,
│ image management, logging. Long-lived, runs as root.
│ gRPC
▼
containerd Container lifecycle + image pull + snapshots.
│ A separate CNCF project. Boring on purpose.
│ fork/exec
▼
containerd-shim ONE PER CONTAINER. Stays alive for the
│ container's whole life. See below.
│ fork/exec
▼
runc Runs ONCE per container. Sets up namespaces,
cgroups, capabilities, pivot_root, then execve.
Then it EXITS. Reference implementation of the
OCI Runtime Spec.
↓
your process PID 1 in its namespace. Parented to the shim.

Two things about this surprise people.

runc is not running. It is not a supervisor. It performs the setup described in chapter 2, calls execve, and exits immediately. Nothing named runc appears in ps while your container runs. The kernel is “running the container”, in the sense that it is running a process.

The CLI is a client, not the program. docker ps is an HTTP GET to /containers/json. You can do the whole thing with curl:

curl --unix-socket /var/run/docker.sock http://localhost/containers/json

This is why docker works against a remote host with DOCKER_HOST, and why the socket is such a serious security boundary — the next section.

The shim is the piece nobody asks about and the one that makes the design work.

Consider: your container’s process must have a parent. If its parent were dockerd, then restarting dockerd — to upgrade it, or because it crashed — would orphan or kill every running container on the machine.

That was genuinely how early Docker behaved, and it was as bad as it sounds: upgrading Docker meant an outage.

The shim fixes it by sitting between:

dockerd ──► containerd ──► shim ──► your process
│
├─ keeps the container's stdout/stderr
│ pipes open so output is never lost
├─ holds the pty if -t was passed
├─ reaps the process and REMEMBERS
│ the exit code until someone asks
└─ survives dockerd restarting entirely

So you can systemctl restart docker on a machine running 200 containers and all 200 keep serving traffic. When dockerd comes back, it reconnects to containerd, which reconnects to the shims, and everything resumes with no gap. The shim costs a few MB of RAM per container, and buys the ability to upgrade the runtime without an outage.

The chain above is not Docker-specific any more. Two OCI specifications carved it into interfaces:

OCI IMAGE SPEC what an image is: manifest, config, layers.
Implemented by Docker, Podman, Buildah, Kaniko,
ko, Buildpacks, every cloud registry.
OCI RUNTIME SPEC a directory + a config.json → a running container.
Implemented by runc, crun, gVisor, Kata, youki.

Because of the runtime spec, runc is swappable per container:

runc the default. Fast, C+Go, direct kernel features.
crun a C rewrite. Faster startup, lower memory. Default on
Fedora/Podman.
gVisor (runsc) intercepts syscalls in a userspace kernel written
in Go. Much stronger isolation, some syscall overhead.
Used where you run untrusted code.
Kata each container is a real lightweight VM with its own
kernel. Strongest isolation, ~100 ms extra startup.

docker run --runtime=runsc is a real flag. The reason this matters is chapter 11: if you accept untrusted workloads, the “shared kernel” trade from chapter 1 is negotiable, at a price.

The famous “Kubernetes is deprecating Docker” announcement caused more panic than any technical change of the decade, and the diagram explains it completely.

BEFORE AFTER
kubelet kubelet
│ CRI │ CRI
▼ ▼
dockershim ← an adapter INSIDE containerd
│ the kubelet │
▼ ▼
dockerd ← everything here shim → runc
│ was unused by K8s
▼ (build, compose,
containerd the CLI, volumes…)
│
▼
shim → runc

Kubernetes speaks CRI (Container Runtime Interface). dockerd never spoke CRI, so the kubelet carried an adapter called dockershim to translate. That adapter was maintained by the Kubernetes project, for one vendor’s daemon, and it sat in front of a large amount of functionality Kubernetes did not use.

Removing it deleted two hops. Nothing about images changed — Kubernetes was already running OCI images through containerd, which is the same containerd dockerd uses. Images built with docker build still run everywhere, because chapter 3’s format is a standard.

The one thing that did change: on Kubernetes nodes, docker ps no longer shows the containers. Use crictl ps instead. That is the whole practical impact for most people.

Everything above works. The design question the industry argued about for years is dockerd itself.

dockerd is:
- a long-lived process that runs as ROOT
- listening on a socket
- that any user in the "docker" group can talk to
- and it is a single point of failure for the machine's containers
(less so since the shim, but it still owns networking,
volumes, the API and builds)

The security consequence is blunt and worth stating without softening:

Access to the Docker socket is root on the host.

Not “close to root”. Root. Anyone who can reach the socket can run:

docker run -v /:/host --privileged -it alpine chroot /host

and they are root on the machine, with the host’s entire filesystem. Adding a user to the docker group is therefore functionally identical to giving them passwordless sudo, and it should be treated as exactly that decision. Mounting /var/run/docker.sock into a container — a very common pattern for CI runners and monitoring agents — gives that container root on the host too.

Podman is the main answer to those objections, and it is worth knowing even if you never switch, because it clarifies what the daemon was for.

DOCKER PODMAN
docker CLI → dockerd → containerd podman → conmon → runc
(the CLI forks the container
directly; no daemon at all)
daemon runs as root rootless by DEFAULT, via user
namespaces — "root" in the
container maps to your UID
socket = root on host no socket to steal
containers survive CLI exit containers are children of
conmon, same as Docker's shim
compose is separate podman-compose, plus it can
generate Kubernetes YAML directly

Podman is CLI-compatible enough that alias docker=podman works for most day-to-day use, and it consumes and produces the same OCI images. What you give up is Docker Desktop’s polish, some Compose edge cases, and the ecosystem of tools that assume a socket exists.

Docker itself now ships a rootless mode (dockerd-rootless-setuptool.sh), which addresses the same problem from the other direction. It has real limitations — no binding to ports below 1024 without extra setup, slower networking via slirp4netns or pasta, and some storage driver constraints — but for a developer machine it is a genuine improvement.

One more piece, because it is not where people assume. When you type docker build:

docker CLI
│ packages the build context (the directory) into a tar
│ and streams it to the daemon over the socket
▼
dockerd ──► BuildKit ← a separate build engine, since 2018
│ default since Docker 23
├─ parses the Dockerfile into a DAG
├─ runs independent steps in PARALLEL
├─ manages the build cache
└─ produces an image, or exports elsewhere

Two consequences that catch people out:

The build does not run on your machine’s filesystem — it runs on the daemon’s. That is why docker build cannot read files outside the context directory, why COPY ../thing . is an error, and why sending a context containing node_modules is slow even though the Dockerfile never uses it. .dockerignore is chapter 5, and it matters more than it looks.

BuildKit is a different engine, not a flag. Parallel execution, cache mounts, secret mounts and multi-platform builds all come from it, and chapter 7 is dedicated to it. If you are on an old Docker or have set DOCKER_BUILDKIT=0, none of that exists for you.

  • The chain is CLI → dockerd → containerd → shim → runc → your process, and each link is a replaceable standard-conforming piece.
  • runc runs once and exits. It sets up namespaces, cgroups and the root filesystem, calls execve, and is gone. Nothing supervises the container in a special way after that.
  • The shim exists so that restarting dockerd does not kill your containers. It holds the stdio pipes, holds the pty, and remembers the exit code. You can upgrade Docker on a busy machine with no outage.
  • Two OCI specs make the pieces interchangeable: the image spec (Podman, Buildah, Kaniko, Buildpacks all produce the same images) and the runtime spec (runc, crun, gVisor, Kata — --runtime is a real flag when you need stronger isolation).
  • Kubernetes removed dockershim, not Docker images. It now talks CRI to containerd directly. The only day-to-day change is that docker ps on a node became crictl ps.
  • The Docker socket is root on the host — no qualifier. The docker group is passwordless sudo; mounting the socket into a container hands that container the machine.
  • Podman removes the daemon entirely and is rootless by default; Docker’s own rootless mode addresses the same problem with some networking and port limitations.
  • Builds run on the daemon, not your filesystem — hence the context tar, hence .dockerignore, hence no COPY ../. BuildKit is the engine that does it.

Next: the Dockerfile — the most important chapter in this guide, because almost every Docker problem people actually hit is written into one.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source