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 --> CPU

The three main kernel features that form the container foundation:

  1. Namespaces — make a process feel like it “owns a system”.
  2. cgroups — limit and measure the resources a process may use.
  3. 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 #

NamespaceIsolatesFile in /procVisual Effect
PIDProcess IDs/proc/<pid>/ns/pidThe container sees its own PIDs starting from 1
NETNetwork interfaces, ports, routing/proc/<pid>/ns/netThe container has its own virtual NIC
MNTFilesystem mount points/proc/<pid>/ns/mntThe container sees a different /
UTSHostname and NIS domain/proc/<pid>/ns/utsThe container can have its own hostname
IPCInter-process communication/proc/<pid>/ns/ipcThe container can’t signal host processes
USERUser and group IDs/proc/<pid>/ns/userUID 0 in the container = UID 100000 on the host
CGROUPView of the cgroup hierarchy/proc/<pid>/ns/cgroupThe 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 --> C1B

From 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.

Resourcecgroup v1cgroup v2Example Docker Flag
CPUcpu, cpuacctcpu (unified)--cpus=1.5
Memorymemorymemory--memory=512m
Block I/Oblkioio--device-read-bps
Networknet_cls, net_prionet_cls (via tc)(no direct flag)
PIDspidspids--pids-limit=100
Devicesdevicesdevices--device (whitelist)
Freezerfreezerfreezer(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_B

When Docker creates a container, it:

  1. Creates a new directory in /sys/fs/cgroup/<controller>/docker/<container-id>/.
  2. Writes limit values to configuration files (memory.max, cpu.max, etc.).
  3. Writes the container PID to the cgroup.procs or tasks file (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 --memory and --cpus in 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 — set resources.limits for 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 --> MERGED

Copy-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 upperdir

CoW 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 #

AspectImpact
StorageUpperdir is usually small (5–50 MB) because runtime changes are minimal
Pull timeShared image layers are used by many containers → no repetition
Build timeUnchanged layers use the cache → incremental builds
BackupBack up only the upperdir for a container state snapshot
PerformanceCoW 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 --- IPTABLES

The five main components in default-bridge container networking:

  1. Network namespace — each container has its own namespace for networking.
  2. veth pair — a virtual ethernet cable connecting two namespaces. One end in the container, one end on the host.
  3. Bridge (docker0) — a virtual switch on the host connecting all container veth pairs.
  4. NAT (iptables) — rules translating container traffic to the internet (masquerade).
  5. 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 --> L5

1. 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 (:Z and :z for bind mounts) or you’ll spend hours debugging mysterious “permission denied” errors.

Comparison with VM Security #

AspectContainer + hardeningVirtual Machine
Kernel attack surfaceShared with the host (risk)Separate per VM (safe)
Defense in depthNeeds explicit configurationBuilt-in via hypervisor
Exposure surfaceSmall (minimal image)Large (full OS)
CVE patchingPatch host = patch all containersPatch per VM
Blast radius if compromisedContainer 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 ID

The eight stages that happen:

  1. CLI parse & forward — the docker CLI parses the flags, builds a JSON request per the Docker REST API, and sends it to the daemon via /var/run/docker.sock.

  2. Daemon validationdockerd receives the request and validates the parameters (port conflict checks, valid image name, etc.).

  3. 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.

  4. Container spec — the daemon assembles the complete container specification: which image, what command, which env vars, port mappings, resource limits, security options.

  5. 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.

  6. Runtime delegation — the daemon forwards the spec to containerd (gRPC), which prepares the OCI bundle (config.json, rootfs directory). containerd then invokes runc (fork+exec).

  7. Namespace creationrunc performs the clone() 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.

  8. Container start — inside the new namespaces, runc mounts the overlay filesystem, pivot_roots into the container rootfs, applies seccomp + capabilities, then execve("/nginx"). The nginx process 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 --> P7

A 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 --memory and --cpus in 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 run flow 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: runc can be swapped for crun (faster) or youki (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.

← Previous: Registry   Next: File System & Layer →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact