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 ofunshare.
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 Task | What We Did Manually in Bash | What runc / Docker Automates |
|---|---|---|
| Process Isolation | unshare --pid --uts --mount --ipc --fork | clone(2) syscall with namespace flags |
| Filesystem Pivot | mount --bind, pivot_root, umount -l | pivot_root(2) syscall + overlayfs layer mounts |
| Virtual Filesystems | mount -t proc proc /proc | Mounts isolated /proc, /sys, /dev devices |
| Resource Caps | Echoing 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
- Linux Man-Pages Project. pid_namespaces(7) & user_namespaces(7). Linux Man Pages.
- Biederman, E. W. (2006). Multiple Instances of the Global Linux Namespaces. Proceedings of Linux Symposium.
- Kerrisk, M. (2013). Namespaces in Operation. LWN.net Series.
Part 5: Anatomy of a Docker Image: Layers, Config JSON, and Manifest Specifications
Continue to Part 5 →