DockerPublished 11/13/2025

How Docker actually works under the hood: namespaces, cgroups, and union filesystems

Starting from the common misconception that a container is just a lightweight VM, this post breaks down the three Linux kernel technologies Docker actually depends on — how a container gets isolated, how its resources get capped, and how its filesystem gets layered together.

Most people's first mental model of Docker is "a lighter version of a VM." That's fine for day-to-day use — a container you spin up with docker run really does feel like its own little machine, with its own filesystem, its own network, its own processes. But push on that analogy for more than a minute and you hit a wall: if it's a virtual machine, where's the hypervisor? Where's the virtualized CPU and memory?

There isn't one. A container was never virtualized in the first place — it's just an ordinary Linux process on the host, wrapped in three pieces of kernel technology that make it believe it's living in its own isolated world. This post pulls that wrapping apart and looks at what namespaces, cgroups, and union filesystems are each actually doing.

First, let's kill the VM comparison

A VM's isolation happens at the hardware layer. A hypervisor emulates an entire set of virtual hardware — CPU, memory, disk — and each VM boots a full, independent kernel on top of it. That's exactly why spinning up a VM takes tens of seconds or even minutes, and why each one eats hundreds of megabytes of memory: it's carrying around its own copy of the kernel.

A container's isolation happens at the OS layer instead. Every container on a host shares that host's single Linux kernel — nothing gets duplicated. Strip away the abstraction and a container is just a specially-treated process; run ps aux on the host and you'll actually see it sitting there. It starts in under a second and barely uses any extra resources precisely because it skips the entire "boot a separate kernel" step.

So here's the obvious follow-up question: if containers all share the same kernel, how do they stay blind to each other and unable to touch each other? That's what namespaces are for.

Namespaces: convincing a process it's alone in the world

A namespace is a kernel mechanism that partitions system resources — the process list, network interfaces, filesystem mount points, and so on — into isolated views. Processes living in different namespaces on the same host see completely different sets of resources, even though under the hood they're all sharing the exact same kernel.

Docker leans on a handful of these:

  • PID namespace: gives processes inside a container their own independent PID numbering. The first process inside a container gets PID 1, but from the host's point of view, it's just an ordinary process with some large, unremarkable PID.
  • Network namespace: gives a container its own network interfaces, IP address, and routing table, separate from the host's network stack.
  • Mount namespace: gives a container its own view of filesystem mount points, hiding whatever else is mounted on the host.
  • UTS namespace: lets a container have its own hostname without colliding with the host or other containers.
  • IPC namespace: isolates inter-process communication resources (semaphores, message queues, etc.) so processes in different containers can't interfere with each other.
  • User namespace: maps UIDs and GIDs inside a container to different UIDs and GIDs on the host — for example, root inside the container can map to an unprivileged user on the host.

You don't need Docker installed to feel this for yourself — Linux ships the unshare command out of the box:

bash
# create a new PID namespace and start a bash shell inside it
unshare --pid --fork --mount-proc bash
 
# run ps inside this new shell, and you'll see yourself as PID 1
ps aux

That's literally what Docker is doing for you behind the scenes — it just stacks up more namespaces and wraps the whole thing more neatly. Namespaces solve the "what can you see" problem, but there's still another one left unsolved: what stops a container from eating up all of the host's CPU or memory? That's where cgroups come in.

Cgroups: putting resource usage in a cage

Cgroups (control groups) are another kernel mechanism, this one responsible for limiting and accounting for how much a group of processes can consume — CPU time, memory, disk I/O, network bandwidth, all of it. If namespaces are about "isolating what you can see," cgroups are about "capping what you can use."

Without cgroups, a runaway process inside a container — say, a service with a bug that spins in an infinite loop allocating memory — could easily drag down the entire host and take every other container with it. Cgroups exist specifically to stop one bad tenant from bringing down the whole building.

On newer systems (cgroup v2), these controls show up as files under /sys/fs/cgroup, and you can try capping memory usage yourself:

bash
# create a new cgroup
mkdir /sys/fs/cgroup/demo
 
# cap memory at 100MB
echo 104857600 > /sys/fs/cgroup/demo/memory.max
 
# add the current shell's process to this cgroup
echo $$ > /sys/fs/cgroup/demo/cgroup.procs

After that, every child process spawned from this shell has its memory usage capped at 100MB. Cross that line, and the kernel steps in — typically by triggering the OOM killer to take out whatever process went over.

The docker run --memory=512m --cpus=1.5 flags you've probably typed before are, under the hood, Docker assembling exactly this kind of cgroup configuration and attaching it to the container's process — you never have to touch /sys/fs/cgroup yourself.

Union filesystems: stacking layers into one coherent view

Namespaces and cgroups handle process-level isolation and resource limits, but there's another container trait to explain: every time you docker run an image, you get a clean filesystem that matches exactly what the image defines. The technology behind that is the union filesystem (Docker's usual implementation is OverlayFS).

The core idea of a union filesystem is that you can stack several independent directories on top of each other and present them externally as one merged filesystem, even though underneath they're still separate, independently-managed sets of data.

That's exactly how a Docker image gets built, layer by layer. Nearly every instruction in a Dockerfile produces a new filesystem layer:

dockerfile
FROM node:20-alpine
# this line alone is a stack of layers (everything in the base image)
 
COPY package.json .
# a new layer, containing only the change from adding package.json
 
RUN npm install
# a new layer, containing whatever npm install produced in node_modules
 
COPY . .
# a new layer, containing the rest of the project's source code

These layers are read-only and stacked together. When you docker run a container from this image, Docker adds one more layer on top — the writable layer. Any file added, modified, or deleted inside the container only ever touches this writable layer; the image layers underneath stay completely untouched and pristine.

That explains two things you've probably noticed:

  • Why the same image can spin up multiple containers without them stepping on each other: they all share the same read-only layers, each one just gets its own writable layer stacked on top.
  • Why deleting a container wipes out everything you changed inside it: those changes only ever lived in that container's own writable layer, and deleting the container deletes that layer along with them.

Putting it all together: what a container actually is

Combine all three pieces and the picture snaps into focus. A container is:

an ordinary Linux process, wrapped in namespaces (so it can't see anything else on the host), capped by cgroups (so it can't exceed its resource quota), and mounted on top of a union filesystem (giving it the image's stacked layers plus one writable layer of its own).

And the docker commands you type don't actually talk to the kernel directly — there are a couple of layers in between. Docker Engine receives your command and hands it off to containerd, a long-running daemon that manages container lifecycles. containerd then calls down into an OCI (Open Container Initiative)-compliant runtime, typically runc, which is the piece that actually invokes the namespace and cgroup APIs described above to bring the process into existence.

This is also why orchestration systems like Kubernetes can talk directly to containerd or any other OCI-compliant runtime without going through Docker Engine at all — the actual isolation and resource limiting has always been the Linux kernel's job. Docker Engine is just one convenient interface on top of it.

Wrapping up

Docker looks like a lightweight VM, but under the hood it's on an entirely different path. There's no hypervisor, no separate kernel — just three native Linux mechanisms working together. Namespaces isolate a process's view of the world, making it believe it's running on its own independent system. Cgroups draw hard limits on resource usage, so one container can't take down the whole host. And union filesystems stack an image's read-only layers together with a container's own writable layer, keeping the underlying image pristine and unchanged. Once you understand these three pieces, watching a container start up fast and light stops looking like magic — it's just a Linux process, very well packaged.