Docker is convenient, but it's also a thick layer of abstraction sitting on top of a handful of Linux kernel primitives. If you strip away the daemon, the CLI, and the image registry, a "container" is really just:
- a process with restricted namespaces (its own view of PIDs, mounts, network, hostname, etc.)
- a root filesystem it's been
chroot/pivot_root'd into - optionally, cgroups limiting what it can consume
In this post we'll build a minimal container runtime by hand — no dockerd, no containerd, just unshare, chroot, and a plain directory tree we'll call a bundle. This is roughly what runc does under the hood when Docker "runs" a container.
Why bother?
- It demystifies what Docker is actually doing.
- It's useful when you're stuck on a machine with no Docker installed (e.g. a locked-down CI runner) but you have root and a kernel with namespace support.
- It's the mental model you need if you ever touch the OCI runtime spec,
runc,crun, or write your own sandboxing tool.
The ingredients
You need:
- A Linux kernel with namespace support (basically any modern distro).
-
unshareandchroot(fromutil-linux/coreutils— almost always already installed). - A root filesystem for the container — a directory containing
/bin,/lib,/etc, etc. This is our "bundle."
Step 1: Build the bundle (rootfs)
The simplest way to get a working rootfs without pulling a Docker image is to export one from an existing image with skopeo/umoci, or — even simpler — just untar a minimal distro's rootfs tarball. Alpine publishes exactly this:
mkdir -p ~/mybundle/rootfs
cd ~/mybundle
curl -LO https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz
tar -xzf alpine-minirootfs-3.20.3-x86_64.tar.gz -C rootfs
You now have a directory that is a Linux filesystem: rootfs/bin, rootfs/etc, rootfs/lib, and so on. This directory is your bundle — the same idea as the rootfs/ + config.json layout the OCI runtime spec formalizes.
Step 2: Isolate it with namespaces
unshare lets you spin up a process in new namespaces without any daemon involved:
sudo unshare \
--mount --uts --ipc --net --pid --fork \
--mount-proc \
chroot ~/mybundle/rootfs /bin/sh
Breaking that down:
-
--mount— the container gets its own mount table; mounting/unmounting inside it won't affect the host. -
--uts— its own hostname/domainname. -
--ipc— isolated System V IPC and POSIX message queues. -
--pid --fork— a new PID namespace; the shell becomes PID 1 inside the container. -
--net— its own network stack (no interfaces exceptloby default — more on this below). -
--mount-proc— remounts/procinside the new mount namespace sopsreports container-local PIDs instead of the host's. -
chroot ~/mybundle/rootfs /bin/sh— pivots the process's filesystem root and execs a shell.
Run that, and ps aux inside the shell will show basically nothing but your own shell as PID 1. hostname somename will only affect this namespace. You are, for most practical purposes, "in a container."
Step 3: Give it a real root (pivot_root instead of chroot)
chroot alone is famously escapable and doesn't fully detach the process from the host's mount tree. The more correct approach — what runc actually does — is pivot_root, which swaps the entire root mount rather than just relabeling a path.
This replaces Step 2's
chroot, it doesn't chain after it. If youchrootinto the rootfs first and then try to run the commands below, they'll fail: once you'vechroot'd, the path~/mybundle/rootfsno longer exists from your process's point of view (its root is now/), somount --bind ~/mybundle/rootfs ~/mybundle/rootfserrors with "No such file or directory," andpivot_rooterrors with "Resource busy" because the target isn't a proper mount point. Pick one entry method —chroot(Step 2) orpivot_root(this step) — not both.
Here's the full sequence run correctly, from the host, in one unshare invocation:
# run as root on the HOST — do not chroot first
mkdir -p ~/mybundle/rootfs/oldroot
unshare --mount --uts --ipc --net --pid --fork --mount-proc -- \
/bin/sh -c '
set -e
ROOTFS=~/mybundle/rootfs
mount --bind "$ROOTFS" "$ROOTFS" # rootfs must be a mount point for pivot_root
cd "$ROOTFS"
pivot_root . oldroot
mount -t proc proc /proc
umount -l /oldroot
rmdir /oldroot
exec /bin/sh
'
Note the mount --bind happens on the host's original mount namespace before anything is switched — that's what makes $ROOTFS a valid mount point in the first place. pivot_root . oldroot (called with relative paths, after cding into the rootfs) is the actual root swap; there's no separate chroot call needed afterward, since the process's root is already the new filesystem.
Now the process genuinely cannot see the host filesystem at all — there's no /oldroot path left to walk back through, unlike a plain chroot where a determined process can sometimes break out.
Step 4: Limit resources with cgroups
Namespaces isolate view, cgroups limit consumption. These commands are run on the host, as root — not inside the chroot/pivoted rootfs (Alpine's minirootfs doesn't even ship sudo, so running them post-pivot will just fail with "sudo: not found"). If you're already root (e.g. via sudo su), drop sudo entirely:
mkdir /sys/fs/cgroup/mybundle
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control
echo "50M" > /sys/fs/cgroup/mybundle/memory.max
echo "20" > /sys/fs/cgroup/mybundle/pids.max
echo $$ > /sys/fs/cgroup/mybundle/cgroup.procs
Do this before the unshare call that launches your namespaced shell, capturing the PID via echo $$ right before you invoke it (or write the container's PID into cgroup.procs right after unshare --fork returns it). Then the shell — and everything it spawns — is capped at 50 MB of memory and 20 processes, exactly like a docker run --memory=50m --pids-limit=20 would enforce.
Step 5: Networking (optional, and the fiddly part)
By default the --net namespace only has loopback. To give the container real connectivity you create a veth pair, put one end inside the namespace, and NAT it — this is precisely what Docker's docker0 bridge and iptables rules automate for you:
sudo ip link add veth-host type veth peer name veth-ctr
sudo ip link set veth-ctr netns <container-pid>
sudo ip addr add 10.200.1.1/24 dev veth-host
sudo ip link set veth-host up
# inside the namespace:
ip addr add 10.200.1.2/24 dev veth-ctr
ip link set veth-ctr up
ip link set lo up
ip route add default via 10.200.1.1
Add a NAT rule on the host (iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -j MASQUERADE) and the container can reach the outside world. This is the one piece where "just do it by hand" starts to feel like reimplementing Docker's networking stack — which, in a sense, you are.
Putting it together: a tiny launcher script
#!/bin/sh
# run-container.sh <bundle-dir> -- <command>
# run as root on the host (e.g. sudo ./run-container.sh ...)
BUNDLE="$1"; shift; shift # drop the "--"
ROOTFS="$BUNDLE/rootfs"
mkdir -p "$ROOTFS/oldroot"
exec unshare --mount --uts --ipc --pid --fork --mount-proc -- \
/bin/sh -c "
set -e
mount --bind '$ROOTFS' '$ROOTFS'
cd '$ROOTFS'
pivot_root . oldroot
mount -t proc proc /proc
umount -l /oldroot
rmdir /oldroot
exec $*
"
sudo ./run-container.sh ~/mybundle -- /bin/sh
That's a real, working container in about ten lines of shell. It's missing image layering, a registry client, seccomp profiles, and a dozen other things runc/containerd/Docker handle for you — but the isolation primitives are the same ones Docker uses.
What this maps to in OCI terms
If you've seen runc spec generate a config.json, that file is just a declarative description of exactly the steps above: which namespaces to unshare, what the rootfs path is, what cgroup limits to apply, what mounts to bind in. A "bundle" in the OCI sense is literally:
mybundle/
├── config.json # namespaces, cgroup limits, mounts, entrypoint
└── rootfs/ # the filesystem tree, e.g. our Alpine extract
runc run mycontainer just reads that config.json and does the unshare/pivot_root/cgroup dance for you, with a lot more edge-case handling (capabilities, seccomp, user namespaces, /dev setup, hooks). Understanding it manually first makes reading runc's source — or debugging a weird container issue — a lot less intimidating.
Caveats
- This needs root (or user namespaces configured for rootless operation — a whole extra topic).
- Skipping seccomp/capabilities means this is not a security boundary equivalent to a real container runtime. Don't run untrusted code in it.
- Cgroup v1 vs v2 paths differ; check
mount | grep cgroupto see which your system uses.
If you want to go further, the natural next step is reading runc's libcontainer source or the OCI runtime-spec repo — everything above is the tip of what it formalizes.
Top comments (0)