Docker Architecture: Client, Daemon, containerd and runc

K
Kai··9 min read

In Article 0 we learned that Docker packages an application into a container. This article goes a level deeper: when you type docker run, which components are actually involved and how do they work together?

Many people use Docker every day yet still treat it as a black box — type a command, the container runs, done. Understanding the internal architecture isn't about showing off; it answers very practical questions: why does a container survive a Docker restart? Why are file operations slow on a Mac? What does the "Cannot connect to the Docker daemon" error mean? By the end you'll be able to answer these yourself, and even inspect each layer by hand with commands.

Docker is a client–server system

The first thing to grasp, and also the most misunderstood: the docker command you type in the terminal is not the thing that runs containers. It's just a client sending a request elsewhere.

Docker works on a client–server model with two distinct parts:

  • Docker client (docker): the command-line tool you type. Its only job is to take the command, package it into a request, and send it off. The client itself doesn't know how to create a container.
  • Docker daemon (dockerd): a process that runs continuously in the background on the machine. This is where the real work happens. Per the Docker docs, the daemon "listens for Docker API requests and manages Docker objects such as images, containers, networks, and volumes."

These two parts talk over a REST API. The default transport on Linux is a UNIX socket at /var/run/docker.sock; it can also go over the network (TCP). Because they communicate through a standard API, the client and daemon do not have to be on the same machine — you can perfectly well have the client on your laptop controlling a daemon running on a remote server.

┌──────────────────────────────────────────────────────┐
│  Docker host (machine running the daemon)             │
│                                                        │
│   ┌──────────┐    REST API        ┌───────────────┐    │
│   │  docker  │ ─────────────────► │    dockerd    │    │
│   │ (client) │  over UNIX socket  │   (daemon)    │    │
│   └──────────┘  /var/run/docker.sock └─────────────┘    │
│        ▲                                  │             │
│        └────────── result ◄───────────────┘             │
└──────────────────────────────────────────────────────┘

The first practical consequence: the classic error "Cannot connect to the Docker daemon at unix:///var/run/docker.sock" is almost never because you mistyped the command. It means the client sent a request but nobody answered at the other end of the socket — i.e. dockerd isn't running, or your user lacks permission to read/write that socket (on Linux you usually need to be in the docker group).

Beneath the daemon: containerd and runc

A natural question follows: so does dockerd create the container itself? Not quite. dockerd manages at a high level (API, network, volume, image build), but the actual building and running of a container it delegates down to two lower layers, each with a clearly separated job:

  • containerd: manages the container lifecycle at an intermediate level. It handles pulling images from a registry, managing image storage on disk, then preparing and supervising the running of containers. dockerd commands containerd over its own API (gRPC).
  • runc: the tool that actually creates the container, and does only that. It's a runtime that follows the OCI (Open Container Initiative) standard. runc takes a configuration, uses Linux kernel features — namespaces and cgroups (which we dissect in Article 2) — to build the isolated environment, starts the process inside, and then exits immediately once creation is done. runc does not stick around to watch the container.

So who watches the container after runc exits? A small process called the shim (containerd-shim). For each container, containerd spawns a shim to be the "parent" of the process inside the container. The shim stays for the container's whole lifetime.

   dockerd            ── API, build, network, volume (high level)
      │ gRPC
      ▼
   containerd         ── pull images, manage storage, lifecycle
      │
      ▼
   containerd-shim    ── stays behind to watch the container
      │ calls
      ▼
   runc               ── builds namespaces + cgroups then EXITS (OCI)
      │
      ▼
   [ process inside the container, e.g. nginx ]

This shim is exactly what answers the question: why does restarting the Docker daemon not kill a running container? Because the process inside the container is not a child of dockerd — it's a child of the shim. dockerd (and containerd too) can stop, upgrade, or restart while the shim and its container stay alive. When the daemon comes back up, it reconnects to the existing shims.

Why split into multiple layers instead of bundling everything into dockerd? Because each layer follows a common standard (OCI for the runtime), so it can be replaced independently — for example swapping runc for another runtime of the same standard. Pulling containerd and runc out of Docker also lets other systems reuse them: Kubernetes, for instance, calls containerd directly without needing Docker. In other words, Docker has "decoupled" its container-running core into reusable pieces.

What happens when you type docker run nginx

Putting it all together, here is the full chain of events when you run a container:

$ docker run nginx
        │
  (1)   ▼   client translates the command into an API request, sends it over the socket
   docker ───────────────────────────────────► dockerd
                                                   │
  (2) is the "nginx" image already on the machine? ─── no ─┤
                                                   ▼
  (3)                                         containerd ──pull──► Docker Hub
                                                   │  downloads the layers, assembles them into
                                                   │  a filesystem for the container
                                                   ▼
  (4)                                            runc ──► creates namespaces + cgroups,
                                                   │       mounts the filesystem, runs nginx
                                                   ▼
  (5)                                    containerd-shim ──► watches the nginx process
                                                   │
  (6)         result ◄───────── dockerd ◄──────────┘
        │
        ▼
   container running
  1. The client receives docker run nginx, translates it into an API request, and sends it to dockerd.
  2. dockerd checks whether the nginx image is already on the machine.
  3. If not, it asks containerd to pull the image from the registry (Docker Hub by default), downloads all the layers, then assembles them into a filesystem for the container.
  4. containerd calls runc to create the container: runc builds the namespaces and cgroups, mounts the filesystem, and starts the nginx process.
  5. runc exits; the shim stays behind to watch the process.
  6. The container runs. dockerd returns the result back to the client.

Next time you type docker ps, remember that information also flows along the same client → API → daemon chain, just in the read direction rather than the create direction.

Registry: where images come from and go to

In step (3), the image is pulled from a registry — an image store. By default Docker uses Docker Hub, but it can be a company's private registry, or a service like Amazon ECR or GitHub Container Registry.

The relationship is simple and symmetric: you pull an image from a registry down to your machine to run it, and push an image you've built up to a registry so other machines can fetch it. Article 4 goes deep into images, layers, and registries.

Why Docker on macOS and Windows has an extra virtual machine

A Linux container needs a Linux kernel to run, because runc uses namespaces and cgroups, which are Linux kernel features. On a Linux machine that kernel is already there. But macOS and Windows don't run a Linux kernel.

So Docker Desktop on macOS/Windows quietly stands up a lightweight Linux VM underneath, and dockerd actually runs inside that VM. The docker client on your machine talks to the daemon living in the VM.

   macOS / Windows (your machine)
   ┌────────────────────────────────────────────┐
   │   docker (client)                           │
   │       │                                     │
   │       │  REST API                           │
   │       ▼                                     │
   │  ┌──────────────────────────────────────┐   │
   │  │  lightweight Linux VM (built by      │   │
   │  │   Docker Desktop)                    │   │
   │  │     dockerd → containerd → runc      │   │
   │  │     [ the containers run here ]      │   │
   │  └──────────────────────────────────────┘   │
   └────────────────────────────────────────────┘

Understanding this explains a few behaviors that often confuse beginners:

  • The "disk" the container sees belongs to the Linux VM, not directly to your machine's disk.
  • When you mount a directory from the host into the container (a bind mount, Article 6), files have to cross a sharing layer between the host and the VM — so file access on macOS/Windows is usually noticeably slower than running directly on Linux.
  • The resources a container can use (CPU, RAM) are bounded by what you allocate to the VM in Docker Desktop's settings.

Inspect each layer by hand

That's the theory; now verify it on your machine (Docker must be installed — if not, Article 3 covers it, and you can come back to this section later).

View both the client and the server (daemon) versions — the client/server split shows up right here:

docker version

The output splits into two clear blocks, Client: and Server:. The Server block also lists the versions of containerd and runc — exactly the layers we just discussed.

View detailed information about the daemon:

docker info

Note lines like Server Version, Storage Driver (usually overlay2 — Article 4 covers it), and the containerd version / runc version parts. These are the lower layers actually running.

On a Linux machine, you can see the processes for each layer:

ps -ef | grep -E "dockerd|containerd|runc" | grep -v grep

You'll see dockerd and containerd running in the background. When a container is running, there will also be a shim process for each container. (On macOS/Windows these processes live inside the Linux VM, so they don't show up here.)

Wrap-up

Docker isn't a single monolith. It's a client sending commands over a REST API to the dockerd daemon; the daemon delegates container running down to containerd and then runc, with a shim keeping each container alive independently of the daemon. This multi-layer, OCI-standard design is why the whole container ecosystem — Kubernetes included — can share the same core.

With this architecture in hand, the three questions from the start are answered: restarting the daemon doesn't kill containers thanks to the shim; files are slow on a Mac because of the VM layer in the middle; the "cannot connect" error means the daemon isn't running or you lack socket permissions.

In Article 2 we go down to the lowest layer: exactly how namespaces, cgroups, and union filesystem — the three things runc relies on — turn an ordinary Linux process into a container.