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
createstartkilldelete

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

1. Root FS Extract image into directory 2. Namespaces unshare PID, mount, UTS, net 3. pivot_root Change root to container FS 4. Cgroups Set memory, CPU limits 5. exec Replace shell with process Result: an isolated process = 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:

NamespaceIsolatesFlag
PIDProcess IDsCLONE_NEWPID
MountFilesystem mountsCLONE_NEWNS
UTSHostnameCLONE_NEWUTS
NetworkNetwork stackCLONE_NEWNET
UserUID/GID mappingsCLONE_NEWUSER
IPCInter-process commsCLONE_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).