🐧 How to Build Your Own Container From Scratch

🧠 Title: “Building a Container Runtime with Nothing But Syscalls”
🔧 Skills: clone(), namespaces, cgroups, pivot_root, filesystem isolation
🎯 Signals: OS fluency, kernel-space understanding, comfort with system calls

To actually build a container runtime from scratch using only syscalls, you’re building something like a very minimal runc clone — no Docker, no Kubernetes, just Linux primitives.

This project is used to learn and/or demonstrate understanding of how containers work under the hood. Here’s a practical, step-by-step breakdown of how to do it, mostly in Go (Go is easier than C for modern devs and has good syscall wrappers):


🧱 0. What Are You Doing?

You’re making your own mini-Docker, but only the bare essentials — a process running in an isolated world, created with Linux syscalls like clone(), chroot(), and mount().

You’ll:

  1. Set up a VM (so your main computer stays safe)
  2. Write a tiny Go program that uses Linux syscalls
  3. Make a fake mini-filesystem (rootfs) for the container to live in
  4. Run your program as root so it can isolate itself
  5. Boom — container.

🖥️ 1. Set Up Ubuntu in VirtualBox

Remember that unlike VMs, containers use the OS kernel of their host. So, if we run this on your main OS, it will be messing with the filesystem of your main OS. If we run the whole thing in a VM, including running the container in that VM, then the container’s host is the VM and there’s no risk to your main OS if you move fast and break anything.

If you already have a VM you can use for these kinds of projects, you can use that.

📥 Download Ubuntu

🛠️ In VirtualBox

  • Create a new VM (name: Ubuntu-Containers)
  • Allocate at least 4 GB RAM, 2 CPUs
  • Use the ISO to install Ubuntu

Once you’re in your new Ubuntu system, open a terminal.


🛠️ 2. Install Tools You’ll Need

1
2
sudo apt update
sudo apt install -y golang wget tar

✅ You now have Go, wget, and tar — all you need to build your container runtime.


📁 3. Make a Project Folder

1
2
mkdir -p ~/containers-from-scratch/rootfs
cd ~/containers-from-scratch

📦 4. Create a rootfs

This gives your container something to “live inside.”

✅ Minimal BusyBox

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
mkdir -p rootfs/bin
wget https://busybox.net/downloads/binaries/1.21.1/busybox-x86_64
mv busybox-x86_64 rootfs/bin/sh
chmod +x rootfs/bin/busybox

# Set up symbolic links to the busybox executable for common
# shell commands. There are a lot more commands that busybox supports,
# if you want your container to be more fully-featured (see further 
# down in this tutorial).
cd rootfs/bin
for cmd in sh ls mkdir echo cat sleep uname; do
  ln -s busybox $cmd
done
cd -

Now you have a /bin/sh in your new fake root.


🧑‍💻 5. Write Your Go Program

Create the file:

1
nano main.go

Paste this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    if len(os.Args) > 1 && os.Args[1] == "child" {
        runChild()
    } else {
        runParent()
    }
}

func runParent() {
    fmt.Println(">> Parent: starting container process")
    cmd := exec.Command("/proc/self/exe", "child")
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS |
            syscall.CLONE_NEWPID |
            syscall.CLONE_NEWNS |
            syscall.CLONE_NEWIPC |
            syscall.CLONE_NEWNET,
    }
    err := cmd.Run()
    if err != nil {
        panic(err)
    }
}

func runChild() {
    fmt.Println(">> Child: setting up container")

    syscall.Sethostname([]byte("container"))
    syscall.Chroot("rootfs")
    os.Chdir("/")

    syscall.Mount("proc", "/proc", "proc", 0, "")

    fmt.Println(">> Running shell inside container")
    syscall.Exec("/bin/sh", []string{"/bin/sh"}, os.Environ())
}

Save and exit: Ctrl+O, Enter, Ctrl+X.

The Go code is explained here: A Minimal Container in Golang - Explained


🧪 6. Build and Run It

Build the program:

1
go build main.go

Run as root (so you can use namespaces and chroot):

1
sudo ./main

You should see:

1
2
3
4
>> Parent: starting container process
>> Child: setting up container
>> Running shell inside container
/ #

You’re inside your container!


🔍 7. What Just Happened?

You’re now:

  • In a new PID namespace — you’re process #1!
  • In a new UTS namespace — your hostname is container
  • In a new root filesystem (chroot)
  • With your own mounted /proc inside
  • Running /bin/sh in isolation

Try commands like:

1
2
3
hostname
ps aux
ls /

See step 8 below if these don’t work! (This tutorial is more about teaching than about “just getting it to work”. I think it’s educational to experience running a really bare-bones installation.)

🛠️ 8. Give your container more Linux commands

If you want to have more Linux commands available inside the container, this is easy.

From inside the container, type

1
busybox

to see a list of all the commands it supports. Then type

1
/bin/busybox --install -s

And it will attempt to create symlinks for all these commands. Though, it will fail on all the commands that don’t already have folders in the new filesystem. If you want them, create these folders for them manually:

1
2
3
4
/bin
/sbin
/usr/bin
/usr/sbin

and run the above install command again.

You will also have to manually create the directory

1
/proc

if you want commands like ps to work.

🚪 9. To Exit

Just type:

1
exit

and now you’re back in the host system.


🧼 10 Cleanup Tip

If /proc stayed mounted for some reason (this is likely):

1
sudo umount rootfs/proc

✅ Summary

You just built your own container system using:

  • ✅ Go
  • clone(), chroot, mount, exec()
  • ✅ Minimal Linux tools
  • ✅ A virtual machine for safety