Docker Architecture: Under the Hood
[!IMPORTANT] Docker is not a monolithic application. It is a Client-Server system composed of multiple specialized binaries.
1. The Big Picture
When you run docker run nginx, you are not actually running a container. You are talking to a daemon that runs the container for you.
Interactive: The Request Lifecycle Visualizer
Click “Send Request” to trace the journey of a docker run command through the system components.
2. The Components Explained
1. The Client (docker)
The Docker CLI is just a REST client. It does nothing related to running containers itself. It converts your commands into HTTP requests and sends them to the Docker Daemon.
2. The Daemon (dockerd)
The persistent background process.
- API Server: Listens on
/var/run/docker.sock(Unix socket) or a TCP port. - Orchestrator: Manages higher-level objects like Networks, Volumes, and Swarm mode.
- It does not manage container execution anymore. It delegates that to
containerd.
3. containerd
A CNCF graduated project. It is an industry-standard container runtime manager.
- Job: Manages the complete container lifecycle (Image pull, storage, execution, supervision).
- Why split it out?: So that other tools (like Kubernetes) can use
containerddirectly without the overhead of the full Docker daemon.
4. runc
The low-level OCI (Open Container Initiative) runtime.
- Job: Talk to the Linux Kernel to create namespaces and cgroups.
- It is a short-lived process. It spawns the container and then exits.
5. containerd-shim
If runc exits, who watches the container? The shim.
- It sits between
containerdand the container process. - It keeps the standard input/output (STDIO) streams open.
- It reports the exit status back to
containerd. - Crucial Feature: It allows “daemonless containers”. You can restart the Docker daemon (upgrade it) without killing your running containers because the shim owns them, not the daemon.
3. Interacting with the API (Go Example)
Since Docker is just an API server, we can talk to it programmatically using Go. This is how tools like Kubernetes or CI pipelines control containers.
We use the official SDK: go get github.com/docker/docker/client
package main
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
)
func main() {
// 1. Create a client connected to the local docker socket
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
panic(err)
}
// 2. List running containers (equivalent to `docker ps`)
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{})
if err != nil {
panic(err)
}
for _, container := range containers {
fmt.Printf("ID: %s | Image: %s | State: %s\n",
container.ID[:10], container.Image, container.State)
}
}
[!TIP] This code bypasses the
dockerCLI entirely and talks directly todockerdvia the Unix socket.
Why does this matter?
Understanding this separation explains why:
- Security: The daemon runs as
root, so access to the socket is equivalent to root access. - Stability: You can restart Docker without killing containers (thanks to
shim). - Performance:
containerdis highly optimized for starting containers quickly.