How It Works #
Docker is often perceived as “a magic tool that runs containers”. But there’s no magic inside. What Docker does is exploit Linux kernel features that have existed for a long time — namespaces, cgroups, union filesystems, capabilities, and security modules — then wrap them in a developer-friendly API. The container you run with a single docker run command is actually an ordinary Linux process given specific views and limits by the kernel.
This is the most technical article in the Architecture section. We’ll dissect what actually happens under the hood when you run docker run nginx — from the CLI sending the request, the daemon processing it, the runtime setting up namespaces and cgroups, to the nginx process genuinely alive and appearing as the only process in its own “system”. After reading this article, you’ll never see Docker as a black box again.
Containers at the Kernel Level — Not Virtual Machines #
The first and most important thing to understand: containers are not virtual machines. No hypervisor, no hardware emulation, no boot sequence. What exists is an ordinary process on the host, with virtual boundaries imposed by the kernel.
flowchart TB
subgraph USERSPACE["User Space"]
CLI[docker CLI]
DAEMON[dockerd]
CONTAINERD[containerd]
RUNC[runc]
end
subgraph KERNEL["Linux Kernel"]
NS["Namespaces<br/>(view isolation)"]
CG["cgroups<br/>(resource isolation)"]
UFS["Union Filesystem<br/>(OverlayFS)"]
NET["Network stack<br/>(bridge, veth, iptables)"]
SEC["Security modules<br/>(seccomp, capabilities)"]
end
subgraph HW["Hardware"]
CPU[CPU]
RAM[RAM]
DISK[Disk]
NIC[Network]
end
CLI --> DAEMON --> CONTAINERD --> RUNC
RUNC --> NS
RUNC --> CG
RUNC --> UFS
RUNC --> NET
RUNC --> SEC
NS --> HW
CG --> HW
UFS --> DISK
NET --> NIC
SEC --> CPUThe three main kernel features that form the container foundation:
- Namespaces — make a process feel like it “owns a system”.
- cgroups — limit and measure the resources a process may use.
- Union filesystems — merge many layers into a single mount point.
Plus supporting features:
- Capabilities — trim root privileges (cut capabilities, don’t disable root entirely).
- Seccomp — filter which system calls may be invoked.
- AppArmor / SELinux — mandatory access control (MAC).
Interesting detail: all of these features existed in Linux long before Docker. chroot (1982) was the precursor to union filesystems. The first namespaces (mount) appeared in 2002. cgroups appeared in 2007. Docker (2013) didn’t invent container technology; Docker is the wrapper that made this technology easy for millions of developers to use.
Namespaces — the Illusion of Isolation #
Namespaces are a Linux kernel feature that makes a set of processes see system resources differently from other processes. There are seven namespaces used by modern containers.
The Seven Docker Namespaces #
| Namespace | Isolates | File in /proc | Visual Effect |
|---|---|---|---|
PID | Process IDs | /proc/<pid>/ns/pid | The container sees its own PIDs starting from 1 |
NET | Network interfaces, ports, routing | /proc/<pid>/ns/net | The container has its own virtual NIC |
MNT | Filesystem mount points | /proc/<pid>/ns/mnt | The container sees a different / |
UTS | Hostname and NIS domain | /proc/<pid>/ns/uts | The container can have its own hostname |
IPC | Inter-process communication | /proc/<pid>/ns/ipc | The container can’t signal host processes |
USER | User and group IDs | /proc/<pid>/ns/user | UID 0 in the container = UID 100000 on the host |
CGROUP | View of the cgroup hierarchy | /proc/<pid>/ns/cgroup | The container only sees its own cgroup |
Namespaces in Action #
To see namespaces at work, you can create one manually without Docker at all. Linux provides the unshare tool for this:
# Create a new PID namespace and run a shell inside it
sudo unshare --pid --fork /bin/bash
# Inside the new namespace, PID 1 is this shell
ps aux
# Output: PID 1 is /bin/bash
# No host processes are visible
# Now exit
exit
# Back in the host namespace
ps aux
# All host processes are visible again
The unshare command does the same thing runc does when creating a container: clone a process with new namespace flags, then exec a program in that namespace. A Docker container is basically unshare wrapped in declarative configuration.
Namespaces Are Hierarchical #
Namespaces can be nested: namespaces inside namespaces. That’s what makes rootless Docker possible — the daemon runs in a user namespace, containers inside another namespace.
flowchart TB
HOST["Host PID 1<br/>(init)"]
DAEMON["dockerd<br/>(Host PID 1234)"]
C1A["Container A<br/>PID 1 in its namespace"]
C1B["Container B<br/>PID 1 in its namespace"]
HOST --> DAEMON
DAEMON --> C1A
DAEMON --> C1BFrom the host’s side, the dockerd daemon has an ordinary PID (say 1234). From the container’s side, PID 1234 is mapped to a different PID (or not visible at all unless --pid=host is enabled). Containers A and B each have their own different PID 1 in their respective namespaces, but on the host they’re just different ordinary PIDs.
A common trap: thinking namespaces provide full security. Namespaces provide view isolation, not kernel isolation. If a kernel bug lets a process escape its namespace, an attacker can access host resources. That’s why Docker adds extra security layers (seccomp, AppArmor, capability dropping) on top of namespaces.
cgroups — Resource Limiting #
cgroups (control groups) are a kernel feature that limit, measure, and isolate the resource usage of a set of processes. They’re the counterpart to namespaces: if namespaces isolate what is seen, cgroups isolate how much may be used.
cgroup Controllers #
Each “resource” that can be limited has its own controller. In cgroups v1, each controller is mounted separately; in v2, they’re unified.
| Resource | cgroup v1 | cgroup v2 | Example Docker Flag |
|---|---|---|---|
| CPU | cpu, cpuacct | cpu (unified) | --cpus=1.5 |
| Memory | memory | memory | --memory=512m |
| Block I/O | blkio | io | --device-read-bps |
| Network | net_cls, net_prio | net_cls (via tc) | (no direct flag) |
| PIDs | pids | pids | --pids-limit=100 |
| Devices | devices | devices | --device (whitelist) |
| Freezer | freezer | freezer | (for docker pause) |
How cgroups Work #
Every Linux process is registered in a cgroup hierarchy. The cgroup hierarchy is a tree-like structure on the filesystem (/sys/fs/cgroup/), where each node is a cgroup and each process is a member of one node.
flowchart TB
ROOT["/sys/fs/cgroup/"]
DOCKER["docker/"]
CONTAINER_A["docker/abc123.../"]
CONTAINER_B["docker/def456.../"]
KUBE["kubepods/"]
POD_A["kubepods/pod-xxx/"]
POD_B["kubepods/pod-yyy/"]
ROOT --> DOCKER
ROOT --> KUBE
DOCKER --> CONTAINER_A
DOCKER --> CONTAINER_B
KUBE --> POD_A
KUBE --> POD_BWhen Docker creates a container, it:
- Creates a new directory in
/sys/fs/cgroup/<controller>/docker/<container-id>/. - Writes limit values to configuration files (
memory.max,cpu.max, etc.). - Writes the container PID to the
cgroup.procsortasksfile (depending on version).
The kernel then enforces the written limits: every time a process tries to allocate memory or CPU, the kernel checks the relevant cgroup first and refuses if the limit would be exceeded.
A Concrete Memory Limit Example #
# Create a container with a 256 MB memory limit
docker run -d --memory=256m --name test nginx
# Check the container's cgroup
CONTAINER_PID=$(docker inspect test --format '{{.State.Pid}}')
cat /sys/fs/cgroup/memory/docker/$(docker inspect test --format '{{.Id}}')/memory.limit_in_bytes
# Output: 268435456 (= 256 * 1024 * 1024)
# Check the OOM kill count
cat /sys/fs/cgroup/memory/docker/.../memory.failcnt
# Goes up if the container ever hit OOM
When a container tries to allocate memory beyond the limit, the kernel sends SIGKILL to the process (an Out-Of-Memory kill). There’s no graceful handling at the container level — poorly written applications can crash suddenly.
Best practice: always set--memoryand--cpusin production. Without limits, one container with a memory leak can consume all host RAM and get other containers OOM-killed. In Kubernetes this applies too — setresources.limitsfor all pods.
Union Filesystems — Layered Storage #
Union filesystems (OverlayFS on Linux) are the technology that makes Docker images small and image layers shareable. It’s one of the most underappreciated yet operationally impactful features.
OverlayFS Anatomy #
OverlayFS works by merging two directories into a single mount point:
- Lowerdir — read-only layers (image layers).
- Upperdir — read-write layer (container layer).
- Workdir — internal, for atomic operations.
- Merged — the view users see (lower + upper combined).
flowchart TB
subgraph LOWER["Lowerdir (image, read-only)"]
L1["Layer 4: app code (10 MB)"]
L2["Layer 3: dependencies (50 MB)"]
L3["Layer 2: apt packages (200 MB)"]
L4["Layer 1: base image (77 MB)"]
end
subgraph UPPER["Upperdir (container, read-write)"]
U1["Container layer (5 MB initially)"]
end
subgraph MERGED["Merged view /"]
M1["/ (looks like one complete filesystem)"]
end
LOWER --> MERGED
UPPER --> MERGEDCopy-on-Write #
OverlayFS uses copy-on-write (CoW): when a container wants to modify a file from the lowerdir, the file is first copied to the upperdir, then modified. The original file in the lowerdir stays unchanged.
sequenceDiagram
participant App as App in Container
participant U as Upperdir
participant L as Lowerdir
participant FS as Filesystem view
App->>FS: read /etc/nginx/nginx.conf
FS->>L: read from lowerdir (cache hit)
L-->>FS: file content
FS-->>App: data
App->>FS: write /etc/nginx/nginx.conf
FS->>U: copy file to upperdir
U-->>FS: file copied
FS->>U: write to upperdir
U-->>App: success
Note over L: original file stays intact
Note over U: new file in upperdirCoW is what makes containers start fast: there’s no “disk initialization” — all image layers already exist, the container just needs to set up an empty upperdir and mount everything.
Operational Impact #
| Aspect | Impact |
|---|---|
| Storage | Upperdir is usually small (5–50 MB) because runtime changes are minimal |
| Pull time | Shared image layers are used by many containers → no repetition |
| Build time | Unchanged layers use the cache → incremental builds |
| Backup | Back up only the upperdir for a container state snapshot |
| Performance | CoW has overhead on the first write to large files, but is minimal for reads |
Other storage drivers used by Docker:
- overlay2 — the default on modern Linux, most common.
- btrfs — a copy-on-write filesystem, also supports layers.
- zfs — a volume manager + filesystem combination, popular on FreeBSD/illumos.
- devicemapper — block-level, legacy, not recommended.
How to check the storage driver: docker info | grep "Storage Driver". If it’s still devicemapper, consider migrating to overlay2 (faster, more space-efficient, better support on modern kernels).
Container Networking — How Containers Get an IP #
Every running container has a virtual network interface in its own network namespace. How this interface is created and connected to the host is a topic that often confuses beginners.
Container Networking Components #
flowchart LR
subgraph HOST["Host Network Namespace"]
DOCKER0["docker0 bridge<br/>172.17.0.1/16"]
ETH0["eth0 (host network)"]
IPTABLES["iptables rules"]
end
subgraph NS1["Container A Network Namespace"]
ETH_A["eth0<br/>172.17.0.2/16"]
end
subgraph NS2["Container B Network Namespace"]
ETH_B["eth0<br/>172.17.0.3/16"]
end
VETH_A["vethXXX (host side)"]
VETH_B["vethYYY (host side)"]
ETH_A --- VETH_A
ETH_B --- VETH_B
VETH_A --- DOCKER0
VETH_B --- DOCKER0
DOCKER0 --- ETH0
DOCKER0 --- IPTABLESThe five main components in default-bridge container networking:
- Network namespace — each container has its own namespace for networking.
- veth pair — a virtual ethernet cable connecting two namespaces. One end in the container, one end on the host.
- Bridge (
docker0) — a virtual switch on the host connecting all container veth pairs. - NAT (iptables) — rules translating container traffic to the internet (masquerade).
- Port mapping (DNAT) — rules forwarding traffic from host ports to container ports.
The Container-to-Internet Connection Flow #
sequenceDiagram
participant C as Container (172.17.0.2)
participant V as veth pair
participant B as docker0 bridge
participant I as iptables
participant H as Host eth0
participant W as Web (1.1.1.1)
C->>V: HTTP GET 1.1.1.1 (source: 172.17.0.2)
V->>B: forward to bridge
B->>I: trigger NAT (POSTROUTING)
I->>I: rewrite source IP to host IP (masquerade)
I->>H: forward
H->>W: HTTP request
W-->>H: HTTP response
H-->>I: response arrives
I-->>I: reverse NAT
I-->>V: forward to bridge
V-->>C: HTTP response (dest: 172.17.0.2)Port Mapping — Making a Container Accessible from Outside #
# Publish host port 8080 to container port 80
docker run -d -p 8080:80 nginx
What happens behind the scenes:
# Docker adds an iptables rule:
iptables -t nat -A DOCKER -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
# And in the FORWARD chain:
iptables -A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp --dport 80 -j ACCEPT
Traffic from outside enters host port 8080 → gets DNAT-ed to 172.17.0.2:80 (the container) → forwarded into the container. The original source IP is lost (replaced with the bridge IP) unless you use host network mode.
Custom Bridges — Automatic DNS #
docker network create my-net
docker run -d --name api --network my-net myapi
docker run -d --name db --network my-net postgres
On a custom network, containers can refer to each other by name (api, db) instead of IP. Docker runs an embedded DNS server that resolves container names to their IPs. Automatic DNS only works on custom networks — on the default bridge, containers must resolve IPs via external DNS (or use the now-deprecated --link). Always use a custom network for multi-container setups.
Security Layers — Containers Aren’t a Perfect Sandbox #
Containers provide isolation that’s good enough for most use cases, but not a perfect sandbox like VMs. For production, you need to add several security layers on top of the default namespace isolation.
The Five Container Security Layers #
flowchart TB
L0["Hardware/kernel"]
L1["L1: Namespaces (view isolation)"]
L2["L2: cgroups (resource isolation)"]
L3["L3: Capabilities (trim privileges)"]
L4["L4: Seccomp (syscall filter)"]
L5["L5: AppArmor/SELinux (MAC)"]
L0 --> L1 --> L2 --> L3 --> L4 --> L51. Namespaces — View Isolation (On by Default) #
Gives the container its own “view” of PIDs, network, filesystem, etc. This is the foundation, automatically enabled.
2. cgroups — Resource Isolation (On by Default) #
Limits CPU, memory, I/O. No limits by default — you must set them explicitly.
3. Linux Capabilities — Trimming Privileges #
Linux capabilities break root rights into many small abilities. Default containers still hold many capabilities they don’t actually need.
# Drop all capabilities, add back only what's needed
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
# The container can still bind ports < 1024 (NET_BIND_SERVICE)
# But can't mount filesystems, load modules, etc.
# ANTI-PATTERN: run with all capabilities
docker run --privileged nginx
# The container has FULL ACCESS to the host (all capabilities,
# access to /dev, access to kernel modules, etc.)
# Only for very specific cases (Docker-in-Docker, etc.)
# CORRECT: drop all, add back only what's needed
docker run --cap-drop=ALL --cap-add=CHOWN --cap-add=NET_BIND_SERVICE nginx
4. Seccomp — System Call Filtering #
Seccomp (Secure Computing Mode) limits which system calls a container may invoke. Docker ships a default seccomp profile that allows common syscalls and denies dangerous ones.
# Check the active default profile
docker info | grep "Security Options"
# Output: seccomp
# Profile: builtin (Docker default)
# Run with a custom profile
docker run --security-opt seccomp=/path/to/profile.json nginx
A seccomp profile is JSON listing which syscalls are allowed, denied, or trapped. Example snippet:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "exit", "exit_group", "open", "close"],
"action": "SCMP_ACT_ALLOW"
}
]
}
5. AppArmor / SELinux — Mandatory Access Control #
AppArmor (Debian/Ubuntu) and SELinux (RHEL/CentOS/Fedora) are mandatory access control (MAC) systems that add policy files on top of traditional Unix controls. They provide an extra security layer by explicitly defining what each binary is allowed to do.
# AppArmor profile for a container
docker run --security-opt apparmor=docker-default nginx
# SELinux label
docker run --security-opt label=type:container_t nginx
Be careful: enabling SELinux on the host but not handling containers properly often causes confusing permission problems. If you run RHEL/CentOS with SELinux enforced, learn the container labels (:Zand:zfor bind mounts) or you’ll spend hours debugging mysterious “permission denied” errors.
Comparison with VM Security #
| Aspect | Container + hardening | Virtual Machine |
|---|---|---|
| Kernel attack surface | Shared with the host (risk) | Separate per VM (safe) |
| Defense in depth | Needs explicit configuration | Built-in via hypervisor |
| Exposure surface | Small (minimal image) | Large (full OS) |
| CVE patching | Patch host = patch all containers | Patch per VM |
| Blast radius if compromised | Container root (possibly host root) | Separate VM |
Containers with proper hardening (rootless, read-only FS, minimal capabilities, seccomp, AppArmor) are secure enough for multi-tenant production. But they require effort to set up; VMs are secure by default (via the hypervisor).
The Complete Flow: What Happens During docker run
#
Now let’s summarize everything above by tracing one command end-to-end: docker run -d -p 8080:80 --memory=256m --cpus=0.5 --name web nginx:latest.
sequenceDiagram
participant U as Terminal
participant CLI as docker CLI
participant D as dockerd
participant CD as containerd
participant RC as runc
participant K as Linux Kernel
participant R as Registry
U->>CLI: docker run -d -p 8080:80 --memory=256m --cpus=0.5 --name web nginx:latest
CLI->>D: POST /containers/create<br/>(name, image, port, limits)
D->>D: validate parameters
D->>R: HEAD /v2/nginx/manifests/latest
R-->>D: 200 OK (manifest info)
D->>D: check local image cache
alt image not present
D->>R: GET manifest + layers
R-->>D: manifest + layer blobs
D->>D: extract layers to image storage
end
D->>D: generate container ID
D->>D: set up cgroup dir + write memory/CPU limits
D->>D: set up iptables rule (DNAT 8080→80)
D->>D: create veth pair (ready to attach when the container starts)
D->>CD: CreateContainer (gRPC)
CD->>CD: snapshot writable layer (OverlayFS)
CD->>CD: prepare OCI bundle (config.json, rootfs)
CD->>CD: set up cgroup v2 entries
CD->>RC: fork+exec runc
RC->>K: clone(CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWUSER)
K-->>RC: new namespace created
RC->>K: mount overlay filesystem
RC->>K: pivot_root to the container rootfs
RC->>K: set up network namespace (move veth inside)
RC->>K: apply seccomp profile
RC->>K: apply capability drops
RC->>K: execve nginx -g daemon off
K-->>RC: nginx process running (PID 1 in its namespace)
RC-->>CD: container started
CD-->>D: success
D-->>CLI: container ID
CLI-->>U: print container IDThe eight stages that happen:
CLI parse & forward — the
dockerCLI parses the flags, builds a JSON request per the Docker REST API, and sends it to the daemon via/var/run/docker.sock.Daemon validation —
dockerdreceives the request and validates the parameters (port conflict checks, valid image name, etc.).Image resolution — the daemon checks the local cache. If absent, it pulls the manifest and layers from the default registry (Docker Hub) via the Docker Registry HTTP API v2.
Container spec — the daemon assembles the complete container specification: which image, what command, which env vars, port mappings, resource limits, security options.
Resource setup — the daemon creates a cgroup entry in
/sys/fs/cgroup/<controller>/docker/<id>/and writes the limits. For networking, the daemon creates a veth pair and iptables rules.Runtime delegation — the daemon forwards the spec to
containerd(gRPC), which prepares the OCI bundle (config.json, rootfs directory).containerdthen invokesrunc(fork+exec).Namespace creation —
runcperforms theclone()system call with namespace flags (CLONE_NEWNS,CLONE_NEWPID,CLONE_NEWNET, etc.). The kernel creates new namespaces and the child process is born inside them.Container start — inside the new namespaces,
runcmounts the overlay filesystem, pivot_roots into the container rootfs, applies seccomp + capabilities, thenexecve("/nginx"). Thenginxprocess becomes PID 1 in its namespace. Port 80 in the container’s namespace is bridged to port 8080 on the host via iptables DNAT.
All of this happens in seconds (milliseconds for cached images). No OS boot sequence, no init system, no hypervisor. A container is a process carefully prepared by the kernel.
The Seven Working Principles of Docker #
Now that we’ve dissected every component, let’s summarize in principles that explain why Docker works the way it does.
flowchart LR
P1["1. Containers are<br/>ordinary Linux processes"]
P2["2. Isolation = namespaces<br/>+ cgroups, not VMs"]
P3["3. Images = layered<br/>filesystems (read-only)"]
P4["4. Containers = image<br/>+ writable layer (CoW)"]
P5["5. Networking = veth<br/>+ bridge + iptables"]
P6["6. Security = layered<br/>(ns, cgroup, cap, seccomp, MAC)"]
P7["7. OCI standard =<br/>interchangeable runtimes"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7A way to remember: Docker isn’t a “mini virtual machine” — Docker is a Linux kernel orchestra given a nice API. All the features that make containers “feel different” (isolation, persistence, networking) are actually long-standing Linux kernel features. Docker just makes them accessible to developers.
Summary #
- Containers are ordinary Linux processes with special namespaces, cgroups, and filesystem views. No hypervisor, no boot sequence, no VM. That’s what makes containers start in milliseconds–seconds, not minutes.
- Namespaces provide view isolation: PID (the container sees its own PIDs), NET (virtual NIC), MNT (its own filesystem), UTS (hostname), IPC (can’t signal the host), USER (UID mapping), CGROUP (cgroup view). Seven namespaces working together create the illusion of a “system of its own”.
- cgroups provide resource isolation: CPU, memory, I/O, PIDs, devices. Each controller is mounted on a pseudo-filesystem, and limits are written as files. The kernel enforces limits in real time. Always set
--memoryand--cpusin production.- Union filesystems (OverlayFS) merge many image layers (read-only) with the container layer (read-write) into one mount point. Copy-on-write copies runtime changes to the upperdir; original files in the lowerdir stay intact. That’s what makes images small, builds fast, and containers efficient.
- Networking is built from veth pairs (virtual cables), bridges (virtual switches), and iptables (NAT/port mapping). The default bridge (
docker0) connects all containers on the host. Custom networks add automatic DNS and isolation.- Layered security: namespaces (view) + cgroups (resource) + capabilities (trim privileges) + seccomp (syscall filter) + AppArmor/SELinux (MAC). Containers with hardening are secure enough for production; without hardening, they’re root-equivalent on the host.
- The
docker runflow goes through 8 stages: CLI parse → daemon validation → image resolution → spec → resource setup → runtime delegation → namespace creation → container start. All at the kernel level, no hypervisor.- The OCI standard makes runtimes interchangeable:
runccan be swapped forcrun(faster) oryouki(Rust). Docker images run on containerd, Podman, or K8s CRI-O without modification.- Containers aren’t a perfect sandbox. For multi-tenancy, strict compliance, or workloads needing hardware isolation, consider VMs. For most cloud-native workloads, containers with hardening are the right choice.