# Containers From Scratch in Go

- URL: https://karandeepsingh.ca/posts/containers-from-scratch-go/
- Author: Karandeep Singh
- Published: 2026-02-14
- Series: Kubernetes and Containers
- Tags: golang, docker, containers, linux
- Summary: Build your own container runtime in Go from scratch. Start by running a command, then add PID isolation, filesystem isolation, hostname, cgroups, one concept at a time.

Most people think containers are lightweight VMs. They're not. A container is just a regular Linux process with three tricks applied to it:

- **Namespaces:** control what a process can *see* (its own PIDs, its own hostname, its own filesystem)
- **Cgroups:** control what a process can *use* (CPU, memory)
- **Chroot:** give the process its own root filesystem

That's it. No hypervisor, no kernel emulation. We're going to prove this by building a container runtime called **Gocker** in about 150 lines of Go, using only the standard library.

_Diagram: Every one of these is a kernel feature that predates Docker. Docker's contribution was packaging and images, not isolation._

By the end, you'll have a working `gocker run /bin/sh` that gives you an isolated shell with its own PID 1, its own hostname, its own filesystem, and memory/CPU limits, just like Docker.

> **The full code is on GitHub:** [karandaid/containers-from-scratch](https://github.com/karandaid/containers-from-scratch)

![Colorful stacked shipping containers representing software container isolation](https://karandeepsingh.ca/images/posts/containers-from-scratch-go.jpg)

## What We're Building

A mini container runtime called Gocker that can:
- Run any command in an isolated environment
- Give the process its own PID namespace (PID 1)
- Give it a custom hostname
- Chroot into an Alpine Linux filesystem
- Mount `/proc` and create device nodes
- Limit memory and CPU with cgroups

**The journey:**
1. Run a command in a child process
2. Isolate process IDs with PID namespace
3. Give it a custom hostname with UTS namespace
4. Chroot into an Alpine root filesystem (the simple way, then break it)
5. The `/proc/self/exe` trick: how real container runtimes work
6. Mount `/proc` and `/dev` inside the container
7. Limit memory and CPU with cgroups
8. Replace the process with `syscall.Exec` for PID 1 for real

## Prerequisites

- **Linux** (Ubuntu 20.04+ recommended), since namespaces and cgroups are Linux kernel features
- Go 1.21+ installed
- Root access (`sudo`), because creating namespaces requires root

> **macOS won't work.** Docker on Mac secretly runs a Linux VM. The syscalls we use don't exist in the Darwin kernel.

**Decision:**
This needs **Linux**, **root**, and — for Step 7 only — the **cgroup v1** hierarchy.

On macOS or Windows the syscalls simply do not exist. On Ubuntu 22.04+ you get
cgroup **v2**, where the file names differ; Steps 1–6 and 8 still work unchanged.

**Do you accept?**

## Step 1: Run a Command in a Child Process

**What:** Use Go's `exec.Command` to spawn a shell.

**Why:** Before we isolate anything, we need a process to isolate. This is the "hello world": just run `/bin/sh` as a child process.

```bash
mkdir containers-from-scratch && cd containers-from-scratch
go mod init gocker
```

**`main.go`**
```go
package main

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

func main() {
	fmt.Println("Gocker - a minimal container runtime")

	cmd := exec.Command("/bin/sh")
	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)
	}
}
```

We connect the child's stdin, stdout, and stderr to our own so we can type commands and see output. `exec.Command` creates a child process that runs the given binary.

Build and run:

```text
$ go build -o gocker .
$ sudo ./gocker
Gocker - a minimal container runtime
```

You get a shell. Type some commands:

```text
$ echo $$        # a large number, like 4523
$ hostname       # your host machine's name
$ ls /           # your host machine's filesystem
$ exit           # leave the shell
```

This isn't a container yet. It's just a shell running as a child process. It can see everything on the host: all processes, all files, the real hostname. Let's start isolating it.

## Step 2: Isolate Process IDs (PID Namespace)

**What:** Create a new PID namespace so the child process thinks it's PID 1.

**Why:** In a real container, `ps aux` should only show the container's own processes, not the host's. PID namespace is the first step; it gives the process its own set of process IDs, starting from 1.

The whole change is four lines:

```diff
# main.go
@@
 import (
 	"fmt"
 	"os"
 	"os/exec"
+	"syscall"
 )
@@
 	cmd.Stderr = os.Stderr
 
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		Cloneflags: syscall.CLONE_NEWPID,
+	}
+
 	if err := cmd.Run(); err != nil {
```

The full file, for reference:

**`main.go`**
```go
package main

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

func main() {
	fmt.Println("Gocker - a minimal container runtime")

	cmd := exec.Command("/bin/sh")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID,
	}

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

`SysProcAttr` lets us set Linux-specific process attributes. `CLONE_NEWPID` tells the kernel to create a new PID namespace for this child process. Inside that namespace, the child will be PID 1.

Build and run:

```text
$ go build -o gocker . && sudo ./gocker
Gocker - a minimal container runtime
$ echo $$
1
```

The child process thinks it's PID 1. That's the init process of our mini "container." But try running `ps aux`:

```bash
ps aux         # Still shows ALL host processes!
```

Why? Because `ps` reads from `/proc`, and `/proc` still points to the host's proc filesystem. We'll fix this after we isolate the filesystem. For now, the PID *is* isolated; it's just that the tools for inspecting it (`ps`) are still looking at the host.

_Diagram: One process, two numbers. The host never loses sight of it — a PID namespace does not hide the process, it renumbers it._

## Step 3: Isolate the Hostname (UTS Namespace)

**What:** Create a UTS namespace so the container has its own hostname.

**Why:** When you SSH into a Docker container, the prompt shows the container ID as the hostname, not the host machine's name. UTS namespace makes this possible.

This step is one word:

```diff
# main.go
@@
 	cmd.SysProcAttr = &syscall.SysProcAttr{
-		Cloneflags: syscall.CLONE_NEWPID,
+		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
 	}
```

The full file, for reference:

**`main.go`**
```go
package main

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

func main() {
	fmt.Println("Gocker - a minimal container runtime")

	cmd := exec.Command("/bin/sh")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
	}

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

We added `CLONE_NEWUTS` with the `|` (bitwise OR) operator. Multiple namespace flags combine this way. UTS stands for "Unix Time-Sharing," and it isolates the hostname and domain name.

Build and run:

```text
$ go build -o gocker . && sudo ./gocker
Gocker - a minimal container runtime
$ hostname gocker    # set the hostname inside the "container"
$ hostname
gocker
$ exit
$ hostname           # back on the host — unchanged
yyc-workstation
```

The hostname change only affected the child process. The host is untouched. That's namespace isolation in action.

But setting the hostname manually isn't great. In Docker, the container gets its hostname automatically. We'll automate that once we restructure the code in Step 5.

## Step 4: Chroot Into Alpine (The Simple Way)

**What:** Give the container its own root filesystem using `chroot`.

**Why:** Right now, the container sees the host's entire filesystem. A real container has its own, isolated filesystem, usually a minimal Linux distribution like Alpine.

First, download the Alpine root filesystem:

```bash
wget https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.0-x86_64.tar.gz
mkdir -p gocker-root
tar -xzf alpine-minirootfs-3.19.0-x86_64.tar.gz -C gocker-root
```

Now you have a complete Linux filesystem in `gocker-root/`, about 3MB. This is the same rootfs that Docker's Alpine image uses.

Three additions — a path, a working directory, and one struct field:

```diff
# main.go
@@
 func main() {
 	fmt.Println("Gocker - a minimal container runtime")
 
+	rootfs := "./gocker-root"
+
 	cmd := exec.Command("/bin/sh")
 	cmd.Stdin = os.Stdin
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
+	cmd.Dir = "/"
 
 	cmd.SysProcAttr = &syscall.SysProcAttr{
 		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
+		Chroot:     rootfs,
 	}
```

The full file, for reference:

**`main.go`**
```go
package main

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

func main() {
	fmt.Println("Gocker - a minimal container runtime")

	rootfs := "./gocker-root"

	cmd := exec.Command("/bin/sh")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Dir = "/"

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
		Chroot:     rootfs,
	}

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

`SysProcAttr.Chroot` tells the kernel to change the root directory for the child process before it starts. `cmd.Dir = "/"` sets the working directory to `/` (relative to the new root).

Build and run:

```text
$ cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.19.0
PRETTY_NAME="Alpine Linux v3.19"
$ ls /
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
```

We're inside Alpine! The container can't see the host filesystem anymore. Now try `ps`:

```text
$ ps aux
# exits with status 1
ps: can't open '/proc': No such file or directory
```

`ps` reads `/proc`, and `/proc` doesn't exist inside our chroot. Fair enough — let's mount it:

```text
$ mount -t proc proc /proc && ps aux
# exits with status 0
| PID   USER     TIME  COMMAND
| 1     root     0:12  /sbin/init
| 412   root     0:03  /usr/lib/systemd/systemd-journald
| 1189  root     0:00  sshd: /usr/sbin/sshd -D
#  ...every process on the HOST. Exit status 0, and completely wrong.
```

No error this time, which is worse — the command succeeded and told us something false.

Wait, we're in a new PID namespace, so why does `ps` show host processes? Here's the problem: `SysProcAttr.Chroot` applies chroot *before* the process enters the new PID namespace. So when we mount `/proc` inside the container, it still gets the host's proc filesystem. The namespaces and chroot happen in the wrong order.

This is a real problem. We need to restructure the code so that we're *inside* the new namespace before we do the chroot and mount. That requires a trick.

## Step 5: The `/proc/self/exe` Trick

**What:** Restructure the program so the container process does its own setup after entering the namespace.

**Why:** The previous step showed that `SysProcAttr.Chroot` happens at the wrong time. We need the child to set up its own filesystem after it's inside the new namespace. The solution: the program re-executes itself.

This is how real container runtimes like `runc` (the runtime behind Docker) work. The parent spawns a child in new namespaces, and the child sets up its own environment.

Replace your entire `main.go`:

**`main.go`**
```go
package main

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

func run() {
	// Re-execute ourselves with "child" as the first argument
	args := append([]string{"child"}, os.Args[2:]...)

	cmd := exec.Command("/proc/self/exe", args...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
	}

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

func child() {
	// We're now INSIDE the new namespaces — do setup here
	if err := syscall.Chroot("./gocker-root"); err != nil {
		fmt.Println("Error chroot:", err)
		os.Exit(1)
	}

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

	if err := syscall.Sethostname([]byte("gocker")); 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)
	}
}

func main() {
	fmt.Println("Gocker - a minimal container runtime")

	if len(os.Args) < 3 {
		fmt.Println("Usage gocker run <command>")
		os.Exit(1)
	}

	switch os.Args[1] {
	case "run":
		run()
	case "child":
		child()
	default:
		fmt.Println("Unknown command. Usage: gocker run <command>")
		os.Exit(1)
	}
}
```

Here's the magic: `/proc/self/exe` is a special Linux path that always points to the currently running binary. So `exec.Command("/proc/self/exe", "child", ...)` re-executes our own `gocker` binary with `"child"` as the first argument.

The flow is:

_Diagram: The same binary runs twice. Step 3 is the only place namespaces are created; everything after it already lives inside them, which is exactly what Step 4 could not do._

Build and run:

```text
$ hostname           # set automatically now
gocker
$ echo $$            # PID 1 inside the namespace
1
$ cat /etc/os-release | head -1
NAME="Alpine Linux"
$ exit
```

Now the hostname is set automatically, and chroot happens after we enter the namespace. But `ps` still doesn't work because `/proc` isn't mounted yet.

## Step 6: Mount `/proc` and `/dev`

**What:** Mount the proc filesystem and create essential device nodes inside the container.

**Why:** Without `/proc`, tools like `ps`, `top`, and `free` won't work. Without `/dev/null`, `/dev/zero`, and `/dev/random`, many programs crash or behave unexpectedly.

Everything goes into `child()`, between the `chdir` and the hostname:

```diff
# main.go
@@
+func makedev(major, minor uint32) uint64 {
+	return uint64(major)*256 + uint64(minor)
+}
+
 func child() {
@@
 	if err := os.Chdir("/"); err != nil {
 		fmt.Println("Error chdir:", err)
 		os.Exit(1)
 	}
 
+	// Mount /proc — makes ps, top, free work
+	if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
+		fmt.Println("Error mounting proc:", err)
+		os.Exit(1)
+	}
+
+	// Mount /dev as tmpfs
+	if err := syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""); err != nil {
+		fmt.Println("Error mounting dev:", err)
+		os.Exit(1)
+	}
+
+	// Create essential device nodes
+	devNull := syscall.Mknod("/dev/null", 0666|syscall.S_IFCHR, int(makedev(1, 3)))
+	if devNull != nil {
+		fmt.Println("Error creating /dev/null:", devNull)
+	}
+
+	devZero := syscall.Mknod("/dev/zero", 0666|syscall.S_IFCHR, int(makedev(1, 5)))
+	if devZero != nil {
+		fmt.Println("Error creating /dev/zero:", devZero)
+	}
+
+	devRandom := syscall.Mknod("/dev/random", 0666|syscall.S_IFCHR, int(makedev(1, 8)))
+	if devRandom != nil {
+		fmt.Println("Error creating /dev/random:", devRandom)
+	}
+
 	if err := syscall.Sethostname([]byte("gocker")); err != nil {
```

The whole function, for reference:

**`child()`, updated:**
```go
func makedev(major, minor uint32) uint64 {
	return uint64(major)*256 + uint64(minor)
}

func child() {
	if err := syscall.Chroot("./gocker-root"); err != nil {
		fmt.Println("Error chroot:", err)
		os.Exit(1)
	}

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

	// Mount /proc — makes ps, top, free work
	if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
		fmt.Println("Error mounting proc:", err)
		os.Exit(1)
	}

	// Mount /dev as tmpfs
	if err := syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""); err != nil {
		fmt.Println("Error mounting dev:", err)
		os.Exit(1)
	}

	// Create essential device nodes
	devNull := syscall.Mknod("/dev/null", 0666|syscall.S_IFCHR, int(makedev(1, 3)))
	if devNull != nil {
		fmt.Println("Error creating /dev/null:", devNull)
	}

	devZero := syscall.Mknod("/dev/zero", 0666|syscall.S_IFCHR, int(makedev(1, 5)))
	if devZero != nil {
		fmt.Println("Error creating /dev/zero:", devZero)
	}

	devRandom := syscall.Mknod("/dev/random", 0666|syscall.S_IFCHR, int(makedev(1, 8)))
	if devRandom != nil {
		fmt.Println("Error creating /dev/random:", devRandom)
	}

	if err := syscall.Sethostname([]byte("gocker")); err != nil {
		fmt.Println("Error setting hostname:", 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)
	}
}
```

Add `"path/filepath"` and `"strconv"` to your imports (you'll need them for cgroups in the next step).

`syscall.Mount("proc", "/proc", "proc", 0, "")` mounts the proc filesystem. Because we're in a new PID namespace and we mounted *after* entering it, `/proc` now shows only our container's processes.

`syscall.Mknod` creates device nodes. The numbers (`1,3` for null, `1,5` for zero, `1,8` for random) are the standard Linux major/minor device numbers. The `makedev` helper combines them into the format `Mknod` expects.

Build and run:

```text
$ ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    2 root      0:00 ps aux
```

Only two processes! PID 1 is our shell, PID 2 is the `ps` command itself. No host processes visible. This is real container isolation.

```text
$ echo "test" > /dev/null
$ cat /dev/random | head -1
$ exit
```

## Step 7: Limit Resources with Cgroups

**What:** Use cgroups to limit how much memory and CPU the container can use.

**Why:** Without resource limits, a container can eat all the host's memory and crash everything. Cgroups (control groups) let you set hard limits per process.

Add the `setupCgroup` function and call it from `run()` after the child starts:

**Add this function:**
```go
func setupCgroup(pid int) error {
	cgroupMemoryPath := "/sys/fs/cgroup/memory/gocker/"
	cgroupCPUPath := "/sys/fs/cgroup/cpu/gocker/"

	// Memory limit: 50MB
	if err := os.MkdirAll(cgroupMemoryPath, 0755); err != nil {
		return fmt.Errorf("create cgroup: %w", err)
	}

	memLimit := filepath.Join(cgroupMemoryPath, "memory.limit_in_bytes")
	if err := os.WriteFile(memLimit, []byte("52428800"), 0644); err != nil {
		return fmt.Errorf("set memory limit: %w", err)
	}

	procs := filepath.Join(cgroupMemoryPath, "cgroup.procs")
	if err := os.WriteFile(procs, []byte(strconv.Itoa(pid)), 0644); err != nil {
		return fmt.Errorf("assign process: %w", err)
	}

	// CPU limit: 50%
	if err := os.MkdirAll(cgroupCPUPath, 0755); err != nil {
		return fmt.Errorf("create cpu cgroup: %w", err)
	}

	cpuLimit := filepath.Join(cgroupCPUPath, "cpu.cfs_period_us")
	if err := os.WriteFile(cpuLimit, []byte("100000"), 0644); err != nil {
		return fmt.Errorf("set cpu period: %w", err)
	}

	cpuQLimit := filepath.Join(cgroupCPUPath, "cpu.cfs_quota_us")
	if err := os.WriteFile(cpuQLimit, []byte("50000"), 0644); err != nil {
		return fmt.Errorf("set cpu quota: %w", err)
	}

	cpuProcs := filepath.Join(cgroupCPUPath, "cgroup.procs")
	if err := os.WriteFile(cpuProcs, []byte(strconv.Itoa(pid)), 0644); err != nil {
		return fmt.Errorf("assign cpu process: %w", err)
	}

	fmt.Printf("Cgroup: PID %d limited to 50MB memory, 50%% CPU\n", pid)
	return nil
}
```

**And call it from `run()`, after the child has started but before we wait on it** — the PID has to exist before it can be written into `cgroup.procs`:

```diff
# main.go
@@ func run() {
 	if err := cmd.Start(); err != nil {
 		fmt.Println("Error Starting:", err)
 		os.Exit(1)
 	}
+	if err := setupCgroup(cmd.Process.Pid); err != nil {
+		fmt.Println("Error setting cgroup:", err)
+		os.Exit(1)
+	}
 	if err := cmd.Wait(); err != nil {
```

The whole function, for reference:
```go
func run() {
	args := append([]string{"child"}, os.Args[2:]...)

	cmd := exec.Command("/proc/self/exe", args...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
	}

	if err := cmd.Start(); err != nil {
		fmt.Println("Error Starting:", err)
		os.Exit(1)
	}
	if err := setupCgroup(cmd.Process.Pid); err != nil {
		fmt.Println("Error setting cgroup:", err)
		os.Exit(1)
	}
	if err := cmd.Wait(); err != nil {
		fmt.Println("Error:", err)
		os.Exit(1)
	}
}
```

Cgroups are just files in `/sys/fs/cgroup/`. To limit memory, you write the byte limit to `memory.limit_in_bytes`. To limit CPU, you set the CFS (Completely Fair Scheduler) period and quota. A period of `100000` microseconds (100ms) with a quota of `50000` means the process gets 50ms out of every 100ms. That's 50% of one CPU core.

_Diagram: This is why a CPU limit is a *throttle*, not a *cap on speed*. The process runs at full speed for its 50 ms, then the scheduler stops it dead until the next period begins._

Writing the child's PID to `cgroup.procs` tells the kernel to apply these limits to that process and all its children.

**warning:**
The `/sys/fs/cgroup/memory/.../memory.limit_in_bytes` and `cpu.cfs_*` paths above are **cgroup v1**. On cgroup v2 systems (the default on Ubuntu 22.04+/24.04, Fedora, and most modern distros) the layout differs — there's a unified hierarchy and memory limits live in `memory.max`, not `memory.limit_in_bytes`. This demo assumes cgroup v1, so boot with `systemd.unified_cgroup_hierarchy=0` (or otherwise enable the v1 hierarchy) for these exact paths to exist.

Build and run:

```text
$ go build -o gocker . && sudo ./gocker run /bin/sh
Gocker - a minimal container runtime
Cgroup: PID 12345 limited to 50MB memory, 50% CPU
```

Test the memory limit from inside the container:

```text
# tail /dev/zero reads an endless stream into memory
$ tail /dev/zero
Killed
```

`tail /dev/zero` keeps reading an endless stream of zero bytes into memory, so its resident memory grows without bound. The process should be killed by the OOM (Out of Memory) killer as soon as it tries to exceed the 50MB limit. (Note: `dd if=/dev/zero of=/dev/null` would *not* work here — it streams bytes through a single reused buffer and never actually holds 50MB resident.)

## Step 8: Make the Command PID 1 (`syscall.Exec`)

**What:** Replace `exec.Command` + `cmd.Run()` in the child with `syscall.Exec` so the user's command becomes PID 1.

**Why:** With `cmd.Run()`, our Go child process is PID 1 and the user's command is PID 2. With `syscall.Exec`, the Go process replaces itself entirely with the user's command. Now it's truly PID 1 in the container, just like Docker.

Replace the `exec.Command` block at the end of `child()`:

```diff
# main.go
@@ func child() {
-	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)
-	}
+	// Replace this process with the user's command
+	// This makes it PID 1 — exactly what Docker does
+	binary, err := exec.LookPath(os.Args[2])
+	if err != nil {
+		fmt.Println("Error finding command:", err)
+		os.Exit(1)
+	}
+
+	if err := syscall.Exec(binary, os.Args[2:], os.Environ()); err != nil {
+		fmt.Println("Error exec:", err)
+		os.Exit(1)
+	}
 }
```

The replacement on its own, for reference:

**`child()`, final version. Replace the last part:**
```go
	// Replace this process with the user's command
	// This makes it PID 1 — exactly what Docker does
	binary, err := exec.LookPath(os.Args[2])
	if err != nil {
		fmt.Println("Error finding command:", err)
		os.Exit(1)
	}

	if err := syscall.Exec(binary, os.Args[2:], os.Environ()); err != nil {
		fmt.Println("Error exec:", err)
		os.Exit(1)
	}
```

`exec.LookPath` finds the full path to the binary (e.g., turns `sh` into `/bin/sh`). `syscall.Exec` replaces the current process entirely; it doesn't return. After this call, our Go code is gone and the user's command is running as PID 1.

The difference:

**before — cmd.Run()**
```
PID 1  gocker child
PID 2  /bin/sh
```

Two processes. Our Go program is PID 1, the user's command is its child.

Signals sent to the container hit **our** program, not the shell — and reaping zombies becomes our job.

**after — syscall.Exec()**
```
PID 1  /bin/sh
```

One process. Same PID, new program image — the Go code is **gone**.

Signals land where the user expects, which is what `docker stop` relies on.

Build and run:

```text
$ ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    2 root      0:00 ps aux
```

`/bin/sh` is PID 1. Not our Go program, but the actual shell. This is what Docker does. When you run `docker exec` and see PID 1, that's your application, not Docker's runtime.

## What We Built

Starting from `exec.Command("/bin/sh")`, we incrementally built a real container runtime:

1. **Child process:** run a command as a subprocess
2. **PID namespace:** the container has its own process IDs, starting from 1
3. **UTS namespace:** the container has its own hostname
4. **Chroot:** the container has its own root filesystem (Alpine Linux)
5. **`/proc/self/exe` trick:** parent/child split so setup happens in the right order
6. **Proc and dev mounts:** `ps`, `top`, `/dev/null` all work inside the container
7. **Cgroups:** memory limited to 50MB, CPU limited to 50%
8. **`syscall.Exec`:** the user's command is PID 1, just like Docker

All of this in about 150 lines of Go with zero external dependencies. That's all a container is: an isolated process with resource limits.

> The full project is on GitHub: [karandaid/containers-from-scratch](https://github.com/karandaid/containers-from-scratch)

## Cleanup

```bash
# Remove cgroup directories (if they persist)
sudo rmdir /sys/fs/cgroup/memory/gocker/ 2>/dev/null
sudo rmdir /sys/fs/cgroup/cpu/gocker/ 2>/dev/null
```

## Next Steps

Gocker covers the basics. Real container runtimes add:

- **Network namespace:** isolated networking with virtual bridges and port mapping
- **OverlayFS:** layered filesystems for container images (copy-on-write)
- **Seccomp filters:** restrict which syscalls the container can make
- **User namespaces:** run containers without root on the host
- **Image pulling:** pull images from Docker Hub using the Registry API

Check out [Build and Deploy a Go Lambda Function](https://karandeepsingh.ca/posts/build-deploy-go-lambda-function/) for more Go projects, or [Building a Go S3 CLI Tool](https://karandeepsingh.ca/posts/go-aws-s3-cli-tool/) for Go + AWS SDK patterns.

## Cheat Sheet

Quick reference for Linux container concepts in Go.

**Create namespaces:**
```go
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWPID |  // New PID namespace
                syscall.CLONE_NEWUTS |  // New hostname
                syscall.CLONE_NEWNS  |  // New mount namespace
                syscall.CLONE_NEWNET,   // New network namespace
}
```

**Chroot:**
```go
syscall.Chroot("./rootfs")
os.Chdir("/")
```

**Mount proc:**
```go
syscall.Mount("proc", "/proc", "proc", 0, "")
```

**Create device nodes:**
```go
syscall.Mknod("/dev/null", 0666|syscall.S_IFCHR, int(1*256+3))   // major 1, minor 3
syscall.Mknod("/dev/zero", 0666|syscall.S_IFCHR, int(1*256+5))   // major 1, minor 5
syscall.Mknod("/dev/random", 0666|syscall.S_IFCHR, int(1*256+8)) // major 1, minor 8
```

**Cgroup memory limit (50MB):**
```go
os.MkdirAll("/sys/fs/cgroup/memory/myapp/", 0755)
os.WriteFile("/sys/fs/cgroup/memory/myapp/memory.limit_in_bytes", []byte("52428800"), 0644)
os.WriteFile("/sys/fs/cgroup/memory/myapp/cgroup.procs", []byte(strconv.Itoa(pid)), 0644)
```

**Cgroup CPU limit (50%):**
```go
os.MkdirAll("/sys/fs/cgroup/cpu/myapp/", 0755)
os.WriteFile("/sys/fs/cgroup/cpu/myapp/cpu.cfs_period_us", []byte("100000"), 0644)
os.WriteFile("/sys/fs/cgroup/cpu/myapp/cpu.cfs_quota_us", []byte("50000"), 0644)
os.WriteFile("/sys/fs/cgroup/cpu/myapp/cgroup.procs", []byte(strconv.Itoa(pid)), 0644)
```

**Replace process (become PID 1):**
```go
binary, _ := exec.LookPath("sh")
syscall.Exec(binary, []string{"sh"}, os.Environ())
// This never returns — the Go process is gone
```

**Key concepts to remember:**
- **Namespaces** control visibility (what a process can see): `CLONE_NEWPID`, `CLONE_NEWUTS`, `CLONE_NEWNS`, `CLONE_NEWNET`
- **Cgroups** control resources (what a process can use). They're just files in `/sys/fs/cgroup/`
- **Chroot** changes the root directory. The process thinks `/` is wherever you pointed it
- `/proc/self/exe` always points to the current binary. Container runtimes use this to re-execute themselves
- `syscall.Exec` *replaces* the current process. It doesn't return, and the new binary takes over the PID
- Containers are **not VMs**. They're just processes with isolation and resource limits applied
- Everything requires **root** (or user namespaces) because creating namespaces is a privileged operation
- CFS quota/period ratio determines CPU percentage: `50000/100000 = 50%` of one core
