Demystifying containers by building one from scratch with Linux primitives
1. What a Runtime Actually Does
A container runtime does exactly one thing: it takes a root filesystem and a configuration, then creates an isolated process. That's it. No magic.
It uses three Linux primitives:
- Namespaces — isolation (the process can't see other processes, networks, or users)
- Cgroups — resource limits (the process can't consume more than X MB of RAM)
- chroot / pivot_root — filesystem isolation (the process sees only its own root)
Docker is not magical. It's a UX layer on top of these primitives. By the end of this lesson, you'll create a container with a 30-line shell script.
2. The OCI Runtime Spec
The Open Container Initiative (OCI) defines a standard for container runtimes. Two key pieces:
config.json- Declares what to run, which namespaces to create, which filesystems to mount, and which cgroup limits to apply.
- Lifecycle commands
create→start→kill→delete
runc is the reference implementation. Any OCI-compliant runtime is a drop-in replacement — you can swap runc for crun, youki, or kata-runtime without changing your container images.
The 5 Steps to Create a Container
3. Step 1: Creating a Root Filesystem
A container needs a filesystem to see. The simplest way: export a Docker image.
# Create a minimal Alpine rootfs
mkdir rootfs
docker export $(docker create alpine) | tar -C rootfs -xf -
# Verify — you should see standard Linux directories
ls rootfs/
# bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
Alternative: use debootstrap for a minimal Debian rootfs:
sudo debootstrap --variant=minbase bullseye rootfs/ http://deb.debian.org/debian
Either way, you now have a directory that looks like a Linux root. That's all a container image is — a tarball of files.
4. Step 2: Isolating with Namespaces
The unshare command (or the unshare(2) / clone(2) syscall) creates new namespaces for a process:
# Create new PID, mount, UTS, and network namespaces
sudo unshare --pid --mount --uts --net --fork /bin/sh
# Inside the new namespace:
hostname container-test # Only affects this UTS namespace
echo $$ # PID 1 — this process thinks it's init!
Key namespace types:
| Namespace | Isolates | Flag |
|---|---|---|
| PID | Process IDs | CLONE_NEWPID |
| Mount | Filesystem mounts | CLONE_NEWNS |
| UTS | Hostname | CLONE_NEWUTS |
| Network | Network stack | CLONE_NEWNET |
| User | UID/GID mappings | CLONE_NEWUSER |
| IPC | Inter-process comms | CLONE_NEWIPC |
5. Step 3: Changing the Root
pivot_root is preferred over chroot because it truly swaps the mount point — the old root becomes inaccessible (not just hidden).
# Inside the unshared namespace:
mount --bind rootfs rootfs # Make it a mount point
cd rootfs
mkdir -p .old_root
# Pivot: new root = current dir, old root moved to .old_root
pivot_root . .old_root
# Mount essential filesystems
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t tmpfs tmp /dev
# Unmount old root completely
umount -l /.old_root
rmdir /.old_root
# Now the process can ONLY see rootfs contents
ls / # Shows Alpine/Debian files, not the host
6. Step 4: Applying Cgroup Limits
Cgroup v2 uses a unified hierarchy via pseudo-files:
# Create a cgroup for our container
mkdir -p /sys/fs/cgroup/mycontainer
# Set memory limit to 512MB
echo 536870912 > /sys/fs/cgroup/mycontainer/memory.max
# Set CPU limit to 50% of one core (100ms period, 50ms quota)
echo "50000 100000" > /sys/fs/cgroup/mycontainer/cpu.max
# Set max PIDs to 64
echo 64 > /sys/fs/cgroup/mycontainer/pids.max
# Assign our process ($$) to this cgroup
echo $$ > /sys/fs/cgroup/mycontainer/cgroup.procs
That's it. The kernel now enforces these limits. If the process exceeds 512MB, the OOM killer terminates it.
7. Step 5: Exec the Process
The setup is complete. The final step replaces our shell with the target process:
# exec replaces the current process — no new PID, no shell overhead
exec /bin/sh
After exec, the shell is gone. The target process IS PID 1 inside the container. When it exits, the container is done.
8. Putting It Together: A Minimal Container Runtime
Here's a ~30-line shell script that creates a real container:
#!/bin/bash
# mini-container.sh — A minimal container runtime
# Usage: sudo ./mini-container.sh <rootfs-path> <command>
set -e
ROOTFS="$1"
CMD="${2:-/bin/sh}"
CGROUP_NAME="mini-container-$$"
# Step 1: Create cgroup with limits
mkdir -p /sys/fs/cgroup/$CGROUP_NAME
echo 536870912 > /sys/fs/cgroup/$CGROUP_NAME/memory.max
echo 64 > /sys/fs/cgroup/$CGROUP_NAME/pids.max
echo $$ > /sys/fs/cgroup/$CGROUP_NAME/cgroup.procs
# Step 2: Enter new namespaces (unshare + fork)
exec unshare --pid --mount --uts --net --fork /bin/bash -c "
# Step 3: Set hostname
hostname mini-container
# Step 4: Pivot root
mount --bind \"$ROOTFS\" \"$ROOTFS\"
cd \"$ROOTFS\"
mkdir -p .old_root
pivot_root . .old_root
# Step 5: Mount essential filesystems
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t tmpfs tmp /dev
# Step 6: Remove old root
umount -l /.old_root
rmdir /.old_root
# Step 7: Exec the target process
exec $CMD
"
Run it:
sudo ./mini-container.sh ./rootfs /bin/sh
# You're now inside a container you built yourself!
This IS what runc does — just with more error handling, security hardening, and OCI spec compliance.
9. Using runc Directly
You can use runc without Docker to run OCI containers:
# Generate a default OCI config
mkdir mycontainer && cd mycontainer
mkdir rootfs
docker export $(docker create alpine) | tar -C rootfs -xf -
runc spec # Generates config.json
# Run the container
sudo runc create mycontainer
sudo runc start mycontainer
sudo runc list
sudo runc delete mycontainer
Example config.json (abbreviated)
{
"ociVersion": "1.0.2",
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"cwd": "/"
},
"root": {
"path": "rootfs",
"readonly": true
},
"hostname": "my-container",
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "mount" },
{ "type": "uts" },
{ "type": "network" },
{ "type": "ipc" }
],
"resources": {
"memory": { "limit": 536870912 },
"pids": { "limit": 64 }
}
}
}
Quiz 1: pivot_root vs chroot
Why is pivot_root preferred over chroot for containers?
Quiz 2: Namespace Creation
Which syscall creates new namespaces for a process?
Quiz 3: Cgroup v2 Memory Limit
Which file do you write to in cgroup v2 to set a container's memory limit?
🛠️ Hands-On Tasks
Task 1: Explore Namespace Isolation
Use unshare to create isolated namespaces and explore the environment from inside:
# Terminal 1: Create a new PID + UTS namespace
sudo unshare --pid --uts --mount --fork /bin/bash
# Inside the namespace:
hostname isolated-host
echo "My PID: $$" # Should be 1
mount -t proc proc /proc
ps aux # Only sees processes in THIS namespace
hostname # Shows "isolated-host"
# Terminal 2 (on host): Verify host is unaffected
hostname # Still your original hostname
ps aux | grep unshare # You can see the unshare process from outside
Observe: The namespace is one-way isolation. The host sees everything; the container sees only itself.
Task 2: Run a Container with runc (No Docker Daemon)
# Install runc (if not present)
# Ubuntu/Debian: sudo apt install runc
# Or download from: https://github.com/opencontainers/runc/releases
# Set up the bundle
mkdir -p ~/runc-test/rootfs
cd ~/runc-test
docker export $(docker create alpine) | tar -C rootfs -xf -
# Generate OCI spec
runc spec
# Edit config.json: change "terminal": true to false if running non-interactively
# Change "args": ["sh"] to ["echo", "Hello from runc!"]
# Run it
sudo runc run my-first-container
# Output: Hello from runc!
# Clean up
sudo runc delete my-first-container 2>/dev/null
Key insight: No Docker daemon involved. runc directly uses the kernel primitives to create the container.
📌 Key Takeaways
- A container is just a Linux process with namespace isolation, cgroup limits, and a pivoted root filesystem.
- Three primitives:
unshare/clone(namespaces), cgroup pseudo-files (limits),pivot_root(filesystem). - The OCI Runtime Spec standardizes how runtimes work —
config.json+ lifecycle commands. - You can build a working container runtime in ~30 lines of shell script.
- runc, crun, youki, and kata-runtime are all interchangeable OCI runtimes with different tradeoffs.
- Understanding these internals is essential for debugging container issues in production (OOM kills, network problems, namespace permission errors).