Containers are not virtual machines. They are just Linux processes with extra isolation. In this series, you will build a mini Docker from scratch in Go. No frameworks. No libraries. Just Go and Linux system calls.

By the end of this three-part series, you will have a working container runtime that can:

  • Isolate processes with Linux namespaces
  • Create a separate filesystem with OverlayFS
  • Limit resources with cgroups
  • Set up networking with virtual ethernet pairs
  • Run commands through a CLI

In this first part, you will learn what containers really are and how to isolate processes using Linux namespaces.

Prerequisites

You need:

  • Go 1.21 or later installed
  • A Linux machine (or a Linux VM — namespaces are a Linux feature)
  • Root access (namespace creation requires privileges)
  • Basic Go knowledge (see our Go tutorial series)

What Are Containers, Really?

When you run docker run ubuntu /bin/bash, Docker does not start a virtual machine. It starts a regular Linux process. But that process thinks it is alone on the machine.

How? Three Linux kernel features:

  1. Namespaces — control what a process can see (other processes, network, filesystem)
  2. Cgroups — control what a process can use (memory, CPU, disk I/O)
  3. Filesystem isolation — give the process its own root filesystem

That is it. A “container” is a process with restricted visibility and limited resources. Nothing more.

Containers vs Virtual Machines

People often compare containers to virtual machines. They are very different:

Virtual Machine:                    Container:
┌──────────────┐                   ┌──────────────┐
│   Your App   │                   │   Your App   │
├──────────────┤                   ├──────────────┤
│  Guest OS    │                   │  Namespaces  │
│  (full Linux)│                   │  + Cgroups   │
├──────────────┤                   ├──────────────┤
│  Hypervisor  │                   │  Host Kernel │
├──────────────┤                   │  (shared)    │
│  Host OS     │                   │              │
└──────────────┘                   └──────────────┘

A virtual machine runs a complete operating system with its own kernel. It needs a hypervisor (like VirtualBox or KVM) to manage hardware access. This is slow to start and uses a lot of memory.

A container shares the host’s kernel. It uses namespaces to isolate visibility and cgroups to limit resources. There is no separate OS. The container starts in milliseconds and uses almost no extra memory.

That is why containers are so fast. They are just regular processes with boundaries.

Project Setup

Create a new Go project:

mkdir minicontainer && cd minicontainer
go mod init github.com/kemalcodes/minicontainer

Create the main file:

// main.go
package main

import (
	"fmt"
	"os"
	"os/exec"
	"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)
	}
}

You will use a two-phase approach. The run command creates a new process with namespaces. The child command runs inside those namespaces. This is the same pattern that real container runtimes like runc use.

Linux Namespaces

Linux has eight types of namespaces. Each one isolates a different part of the system:

NamespaceFlagWhat It Isolates
PIDCLONE_NEWPIDProcess IDs
UTSCLONE_NEWUTSHostname and domain name
MountCLONE_NEWNSFilesystem mounts
NetworkCLONE_NEWNETNetwork interfaces, IP addresses, routing
IPCCLONE_NEWIPCInter-process communication
UserCLONE_NEWUSERUser and group IDs
CgroupCLONE_NEWCGROUPCgroup root directory
TimeCLONE_NEWTIMESystem clocks (added in Linux 5.6)

When you create a new namespace, the process inside it gets a fresh, isolated view of that resource. For example, a process in a new PID namespace sees itself as PID 1. It cannot see any other processes on the host.

Your First Isolated Process

Add the run function:

func run() {
	fmt.Println("[parent] Starting container...")

	// Re-execute ourselves with the "child" command
	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// Create new namespaces for the child process
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS |
			syscall.CLONE_NEWPID |
			syscall.CLONE_NEWNS,
	}

	if err := cmd.Run(); err != nil {
		fmt.Println("Error:", err)
		os.Exit(1)
	}
}

There is a trick here. /proc/self/exe is a special Linux path that points to the currently running program. So this code runs itself again, but this time with the child argument and inside new namespaces.

Why not run the user command directly? Because we need to do setup work inside the namespaces before running the actual command. The child function handles that setup.

The Cloneflags field tells the kernel which namespaces to create:

  • CLONE_NEWUTS — the child gets its own hostname. UTS stands for “Unix Time-Sharing System.” This lets you give each container a unique hostname without changing the host.
  • CLONE_NEWPID — the child gets its own process ID space. It will be PID 1, the init process of its own little world. It cannot see any processes from the host.
  • CLONE_NEWNS — the child gets its own mount table. “NS” stands for namespace — this was the first namespace type added to Linux, before the naming convention was established. With a mount namespace, the container can mount and unmount filesystems without affecting the host.

Setting Up the Child Process

Add the child function:

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

	// Set a custom hostname for the container
	if err := syscall.Sethostname([]byte("container")); err != nil {
		fmt.Println("Error setting hostname:", 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)
	}
}

Build and test it:

go build -o minicontainer .
sudo ./minicontainer run /bin/bash

Inside the container shell, check the hostname:

hostname
# Output: container

The hostname changed. But the host machine’s hostname is untouched. That is the UTS namespace at work.

Verifying PID Isolation

Inside the container shell, check the process ID:

echo $$
# Output: some number (not 1 yet — we will fix this)

The PID namespace is active, but you will not see PID 1 for the bash shell because the child process is PID 1, and bash is its child process. Let’s verify by mounting /proc and checking:

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

	// Set a custom hostname
	if err := syscall.Sethostname([]byte("container")); err != nil {
		fmt.Println("Error setting hostname:", err)
		os.Exit(1)
	}

	// Make mount namespace private to prevent leaks to host
	// This is important on systemd-based systems where / is shared
	if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
		fmt.Println("Error making mount private:", err)
		os.Exit(1)
	}

	// Mount a new /proc so ps works correctly inside the container
	if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
		fmt.Println("Error mounting /proc:", err)
		os.Exit(1)
	}

	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)
	}

	// Unmount /proc when done
	syscall.Unmount("/proc", 0)
}

Now rebuild and run again:

go build -o minicontainer .
sudo ./minicontainer run /bin/bash

Inside the container:

ps aux
# Only shows processes inside the container
# PID 1 is the minicontainer child process

You can only see processes inside your container. The host’s processes are invisible. That is PID namespace isolation.

Understanding the Two-Phase Pattern

Why do container runtimes use this two-phase approach? Here is what happens step by step:

1. User runs: minicontainer run /bin/bash
2. run() creates a new process with new namespaces
3. The new process runs: minicontainer child /bin/bash
4. child() is now INSIDE the new namespaces
5. child() sets up hostname, mounts, etc.
6. child() runs /bin/bash

This pattern exists because namespace setup must happen from inside the namespace. You cannot set a container’s hostname from outside. You must be inside the UTS namespace first.

The real Docker does the same thing. The docker run command talks to the Docker daemon. The daemon calls runc, which forks itself into new namespaces and then sets up the container environment.

Adding All Namespaces

Update the run function to use more namespaces:

func run() {
	fmt.Println("[parent] Starting container...")

	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 |
			syscall.CLONE_NEWIPC |
			syscall.CLONE_NEWNET,
	}

	if err := cmd.Run(); err != nil {
		fmt.Println("Error:", err)
		os.Exit(1)
	}
}

Now the container has:

  • Its own hostname (UTS)
  • Its own process tree (PID)
  • Its own mount table (Mount)
  • Its own IPC (shared memory, semaphores, message queues)
  • Its own network stack (Network — interfaces, IP addresses, routing tables, firewall rules)

Each namespace adds another layer of isolation. The more namespaces you use, the more the process feels like a separate machine.

Try it with the network namespace. Inside the container:

ip link
# Only shows "lo" (loopback) — no host network interfaces

The container has no network access. It only has a loopback interface. In Part 3, we will set up networking with virtual ethernet pairs.

What About the User Namespace?

You might wonder about CLONE_NEWUSER. User namespaces let you run containers without root. The process inside the namespace thinks it is root (UID 0), but on the host it is a regular user.

Here is how to add it:

cmd.SysProcAttr = &syscall.SysProcAttr{
	Cloneflags: syscall.CLONE_NEWUTS |
		syscall.CLONE_NEWPID |
		syscall.CLONE_NEWNS |
		syscall.CLONE_NEWUSER,
	UidMappings: []syscall.SysProcIDMap{
		{ContainerID: 0, HostID: os.Getuid(), Size: 1},
	},
	GidMappings: []syscall.SysProcIDMap{
		{ContainerID: 0, HostID: os.Getgid(), Size: 1},
	},
}

This maps the container’s root user (UID 0) to your actual user on the host. The process inside the container believes it is root, but it has no special privileges on the host.

User namespaces are powerful but add complexity. For this tutorial series, we will run as root to keep things simple. Real container runtimes like Podman use user namespaces for rootless containers.

The Complete Code So Far

Here is the full main.go at the end of Part 1:

package main

import (
	"fmt"
	"os"
	"os/exec"
	"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...")

	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 |
			syscall.CLONE_NEWIPC |
			syscall.CLONE_NEWNET,
	}

	if err := cmd.Run(); err != nil {
		fmt.Println("Error:", err)
		os.Exit(1)
	}
}

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)
	}

	// Make mount namespace private
	if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
		fmt.Println("Error making mount private:", err)
		os.Exit(1)
	}

	// Mount /proc for process visibility
	if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
		fmt.Println("Error mounting /proc:", 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)
	}

	// Clean up
	syscall.Unmount("/proc", 0)
}

Testing the Container

Build and run:

go build -o minicontainer .
sudo ./minicontainer run /bin/bash

Inside the container, run these checks:

# Check hostname — should show "container"
hostname

# Check processes — should only show container processes
ps aux

# Check network — should only show loopback
ip link

# Check PID — the shell should be PID 2 (child process is PID 1)
echo $$

# Exit the container
exit

You now have a process that:

  • Has its own hostname
  • Can only see its own processes
  • Has no network access
  • Has its own mount table

But the process still shares the host’s filesystem. It can read and write any file on the host. In Part 2, we will fix that with filesystem isolation.

Security: What Namespaces Do NOT Protect

Namespaces provide isolation, but they are not a complete security solution by themselves. Here are some things to keep in mind:

Shared kernel. The container and the host share the same Linux kernel. A kernel exploit inside the container can affect the host. Virtual machines do not have this problem because they run separate kernels.

Root inside the container. If the container process runs as root, and it escapes the namespace (through a bug), it has root access on the host. That is why production containers use user namespaces and drop unnecessary capabilities.

System calls. The container can call any system call the kernel supports. Docker uses seccomp profiles to restrict which system calls are allowed. Our mini runtime does not do this yet.

No filesystem isolation yet. Right now, the container can read and write the host filesystem. We will fix this in Part 2 with pivot_root.

For a tutorial project, running as root with basic namespaces is fine. For production, you need multiple layers of security: user namespaces, seccomp, AppArmor or SELinux, dropped capabilities, and read-only filesystems.

What’s Next?

In Part 2: Filesystem Isolation with chroot and OverlayFS, you will:

  • Use pivot_root to give the container its own filesystem
  • Create a minimal root filesystem with Alpine Linux
  • Set up OverlayFS for layered filesystems, just like Docker images
  • Mount /proc and /dev inside the isolated filesystem