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 chroot and pivot_root
  • Download and set up a minimal root filesystem
  • Implement pivot_root in Go
  • Add OverlayFS for layered filesystems (just like Docker images)
  • Mount /proc inside 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:

  1. Bind mountpivot_root requires the new root to be a mount point. A bind mount onto itself makes this work.
  2. Create oldrootpivot_root needs a place to put the old root filesystem.
  3. pivot_root — swaps the root. The old root is now at /oldroot.
  4. chdir — move to the new root directory.
  5. Mount /proc — so ps, top, and other tools work inside the container.
  6. Mount /dev — a temporary filesystem for device files.
  7. Unmount oldroot — remove access to the host filesystem. This is the key security step.
  8. 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:

  1. Layer 1 — Alpine base filesystem
  2. Layer 2 — files added by apk add curl
  3. Layer 3 — files added by apk add git
  4. Layer 4 — the app binary

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_root to prevent access to the host filesystem
  • Use OverlayFS so containers start fresh from a shared base image
  • Mount /proc and /dev inside 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