How Docker works
A container is not a small virtual machine. It is a normal Linux process that the kernel gives a private view of the system (namespaces), a budget of memory and CPU (cgroups) and fewer privileges. Switch the pieces on and off and run commands inside to see what each one does.
A new PID namespace: the app is PID 1 and sees only its own processes. On the host the same processes are PIDs 31902 and 31960.
clone(CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWCGROUP | SIGCHLD)
Equivalent docker run options: none (the defaults).
| Namespace | Isolates | Docker |
|---|---|---|
| process IDs | --pid host | |
| interfaces, addresses, ports, routes, firewall | --network host | |
| the mount table and root filesystem | always on | |
| hostname and domain name | --uts host | |
| System V IPC and POSIX message queues | --ipc host | |
| user and group IDs, capabilities | off unless the daemon sets userns-remap | |
| the view of the cgroup tree | --cgroupns host |
Greyed rows are switched off: the container shares that namespace with the host. The last column is the docker run option that turns each one off.
How to use the simulator
What it can see runs commands in a container on a small made-up host with a PostgreSQL server, an SSH daemon and a user's shell. Each output is worked out from the namespaces that are switched on, and the clone() call underneath shows the flags runc would pass.
- Run
pswith the Docker defaults, then switch the PID namespace off: the host's processes appear, with their real PIDs. - Switch the network namespace off and run
ip: the container now has the host's interfaces, as with--network host. - Try
mountandreboot, then turn on--privileged. With the PID namespace still on, reboot only ends the container; with it off, it would reboot the machine.
What it can use applies cgroup limits. Allocate memory until the OOM killer fires, then set a CPU limit of half a CPU with 4 busy threads and see how long a single 30 ms request takes.
What is a container?
A Linux container is a process, or a tree of processes, that the kernel isolates from the rest of the system without running a separate operating system. There is no single "container" object in the Linux kernel. A container runtime combines several independent features: namespaces to give the process its own view of process IDs, network, files and other resources; control groups (cgroups) to limit and measure the memory, CPU and other resources it uses; a root filesystem built from the image's layers; and reduced privileges through capabilities and a seccomp system-call filter.
Because the processes run directly on the host kernel, a container starts as fast as a process and has almost no overhead. The same fact sets the limit of its isolation: everything is enforced by one shared kernel.
Namespaces
A namespace wraps a global resource so that processes inside see their own instance of it. Linux has eight types; Docker uses seven of them, and the user namespace only when the daemon is configured for it:
| Namespace | clone() flag | Isolates | Turned off in Docker with |
|---|---|---|---|
| PID | CLONE_NEWPID | process IDs | --pid host |
| Network | CLONE_NEWNET | interfaces, addresses, ports, routes, firewall | --network host |
| Mount | CLONE_NEWNS | the mount table and root filesystem | always on |
| UTS | CLONE_NEWUTS | hostname and domain name | --uts host |
| IPC | CLONE_NEWIPC | System V IPC and POSIX message queues | --ipc host |
| User | CLONE_NEWUSER | user and group IDs, capabilities | off unless the daemon sets userns-remap |
| Cgroup | CLONE_NEWCGROUP | the view of the cgroup tree | --cgroupns host |
| Time | CLONE_NEWTIME | boot and monotonic clocks | not used by Docker |
Namespaces are created with clone() or unshare(), joined with setns() (which is how docker exec enters a running container), and listed for any process under /proc/PID/ns. Sharing a namespace on purpose is common: Kubernetes puts all containers of a pod in one network namespace so they can talk over localhost, and --pid host lets a debugging container see the host's processes.
What docker run does
The Docker daemon asks containerd to run the container, and containerd starts runc, the reference implementation of the Open Container Initiative runtime specification, with a config.json describing it. runc then:
- creates the new namespaces (with
clone()andunshare()); - creates a cgroup for the container and writes the limits into files such as
memory.max,cpu.maxandpids.max, then moves the process into it; - sets up the mounts: the overlay filesystem assembled from the image layers, plus
/proc,/sysand/dev, and switches root to it withpivot_root(); - drops capabilities to the default set, applies the seccomp filter, sets the user, and calls
execve()on the image's entry point, which becomes PID 1 in the new PID namespace.
The network namespace starts with only a loopback interface. Docker's default bridge network adds one end of a virtual Ethernet (veth) pair to it and attaches the other end to the docker0 bridge on the host, with NAT rules for published ports.
Cgroups
Control groups organize processes in a tree and apply resource controllers to each node. Modern systems use the unified cgroup v2 hierarchy under /sys/fs/cgroup, where a container's limits are plain files.
Memory. memory.max is a hard limit. When the cgroup reaches it the kernel first tries to reclaim memory, such as file cache; if nothing can be reclaimed, the cgroup's OOM killer kills a process in it. With a 256 MB limit and allocations of 64 MB, the program on this page is killed when it asks for 280 MB, and the container exits with status 137, which is 128 plus SIGKILL's number 9. memory.high is a softer limit that throttles and reclaims instead of killing.
CPU. cpu.max holds a quota and a period, by default 100 ms. The cgroup's threads may use at most the quota of CPU time in each period, summed over all CPUs; when it is used up, every thread in the cgroup waits for the next period. Kubernetes turns a CPU limit of 500m into cpu.max = 50000 100000. For a multithreaded program this has a surprising effect on latency:
| Busy threads | 30 ms request, limit 0.5 CPU | Throttled periods | 30 ms request, limit 2 CPUs | Throttled periods |
|---|---|---|---|---|
| 1 | 30 ms | 10 of 10 | 30 ms | 0 of 10 |
| 2 | 105 ms | 10 of 10 | 30 ms | 0 of 10 |
| 4 | 205 ms | 10 of 10 | 30 ms | 10 of 10 |
| 8 | 405 ms | 10 of 10 | 105 ms | 10 of 10 |
With 4 busy threads and half a CPU, the 50 ms quota is used up 12.5 ms into each period, so a request needing 30 ms of CPU is spread over three periods and finishes after 205 ms, even though the container uses only half a CPU on average. This is why services with many threads, such as JVMs and Go programs with a large GOMAXPROCS, see high tail latency under tight CPU limits, and why Go 1.25 began setting GOMAXPROCS from the cgroup's CPU limit. The model assumes enough cores for every thread; the kernel hands out quota in 5 ms slices per CPU, which changes the details but not the shape.
Capabilities, seccomp and user namespaces
Linux splits root's powers into 41 capabilities. Docker keeps 14 by default (CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER, CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE) and drops the rest, including CAP_SYS_ADMIN (mounting, and much else), CAP_SYS_BOOT, CAP_SYS_MODULE and CAP_NET_ADMIN. Its default seccomp profile also blocks around 44 of the 300-plus system calls and allows some, such as mount and reboot, only to processes holding the matching capability.
--privileged gives back every capability, disables seccomp and AppArmor or SELinux confinement and exposes the host's devices; it keeps the namespaces, but a privileged container can usually reach the host. Without a user namespace, which Docker enables only with the daemon's userns-remap setting, UID 0 in a container is UID 0 on the host, so these restrictions are what stand between a root process and the machine. Rootless Docker and Podman run the whole engine inside a user namespace.
Containers and virtual machines
A virtual machine runs its own kernel on virtual hardware provided by a hypervisor; a container shares the host's kernel. Containers therefore start in milliseconds and add little overhead, but a kernel vulnerability reachable from inside a container can compromise the host. Where that matters, such as running untrusted code from many customers, there are middle grounds: gVisor intercepts the container's system calls in a user-space kernel, and Firecracker and Kata Containers wrap each container or function in a lightweight virtual machine.
History
The idea of changing a process's view of the system goes back to chroot in Version 7 Unix (1979), which changes only the root directory. FreeBSD jails (2000) and Solaris Zones (2005) isolated processes more fully. In Linux, mount namespaces arrived in 2002, and the rest followed over the next decade, with user namespaces completed in Linux 3.8 in 2013. Cgroups were developed at Google as "process containers" and merged in Linux 2.6.24 in 2008; cgroup v2 was declared stable in Linux 4.5 in 2016. LXC (2008) combined them into containers, and Docker (2013) made them easy to package and ship with layered images. In 2015 Docker donated its runtime as runc to the new Open Container Initiative, whose image and runtime specifications other tools, including containerd, CRI-O and Podman, also follow.
Container internals questions and answers
What is a Docker container, technically?
An ordinary Linux process (and its children) started with its own namespaces, so it gets a private view of process IDs, network, mounts, hostname and IPC; placed in a cgroup that limits its memory, CPU and process count; given the image’s filesystem as its root; and stripped of most capabilities and many system calls. There is no separate kernel: every container shares the host’s.
What are Linux namespaces?
A kernel feature that gives a group of processes their own instance of a global resource. Linux has eight types: mount, UTS (hostname), IPC, PID, network, user, cgroup and time. A process is created in new namespaces with clone() or moves into them with unshare() or setns(); Docker uses all but the time namespace, and the user namespace only if the daemon is configured for it.
What is the difference between namespaces and cgroups?
Namespaces limit what a process can see; cgroups limit what it can use. A namespace makes the container’s process list or network interfaces its own, while a cgroup caps its memory (memory.max), CPU time (cpu.max), number of processes (pids.max) and I/O, and accounts for what it uses.
Why does my container get CPU throttled when it is not using its full limit?
Because cpu.max is a quota per 100 ms period shared by all the container’s threads. With a limit of half a CPU (50 ms per period) and 4 busy threads, the quota is gone after 12.5 ms of wall time and every thread waits for the next period. A request that needs 30 ms of CPU then takes 205 ms. Average usage still looks like half a CPU.
What does exit code 137 mean in Docker?
137 is 128 + 9: the process was killed with SIGKILL. In a container that usually means the cgroup’s memory limit was hit and the kernel’s OOM killer chose it; memory.events in the cgroup shows the oom_kill count and docker inspect reports OOMKilled true.
Is a container as secure as a virtual machine?
No. A virtual machine has its own kernel behind a hardware boundary; containers share the host kernel, so a kernel bug can be an escape. Docker narrows the attack surface by keeping 14 of the kernel’s 41 capabilities and blocking around 44 system calls with seccomp, and user namespaces make root inside an unprivileged user outside. Sandboxes such as gVisor and microVMs such as Firecracker and Kata Containers add a stronger boundary.
Further reading
- Linux manualnamespaces(7)The eight namespace types and the system calls that create and join them.
- Linux kernel documentationControl Group v2memory.max, memory.events, cpu.max, cpu.stat and the rest of the interface.
- Linux kernel documentationCFS Bandwidth ControlHow CPU quotas, periods and slices work.
- Docker documentationSeccomp security profiles for DockerThe default profile and the system calls it blocks.
- Open Container InitiativeOCI Runtime SpecificationThe config.json that runc and other runtimes execute.
- Michael Kerrisk, LWN, 2013Namespaces in operation, part 1A series walking through each namespace with example programs.