Skip to main content

Chapter 1: Linux Namespaces

Containers aren’t magic — they’re built on Linux kernel primitives that have existed since 2002. The first and most fundamental of these is namespaces. In this chapter, we’ll build our own container runtime in Java, starting with namespace isolation. Think of namespaces like one-way mirrors in an interrogation room. The person inside the room (the container) sees only what’s in their room. They have no idea other rooms exist. The person outside (the host) can see into every room. Namespaces give each container its own private view of system resources — its own process table, its own network stack, its own hostname — while the host kernel manages all of them simultaneously. The key realization is that containers are not virtual machines. There is no hypervisor, no guest kernel. Containers are regular Linux processes that have been given a restricted view of the world.
Prerequisites: Linux Internals: Processes
Further Reading: Operating Systems: Process Management
Time: 3-4 hours
Outcome: Understanding of namespace isolation

What Are Namespaces?


Linux Namespace Types


Part 1: Project Setup

We’ll use Java with JNA (Java Native Access) to call Linux system calls.
pom.xml

Part 2: Linux System Call Bindings

First, we need to call Linux system calls from Java:
src/main/java/com/minidocker/linux/LibC.java

Part 3: Namespace Manager

src/main/java/com/minidocker/namespace/NamespaceManager.java

Part 4: Namespace Options

src/main/java/com/minidocker/namespace/NamespaceOptions.java

Part 5: Understanding Each Namespace

PID Namespace

UTS Namespace

Mount Namespace


Part 6: Container Runner

src/main/java/com/minidocker/Container.java

Exercises

Extend the namespace manager to create network namespaces:
Allow joining an existing container’s namespaces:
Implement user namespace with UID/GID mapping:

Key Takeaways

Isolation Not Virtualization

Namespaces isolate views of resources, not the resources themselves

Kernel Primitives

unshare(), clone(), setns() are the syscalls that power containers

Layered Isolation

Each namespace type isolates a different resource

No Overhead

Namespaces add negligible overhead - just kernel data structures

Further Reading

Linux Namespaces Manual

Official documentation for Linux namespaces

Linux Internals Course

Deep dive into Linux process management

What’s Next?

In Chapter 2: Control Groups (cgroups), we’ll implement:
  • CPU limits
  • Memory limits
  • Process count limits
  • Resource accounting

Next: Cgroups

Learn how to limit container resources

Interview Deep-Dive

Strong Answer:
  • clone() creates a new child process that starts in the new namespace(s). It is analogous to fork() but accepts flags that specify which namespaces to create. The parent and child are in different namespaces from the moment the child starts executing.
  • unshare() moves the calling process itself into new namespace(s). There is no new process created. This is simpler when you want to isolate the current process rather than spawn a child.
  • Real container runtimes like runc use clone() because they need the container’s init process (PID 1 in the new PID namespace) to be a child process that the runtime can monitor and wait on. If you used unshare(CLONE_NEWPID), the calling process does not get PID 1 in the new namespace — only its next child does. This is a subtle but critical distinction that trips up many implementations.
  • There is also setns(), which joins an existing namespace by opening /proc/<pid>/ns/<type> and passing the file descriptor. This is how docker exec works — it calls setns() to enter the target container’s namespaces before executing the new command.
Follow-up: Why does the PID namespace behave differently from other namespaces with unshare()?Because PID namespace membership is determined at process creation time, not at runtime. When you call unshare(CLONE_NEWPID), the calling process remains in its original PID namespace — it is the next fork() that gets PID 1 in the new namespace. This is by kernel design: a process cannot change its own PID. Other namespaces like UTS or NET can take effect immediately because they do not involve identity (the hostname or network stack can change under a running process without ambiguity, but changing a process’s PID mid-execution would break everything that references it).
Strong Answer:
  • PID 1 in any namespace has two critical responsibilities inherited from Unix init: signal handling and zombie reaping. The kernel does not deliver certain default signal dispositions to PID 1 — notably, SIGTERM and SIGINT are ignored unless PID 1 explicitly registers a handler. This is why docker stop has a 10-second timeout: it sends SIGTERM, but if the container’s entrypoint does not handle it, Docker waits the timeout then sends SIGKILL.
  • Zombie reaping is the second issue. When a child process exits, it becomes a zombie until its parent calls wait(). In a normal system, init (PID 1) adopts orphaned processes and reaps them. If a container’s PID 1 is a simple application that does not call wait(), orphaned child processes accumulate as zombies, consuming PID table entries. This is especially common with shell scripts as entrypoints that spawn background processes.
  • The practical solutions are: use a proper init system like tini (Docker’s --init flag), or ensure your entrypoint is written to forward signals and reap children. In Go, this is relatively straightforward because the runtime handles SIGCHLD, but in Node.js or Python, you need explicit signal handlers.
  • A war story: at scale, zombie accumulation inside containers can hit the PID limit set by cgroups (pids.max), causing the container to fail to spawn any new processes. The symptoms look like “cannot fork: resource temporarily unavailable” errors that are mystifying if you do not know to check for zombies with ps aux | grep Z.
Follow-up: How does Kubernetes handle this problem?Kubernetes enables process namespace sharing between containers in a pod via shareProcessNamespace: true. When enabled, the pause container (the pod’s infrastructure container) becomes PID 1 and handles zombie reaping for all containers in the pod. Without this setting, each container has its own PID namespace and must handle its own signal forwarding and reaping. This is one reason the pause container exists — it is a minimal process that correctly implements init behavior, acting as the stable anchor for the pod’s shared namespaces.
Strong Answer:
  • The user namespace maps UIDs and GIDs inside the namespace to different UIDs outside. A process can be UID 0 (root) inside the container but map to UID 100000 (unprivileged) on the host. This means the container process has full root capabilities within its namespace but if it escapes the container, it lands as an unprivileged user on the host.
  • The mapping is configured by writing to /proc/<pid>/uid_map and /proc/<pid>/gid_map. A typical mapping like 0 100000 65536 means container UIDs 0-65535 map to host UIDs 100000-165535. This requires either root on the host or entries in /etc/subuid and /etc/subgid that grant ranges to unprivileged users.
  • The trade-off is complexity and compatibility. Some operations inside rootless containers behave differently — for example, mknod for device files is restricted because the kernel checks the host UID for device access. Network namespace setup requires workarounds (like slirp4netns instead of veth pairs) because creating network interfaces needs real CAP_NET_ADMIN on the host.
  • Despite these trade-offs, rootless containers are a significant security improvement and are the default in Podman. For production environments where the threat model includes container escape, running rootless eliminates the most dangerous scenario: an attacker gaining root on the host.
Follow-up: If user namespaces are so beneficial, why did Docker not enable them by default from the start?Primarily because of the compatibility burden. When user namespaces are enabled, every file in the container image is accessed as the mapped (unprivileged) host UID. This breaks volume mounts where the host directory is owned by a different user, breaks images that expect real root capabilities (like installing packages), and introduces subtle permission errors with shared storage. Docker chose operational simplicity over security by default. Podman made the opposite bet, choosing rootless by default and absorbing the compatibility pain. The industry has gradually shifted toward rootless as the ecosystem adapted, but the transition took years.