<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Gancho Stoychev · gshell.dev</title><description>Hands-on notes on Linux, containers and AI infrastructure from a two-node GPU home lab, with real measurements and raw terminal output.</description><link>https://gshell.dev/</link><item><title>A container in 81 lines of Bash: what the kernel primitives cost on Ubuntu 24.04</title><link>https://gshell.dev/blog/container-from-scratch/</link><guid isPermaLink="true">https://gshell.dev/blog/container-from-scratch/</guid><description>Namespaces, cgroups v2, pivot_root and a veth pair, assembled by hand on an AI home lab: real outputs, real mistakes, and 124 ms against Docker’s 518 ms.</description><pubDate>Sun, 11 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Early in this lab I had a shell that was PID 1, and from that same shell &lt;code&gt;ps&lt;/code&gt; listed &lt;code&gt;systemd&lt;/code&gt; as PID 1. Both were right. The kernel had put the shell in a new PID namespace; &lt;code&gt;ps&lt;/code&gt; was still reading a &lt;code&gt;/proc&lt;/code&gt; mounted for the host. Isolation and what the tools report are two different things, and that gap is where most container debugging happens.&lt;/p&gt;
&lt;p&gt;This is the first post in a series about building AI infrastructure on a two-node home lab. I ran Linux servers for years before moving to front-end development, and I am now working my way back to infrastructure, this time for AI workloads: GPU nodes, inference serving, observability and sandboxes for AI agents. Everything happens in the public &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab&quot;&gt;ai-homelab repository&lt;/a&gt;, one lab at a time. Each lab has a step-by-step guide with every command and a results file with the raw numbers, mistakes included; each post tells the story of one lab.&lt;/p&gt;
&lt;p&gt;This one is Lab 0a. Every inference server, sandbox and Kubernetes pod I will run later is built from the same few kernel primitives: namespaces, cgroups, a root filesystem and a virtual network cable. So before running any of them, I built a container by hand, one primitive at a time, and wrote down every number and every wrong prediction. The result is &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh&quot;&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt;, 81 lines of Bash.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0a.md&quot;&gt;every command, step by step&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0a.md&quot;&gt;measurements, surprises and mistakes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Script:&lt;/strong&gt; &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh&quot;&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What &quot;done&quot; meant&lt;/h2&gt;
&lt;p&gt;Six checks, decided before writing any code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ps&lt;/code&gt; inside shows only the container&apos;s processes, and the shell is PID 1.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hostname&lt;/code&gt; inside differs from the host, and &lt;code&gt;ls /&lt;/code&gt; shows Alpine, not the host.&lt;/li&gt;
&lt;li&gt;A process exceeding &lt;code&gt;memory.max&lt;/code&gt; is killed, and &lt;code&gt;memory.events&lt;/code&gt; shows &lt;code&gt;oom_kill 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ping&lt;/code&gt; from the container to the host end of a veth pair works.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lsns&lt;/code&gt; shows distinct namespace inodes for the container.&lt;/li&gt;
&lt;li&gt;The script cleans up after itself: no leftover cgroup, network namespace or veth.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;The Z790 node of my two-node home lab: i7-14700, 64 GB DDR5, two RTX 5060 Ti cards (unused here), Ubuntu 24.04 with the HWE kernel &lt;code&gt;7.0.0-34-generic&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The container&apos;s userland is Alpine minirootfs 3.24.2: a 3.7 MB tarball, 8.7 MB extracted. I read the current version from Alpine&apos;s release metadata, then downloaded it by exact path (&lt;code&gt;v3.24/…&lt;/code&gt;, not &lt;code&gt;latest-stable/…&lt;/code&gt;) so every run uses the same rootfs, and verified it with &lt;code&gt;sha256sum -c&lt;/code&gt;. A checksum from the same server proves integrity, not authenticity; Alpine also publishes a GPG signature, which I left for a later lab.&lt;/p&gt;
&lt;h2&gt;Namespaces, one at a time&lt;/h2&gt;
&lt;h3&gt;UTS: the name of the machine&lt;/h3&gt;
&lt;p&gt;The UTS namespace isolates only the hostname, which makes it the safest way to see the whole mechanism. Changing the hostname inside a new UTS namespace leaves the host&apos;s name untouched, and &lt;code&gt;readlink /proc/self/ns/uts&lt;/code&gt; shows why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;inside:  uts:[4026533010]
outside: uts:[4026531838]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The number is an inode, and two processes share a namespace exactly when the numbers match. The host&apos;s value is no accident: 4026531838 is &lt;code&gt;0xEFFFFFFE&lt;/code&gt;, a constant. The kernel&apos;s initial namespaces have the fixed inode numbers 4026531832–4026531839; new namespaces get dynamic numbers, which are recycled after a namespace dies. Seeing one of those constants means &quot;this is the host&apos;s namespace&quot;.&lt;/p&gt;
&lt;h3&gt;PID: why &lt;code&gt;--fork&lt;/code&gt; is not optional&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;unshare()&lt;/code&gt; moves the calling process into most new namespaces immediately, but not into a new PID namespace. A process&apos;s PID is fixed at birth, so the only way in is to be born there: the PID namespace applies only to the caller&apos;s future children.&lt;/p&gt;
&lt;p&gt;Without &lt;code&gt;--fork&lt;/code&gt;, the first child becomes PID 1 of the new namespace. When it exits, the namespace has no init, and the kernel refuses to create any more processes in it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;sh PID: 6000
after 1st child
sh: 1: Cannot fork
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also ran &lt;code&gt;sudo unshare --pid&lt;/code&gt; interactively by accident. It printed &lt;code&gt;fork: Cannot allocate memory&lt;/code&gt; before the prompt even appeared: &lt;code&gt;.bashrc&lt;/code&gt; forks at startup, that child became PID 1 and exited, and every command after that failed to fork. Only the builtin &lt;code&gt;exit&lt;/code&gt; worked. The error says &quot;memory&quot;, but the real problem is a dead namespace.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;ps&lt;/code&gt; lies until &lt;code&gt;/proc&lt;/code&gt; is remounted&lt;/h3&gt;
&lt;p&gt;With &lt;code&gt;--fork&lt;/code&gt;, the shell really is PID 1 — and &lt;code&gt;ps&lt;/code&gt; still printed &lt;code&gt;1 systemd&lt;/code&gt;, &lt;code&gt;2 kthreadd&lt;/code&gt; and the rest of the host. &lt;code&gt;ps&lt;/code&gt; does not ask the kernel which processes it can see; it reads &lt;code&gt;/proc&lt;/code&gt;, and a &lt;code&gt;proc&lt;/code&gt; mount always shows the PID namespace it was mounted for. Adding &lt;code&gt;--mount-proc&lt;/code&gt; (a new mount namespace plus a fresh &lt;code&gt;/proc&lt;/code&gt;) left exactly two lines: &lt;code&gt;1 sh&lt;/code&gt; and &lt;code&gt;2 ps&lt;/code&gt;. First check passed.&lt;/p&gt;
&lt;h3&gt;Mount propagation: a tmpfs that leaked&lt;/h3&gt;
&lt;p&gt;systemd makes every host mount &lt;code&gt;shared&lt;/code&gt;, so a mount in one member of a peer group propagates to the others. A new mount namespace started with &lt;code&gt;--propagation shared&lt;/code&gt; stays in the host&apos;s peer group: I mounted a tmpfs inside, the namespace ended, and &lt;code&gt;findmnt&lt;/code&gt; on the host still showed it. With &lt;code&gt;--propagation private&lt;/code&gt;, the same mount never left the namespace. This is why &lt;code&gt;unshare&lt;/code&gt; defaults to &lt;code&gt;private&lt;/code&gt;, and it is also a precondition for the next step.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;chroot&lt;/code&gt; versus &lt;code&gt;pivot_root&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;chroot&lt;/code&gt; into the Alpine rootfs worked: &lt;code&gt;NAME=&quot;Alpine Linux&quot;&lt;/code&gt;, but &lt;code&gt;uname -r&lt;/code&gt; still printed the host&apos;s &lt;code&gt;7.0.0-34-generic&lt;/code&gt;. A container has its own filesystem and shares the kernel.&lt;/p&gt;
&lt;p&gt;I expected &lt;code&gt;/proc/mounts&lt;/code&gt; inside the &lt;code&gt;chroot&lt;/code&gt; to list dozens of host mounts. It listed one. Same mount namespace, two views:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Root change&lt;/th&gt;
&lt;th&gt;Mounts before&lt;/th&gt;
&lt;th&gt;Mounts after&lt;/th&gt;
&lt;th&gt;The host filesystem is…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chroot&lt;/code&gt; (outside / inside, same namespace)&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;hidden, still attached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pivot_root&lt;/code&gt; (before / after &lt;code&gt;umount -l&lt;/code&gt; of the old root)&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;detached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;/proc/self/mounts&lt;/code&gt; only lists mounts reachable from the process&apos;s root, so &lt;code&gt;chroot&lt;/code&gt; hides the host&apos;s mounts without detaching them. &lt;code&gt;pivot_root&lt;/code&gt; swaps the root mount and parks the old root at &lt;code&gt;/.oldroot&lt;/code&gt;; one lazy &lt;code&gt;umount -l&lt;/code&gt; then detaches it with all its submounts. After that, &lt;code&gt;/.oldroot&lt;/code&gt; was empty and the process had no path back to the host.&lt;/p&gt;
&lt;h3&gt;Side quest: a 3.9 MB root filesystem&lt;/h3&gt;
&lt;p&gt;Any directory can become &lt;code&gt;/&lt;/code&gt;. I copied &lt;code&gt;/bin/bash&lt;/code&gt; into an empty &lt;code&gt;tiny/&lt;/code&gt; directory and tried to &lt;code&gt;chroot&lt;/code&gt; into it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;chroot: failed to run command &apos;/bin/bash&apos;: No such file or directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file existed. What was missing was the dynamic loader that the kernel starts first; the error names &lt;code&gt;bash&lt;/code&gt;, the problem is the loader. After copying the loader and the two libraries &lt;code&gt;ldd&lt;/code&gt; listed, &lt;code&gt;bash&lt;/code&gt; ran, &lt;code&gt;ls&lt;/code&gt; did not exist, and the whole rootfs was 3.9 MB. An image is literally the program plus everything it depends on, which is why statically linked binaries and distroless images exist.&lt;/p&gt;
&lt;h2&gt;cgroups v2 by hand&lt;/h2&gt;
&lt;h3&gt;How systemd runs a real service&lt;/h3&gt;
&lt;p&gt;Before writing my own cgroup, I looked at a real one: Ollama, running as a systemd service. It lives in &lt;code&gt;system.slice/ollama.service&lt;/code&gt;; idle with no model loaded, &lt;code&gt;memory.current&lt;/code&gt; was about 552 MiB (page cache included) and &lt;code&gt;pids.current&lt;/code&gt; was 18. There is no separate &quot;cgroup config&quot;: the unit file is the configuration, and systemd writes it into the cgroup&apos;s files.&lt;/p&gt;
&lt;p&gt;One limit nobody wrote: &lt;code&gt;pids.max&lt;/code&gt; was 70833. systemd applies a default &lt;code&gt;TasksMax&lt;/code&gt; to every service, so a single fork bomb cannot exhaust the machine.&lt;/p&gt;
&lt;h3&gt;The swap trap and an OOM kill&lt;/h3&gt;
&lt;p&gt;Creating a cgroup is a &lt;code&gt;mkdir&lt;/code&gt; in &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;; the kernel fills the directory with 86 control files. I set &lt;code&gt;memory.max&lt;/code&gt; to 100M, &lt;code&gt;pids.max&lt;/code&gt; to 20 and &lt;code&gt;cpu.max&lt;/code&gt; to &lt;code&gt;50000 100000&lt;/code&gt; (half a core), and &lt;code&gt;memory.swap.max&lt;/code&gt; to 0. That last one matters: this machine has an 8 GB swap file, and without it a process at &lt;code&gt;memory.max&lt;/code&gt; gets swapped instead of killed.&lt;/p&gt;
&lt;p&gt;Then a Python process inside the cgroup allocated 200 MB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Killed
exit code: 137
Memory cgroup out of memory: Killed process 5462 (python3) total-vm:223152kB, anon-rss:101884kB, file-rss:6404kB
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;memory.events&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom_kill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom_group_kill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Exit code 137 is 128 + 9, a SIGKILL with no chance to clean up. &lt;code&gt;max 18&lt;/code&gt; is how many times the cgroup hit its ceiling and the kernel tried to reclaim memory before giving up. &lt;code&gt;total-vm&lt;/code&gt; is what the process asked for, &lt;code&gt;anon-rss&lt;/code&gt; is what was resident when it died, right at the 100 MB limit. That &lt;code&gt;dmesg&lt;/code&gt; line is what to look for when a container &quot;just disappears&quot;.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;pids.max&lt;/code&gt; versus 30 sleeps&lt;/h3&gt;
&lt;p&gt;I predicted that starting 30 background &lt;code&gt;sleep&lt;/code&gt; processes under &lt;code&gt;pids.max = 20&lt;/code&gt; would produce 11 failed forks. I got one: &lt;code&gt;pids.current&lt;/code&gt; was 19, a single &lt;code&gt;sh: 0: Cannot fork&lt;/code&gt;, and &lt;code&gt;pids.events&lt;/code&gt; showed &lt;code&gt;max 1&lt;/code&gt;. A non-interactive &lt;code&gt;dash&lt;/code&gt; gives up on the first failed fork. A real fork bomb would keep retrying, and the cgroup would keep stopping it at the ceiling.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;dmem&lt;/code&gt; and NVIDIA: no VRAM limit&lt;/h3&gt;
&lt;p&gt;The kernel&apos;s list of cgroup controllers ends with &lt;code&gt;dmem&lt;/code&gt;, the &lt;a href=&quot;https://docs.kernel.org/admin-guide/cgroup-v2.html#dmem&quot;&gt;controller for device memory&lt;/a&gt;: &lt;code&gt;dmem.min&lt;/code&gt;, &lt;code&gt;dmem.low&lt;/code&gt; and &lt;code&gt;dmem.max&lt;/code&gt; work like their &lt;code&gt;memory&lt;/code&gt; counterparts, per memory region of a device. On this machine &lt;code&gt;dmem.capacity&lt;/code&gt; exists and is empty.&lt;/p&gt;
&lt;p&gt;Drivers have to opt in by registering their memory regions. On this kernel, Intel&apos;s &lt;code&gt;xe&lt;/code&gt; and AMD&apos;s &lt;code&gt;amdgpu&lt;/code&gt; do; none of the NVIDIA 580 open kernel modules reference &lt;code&gt;dmem&lt;/code&gt; at all. So on this lab, GPU memory cannot be limited through cgroups.&lt;/p&gt;
&lt;h2&gt;Network: a cable between namespaces&lt;/h2&gt;
&lt;p&gt;A named network namespace (&lt;code&gt;ip netns add&lt;/code&gt;) plus a veth pair, one end moved inside, is a virtual Ethernet cable between two network stacks. Inside, the new namespace had only &lt;code&gt;lo&lt;/code&gt; (down) and its end of the cable, shown as &lt;code&gt;@if8&lt;/code&gt;: the index of the peer in the &lt;em&gt;other&lt;/em&gt; namespace. No LAN interface, no Docker bridges, not even a working loopback.&lt;/p&gt;
&lt;p&gt;With addresses on both ends, the ping went through: 0.148, 0.077 and 0.079 ms. The first packet is slower because it includes ARP resolution; all of them stay well under a millisecond because they never leave the kernel. There is no default route, so the internet is unreachable — NAT is a topic for a later lab. Deleting the namespace destroyed its end of the cable, and since a veth pair cannot exist with one end, the kernel removed the host end too. One command cleans up the whole thing.&lt;/p&gt;
&lt;h2&gt;User namespaces and AppArmor on Ubuntu 24.04&lt;/h2&gt;
&lt;p&gt;Everything so far ran with &lt;code&gt;sudo&lt;/code&gt;. A user namespace is the way to do it without: root inside, an ordinary user outside. As an unprivileged user it failed with &lt;code&gt;write failed /proc/self/uid_map: Operation not permitted&lt;/code&gt;, and the kernel audit log explained why in two lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;apparmor=&quot;AUDIT&quot; operation=&quot;userns_create&quot; … info=&quot;Userns create - transitioning profile&quot; profile=&quot;unconfined&quot; … target=&quot;unprivileged_userns&quot;
apparmor=&quot;DENIED&quot; operation=&quot;capable&quot; profile=&quot;unprivileged_userns&quot; … capability=21 capname=&quot;sys_admin&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Creating the namespace is allowed, but the process is immediately moved into the restrictive &lt;code&gt;unprivileged_userns&lt;/code&gt; profile, which denies &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; inside. User namespaces give an unprivileged user &quot;root&quot; over large parts of the kernel, and that path has carried many privilege-escalation bugs. Ubuntu 24.04 allows them only for programs with an explicit AppArmor profile.&lt;/p&gt;
&lt;p&gt;I did not turn the protection off globally. Instead, a root-owned copy of &lt;code&gt;unshare&lt;/code&gt; gets its own profile, whose only addition is &lt;code&gt;userns&lt;/code&gt;, so AppArmor no longer moves it into the restrictive profile. The profile is part of the repo as an &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/lab_tools&quot;&gt;Ansible role&lt;/a&gt;. The trade-off is written down rather than hidden: the copy does not receive util-linux security updates, so the role must be re-run after upgrades, or the copy removed.&lt;/p&gt;
&lt;p&gt;With the copy, the rootless test worked, and showed what &quot;root&quot; in a user namespace means:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
CapEff: 000001ffffffffff
touch: cannot touch &apos;/etc/lab0a-test&apos;: Permission denied
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All capabilities, and still no write access to &lt;code&gt;/etc&lt;/code&gt;: the kernel checks host resources against the UID &lt;em&gt;outside&lt;/em&gt; the namespace.&lt;/p&gt;
&lt;p&gt;For context: in March 2025, Qualys published &lt;a href=&quot;https://openwall.com/lists/oss-security/2025/03/27/6&quot;&gt;three bypasses of this restriction&lt;/a&gt;. Canonical treats it as a defense-in-depth measure. The restriction is a tripwire that leaves audit records, not a wall; the real defense against kernel exploits is a patched kernel.&lt;/p&gt;
&lt;p&gt;On my host, the first one worked: &lt;code&gt;kernel.apparmor_restrict_unprivileged_unconfined&lt;/code&gt; was 0, and &lt;code&gt;aa-exec -p trinity&lt;/code&gt; gave an unprivileged user a user namespace with all capabilities, through the profile of a program that is not even installed. One sysctl closes it: with &lt;code&gt;kernel.apparmor_restrict_unprivileged_unconfined = 1&lt;/code&gt;, the same command fails with &lt;code&gt;Operation not permitted&lt;/code&gt;, and the dedicated &lt;code&gt;unshare&lt;/code&gt; copy still works.&lt;/p&gt;
&lt;h2&gt;The 81-line script&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh&quot;&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt; puts all of this together in four blocks, after some pre-checks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;cgroup:&lt;/strong&gt; create it and write the limits, swap off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;network:&lt;/strong&gt; namespace, veth pair, the container end renamed to &lt;code&gt;eth0&lt;/code&gt; as Docker does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;inside, as PID 1:&lt;/strong&gt; hostname, bind mount, &lt;code&gt;pivot_root&lt;/code&gt;, a fresh &lt;code&gt;/proc&lt;/code&gt;, &lt;code&gt;umount -l&lt;/code&gt; of the old root, then &lt;code&gt;exec&lt;/code&gt; Alpine&apos;s shell with a clean environment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;launch:&lt;/strong&gt; a subshell moves itself into the cgroup first, so everything it starts inherits it, then enters the network namespace and creates the rest with &lt;code&gt;unshare --uts --pid --mount --ipc --cgroup --fork&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A &lt;code&gt;trap&lt;/code&gt; on &lt;code&gt;EXIT&lt;/code&gt; deletes the network namespace (and with it both veth ends) and removes the cgroup with a short retry loop, since a cgroup can only go once its last process has exited. After &lt;code&gt;exit&lt;/code&gt;, the cgroup, the namespace, the veth pair and the mounts were all gone. All six checks passed.&lt;/p&gt;
&lt;p&gt;This is the process tree while the container runs, with the host PIDs from one run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;subshell → exec ip netns exec → exec unshare      host PID 16242
  joins the lab0a network namespace and cgroup,
  unshare() moves it into new uts, mnt, ipc and cgroup namespaces
  └── fork → /bin/sh                              host PID 16244, PID 1 inside
        the first process in the new pid namespace
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Seen from the host: mine versus Docker&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;lsns&lt;/code&gt; shows all eight namespace types of a process. Here is my container next to &lt;code&gt;docker run alpine:3.24&lt;/code&gt; on Docker 29.8.0:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Namespace&lt;/th&gt;
&lt;th&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Docker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mnt, uts, ipc, cgroup, net&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pid&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time&lt;/td&gt;
&lt;td&gt;shared with the host&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;new&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;shared with the host&lt;/td&gt;
&lt;td&gt;shared with the host&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Same core model: new mnt, uts, ipc, pid, cgroup and net namespaces, and no user namespace. Root in my container is root on the host, and the same is true of a default Docker container.&lt;/p&gt;
&lt;p&gt;Two differences. Docker also created a time namespace, which I had predicted it would not. It is part of the OCI spec that Docker 29.8.0 generates with its default configuration, with zero offsets: a new namespace that changes nothing visible. And Docker&apos;s process model is cleaner: every namespace starts at the container&apos;s own process, because runc creates it directly in all of them, while my script leaves the &lt;code&gt;unshare&lt;/code&gt; parent (16242 above) inside five of the six new namespaces. That is exactly the PID-namespace rule from earlier, made visible.&lt;/p&gt;
&lt;h2&gt;Start-up: 124 ms versus 518 ms&lt;/h2&gt;
&lt;p&gt;Five runs of each, with &lt;code&gt;true&lt;/code&gt; as the command, so only create and destroy are measured:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;5 runs (real)&lt;/th&gt;
&lt;th&gt;Per run&lt;/th&gt;
&lt;th&gt;user + sys (5 runs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.619 s&lt;/td&gt;
&lt;td&gt;~124 ms&lt;/td&gt;
&lt;td&gt;0.022 + 0.047 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker run --rm alpine:3.24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.589 s&lt;/td&gt;
&lt;td&gt;~518 ms&lt;/td&gt;
&lt;td&gt;0.025 + 0.042 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;About four times faster, and still slower than my guess of &quot;tens of milliseconds&quot;. CPU time is about 14 ms per run, so most of the rest is waiting: on namespace, veth and mount setup and teardown in the kernel. Profiling exactly where it goes is a job for a later lab.&lt;/p&gt;
&lt;p&gt;Docker&apos;s &lt;code&gt;user&lt;/code&gt; and &lt;code&gt;sys&lt;/code&gt; are just as small, and that is misleading. &lt;code&gt;time&lt;/code&gt; only counts the client and its children; the real work happens in &lt;code&gt;dockerd&lt;/code&gt;, &lt;code&gt;containerd&lt;/code&gt; and &lt;code&gt;runc&lt;/code&gt;, which are not children of the shell. On a client-server system, &lt;code&gt;time&lt;/code&gt; measures latency, not cost.&lt;/p&gt;
&lt;p&gt;None of this makes Docker slow. It pays for layered images shared between containers, overlayfs, bridge networking with NAT, logging, restart policies and an API. My 124 ms is the cost of the kernel primitives alone.&lt;/p&gt;
&lt;h2&gt;What I got wrong&lt;/h2&gt;
&lt;p&gt;Mistakes were the most useful part of this lab, so here they are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I expected dozens of mounts in &lt;code&gt;/proc/mounts&lt;/code&gt; after &lt;code&gt;chroot&lt;/code&gt;. It showed one.&lt;/li&gt;
&lt;li&gt;I expected 11 failed forks against &lt;code&gt;pids.max&lt;/code&gt;. &lt;code&gt;dash&lt;/code&gt; stopped after the first.&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;ip -br addr&lt;/code&gt; inside the container. &lt;code&gt;-br&lt;/code&gt; is iproute2; Alpine&apos;s &lt;code&gt;ip&lt;/code&gt; is a BusyBox applet. Inside the container, the tools are Alpine&apos;s.&lt;/li&gt;
&lt;li&gt;I expected start-up in tens of milliseconds. It was ~124 ms.&lt;/li&gt;
&lt;li&gt;I expected Docker to share the time namespace. It created a new one.&lt;/li&gt;
&lt;li&gt;I ran an interactive &lt;code&gt;sudo unshare --pid&lt;/code&gt; by accident, and nothing could execute in it.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Gaps and what’s next&lt;/h2&gt;
&lt;p&gt;The script is deliberately incomplete. It has no &lt;code&gt;/dev&lt;/code&gt; (not even &lt;code&gt;/dev/null&lt;/code&gt;), no &lt;code&gt;/tmp&lt;/code&gt; or &lt;code&gt;/sys&lt;/code&gt;, no capability dropping, no seccomp and no user namespace, and, like default Docker, root inside is root on the host. That is fine for learning what each primitive does; it is not something to run untrusted code in.&lt;/p&gt;
&lt;p&gt;The next lab puts these primitives to work on the same server: sandboxing Claude Code, the AI coding agent I use in this lab. On Linux its sandbox is built on bubblewrap, which creates the same namespaces this post built by hand, and on Ubuntu 24.04 it meets the same AppArmor wall. Further down the road, the gaps above become a hardened-sandbox comparison: Claude Code’s built-in sandbox, NVIDIA OpenShell, gVisor and a hardened version of this script.&lt;/p&gt;
&lt;p&gt;Everything from this post, in full:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0a.md&quot;&gt;every command, step by step&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0a.md&quot;&gt;measurements, surprises and mistakes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Script:&lt;/strong&gt; &lt;a href=&quot;https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh&quot;&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Originally published at &lt;a href=&quot;https://gshell.dev/blog/container-from-scratch/&quot;&gt;gshell.dev&lt;/a&gt;.&lt;/p&gt;</content:encoded></item></channel></rss>