Tutorial  on  Linux, Containers, Programming

Build a Container from Scratch in Go (Liz Rice GOTO 2018)

Follow along with Liz Rice's classic GOTO 2018 presentation and build your own container runtime in under 100 lines of Go using Linux namespaces, chroot, and cgroups.

Disclaimer: This tutorial is meant to be a companion to Liz Rice's awesome talk: Containers From Scratch • GOTO 2018.

In 2018, Liz Rice demonstrated what a container really is. In just about 60 lines of Go code live on stage, she demonstrated that containers are not virtual machines or magical black boxes. Instead, a container is simply a standard Linux process created with a specific isolated environment utilizing Linux kernel's namespaces and cgroups features.

Building a Container from Scratch in Go Architecture

In this tutorial, you will code along step-by-step with the concepts from that talk, starting from a basic program runner and turning it into a working container runtime named minic.


Step 1: The Simplest Command Runner

Navigate to your workspace directory:

cd /home/laborant/container-lab

A container runtime's primary job is executing a command on behalf of the user (e.g. docker run <cmd> <args>).

Create main.go with our starting skeleton:

package main

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

// Usage: ./minic run <cmd> <args>
// Example: ./minic run /bin/bash
func main() {
    // Simple CLI dispatcher matching Docker's 'run' subcommand syntax
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("invalid command")
    }
}

func run() {
    fmt.Printf("Running %v as PID %d\n", os.Args[2:], os.Getpid())

    // Prepare the user command (e.g., /bin/bash with any optional arguments)
    cmd := exec.Command(os.Args[2], os.Args[3:]...)

    // Connect container standard I/O streams directly to your terminal
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // Start the command and wait for it to finish
    must(cmd.Run())
}

// Helper function to panic on unexpected errors
func must(err error) {
    if err != nil {
        panic(err)
    }
}

Build and test running a shell with your program:

go build -o minic main.go
./minic run /bin/bash

Output:

Running [/bin/bash] as PID 1842

Inside this shell, check your hostname:

hostname

Output:

golang-01

You are running directly on the host system with zero isolation. If you were to change the hostname here, it would change the hostname for the entire machine.

Exit the shell:

exit

Step 2: Isolating the Hostname (UTS Namespace)

The first step in container isolation is creating a private UTS Namespace (CLONE_NEWUTS), which isolates the system hostname.

In Go, we pass kernel clone flags to exec.Command via cmd.SysProcAttr.

  1. Import the "syscall" package in main.go:
import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)
  1. Inside run(), add cmd.SysProcAttr:
func run() {
    fmt.Printf("Running %v as PID %d\n", os.Args[2:], os.Getpid())

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // SysProcAttr holds OS-specific process attributes.
    // CLONE_NEWUTS instructs the Linux kernel to create a brand new UTS namespace
    // for the spawned process, giving it an independent hostname and domain name.
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}

Rebuild and run with sudo (creating namespaces requires root privileges):

go build -o minic main.go
sudo ./minic run /bin/bash

Output:

Running [/bin/bash] as PID 1890

Inside this new shell, change the hostname:

hostname container-demo
hostname

Output:

container-demo

Now exit this shell:

exit

Check the host's hostname:

hostname

Output:

golang-01

The host hostname is unchanged! The child process had its own private copy of the UTS namespace.


Step 3: The /proc/self/exe Re-Exec Trick

We want the container runtime to automatically set properties (such as setting the container hostname, configuring mount points, or dropping capabilities) inside the new namespace before handing control over to the user's shell.

However, calling syscall.Sethostname() inside run() would change the host's hostname because run() runs before the child process is spawned.

To solve this, Liz Rice demonstrates the famous fork / re-exec pattern:

  1. When minic runs with run, it calls /proc/self/exe child <args> with clone namespace flags.
  2. /proc/self/exe is a special symlink in Linux pointing to the currently running executable binary (minic).
  3. The new process starts in child(), where it already resides inside the new namespaces and can configure everything before executing the target binary!
  1. Update main() to route the new child subcommand:
func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("invalid command")
    }
}
  1. Update run() to re-execute /proc/self/exe:
func run() {
    fmt.Printf("Parent running %v as PID %d\n", os.Args[2:], os.Getpid())

    // Re-execute this exact binary with the 'child' argument.
    // /proc/self/exe resolves to our own compiled executable on disk.
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // The parent spawns the child process into a new UTS namespace
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}
  1. Add the child() function:
func child() {
    fmt.Printf("Container running %v as PID %d\n", os.Args[2:], os.Getpid())

    // Because we are already inside the new UTS namespace, this only sets
    // the hostname for our container, completely isolated from the host!
    must(syscall.Sethostname([]byte("container")))

    // Now launch the actual user command (e.g. /bin/bash) inside the configured container
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

Build and test:

go build -o minic main.go
sudo ./minic run /bin/bash

Output:

Parent running [/bin/bash] as PID 1940
Container running [/bin/bash] as PID 1941

Inside the shell, verify the hostname:

hostname

Output:

container

It is already automatically set to container! Exit the shell:

exit

Step 4: Isolating Process IDs (PID Namespace)

Currently, the child process can still see and interact with all processes running on the host system.

Let's add the PID Namespace (syscall.CLONE_NEWPID). When a process is created with CLONE_NEWPID, it becomes PID 1 (the init process) within its new process hierarchy.

Inside run(), add syscall.CLONE_NEWPID to Cloneflags:

    // CLONE_NEWPID creates a new process hierarchy where the child starts as PID 1
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
    }

    must(cmd.Run())
}

Rebuild and run:

go build -o minic main.go
sudo ./minic run /bin/bash

Output:

Parent running [/bin/bash] as PID 1990
Container running [/bin/bash] as PID 1

Inside the container shell, check your current shell PID:

echo $$

Output:

1

Now try running ps:

ps aux

Output:

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.1 169000 13000 ?        Ss   12:00   0:00 /sbin/init
root       520  0.0  0.1  25000  8000 ?        Ss   12:00   0:00 /usr/sbin/sshd
...

Wait, why does ps aux still list all the host processes?

Because ps does not make a syscall asking the kernel for process lists; instead, ps simply reads the directory contents of /proc, and because our container is still using the host's root filesystem, /proc is still pointing to the host's /proc mount.

Exit the shell:

exit

Step 5: Filesystem Isolation (chroot & Private /proc)

To completely confine the container and fix process inspection, we need to address filesystem root confinement and /proc mounting.

Our playground environment has a pre-extracted Ubuntu rootfs ready at /container-fs.

Part A: Confining the Root Directory with chroot

Inside child(), add syscall.Chroot and syscall.Chdir to confine the process to /container-fs, without mounting /proc yet:

func child() {
    fmt.Printf("Container running %v as PID %d\n", os.Args[2:], os.Getpid())

    // 1. Set container hostname
    must(syscall.Sethostname([]byte("container")))

    // 2. Change the root directory for this process and its children to /container-fs
    must(syscall.Chroot("/container-fs"))
    must(syscall.Chdir("/"))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

Rebuild and test:

go build -o minic main.go
sudo ./minic run /bin/bash

Inside the container:

  1. Check Root Directory:
    ls /
    

    Output:
    bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
    

    The process is confined inside /container-fs. Host directories like /home/laborant are completely inaccessible.
  2. Now try running ps:
    ps
    

    Output:
    Error: /proc must be mounted
      To mount /proc at /proc you can run the following command:
        mount -t proc proc /proc
    

Why did ps fail? Because inside /container-fs, /proc is simply an empty directory. The ps command relies on the Linux kernel's proc pseudo-filesystem to query process metadata.

Exit the container:

exit

Part B: Mounting a Private /proc (Mount Namespace)

To allow ps to work without exposing host processes or leaking mounts to the host system:

  1. Add syscall.CLONE_NEWNS (Mount Namespace) to SysProcAttr in run().
  2. Mount a fresh proc pseudo-filesystem at /proc using syscall.Mount inside child().
  3. Cleanly unmount /proc with syscall.Unmount when the container exits.
  4. Update run():
func run() {
    // ...
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }

    must(cmd.Run())
}
  1. Update child():
func child() {
    fmt.Printf("Container running %v as PID %d\n", os.Args[2:], os.Getpid())

    must(syscall.Sethostname([]byte("container")))
    must(syscall.Chroot("/container-fs"))
    must(syscall.Chdir("/"))

    // Mount a fresh, private /proc pseudo-filesystem
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())

    // Cleanly unmount /proc on container exit
    must(syscall.Unmount("/proc", 0))
}

Let's test filesystem isolation by creating a test file on the host before launching the container:

echo "confidential host data" > /home/laborant/host-secret.txt

Rebuild and run the container:

go build -o minic main.go
sudo ./minic run /bin/bash

Output:

Parent running [/bin/bash] as PID 2040
Container running [/bin/bash] as PID 1

Inside the container:

  1. Check Process Table:
    ps aux
    

    Output:
    USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
    root           1  0.0  0.0 1225852 1904 ?        Sl   12:12   0:00 /proc/self/exe child /bin/bash
    root           6  0.0  0.0   8280  4204 ?        R    12:12   0:00 ps aux
    

    ps now works cleanly and displays only the processes running inside your container!
  2. Check Root Directory:
    ls /
    

    Output:
    bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
    
  3. Verify Host Isolation (Try reading the host file):
    cat /home/laborant/host-secret.txt
    

    Output:
    cat: /home/laborant/host-secret.txt: No such file or directory
    

    You are confined inside /container-fs. The host's files are completely inaccessible.

Exit the container:

exit

Step 6: Resource Limits with cgroups (Defeating Fork Bombs)

Namespaces control what a process can see (hostname, PIDs, mounts). Control Groups (cgroups) control how much resources a process can use (CPU, memory, process count).

In the talk, Liz Rice demonstrates setting a limit on the maximum number of processes (pids.max) to protect the host against a fork bomb.

  1. Add "path/filepath" and "strconv" to your imports in main.go:
import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strconv"
    "syscall"
)
  1. Add the cg() helper function to configure cgroups v2 limits:
func cg() {
    cgroup := "/sys/fs/cgroup/demo-container"

    // 1. Create a cgroup node directory
    os.Mkdir(cgroup, 0755)

    // 2. Limit the container process hierarchy to at most 20 concurrent PIDs
    must(os.WriteFile(filepath.Join(cgroup, "pids.max"), []byte("20"), 0700))

    // 3. Attach our current process (os.Getpid()) to this cgroup controller
    must(os.WriteFile(filepath.Join(cgroup, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}
  1. Call cg() at the beginning of child():
func child() {
    fmt.Printf("Container running %v as PID %d\n", os.Args[2:], os.Getpid())

    // Configure cgroup resource limits before entering the jail
    cg()

    must(syscall.Sethostname([]byte("container")))
    must(syscall.Chroot("/container-fs"))
    must(syscall.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())

    must(syscall.Unmount("/proc", 0))
}

Now, let's test our container and see cgroups in action!

Rebuild and start the container:

go build -o minic main.go
sudo ./minic run /bin/bash

Inside the container shell, launch the classic bash fork bomb (just like in Liz Rice's live demo):

:() { :|:& }; :

Output:

bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
bash: fork: Resource temporarily unavailable

Watch what happens:

  • The function recursively spawns background processes to exhaust system resources.
  • As soon as the total process count in the container reaches 20, the Linux kernel's cgroups controller intervenes and refuses to fork any more processes (bash: fork: retry: Resource temporarily unavailable).
  • The host system (and all other processes outside the cgroup) remain completely unaffected and responsive!

Press Ctrl+C and exit the container:

exit

Summary

In fewer than 100 lines of Go code, you built a working container runtime that implements:

  • Hostname Isolation: UTS namespace (CLONE_NEWUTS)
  • Process ID Isolation: PID namespace (CLONE_NEWPID)
  • Filesystem & Mount Isolation: Mount namespace (CLONE_NEWNS), chroot, and /proc mount
  • Resource Constraints: cgroups (pids.max)

You now understand the fundamental primitives powering every Docker, Podman, and Kubernetes container running in production today!

Click here to view the complete final main.go code
package main

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strconv"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("invalid command")
    }
}

func run() {
    fmt.Printf("Parent running %v as PID %d\n", os.Args[2:], os.Getpid())

    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    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,
    }

    must(cmd.Run())
}

func child() {
    fmt.Printf("Container running %v as PID %d\n", os.Args[2:], os.Getpid())

    cg()

    must(syscall.Sethostname([]byte("container")))
    must(syscall.Chroot("/container-fs"))
    must(syscall.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())

    must(syscall.Unmount("/proc", 0))
}

func cg() {
    cgroup := "/sys/fs/cgroup/demo-container"

    os.Mkdir(cgroup, 0755)

    must(os.WriteFile(filepath.Join(cgroup, "pids.max"), []byte("20"), 0700))
    must(os.WriteFile(filepath.Join(cgroup, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

Check out Liz's Other Awesome Talks

About the Author

Başar Subaşı

Başar Subaşı

Find this author online

Writes about

linuxprogrammingcontainers

Frequently covers

#c#gcc#assembly#golang#process

More tutorials you might like

Linux Processes: Understanding Signals (cover image)

Linux Processes: Understanding Signals

Explore Linux/Unix signals in Go, how signals act as asynchronous notifications, who can send them, default kernel behaviors, sending signals with kill and Go, graceful signal handling with os/signal, and why SIGKILL cannot be trapped.

Learn by doing, not just by reading or watching

Sign up for a free account to start a VM playground right on this page, track your progress, and get notified about new learning materials.

Sign up for free