deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

Thirty lines, no Docker: a container built from namespaces, pivot_root and a cgroup

A dev.to walkthrough builds a working container in about thirty lines using unshare, namespaces, pivot_root and a cgroup memory cap — no Docker involved.

Thirty lines, no Docker: a container built from namespaces, pivot_root and a cgroup

A process with private views

A developer writing on dev.to has built a working container in roughly thirty lines of Python and shell, with no Docker and no root privileges on the host. The point of the exercise, the author explains, is that there is no kernel object called a container to point at: a container is an ordinary process handed private copies of resources it would otherwise share with the rest of the machine. The finished process believed it was PID 1 on a host named container, saw none of the machine's other processes, had an empty network stack and its own root filesystem, and was killed by the kernel once it passed a 20 MB memory cap.

One unshare call per namespace

The isolation half comes entirely from Linux namespaces, each a private copy of one global resource: PID, mount, network, UTS for the hostname, IPC, and user. The post reduces the whole thing to one unshare invocation:

unshare --user --map-root-user --mount --uts --ipc --pid --fork --net
python3 enter.py rootfs

Each flag buys exactly one kind of separation: --uts a private hostname, --pid --fork a private process table in which the forked child becomes PID 1, --net a network stack whose only interface is a loopback, down, and --mount a private set of mounts. The quietly crucial one is --user --map-root-user, which maps the invoking user to root inside a user namespace, letting the process mount filesystems and call pivot_root while staying unprivileged on the host. Inside, two processes are visible where the host has eighty-seven, plus one downed interface and a chosen hostname.

pivot_root rather than chroot

The genuinely fiddly step is swapping the root filesystem, and according to the post this is where a toy becomes a container. chroot works but leaves the old root mounted underneath, hidden but reachable through well-known escape tricks, so real runtimes use pivot_root, which swaps the root mount and lets the old one be unmounted entirely. Since pivot_root has no friendly wrapper on the author's machine, enter.py calls the syscall through libc. Three requirements each cost a failed run: the new root must be a mount point, so the rootfs is bind-mounted onto itself; the parent mount must be made private or the call fails with EINVAL; and the old root, left dangling after the pivot, must be detached with umount2. After that, the host filesystem is not hidden but gone: ls /oldroot returns no such directory.

The bug that made ps lie

The most instructive mistake in the post produced zero processes from ps, which looked like perfect isolation but was a measurement error. The first version mounted /proc via unshare --mount-proc before the chroot, so the process ended up reading an empty /proc directory inside the rootfs. ps was not seeing an empty process table; it was seeing an empty folder. Mounting /proc after the root swap, from inside the new root, made ps report exactly two processes — the real answer, and proof that the PID namespace works.

Namespaces isolate, cgroups limit

The resource ceiling, the other half of what people mean by container, is a separate kernel feature. In the author's framing, namespaces decide what a process can see and control groups decide what it can consume. A cgroup v2 memory limit is a byte count written into memory.max. To watch it bite, the post ran a child that grabbed a megabyte at a time under a 20 MB cap: it died after allocating 15 MB with exit code 137 — 128 plus 9, a SIGKILL from the kernel's OOM killer — while the container's PID 1 shell survived unharmed.

Two further stumbles shaped the demo. A first memory hog wrote to tmpfs, whose pages outlive the writing process, so the OOM killer went on to kill PID 1 and collapse the whole container; anonymous memory, freed on death, is the honest way to demonstrate a limit. On a systemd machine the author's shell also sat in an /init.scope cgroup outside the tree delegated to the user, so manual attach attempts returned EIO; the fix was systemd-run --user --scope, which starts the process inside the delegated tree where limits can be set.

Why it matters

Many developers start containers daily without a mental model of what the kernel does, and the gap shows up in practice: debugging odd ps output inside a container, asking whether a chroot is a security boundary (it is not), working out why a pod was OOM-killed, or seeing why rootless containers are possible at all. This thirty-line demonstration splits the concept cleanly — namespaces for visibility, pivot_root for the filesystem, cgroups for limits — and shows each piece is a request to the kernel rather than an artefact of any particular tool. When something breaks in production, that decomposition is the difference between guessing and knowing where to look.

  • #linux
  • #containers
  • #namespaces
  • #cgroups
  • #kernel

Related posts