Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Theory: Docker architecture

Docker uses a client-server architecture. The tool you type commands into (the client) is separate from the process that actually runs containers (the daemon). They communicate over a REST API, which means the client and daemon do not have to be on the same machine.

Full architecture

flowchart TB
    subgraph DC["Docker Client"]
        CLI["docker CLI"]
    end
    subgraph DE["Docker Engine"]
        D["dockerd (Docker Daemon)"]
        C["containerd"]
        S["containerd-shim"]
        R["runc"]
    end
    REG[("Registry: Docker Hub / private")]
    K["Linux kernel: namespaces + cgroups"]

    CLI -->|"REST API"| D
    D <-->|"push / pull"| REG
    D --> C
    C --> S
    S --> R
    R --> K

Component roles

  • docker CLI is the client you type commands into. It sends requests to the daemon over a REST API, either on the same machine via a Unix socket or over the network.
  • dockerd (Docker daemon) receives those requests and manages images, containers, networks, and volumes. It exposes the Docker Engine API.
  • Registry stores and serves images. Docker Hub is the public default. Teams use private registries (AWS ECR, GitHub Container Registry, and others) to control who can push and pull. docker pull downloads from the registry; docker push uploads to it.
  • containerd handles the container lifecycle on behalf of the daemon. It pulls image layers, manages container state, and delegates process creation.
  • containerd-shim keeps each container process running independently from the daemon so a daemon restart does not kill running containers.
  • runc creates the container by calling into the Linux kernel. It sets up namespaces and cgroups and starts the application process.
  • Linux kernel enforces isolation (namespaces: what the process can see) and resource limits (cgroups: how much CPU and memory it can use).

What happens on docker run

  1. You type docker run. The CLI sends a request to dockerd via REST API.
  2. dockerd checks the local image store. If the image is not there it pulls it from the registry.
  3. dockerd calls containerd to create the container.
  4. containerd calls runc via a shim. runc asks the kernel to set up namespaces and cgroups, then starts your process.

What happens on docker build and push

docker build sends your Dockerfile and files to the daemon, which creates image layers and stores the result locally. docker push then uploads those layers to the registry so other machines can pull them.

who forks/spawns whom: runtime flow, and namespace

  • dockerd delegates -> containerd forks a shim per container -> shim spawns runc for setup -> runc forks+execs the real process into new namespaces, then exits -> shim becomes the process’s real parent.

Also there is runc which existing in the image (which runc works) just means the binary is there for containerd-shim to invoke each time a container starts — it doesn’t mean it stays running.

docker CLI (user)  ->  dockerd s ->  containerd  ->  containerd-shim  ->  runc  ->  actual container process

dockerd and containerd

systemd (PID 1)
 ├─ dockerd            (started as a systemd service)
 ├─ containerd         (also its own systemd service — NOT a child of dockerd)
 │   └─ containerd-shim   (orphaned from containerd, reparented straight to systemd, since systemd is also a subreaper)
 │       └─ actual container process   (reparented to the shim once runc exits)
 └─ bash → docker (CLI)   (your terminal shell — a completely separate branch, no relation to dockerd)

Key points:
- dockerd and containerd are two separate systemd services — systemd starts (forks) both directly at boot; they're not parent/child of each other.
- docker CLI is a child of your shell, in a totally separate branch of the tree from dockerd/containerd — it's just a client that connects, sends a request, and exits.
- The shim/container-process orphan-reparent trick is the same as in the Codespace — there it was docker-init (PID 1 + subreaper); on real Linux it's systemd (PID 1) doing that job.

Lab: “a container is just a namespace” (host vs container split-pane)

Cleanest proof: /proc/<pid>/status has an NSpid line - same kernel process, shown with two different PID numbers depending which PID namespace you’re viewing it from. Uses a fresh, minimal container, not the lab target.

  • Setup - one tiny container, just sleeping**
docker run -d --name nsdemo busybox sleep 3600

Left pane = host

# 1. get the container's real (host-side) PID
HOSTPID=$(docker inspect --format '{{.State.Pid}}' nsdemo)
echo "Host PID: $HOSTPID"

# 2. the punchline - same process, two PID views
cat /proc/$HOSTPID/status | grep NSpid
# e.g. NSpid:  7340  1
#            ^host    ^inside container's own PID namespace

# 3. proof it's just a normal child in the host's own process tree, not a VM
pstree -ps $HOSTPID
# containerd-shim -> sleep

Right pane = container

# 4. get in
docker exec -it nsdemo sh

# 5. same PID 1, viewed from inside
cat /proc/1/status | grep NSpid
# NSpid:  1

Explaination: same process, but the number of PIDs shown depends on who’s looking. From the host (an ancestor namespace) you see both: 7340 1. From inside the container you only ever see your own view: 1 - you cannot see your own host-side PID from in there. That one-sided visibility gap is PID namespace isolation. No VM, no separate kernel - just this one Linux feature creating the “container” illusion.

Also we saw containerd-shim is sibling of containerd. Because containerd forks containerd-shim but intentionally detach/orphan’s it (to create a new session, double-fork). Why? Because if containerd crash/restart/upgrade , then also our container keeps running.

Cleanup

docker rm -f nsdemo

Summary:

Run time (when you type docker run) — dockerd and conatinerd already running from fork of systemd at the start of docker. It's just API calls, until the point a real container actually gets created:

1. docker CLI         --launches as a child of your shell--   just sends an HTTP request over docker.sock
2. dockerd            --already running (since boot)--        receives it, forks NOTHING itself,
                                                               just sends a gRPC request over containerd.sock
3. containerd         --already running (since boot)--        THIS is where the real FORK happens:
                                                               containerd forks a new "containerd-shim" process
                                                               (one new shim per container)
4. shim               --just created--                         SPAWNS runc as a subprocess
5. runc(calls unshare) --short-lived--                          forks a new process, builds namespaces/cgroups,
                                                               execs your actual command, then exits itself
6. container process --becomes a child of the shim--          this is your actual running container

Note: Once runc (short lived) exits, the container’s actual process gets reparented straight to the shim (shim set itself as a subreaper, so it catches its own orphaned grandchild).

Still confused , watch How Docker Works - Intro to Namespaces by liveoverflow

Relevance to security

  • Misconfiguration can enter at the CLI (--privileged, volume mounts), in dockerd settings, or in daemon.json on the host.
  • The registry is on the trust path. Whoever controls push access to a registry can affect what every downstream host pulls and runs.
  • containerd and runc bugs have been part of past container escape incidents. Patching the host runtime matters as much as patching the app image.

Next: Lab: Dockerfile static analysis.