<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vandit Singh</title>
    <description>The latest articles on DEV Community by Vandit Singh (@v4nd1t).</description>
    <link>https://dev.to/v4nd1t</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F950253%2Fdad44c42-ad66-4fe3-93b8-82ba2126c4bc.jpeg</url>
      <title>DEV Community: Vandit Singh</title>
      <link>https://dev.to/v4nd1t</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/v4nd1t"/>
    <language>en</language>
    <item>
      <title>What Hardening a Container Image Actually Removes</title>
      <dc:creator>Vandit Singh</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/v4nd1t/what-hardening-a-container-image-actually-removes-411f</link>
      <guid>https://dev.to/v4nd1t/what-hardening-a-container-image-actually-removes-411f</guid>
      <description>&lt;p&gt;Here's the fact that reframes container security once it lands: &lt;strong&gt;most of the CVEs a scanner screams about in your image are not in your code.&lt;/strong&gt; They're in the operating system packages you inherited from your base image and never once use at runtime. Your Go binary doesn't need &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;perl&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, a full glibc userland, or the seven libraries those drag in, but if they're in the image, every vulnerability in them is now &lt;em&gt;your&lt;/em&gt; vulnerability as far as the scanner is concerned.&lt;/p&gt;

&lt;p&gt;I spent a chunk of time at RapidFort hardening open-source container images, more than a dozen of them, including images that had to meet IronBank / DoD standards, and the headline result was cutting CVE counts by roughly 80%. The thing nobody tells you up front is &lt;em&gt;how&lt;/em&gt;. You don't patch your way to that number. You &lt;strong&gt;remove&lt;/strong&gt; your way to it. This post is what hardening actually does, why the removal is legitimate and not scanner-gaming, and the real costs it comes with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the CVEs actually live
&lt;/h2&gt;

&lt;p&gt;Pull a normal application image and scan it with &lt;a href="https://trivy.dev/" rel="noopener noreferrer"&gt;Trivy&lt;/a&gt; or Grype. You'll get a wall of findings. Read them closely and a pattern jumps out: the overwhelming majority are attached to OS packages, not your application. A Node app on &lt;code&gt;node:latest&lt;/code&gt; ships a complete Debian userland. A Python app on &lt;code&gt;python:3&lt;/code&gt; ships the same. That's a shell, a package manager, coreutils, compression libraries, TLS libraries, sometimes a compiler toolchain, plus their transitive dependencies. Every one of those is code maintained by someone else, scanned against the same CVE feeds, and counted against your image.&lt;/p&gt;

&lt;p&gt;So the image is mostly not your program. It's a small application sitting on top of a full Linux distribution that came along for the ride. And a full Linux distribution has a lot of surface for a CVE database to hit. Your 40MB of app is buried under a few hundred megabytes of things you're being held accountable for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core move: the most secure package is the one that isn't there
&lt;/h2&gt;

&lt;p&gt;Hardening is, at its heart, one idea applied relentlessly: &lt;strong&gt;remove everything the application doesn't actually need to run.&lt;/strong&gt; Not patch it. Remove it. A CVE in &lt;code&gt;bash&lt;/code&gt; is completely irrelevant to an image that has no &lt;code&gt;bash&lt;/code&gt;. You didn't fix the vulnerability, you deleted the code that had it, which is strictly better because now it can't be exploited, can't be a foothold, and can't show up in next week's scan when a new CVE lands in that same package.&lt;/p&gt;

&lt;p&gt;This flips the mental model from "keep everything updated" to "ship as little as possible." Every binary you don't include is a vulnerability you'll never have to patch, an attack-surface reduction, and one fewer thing in your &lt;a href="https://www.cisa.gov/sbom" rel="noopener noreferrer"&gt;SBOM&lt;/a&gt;. The question stops being "how do I fix these 200 CVEs" and becomes "why is any of this in my image at all."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The reframe&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Patching is a treadmill: new CVEs land in your dependencies forever, and you chase them forever. Removal is permanent: a package that isn't in the image can never generate another finding. Hardening trades an endless patching task for a one-time subtraction. That's why the CVE count doesn't just drop, it stays down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The toolkit, roughly in order of impact
&lt;/h2&gt;

&lt;p&gt;None of these are exotic. The craft is in applying them without breaking the thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start from a smaller base.&lt;/strong&gt; The single biggest lever. The ladder runs &lt;code&gt;full&lt;/code&gt; → &lt;code&gt;slim&lt;/code&gt; → &lt;strong&gt;distroless&lt;/strong&gt; → &lt;code&gt;scratch&lt;/code&gt;. Google's &lt;a href="https://github.com/GoogleContainerTools/distroless" rel="noopener noreferrer"&gt;distroless&lt;/a&gt; images contain your language runtime and nothing else: no shell, no package manager, no coreutils. &lt;code&gt;scratch&lt;/code&gt; is literally empty, perfect for a static Go binary. The newer option worth knowing is &lt;a href="https://github.com/wolfi-dev" rel="noopener noreferrer"&gt;Chainguard's Wolfi&lt;/a&gt; images, built from the ground up to be minimal and near-zero-CVE. Swapping a &lt;code&gt;node:latest&lt;/code&gt; base for a distroless or Wolfi runtime can erase most of your findings before you've done anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use multi-stage builds.&lt;/strong&gt; Build in a fat stage with all the compilers, headers, and package managers you need, then &lt;code&gt;COPY&lt;/code&gt; only the finished artifact into a minimal runtime stage. The build toolchain is where a huge amount of CVE surface hides, and with multi-stage it never ships. Your users get the binary, not the &lt;code&gt;gcc&lt;/code&gt; that made it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kill the shell and the package manager.&lt;/strong&gt; Removing &lt;code&gt;bash&lt;/code&gt;/&lt;code&gt;sh&lt;/code&gt; and &lt;code&gt;apt&lt;/code&gt;/&lt;code&gt;apk&lt;/code&gt;/&lt;code&gt;yum&lt;/code&gt; does double duty. It drops their CVEs, and it removes the attacker's toolkit: if someone gets code execution in your container, a distroless image gives them no shell to spawn and no package manager to pull down their tools. This is one of those rare moves that improves the scan number and the actual security posture at the same time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop root, drop setuid.&lt;/strong&gt; Run as a non-root &lt;code&gt;USER&lt;/code&gt;, and strip setuid/setgid bits off any binaries that keep them, since those are classic local privilege-escalation vectors. Remove docs, man pages, package caches, and test fixtures while you're at it. They're pure dead weight and occasionally a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that looks like concretely
&lt;/h2&gt;

&lt;p&gt;Here's the transformation in the smallest honest example. The naive image, a full base with the whole toolchain baked into the shipped layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# before: app + a complete Debian userland + the build toolchain, all shipped&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That final image carries &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, the compilers &lt;code&gt;node-gyp&lt;/code&gt; pulled in, and a full glibc userland, none of which &lt;code&gt;server.js&lt;/code&gt; touches at runtime. Now the hardened version, multi-stage so the build junk stays in the builder, and a distroless runtime with no shell and no package manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# stage 1: build with everything you need&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# stage 2: ship only the runtime and the artifact&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/nodejs20-debian12&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app /app&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nonroot&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same app, same behavior. The second image has no &lt;code&gt;apt&lt;/code&gt;, no &lt;code&gt;bash&lt;/code&gt;, no build toolchain, and runs as non-root. The scanner findings that were attached to all of that vanish, because all of that is gone. Nothing about &lt;code&gt;server.js&lt;/code&gt; changed; everything about what surrounds it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the number really does drop ~80%
&lt;/h2&gt;

&lt;p&gt;Put those together and the arithmetic is not mysterious. You removed the packages the CVEs were counted against. If 80% of the findings were in the OS userland and the build toolchain, and you ship neither, 80% of the findings are gone. Not suppressed, not marked as false positives, &lt;em&gt;gone&lt;/em&gt;, because the vulnerable code is no longer in the image. That's the honest mechanism behind the headline number, and it's why a hardened image tends to stay clean as new CVEs get disclosed: the disclosures land in packages you already don't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: is this just gaming the scanner?
&lt;/h2&gt;

&lt;p&gt;It's the first objection everyone raises, and it deserves a straight answer. Deleting packages to make Trivy show a smaller number &lt;em&gt;would&lt;/em&gt; be gaming if the packages were actually used, because then you've shipped a broken image that happens to scan well. The line between legitimate hardening and cheating the scanner is exactly one thing: &lt;strong&gt;is the removed package genuinely unused at runtime?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's where the real work is, and it's less glamorous than it sounds. You have to prove the image still does its job with the package gone. That means actually running it, exercising the real code paths, and watching for the thing that breaks because some library shelled out to a binary you removed, or loaded a locale file, or needed a CA bundle you cleaned up too aggressively. Hardening that isn't verified by running the workload isn't hardening, it's wishful deletion. The scan number is the easy part; the proof that the image still works is the job.&lt;/p&gt;

&lt;p&gt;This is exactly the problem RapidFort's &lt;a href="https://www.rapidfort.com/use-case/software-attack-surface" rel="noopener noreferrer"&gt;runtime intelligence&lt;/a&gt; approach automates: instead of guessing what's unused, it profiles what the workload actually loads and touches while it runs, then removes what it never reaches, which is how it reduces attack surface by 60 to 90% without breaking the app. The manual version of this, doing it by hand for one image, is what teaches you why the runtime signal matters. You cannot safely remove a package by reading the Dockerfile. You can only safely remove it by watching the container run and confirming it never needs it. (RapidFort's &lt;a href="https://www.rapidfort.com/resources/resource-center" rel="noopener noreferrer"&gt;resource center&lt;/a&gt; has the deeper write-ups on the methodology if you want the vendor-grade version.)&lt;/p&gt;

&lt;p&gt;And the final acceptance test is the least clever and most convincing one: &lt;strong&gt;actually use the hardened image.&lt;/strong&gt; Not scan it and move on, run it as the real image, in the real workflow, and exercise the things it's supposed to do. If hardening stripped something the app quietly needed, using it is where you find out, immediately and unambiguously, instead of shipping a "secure" image that falls over the first time it hits a code path the scanner never made it touch. A hardened image that nobody ran is a hypothesis, not a result.&lt;/p&gt;

&lt;p&gt;We did that by hand first, then automated it with &lt;strong&gt;&lt;a href="https://robotframework.org/" rel="noopener noreferrer"&gt;Robot Framework&lt;/a&gt; acceptance tests&lt;/strong&gt; (the Python-based keyword-driven testing framework). Each hardened image gets booted and put through its real behaviors by a test suite, so every rebuild is auto-verified instead of manually poked, and the framework produces a pass/fail &lt;strong&gt;report&lt;/strong&gt; you can attach to the image as evidence it still works. Manual dogfooding proves the idea once; automated acceptance tests prove it on every rebuild, which is exactly what you need when you're hardening a dozen images that keep getting new base layers over time. The scan proves the CVEs are gone; the acceptance suite proves you didn't remove anything that mattered.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The cost&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Removing the shell is great for security and annoying for you at 3am. &lt;code&gt;kubectl exec -it pod -- sh&lt;/code&gt; to poke around a running container doesn't work when there's no &lt;code&gt;sh&lt;/code&gt;. You debug hardened images differently: ephemeral debug containers (&lt;code&gt;kubectl debug&lt;/code&gt;) that attach a temporary toolbox to the pod, or a separate, fatter debug image you deploy deliberately. The convenience you're giving up is real. Budget for it instead of being surprised by it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  IronBank, and hardening as a standard rather than a vibe
&lt;/h2&gt;

&lt;p&gt;Some of the images had to meet IronBank requirements. IronBank is the US Department of Defense's hardened-container registry (part of Platform One), and it raises the bar from "make the scanner happy" to "satisfy a documented standard." That means an approved base image, a justification for the packages that remain, continuous scanning, and an accepted-findings process for the CVEs you genuinely can't remove yet (with a reason attached, not a shrug).&lt;/p&gt;

&lt;p&gt;Working to that standard changed how I thought about the whole exercise. "Zero CVEs" is not really the goal, because you can't always hit zero and a number alone doesn't prove much. The goal is a &lt;strong&gt;defensible image&lt;/strong&gt;: everything in it is there for a reason you can state, everything that's gone was removed because it was unused, and the handful of findings that remain are known, tracked, and justified. That's a stronger claim than a green checkmark, and it's the mindset that survives contact with an actual auditor.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Most CVEs in your image are in OS packages you never use&lt;/strong&gt;, inherited from a fat base image, not in your application code.&lt;/li&gt;
&lt;li&gt;Hardening's core move is &lt;strong&gt;remove, don't patch&lt;/strong&gt;. The most secure package is the one that isn't in the image. Removal is permanent; patching is a treadmill.&lt;/li&gt;
&lt;li&gt;The toolkit: &lt;strong&gt;smaller base&lt;/strong&gt; (distroless / Wolfi / scratch), &lt;strong&gt;multi-stage builds&lt;/strong&gt; so the toolchain never ships, &lt;strong&gt;kill the shell + package manager&lt;/strong&gt; (drops CVEs &lt;em&gt;and&lt;/em&gt; the attacker's tools), non-root, no setuid.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;~80% CVE cut&lt;/strong&gt; is just arithmetic: you deleted the packages the CVEs lived in. And it stays down because new disclosures land in packages you no longer carry.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;not scanner-gaming if the removed package is truly unused&lt;/strong&gt;, and proving that (by running the workload) is the actual work.&lt;/li&gt;
&lt;li&gt;Costs are real: &lt;strong&gt;no shell means debugging via &lt;code&gt;kubectl debug&lt;/code&gt; or a separate debug image.&lt;/strong&gt; Standards like &lt;strong&gt;IronBank&lt;/strong&gt; push you from "clean scan" to "defensible image," which is the better goal anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Go deeper
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/GoogleContainerTools/distroless" rel="noopener noreferrer"&gt;Google distroless&lt;/a&gt; and &lt;a href="https://github.com/wolfi-dev" rel="noopener noreferrer"&gt;Chainguard / Wolfi&lt;/a&gt; images, the two minimal-base families worth knowing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Multi-stage builds&lt;/a&gt;, the cheapest way to keep the build toolchain out of your runtime&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://trivy.dev/" rel="noopener noreferrer"&gt;Trivy&lt;/a&gt; and &lt;a href="https://github.com/anchore/grype" rel="noopener noreferrer"&gt;Grype&lt;/a&gt;, the scanners you're hardening against&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://p1.dso.mil/products/iron-bank" rel="noopener noreferrer"&gt;IronBank / Platform One&lt;/a&gt;, the DoD hardened-image standard&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rapidfort.com/use-case/software-attack-surface" rel="noopener noreferrer"&gt;RapidFort's software attack surface use case&lt;/a&gt; and &lt;a href="https://www.rapidfort.com/resources/resource-center" rel="noopener noreferrer"&gt;resource center&lt;/a&gt;, for the runtime-intelligence approach to automating all of the above&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rapidfort.com/blog/reducing-attack-surface-noise-with-runtime-intelligence-a-better-approach-to-cve-management" rel="noopener noreferrer"&gt;Reducing attack surface noise with runtime intelligence&lt;/a&gt;, on separating the CVEs that matter from the ones in code you never run&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.cisa.gov/sbom" rel="noopener noreferrer"&gt;SBOMs&lt;/a&gt;, because the smaller the image, the smaller the thing you have to account for&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Fun fact: the most satisfying hardening result isn't the CVE number, it's opening a shell in the "before" image, then trying the same thing in the "after" image and getting "no such file or directory." That error message is the whole point. There's nothing there to attack.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>docker</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Wrote a Container Runtime From Scratch to Find Out What a Container Even Is</title>
      <dc:creator>Vandit Singh</dc:creator>
      <pubDate>Tue, 25 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/v4nd1t/i-wrote-a-container-runtime-from-scratch-to-find-out-what-a-container-even-is-1ep</link>
      <guid>https://dev.to/v4nd1t/i-wrote-a-container-runtime-from-scratch-to-find-out-what-a-container-even-is-1ep</guid>
      <description>&lt;p&gt;For the longest time "container" lived in my head as a magic word. You type &lt;code&gt;docker run alpine&lt;/code&gt;, a tiny Linux appears out of nowhere, and you just accept it, the way you accept that the fridge light turns off when you close the door. At some point I got tired of not knowing. So I wrote my own little runtime in Go, called &lt;a href="https://github.com/Vandit1604/dockerium" rel="noopener noreferrer"&gt;dockerium&lt;/a&gt;, for three reasons: I wanted to get better at Go, I wanted to see the container trick with my own eyes, and I wanted to understand &lt;em&gt;why Docker itself is written in Go&lt;/em&gt; in the first place. That last one turned out to have the most interesting answer.&lt;/p&gt;

&lt;p&gt;Here's the spoiler that ruined the magic in the best way: &lt;strong&gt;a container is not a thing. It's just a normal process that has been lied to about what it can see.&lt;/strong&gt; That's it. No tiny VM, no special "container" object in the kernel. Just a process with a very sheltered upbringing. Let me show you the actual code that does the lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one sentence version
&lt;/h2&gt;

&lt;p&gt;A container is a regular Linux process where you've flipped a few switches so it thinks it's alone in the world:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its own filesystem (it can't see yours).&lt;/li&gt;
&lt;li&gt;Its own process list (it thinks it's PID 1, the first process).&lt;/li&gt;
&lt;li&gt;Its own hostname, network, users.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those switches are &lt;strong&gt;namespaces&lt;/strong&gt;, and the kernel hands them to you for free. dockerium's whole job is to start a process with the right switches flipped, then drop it into a filesystem we downloaded from Docker Hub. Here's the shape of it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5crq5azop56dhm873rk6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5crq5azop56dhm873rk6.png" alt="The dockerium binary runs on the host, re-execs itself as a child into a fresh set of Linux namespaces, mounts a private proc, pivot_roots into the pulled image rootfs, sets the hostname, and finally execs bin sh as PID 1. A user namespace maps the caller to root inside the container only." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The entire runtime in one picture. The interesting part is the dashed box: a fresh namespace set is what makes a process feel like a container.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Flipping the switches
&lt;/h2&gt;

&lt;p&gt;This is the beating heart of the whole project, and it's shorter than you'd think. When we launch the child process, we hand the kernel a pile of &lt;code&gt;CLONE_NEW*&lt;/code&gt; flags. Each one says "give this process its own private copy of X."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SysProcAttr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SysProcAttr&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Cloneflags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLONE_NEWNS&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="c"&gt;// mount namespace: its own filesystem view&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLONE_NEWUTS&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="c"&gt;// its own hostname&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLONE_NEWIPC&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="c"&gt;// its own inter-process comms&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLONE_NEWPID&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="c"&gt;// its own process tree (hello, PID 1)&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLONE_NEWNET&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="c"&gt;// its own network stack&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLONE_NEWUSER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// its own user table (this is the rootless bit)&lt;/span&gt;
    &lt;span class="n"&gt;UidMappings&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SysProcIDMap&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ContainerID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HostID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;GidMappings&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SysProcIDMap&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ContainerID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HostID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getgid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/dockerium/blob/1c3a253f16e2774d3189dae4fecb5232a2d58581/main.go#L148-L169" rel="noopener noreferrer"&gt;↗ main.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that comment column top to bottom and you have basically read the definition of a container. Six flags. That's the "magic."&lt;/p&gt;

&lt;p&gt;The last one, &lt;code&gt;CLONE_NEWUSER&lt;/code&gt;, is the part I'm proudest of, because it's what lets the whole thing run &lt;strong&gt;without root&lt;/strong&gt;. The &lt;code&gt;UidMappings&lt;/code&gt; block says: "the user that is root (ID 0) &lt;em&gt;inside&lt;/em&gt; this namespace is actually just me, my normal unprivileged user, out here." So inside the container you're root and can do root-y things, but the kernel still treats you as boring old you on the host. Root in there, not-root out here. That is the entire rootless trick in one struct.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The reframe&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Docker feels like it "creates" an isolated machine. It doesn't. The kernel already knows how to give a process a private filesystem, process list, and network. Docker (and dockerium) just asks nicely with the right flags. The isolation was always sitting there in the kernel waiting for someone to turn it on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The weird part: the program has to run itself
&lt;/h2&gt;

&lt;p&gt;Here's the first thing that genuinely surprised me. You'd think you'd just set those flags on your own process and start doing container stuff. You can't, not cleanly. A Go program is multithreaded from the moment it starts, and some namespaces (PID especially) only really apply to a &lt;em&gt;fresh child&lt;/em&gt;. So the classic move, the one every real runtime uses, is: &lt;strong&gt;the program re-executes itself.&lt;/strong&gt; It launches a second copy of its own binary, and &lt;em&gt;that&lt;/em&gt; copy is the thing that gets the new namespaces and becomes PID 1.&lt;/p&gt;

&lt;p&gt;dockerium does this with Docker's own &lt;code&gt;reexec&lt;/code&gt; helper, and finding it is half the fun of this project. I went digging through the &lt;a href="https://github.com/moby/moby" rel="noopener noreferrer"&gt;moby/moby&lt;/a&gt; source (moby is the open source engine underneath Docker) expecting the "make a container" code to be some huge scary subsystem. Instead, tucked away in &lt;a href="https://github.com/moby/moby/tree/master/pkg/reexec" rel="noopener noreferrer"&gt;&lt;code&gt;pkg/reexec&lt;/code&gt;&lt;/a&gt;, was this tiny, almost cheeky helper that does exactly the self-re-execution trick. The billion-dollar container company and my weekend toy use &lt;em&gt;the same little function&lt;/em&gt;. In &lt;code&gt;init()&lt;/code&gt; we register a named entrypoint, and check whether we're the re-exec'd child:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// register the function that will run *inside* the new namespaces&lt;/span&gt;
    &lt;span class="n"&gt;reexec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"initialisation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;initialisation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// if we ARE the re-exec'd child, run it and stop here&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reexec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/dockerium/blob/1c3a253f16e2774d3189dae4fecb5232a2d58581/main.go#L21-L28" rel="noopener noreferrer"&gt;↗ main.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the same binary plays two roles. First run: "I'm the parent, let me pull the image and spawn a child with the clone flags." Second run (the child it spawned): "I'm &lt;code&gt;initialisation&lt;/code&gt;, I'm inside the fresh namespaces now, let me set up the container." One program, two personalities, split by a single &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why &lt;em&gt;is&lt;/em&gt; Docker written in Go?
&lt;/h2&gt;

&lt;p&gt;This was the question I actually built dockerium to answer, and the &lt;code&gt;reexec&lt;/code&gt; trick turns out to be the clue. Go is a strange choice for a container runtime if you only look at the good parts. Here's the honest ledger.&lt;/p&gt;

&lt;p&gt;The pros are real and big. Go compiles to a &lt;strong&gt;single static binary&lt;/strong&gt; with no runtime to install, which is exactly what you want for a tool that has to run on every Linux box on earth. It has &lt;strong&gt;first-class access to raw Linux syscalls&lt;/strong&gt; through the &lt;code&gt;syscall&lt;/code&gt; package, so &lt;code&gt;clone&lt;/code&gt;, &lt;code&gt;pivot_root&lt;/code&gt;, &lt;code&gt;mount&lt;/code&gt;, and &lt;code&gt;sethostname&lt;/code&gt; are all right there. It &lt;strong&gt;cross-compiles&lt;/strong&gt; trivially, and its &lt;strong&gt;concurrency&lt;/strong&gt; model makes juggling many containers pleasant. For a fleet-of-processes tool, that's a fantastic fit.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;reexec&lt;/code&gt; exists because of Go's one genuine &lt;em&gt;con&lt;/em&gt; here, and it's a sharp one. A Go program is &lt;strong&gt;multithreaded the moment it starts&lt;/strong&gt; (the runtime spins up threads for the scheduler, garbage collector, and so on). Some namespace operations, especially entering a new PID namespace, are defined per-thread or only apply cleanly to a fresh single-threaded process. You cannot reliably just call &lt;code&gt;unshare&lt;/code&gt; inside a running Go program and expect a clean container, because you're not one process, you're a threaded runtime. So the whole "re-exec yourself as a fresh child with the flags set at spawn time" dance isn't a cute pattern. It's a &lt;strong&gt;workaround for Go's threading model&lt;/strong&gt;, and Docker's own engineers hit the exact same wall, which is why &lt;code&gt;reexec&lt;/code&gt; is sitting in moby's source in the first place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most interesting thing I learned building this wasn't how containers work. It was that even the "right" language choice comes with a tax, and good engineering is often a clever workaround for your own tools. &lt;code&gt;reexec&lt;/code&gt; is Docker admitting, in code, that Go's concurrency (its headline feature) is the exact thing that makes low-level namespace work awkward. They shipped the workaround and moved on. So did I.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Building the container's fake world
&lt;/h2&gt;

&lt;p&gt;Once we're the child, inside the new namespaces, we set the place up. The order matters and each line is doing real work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;initialisation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;limitCPUandMemory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rootfsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;memorylimit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cpulimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// (see the honest note below)&lt;/span&gt;
    &lt;span class="n"&gt;mountProc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rootfsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="c"&gt;// give it a private /proc&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rootfsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/proc"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pivotRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rootfsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="c"&gt;// swap the whole filesystem&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sethostname&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dockerium"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;             &lt;span class="c"&gt;// its own name&lt;/span&gt;
    &lt;span class="n"&gt;nsRun&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                              &lt;span class="c"&gt;// finally, exec /bin/sh&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/dockerium/blob/1c3a253f16e2774d3189dae4fecb5232a2d58581/main.go#L31-L56" rel="noopener noreferrer"&gt;↗ main.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The star of this function is &lt;code&gt;pivotRoot&lt;/code&gt;. This is how the container gets a filesystem that isn't yours. You might expect &lt;code&gt;chroot&lt;/code&gt; here, the old classic. Real runtimes use &lt;a href="https://man7.org/linux/man-pages/man2/pivot_root.2.html" rel="noopener noreferrer"&gt;&lt;code&gt;pivot_root&lt;/code&gt;&lt;/a&gt; instead because &lt;code&gt;chroot&lt;/code&gt; is escapable and &lt;code&gt;pivot_root&lt;/code&gt; actually swaps out the root mount and lets you unmount the old one entirely, so there's no path back to the host filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;pivotRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRoot&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;putOld&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/.pivot_root"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// pivot_root needs newRoot to be a mount point, so bind-mount it to itself&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MS_BIND&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MS_REC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MkdirAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;putOld&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0700&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PivotRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;putOld&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// new root is live; old root parked at putOld&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;putOld&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/.pivot_root"&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;putOld&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MNT_DETACH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// cut the cord to the host&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;putOld&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/dockerium/blob/1c3a253f16e2774d3189dae4fecb5232a2d58581/rootfs.go#L15-L52" rel="noopener noreferrer"&gt;↗ rootfs.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two little gotchas hide in there that cost me real time. One: &lt;code&gt;pivot_root&lt;/code&gt; refuses to run unless the new root is a proper mount point, which is why we bind-mount the directory onto itself first. Two: the place you park the old root (&lt;code&gt;putOld&lt;/code&gt;) has to live &lt;em&gt;underneath&lt;/em&gt; the new root, or the kernel throws &lt;code&gt;EINVAL&lt;/code&gt; and you sit there confused. Both are the kind of thing no tutorial mentions until you hit the wall yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why /proc&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;mountProc&lt;/code&gt; step isn't decoration. Tools like &lt;code&gt;ps&lt;/code&gt; read the &lt;code&gt;/proc&lt;/code&gt; filesystem to list processes. Give the container a fresh &lt;code&gt;/proc&lt;/code&gt; and &lt;code&gt;ps&lt;/code&gt; inside it shows only the container's own processes, which is why your process genuinely believes it's PID 1 and alone. This only works because &lt;code&gt;CLONE_NEWNS&lt;/code&gt; gave us a private mount namespace, so mounting &lt;code&gt;/proc&lt;/code&gt; here doesn't leak out onto your real machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where the image comes from
&lt;/h2&gt;

&lt;p&gt;Before any of that, we need a filesystem to pivot into. That's just Alpine (or whatever) pulled straight from Docker Hub with its plain HTTP registry API: grab an auth token, fetch the image manifest, download the layer blob, and untar it into a folder. The extraction is refreshingly dumb, it literally shells out to &lt;code&gt;tar&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ExtractLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-xvf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-C"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/tmp/dockerium/rootfs/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/dockerium/blob/1c3a253f16e2774d3189dae4fecb5232a2d58581/docker/docker.go#L123-L131" rel="noopener noreferrer"&gt;↗ docker/docker.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Docker image, demystified, is a tarball of a filesystem plus a JSON file describing it. Unpack the tar, and you have a root directory you can pivot into. That's the whole "image" concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: what I faked
&lt;/h2&gt;

&lt;p&gt;I promised myself I'd be straight about this, because pretending a learning project is production is how you fool exactly one person, yourself. Two things in dockerium look done but aren't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The resource limits are theatre.&lt;/strong&gt; There's a &lt;code&gt;limitCPUandMemory&lt;/code&gt; function that dutifully writes &lt;code&gt;500MB&lt;/code&gt; and some CPU shares into files like &lt;code&gt;memory.limit_in_bytes&lt;/code&gt;. Looks legit. It does nothing. It writes those files &lt;em&gt;inside the container's own rootfs directory&lt;/em&gt;, as plain text files the kernel never reads. Real cgroups mean writing into an actually-mounted cgroup hierarchy, and doing it under a user namespace needs delegated permissions on top. So my container will happily eat all your RAM. Lesson burned into me forever: &lt;strong&gt;writing a file named &lt;code&gt;memory.limit_in_bytes&lt;/code&gt; is not the same as setting a memory limit.&lt;/strong&gt; The name is not the mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's no networking.&lt;/strong&gt; I set &lt;code&gt;CLONE_NEWNET&lt;/code&gt;, which gives the container its own network stack, which sounds great until you realize a &lt;em&gt;fresh&lt;/em&gt; network namespace has nothing in it but a loopback interface. To actually reach the internet you have to build a virtual ethernet pair, wire it to a bridge, set up NAT. dockerium does none of that. So the container is isolated on the network in the most complete way possible: it can talk to no one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's also a real bug I left in as a monument to my own hubris: the layer download writes every layer to the same fixed path and overwrites it each time, so a multi-layer image only ever unpacks its last layer. Single-layer images like Alpine work fine, which is exactly why I didn't notice for way too long. The demo working is not the same as the thing working.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this actually taught me
&lt;/h2&gt;

&lt;p&gt;The point was never to replace Docker. Docker has a decade of people solving the exact "theatre" problems I just admitted to. The point was to delete the magic. And it worked: I can't hear "container" anymore without seeing six clone flags and a &lt;code&gt;pivot_root&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've got a black box in your own stack that you've just been &lt;em&gt;accepting&lt;/em&gt;, this is my pitch: go build the crappiest possible version of it. Two hundred lines that barely work will teach you more than a hundred blog posts, because the kernel doesn't accept vibes. It makes you get the flags right.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A container is a normal process with a few kernel switches flipped: &lt;strong&gt;namespaces&lt;/strong&gt; for filesystem, PID, network, hostname, users. There is no "container" object.&lt;/li&gt;
&lt;li&gt;The program &lt;strong&gt;re-execs itself&lt;/strong&gt; so the child gets a clean set of namespaces and becomes PID 1. One binary, two roles, split by an &lt;code&gt;if&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pivot_root&lt;/code&gt;, not &lt;code&gt;chroot&lt;/code&gt;,&lt;/strong&gt; swaps the filesystem and cuts the cord to the host. Mind the two gotchas: new root must be a mount point, old root must live underneath it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CLONE_NEWUSER&lt;/code&gt; mapping your uid to 0-inside-only is the whole &lt;strong&gt;rootless&lt;/strong&gt; trick.&lt;/li&gt;
&lt;li&gt;Be honest about the theatre. Writing to a file called &lt;code&gt;memory.limit_in_bytes&lt;/code&gt; limits nothing. A fresh net namespace has no network.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Go deeper
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The whole thing is ~440 lines of Go: &lt;a href="https://github.com/Vandit1604/dockerium" rel="noopener noreferrer"&gt;github.com/Vandit1604/dockerium&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker's own &lt;a href="https://github.com/moby/moby/tree/master/pkg/reexec" rel="noopener noreferrer"&gt;&lt;code&gt;pkg/reexec&lt;/code&gt;&lt;/a&gt; in the &lt;a href="https://github.com/moby/moby" rel="noopener noreferrer"&gt;moby&lt;/a&gt; source, the tiny helper this whole thing (and Docker itself) leans on&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man7.org/linux/man-pages/man2/pivot_root.2.html" rel="noopener noreferrer"&gt;&lt;code&gt;man 2 pivot_root&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://man7.org/linux/man-pages/man7/namespaces.7.html" rel="noopener noreferrer"&gt;&lt;code&gt;man 7 namespaces&lt;/code&gt;&lt;/a&gt;, the two pages that explain the real machinery&lt;/li&gt;
&lt;li&gt;Liz Rice's &lt;a href="https://github.com/lizrice/containers-from-scratch" rel="noopener noreferrer"&gt;"Containers From Scratch"&lt;/a&gt; talk, which is the canonical version of this exercise and where a lot of the shape comes from&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Fun fact: the moment it clicked was watching &lt;code&gt;ps&lt;/code&gt; inside the container show a process list of exactly one, my shell, sitting there as PID 1, completely convinced it was the only thing alive on the machine. A whole process, blissfully lied to. That's a container.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>docker</category>
      <category>linux</category>
      <category>programming</category>
    </item>
    <item>
      <title>A Load Balancer Is 200 Lines of Go, and Five Bugs You Won't See Coming</title>
      <dc:creator>Vandit Singh</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/v4nd1t/a-load-balancer-is-200-lines-of-go-and-five-bugs-you-wont-see-coming-5cm3</link>
      <guid>https://dev.to/v4nd1t/a-load-balancer-is-200-lines-of-go-and-five-bugs-you-wont-see-coming-5cm3</guid>
      <description>&lt;p&gt;"Load balancer" is one of those phrases that sounds like it should live in a data center behind a locked door. You have used a dozen of them without thinking: every time nginx sits in front of your app, every time a Kubernetes &lt;code&gt;Service&lt;/code&gt; fans traffic across pods, every time an AWS ALB spreads requests, something in the middle is quietly picking a backend for you. I built a tiny one, &lt;a href="https://github.com/Vandit1604/go-lb" rel="noopener noreferrer"&gt;go-lb&lt;/a&gt;, because I wanted to stop hand-waving the one question underneath all of that: &lt;em&gt;how does a single incoming request find its way to one specific server out of many?&lt;/em&gt; You hit one address, but behind it are five identical backends. Something has to sit in the middle and decide. I wanted to be that something, in code, so the routing stopped being magic.&lt;/p&gt;

&lt;p&gt;The answer, it turns out, fits in a couple hundred lines of Go. A load balancer takes incoming HTTP requests and spreads them across a pool of backend servers. That's the job. That's the whole job.&lt;/p&gt;

&lt;p&gt;But this post has a twist, and it's the honest kind. Writing the happy path was easy. The interesting part is the bugs sitting quietly in my own code, the kind that pass every casual test and would absolutely bite you in production. There are five of them. Four you'd never notice by running the thing. One is loud enough that Go's own tooling flags it, and I shipped it anyway. Let me build the thing and then show you where it lies to you, because &lt;em&gt;that&lt;/em&gt; is the actual lesson about load balancers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually sits in the middle
&lt;/h2&gt;

&lt;p&gt;Before the code, the shape. Strip away the marketing and every layer-7 load balancer is two pieces: a &lt;strong&gt;picker&lt;/strong&gt; that chooses which backend handles the next request, and a &lt;strong&gt;forwarder&lt;/strong&gt; that relays the request to it and streams the response back. nginx calls the pool an &lt;code&gt;upstream&lt;/code&gt; block. HAProxy calls it a &lt;code&gt;backend&lt;/code&gt;. Envoy calls it a &lt;code&gt;cluster&lt;/code&gt;. Kubernetes hides it inside &lt;code&gt;kube-proxy&lt;/code&gt; and calls the front door a &lt;code&gt;Service&lt;/code&gt;. Different words, same two pieces.&lt;/p&gt;

&lt;p&gt;The picker is where the named strategies live: round-robin, least-connections, IP-hash, weighted. The forwarder is mostly plumbing: rewrite the request headers, open a connection, copy bytes both ways. What surprised me building go-lb is how lopsided the difficulty is. The forwarder is nearly free in Go. The picker is trivial to write and &lt;em&gt;very&lt;/em&gt; easy to write subtly wrong. Almost every bug below lives in the picker or in the state it leans on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core: round-robin in one function
&lt;/h2&gt;

&lt;p&gt;The simplest way to spread load is round-robin: hand request 1 to backend A, request 2 to backend B, request 3 to backend C, then back to A. Just rotate through the list. Here's the real rotation from go-lb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ServerPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;getNextBackend&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backends&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backends&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backends&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/serverpool.go#L30-L41" rel="noopener noreferrer"&gt;↗ serverpool.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;% len(s.backends)&lt;/code&gt; wraps the index back to zero, so it cycles forever. The &lt;code&gt;sync.Mutex&lt;/code&gt; matters more than it looks: a load balancer is hammered by many requests at once, so two goroutines could try to bump &lt;code&gt;current&lt;/code&gt; at the same instant and both grab the same backend, or worse, index out of bounds. The lock makes the rotation atomic. Miss that and you've got a race condition that only shows up under load, which is the worst kind. (nginx sidesteps this entirely by keeping its round-robin counter per-worker-process rather than shared, so there's nothing to lock. Different concurrency model, same problem to solve.)&lt;/p&gt;

&lt;p&gt;Then the actual forwarding is almost free, because Go's standard library already has a reverse proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;rp&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httputil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewSingleHostReverseProxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/main.go#L11" rel="noopener noreferrer"&gt;↗ main.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That one line is doing a lot. &lt;code&gt;NewSingleHostReverseProxy&lt;/code&gt; returns a &lt;code&gt;*httputil.ReverseProxy&lt;/code&gt; whose &lt;code&gt;Director&lt;/code&gt; function rewrites each incoming request: it swaps in the backend's scheme and host, joins the paths, and leaves the rest alone. When a request arrives, &lt;code&gt;ServeHTTP&lt;/code&gt; runs the Director, dials the backend (reusing pooled connections through the default &lt;code&gt;http.Transport&lt;/code&gt;), and then &lt;em&gt;streams&lt;/em&gt; the response back to the client as it arrives rather than buffering the whole thing in memory. That streaming detail is why a reverse proxy can relay a 2GB download without using 2GB of RAM. Writing that correctly, with trailers, flushing, and hop-by-hop header stripping handled to spec, is the genuinely hard part of a proxy, and Go hands it to you for free.&lt;/p&gt;

&lt;p&gt;So the "balancer" is really just: pick a backend, hand the request to its proxy. Pick, forward, repeat.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The reframe&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A load balancer isn't a mysterious appliance. It's a loop that picks a backend and a reverse proxy that forwards to it. Go ships the hard part (the proxy) in the standard library. What's left, the picking, is the part you actually design, and it's where all the interesting decisions and bugs live.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bug one: it skips the first backend
&lt;/h2&gt;

&lt;p&gt;Look at that rotation again. &lt;code&gt;s.current&lt;/code&gt; starts at &lt;code&gt;0&lt;/code&gt;, and the function does &lt;code&gt;(s.current + 1) % len&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; returning the backend. So the very first request doesn't go to &lt;code&gt;backends[0]&lt;/code&gt;. It goes to &lt;code&gt;backends[1]&lt;/code&gt;. Backend zero doesn't get a request until the counter wraps all the way around.&lt;/p&gt;

&lt;p&gt;With three backends it's a small unfairness. But it's a real off-by-one, and it's the perfect example of a bug that passes every test you'd casually write. You fire ten requests, see them spread across all three servers, go "yep, round-robin works," and ship it. The skipped-first-backend detail only matters at the edges: a single request, a pool of two, a health check that always hits the "wrong" one, a canary you just added at index 0 that quietly gets no traffic. Increment-then-use versus use-then-increment is a one-character difference that changes behavior, and you will not see it unless you're looking. The fix is to read &lt;code&gt;backends[s.current]&lt;/code&gt; first and increment afterward, so the sequence starts at 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the health check that never runs
&lt;/h2&gt;

&lt;p&gt;Here's the one that actually matters. A load balancer's real value isn't spreading load, it's &lt;em&gt;not sending traffic to a dead server&lt;/em&gt;. If backend B falls over, requests routed to B should stop. go-lb even has the code for it. There's a function that TCP-dials a backend to see if it's alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;IsBackendAlive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dialer&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DialContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/serverpool.go#L54-L62" rel="noopener noreferrer"&gt;↗ serverpool.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Looks great. There's a &lt;code&gt;SetAlive&lt;/code&gt; setter, an &lt;code&gt;IsAlive&lt;/code&gt; getter, and the routing code even walks the pool with &lt;code&gt;GetNextValidServer&lt;/code&gt;, checking &lt;code&gt;IsAlive()&lt;/code&gt; on each candidate and correctly returning a 503 if every backend is down. The whole liveness system is wired up... except &lt;strong&gt;nothing ever calls &lt;code&gt;IsBackendAlive&lt;/code&gt; or &lt;code&gt;SetAlive&lt;/code&gt;.&lt;/strong&gt; No background loop pings the servers. So every backend is marked alive when it's created, in &lt;code&gt;NewBackend&lt;/code&gt;, and stays "alive" forever, even after it's been on fire for an hour. The safety net is fully built and never hung up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the real gotcha&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the single most important thing a real load balancer does, and it's the easiest to leave half-finished, because a demo with three healthy backends works perfectly without it. The failure only appears when a backend actually dies, which never happens on your laptop and always happens at 3am in prod. "The code exists" and "the code runs" are different claims. Grep for the callers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The grown-up version is a whole subsystem. HAProxy runs &lt;strong&gt;active&lt;/strong&gt; checks on a timer (a TCP connect or an HTTP &lt;code&gt;GET /health&lt;/code&gt; every few seconds) and, separately, &lt;strong&gt;passive&lt;/strong&gt; checks that watch real traffic and eject a backend after N consecutive failures. Envoy calls the passive version &lt;em&gt;outlier detection&lt;/em&gt; and will pull a host out of rotation the moment it starts returning 5xx, then slowly let it back in. Kubernetes splits the concept in two: a &lt;code&gt;readinessProbe&lt;/code&gt; decides whether a pod gets traffic at all, and a &lt;code&gt;livenessProbe&lt;/code&gt; decides whether to restart it. go-lb has exactly the dialing primitive those systems are built on, and never schedules it. That gap &lt;em&gt;is&lt;/em&gt; the difference between a demo and a load balancer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug three: the counter that's lying about its own name
&lt;/h2&gt;

&lt;p&gt;go-lb has a field called &lt;code&gt;aliveConnections&lt;/code&gt; and a getter &lt;code&gt;GetActiveConnections&lt;/code&gt;. Sounds like it tracks how many requests a backend is currently handling, which is exactly what you'd need for a smarter "least-connections" strategy (send the next request to whoever's least busy, the thing nginx spells &lt;code&gt;least_conn&lt;/code&gt; and Envoy calls &lt;code&gt;LEAST_REQUEST&lt;/code&gt;). Here's how it's updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rw&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reverseProxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aliveConnections&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/backend.go#L59-L64" rel="noopener noreferrer"&gt;↗ backend.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Spot it? The counter goes up &lt;em&gt;after&lt;/em&gt; the request finishes, and it never comes back down. There's no matching &lt;code&gt;aliveConnections--&lt;/code&gt;. So it's not "active connections" at all, it's a lifetime total of requests ever served. A number that only grows. To actually track active connections you'd increment &lt;em&gt;before&lt;/em&gt; &lt;code&gt;ServeHTTP&lt;/code&gt; and decrement &lt;em&gt;after&lt;/em&gt;, in a &lt;code&gt;defer&lt;/code&gt; so it survives a panic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rw&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aliveConnections&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aliveConnections&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reverseProxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As written, the field's name is a promise the code doesn't keep, and any least-connections logic built on it would be quietly, confidently wrong: it would always route to the backend that has served the &lt;em&gt;fewest requests since startup&lt;/em&gt;, which after a restart means dumping everything on whichever server booted last. The name lies, and the lie is the kind that survives code review because everyone reads the name instead of the arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug four: the config file is a decoration
&lt;/h2&gt;

&lt;p&gt;go-lb reads a &lt;code&gt;config.yaml&lt;/code&gt; on startup and validates it. Here's the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3332&lt;/span&gt;
&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;round-robin&lt;/span&gt;
&lt;span class="na"&gt;backends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8081"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8082"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/config.yaml#L1-L6" rel="noopener noreferrer"&gt;↗ config.yaml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The config loader even &lt;em&gt;validates&lt;/em&gt; that a port was provided, erroring out if &lt;code&gt;config.Port == 0&lt;/code&gt;. So it insists you give it a port. Then &lt;code&gt;main.go&lt;/code&gt; throws it away and hardcodes a different one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Starting load balancer on port :9000"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListenAndServe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":9000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/main.go#L38-L39" rel="noopener noreferrer"&gt;↗ main.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You set &lt;code&gt;port: 3332&lt;/code&gt;, the validator demands it, and the server binds &lt;code&gt;:9000&lt;/code&gt; anyway. The &lt;code&gt;strategy: round-robin&lt;/code&gt; line is worse: nothing in the codebase ever reads &lt;code&gt;config.Strategy&lt;/code&gt;. The struct field exists, YAML happily unmarshals into it, and it is referenced exactly zero times. So the one knob that would make this a &lt;em&gt;configurable&lt;/em&gt; load balancer, the choice of routing algorithm, is decorative. Round-robin isn't selected; it's the only thing there is.&lt;/p&gt;

&lt;p&gt;This is the most human bug of the five, and the most common one in real codebases. The config file reads like a contract. Half of it isn't wired to anything. Nobody notices because the defaults happen to match what the ignored values say, so the demo works and the file looks authoritative. The tell is always the same: grep for where a config field is &lt;em&gt;read&lt;/em&gt;, not where it's declared. A field that's declared and never read is a comment wearing a costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug five: the one go vet catches, that I shipped anyway
&lt;/h2&gt;

&lt;p&gt;This is the loud one. Run &lt;code&gt;go vet ./...&lt;/code&gt; on go-lb and it reports three copies of the same complaint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;loadbalancer.go:12: InitLoadBalancer passes lock by value:
    ServerPool contains sync.Mutex
main.go:33: call of InitLoadBalancer copies lock value:
    ServerPool contains sync.Mutex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the crime. &lt;code&gt;ServerPool&lt;/code&gt; embeds a &lt;code&gt;sync.Mutex&lt;/code&gt; by value, and then I pass the whole &lt;code&gt;ServerPool&lt;/code&gt; &lt;em&gt;by value&lt;/em&gt; into the constructor and copy it again into a struct literal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;InitLoadBalancer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serverPool&lt;/span&gt; &lt;span class="n"&gt;ServerPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;LoadBalancer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;LoadBalancer&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;serverPool&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;serverPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/Vandit1604/go-lb/blob/70ec783994ca4c967ecb3b3161f9ac16b1a393ac/loadbalancer.go#L12-L16" rel="noopener noreferrer"&gt;↗ loadbalancer.go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Copying a &lt;code&gt;sync.Mutex&lt;/code&gt; is a genuine sin in Go, because a mutex's whole job is to be a single shared piece of state that every goroutine agrees on. Copy it and you now have two mutexes that don't know about each other; a goroutine locking one provides no protection against a goroutine reading through the other. go-lb gets away with it &lt;em&gt;only&lt;/em&gt; by luck: the copy happens once, at startup, before any request goroutines exist, and from then on every request goes through the same &lt;code&gt;lb.serverPool&lt;/code&gt; copy, so there's effectively one mutex in play. It works. It's still wrong, and it's exactly the kind of "works today" that becomes a heisenbug the day someone adds a second code path that touches the original &lt;code&gt;ServerPool&lt;/code&gt;. The fix is to hold &lt;code&gt;*ServerPool&lt;/code&gt; (a pointer) everywhere and never copy the struct. &lt;code&gt;go vet&lt;/code&gt; told me this before I ever pushed. I shipped it anyway, which is its own small lesson about how easy it is to tune out the tooling that's trying to help you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm showing you my own bugs
&lt;/h2&gt;

&lt;p&gt;Because this is the real education. Anyone can write the round-robin. What building go-lb actually taught me is that a load balancer is defined by its failure handling and its honesty about its own state, and both are exactly the stuff that's invisible when everything's healthy. The five bugs aren't embarrassing accidents, they're a map of where the real difficulty lives: &lt;strong&gt;concurrency&lt;/strong&gt; (the mutex, the off-by-one, the copied lock), &lt;strong&gt;liveness&lt;/strong&gt; (the check nobody calls), and &lt;strong&gt;honest state&lt;/strong&gt; (the counter that grows forever, the config that's ignored).&lt;/p&gt;

&lt;p&gt;Notice that not one of them is a crash. They're all cases where the code runs, the demo passes, and the behavior is quietly not what the names claim. That's the texture of infrastructure bugs generally. The system stays up and does the wrong thing calmly, and you find out from a graph three weeks later.&lt;/p&gt;

&lt;p&gt;Production load balancers, HAProxy, Envoy, nginx, Traefik, are mostly &lt;em&gt;that hard part&lt;/em&gt;: active and passive health checks, connection draining, timeouts, retries with budgets, circuit breaking, outlier ejection, weighted and least-request routing, sticky sessions. The routing loop is the 10%. The other 90% is what happens when a backend misbehaves, and every one of my five bugs is a tiny hole in that 90%. Writing the toy version is what made the shape of the real thing finally obvious to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A load balancer's core is a &lt;strong&gt;picker plus a forwarder&lt;/strong&gt;. In Go the forwarder is &lt;code&gt;httputil.ReverseProxy&lt;/code&gt;, which streams responses and handles the hard proxy spec for free. The picker is the part you design.&lt;/li&gt;
&lt;li&gt;Lock the rotation with a &lt;strong&gt;mutex&lt;/strong&gt;, or you get a race under load that never shows up in testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 1:&lt;/strong&gt; increment-then-index skips &lt;code&gt;backends[0]&lt;/code&gt; on the first request. A one-character off-by-one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 2:&lt;/strong&gt; the health-check code exists but &lt;strong&gt;nothing calls it&lt;/strong&gt;, so dead backends keep getting traffic. This is the whole point of a load balancer, and the easiest thing to leave unwired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 3:&lt;/strong&gt; &lt;code&gt;aliveConnections&lt;/code&gt; counts up after each request and never down, so it's a lifetime total, not active connections. The name lies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 4:&lt;/strong&gt; &lt;code&gt;config.yaml&lt;/code&gt; sets a port and a strategy that the code &lt;strong&gt;never reads&lt;/strong&gt;. It binds a hardcoded &lt;code&gt;:9000&lt;/code&gt; and only ever does round-robin. Config that isn't wired up is a comment in a costume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 5:&lt;/strong&gt; &lt;code&gt;go vet&lt;/code&gt; flags a &lt;strong&gt;copied mutex&lt;/strong&gt; (&lt;code&gt;ServerPool&lt;/code&gt; passed by value). It works by luck because the copy happens once at startup. Still wrong. Hold a pointer.&lt;/li&gt;
&lt;li&gt;Real load balancers are 90% failure handling. The routing loop is the easy 10%.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Go deeper
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The whole thing, ~230 lines: &lt;a href="https://github.com/Vandit1604/go-lb" rel="noopener noreferrer"&gt;github.com/Vandit1604/go-lb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Go's &lt;a href="https://pkg.go.dev/net/http/httputil#ReverseProxy" rel="noopener noreferrer"&gt;&lt;code&gt;httputil.ReverseProxy&lt;/code&gt;&lt;/a&gt;, the standard-library workhorse, and its &lt;code&gt;Director&lt;/code&gt;/&lt;code&gt;Rewrite&lt;/code&gt; internals&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/health-checks/" rel="noopener noreferrer"&gt;How HAProxy does health checks&lt;/a&gt;, active and passive, for what the grown-up version of bug two looks like&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/outlier" rel="noopener noreferrer"&gt;Envoy outlier detection&lt;/a&gt;, the passive "eject a misbehaving host" pattern&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/copylock" rel="noopener noreferrer"&gt;&lt;code&gt;go vet&lt;/code&gt;'s copylocks check&lt;/a&gt;, the analyzer that caught bug five&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Fun fact: I found most of these bugs by reading my own code back to write this post, not while writing the code. The &lt;code&gt;go vet&lt;/code&gt; one I'd seen and ignored. Nothing teaches you a system like having to explain it out loud to a stranger.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>networking</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Contribute to Open Source</title>
      <dc:creator>Vandit Singh</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:56:14 +0000</pubDate>
      <link>https://dev.to/v4nd1t/how-to-contribute-to-open-source-523o</link>
      <guid>https://dev.to/v4nd1t/how-to-contribute-to-open-source-523o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; Contributing to open source requires patience—lots of it. If you lack the time or willingness to persevere, open source may not be the right choice for you. Instead, consider building impactful projects that demonstrate your skills. The goal is to showcase proof of work, especially if you're pursuing a job.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Getting started with open-source projects can feel overwhelming, but with the right approach, it becomes manageable. Here’s a straightforward guide to help you contribute effectively:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;strong&gt;Choose an Organization&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Explore organizations using resources like &lt;a href="https://gsocorganizations.dev" rel="noopener noreferrer"&gt;gsocorganizations.dev&lt;/a&gt; or the GSoC Project/Organization list.&lt;/li&gt;
&lt;li&gt;Filter projects by the programming languages you know or want to contribute to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. &lt;strong&gt;Use the Project&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use the software you plan to contribute to. This helps you gain essential context and familiarize yourself with terminology and concepts reflected in the codebase and software interface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. &lt;strong&gt;Read the CONTRIBUTING.md&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;README.md&lt;/code&gt; provides an overview of the project, while &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; outlines how you can contribute. &lt;/li&gt;
&lt;li&gt;It is the &lt;strong&gt;essential guide&lt;/strong&gt; for contributing. It includes steps for setting up the project locally, guidelines for issue assignment, testing procedures, and any separate issue trackers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. &lt;strong&gt;Set Up the Project Locally&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Follow the instructions in &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; to set up the project on your local environment.&lt;/li&gt;
&lt;li&gt;Build the project and familiarize yourself with the file structure, understanding where different components of the code are stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. &lt;strong&gt;Analyze Frequently Updated Files&lt;/strong&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format: &lt;span class="nt"&gt;--name-only&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rg&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Identify files that are updated most frequently; these are likely critical components of the project. This aligns with the 80/20 rule—most impactful work happens in a minority of files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. &lt;strong&gt;Understand the Repository Structure&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Gain a high-level overview of the directory structure. For example:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - Repository
  |
  |- directory1 # Contains X
  |- directory2 # Contains Y
  |- directory3 # Contains Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Dive into specific directories to inspect files and understand their purpose. Validate your assumptions with project maintainers if you're making significant changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. &lt;strong&gt;Focus on Your First PR&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Choose a manageable issue and commit at least three weeks to it. Research thoroughly and familiarize yourself with the project context.&lt;/li&gt;
&lt;li&gt;Look up unfamiliar terms or concepts related to the issue in context of the organization you're contributing to.&lt;/li&gt;
&lt;li&gt;Check commit history and git blame when figuring out something in the codebase via the PR description that added the changes you're trying to wrap your head around. &lt;/li&gt;
&lt;li&gt;Try to find file(s) where you might need to do the changes for the bug or the feature&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. &lt;strong&gt;Use Debugging Tools&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;While debugging, simple print statements often work best for gaining insights quickly.&lt;/li&gt;
&lt;li&gt;Create and utilize your debugging hacks as needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Contributing to open source takes effort and time, but it’s a rewarding experience. Start with a project aligned with your skills, understand its structure and culture, and focus on delivering a meaningful first PR. Be patient, persistent, and open to learning—the process becomes easier as you grow. 🚀&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>beginners</category>
      <category>career</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
