Adetayo Akinsanya unkletayo.dev

Building a Container From Scratch in Linux: unshare, chroot, pivot_root, and cgroups

Hands-on tutorial building a container using raw Linux kernel isolation primitives in bash.

Part 4 in Series — Catch up on the previous article: The Open Container Initiative (OCI): Image Specification and Runtime Specification (Part 3) before diving into this post.

To truly understand what container engines like Docker and runc do under the hood, you should build a container manually using raw Linux kernel primitives.

Many engineers assume containers require hypervisors, kernel virtualization drivers, or complex daemon binaries.

In reality, a Linux container is just a normal Linux process wrapped in kernel isolation boundary flags.

In this article, we will step through a hands-on tutorial that constructs a fully isolated Linux container from scratch using standard terminal commands: unshare, pivot_root, and cgroups v2.


1. Preparing the Container Root Filesystem (rootfs)

A container cannot run against the host’s / directory without seeing host files. We need an isolated root filesystem directory (rootfs).

Let’s download and extract a minimal Alpine Linux rootfs tarball into a local directory:

# Create working directories on host
mkdir -p /tmp/my_container/rootfs
mkdir -p /tmp/my_container/old_root

# Download Alpine Linux Mini RootFS
curl -sSL https://dl-cdn.alpinelinux.org/alpine/v3.18/releases/x86_64/alpine-minirootfs-3.18.3-x86_64.tar.gz \
  | tar -xz -C /tmp/my_container/rootfs

Inspecting /tmp/my_container/rootfs shows standard Linux OS folders: /bin, /etc, /lib, /usr, /var.


2. Isolating Namespaces with unshare

Linux provides the unshare(1) utility to disassociate process execution contexts from host namespaces.

We launch a new shell with isolated namespaces:

  • --pid: Creates a new PID namespace (the new process becomes PID 1).
  • --uts: Creates a new UTS namespace (allows setting a container hostname).
  • --mount: Isolates mount table operations.
  • --ipc: Isolates Inter-Process Communication queues.
  • --fork: Forks the specified program as a child of unshare.
sudo unshare --pid --uts --mount --ipc --fork /bin/bash

Inside this new shell, set an isolated container hostname:

hostname isolated-container-node
hostname  # Returns "isolated-container-node", host hostname remains unchanged!

3. Isolating the Filesystem (pivot_root vs chroot)

While chroot changes the apparent root directory for a process, chroot is insecure because a privileged process can escape a chroot jail using relative path traversal (../..).

Production runtimes use pivot_root(2), which unmounts the host root filesystem entirely and swaps it with the container’s rootfs.

Step 1: Make Mount Private

By default, mounts are shared with the host. We must recursively set mount propagation to private:

mount --make-rprivate /

Step 2: Bind Mount rootfs

pivot_root requires the target rootfs to be a mount point:

mount --bind /tmp/my_container/rootfs /tmp/my_container/rootfs

Step 3: Swap Root Filesystems

Pivot the current root filesystem to /tmp/my_container/old_root and make /tmp/my_container/rootfs the new root:

cd /tmp/my_container/rootfs
mkdir -p old_root
pivot_root . old_root
cd /

Step 4: Unmount Old Host Root

Unmount the old host root mount and mount isolated virtual filesystems (/proc and /sys):

umount -l /old_root
rmdir /old_root

# Mount isolated virtual proc filesystem inside container
mount -t proc proc /proc
mount -t sysfs sysfs /sys

Now, execute ps aux inside your manual container:

PID   USER     TIME  COMMAND
  1   root     0:00  /bin/bash
  5   root     0:00  ps aux

Success! The shell process is running as PID 1, and no host processes are visible.


4. Applying Resource Limits via cgroups v2

Without resource controls, our container process could consume 100% of host CPU and RAM.

We use cgroups v2 in /sys/fs/cgroup to restrict process memory usage to 100 Megabytes:

# Executed on host terminal:
# 1. Create a cgroup sub-node
sudo mkdir -p /sys/fs/cgroup/my_container_group

# 2. Set maximum memory threshold (100MB = 104857600 bytes)
echo 104857600 | sudo tee /sys/fs/cgroup/my_container_group/memory.max

# 3. Add container PID to cgroup process list
CONTAINER_PID=$(pgrep -f "unshare.*pid")
echo $CONTAINER_PID | sudo tee /sys/fs/cgroup/my_container_group/cgroup.procs

If the process inside my_container_group attempts to allocate more than 100MB of RAM, the Linux kernel Out-Of-Memory (OOM) Killer will instantly terminate the process.


Manual Container vs Docker / runc Automation Matrix

Container TaskWhat We Did Manually in BashWhat runc / Docker Automates
Process Isolationunshare --pid --uts --mount --ipc --forkclone(2) syscall with namespace flags
Filesystem Pivotmount --bind, pivot_root, umount -lpivot_root(2) syscall + overlayfs layer mounts
Virtual Filesystemsmount -t proc proc /procMounts isolated /proc, /sys, /dev devices
Resource CapsEchoing PIDs into /sys/fs/cgroup/...Configuring cgroup v2 control files automatically
Networking(Not configured above)Creating veth pairs, bridge connections, NAT rules

Summary & Next Steps

A container is not magic; it is the combination of three core Linux kernel primitives:

  • Namespaces (unshare) isolate process visibility (PIDs, hostnames, mounts).
  • Filesystem Pivoting (pivot_root) locks execution inside a rootfs directory.
  • Control Groups (cgroups v2) cap CPU and RAM memory resource usage.

In the next article, we transition to Module 2 and analyze Anatomy of a Docker Image: Layers, Config JSON, and Manifest Specifications.

References & Further Reading

  1. Linux Man-Pages Project. pid_namespaces(7) & user_namespaces(7). Linux Man Pages.
  2. Biederman, E. W. (2006). Multiple Instances of the Global Linux Namespaces. Proceedings of Linux Symposium.
  3. Kerrisk, M. (2013). Namespaces in Operation. LWN.net Series.

Up Next in Series →

Part 5: Anatomy of a Docker Image: Layers, Config JSON, and Manifest Specifications

Continue to Part 5 →