JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Containers: Namespaces, cgroups, and microVMs

The four guides before this built a whole machine in software, with a hypervisor below a full guest kernel. Containers take the opposite bet: keep one shared kernel, but lie to each process about what it can see and use. We walk through the two kernel features that make that lie airtight — namespaces and cgroups — then ask honestly where the boundary is thinner than a VM's, and how microVMs put the wall back without the bloat.

A different bet: share the kernel, fake the view

Across this rung you watched a hypervisor build an entire fake machine: a guest gets its own virtualized CPU (trap-and-emulate sharpened by VT-x), its own memory through nested paging (EPT), and its own devices via virtio. Inside that machine runs a whole separate kernel, which does not even know it is a guest. That is heavy on purpose — the isolation is strong because the wall is the hardware itself. A container makes the opposite trade. There is no second kernel and no emulated machine. Every container is just an ordinary group of processes running on the one host kernel you already have, sharing the same system-call interface as everything else.

So what makes a container feel separate, if it is just processes? Two kernel features, and they are worth stating plainly because the whole idea rests on them. The first, namespaces, controls what a process can see: which process ids, which mount points, which network interfaces, which hostname exist for it. The second, cgroups, controls what a process can use: how much CPU time, how much memory, how much I/O bandwidth. See and use — perception and consumption. A container is nothing more, and nothing less, than a set of processes wrapped in a fresh bundle of namespaces and pinned under a cgroup. There is no "container" object in the kernel at all; it is a pattern assembled from these parts.

Namespaces: what a process is allowed to see

Recall from the process rung that every process has a PID, and they form one global tree rooted at PID 1. A PID namespace forks that idea: it gives a group of processes their own numbering, so the first process inside the namespace is PID 1 to them, with its own little tree, even though the host kernel still knows it by some larger global PID like 0x4D2. Run "ps" inside the container and you see only your own processes; the host's hundreds are simply not in your view. Nothing is hidden by a permission check — the other processes do not exist in your namespace's numbering, the way a number not in a set is not refused, it is just absent.

PID is only one kind. There is a whole family, each slicing one global resource: the mount namespace gives a private filesystem tree, so a container can have its own root "/" without touching the host's; the network namespace gives private network interfaces, routing, and ports, so two containers can both bind port 80 without colliding; the UTS namespace gives a private hostname; the user namespace lets a process be root (uid 0) inside while mapping to an unprivileged uid outside. A container is built by placing its processes into a fresh set of these at once. The kernel calls that are the real machinery are unshare() to detach the current process into new namespaces, and setns() to join an existing one — small syscalls doing a surprisingly large job.

cgroups: what a process is allowed to use

Namespaces stop a container from seeing the rest of the machine, but they say nothing about resources. A process you cannot see can still starve you: a runaway program in one container could eat every CPU and all the memory, freezing its well-behaved neighbours. That is the job of control groups, cgroups. A cgroup is a labelled bucket you drop processes into, and on that bucket the kernel enforces limits and accounting. You can say this group may use at most 2 CPUs' worth of time, may hold at most 512 MiB of memory, may issue at most so many disk I/O operations per second. The kernel's scheduler and memory subsystem read those limits and hold the line.

The memory limit is worth a careful look, because it shows cgroups have teeth. When a cgroup's processes together try to hold more memory than its limit, the kernel does not politely fail an malloc() — it invokes the OOM (out-of-memory) killer scoped to that cgroup and kills a process inside it, leaving the rest of the machine untouched. This is the honest reason containers feel safe to pack densely: a memory bug in one container is contained to its own budget. The interface itself is files. cgroups are controlled by writing plain text into a pseudo-filesystem under /sys/fs/cgroup — "everything is a file" carried all the way into resource control, the same Unix idea from the I/O rung wearing a new hat.

From primitives to an image you can ship

We have the isolation. What about the contents — the actual files a container runs against? A container does not boot a kernel, but it still needs a root filesystem: the libraries, the binaries, the configuration its program expects. That bundle is a container image. A container image is a packaged, read-only filesystem tree plus a little metadata saying which command to run and with what environment. Crucially it is built in layers — each instruction in a build adds a thin stacked layer — and the layers are content-addressed and shared. If ten images all build on the same base layer, that layer is stored and downloaded once. At run time the read-only layers are stacked and a thin writable layer is laid on top, using copy-on-write so the original layers are never mutated. You have met copy-on-write before in virtual memory; here the very same trick makes filesystem layers cheap to share.

Now the orchestration. Pulling an image, unpacking its layers, setting up the namespaces and cgroups, and finally exec()-ing the program — that whole dance needs a common contract so a Docker image can run under a completely different tool. That contract is the OCI runtime specification. The OCI runtime standard says: an image is laid out this way, and a low-level runtime (the reference one is runc) is handed a config plus a root filesystem and is responsible for creating the namespaces, applying the cgroup limits, and starting the process. Higher tools (Docker, containerd, Podman, Kubernetes) all sit above this line and ultimately call an OCI-compliant runtime to do the actual kernel work. The standard is why "build once, run anywhere on Linux" actually holds.

  VM stack                         Container stack
  --------                         ---------------
  app                              app
  guest libs                       container image libs
  GUEST KERNEL   <-- own kernel    (no second kernel)
  virtual HW     <-- emulated      namespaces + cgroups  <-- kernel features
  HYPERVISOR                       ----------------------
  host kernel / hardware           ONE host kernel / hardware

  isolation wall = the hardware    isolation wall = the syscall boundary
Where the wall sits. A VM puts a whole guest kernel and emulated hardware between the app and the host; a container shares the single host kernel and is fenced off only by namespaces and cgroups at the system-call boundary. Same goal, walls of very different thickness.

The thin wall, and microVMs as the answer

Now the honest part this rung has been building toward. Because every container shares the one host kernel, a container's attack surface is the entire system-call interface — hundreds of syscalls, any of which, if it has a kernel bug, might let a process escape its namespaces and reach the host or a neighbour. This is real, not theoretical: container escapes through kernel vulnerabilities have happened. A VM versus container comparison must say this plainly — the VM's wall is the CPU's own virtualization hardware and a tiny hypervisor interface, a much smaller and more scrutinized surface than the full syscall API. Containers buy speed and density by accepting a thinner wall. That is a genuine engineering tradeoff, not a flaw to paper over.

So can you get a container's startup speed and a VM's hardware-enforced wall? That is exactly what a microVM chases. A microVM is a stripped-down virtual machine: a minimal hypervisor that emulates only the few devices a Linux guest truly needs — almost always through virtio, the paravirtualized interface from the previous guide — and nothing else. No legacy BIOS, no emulated sound card, no PCI bus full of pretend hardware. By dropping all of that, a microVM can boot a guest kernel in tens of milliseconds and use only a few MiB of overhead, while still giving each workload a real separate kernel behind the hardware virtualization wall. AWS's Firecracker is the well-known example, purpose-built to run functions and containers with VM-grade isolation at near-container speed.

Step back and the whole rung snaps into one shape. Virtualization is a spectrum of where you put the wall and how much you pay for it. At one end, a full type-1 hypervisor runs heavy guests with the strongest isolation. In the middle, microVMs keep the hardware wall but shave the machine down to virtio-only essentials. At the other end, containers drop the second kernel entirely and fence processes with namespaces and cgroups for the lightest, densest, fastest option — at the cost of a shared kernel. There is no single "best"; there is a dial, and an honest engineer chooses the point on it that matches how much they trust the workload. That judgement — not any one mechanism — is the real mastery this rung was for.