Post

No Docker, No Problem - Building a Container from Scratch

A deep dive into building a basic container from scratch using Linux namespaces and chroot.

No Docker, No Problem - Building a Container from Scratch

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: Tells unshare 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 to my-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
This post is licensed under CC BY 4.0 by the author.