In Part 1, you built a process with isolated namespaces. It has its own hostname, its own process tree, and its own network stack. But it still shares the host’s filesystem.
That is dangerous. The containerized process can read /etc/shadow, write to /usr/bin, or delete anything on the host. We need to give the container its own filesystem.
In this part, you will:
- Understand
chrootandpivot_root - Download and set up a minimal root filesystem
- Implement
pivot_rootin Go - Add OverlayFS for layered filesystems (just like Docker images)
- Mount
/procinside the isolated filesystem
Preparing a Root Filesystem
A container needs a root filesystem — a directory that contains everything a Linux system needs: /bin, /lib, /etc, /proc, and so on.
Docker uses images for this. An image is a pre-built root filesystem. We will use Alpine Linux because it is tiny (about 3MB) and has everything we need.
Download the Alpine mini root filesystem:
mkdir -p rootfs
cd rootfs
curl -o alpine.tar.gz https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.3-x86_64.tar.gz
tar xzf alpine.tar.gz
rm alpine.tar.gz
cd ..
You now have a complete Linux filesystem in the rootfs directory:
ls rootfs/
# bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
chroot vs pivot_root
There are two ways to change a process’s root filesystem:
chroot changes the root directory for the current process. It is simple but insecure. A process can escape a chroot with some effort. The old root filesystem is still accessible.
pivot_root swaps the entire root mount. The old root is moved to a subdirectory, and you can unmount it. Once unmounted, there is no way back to the host filesystem. This is what Docker and other container runtimes use.
We will use pivot_root because it is more secure.
Implementing pivot_root in Go
Update the child function to set up filesystem isolation:
func child() {
fmt.Printf("[container] Running %v as PID %d\n", os.Args[2:], os.Getpid())
// Set container hostname
if err := syscall.Sethostname([]byte("container")); err != nil {
fmt.Println("Error setting hostname:", err)
os.Exit(1)
}
// Set up the root filesystem
if err := setupRootfs("rootfs"); err != nil {
fmt.Println("Error setting up rootfs:", err)
os.Exit(1)
}
// Run the user's command
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
Now add the setupRootfs function:
func setupRootfs(rootfs string) error {
// pivot_root requires the new root to be a mount point.
// Bind-mount it onto itself to make it a mount point.
if err := syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("bind mount rootfs: %w", err)
}
// Create a directory for the old root
oldRoot := filepath.Join(rootfs, "oldroot")
if err := os.MkdirAll(oldRoot, 0700); err != nil {
return fmt.Errorf("create oldroot dir: %w", err)
}
// pivot_root: swap the root filesystem
if err := syscall.PivotRoot(rootfs, oldRoot); err != nil {
return fmt.Errorf("pivot_root: %w", err)
}
// Change to the new root
if err := os.Chdir("/"); err != nil {
return fmt.Errorf("chdir: %w", err)
}
// Mount /proc inside the container
if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
return fmt.Errorf("mount /proc: %w", err)
}
// Mount a tmpfs on /dev for device files
if err := syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755"); err != nil {
return fmt.Errorf("mount /dev: %w", err)
}
// Create essential device nodes so shells and redirects work
// Without these, commands like "> /dev/null" will fail
createDeviceNodes()
// Unmount the old root
if err := syscall.Unmount("/oldroot", syscall.MNT_DETACH); err != nil {
return fmt.Errorf("unmount old root: %w", err)
}
// Remove the old root directory
if err := os.Remove("/oldroot"); err != nil {
return fmt.Errorf("remove oldroot: %w", err)
}
return nil
}
Add a helper to create essential device nodes:
func createDeviceNodes() {
// Device nodes: name, major, minor
devices := []struct {
path string
major uint32
minor uint32
}{
{"/dev/null", 1, 3},
{"/dev/zero", 1, 5},
{"/dev/random", 1, 8},
{"/dev/urandom", 1, 9},
{"/dev/tty", 5, 0},
}
for _, dev := range devices {
// makedev combines major and minor numbers
devNum := int(dev.major*256 + dev.minor)
syscall.Mknod(dev.path, syscall.S_IFCHR|0666, devNum)
}
}
Add the missing import at the top:
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
)
Let’s walk through what setupRootfs does:
- Bind mount —
pivot_rootrequires the new root to be a mount point. A bind mount onto itself makes this work. - Create oldroot —
pivot_rootneeds a place to put the old root filesystem. - pivot_root — swaps the root. The old root is now at
/oldroot. - chdir — move to the new root directory.
- Mount /proc — so
ps,top, and other tools work inside the container. - Mount /dev — a temporary filesystem for device files.
- Unmount oldroot — remove access to the host filesystem. This is the key security step.
- Remove oldroot — clean up the mount point directory.
Testing Filesystem Isolation
Build and run:
go build -o minicontainer .
sudo ./minicontainer run /bin/sh
Inside the container:
# Check the filesystem — it is Alpine Linux
cat /etc/os-release
# NAME="Alpine Linux"
# Check that the host filesystem is gone
ls /oldroot
# No such file or directory
# List processes
ps aux
# Only container processes
# Check hostname
hostname
# container
# Try to access host files — they do not exist
cat /etc/shadow
# Shows the Alpine shadow file, not the host's
# Exit
exit
The container has its own filesystem. It cannot see or modify the host’s files.
Adding OverlayFS
There is a problem with our current approach. Every container modifies the root filesystem directly. If you install a package in one container, it is there for the next container too.
Docker solves this with layers. The base image is read-only. Each container gets a thin writable layer on top. Changes only go to the writable layer. This is OverlayFS.
OverlayFS combines multiple directories into one:
┌─────────────────────┐
│ merged view │ ← what the container sees
├─────────────────────┤
│ upper (writable) │ ← container's changes go here
├─────────────────────┤
│ lower (read-only) │ ← base image (Alpine rootfs)
└─────────────────────┘
When a container reads a file, OverlayFS checks the upper layer first. If the file is not there, it reads from the lower layer. When a container writes a file, the write goes to the upper layer. The lower layer is never modified.
This is called copy-on-write. You can run 100 containers from the same base image, and they all share the read-only layer. Each container only stores its own changes.
Implementing OverlayFS in Go
Add a function to set up OverlayFS:
func setupOverlayfs(containerID string) (string, error) {
// Create directories for the overlay layers
baseDir := filepath.Join("containers", containerID)
lowerDir := "rootfs" // read-only base image
upperDir := filepath.Join(baseDir, "upper") // writable layer
workDir := filepath.Join(baseDir, "work") // required by OverlayFS
mergedDir := filepath.Join(baseDir, "merged") // combined view
for _, dir := range []string{upperDir, workDir, mergedDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create dir %s: %w", dir, err)
}
}
// Mount OverlayFS
opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", lowerDir, upperDir, workDir)
if err := syscall.Mount("overlay", mergedDir, "overlay", 0, opts); err != nil {
return "", fmt.Errorf("mount overlay: %w", err)
}
return mergedDir, nil
}
Now update the run function to use OverlayFS:
func run() {
fmt.Println("[parent] Starting container...")
// Generate a simple container ID
containerID := fmt.Sprintf("container-%d", os.Getpid())
// Set up OverlayFS
mergedDir, err := setupOverlayfs(containerID)
if err != nil {
fmt.Println("Error setting up overlayfs:", err)
os.Exit(1)
}
// Pass the merged directory path to the child
cmd := exec.Command("/proc/self/exe", append([]string{"child", mergedDir}, 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 |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWNET,
}
if err := cmd.Run(); err != nil {
fmt.Println("Error:", err)
}
// Clean up: unmount overlay
syscall.Unmount(mergedDir, 0)
fmt.Println("[parent] Container stopped. Changes saved in:", filepath.Join("containers", containerID, "upper"))
}
Update the child function to receive the rootfs path:
func child() {
rootfs := os.Args[2]
args := os.Args[3:]
fmt.Printf("[container] Running %v as PID %d\n", args, os.Getpid())
if err := syscall.Sethostname([]byte("container")); err != nil {
fmt.Println("Error setting hostname:", err)
os.Exit(1)
}
if err := setupRootfs(rootfs); err != nil {
fmt.Println("Error setting up rootfs:", err)
os.Exit(1)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
Update the main function to adjust argument parsing:
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: minicontainer run <command> [args...]")
os.Exit(1)
}
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
fmt.Println("Unknown command:", os.Args[1])
os.Exit(1)
}
}
Testing OverlayFS
Build and run:
go build -o minicontainer .
sudo ./minicontainer run /bin/sh
Inside the container:
# Create a file
echo "hello from container" > /tmp/test.txt
# Install a package
apk add curl
# Exit
exit
The base rootfs directory is unchanged. All modifications are in the containers/<id>/upper/ directory:
# Check the upper layer — your changes are here
ls containers/container-*/upper/tmp/
# test.txt
# The base rootfs is untouched
ls rootfs/tmp/
# Empty (or whatever was there before)
Start a new container:
sudo ./minicontainer run /bin/sh
Inside this new container:
# The file from the previous container is gone
cat /tmp/test.txt
# No such file or directory
# curl is not installed
which curl
# Not found
Each container starts fresh from the base image. That is exactly how Docker works.
How Docker Images Use Layers
A Docker image is a stack of OverlayFS layers. When you write a Dockerfile like this:
FROM alpine:3.21
RUN apk add curl
RUN apk add git
COPY app /app
Docker creates four layers:
- Layer 1 — Alpine base filesystem
- Layer 2 — files added by
apk add curl - Layer 3 — files added by
apk add git - Layer 4 — the
appbinary
OverlayFS can stack multiple lower layers:
merged = overlay(lower1 + lower2 + lower3 + lower4 + upper)
When you docker pull, you download each layer. If two images share the same base layer, you only download it once. This is why Docker images are fast to download and storage-efficient.
Our implementation uses a single lower layer for simplicity. But the concept is identical to what Docker does.
The Complete Code
Here is the full main.go at the end of Part 2:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: minicontainer run <command> [args...]")
os.Exit(1)
}
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
fmt.Println("Unknown command:", os.Args[1])
os.Exit(1)
}
}
func run() {
fmt.Println("[parent] Starting container...")
containerID := fmt.Sprintf("container-%d", os.Getpid())
mergedDir, err := setupOverlayfs(containerID)
if err != nil {
fmt.Println("Error setting up overlayfs:", err)
os.Exit(1)
}
cmd := exec.Command("/proc/self/exe", append([]string{"child", mergedDir}, 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 |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWNET,
}
if err := cmd.Run(); err != nil {
fmt.Println("Error:", err)
}
syscall.Unmount(mergedDir, 0)
fmt.Println("[parent] Container stopped. Changes in:", filepath.Join("containers", containerID, "upper"))
}
func child() {
rootfs := os.Args[2]
args := os.Args[3:]
fmt.Printf("[container] Running %v as PID %d\n", args, os.Getpid())
if err := syscall.Sethostname([]byte("container")); err != nil {
fmt.Println("Error setting hostname:", err)
os.Exit(1)
}
if err := setupRootfs(rootfs); err != nil {
fmt.Println("Error setting up rootfs:", err)
os.Exit(1)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
func setupOverlayfs(containerID string) (string, error) {
baseDir := filepath.Join("containers", containerID)
lowerDir := "rootfs"
upperDir := filepath.Join(baseDir, "upper")
workDir := filepath.Join(baseDir, "work")
mergedDir := filepath.Join(baseDir, "merged")
for _, dir := range []string{upperDir, workDir, mergedDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create dir %s: %w", dir, err)
}
}
opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", lowerDir, upperDir, workDir)
if err := syscall.Mount("overlay", mergedDir, "overlay", 0, opts); err != nil {
return "", fmt.Errorf("mount overlay: %w", err)
}
return mergedDir, nil
}
func setupRootfs(rootfs string) error {
if err := syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("bind mount rootfs: %w", err)
}
oldRoot := filepath.Join(rootfs, "oldroot")
if err := os.MkdirAll(oldRoot, 0700); err != nil {
return fmt.Errorf("create oldroot dir: %w", err)
}
if err := syscall.PivotRoot(rootfs, oldRoot); err != nil {
return fmt.Errorf("pivot_root: %w", err)
}
if err := os.Chdir("/"); err != nil {
return fmt.Errorf("chdir: %w", err)
}
if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
return fmt.Errorf("mount /proc: %w", err)
}
if err := syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755"); err != nil {
return fmt.Errorf("mount /dev: %w", err)
}
createDeviceNodes()
if err := syscall.Unmount("/oldroot", syscall.MNT_DETACH); err != nil {
return fmt.Errorf("unmount old root: %w", err)
}
if err := os.Remove("/oldroot"); err != nil {
return fmt.Errorf("remove oldroot: %w", err)
}
return nil
}
func createDeviceNodes() {
devices := []struct {
path string
major uint32
minor uint32
}{
{"/dev/null", 1, 3},
{"/dev/zero", 1, 5},
{"/dev/random", 1, 8},
{"/dev/urandom", 1, 9},
{"/dev/tty", 5, 0},
}
for _, dev := range devices {
devNum := int(dev.major*256 + dev.minor)
syscall.Mknod(dev.path, syscall.S_IFCHR|0666, devNum)
}
}
What We Built So Far
After two parts, your mini container runtime can:
- Isolate processes with PID, UTS, Mount, IPC, and Network namespaces
- Set a custom hostname for each container
- Provide a complete Alpine Linux filesystem
- Use
pivot_rootto prevent access to the host filesystem - Use OverlayFS so containers start fresh from a shared base image
- Mount
/procand/devinside the container
That is already a functional container. But it has no resource limits and no networking.
What’s Next?
In Part 3: Cgroups, Networking, and a CLI, you will:
- Add cgroups to limit memory and CPU usage
- Set up virtual ethernet pairs for container networking
- Build a proper CLI with Cobra (run, ps, exec commands)
- Run the final “container” and compare it with Docker
Related Articles
- Build Docker from Scratch in Go — Part 1: Linux Namespaces — previous part
- Go Tutorial: Getting Started with Go — learn Go from the beginning
- Docker Cheat Sheet 2026 — quick reference for Docker commands
- Docker Tutorial: Volumes and Networking — Docker networking explained