<?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: Henry Osei</title>
    <description>The latest articles on DEV Community by Henry Osei (@henryosei).</description>
    <link>https://dev.to/henryosei</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%2F213893%2F53de07be-229c-41a6-b21d-05ba7a432cb1.jpeg</url>
      <title>DEV Community: Henry Osei</title>
      <link>https://dev.to/henryosei</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/henryosei"/>
    <language>en</language>
    <item>
      <title>How Docker Uses cgroups (a /sys/fs/cgroup Walkthrough)</title>
      <dc:creator>Henry Osei</dc:creator>
      <pubDate>Sun, 05 Jul 2026 15:15:33 +0000</pubDate>
      <link>https://dev.to/henryosei/how-docker-uses-cgroups-a-sysfscgroup-walkthrough-3c2m</link>
      <guid>https://dev.to/henryosei/how-docker-uses-cgroups-a-sysfscgroup-walkthrough-3c2m</guid>
      <description>&lt;p&gt;At some point Docker stops feeling like magic. You run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256m &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and somehow the container gets exactly 256 MB of memory and half a CPU. Where does that rule actually live? Not in the image, not in the Dockerfile, and not in some private Docker database. It ends up as files under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That path is one of the places where Docker stops being a product and becomes plain Linux.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; is not a normal folder
&lt;/h2&gt;

&lt;p&gt;List it and it looks like a regular directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /sys/fs/cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a modern system using cgroup v2 you'll see entries like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cgroup.controllers
cgroup.procs
cpu.max
cpu.stat
memory.current
memory.max
pids.current
pids.max
io.stat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these sit on disk. &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; is a virtual filesystem backed by the kernel: reading a file asks the kernel for cgroup state, and writing one changes how the kernel controls processes.&lt;/p&gt;

&lt;p&gt;This is worth pausing on, because it changes how you should picture Docker. Docker isn't watching your container from outside and politely asking it to behave. It writes rules into these files once, and from then on the kernel does the enforcing. Docker is the manager; the kernel is the bouncer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A container is just a process in a cgroup
&lt;/h2&gt;

&lt;p&gt;A Docker container is not a tiny VM. At runtime it's one or more ordinary Linux processes, and you can prove it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask Docker for the container's main process ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect web &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Pid}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say it returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;18432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the host's point of view, that container is a normal Linux process with PID &lt;code&gt;18432&lt;/code&gt;. Now check which cgroup it belongs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/18432/cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a cgroup v2 system you'll see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0::/system.slice/docker-&amp;lt;container-id&amp;gt;.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That path maps to a directory, usually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/docker-&amp;lt;container-id&amp;gt;.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and inside it are the control files for this specific container. Everything Docker configured with those &lt;code&gt;--memory&lt;/code&gt; and &lt;code&gt;--cpus&lt;/code&gt; flags is sitting in there as readable files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you set a memory limit
&lt;/h2&gt;

&lt;p&gt;Run a container with a memory limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; limited-nginx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256m &lt;span class="se"&gt;\&lt;/span&gt;
  nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker does not inject special memory-limiting code into Nginx. The flow is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker CLI
  ↓
dockerd
  ↓
containerd
  ↓
runc
  ↓
create process
  ↓
place process into cgroup
  ↓
write memory limit into /sys/fs/cgroup
  ↓
kernel enforces the limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On cgroup v2 the memory limit lives in &lt;code&gt;memory.max&lt;/code&gt;. If the container's cgroup path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/docker-abc123.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then Docker writes &lt;code&gt;268435456&lt;/code&gt; (256 MB in bytes) into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can look at both the limit and the current usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker wrote &lt;code&gt;memory.max&lt;/code&gt;; the kernel keeps &lt;code&gt;memory.current&lt;/code&gt; up to date. When the cgroup crosses the limit, the kernel's out-of-memory handling kills a process inside it. If you've ever stared at an OOM-killed container while &lt;code&gt;free -h&lt;/code&gt; on the host showed gigabytes of headroom, this is the explanation you were missing: the host had memory, but the cgroup didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you set a CPU limit
&lt;/h2&gt;

&lt;p&gt;Now run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; cpu-limited-nginx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 &lt;span class="se"&gt;\&lt;/span&gt;
  nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On cgroup v2, CPU quota is controlled by &lt;code&gt;cpu.max&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which might show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50000 100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first number is the quota and the second is the period, both in microseconds. This cgroup may use 50,000 microseconds of CPU time in every 100,000-microsecond window, which works out to half a CPU. So &lt;code&gt;docker run --cpus=0.5 nginx&lt;/code&gt; eventually becomes a scheduler rule of &lt;code&gt;cpu.max = 50000 100000&lt;/code&gt;, enforced by the kernel on every scheduling decision. Nobody is pausing Nginx by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about process limits
&lt;/h2&gt;

&lt;p&gt;This command controls how many processes the container can create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; pid-limited &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pids-limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100 &lt;span class="se"&gt;\&lt;/span&gt;
  nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On cgroup v2 it maps to &lt;code&gt;pids.max&lt;/code&gt;, which would hold &lt;code&gt;100&lt;/code&gt; here. Two files tell the story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.max
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is the maximum allowed, the second is how many exist right now. This is your fork-bomb protection. Without a PID limit, a broken or malicious container could keep spawning processes until the host became unstable; with one, the kernel refuses the fork the moment the cgroup hits its cap. Cgroups don't just count things, they say no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important file: &lt;code&gt;cgroup.procs&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Every cgroup needs to know which processes belong to it, and &lt;code&gt;cgroup.procs&lt;/code&gt; records exactly that: the PIDs attached to the cgroup. Docker creates a cgroup for the container, then places the container's process into it. Once the process is inside, every rule in that cgroup applies to it and to all of its children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;container process
  └── child process
      └── child process
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole tree is charged to the same resource-control group. If Nginx forks a dozen workers, they all draw from the same 256 MB, which is exactly what you want from something calling itself a container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the path looks different on different machines
&lt;/h2&gt;

&lt;p&gt;On one system a container shows up under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/docker-&amp;lt;container-id&amp;gt;.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On another it's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup/docker/&amp;lt;container-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Kubernetes you'll see paths involving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubepods.slice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference comes down to a few variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cgroup v1 or cgroup v2
systemd cgroup driver or cgroupfs driver
Docker directly or Kubernetes/containerd
rootful or rootless containers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't let the varying paths throw you. Whatever the layout, there's a cgroup directory somewhere, the container's processes are attached to it, and the kernel enforces whatever limits were written there. Find the directory and everything in this post still applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker does not work alone
&lt;/h2&gt;

&lt;p&gt;"Docker uses cgroups" compresses a whole runtime stack into one sentence. In practice Docker delegates most of the low-level work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker CLI
  talks to
dockerd
  talks to
containerd
  talks to
runc
  talks to
Linux kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runc&lt;/code&gt; deserves special mention. It's the low-level OCI runtime that actually builds the container process out of Linux primitives: it sets up namespaces, configures mounts, applies capabilities, configures seccomp, and places the process into the right cgroups. Docker gives you a pleasant command-line experience on top, but if you want to know where a "container" actually comes into existence, &lt;code&gt;runc&lt;/code&gt; is the place to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Namespaces isolate what the container sees
&lt;/h2&gt;

&lt;p&gt;Cgroups control what the container can use; namespaces control what it can see. Namespaces answer questions like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Which processes can this container see?
Which network interfaces can it see?
What hostname does it see?
What filesystem root does it see?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cgroups answer a different set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;How much memory can this container use?
How much CPU can it consume?
How many processes can it create?
How much I/O can it perform?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker needs both. Namespaces create the illusion of a separate machine, and cgroups stop that illusion from eating the whole host. Take away namespaces and the container isn't isolated; take away cgroups and it isn't contained.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical inspection flow
&lt;/h2&gt;

&lt;p&gt;To see all of this on your own machine, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; cgroup-demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;128m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pids-limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="se"&gt;\&lt;/span&gt;
  nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get the container PID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect cgroup-demo &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Pid}}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$PID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check its cgroup membership:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&lt;span class="nv"&gt;$PID&lt;/span&gt;/cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0::/system.slice/docker-abc123.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then the cgroup directory is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/docker-abc123.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now inspect the resource files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.current
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.max
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the actual kernel interface Docker uses to control the container, sitting right there as plain readable files. Once you've cat-ted &lt;code&gt;memory.max&lt;/code&gt; yourself, &lt;code&gt;docker run --memory&lt;/code&gt; never feels mysterious again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;The cleanest way to think about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Docker starts Linux processes.

Namespaces make those processes see an isolated world.

Cgroups limit how much of the real machine those processes can consume.

`/sys/fs/cgroup` is the filesystem interface Docker uses to configure those limits.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when you type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512m &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you're really asking Docker to create a process and tell the kernel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Put this process in its own resource group.
Allow it up to 512 MB of memory.
Allow it up to 1 CPU worth of time.
Track its usage.
Kill or throttle it if it crosses the line.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Containers aren't powerful because Docker invented isolation from scratch. They're powerful because Docker took primitives the kernel already had and packaged them into a workflow developers actually want to use. &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; is one of the clearest places to see that with your own eyes, one &lt;code&gt;cat&lt;/code&gt; command at a time.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Thought I Understood Containers. Then I Tried Building One.</title>
      <dc:creator>Henry Osei</dc:creator>
      <pubDate>Sat, 04 Jul 2026 21:48:57 +0000</pubDate>
      <link>https://dev.to/henryosei/i-thought-i-understood-containers-then-i-tried-building-one-5a80</link>
      <guid>https://dev.to/henryosei/i-thought-i-understood-containers-then-i-tried-building-one-5a80</guid>
      <description>&lt;p&gt;I had just aced my mentor’s Docker exam, so of course I thought I understood containers.&lt;/p&gt;

&lt;p&gt;I had said all the right words: namespaces, cgroups, images, layers, PID 1, Kubernetes Pods. Then I typed my first serious command and Linux reminded me that knowing the nouns is not the same thing as building the thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;unshare &lt;span class="nt"&gt;-p&lt;/span&gt; 1 &lt;span class="nb"&gt;test
&lt;/span&gt;unshare: failed to execute 1: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the opening scene. I had not even built anything yet. I had typed the flags wrong and accidentally asked &lt;code&gt;unshare&lt;/code&gt; to execute a program called &lt;code&gt;1&lt;/code&gt;. This was going to be less “implement Docker” and more “let the kernel correct my confidence, one error at a time.”&lt;/p&gt;

&lt;h2&gt;
  
  
  v1: namespaces, or the first time PID 1 lied to me
&lt;/h2&gt;

&lt;p&gt;The first version was supposed to be easy: run a process in a new PID namespace and prove it sees itself as PID 1.&lt;/p&gt;

&lt;p&gt;So I ran the command the way I thought it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;unshare &lt;span class="nt"&gt;--pid&lt;/span&gt; bash
&lt;span class="c"&gt;# echo $$&lt;/span&gt;
25184
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was not PID 1. That was just embarrassing.&lt;/p&gt;

&lt;p&gt;The rule I had missed is simple: PID namespaces apply to children. The process that calls &lt;code&gt;unshare --pid&lt;/code&gt; does not magically become PID 1. You need to fork. The first child born into the new namespace becomes PID 1.&lt;/p&gt;

&lt;p&gt;So the working version was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;unshare &lt;span class="nt"&gt;--pid&lt;/span&gt; &lt;span class="nt"&gt;--fork&lt;/span&gt; bash
&lt;span class="c"&gt;# echo $$&lt;/span&gt;
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line changed the tone. I was inside a different process universe. The shell thought it was process 1. Signals felt different. Orphans came home to it.&lt;/p&gt;

&lt;p&gt;Then I ran &lt;code&gt;ps&lt;/code&gt;, and got humbled again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ps -o pid,ppid,comm&lt;/span&gt;
    PID    PPID COMMAND
  25310   25304 bash
  25344   25310 ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That made no sense at first. I was PID 1, but &lt;code&gt;ps&lt;/code&gt; was showing host-looking PIDs. The next reveal: &lt;code&gt;ps&lt;/code&gt; does not ask the kernel some pure “what processes exist?” question. It reads files. If &lt;code&gt;/proc&lt;/code&gt; still points at the host procfs, your tools will tell you the host story.&lt;/p&gt;

&lt;p&gt;So I remounted &lt;code&gt;/proc&lt;/code&gt; from inside the namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# mount -t proc proc /proc&lt;/span&gt;
&lt;span class="c"&gt;# ps -o pid,ppid,comm&lt;/span&gt;
    PID    PPID COMMAND
      1       0 bash
      7       1 ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was when it clicked. The namespace did not become real to my eyes until &lt;code&gt;/proc&lt;/code&gt; agreed with it. Before that, I had isolation, but my tools were reading the old filesystem view.&lt;/p&gt;

&lt;p&gt;The UTS namespace lesson was cleaner. I accidentally ran a science experiment.&lt;/p&gt;

&lt;p&gt;In one terminal, without a UTS namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;hostname
&lt;/span&gt;ba149abae9bd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then inside a new UTS namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;unshare &lt;span class="nt"&gt;--uts&lt;/span&gt; bash
&lt;span class="c"&gt;# hostname toybox&lt;/span&gt;
&lt;span class="c"&gt;# hostname&lt;/span&gt;
toybox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back outside that UTS namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;hostname
&lt;/span&gt;ba149abae9bd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was my control and treatment. Same machine, same kernel, different hostname view — and the “host” was already a container hostname, which made the containers-inside-containers setup visible in the output. Nothing mystical. Just one isolated kernel data structure doing exactly what the docs said, except now I had seen it with my own hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: pivot_root, the boss fight
&lt;/h2&gt;

&lt;p&gt;After namespaces, I got overconfident again. The next version was supposed to give the process its own filesystem: a tiny rootfs, a shell, maybe BusyBox. Very container-ish.&lt;/p&gt;

&lt;p&gt;My repo had bash scripts for this, not some compiled runtime from a tutorial. So the shape of the attempt was &lt;code&gt;v2.sh&lt;/code&gt;, a rootfs, and a command to run inside it.&lt;/p&gt;

&lt;p&gt;The parade started with the obvious error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; ./v2.sh ./rootfs /bin/sh
&lt;span class="nb"&gt;exec&lt;/span&gt; /bin/sh: no such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine. There was no shell where I said there would be a shell. I fixed that with Alpine’s own BusyBox and hit the more annoying version: the file existed, but the kernel still said it could not run it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./rootfs/bin/busybox sh
bash: ./rootfs/bin/busybox: cannot execute: required file not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of error that feels personal because you can list the file. You can see the symlink. The computer still refuses.&lt;/p&gt;

&lt;p&gt;The plot twist came from &lt;code&gt;file&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;rootfs
&lt;span class="nv"&gt;$ &lt;/span&gt;file bin/busybox
bin/busybox: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, interpreter /lib/ld-musl-aarch64.so.1, stripped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary was there. The interpreter was not available from the old world. Linux was not saying “your BusyBox file does not exist.” It was saying “from here, I cannot load the interpreter this ELF needs.” Same surface error, different problem.&lt;/p&gt;

&lt;p&gt;The fix was not what I first thought. Alpine’s BusyBox did not need to become static. Once Alpine became &lt;code&gt;/&lt;/code&gt;, its musl loader would be at &lt;code&gt;/lib/ld-musl-aarch64.so.1&lt;/code&gt;, and Alpine’s &lt;code&gt;/bin/sh&lt;/code&gt; would be happy. The thing that needed help was the handoff itself: my Ubuntu slim image did not even have &lt;code&gt;pivot_root&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pivot_root &lt;span class="nb"&gt;.&lt;/span&gt; put_old
bash: pivot_root: &lt;span class="nb"&gt;command &lt;/span&gt;not found
&lt;span class="nv"&gt;$ &lt;/span&gt;file /bin/busybox
/bin/busybox: ELF 64-bit LSB executable, ARM aarch64, statically linked, stripped
&lt;span class="nv"&gt;$ &lt;/span&gt;/bin/busybox pivot_root &lt;span class="nb"&gt;.&lt;/span&gt; put_old
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the better plot twist: the old world could not perform the handoff without borrowing a static tool. &lt;code&gt;busybox-static&lt;/code&gt; was not my replacement shell inside Alpine. It was the bridge that could run before and during the transition.&lt;/p&gt;

&lt;p&gt;Then I hit the Bash hash-cache moment. Alpine was now &lt;code&gt;/&lt;/code&gt;, but Bash still remembered a command path from before the filesystem switch. It went hunting for &lt;code&gt;/usr/bin/mount&lt;/code&gt; in a world that had just been evicted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/ &lt;span class="c"&gt;# mount -t proc proc /proc&lt;/span&gt;
bash: /usr/bin/mount: No such file or directory
/ &lt;span class="c"&gt;# hash -r&lt;/span&gt;
/ &lt;span class="c"&gt;# mount -t proc proc /proc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had fixed the filesystem and was still debugging an old decision Bash had remembered for me. That is the kind of bug that makes you take a short walk.&lt;/p&gt;

&lt;p&gt;Then came the Mac problem. My setup was not “normal Linux laptop, local ext4 disk.” It was Apple Silicon Mac → privileged Ubuntu container → repo mounted from macOS. That means virtiofs was in the story whether I wanted it there or not.&lt;/p&gt;

&lt;p&gt;The symptom showed up after the pivot, inside Alpine, which made it stranger. Applet symlinks like &lt;code&gt;mount&lt;/code&gt; and &lt;code&gt;ls&lt;/code&gt; could fail with &lt;code&gt;Permission denied&lt;/code&gt; on the Mac-shared mount, while calling BusyBox directly still worked. The files were there; executing through those symlinks was the weird part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/ &lt;span class="c"&gt;# ls&lt;/span&gt;
sh: &lt;span class="nb"&gt;ls&lt;/span&gt;: Permission denied
/ &lt;span class="c"&gt;# /bin/busybox ls&lt;/span&gt;
bin      dev      etc      lib      proc     put_old
/ &lt;span class="c"&gt;# mount -t proc proc /proc&lt;/span&gt;
sh: mount: Permission denied
/ &lt;span class="c"&gt;# /bin/busybox mount -t proc proc /proc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is where I stopped trying to make the shared mount behave like a normal Linux filesystem. I moved the rootfs to a container-native path and reran the same idea from there. Boring fix, correct fix.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pivot_root&lt;/code&gt; itself still had opinions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pivot_root: invalid argument
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one was not glamorous either. The new root had to be a mount point. The old root needed somewhere to go. I had to bind-mount the new root onto itself, create &lt;code&gt;oldroot&lt;/code&gt;, call &lt;code&gt;pivot_root(newroot, oldroot)&lt;/code&gt;, &lt;code&gt;chdir("/")&lt;/code&gt;, and unmount the old root.&lt;/p&gt;

&lt;p&gt;When it finally worked, the reward was tiny and perfect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/ &lt;span class="c"&gt;# cat /etc/os-release&lt;/span&gt;
&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Alpine Linux"&lt;/span&gt;
&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;alpine
&lt;span class="nv"&gt;VERSION_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3.24.1
&lt;span class="nv"&gt;PRETTY_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Alpine Linux v3.24"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output felt better than it should have. It was just &lt;code&gt;/etc/os-release&lt;/code&gt;, but it meant the process was now living in the filesystem I had assembled. Not Docker magic. Just mounts, an ELF loader problem, a static &lt;code&gt;pivot_root&lt;/code&gt; applet, a root pivot, and errors more useful than any clean diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  v3: cgroups, or Linux as a filesystem API
&lt;/h2&gt;

&lt;p&gt;The third version was about resource limits. This was where cgroups stopped being “that Kubernetes thing” and became a filesystem API I could write to.&lt;/p&gt;

&lt;p&gt;On cgroup v2, everything looked like files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /sys/fs/cgroup
cgroup.controllers  cgroup.procs  cgroup.subtree_control  memory.current  memory.max  pids.max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beautiful, until you write the wrong value to the wrong file at the wrong level.&lt;/p&gt;

&lt;p&gt;My first attempt was to create a child cgroup, move the process, and enable controllers casually.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; /sys/fs/cgroup/tiny
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; +memory | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/fs/cgroup/cgroup.subtree_control
&lt;span class="nb"&gt;tee&lt;/span&gt;: /sys/fs/cgroup/cgroup.subtree_control: Device or resource busy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error was the “no internal processes” rule. In cgroup v2, you cannot enable domain controllers on a cgroup that still has normal processes in it. The parent has to become a manager; work happens in children.&lt;/p&gt;

&lt;p&gt;So I had to do the evacuation dance: create a child for the shell, move myself into it, then enable controllers on the parent, then create the actual container cgroup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; /sys/fs/cgroup/init
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/fs/cgroup/init/cgroup.procs
18420
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; +memory +pids | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/fs/cgroup/cgroup.subtree_control
+memory +pids
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; /sys/fs/cgroup/tiny
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;32M | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/fs/cgroup/tiny/memory.max
32M
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;64 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/fs/cgroup/tiny/pids.max
64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the runtime could put the child into &lt;code&gt;/sys/fs/cgroup/tiny/cgroup.procs&lt;/code&gt; before executing the workload.&lt;/p&gt;

&lt;p&gt;The first OOM test was almost too satisfying. I ran a small memory hog inside the limited cgroup and watched the kernel enforce the number.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;dmesg | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 3
&lt;span class="o"&gt;[&lt;/span&gt; 5221.143892] memory: usage 32768kB, limit 32768kB, failcnt 41
&lt;span class="o"&gt;[&lt;/span&gt; 5221.143901] Memory cgroup out of memory: Killed process 18432 &lt;span class="o"&gt;(&lt;/span&gt;python3&lt;span class="o"&gt;)&lt;/span&gt; total-vm:89344kB, anon-rss:31812kB, file-rss:0kB, shmem-rss:0kB
&lt;span class="o"&gt;[&lt;/span&gt; 5221.143944] oom_reaper: reaped process 18432 &lt;span class="o"&gt;(&lt;/span&gt;python3&lt;span class="o"&gt;)&lt;/span&gt;, now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line made cgroups real. Not a Docker setting. Not YAML. The kernel enforcing a number I wrote into a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then Kubernetes looked smaller
&lt;/h2&gt;

&lt;p&gt;After this, I deployed to Kubernetes and the concepts no longer felt distant. A Pod was not magic anymore. It was these same pieces with an API in front: namespaces to shape what the process can see, a filesystem root to control what it can touch, and cgroups to limit what it can consume. I would not call my tiny runtime useful, but it made the abstractions stop floating. Docker, containerd, and Kubernetes became less like brands and more like organized versions of the three scripts I had just fought into existence.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>learning</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
