πŸ“š Learning Hub
Β· 7 min read

How Docker Containers Actually Work β€” Namespaces, Cgroups, and Layers


Every Docker tutorial starts the same way: β€œContainers are like lightweight VMs.” That’s a useful lie to get started, but it falls apart the moment you need to debug a networking issue, understand why your image is 2 GB, or figure out why a process inside a container can still see the host’s clock.

Containers are not VMs. They’re isolated processes running on the host kernel, constrained by a handful of Linux primitives. If you’ve ever wondered what Docker actually is, this post tears the lid off the engine.

Containers vs VMs β€” The Real Difference

A virtual machine runs a full guest operating system on emulated hardware, managed by a hypervisor. Every VM carries its own kernel, its own init system, its own memory management.

A container is a regular Linux process (or group of processes) that the kernel has been told to isolate and constrain. There is no guest kernel. There is no hypervisor. The container shares the host’s kernel directly.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Virtual Machines                    β”‚
β”‚                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”‚
β”‚  β”‚  App A   β”‚  β”‚  App B   β”‚  β”‚  App C   β”‚      β”‚
β”‚  β”‚  Bins/Libsβ”‚  β”‚  Bins/Libsβ”‚  β”‚  Bins/Libsβ”‚     β”‚
β”‚  β”‚  Guest OS β”‚  β”‚  Guest OS β”‚  β”‚  Guest OS β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚             Hypervisor                    β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚             Host OS + Kernel              β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Containers                          β”‚
β”‚                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”‚
β”‚  β”‚  App A   β”‚  β”‚  App B   β”‚  β”‚  App C   β”‚      β”‚
β”‚  β”‚  Bins/Libsβ”‚  β”‚  Bins/Libsβ”‚  β”‚  Bins/Libsβ”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚          Container Runtime                β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚          Host OS + Kernel (shared)        β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

This is why containers start in milliseconds instead of minutes. No OS to boot. No hardware to emulate. Just fork a process and apply constraints.

Linux Namespaces β€” The Isolation Layer

Namespaces are the kernel feature that makes a process think it’s alone on the machine. Each namespace type isolates a different resource:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Linux Namespaces                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  PID     β”‚  Process IDs β€” container sees PID 1      β”‚
β”‚  NET     β”‚  Network stack β€” own interfaces, IPs     β”‚
β”‚  MNT     β”‚  Mount points β€” own filesystem view      β”‚
β”‚  UTS     β”‚  Hostname β€” container gets its own name   β”‚
β”‚  IPC     β”‚  Inter-process comms β€” isolated queues    β”‚
β”‚  USER    β”‚  User/group IDs β€” root inside β‰  root out  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

PID namespace is the most visible one. The main process inside a container always sees itself as PID 1. On the host, that same process has a normal PID like 48291. Run ps aux on the host and you’ll see your containerized process sitting right there alongside everything else.

NET namespace gives each container its own network stack β€” its own eth0, its own IP address, its own routing table. Docker creates a virtual bridge (docker0) and connects each container’s virtual interface to it. That’s how containers talk to each other and to the outside world.

MNT namespace controls what the filesystem looks like. The container sees the image’s filesystem as its root /, not the host’s. Bind mounts and volumes punch holes through this isolation when you need them.

USER namespace is the security-critical one. A process can be root (UID 0) inside the container but map to an unprivileged user (say UID 100000) on the host. This means even if an attacker escapes the container, they land as a nobody.

None of this is Docker-specific. These are kernel primitives that have existed since Linux 3.8 (2013). Docker just made them usable.

Cgroups β€” The Resource Limits

Namespaces handle isolation. Cgroups (control groups) handle resource limits. Without cgroups, a container could consume all the host’s CPU and memory, starving everything else.

Cgroups let you set hard limits on:

  • CPU β€” how many cores or what percentage of CPU time a container gets
  • Memory β€” hard cap; exceed it and the OOM killer terminates the process
  • Disk I/O β€” throttle read/write bandwidth
  • PIDs β€” maximum number of processes (prevents fork bombs)

When you run docker run --memory=512m --cpus=1.5, Docker creates a cgroup for that container and writes those limits to the cgroup filesystem (usually under /sys/fs/cgroup/). The kernel enforces them transparently.

This is also how orchestrators like Kubernetes schedule workloads β€” they read cgroup limits to understand resource requests and allocations across nodes.

Union Filesystems and Layers β€” Why Images Are Layered

Docker images aren’t monolithic disk snapshots. They’re stacks of read-only layers, assembled using a union filesystem (commonly OverlayFS).

Every instruction in a Dockerfile creates a new layer:

FROM ubuntu:22.04          # Layer 1: base OS files
RUN apt-get install nginx  # Layer 2: nginx + dependencies
COPY app/ /var/www/html/   # Layer 3: your application code

These layers stack on top of each other:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Container Layer (read-write)   β”‚  ← runtime changes go here
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Layer 3: COPY app/             β”‚  (read-only)
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Layer 2: RUN apt-get install   β”‚  (read-only)
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Layer 1: FROM ubuntu:22.04     β”‚  (read-only)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The union filesystem merges all layers into a single coherent view. When a container writes a file, it goes into a thin read-write layer on top. The image layers below are never modified β€” this is called copy-on-write.

Why does this matter?

  • Sharing: Ten containers from the same image share all the read-only layers. Only the thin writable layer is unique per container.
  • Caching: If Layer 1 and Layer 2 haven’t changed, Docker reuses them from cache during builds. Only Layer 3 gets rebuilt.
  • Distribution: When you push an image, only layers the registry doesn’t already have get uploaded.

This is why Dockerfile ordering matters. Put things that change rarely (OS, dependencies) at the top and things that change often (your code) at the bottom.

The Docker Daemon and Architecture

Docker uses a client-server architecture:

  • docker CLI β€” the command you type. It sends API requests.
  • dockerd (the daemon) β€” receives those requests, manages images, containers, networks, and volumes.
  • containerd β€” a lower-level container runtime that dockerd delegates to. It manages the container lifecycle.
  • runc β€” the actual binary that creates the container by calling the kernel’s namespace and cgroup APIs.

The chain looks like: docker CLI β†’ dockerd β†’ containerd β†’ runc β†’ your process.

This layered design exists because of the OCI (Open Container Initiative) standard. OCI defines two specs: an image format and a runtime spec. Any OCI-compliant runtime (runc, crun, gVisor, Kata) can run OCI images. This is why the same image you build with Docker runs on Podman, Kubernetes, or any other OCI-compatible platform.

What docker run Actually Does β€” Step by Step

When you type docker run -d -p 8080:80 nginx, here’s the sequence:

  1. Image pull β€” Docker checks if the nginx image exists locally. If not, it pulls layers from the registry (Docker Hub by default).

  2. Layer assembly β€” The union filesystem stacks all image layers and adds a writable container layer on top.

  3. Namespace creation β€” The kernel creates new PID, NET, MNT, UTS, IPC, and USER namespaces for the container.

  4. Cgroup setup β€” A new cgroup is created with any resource limits you specified (or defaults).

  5. Network plumbing β€” Docker creates a virtual ethernet pair (veth), attaches one end to the container’s NET namespace and the other to the docker0 bridge. It assigns an IP address and sets up iptables rules for the -p 8080:80 port mapping.

  6. Root filesystem pivot β€” The container’s MNT namespace gets the assembled layer stack as its root filesystem via pivot_root.

  7. Process execution β€” runc calls clone() with the appropriate namespace flags, applies cgroup limits, and execs the container’s entrypoint (in this case, the nginx binary).

  8. Detach β€” The -d flag means Docker returns control to your terminal while the process runs in the background.

The whole thing takes a fraction of a second. No kernel to boot, no BIOS, no hardware emulation.

Why Containers Are Fast

The performance story comes down to what containers don’t do:

  • No hypervisor overhead β€” no hardware emulation, no trap-and-emulate cycle
  • Shared kernel β€” system calls go directly to the host kernel, not through a virtualized layer
  • No OS boot β€” the container process starts immediately; there’s no init sequence
  • Minimal memory overhead β€” shared image layers mean ten containers don’t need ten copies of Ubuntu in RAM
  • Native I/O β€” disk and network operations hit real hardware through the real kernel

The tradeoff is weaker isolation compared to VMs. Containers share a kernel, so a kernel exploit can potentially affect all containers on the host. For most workloads, this tradeoff is worth it. For multi-tenant or high-security environments, you might combine containers with lightweight VMs (like Kata Containers or Firecracker).

Putting It Together

Docker didn’t invent any of the underlying technology. Namespaces, cgroups, and union filesystems existed before Docker. What Docker did was package them into a workflow that developers could actually use β€” a Dockerfile, a CLI, a registry, and a daemon that wires everything together.

Understanding these internals pays off when you need to debug networking issues, optimize image sizes, set resource limits in production, or evaluate whether to run something like Ollama in a container. It also makes tools like Docker Compose less magical β€” you can see that a compose file is just a declaration of which namespaces, cgroups, networks, and volumes to create.

Containers are processes with boundaries. Once you internalize that, everything else clicks.

πŸ“˜