<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tech Series]]></title><description><![CDATA[A backend engineer's notebook. Real explorations into systems internals, Go, and how things actually work under the hood, that are written while learning, not after.]]></description><link>https://blog.iamvedant.in</link><image><url>https://cdn.hashnode.com/uploads/logos/63a479263916c9c9a801da99/8c1ccca3-2034-4546-af1c-f299a2a489a8.png</url><title>Tech Series</title><link>https://blog.iamvedant.in</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 12 Sep 2026 22:52:13 GMT</lastBuildDate><atom:link href="https://blog.iamvedant.in/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Containers Are Not Magic: cgroups and chroot From Scratch]]></title><description><![CDATA[If you haven't read the Part-1 it is highly recommended to read it here so that you can fully understand the context of this blog. It is the continuation of the previous blog where we dive into implem]]></description><link>https://blog.iamvedant.in/containers-are-not-magic-cgroups-and-chroot-from-scratch</link><guid isPermaLink="true">https://blog.iamvedant.in/containers-are-not-magic-cgroups-and-chroot-from-scratch</guid><category><![CDATA[golang]]></category><category><![CDATA[Docker]]></category><category><![CDATA[oci-container]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Vedant]]></dc:creator><pubDate>Tue, 21 Apr 2026 04:15:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/66999685-c3de-4b25-a123-5cae1347696c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<blockquote>
<p>If you haven't read the Part-1 it is highly recommended to read it <a href="https://blog.iamvedant.in/containers-are-not-magic-namespaces-from-scratch">here</a> so that you can fully understand the context of this blog. It is the continuation of the previous blog where we dive into implementing namespace for our container.</p>
</blockquote>
<hr />
<p>In this part we will implement cgroups and chroot to finish what we started. By the end you will have a strong foundation of how containers actually work and why they were never a black box to begin with (like Neural Networks).</p>
<hr />
<h2>Quick Recap</h2>
<p>In Part 1 you gave your container its own hostname, its own process tree and its own view of mounted filesystems. It was starting to feel like a real container. But remember I said each namespace is like a hotel room. Same building, same infrastructure, but every guest gets their own private space. Well, we never added any security to that room. The hotel forgot to put a lock on the minibar and gave you access to every other room on the floor. The container was isolated but not limited and not truly private. Today you are going to fix both of those with cgroups and chroot.</p>
<hr />
<h2>Adding the Door Locks and Level Restrictions</h2>
<p>Before diving into our implementation, I'll briefly explain the concepts we will be using to add our door locks logic.</p>
<h3>cgroup</h3>
<p>Think of cgroups like the hotel management system. When you check into a hotel, the front desk does not just give you a room key. They also set limits on what you can use. Your room plan says you get access to the gym but not the spa, and the minibar has a fixed budget. If you try to exceed that budget, the system cuts you off. cgroups work exactly the same way for your processes. You decide how much RAM, CPU or how many processes a container can use and the kernel enforces it. Try to go over, and the kernel cuts you off just like the hotel would cut your minibar access. Here is a video <a href="https://www.youtube.com/watch?v=z7mgaWqiV90">resource</a> that I found helpful in understanding cgroups.</p>
<p>You might wonder that VMs also work the same way, so what is the difference? A VM allocates a fixed chunk of memory and CPU upfront. If you tell a VM it needs 4GB of RAM, the system reserves that 4GB immediately even if the VM is sitting idle using only 200MB. That reserved memory is gone for everyone else. cgroups work differently. They set a ceiling, not a reservation. Your container can use anywhere from 0 to 50MB, and the host only pays for what is actually used. No waste, no hogging, no unnecessary $$$.</p>
<img src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExZWxsaWM3Ym9wdmdoc3Fuamh0eTY4dGtoaHNhdXBkcXh0cnR1dDZ4NSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/xTiTnqUxyWbsAXq7Ju/giphy.gif" alt="" style="display:block;margin:0 auto" />

<h3>chroot</h3>
<p>Think of chroot like a film set. The audience watching the movie genuinely believes the actor is in a mediaeval castle or a space station. They have no idea there are cables, crew members and a parking lot right behind those walls. chroot does the same thing for your container. It builds a set, and the container is the audience. It genuinely believes that the root directory you told it is the root of the entire world. Everything outside does not exist as far as it is concerned. The kernel here only performs different functions, like camera, lighting, etc., that the audience never sees.</p>
<blockquote>
<p>We are using <code>chroot</code> in the blog for educational purposes only. <code>chroot</code> is more of an isolation feature rather than a security feature. A privileged process having <code>CAP_SYS_CHROOT</code> capability can escape the container by using <code>chdir("..")</code> directly. To avoid this docker uses <code>pivot_root</code> but for educational purposes I am limiting to implementing <code>chroot</code>. This is why production ready containers need more hardening.</p>
</blockquote>
<img src="https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExOWRhcmk0aTNhNHdubWlyNjJqNmo4OTQxcmQ1dGc5bjJvdDJsb3l6NSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3ohc1eo7Z8Kkl9NtBK/giphy.gif" alt="" style="display:block;margin:0 auto" />

<h3>Let's start building it</h3>
<p>First of all, let's do the honours: Here is the file to the whole code:</p>
<p><a class="embed-card" href="https://github.com/Vedant-Gandhi/How-containers-work/blob/main/container/container.go">https://github.com/Vedant-Gandhi/How-containers-work/blob/main/container/container.go</a></p>

<p>I have put this in a separate package. If you are wondering why it is not in the same file as Part 1, that is a story for another day. Just know it works and follow along.</p>
<p>You can clone this repo and run the program with following commands:</p>
<pre><code class="language-shell">go build -o mycontainer .
sudo ./mycontainer --mode=container --name=&lt;container_name(optional)&gt;
</code></pre>
<p>I made a few changes to the container, such as adding named flags and adding your custom container name to add parity with how Docker handles the same. Here are details:</p>
<ul>
<li><p><code>mode</code>: If it has a value of container, it runs the whole container implementation with namespace, cgroup and chroot, and if it is not mentioned, then it runs the namespace code only mentioned in the previous part.</p>
</li>
<li><p><code>name</code>: An optional name for the container that works only with the container mode. It is similar to <code>docker run --name</code>.</p>
</li>
</ul>
<p>Now the functions <code>StartParentNameSpace</code> and <code>StartNameSpaceChild</code> are the same as in the previous blog but are copied here again for isolation with only 2 main changes:</p>
<ul>
<li><p><code>StartParentNameSpace</code>: Instead of running the child directly using <code>cmd.Run</code> we return the command object because we need the child's PID to add it to the cgroup before it starts running.</p>
</li>
<li><p><code>StartNameSpaceChild</code>: Before mounting the proc, I have added the chroot mount to emulate the file system.</p>
</li>
</ul>
<p>Just keep track of above changes. I will explain it in a neat way such that it does not cause confusion.</p>
<p>Our entry point in this code is the Run function that accepts the container name passed as flag. You can ignore that flag if it feels too much overload.</p>
<pre><code class="language-go">func Run(name string) {
	args := os.Args

	if strings.EqualFold("", name) {
		name = strconv.Itoa(rand.Int())
	}

	// Confirm if the current process is for child if there is a child arg.
	for _, arg := range args {
		if strings.EqualFold(arg, "child") {
			StartNameSpaceChild(name)
			return
		}
	}

	cmd := StartParentNameSpace(name)
	err := cmd.Start()
	if err != nil {
		fmt.Printf("Failed to start the container: %v\n", err)
		os.Exit(1)
	}

	AddCgroup(cmd.Process.Pid, name)
	defer CleanupCgroup(name)

	err = cmd.Wait()

	if err != nil {
		fmt.Printf("Failed to wait for the container: %v\n", err)
		os.Exit(1)
	}

}
</code></pre>
<p>This function checks if the current invocation is by the child or the parent by looking for child in the arguments, just like we did in Part 1. If it finds child it hands off to StartNameSpaceChild and returns immediately.</p>
<p>If it is the parent, we call <code>StartParentNameSpace</code> which sets up the namespace configuration and returns the command object instead of running it directly. We then call <code>cmd.Start()</code> which starts the child process but does not block the current thread. This is important because we need the child's PID before it starts doing anything. <code>cmd.Start()</code> gives us that window.</p>
<p>Once we have the PID we call <code>AddCgroup</code> which creates a cgroup for our container and applies the resource limits. Then we call <code>cmd.Wait()</code> which now blocks until the container exits. The defer CleanupCgroup at the end ensures the cgroup directory is always cleaned up when the container exits, no matter what happens.</p>
<blockquote>
<p><strong>Fun Fact:</strong> In C you would use <code>fork()</code> which gives you the child PID immediately since the child is a direct copy of the parent. You set up the cgroup right there and move on. In Go we cannot do that because of the multithreaded runtime as we discussed in Part 1. So <code>cmd.Start()</code> and <code>cmd.Wait()</code> is our workaround. Same result, different path to get there.</p>
<p>Multithreaded languages has their own headaches and workarounds.</p>
</blockquote>
<blockquote>
<p>Another fun fact: There is a small window between <code>cmd.Start()</code> and <code>AddCgroup()</code> where our container runs without limits. <code>runc</code> solves this by embedding a small C program inside the Go binary specifically for this purpose, using <code>fork()</code> to set up cgroups before the container process runs its first instruction. Sometimes even Go needs to call in C for backup.</p>
</blockquote>
<p>Now let's move on to look at the cgroup implementation.</p>
<pre><code class="language-go">func AddCgroup(pid int, name string) {

	// Add permission to allow read and write.
	err := os.Mkdir(fmt.Sprintf("/sys/fs/cgroup/%s", name), 0755)
	if err != nil &amp;&amp; !os.IsExist(err) {
		fmt.Printf("Failed to setup permissions for the container: %v\n", err)
		os.Exit(1)
	}

	// We set limit for now to 50MB for now.
	err = os.WriteFile(fmt.Sprintf("/sys/fs/cgroup/%s/memory.max", name), []byte(strconv.Itoa(50*1024*1024)), 0755)
	if err != nil {
		fmt.Printf("Failed to setup memory limit for the container: %v\n", err)
		os.Exit(1)
	}

	// Write the process id to our cgroup.
	err = os.WriteFile(fmt.Sprintf("/sys/fs/cgroup/%s/cgroup.procs", name), []byte(strconv.Itoa(pid)), 0755)
	if err != nil {
		fmt.Printf("Failed to setup the cgroup for container: %v\n", err)
		os.Exit(1)
	}

	// We set the swap to 10MB for now to ensure limts are working.
	err = os.WriteFile(fmt.Sprintf("/sys/fs/cgroup/%s/memory.swap.max", name), []byte(strconv.Itoa(10*1024*1024)), 0755)
	if err != nil {
		fmt.Printf("Failed to set the swap for container: %v\n", err)
		os.Exit(1)
	}

	// We set the max allowed processes that can be created by child to 1000.
	err = os.WriteFile(fmt.Sprintf("/sys/fs/cgroup/%s/pids.max", name), []byte("1000"), 0755)
	if err != nil {
		fmt.Printf("Failed to set th max process allowed for the container : %v\n", err)
		os.Exit(1)
	}

}
</code></pre>
<blockquote>
<p><strong>Note:</strong> We are using cgroup v2. If you do not know what that means, ignore it and move on. If you do know, yes it is v2, you are welcome.</p>
</blockquote>
<p>This is the hero function that sets our door locks so that the guests stay within their limits.</p>
<p>The first thing it does is create a directory under <code>/sys/fs/cgroup</code> with the container name. This is a special path the kernel watches. The moment you create a directory here, you are telling the kernel, <strong>"I want a new cgroup with this name."</strong> If you are on a Linux machine and have Docker running, try <code>ls /sys/fs/cgroup</code> and look for any <code>docker-*</code> directories. Docker creates one for every container you run, exactly like we are doing here. The kernel then automatically populates that directory with all the control files.</p>
<p>Think of it like checking a new guest into the hotel. The moment you create a room entry in the system, the hotel management software automatically sets up all the default rules for that room such as room service access, minibar budget, gym access. You just created the room, the system handled the rest.</p>
<p>Then we write four files to apply our limits:</p>
<ul>
<li><p><code>memory.max</code> : sets the hard memory limit to 50MB. When the container hits this limit it tries to use swap if available. If not it gets killed. <strong>Equivalent Docker flag:</strong> <code>--memory=50m</code></p>
</li>
<li><p><code>cgroup.procs</code> : this is where the magic happens. Writing the child's PID here tells the kernel to put that process under this cgroup. From this point every process the container spawns automatically inherits these limits. This is the golden file that tells the kernel where to enforce everything. Docker does this internally using <code>runc</code>.</p>
</li>
<li><p><code>memory.swap.max</code> : sets the swap limit to 10MB. Without this the kernel lets the container use unlimited swap to sneak around the memory limit. We give it a small buffer before the kill. <strong>Equivalent Docker flag:</strong> <code>--memory-swap=60m</code> <strong>.</strong><br /><strong>Note:</strong> The <code>--memory-swap</code> in Docker is the total of RAM plus swap combined, not just the swap alone. So <code>--memory=50m</code> and <code>--memory-swap=60m</code> means 50MB of RAM and only 10MB of swap. That matches exactly what we set in <code>memory.swap.max</code>.</p>
</li>
<li><p><code>pids.max</code> : limits the total number of processes the container can create to 1000. This prevents fork bombs where a process keeps spawning children until the system collapses. <strong>Equivalent Docker flag:</strong> <code>--pids-limit=1000</code></p>
</li>
</ul>
<p>That is all it takes to limit what your container can consume. A directory and a few file writes. There are many more control files (<a href="https://www.kernel.org/doc/html/v4.18/admin-guide/cgroup-v2.html#core-interface-files">refer cgroup v2 docs</a>) you can play with, like CPU limits, disk IO limits and more, but we are keeping it focused for now. If you are curious, go explore <code>/sys/fs/cgroup</code> on your machine, everything is right there waiting for you. So we have set up the door locks and minibar limits. Any mischievous guest will be caught red-handed.</p>
<p>Now we move to chroot, and I am going to switch the analogy on you because the hotel does not quite capture what chroot does. For this one, think film sets. Now let me show you how we actually implement this in code.</p>
<blockquote>
<p>Before running this, make sure you have run the <a href="http://setup.sh"><code>setup.sh</code></a> script from the repository. It downloads and extracts Alpine Linux into the <code>rootfs</code> folder next to your binary. If you want to use a different base like Ubuntu or Debian that works too, just extract it into the same <code>rootfs</code> folder and you are good to go. The Alpine Linux here is the same that you use in Docker base build in <code>FROM alpine:latest.</code> It is exactly the same implementation only in Docker it sets up automatically using Docker hub and uses build layers from image files.</p>
</blockquote>
<p><a class="embed-card" href="https://github.com/Vedant-Gandhi/How-containers-work/blob/main/setup.sh">https://github.com/Vedant-Gandhi/How-containers-work/blob/main/setup.sh</a></p>

<pre><code class="language-go">func SetupChRoot() {
	ex, _ := os.Executable()

	// We get the path of executable and assume the rootfs is store there as well according to the script :).
	rootfs := filepath.Join(filepath.Dir(ex), "rootfs")

	err := syscall.Chroot(rootfs)
	if err != nil {
		fmt.Printf("Failed to setup environment for the container: %v\n", err)
		os.Exit(1)
	}

	// We need to reset the root dir to root else it points to the working directory before we set the new root for current process.
	err = os.Chdir("/")
	if err != nil {
		fmt.Printf("Failed to change directory to root: %v\n", err)
		os.Exit(1)
	}
}
</code></pre>
<p>This function is where the film set gets built. Let me walk you through it.</p>
<p>First we get the path of the currently running binary using <code>os.Executable()</code>. We then assume the <code>rootfs</code> directory sits right next to it. Think of it like an actor preparing for a role. Before they step on set, the costume, the props, everything that makes them their in-movie character is laid out in the dressing room right next to the studio. The <code>rootfs</code> is that dressing room. Everything the container needs to become its character is sitting right there next to the binary.</p>
<p>Then we call <code>syscall.Chroot(rootfs)</code> which tells the kernel "<em><strong>for this process, this</strong></em> <code>rootfs</code> <em><strong>directory is now</strong></em> <code>/</code><em><strong>. Everything starts here and nothing exists above it.</strong></em>" This is the moment the actor steps on set and becomes the in-movie character. The cameras start rolling, and from this point the actor is no longer themselves. The container has no idea there is a host filesystem behind the walls just like the in-movie character has no idea there is a parking lot behind the castle walls because logically they are supposed to be in a castle. Since we are using Alpine as our rootfs, the container gets an Alpine environment. But this could be Ubuntu, Debian or anything you extract into that folder. The kernel does not care what is inside, it just enforces who the character is.</p>
<p>The last part is important and easy to miss. After <code>chroot</code> the process is inside the new root but its current working directory still points to the old path from before the chroot. Think of it like the actor who just stepped on set but is still mentally still thinking about the parking lot. They are wearing the costume but their head is still outside. <code>os.Chdir("/")</code> is the director shouting "<strong>action</strong>" and it is the moment the actor fully commits to the in-movie character and forgets who they were outside. This is not a Go specific thing by the way. Any language or program that calls <code>chroot</code> must follow it with <code>chdir("/")</code>. It is just standard practice across the board.</p>
<p>Now we have setup every form of isolation that a process needs to believe that it is in a unique environment and added every handle to ensure it is shown its place if it acts naughty. Let's test if the kernel really keeps its promise after letting us go through all the complexity hell.</p>
<p>We have already tested namespaces in previous blog so I won't be testing them again. Here I will only test if cgroup and chroot works or not.</p>
<hr />
<h2>Validation and Testing</h2>
<h3>Memory Limit</h3>
<p>We have set the memory limit to 50MB. I am going to run a memory bomb inside the container that keeps allocating memory and we will see if the cgroup police shows up to handle it.</p>
<p>Here is the memory bomb:</p>
<pre><code class="language-go">package main

import (
	"fmt"
	"time"
)

func main() {
	var data [][]byte
	for i := range 1000 {
		// Allocate a chunk of 2 MB.
		chunk := make([]byte, 2048*1024)
		// actually touch every page because go does lazy memory allocation.
		for j := range chunk {
			chunk[j] = 1
		}
		data = append(data, chunk)
		fmt.Printf("Allocated %dMB\n", (i+1)*2)
		time.Sleep(100 * time.Millisecond)
	}
}
</code></pre>
<p>Build it using following command:</p>
<pre><code class="language-shell">go build -o mem_bomb main.go
</code></pre>
<p>I have copied it inside the bin folder of my rootfs. If you are going to run this test ensure you do this before starting the container.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/723d4119-fd21-4815-bb0c-244d88795164.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see it allocated up to 56MB and then the cgroup police showed up. The kernel killed the process because it crossed the 60MB total limit we set which is 50MB of RAM plus 10MB of swap. No warnings, no second chances. The kernel just pulled the plug.</p>
<p>Notice that only the memory bomb was killed, not the entire container. The shell survived because it is PID 1 inside our container. If the memory bomb was our entrypoint and was PID 1, it would have taken the whole container down with it. That is exactly how Docker handles OOM kills too.</p>
<p>The killed process exits with code 137 which is the standard Linux exit code for a process killed by SIGKILL. When Docker sees exit code 137 it checks the <a href="http://memory.events"><code>memory.events</code></a> file from the container's cgroup to confirm it was an OOM kill. Let's check ours from the host:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/6c67c007-1ac5-4df4-ae31-de2bf5c9f935.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see <code>oom</code> is 1 and <code>oom_kill</code> is 1 which means one process hit the memory limit and was killed. The <code>max</code> value of 76 means the process hit the memory limit 76 times. Each time the kernel tried to reclaim memory before giving up. After exhausting all options it finally invoked the OOM killer and sent the kill signal. And yes, if the memory bomb had continued without the cgroup limit it would have consumed almost 2GB before finishing.</p>
<p>The cgroup police did their job. It enforced the memory limits for the container. So we can conclude that the cgroup are working as intended which means our door locks are working and resource utilization is watched by the kernel.</p>
<blockquote>
<p>Here is a short challenge for you. We also set a process limit of 1000 in our cgroup which means the container can only have 1000 processes running at a time. Write a program that creates a process bomb and run it inside the container. See what happens and drop your findings in the discussion forum of the blog. Curious to see how many of you try it.</p>
</blockquote>
<h3>FileSystem Isolation</h3>
<p>Now we have also implemented chroot which means filesystem isolation or in our analogy: the film set. Let's test that:</p>
<p>Let's see what is the host OS inside the container:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/7fbea835-648a-47ae-a63c-e22fa2982232.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see the container genuinely believes it is running on Alpine Linux even though the host is Ubuntu. The actor(container) has fully committed to the role. It has no idea it is still on the same kernel, the same machine, just looking at a made up set.</p>
<p>Now let's see what does it show if we print the available files in root:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/4dd1cdad-51e4-4352-86b8-c5b7e1f82510.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see the root for the container is Alpine Linux. If you observe there is no <code>boot</code> folder. That is because Alpine does not need one since it is not booting anything. It is just a userspace filesystem sitting on top of your host kernel. The actor does not need to know how the camera works, they just perform within the set they have been given.</p>
<p>Let's assume the container has a mischevious process and is trying to escape <strong>The Matrix</strong>. Let's see what happens if it tries that:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/56f2c7b8-313f-48ef-97de-dc9dce1263ee.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see no matter how many times it tries to go up, it always ends up back at Alpine's root. The process is Neo before he took the red pill. It is running, it thinks it is free, but it is completely bound by the walls of <code>chroot</code> and can never escape <strong>The Matrix</strong>. Of course it has an exception we discussed previously : With right capabilities a privileged process can escape our matrix but they need that red pill.</p>
<hr />
<h2>You've Built The Matrix</h2>
<img src="https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExdWVqYTZ5cjNjaWNwaGozbW5yNzY3Mnh0YTNqaXh3NjVhdW9oNDlsMCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/zXmbOaTpbY6mA/giphy.gif" alt="" style="display:block;margin:0 auto" />

<p>Congratulations for making it to the end. Genuinely. In the age of 30 second videos and infinite scroll, reading a deep technical article takes a different kind of patience.</p>
<p><strong>A recap of what we did in both articles :</strong></p>
<p>Namespaces control <em><strong>what a process can see</strong></em>. We gave the container its own hostname so it has its own identity, its own process tree so it cannot see what else is running on the host, and its own mount namespace so our <code>/proc</code> mount stayed contained. But namespaces only isolate the kernel resources that live in memory. They say nothing about how much a process can consume or what files it can access on disk.</p>
<p>That is where <code>cgroups</code> and <code>chroot</code> come in. <code>cgroups</code> control <em><strong>what a process can use</strong></em>. Without them your container could eat all your RAM and the host would feel every bit of it. <code>cgroups</code> put a hard ceiling on memory, swap and process count and the kernel enforces it with no mercy.</p>
<p><code>chroot</code> controls what a <em><strong>process can see on disk.</strong></em> <code>Namespaces</code> gave the container its own view of kernel resources but the filesystem was still the host's filesystem. <code>chroot</code> changes the root directory so the container thinks Alpine is the entire world. It cannot see your home directory, your configs or anything outside that rootfs boundary.</p>
<p>You just built your own basic container runtime and that is exactly what <code>runc</code> does at the core of Docker. We did intentionally skip a few things to keep it focused such as user namespaces as our container process and child process all run as root, network isolation so the container shares the host network, and IPC isolation for inter-process communication. Docker handles all of these out of the box along with pulling images from registries, layered filesystems and a lot of production hardening. But the heart of it, <code>namespaces</code>, <code>cgroups</code> and <code>chroot</code>, is exactly what you implemented today. Next time someone says containers are magic, you know better.</p>
<p>Oh and before ending here is the equivalent DockerFile of what we did in both articles:</p>
<pre><code class="language-dockerfile">FROM alpine:3.19

CMD ["/bin/sh"]
</code></pre>
<p>And to run it:</p>
<pre><code class="language-shell">docker run -it \
  --name mycontainer \
  --memory=50m \
  --memory-swap=60m \
  --pids-limit=1000 \
  alpine:3.19
</code></pre>
<p>And we are done. It took us two blogs and a few hundred lines of Go, Docker does in one command. Now you know what is hiding behind that command. You are no longer just a Docker user, you are someone who understands what Docker actually is. The black box of abstraction is now open and it does not look as messy as we thought before opening it.</p>
]]></content:encoded></item><item><title><![CDATA[Containers Are Not Magic: Namespaces From Scratch]]></title><description><![CDATA[Prerequisite (Recommended)

To understand this article you must know the following:

Basic familiarity with Docker and you've run a container before.

Basic Go knowledge (It's okay if you can read Go ]]></description><link>https://blog.iamvedant.in/containers-are-not-magic-namespaces-from-scratch</link><guid isPermaLink="true">https://blog.iamvedant.in/containers-are-not-magic-namespaces-from-scratch</guid><category><![CDATA[Go Language]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Docker]]></category><category><![CDATA[containers]]></category><category><![CDATA[#namespaces]]></category><dc:creator><![CDATA[Vedant]]></dc:creator><pubDate>Sat, 18 Apr 2026 14:25:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/ee691d3d-ee7a-40d1-9a73-98b44fe8a990.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>Prerequisite (Recommended)</h2>
<blockquote>
<p>To understand this article you must know the following:</p>
<ol>
<li><p>Basic familiarity with Docker and you've run a container before.</p>
</li>
<li><p>Basic Go knowledge (It's okay if you can read Go code even if you don't use it regularly).</p>
</li>
</ol>
</blockquote>
<hr />
<p>The prerequisite is there so that you can have a clear gap. I will try to explain and recap about what a container is but if you already know it you can skip to the <code>Enough Theory, Let's Build</code> section.</p>
<h2>What is Docker?</h2>
<p>So what exactly is Docker?</p>
<p>Think of it like this.</p>
<p>Imagine you built an app on your laptop. It works perfectly. Then you send it to your friend and it breaks immediately because they have a different Node version, different libraries, different everything.</p>
<p>Docker solves this by saying:<br />“Don’t just send the code. Send the entire environment.”</p>
<p>It packs your app along with everything it needs into a single unit called a container. Same app, same dependencies, same behavior, no surprises.</p>
<p>That is why people love saying <em>“it works on my machine”</em> Docker basically turns that into <em>“it works on every machine”</em></p>
<p>Docker uses containers to implement this system. A container is basically a running program that already has everything baked in to run your program. You can have multiple container running on a single machine and they will not bother each other because the apps inside that container will never know what else is running. It only knows what you've told it about the outside world. Like a frog in a well that doesn't know the ocean exists. Isolation is the most remarkable feature of containerization as it prevents resource conflicts, makes your application traceable, and keeps failures contained to one place instead of causing a domino effect across your system.</p>
<img src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExejIxNGhoMDNud2hhaTB6N3ZudmZhb2RhYmtiYWx0YXZrZGJzZmZjMiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/zFb4l4CvD4MOQ/giphy.gif" alt="" style="display:block;margin:0 auto" />

<h2>Why not use VM directly</h2>
<p>Virtual Machines solve the same problem but in a heavy way. A VM emulates an entire computer with its own OS, kernel and virtual hardware. This means booting a full operating system just to run your application. Running five VMs means running five operating systems at the same time. That is a lot of overhead.</p>
<p>Containers are much lighter. Instead of emulating hardware, containers share the host kernel directly. The isolation comes from features already built into the Linux kernel called namespaces and cgroups. This makes containers fast to start and cheap to run. You can run dozens of containers where you could barely fit five VMs.</p>
<blockquote>
<p>The only disadvantage of container is if the host kernel has some security vulnerability then the container also has it whereas in VM it does not because each VM machine is its own OS. So its a security isolation tradeoff for multiple advantages.</p>
</blockquote>
<h2>Enough Theory, Let's Build</h2>
<p>As the title says, let's start building our own container and see what is actually happening under the hood. By the end of this you will know exactly what a container looks like from the inside.</p>
<p>First let's get you familiar with namespace and cgroup which are the heart and the soul of our precious containers.Trust me, once you understand these two, containers will never feel like a black box again(like Neural Networks).</p>
<blockquote>
<p>Our goal in this tutorial is to run bash in a container. Nothing fancy just our plain simple bash.</p>
</blockquote>
<h3>Namespace</h3>
<p>A namespace is a Linux kernel feature that wraps a global resource and gives your process its own private view of it. Think of it like your ENV_TYPE variable. You set it to production, preprod or development and based on that your application behaves completely differently. Same codebase, different view of the world. Namespaces work the same way. Same kernel, but every process gets its own private view of resources like hostname, process tree and network. You can also think of each namespace as a hotel room. Same building, same infrastructure, but every guest gets their own private space and whatever they do in their room stays in their room. Read more about it here: <a href="https://man7.org/linux/man-pages/man7/namespaces.7.html">Linux Namespace Manual</a>.</p>
<h3>cgroup</h3>
<p>cgroups or control groups is a Linux kernel feature that limits how much of a resource a process can use. Think of it like shared hosting. You and 50 other people are on the same server but your provider makes sure one person cannot eat up all the RAM and starve everyone else. Each account gets a slice and stays within it. cgroups give you that same control but for any process running on your machine. You decide how much CPU or memory a process gets and the kernel enforces it.</p>
<h3>Let's get to work</h3>
<p>I will only work on namespaces now and add cgroup in the next one to keep you focued and not get bored.</p>
<p>Here is the link to the whole file:</p>
<p><a class="embed-card" href="https://github.com/Vedant-Gandhi/How-containers-work/blob/main/namespace.go">https://github.com/Vedant-Gandhi/How-containers-work/blob/main/namespace.go</a></p>

<p>You can clone this repository and run it with the following commands:</p>
<pre><code class="language-shell">go build -o mycontainer main.go namespace.go
sudo ./mycontainer # Run as root since namespace creation needs root permission.
</code></pre>
<p>Right now it might seem too overwhelming but trust me and stay here, I will explain it to you in a very simple and streamlined way.</p>
<p>So our entry point is this function <code>RunNameSpace</code> .</p>
<pre><code class="language-go">func RunNameSpace() {
	args := os.Args

	// The current process is for the child.
	if len(args) &gt; 1 &amp;&amp; strings.EqualFold(args[1], "child") {
		StartNameSpaceChild()
		return
	}

	StartParentNameSpace()

}
</code></pre>
<p>This function checks if the binary has any argument named child to determine if it is a parent process running on the host or a child process running inside the container. If you run the command after cloning the repository it will start the parent flow.</p>
<h4>The absurd way of creating child process in Linux</h4>
<p>In Windows you can specify exactly what process you want to start as a child. Linux does not work like that. In Linux you use <code>fork()</code> which copies the entire current process as it is and runs the same code from that point. Both parent and child are running the same code, the only difference is the return value of fork().</p>
<p>Now this works fine for single threaded programs but Go is always multi threaded. When <code>fork()</code> is called only the thread that invoked it survives in the child. Every other thread is gone, including ones that were holding locks or managing memory. This leads to deadlocks and crashes.</p>
<p>Go solves this by not using <code>fork()</code> at all. Instead of forking, Go starts a completely fresh new process using exec. This new process initializes its own Go runtime from scratch which means no inherited threads, no dangling locks and no risk of corruption. Since the process is spawned using <code>exec.Command</code> the parent child relationship is still there at the OS level just like <code>fork()</code>. It will get more clear once you get familiar with the <code>StartParentNameSpace</code> function.</p>
<h4>Running the Parent</h4>
<pre><code class="language-go">func StartParentNameSpace() {

	// In linux /proc/self/exe points to current running binary which prevents spoofing via CLI.
	// Here we are mentioning child as arg so as to allow us to recognize child process. It is purely syntactical and you can replace it with anything to detect if process is child.
	cmd := exec.Command("/proc/self/exe", "child", "/bin/sh")
	cmd.SysProcAttr = &amp;syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
		Pdeathsig:  syscall.SIGTERM,
		Setsid:     true,
	}

	// We map the host pipelines to allow us to see output.
	cmd.Stderr = os.Stderr
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout

	err := cmd.Run()

	if err != nil {
		fmt.Printf("Failed to clone the child process: %v\n", err)
		os.Exit(1)
	}
}
</code></pre>
<p>This is where the actual magic happens. Let me walk you through it line by line.</p>
<blockquote>
<p>When I say parent I just mean the process that starts when you run the program from your terminal. That is it. Nothing fancy.</p>
</blockquote>
<p>First we create a command using <code>/proc/self/exe</code>. This is a special Linux path that always points to the currently running binary. Think of it like a self referencing letter that says "run me again but this time act differently." We pass <code>child</code> as the first argument so our program knows it is now inside the container. This also prevents any spoofing or argument injection where a different binary sneaks in as the child. We also pass <code>/bin/sh</code> which is the shell we want running inside our container.</p>
<p>Now this is the interesting part. We attach a <code>SysProcAttr</code> to our command before running it. Think of this like a configuration sheet you hand to the kernel before it starts the process. Like when you are setting up a new employee and you hand them an access card that defines exactly what rooms they can enter and what they cannot touch. In that sheet we are specifying three namespace flags:</p>
<ul>
<li><p><code>CLONE_NEWUTS</code> — gives the child its own hostname so its identity is completely isolated from your machine.</p>
</li>
<li><p><code>CLONE_NEWPID</code> — gives it its own process tree so it cannot see anything running on your host.</p>
</li>
<li><p><code>CLONE_NEWNS</code> — gives it its own mount namespace so our /proc mount stays inside the container and never leaks back to your host. See what <a href="https://www.geeksforgeeks.org/linux-unix/proc-file-system-linux/">/proc</a> is used for</p>
</li>
</ul>
<p>We also set <code>Setsid</code> to true which gives the child its own terminal session. Without this the child inherits your terminal session and the shell gets confused about who is in control, like two people trying to drive the same car at the same time. <code>Pdeathsig</code> tells the kernel to send a <code>SIGTERM</code> to the child if the parent dies so we do not leave orphan processes running in the background. Think of it as a safety net that says if the parent is gone, the child should not keep running alone.</p>
<p>The last part is the <code>stdin stdout stderr</code> wiring. By default a child process is completely blind and mute. It has no connection to your terminal. It is like putting someone in a room with no windows, no door and no phone. By mapping it to the parent's stdin stdout and stderr you open that connection so you can actually see and interact with the shell running inside your container. Without this you would start the container and see absolutely nothing. This is exactly what the command <code>docker run</code> does when you start a container with the <code>-it</code> flag.</p>
<blockquote>
<p><strong>Fun Fact:</strong> Ever wondered how <code>docker exec -it</code> works? When you run it, your request first goes to the Docker daemon which then spawns a brand new process and slides it into the same namespaces of the running container using a syscall called <code>setns()</code>. Then it sets up a pseudo terminal (PTY) which is basically a fake terminal. The daemon sits in the middle proxying everything between your terminal and the process inside the container. You think you are sitting inside the container but you are actually talking to the daemon which is passing notes back and forth. The container never knew you knocked. Sneaky right?</p>
</blockquote>
<p>Finally we call <a href="http://cmd.Run"><code>cmd.Run</code></a><code>()</code> which actually starts the child process and blocks until it exits. So when you are inside your container shell doing your thing, your parent process is just sitting here waiting patiently like a driver waiting outside while you run your errands. The moment you type exit in your shell, <a href="http://cmd.Run"><code>cmd.Run</code></a><code>()</code> returns and the parent cleans up and exits too.</p>
<blockquote>
<p><strong>TLDR:</strong> We create a new process pointing to our own binary, hand the kernel a configuration sheet with three namespace flags, wire up the terminal so you can interact with it and then wait for you to exit.</p>
</blockquote>
<p>Now you know how the parent starts the child process in isolation and hands over execution to it. The parent is just setting up the template, defining what the container should look like before it starts. This is exactly what the Docker daemon does when you run <code>docker run</code>. It sets up all the namespace configuration and then hands control over to your container. We are doing the same thing, just without the extra layers of modularity, flexibility and complexity.</p>
<p>So now let's see what our child will do. It has to take control now and start doing its own thing i.e set up everything to run our bash in isolation.</p>
<pre><code class="language-go">func StartNameSpaceChild() {
	args := os.Args

	err := syscall.Sethostname([]byte("custom-host"))
	if err != nil {
		fmt.Println("Failed to change the host name of child namespace")
		os.Exit(1)
	}
	hname, err := os.Hostname()
	if err != nil {
		fmt.Printf("Failed to get the host name of child namespace: %v", err)

	} else {
		fmt.Printf("Hostname changed. New host name is: %s\n", hname)
	}

	// We prevent any event propoagation to the host.
	err = syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
	if err != nil {
		fmt.Printf("Failed to make the root mount private : %v", err)
		os.Exit(1)
	}

	// We add hardening just like Docker do.
	err = syscall.Mount("proc", "/proc", "proc", syscall.MS_NOSUID|syscall.MS_NODEV|syscall.MS_NOEXEC, "")
	if err != nil {
		fmt.Printf("Failed to mount the /proc: %v", err)
		os.Exit(1)
	}

	if len(args) &gt; 2 &amp;&amp; len(args[2]) &gt; 0 {
		err := syscall.Exec(args[2], args[2:], os.Environ())
		if err != nil {
			fmt.Printf("Failed to run the binary in child namespace: %v\n", err)
			os.Exit(1)
		}
		return
	}

}
</code></pre>
<p>The first thing our child does is set its own hostname using syscall.Sethostname. Remember the hotel room analogy? This is the moment our room gets its own number. We hardcode it to custom-host here but in a real container runtime like Docker this would be a randomly generated name or whatever you pass with --name. After setting it we read it back with os.Hostname just to confirm it worked. Trust but verify.</p>
<p>Now here is where it gets interesting. Remember we said when you create a new mount namespace it starts as a copy of the host's mounts? Think of it like a hotel room that was set up from a master template. Every room looks the same when you check in. But before you start rearranging the furniture you need to tell the hotel this is your room now and your changes should not affect the master template or any other room. MS_PRIVATE|MS_REC is us detaching our copy so whatever we do inside our container never bleeds back to the host.</p>
<p>Then we mount a fresh /proc filesystem. Remember /proc is not a real filesystem on disk, it is a virtual one the kernel generates in memory to expose process information. Without remounting it our container would still see the host's process tree and ps would show everything running on your machine. Not very isolated is it? We also add three hardening flags:</p>
<ul>
<li><p><code>MS_NOSUID</code> — ignores any setuid bits on executables inside this mount. This prevents privilege escalation attacks where a process tries to run as root on host.</p>
</li>
<li><p><code>MS_NODEV</code> — blocks access to device files inside this mount. No sneaking into <code>/dev/sda</code> from inside the container.</p>
</li>
<li><p><code>MS_NOEXEC</code> — prevents executing binaries directly from this mount. An extra layer so nothing suspicious runs straight out of <code>/proc</code>.</p>
</li>
</ul>
<p>Finally we call <code>syscall.Exec</code> which replaces our Go process entirely with /bin/sh. The Go runtime is gone, the shell takes over. This is your container. Everything you do from this point is inside the isolated environment we just built.</p>
<blockquote>
<p><strong>TLDR:</strong> The child sets its own hostname, detaches from the host's mount template, mounts a fresh <code>/proc</code> so it can only see its own processes, and then hands over control to your shell. From this point you are inside the container.</p>
</blockquote>
<p>Once you run the program you will see as follows on your terminal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/7bf7fc70-36de-4c2e-a4b4-f054c2f08fc5.png" alt="" style="display:block;margin:0 auto" />

<p>This means your program ran effectively. You can safely ignore the warning because I couldn't find how to turn it off <strong>😅.</strong></p>
<h2>Process Isolation Test</h2>
<p>If we type <code>ps</code> in the child bash you see this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/0eb34ac8-6370-4f84-b562-4940727cc936.png" alt="" style="display:block;margin:0 auto" />

<p>Notice the PID of sh is 1 which means the container thinks the bash is the first process started on the system. Remember PID 1 is the first process that starts after 0. On your actual machine PID 1 is systemd or init, the process that bootstraps everything. But inside our container your shell has stolen that crown. It has no idea there are thousands of processes running outside.</p>
<p>Now let's see what the actual PID of the container is according to the host system:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/dcfeddb0-a913-4a5b-8974-6736376a4a4f.png" alt="" style="display:block;margin:0 auto" />

<p>Notice the PID according to the host is 124553 but any process running inside our namespace custom-host will see the PID of bash as 1. The host knows the truth, the container lives in its own reality. This means we have successfully isolated the process tree.</p>
<h2>Validation and Testing</h2>
<p>Let us put our work to the test now. Here is proof that each isolation is actually working. This is where you can confirm that the isolation at namespace level we have implemented is there and works perfect as intended just like Docker.</p>
<h2>Hostname Isolation Test</h2>
<p>If we type <code>hostname</code> in the child bash you see this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/7edc3c9b-a7f3-4cd1-b104-18197f26a07f.png" alt="" style="display:block;margin:0 auto" />

<p>Notice the hostname is custom-host, exactly what we set. The container has its own identity now.</p>
<p>Now let's see what the parent <code>hostname</code> is. Run the same command in your host system :</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/de6838cb-72e6-4ef0-9b79-d3f97594c09e.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see the parent hostname is different than the child which means we have sucessfully isolated the hostname.</p>
<h2>Mount Isolation Test</h2>
<p><code>cat /proc/mounts</code> shows all the filesystems currently mounted on the system. It reads from <code>/proc</code> which as we know is a virtual filesystem the kernel generates in memory. Instead of showing the full list which could expose sensitive system details, let's just check the count.</p>
<p>If we type <code>cat /proc/mounts | wc -l</code> in the child bash you see this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/ddbb9ddf-d930-4b74-9435-59edd4e332d6.png" alt="" style="display:block;margin:0 auto" />

<p>Notice the count in child is 42.</p>
<p>Now let's see what the parent filesystem mount count is :</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/26aa1ac0-c1bc-4852-9395-efec336109ed.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see the host has one less mount than the container. That extra mount in the child is exactly the <code>/proc</code> we mounted ourselves inside the namespace. The host never saw it, never knew about it. That is mount isolation working exactly as intended.</p>
<h2>Filesystem Isolation Test</h2>
<p><code>ls /proc</code> shows all the directories currently mounted in the proc.</p>
<p>If we type <code>ls /proc</code> in the child bash you see this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/55f47f5b-fd52-4ae8-ae16-49ce1907d624.png" alt="" style="display:block;margin:0 auto" />

<p>Now let's see what the parent /proc shows us :</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a479263916c9c9a801da99/7b893b67-efed-466f-af7e-5d19b6e7caea.png" alt="" style="display:block;margin:0 auto" />

<p>As you can see both show completely different directories. Each has its own <code>/proc</code> virtual filesystem, completely unaware of the other. Filesystem isolation confirmed.</p>
<h2>We are not done yet</h2>
<img src="https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExbTJwcG9veTJrYWJrNnl2bGtsYXd1cG4zYWhrbXZ0Y2g2cWpyd2kwOSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3hs4919rVjpKQAsnlD/giphy.gif" alt="" style="display:block;margin:0 auto" />

<p>Woah!!</p>
<p>You just built your own container using around 100 lines of Go.</p>
<p>No Docker. No magic. Just Linux doing what it has always been capable of.</p>
<p>The same primitives you used here are exactly what Docker uses under the hood.</p>
<p>The only difference is Docker adds polish, networking, volumes, and a lot of convenience on top.</p>
<p>Right now your container still has two problems:</p>
<ol>
<li><p>It has no resource limits. One process can eat all your RAM</p>
</li>
<li><p>It can still see your host filesystem</p>
</li>
</ol>
<p>We fix both in the next part using:</p>
<ul>
<li><p>cgroups</p>
</li>
<li><p>chroot</p>
</li>
</ul>
<p>Think of this part as building the walls.<br />Next part, we add the roof and lock the doors.</p>
<p>If you made it this far, you are exactly the kind of person I am writing this for.</p>
<p>See you in Part 2. 🚀</p>
]]></content:encoded></item></channel></rss>