Contents

A Container Is a Lie Your Kernel Agrees To

Contents

I spent an embarrassing chunk of my first year running Docker in production assuming a container was a lightweight virtual machine — a tiny, fast VM without the hypervisor overhead. That model works fine right up until something goes wrong, and then it actively misleads you. A container escape doesn’t breach a hypervisor boundary, because there isn’t one. A container sharing the host’s kernel with a vulnerability doesn’t affect “just that VM,” because there’s no VM to contain the blast radius. The moment I ran ps aux on the host and saw every containerised process listed right there alongside the host’s own, the model broke and a better one had to replace it.

A container is a regular Linux process. It runs on the same kernel as everything else on the box, scheduled by the same scheduler, subject to the same system calls. What makes it look isolated is a set of kernel features — namespaces and cgroups, mainly — that change what that one process can see and how much of the machine it’s allowed to use. Nothing about a container is virtualised in the hypervisor sense. The isolation is real, but it’s a negotiated agreement with the kernel, not a hardware wall, and understanding that difference changes how you think about container security, resource limits and what actually happens when something breaks.

Namespaces: what the process is allowed to see

Advertisement

A Linux namespace restricts what a process can observe of the wider system. There are several kinds, and each one hides a different slice of reality from the process running inside it:

  • PID namespace — the process sees only itself and its own children as a private process tree, typically believing it’s PID 1, even though the host sees it as some ordinary PID like 48213.
  • Network namespace — the process gets its own network interfaces, routing table and iptables rules, isolated from the host’s actual network stack until something explicitly bridges the two.
  • Mount namespace — the process sees its own filesystem tree, built from the image layers, with the host’s real filesystem hidden outside whatever’s been explicitly bind-mounted in.
  • UTS namespace — the process can set its own hostname without touching the host’s.
  • User namespace — the process can believe it’s running as root inside the container while mapping to an unprivileged UID on the host, which is the single most important namespace for genuine isolation and the one most container setups still skip.

You can see this mechanism directly without Docker at all, using the same syscalls Docker calls under the hood:

1
2
3
4
# unshare creates new namespaces and runs a command inside them
sudo unshare --pid --fork --mount-proc /bin/bash
# inside this shell, ps sees only this process tree
ps aux

Run that and ps inside the new shell shows a tiny process list, as if the machine had almost nothing running on it. Open another terminal on the same host and ps aux there shows the full picture, including the unshared shell sitting in the list like any other process. Same kernel, same physical machine, two completely different views of what’s running — namespaces do the entire trick, with no hypervisor involved.

Cgroups: how much of the machine it’s allowed to use

Namespaces control visibility; control groups (cgroups) control consumption. A cgroup caps how much CPU, memory, disk I/O or process count a group of processes can use, enforced directly by the kernel scheduler rather than by any container-specific code. Docker, Podman and Kubernetes all configure cgroups on your behalf, but the primitive is plain kernel functionality you can drive by hand:

1
2
3
4
5
# cgroup v2: create a cgroup and cap it at 256MB of memory
sudo mkdir /sys/fs/cgroup/demo
echo 268435456 | sudo tee /sys/fs/cgroup/demo/memory.max
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs
# every process in this shell from now on is capped at 256MB

Exceed that limit inside the shell and the kernel’s OOM killer terminates the offending process directly — the exact mechanism a docker run --memory=256m flag configures for you, just without the CLI wrapping it in friendlier syntax.

Why the boundary is softer than a hypervisor’s

Advertisement

A hypervisor enforces isolation through actual hardware — virtualisation extensions (Intel VT-x, AMD-V) that trap and control access to memory and CPU at a level the guest OS cannot see around, because the guest is running its own separate kernel entirely. A container shares one kernel across every container on the box. Every namespace and cgroup boundary is enforced by that single kernel’s code, and a bug in that code is a bug that affects every container relying on it. Dirty COW, Dirty Pipe and similar Linux kernel exploits matter enormously more to container security than to VM security for exactly this reason: escaping a container’s namespace boundary through a kernel bug lands you directly on the host, with no hypervisor left to catch the fall.

This is why rootless containers matter for anyone paranoid about privilege: running the container engine itself as an unprivileged user, and mapping container “root” to a genuinely unprivileged host UID via user namespaces, means that even a full container escape lands the attacker as a nobody-user on the host rather than as root. It doesn’t make the kernel bug disappear, but it changes what that bug is worth to an attacker, which is the entire game in container hardening.

Capabilities and seccomp: the two limits namespaces don’t cover

Namespaces and cgroups handle visibility and resource consumption, but neither one restricts what a process is technically permitted to do to the kernel once it can see it. That’s a separate pair of mechanisms: Linux capabilities and seccomp filters.

Capabilities split up what used to be an all-or-nothing “root” privilege into around forty separate permissions — CAP_NET_ADMIN for network configuration, CAP_SYS_ADMIN for a grab-bag of administrative operations, CAP_NET_BIND_SERVICE for binding ports below 1024, and so on. A container running as UID 0 inside its user namespace doesn’t need most of these, and Docker drops a sensible default set already. You can go further and drop nearly everything:

1
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-web-app

Seccomp goes one level lower still, filtering which raw system calls a process is allowed to make at all, regardless of capability. Docker’s default seccomp profile blocks around 44 syscalls known mainly for causing container escapes or kernel exploits — mount, reboot, ptrace in some configurations, and others no ordinary web application ever legitimately calls. Together, capabilities and seccomp are why a properly configured container is a meaningfully smaller attack surface than a bare unshare session: the kernel both hides the outside world from the process and refuses to honour whole categories of request that a namespace boundary alone would still allow through.

The filesystem layer nobody thinks about until it breaks

The other piece namespaces don’t explain is how a container image becomes a filesystem the process can chroot into without copying gigabytes of data for every container you start. That’s OverlayFS: a union filesystem that stacks read-only image layers underneath one writable layer per container. Reads fall through to whichever layer actually has the file; writes go to the top layer only, leaving the underlying image layers untouched and shareable across every container using that image.

1
2
# inspect which layers make up a running container's merged view
docker inspect --format '{{json .GraphDriver.Data}}' my-container | python3 -m json.tool

This is also where “container images share a kernel” bites people who expect Docker’s isolation to extend to the filesystem the way a VM’s virtual disk would. A file deleted from the writable layer stays fully present in the image layer beneath it, masked instead by a whiteout marker, which is why docker diff on a container that “deleted” a large file from its base image never reclaims any disk space until the image layer itself is pruned.

Building a container by hand

The clearest way to see that Docker isn’t doing anything mystical is to combine the primitives above yourself, roughly the way runc (the low-level runtime underneath Docker and Podman) actually does it:

1
2
sudo unshare --pid --fork --mount --uts --net --mount-proc \
  chroot /path/to/a/root/filesystem /bin/sh

That single line creates new PID, mount, UTS and network namespaces, chroots into an extracted root filesystem, and drops you into a shell that looks, from the inside, exactly like a container. It’s missing cgroup limits, a proper overlay filesystem and a dozen security refinements Docker adds by default — but the core trick, the thing that makes it feel like an isolated machine, is entirely namespaces plus a changed root, both of which the kernel has supported since long before Docker existed.

Troubleshooting: when the abstraction leaks

  • A process inside the container can see host processes. This means the PID namespace either wasn’t created or the container is running with --pid=host, deliberately sharing the host’s PID namespace — sometimes intentional for monitoring tools, always worth double-checking in anything else.
  • docker stats shows a container hard against its memory limit and getting OOM-killed. That’s memory.max doing exactly its job. Check dmesg | grep -i "killed process" to confirm the OOM killer’s decision and raise the cgroup limit if the workload genuinely needs more headroom.
  • A container process shows as root on ps output on the host, not just inside the container. No user namespace remapping is happening. This is the default in most Docker installs and is worth fixing with --userns-remap or a rootless Podman setup for anything handling untrusted input.
  • Network isolation seems to leak between containers. Check which network namespace each container actually joined — docker network inspect and comparing container NetworkSettings.SandboxKey values across containers shows whether they truly have separate namespaces or are sharing one via --network=container:other.
  • A dropped capability broke something that used to work. --cap-drop=ALL is the right instinct but occasionally removes something the application genuinely needs, like CAP_NET_BIND_SERVICE for binding a low port. docker logs showing a permission denied on a syscall the process previously succeeded at is the signal to check docker inspect --format '{{.HostConfig.CapDrop}}' against what the application actually requires, rather than reflexively re-adding everything back.
  • docker diff shows deletions but disk usage hasn’t dropped. That’s OverlayFS whiteout markers doing their job correctly — the file is masked in the writable layer, not physically freed, because the underlying image layer is shared with every other container using that image. Run docker image prune to reclaim space from layers nothing references any more.

Where this shows up in real incidents

The theory matters most when something has actually gone wrong. A shared-kernel vulnerability like Dirty Pipe demonstrated exactly why the “tiny VM” mental model fails at the worst possible moment: a container with no special privileges could, through a bug in how the kernel handled a specific page-cache operation, overwrite arbitrary files on the host filesystem — a boundary a hypervisor would never have exposed in the first place, because a VM guest simply cannot touch host files through a kernel bug in its own guest kernel. Every container on a host sharing that kernel version was exposed simultaneously, not “the one VM that got compromised.” That blast radius is the direct, structural consequence of one kernel serving every tenant, and it’s the reason patching host kernels promptly matters more for container fleets than it ever did for hypervisor hosts running fully isolated guest kernels.

The same logic applies in the other direction when things go right: container escapes get caught far more often by tracing raw packets and syscalls with tcpdump and nsenter than by anything resembling hypervisor-level monitoring, precisely because there’s no hypervisor layer to instrument. Debugging a container problem means debugging a Linux process with some kernel features layered on top of it, using the same tools you’d use for any other Linux process — not a separate toolchain built for virtual machines.

Is it worth reaching for

Treating containers as tiny VMs isn’t wrong in the sense of getting you a working docker-compose.yml — the abstraction holds up fine for ordinary day-to-day use. It becomes actively dangerous the day you’re reasoning about what happens during a kernel exploit, a resource exhaustion incident, or a multi-tenant setup where you genuinely don’t trust every workload on the box. A container’s isolation is real, kernel-enforced and useful — it just isn’t the wall a hypervisor builds, and knowing exactly which wall you’re standing behind is the difference between confident security decisions and a nasty surprise the day a namespace boundary doesn’t hold.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.