No Docker, No Problem - Building a Container from Scratch
A deep dive into building a basic container from scratch using Linux namespaces and chroot.
Introduction
I’ve used Docker countless times to spin up environments for development and deployment. It’s easy, fast, and reliable. But it always felt a bit like a black box. Sure, I know the theory behind containers, but I never truly dug into how they actually work under the hood.
So, I decided to build one from scratch. No Docker, no Podman, just me and the command line.
It’s simple, minimal and definitely not production-ready.
For simplicity, I’m not covering advanced features like cgroups, security hardening, or proper network namespaces. The goal here is just to explore the fundamentals of containers in the simplest way possible.
The Basics
So what makes a container, really? It’s a tiny, isolated environment for running applications. It has its own filesystem and processes and it needs to be able to communicate with the outside world.
Building the Root Filesystem (rootfs)
We’ll start with a basic root filesystem or a rootfs
. We want something lightweight and minimal. After some research, it turns out Alpine Linux is a great choice for this. It’s small, fast, and has a package manager called apk
that makes it easy to install software.
We can download the Alpine Linux root filesystem from the official website, and then extract it into a directory that will serve as the root of our container.
Let’s start by downloading the Alpine Linux root filesystem:
1
wget https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz
Now that we have the tarball, we can create a directory for our container and extract the tarball into it:
1
2
mkdir my-container
tar -xzf alpine-minirootfs-3.20.3-x86_64.tar.gz -C my-container
Finally, if we cd
into my-container
, we can see the basic filesystem structure of our container:
1
2
cd my-container
ls -l
You should see directories like bin
, etc
, lib
, usr
, and so on.
Process Isolation with chroot
and Namespaces
Now that we have a basic filesystem, we need to isolate it from the host system. This is where things get interesting.
When we run a command in the terminal, it uses the host system’s root filesystem. But we want our container to use the my-container
directory as its root filesystem.
It turns out there’s a command for that: chroot
. It changes the root directory for the current running process and its children. This means that when we run a command with chroot
, it will see the specified directory as its root filesystem.
Let’s try it out, and open a shell inside our new root filesystem:
1
sudo chroot ./my-container /bin/sh
Now we are inside the my-container
filesystem. We can verify this by checking which sh
we are running:
1
which sh
This should return /bin/sh
, which is inside our my-container
directory.
Awesome! We are now running a shell inside our container’s filesystem. But wait, what about process isolation? However, this alone isn’t enough to create a fully isolated container. We also need to isolate the filesystem and processes running inside the container from the host system.
When it comes to isolation, the first thing that will come up with any search is namespaces
. Linux namespaces are a powerful feature that allows us to isolate various aspects of a process, such as its process IDs, network interfaces, and mount points. At this stage, we will only focus on the PID
namespace and the mount
namespace to isolate the process IDs and the filesystem.
The mount
namespace creates a separate view of the filesystem for the processes running inside the container. This means that the processes inside the container will only see the files and directories that are part of the container’s filesystem, and not the host system’s filesystem.
The PID
namespace ensures that processes inside the container have their own unique process IDs, separate from the host system. This means that a process running inside the container can have the same PID as a process running on the host system, without any conflict.
To create a new PID namespace and mount namespace, we can use the unshare
command. This command allows us to run a program with some namespaces unshared from the parent process.
Let’s try it out:
1
sudo unshare --fork --pid --mount --mount-proc chroot ./my-container /bin/sh
This command does several things:
unshare --fork --pid --mount-proc
: This creates a new PID namespace and mounts the/proc
filesystem inside the new namespace.--fork
: Tellsunshare
to fork a new process. Without this, the current shell would be used, which is not what we want.--pid
: Creates a new PID namespace. Processes inside this namespace will have their own unique PIDs isolated from the host system.--mount
: Creates a new mount namespace. This isolates the filesystem mounts from the host system.--mount-proc
: Mounts the/proc
filesystem inside the new PID namespace. This is important because/proc
provides information about the processes running on the system, and we want to see the processes inside the container, not the host system.
chroot ./my-container /bin/sh
: This changes the root directory tomy-container
and starts a new shell as we did before.
Now we are inside the my-container
filesystem and in a new PID namespace. We can verify this by checking the PID of the shell we are running:
1
echo $$
This should return a PID that is unique to the container.
Adding Networking and Package Installs
So far, we have created a basic filesystem and isolated it from the host system. But what about installing software inside the container?
I mentioned earlier that the Alpine rootfs
has apk
, a package manager for Linux. This means we can install software inside our container. However, in order to be able to apk
from inside the new filesystem we just created, we need to create a new process that considers the my-container
directory as its root filesystem.
Let’s try it out, by running apk update
inside the container we just created:
1
apk update
And… it fails. Do we even have networking inside the container? Let’s check by pinging google.com
:
1
ping google.com
This should fail with “ping: bad address ‘google.com’“.
Apparently not. The my-container
doesn’t know how to resolve domain names. This is because the /etc/resolv.conf
file inside the container is missing. Let’s create it:
1
echo "nameserver 8.8.8.8" > /etc/resolv.conf
Now, let’s try pinging google.com
again:
1
ping google.com
And… it works! We now have networking inside our container.
Now that we are inside the container, we can use apk
to install software. For example, let’s install Python and pip:
1
2
apk update
apk add python3 py3-pip
Now let’s verify that Python and pip are installed:
1
python3 --version
This should return the version of Python installed inside the container.
Wrapping Up
And there you have it! We have successfully built a basic container from scratch using chroot
and unshare
. We created a minimal filesystem using Alpine Linux, isolated it from the host system using chroot
, and created a new PID namespace using unshare
. Finally, we set up networking inside the container and installed software using apk
.
We just scratched the surface of what is possible with containers. There is a lot more to uncover, such as mounting filesystems, setting up network namespaces, and managing resources.
But this is a great starting point for anyone interested in learning more about containers and how they work under the hood.
Quick Reference
Here’s a quick reference of all the commands we used.
- Host System
1
2
3
4
5
wget https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz
mkdir my-container
tar -xzf alpine-minirootfs-3.20.3-x86_64.tar.gz -C my-container
echo "nameserver 8.8.8.8" > my-container/etc/resolv.conf
sudo unshare --fork --pid --mount-proc chroot ./my-container /bin/sh
- Inside Container
1
2
3
apk update
apk add python3 py3-pip
python3 --version