Building "Containers" Manually
LEVEL 0
The Problem
We’ve learned that containers are just Linux processes with namespaces and cgroups applied.
But it’s one thing to understand that conceptually. It’s another to truly internalize it.
The best way to understand how containers work is to build one yourself—without Docker. Using just Linux commands and kernel features.
This is your “demystification moment.” After this, containers will never feel opaque again.
LEVEL 1
The Concept — The Wizard Behind the Curtain
The Concept
You’re watching a stage illusion. The performer makes a person disappear. It looks impossible from the audience.
Then, after the show, the performer shows you backstage. You see the trap door, the mirror angles, the assistant waiting below. You understand how the trick works.
The illusion is broken—but you gain something more valuable: understanding.
Docker is the polished performance. It makes containers appear effortlessly. But under the hood, it’s using Linux features you can use directly.
In this chapter, we’re going backstage. We’re going to create a “container” using just:
unshare(to create namespaces)cgcreateandcgexec(to set up cgroups)chroot(to change the root filesystem)
No Docker. No containerd. Just Linux.
LEVEL 2
The Mechanics — What We'll Build
The Mechanics
We’re going to create an isolated environment that:
- Has its own PID namespace (can’t see host processes)
- Has its own mount namespace (has its own root filesystem)
- Has memory and CPU limits (via cgroups)
- Runs a simple bash shell
This won’t be as polished as Docker, but it’ll have the core features of a container.
LEVEL 3
Building the Container
Prerequisites:
You’ll need:
- A Linux machine (Ubuntu, Debian, etc.)
- Root access (or sudo)
cgroup-toolspackage installed
sudo apt-get install cgroup-tools
Step 1: Create a root filesystem
We need a filesystem to use as the container’s root. We’ll use a minimal Alpine Linux rootfs.
# Create a directory for our container's filesystem
mkdir -p /tmp/mycontainer/rootfs
cd /tmp/mycontainer
# Download Alpine Linux rootfs
wget http://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.0-x86_64.tar.gz
# Extract it
tar -xzf alpine-minirootfs-3.19.0-x86_64.tar.gz -C rootfs/
# Verify it has the basics
ls rootfs/
# You should see: bin etc home lib media mnt opt proc root run sbin srv sys tmp usr var
Now we have a minimal Linux filesystem in rootfs/.
Step 2: Create cgroups for resource limits
# Create a cgroup for memory
sudo cgcreate -g memory:/mycontainer
# Set memory limit to 512MB
echo 536870912 | sudo tee /sys/fs/cgroup/memory/mycontainer/memory.limit_in_bytes
# Create a cgroup for CPU
sudo cgcreate -g cpu:/mycontainer
# Set CPU limit to 50% of one core
echo 50000 | sudo tee /sys/fs/cgroup/cpu/mycontainer/cpu.cfs_quota_us
Step 3: Enter new namespaces and chroot
Now we’ll use unshare to create new namespaces and chroot to change the root filesystem.
sudo cgexec -g memory,cpu:/mycontainer \
unshare --pid --mount --uts --fork \
chroot rootfs/ /bin/sh
Let’s break down this command:
cgexec -g memory,cpu:/mycontainer— Run the following command within the cgroups we createdunshare --pid --mount --uts --fork— Create new PID, mount, and UTS namespaceschroot rootfs/— Change the root directory to our Alpine filesystem/bin/sh— Start a shell
You’re now “inside the container”!
Step 4: Set up the proc filesystem
Inside the “container,” run:
mount -t proc proc /proc
This mounts the proc filesystem so commands like ps work.
Now try:
ps aux
You should only see a few processes:
PID USER TIME COMMAND
1 root 0:00 /bin/sh
2 root 0:00 ps aux
You’re PID 1! The shell thinks it’s the init process. It can’t see any host processes.
Step 5: Test the isolation
Hostname isolation (UTS namespace):
hostname mycontainer
hostname
# mycontainer
Exit the container (type exit), then check the host hostname:
hostname
# (Your original hostname, unchanged)
The hostname change only affected the container’s UTS namespace.
Filesystem isolation (mount namespace):
Inside the “container” (run the cgexec/unshare/chroot command again):
ls /
# bin etc home lib ...
# Only the Alpine rootfs contents
You can’t see the host’s filesystem. You’re “jailed” in rootfs/.
Memory limit (cgroup):
Try to allocate a lot of memory. Create a script:
# Inside container
cat > /tmp/memtest.sh << 'EOF'
#!/bin/sh
perl -e 'my @a; while(1){push(@a, "x" x 1024000); sleep 0.01;}'
EOF
chmod +x /tmp/memtest.sh
/tmp/memtest.sh
This script tries to allocate memory in a loop. After a few seconds, it should be killed:
Killed
The cgroup memory limit enforced the 512MB cap and killed the process.
LEVEL 4
What We Just Did
We built a container from scratch using:
-
Namespaces (
unshare):- PID namespace: Isolated process tree
- Mount namespace: Isolated filesystem view
- UTS namespace: Isolated hostname
-
Cgroups (
cgcreate,cgexec):- Memory limit: 512MB max
- CPU limit: 50% of one core
-
Filesystem Isolation (
chroot):- Changed root to a minimal Alpine filesystem
This is fundamentally what Docker does. Docker adds:
- Easier API and commands
- Image layering (union filesystem)
- Networking setup
- Volume management
- Better lifecycle management
But at its core, Docker is orchestrating the same Linux features we just used manually.
LEVEL 5
The Takeaway
Containers are not a special kind of virtual machine. They’re not a Docker-specific invention.
They’re processes running on the Linux kernel with isolation and resource limits provided by:
- Namespaces (isolation)
- Cgroups (limits)
- Chroot/mount namespaces (filesystem)
Docker is a well-designed wrapper around these features. It makes them usable, composable, and portable. But it’s not doing anything you couldn’t do yourself with enough bash commands.
Now that you’ve built a container manually, you understand exactly what docker run is doing under the hood. It’s running these same kernel features—just with better UX and more automation.