Many developers use Docker every day, but have you ever wondered how container actually works? When I first started learning Linux backend development, I thought Docker was like a virtual machine. But later I realized containers are not VM at all. They are just simple Linux processes that are isolated from the host system.
To understand this better, I decided to build a small container engine in Go called ship. In this post, I will explain how Linux namespaces and cgroups work in simple words with some basic Go code.
1. What is a Container Really?
A virtual machine runs a whole separate operating system with its own kernel. But containers share the same Linux kernel with your main machine.
Containers use three main Linux features to create isolation:
- Namespaces: Controls what a process can see (like PID table, network, or hostname).
- cgroups (Control Groups): Controls how much resources a process can use (CPU, RAM).
- chroot: Changes the root folder so the process cannot look at other files on your computer.
2. Using Linux Namespaces in Go
Linux have different flags to isolate things. For example:
CLONE_NEWPIDgives the process its own process ID list (so it thinks it is PID 1).CLONE_NEWUTSlets it have its own hostname.CLONE_NEWNSisolates mount points.
In Go language, we can pass this flags using syscall package when starting a command:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func run() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Tell Linux to create new namespaces for this process
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS | syscall.CLONE_NEWNS,
}
if err := cmd.Run(); err != nil {
fmt.Printf("Error starting container: %v\n", err)
os.Exit(1)
}
}
When this runs, Linux creates a new process that runs inside its own PID namespace.
3. Isolating Hostname and Folder (chroot)
Inside the child process, we can change the container hostname and change root folder so it cannot access host system files:
func child() {
fmt.Printf("Running inside container as PID %d\n", os.Getpid())
// Set container hostname
syscall.Sethostname([]byte("my-container"))
// Change root folder to a small alpine rootfs folder
syscall.Chroot("/var/containers/alpine-rootfs")
syscall.Chdir("/")
// Mount proc folder so commands like 'ps' can work
syscall.Mount("proc", "proc", "proc", 0, "")
// Run the shell inside container
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}
4. Controlling Memory and CPU with cgroups v2
If a container code has bugs, it might use all your CPU or RAM and crash your computer. We use cgroups to prevent this.
In Linux, cgroups are just simple folders inside /sys/fs/cgroup:
func limitResources(cgroupName string) {
cgroupPath := "/sys/fs/cgroup/" + cgroupName
os.MkdirAll(cgroupPath, 0755)
// Set max memory limit to 100MB
os.WriteFile(cgroupPath+"/memory.max", []byte("104857600"), 0644)
// Limit CPU usage
os.WriteFile(cgroupPath+"/cpu.max", []byte("50000 100000"), 0644)
// Add container process ID into the cgroup
pid := fmt.Sprintf("%d", os.Getpid())
os.WriteFile(cgroupPath+"/cgroup.procs", []byte(pid), 0644)
}
5. Testing it Out
Now when we run our program:
$ sudo go run main.go run /bin/sh
Running inside container as PID 1
[my-container] /# hostname
my-container
[my-container] /# ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/sh
4 root 0:00 ps aux
As you can see, the process inside container only sees PID 1 and cannot see other processes from the main computer.
Conclusion
Building this small project helped me understand that containers are not magic tools or heavy virtual machines. They are just standard Linux processes wrapped with namespaces and cgroups.
Hope this post was easy to understand! If you have any questions feel free to reach out.