HomeCurriculumVirtualizationContainerization Primitives - Namespaces & Cgroups
Containerization Primitives: Namespaces & Cgroups

Containerization Primitives - Namespaces & Cgroups

Dissect the Linux container kernel stack: process isolation via 8 namespaces, CPU resource throttling via cgroups v2 CFS quotas, and OverlayFS storage layers.

DT
Authored by DevThesis Systems Editorial Team
Reviewed for Technical Accuracy & Code Determinism

In the evolution of system virtualization, hardware-assisted hypervisors (such as KVM and ESXi) solved the problem of multi-tenancy by virtualizing the physical silicon: executing an entire, independent guest operating system kernel inside a dedicated hardware partition. While this approach delivers absolute isolation and multi-OS heterogeneity, it imposes substantial computational penalties: booting a full kernel takes tens of seconds, each virtual machine consumes hundreds of megabytes of RAM before executing application code, and hardware state transitions (VM-Exits) introduce measurable latency.

Operating System-Level Virtualization—commonly known as Containerization—takes an entirely different architectural approach. Rather than virtualizing the underlying hardware, containers virtualize the operating system kernel itself. Multiple isolated user space environments share a single, monolithic host operating system kernel. The container engine (such as Docker, containerd, or Podman) is not a hypervisor; instead, it is a user space orchestrator that combines foundational Linux kernel primitives: Namespaces for spatial isolation, Control Groups (cgroups) for resource governance, and Union Filesystems (OverlayFS) for layered storage efficiency.


1. Architectural Foundations: Virtual Machines versus Containers#

To appreciate the design of containerized systems, one must compare their architectural execution model against hypervisor-based virtual machines.

text
       HARDWARE VIRTUALIZATION (VMs)                 OPERATING SYSTEM VIRTUALIZATION (CONTAINERS)

  +-------------------------------------+      +-------------------------------------------------+
  | App 1 | App 2 | App 3 | App 4       |      | App 1 (PID 1) | App 2 (PID 1) | App 3 (PID 1)   |
  | (Userland Binaries & Libraries)     |      | (Isolated Namespaces: Mount, Net, PID, User)    |
  +-------------------------------------+      +-------------------------------------------------+
  | GUEST OS KERNEL (Linux / Windows)   |      | CONTROL GROUPS (cgroups v2: CPU, Memory, IO)    |
  | (Independent memory & scheduler)    |      +-------------------------------------------------+
  +-------------------------------------+      | LINUX SYSTEM CALL INTERFACE (seccomp, cap)      |
  | VIRTUAL HARDWARE (vCPU, EPT, Virtio)|      +-------------------------------------------------+
  +-------------------------------------+      | HOST OPERATING SYSTEM KERNEL (Linux 6.x)        |
  | HYPERVISOR (KVM / Type-1 ESXi)      |      | (Single shared kernel scheduling all processes) |
  +-------------------------------------+      +-------------------------------------------------+
  | PHYSICAL SILICON (x86-64 / ARM64)   |      | PHYSICAL SILICON (x86-64 / ARM64)               |
  +-------------------------------------+      +-------------------------------------------------+

The Trade-Off Spectrum#

DimensionVirtual Machines (Hardware Virtualization)Containers (OS-Level Virtualization)
Isolation BoundaryHardware silicon enforcement (Intel VT-x VMX Root/Non-Root, EPT memory pages, IOMMU DMA barriers).Software kernel enforcement (Linux system call filter, namespace tables, cgroup meters).
Kernel InstancesEach VM runs its own independent, isolated OS kernel.All containers share the single host Linux kernel.
Startup Latency10 to 60 seconds (hardware POST emulation, kernel initialization, systemd service startup).5 to 50 milliseconds (executing a standard fork(2) / clone(2) process invocation).
Memory Footprint512 MB to 2 GB baseline memory overhead per guest kernel and hypervisor control structures.Near zero baseline overhead; a container process consumes only the RAM its application code allocates.
I/O OverheadMinor virtualization tax across EPT and Virtio layers (~2% to 8%).True bare-metal I/O performance (processes execute native system calls directly).
HeterogeneityCan run different operating systems (e.g., Windows and Linux running concurrently on the same host).Homogeneous; all containers must be compatible with the host Linux kernel ABI.

2. Linux Kernel Namespaces: The Isolation Primitives#

A Linux Namespace wraps a global system resource into an isolated abstraction, providing processes inside the namespace with the illusion that they possess their own dedicated instance of that system resource.

text
+----------------------------------------------------------------------------------------------------+
|                               THE EIGHT LINUX KERNEL NAMESPACES                                    |
+----------------------------------------------------------------------------------------------------+
| Namespace | Flag              | Isolated System Resource                                           |
+----------------------------------------------------------------------------------------------------+
| PID       | CLONE_NEWPID      | Process IDs (Process inside container is PID 1; on host is PID 9482)|
| NET       | CLONE_NEWNET      | Network Devices, IP Routing Tables, Port Bindings, Firewall Rules   |
| MNT       | CLONE_NEWNS       | File System Mount Points (Provides isolated root directory view)   |
| IPC       | CLONE_NEWIPC      | System V IPC, POSIX Message Queues, Shared Memory Segments         |
| UTS       | CLONE_NEWUTS      | Hostname and NIS Domain Name                                       |
| USER      | CLONE_NEWUSER     | User IDs and Group IDs (Container root UID 0 mapped to host UID 100K|
| CGROUP    | CLONE_NEWCGROUP   | View of /proc/self/cgroup hierarchy                                |
| TIME      | CLONE_NEWTIME     | Monotonic and Boot Clocks (Virtual clock offsets per container)    |
+----------------------------------------------------------------------------------------------------+

The System Calls Governing Namespaces#

The Linux kernel exposes three primary system calls for creating and managing namespaces:

  1. *`clone(2)` with `CLONE_NEW` Flags**:

Creates a new child process, instantly instantiating specified namespaces:

```c

int child_pid = clone(child_fn, stack_top, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL);

```

  1. `unshare(2)`:

Disassociates the calling process from its parent's namespaces, creating fresh namespaces without spawning a new child process.

  1. `setns(2)`:

Attaches the calling process to an existing namespace identified by an open file descriptor in /proc/[pid]/ns/. This is the exact mechanism utilized by docker exec to inject an interactive bash shell into a running container:

```c

int net_fd = open("/proc/14892/ns/net", O_RDONLY);

setns(net_fd, CLONE_NEWNET); // The caller is now inside the container's network stack!

```

Deep Dive: The PID Namespace & PID 1 Zombie Reaping#

When a new PID namespace is created via CLONE_NEWPID:

  • The first process spawned inside the namespace receives Process ID 1 (PID 1).
  • From the perspective of the host operating system, that same process has a completely different PID (e.g., PID 14892).
  • Processes inside the container cannot see or send signals (e.g., SIGKILL) to processes in parent or sibling namespaces.
text
+----------------------------------------------------------------------------------------------------+
|                                  NESTED PID NAMESPACE TOPOLOGY                                     |
|                                                                                                    |
|   HOST ROOT PID NAMESPACE                                                                          |
|   +--------------------------------------------------------------------------------------------+   |
|   | PID 1: /sbin/init (systemd)                                                                |   |
|   |   |                                                                                        |   |
|   |   +──> PID 842: containerd                                                                  |   |
|   |          |                                                                                 |   |
|   |          +──> PID 14892: /usr/bin/node server.js                                            |   |
|   |                 |                                                                          |   |
|   |                 +──> PID 14905: worker-thread                                              |   |
|   +-----------------|--------------------------------------------------------------------------+   |
|                     |                                                                              |
|                     v (Mapped into Container Namespace)                                            |
|   CONTAINER PID NAMESPACE                                                                          |
|   +--------------------------------------------------------------------------------------------+   |
|   | PID 1: /usr/bin/node server.js (Acts as init inside container!)                            |   |
|   |   |                                                                                        |   |
|   |   +──> PID 2: worker-thread                                                                |   |
|   +--------------------------------------------------------------------------------------------+   |
+----------------------------------------------------------------------------------------------------+

The PID 1 Zombie Reaping Responsibility:

In standard Linux, when a child process terminates, it enters the ZOMBIE state until its parent calls waitpid(2) to harvest its exit code. If the parent process crashes or terminates before its child, the child is "orphaned."

In the host OS, orphaned processes are adopted by systemd (PID 1), which continuously reaps zombies.

Inside a container, the application executing as PID 1 is responsible for reaping orphaned zombies. If a naive Node.js or Python script runs as PID 1 and spawns subprocesses that orphan children, the zombie processes can never be reaped. Over time, zombies exhaust the operating system process table, crashing the host server. Modern container runtimes mitigate this by injecting a lightweight init process (such as tini or dumb-init) as PID 1.

Deep Dive: The Network Namespace & Virtual Ethernet (`veth`) Pairs#

A Network Namespace (CLONE_NEWNET) provides an isolated network subsystem: independent network interfaces, routing tables, ARP caches, and firewall rules (iptables / nftables).

To connect an isolated network namespace to the physical network, the container runtime creates a Virtual Ethernet (`veth`) Pair:

  • A veth pair functions like a bidirectional virtual patch cable: any packet sent into one end automatically emerges from the other end.
  • The runtime places one end (veth0) inside the container namespace and renames it to eth0.
  • The runtime places the other end (veth-host-xxx) into the host root namespace and attaches it to a software network bridge (e.g., docker0 or a Linux bridge br0).
  • The container is assigned a private IPv4 address (e.g., 172.17.0.2/16), and outbound traffic is translated via Source NAT (Masquerading) by host iptables rules.

3. Control Groups (cgroups v1 vs. cgroups v2): Resource Governance#

While namespaces isolate what a process can see, Control Groups (cgroups) strictly govern what a process can consume. Without cgroups, a single compromised or runaway container process could allocate 100% of host CPU cores, exhaust all physical DRAM, or saturate storage I/O bandwidth, causing a Denial of Service (DoS) across all other containers.

text
+----------------------------------------------------------------------------------------------------+
|                                cgroups v1 versus cgroups v2 ARCHITECTURE                           |
|                                                                                                    |
|   cgroups v1 (Legacy Multi-Hierarchy):            cgroups v2 (Modern Unified Hierarchy):           |
|                                                                                                    |
|   /sys/fs/cgroup/                                 /sys/fs/cgroup/                                  |
|     ├── cpu/                                        ├── cgroup.controllers (cpu memory io pids)   |
|     │    └── container_A (CPU limits)               ├── cgroup.procs                              |
|     ├── memory/                                     └── container_A/                              |
|     │    └── container_A (Memory limits)                 ├── cgroup.procs                          |
|     ├── blkio/                                           ├── cpu.max (quota and period)            |
|     │    └── container_A (I/O limits)                    ├── memory.max (hard OOM ceiling)         |
|     └── pids/                                            ├── memory.high (soft throttle boundary)  |
|          └── container_A (Process limits)                ├── io.max (bandwidth & IOPS caps)        |
|                                                          └── pids.max (fork-bomb guardrail)        |
|   * Orthogonal trees caused synchronization deadlocks!   * Single tree: All resources unified!     |
+----------------------------------------------------------------------------------------------------+

The Evolution to cgroups v2#

In cgroups v1, each resource controller (cpu, memory, blkio, net_cls) maintained its own independent file system tree. A process could be in group /sys/fs/cgroup/cpu/web for CPU, but in group /sys/fs/cgroup/memory/database for memory. This orthogonal design created severe kernel synchronization bugs; for instance, the kernel could not throttle page-cache writeback I/O under memory pressure because the block I/O controller and memory controller could not cross-communicate.

Introduced by Tejun Heo in Linux 4.5 and finalized in modern Linux distributions, cgroups v2 replaces disparate trees with a single Unified Hierarchy:

  • Every process belongs to exactly one cgroup node in the tree.
  • Controllers are enabled hierarchically down the tree via cgroup.subtree_control.
  • Coordinated resource governance: memory writeback, swap allocation, and block I/O throttling operate in complete synchronization.

Core Controllers in Depth#

  1. Memory Controller (`memory.max` and `memory.high`):

- memory.max: The hard memory ceiling (e.g., 536870912 = 512 MB). If a container's aggregate memory usage (anonymous memory + page cache) breaches this limit, the Linux kernel invokes the Out-Of-Memory (OOM) Killer. The OOM killer inspects oom_score, immediately terminates the offending process with a SIGKILL (Exit Code 137: 128 + 9), and logs oom-kill events to memory.events.

- memory.high: The soft memory boundary. When breached, the kernel does not kill the process; instead, it throttles the process and aggressively reclaims file-backed page caches in background.

  1. CPU Controller (`cpu.max` - Completely Fair Scheduler Quota):

- Configures CPU time allocation via the Linux Completely Fair Scheduler (CFS).

- The file accepts two space-separated integers: quota and period (in microseconds):

cpu.max = quota period- Example: Setting cpu.max = 200000 100000 assigns a quota of 200,000 μ s per 100,000 μ s period. This grants the container exactly:

Cores = (200,000) / (100,000) = 2.0 physical CPU cores- If a container exhausts its quota before the period expires, the scheduler deschedules the container's threads, imposing CPU Throttling until the next period resets.

  1. PIDs Controller (`pids.max`):

- Caps the maximum number of concurrent task threads (pids.max = 100).

- Completely eliminates classical fork() bombs: if an attacker script executes while(1) fork(), subsequent fork() system calls fail with EAGAIN once the limit is reached, protecting the host system from thread table exhaustion.


4. Layered Storage & Union Filesystems: OverlayFS#

If every container required a dedicated, full copy of an operating system filesystem (e.g., 2 GB for an Ubuntu or Debian rootfs), running 100 containers would consume 200 GB of disk space and require minutes to duplicate files on disk.

Containers achieve instant startup and massive storage deduplication using Union Mounts, specifically OverlayFS.

text
+----------------------------------------------------------------------------------------------------+
|                                    OVERLAYFS DIRECTORY HIERARCHY                                   |
|                                                                                                    |
|  +----------------------------------------------------------------------------------------------+  |
|  |                            MERGED VIEW (Container Root Mount: /)                             |  |
|  |             The unified filesystem presented to the containerized application.               |  |
|  +----------------------------------------------------------------------------------------------+  |
|                                                ^                                                   |
|                                                |                                                   |
|  +---------------------------------------------+------------------------------------------------+  |
|  | UPPERDIR (Read-Write Layer): Container-Specific                                             |  |
|  | * Contains newly created files, modified files (COW copies), and whiteout deletion markers. |  |
|  +----------------------------------------------------------------------------------------------+  |
|                                                ^                                                   |
|                                                | (Overlay Mount)                                   |
|  +---------------------------------------------+------------------------------------------------+  |
|  | LOWERDIR 3 (Read-Only Image Layer): RUN apt-get install -y nodejs (Layer Hash: c3a1...)     |  |
|  +----------------------------------------------------------------------------------------------+  |
|  | LOWERDIR 2 (Read-Only Image Layer): RUN apt-get update (Layer Hash: 8f42...)                 |  |
|  +----------------------------------------------------------------------------------------------+  |
|  | LOWERDIR 1 (Read-Only Base Image Layer): ubuntu:22.04 base rootfs (Layer Hash: e9b5...)     |  |
|  +----------------------------------------------------------------------------------------------+  |
+----------------------------------------------------------------------------------------------------+

The Mechanics of OverlayFS#

An OverlayFS mount is created using a standard Linux mount(2) command:

bash
mount -t overlay overlay -o lowerdir=/layer1:/layer2:/layer3,upperdir=/upper,workdir=/work /merged
  • `lowerdir` (Read-Only Layers): The immutable container image layers downloaded from an OCI container registry. Multiple containers derived from the same base image share the exact same lowerdir on physical disk without duplication.
  • `upperdir` (Read-Write Layer): An ephemeral, container-specific directory. All filesystem writes generated by the running container are stored here.
  • `workdir` (Atomic Operations Scratchpad): An internal directory used by the Linux kernel to stage operations (such as file creation and copy-up) before making them visible in upperdir, guaranteeing atomic file updates.
  • `merged`: The virtual mount point representing the composite view. When a container process inspects /, it sees the merged projection.

Copy-On-Write (COW) and Whiteout Deletions#

  1. File Read (`open(..., O_RDONLY)`):

- The kernel checks upperdir. If the file exists, it is served.

- If not in upperdir, the kernel scans lowerdir layers from top to bottom, serving the first matching instance.

  1. File Modification (`open(..., O_RDWR)`):

- If the file exists in lowerdir and the container attempts to write to it, the kernel triggers a Copy-Up (COW):

- The entire file is copied from the read-only lowerdir into upperdir.

- The write operation is performed directly on the newly copied file in upperdir.

- The original file in lowerdir remains completely untouched, ensuring no other running container is impacted.

  1. File Deletion (`unlink(2)`):

- If a container deletes a file that exists in an immutable lowerdir, the kernel cannot modify the lower layer.

- Instead, the kernel creates a special Whiteout File in upperdir: a character device with major and minor numbers set to 0 (c 0 0).

- When OverlayFS renders the merged view, encountering a whiteout file masks the corresponding file in lowerdir, rendering it invisible inside the container.


5. Container Security & Sandboxing: Capabilities, Seccomp & MicroVMs#

Because all containers share the single host Linux kernel, container security is fundamentally an exercise in reducing the kernel system call attack surface.

text
+----------------------------------------------------------------------------------------------------+
|                                    DEFENSE-IN-DEPTH CONTAINER SECURITY                             |
|                                                                                                    |
|  1. LINUX CAPABILITIES (cap_drop = ALL, cap_add = NET_BIND_SERVICE)                               |
|     * Eliminates coarse-grained root. Strips CAP_SYS_ADMIN, CAP_NET_ADMIN, CAP_SYS_RAWIO.         |
|                                     |                                                              |
|                                     v                                                              |
|  2. SECCOMP-BPF (Secure Computing Mode System Call Filtering)                                      |
|     * Berkeley Packet Filters inspect syscall numbers. Blocks 300+ dangerous syscalls             |
|       (e.g., ptrace, bpf, kexec_load, clone3) with immediate EPERM or SIGSYS.                      |
|                                     |                                                              |
|                                     v                                                              |
|  3. LINUX SECURITY MODULES (AppArmor / SELinux Mandatory Access Control)                           |
|     * Path-based and inode-label access control. Blocks reading /proc/kcore or writing to /sys.    |
|                                     |                                                              |
|                                     v                                                              |
|  4. MICROVM / HYPERVISOR SANDBOXING (AWS Firecracker / Google gVisor)                              |
|     * Complete kernel isolation for untrusted multi-tenant workloads.                              |
+----------------------------------------------------------------------------------------------------+

Linux Capabilities#

Traditionally, UNIX privilege was binary: a process was either standard user (UID != 0) or superuser root (UID == 0). Root possessed unlimited silicon authority.

Linux breaks superuser power into ~40 granular Capabilities:

  • CAP_CHOWN: Make arbitrary changes to file UIDs/GIDs.
  • CAP_NET_BIND_SERVICE: Bind a socket to privileged internet domain ports (< 1024).
  • CAP_SYS_ADMIN: The "god-mode" capability (allows mounting filesystems, setting cgroups, configuring device drivers).

By default, Docker and Kubernetes strip CAP_SYS_ADMIN and over 20 dangerous capabilities from container processes. Even if an attacker achieves root (UID 0) inside a container, without CAP_SYS_ADMIN, they cannot load kernel modules or mount file systems.

Seccomp-BPF (System Call Filtering)#

The Linux kernel implements over 450 distinct system calls. Many historical kernel privilege escalation exploits target obscure, legacy system calls (such as keyctl(2), bpf(2), or userfaultfd(2)).

Seccomp-BPF attaches a Berkeley Packet Filter (BPF) program to the container's process descriptor (task_struct). Before the kernel executes any system call, the BPF program inspects the syscall number and arguments. The default Docker seccomp profile whitelists ~300 safe syscalls and immediately blocks all others with EPERM, preventing exploitation of unpatched kernel bugs.


6. Industrial Implementation: Complete Linux Container Runtime & OverlayFS Simulator#

To internalize how namespaces, cgroup limits, and OverlayFS layer merging operate under the hood, we present a complete, fully typed, production-grade TypeScript implementation of a Container Runtime and OverlayFS Engine.

typescript
// container-runtime-simulator.ts
// Production-grade implementation of Linux Namespaces, cgroups v2 resource accounting,
// and OverlayFS Copy-On-Write filesystem virtualization.

export interface Process {
  pid: number;
  name: string;
  command: string;
  memoryUsageBytes: number;
  cpuTimeConsumedUs: number;
}

// ============================================================================
// PART 1: OVERLAYFS STORAGE ENGINE
// ============================================================================

export interface FileEntry {
  path: string;
  content: string;
  isWhiteout: boolean; // Character device (c 0 0) indicating deletion
}

export class OverlayFsEngine {
  // Lowerdir: Array of Read-Only layers (Layer 0 is bottom base image)
  public lowerLayers: Map<string, FileEntry>[] = [];

  // Upperdir: Ephemeral Read-Write layer
  public upperLayer: Map<string, FileEntry> = new Map();

  constructor() {}

  public addLowerLayer(files: { path: string; content: string }[]): void {
    const layer = new Map<string, FileEntry>();
    for (const f of files) {
      layer.set(f.path, { path: f.path, content: f.content, isWhiteout: false });
    }
    this.lowerLayers.unshift(layer); // Most recent layer at index 0
  }

  // READ Operation: Upper layer overrides lower layers
  public readFile(path: string): string | null {
    // 1. Check upperdir
    if (this.upperLayer.has(path)) {
      const entry = this.upperLayer.get(path)!;
      if (entry.isWhiteout) return null; // Masked by whiteout deletion
      return entry.content;
    }

    // 2. Scan lower layers from top to bottom
    for (const layer of this.lowerLayers) {
      if (layer.has(path)) {
        const entry = layer.get(path)!;
        if (entry.isWhiteout) return null;
        return entry.content;
      }
    }

    return null; // File does not exist
  }

  // WRITE Operation: Implements Copy-On-Write (COW)
  public writeFile(path: string, newContent: string): void {
    // Write directly into upperdir, leaving lowerdir layers pristine
    this.upperLayer.set(path, {
      path,
      content: newContent,
      isWhiteout: false,
    });
  }

  // DELETE Operation: Creates a Whiteout file in upperdir
  public deleteFile(path: string): boolean {
    const exists = this.readFile(path) !== null;
    if (!exists) return false;

    // Place a whiteout marker in upperdir
    this.upperLayer.set(path, {
      path,
      content: '',
      isWhiteout: true,
    });
    return true;
  }
}

// ============================================================================
// PART 2: CGROUPS V2 RESOURCE GOVERNANCE ENGINE
// ============================================================================

export class CgroupsV2Controller {
  public memoryMaxBytes: number; // Hard ceiling (OOM trigger)
  public cpuMaxQuotaUs: number; // CFS Quota
  public cpuMaxPeriodUs: number; // CFS Period (default 100,000 us)
  public pidsMax: number;

  public currentMemoryUsage: number = 0;
  public totalOomKills: number = 0;
  public totalCpuThrottles: number = 0;

  constructor(memoryLimitMb: number, coresLimit: number, pidsMax: number = 100) {
    this.memoryMaxBytes = memoryLimitMb * 1024 * 1024;
    this.cpuMaxPeriodUs = 100000; // 100 ms
    this.cpuMaxQuotaUs = Math.floor(coresLimit * this.cpuMaxPeriodUs);
    this.pidsMax = pidsMax;
  }

  // Enforce Memory Allocation
  public allocateMemory(proc: Process, bytes: number): boolean {
    if (this.currentMemoryUsage + bytes > this.memoryMaxBytes) {
      this.totalOomKills++;
      console.log(`[cgroup OOM-Killer] Task '{proc.name}' (PID {proc.pid}) breached memory.max ({this.memoryMaxBytes} bytes). Sending SIGKILL.`);
      return false; // Out Of Memory
    }

    this.currentMemoryUsage += bytes;
    proc.memoryUsageBytes += bytes;
    return true;
  }

  // Check CPU CFS Quota Throttling
  public executeCpuBurst(proc: Process, burstDurationUs: number): boolean {
    if (burstDurationUs > this.cpuMaxQuotaUs) {
      this.totalCpuThrottles++;
      console.log(`[cgroup CFS Throttler] Task '{proc.name}' exceeded quota ({burstDurationUs}us > {this.cpuMaxQuotaUs}us). Throttling process.`);
      return false; // Throttled
    }

    proc.cpuTimeConsumedUs += burstDurationUs;
    return true;
  }
}

// ============================================================================
// PART 3: CONTAINER RUNTIME CORE
// ============================================================================

export class ContainerInstance {
  public id: string;
  public hostname: string; // UTS Namespace
  public virtualIp: string; // NET Namespace
  public processes: Map<number, Process> = new Map(); // PID Namespace
  public nextContainerPid: number = 1;

  public overlayFs: OverlayFsEngine = new OverlayFsEngine();
  public cgroup: CgroupsV2Controller;

  constructor(id: string, hostname: string, memoryLimitMb: number, coresLimit: number) {
    this.id = id;
    this.hostname = hostname;
    this.virtualIp = '172.17.0.2';
    this.cgroup = new CgroupsV2Controller(memoryLimitMb, coresLimit);
  }

  // Spawn process into Container PID Namespace
  public spawnProcess(name: string, command: string): Process {
    const containerPid = this.nextContainerPid++;
    const proc: Process = {
      pid: containerPid,
      name,
      command,
      memoryUsageBytes: 0,
      cpuTimeConsumedUs: 0,
    };

    this.processes.set(containerPid, proc);
    console.log(`[Container {this.id}] Spawned process '{name}' as Container PID {containerPid}`);
    return proc;
  }
}

// Verification Harness and Demonstration
export function runContainerSimulation(): void {
  console.log('=== Initializing Linux Container Runtime & OverlayFS Simulation ===\n');

  // 1. Initialize Container with 256MB RAM and 1.5 CPU Cores
  const container = new ContainerInstance('ctr-web-prod', 'web-server-node-1', 256, 1.5);

  // 2. Setup OverlayFS Base Layers
  console.log('[OverlayFS] Building immutable lowerdir image layers...');
  // Base Layer 1: Ubuntu OS
  container.overlayFs.addLowerLayer([
    { path: '/etc/os-release', content: 'NAME="Ubuntu"\nVERSION="22.04"' },
    { path: '/etc/nginx/nginx.conf', content: 'worker_processes 1;' },
  ]);
  // Base Layer 2: App Code
  container.overlayFs.addLowerLayer([
    { path: '/var/www/html/index.html', content: '<h1>Hello from Lower Layer</h1>' },
  ]);

  // Read pristine file from lowerdir
  console.log(`[OverlayFS Read] Initial index.html: "{container.overlayFs.readFile('/var/www/html/index.html')}"`);

  // 3. Demonstrate Copy-On-Write (COW) Modification
  console.log('\n[OverlayFS Write] Modifying /var/www/html/index.html inside running container...');
  container.overlayFs.writeFile('/var/www/html/index.html', '<h1>Hello from Upperdir (COW Active)</h1>');
  console.log(`[OverlayFS Read] Merged view index.html: "{container.overlayFs.readFile('/var/www/html/index.html')}"`);
  console.log(`[OverlayFS Check] Pristine lowerdir Layer 1 is unchanged: "{container.overlayFs.lowerLayers[0].get('/var/www/html/index.html')?.content}"`);

  // 4. Demonstrate Whiteout File Deletion
  console.log('\n[OverlayFS Delete] Deleting /etc/nginx/nginx.conf inside container...');
  container.overlayFs.deleteFile('/etc/nginx/nginx.conf');
  console.log(`[OverlayFS Read] Querying deleted file: {container.overlayFs.readFile('/etc/nginx/nginx.conf')}`);

  // 5. Demonstrate cgroup v2 Resource Controls
  console.log('\n=== Testing cgroups v2 Memory and CPU Governance ===');
  const appProc = container.spawnProcess('node', 'node /app/server.js');

  // Allocate 100 MB RAM (Within limit)
  console.log('[cgroup Test] Allocating 100 MB memory...');
  container.cgroup.allocateMemory(appProc, 100 * 1024 * 1024);

  // Attempt to allocate 200 MB RAM (Breaches 256 MB memory.max)
  console.log('[cgroup Test] Allocating additional 200 MB memory (Exceeds 256MB limit)...');
  const allowed = container.cgroup.allocateMemory(appProc, 200 * 1024 * 1024);
  console.log(`  -> Allocation Result: {allowed ? 'SUCCESS' : 'BLOCKED BY OOM-KILLER'}`);

  // Test CPU Quota (1.5 Cores = 150,000 us quota per 100,000 us period)
  console.log('\n[cgroup Test] Executing 180,000us compute burst (Exceeds 150,000us quota)...');
  container.cgroup.executeCpuBurst(appProc, 180000);

  console.log(`\n=== Telemetry Summary ===`);
  console.log(`Total OOM Kills Triggered: {container.cgroup.totalOomKills}`);
  console.log(`Total CPU Throttles Enforced: {container.cgroup.totalCpuThrottles}`);
}

7. Real-World Case Studies & Architectural Post-Mortems#

Because containerization shares the underlying Linux kernel across all tenants, microarchitectural vulnerabilities and container escape bugs have triggered critical production disasters.

Case Study 1: The Dirty COW Linux Kernel Container Escape (CVE-2016-5195)#

In October 2016, security researchers disclosed Dirty COW, a severe race condition vulnerability in the Linux kernel's Memory Management subsystem:

  • The Flaw: When a non-privileged user space process requested write access to a read-only memory mapping, the kernel invoked Copy-On-Write (COW). However, a race condition between madvise(MADV_DONTNEED) and standard page fault handlers caused the kernel to write directly to the underlying physical page cache frame instead of the copied private page.
  • The Container Breakout: In multi-tenant container platforms, the host kernel executable files (such as /usr/bin/runc or shared system libraries) were mapped read-only into container namespaces via OverlayFS lower layers.
  • The Exploit: A low-privileged attacker inside a Docker container executed the Dirty COW race condition against their local read-only mapping of a host system library. The race condition succeeded in writing directly to the host's underlying physical page cache.
  • The attacker modified host binaries, gaining immediate root execution on the host machine, escaping all namespaces, cgroups, and container boundaries.
  • Architectural Lesson: Containers sharing a single kernel cannot defend against kernel memory corruption bugs. Multi-tenant untrusted code must be sandboxed using lightweight virtualization (such as AWS Firecracker microVMs or Google gVisor).

Case Study 2: The `runc` Container Breakout via `/proc/self/exe` (CVE-2019-5736)#

In February 2019, security researcher Aleksa Sarai identified a critical vulnerability in runc, the industry-standard low-level container runtime used by Docker and Kubernetes:

  • The Vector: When an administrator executes docker exec -it <container> /bin/sh, the host runc binary attaches to the container's namespaces and executes the requested command.
  • The Exploit: While attached to the container namespaces, the host runc process temporarily exposes its own execution binary via the Linux proc filesystem at /proc/self/exe.
  • An attacker inside the container with root permissions replaced /bin/sh with a malicious script. When docker exec attached, the script opened /proc/self/exe in write-only mode (O_WRONLY) and overwrote the physical runc binary residing on the host disk with attacker bytecode.
  • The next time any container operation executed on the host, the corrupted runc executed the attacker's payload as root on the physical host server.
  • The Remediation: The open-source community patched runc to clone its own binary into an anonymous, temporary in-memory file (memfd_create) marked read-only before entering container namespaces.

8. Common Containerization Anti-Patterns & Operational Pitfalls#

Operational failures in containerized fleets frequently result from misconfigured security flags or incomplete resource specifications:

1. Running Containers in Privileged Mode (`--privileged`)#

  • The Mistake: Developers encountering permission issues with Docker volumes or network interfaces add --privileged to the run command or privileged: true in Kubernetes manifests.
  • The Failure: The --privileged flag completely disables Seccomp filtering, AppArmor profiles, and drops all capability restrictions, while granting the container direct access to all physical host /dev devices. Any process inside the container can immediately mount the physical host root hard drive (/dev/sda1) and execute arbitrary code on the host server.
  • The Fix: Never use --privileged in production. Add only specific, tightly scoped Linux capabilities using --cap-add (e.g., --cap-add=NET_ADMIN).

2. Failing to Specify CPU and Memory Limits in Production#

  • The Mistake: Deploying containers into production Kubernetes clusters without declaring resources.limits.memory or resources.limits.cpu.
  • The Failure: A container experiencing a memory leak will expand consumption unbounded until it exhausts all physical RAM on the Kubernetes worker node. When the host runs out of memory, the host kernel OOM killer terminates arbitrary processes, often killing critical system daemons (kubelet or containerd), taking down the entire node and all co-located pods.

9. Checkpoint Practice Problems with Detailed Step-by-Step Solutions#

Problem 1: Linux cgroups v2 CFS CPU Quota & Multi-Core Throttling Calculation#

Scenario: A microservice container processes asynchronous image transformation tasks in a Kubernetes cluster. The container's resource limits are configured in its cgroups v2 interface as:

  • cpu.max: 250000 100000 (quota = 250,000 microseconds, period = 100,000 microseconds).
  • The physical host server possesses 16 physical CPU cores running at 3.0 GHz.
  • The microservice executes a multi-threaded image processing pipeline that spawns 4 concurrent worker threads.

During a peak processing window, each of the 4 worker threads executes compute-intensive mathematical pixel operations requiring 100% of a physical core.

Task:

  1. Calculate the maximum number of physical CPU cores the container is allowed to consume based on its cpu.max setting.
  2. In a single 100-millisecond scheduling period (100,000 μ s), calculate how much CPU time (in microseconds) the 4 threads consume collectively per wall-clock millisecond.
  3. Calculate the exact elapsed wall-clock time (in milliseconds) into the period when the container exhausts its quota and becomes throttled.
  4. Calculate the duration of the throttling window (in milliseconds) where the container's threads are forced to stall before the next CFS period begins. What percentage of the period is spent throttled?

Step-by-Step Solution:

Step 1: Calculate allowed physical CPU cores:

Allowed Cores = (quota) / (period) = (250,000 μ s) / (100,000 μ s) = {2.5 physical CPU cores}Step 2: CPU consumption rate across 4 threads:

  • There are 4 worker threads running concurrently at 100% load on physical cores.
  • In 1 millisecond (1,000 μ s) of wall-clock time, 4 threads consume:

4 threads × 1,000 μ s = 4,000 μ s of CPU time per wall-clock millisecondStep 3: Time until quota exhaustion:

The container possesses a quota of 250,000 μ s.

Time to Exhaustion = (Total Quota) / (Consumption Rate) = (250,000 μ s) / (4,000 μ s/ms) = {62.5 milliseconds}After exactly 62.5 milliseconds of wall-clock time, the 4 threads have collectively consumed 250,000 μ s of CPU time. The Linux CFS scheduler deschedules all 4 threads.

Step 4: Throttling window duration:

  • Total period duration = 100.0 milliseconds (100,000 μ s).
  • Active execution time = 62.5 milliseconds.
  • Throttled Stall Duration:

Stall Duration = 100.0 ms - 62.5 ms = {37.5 milliseconds}- Percentage of period spent throttled:

Throttling Percentage = ≤ft( (37.5) / (100.0) ) × 100\% = {37.5\%}Conclusion: During peak bursts, the container's application threads are frozen for 37.5% of every second, causing catastrophic latency spikes on incoming API requests.


Problem 2: OverlayFS Copy-On-Write Latency Amplification on Large File Writes#

Scenario: A legacy application running inside a Docker container writes a single 4-byte timestamp update into an existing configuration archive located at /var/data/archive.tar.

  • The file /var/data/archive.tar resides in the immutable lowerdir image layer.
  • The file size is exactly 2.0 Gigabytes (2,147,483,648 bytes).
  • The underlying physical host filesystem is an ext4 volume backed by an enterprise NVMe SSD with a sequential write speed of 800 MB/second and an average read speed of 1,600 MB/second.

The application opens the file in read-write mode: open("/var/data/archive.tar", O_RDWR).

Task:

  1. Explain the step-by-step kernel sequence executed by OverlayFS when this write occurs.
  2. Calculate the exact volume of data that must be read and written across the physical storage disk to execute this 4-byte modification.
  3. Calculate the total latency delay (in seconds) introduced by the Copy-On-Write (copy-up) operation before the 4-byte write finishes.
  4. Formulate the architectural remedy recommended by Docker and OCI standards to eliminate this Copy-On-Write penalty for large stateful files.

Step-by-Step Solution:

Step 1: OverlayFS Execution Trace:

  1. The application issues open("/var/data/archive.tar", O_RDWR).
  2. The VFS layer routes the call to the OverlayFS driver.
  3. OverlayFS discovers that /var/data/archive.tar exists in lowerdir but does not exist in upperdir.
  4. Because the file is opened for writing, OverlayFS cannot write directly to lowerdir (which is read-only).
  5. OverlayFS initiates an atomic Copy-Up:

- Reads the entire 2.0 GB file from lowerdir.

- Writes the full 2.0 GB file into the workdir scratchpad in upperdir.

- Renames the file atomically into upperdir.

  1. Once the copy-up completes, the application's 4-byte write is applied to the newly duplicated copy in upperdir.

Step 2: Volume of data transferred:

  • Read from lowerdir: 2.0 Gigabytes (2,048 MB).
  • Write to upperdir: 2.0 Gigabytes (2,048 MB).
  • Data Transferred:

Total Data Transferred = 2.0 GB read + 2.0 GB write = {4.0 Gigabytes total disk I/O}To modify 4 bytes, the system transferred 4,294,967,296 bytes!

Step 3: Latency calculation:

  • Read Latency:

Read Time = (2,048 MB) / (1,600 MB/s) = 1.28 seconds- Write Latency:

Write Time = (2,048 MB) / (800 MB/s) = 2.56 seconds- Total Stall Latency:

Total Stall Time = 1.28 s + 2.56 s = {3.84 seconds}A simple 4-byte write stalls the application thread for nearly 4 seconds!

Step 4: Architectural Remedy:

Docker Volumes or Bind Mounts:

Mount large files or directories as a dedicated external volume (docker run -v /host/data:/var/data ...). Volumes bypass OverlayFS completely, interacting directly with the native host filesystem without Copy-On-Write translation.


Problem 3: Linux User Namespace UID/GID Mapping Table Translation Trace#

Scenario: A security-hardened container platform executes rootless containers using Linux User Namespaces (CLONE_NEWUSER).

The container runtime configures the UID mapping file /proc/[pid]/uid_map for a container with the following mapping record:

text
# Container UID    Host UID    Length
0                  100000      65536

Inside the container, three processes execute with the following internal credentials:

  • Process A: Internal UID 0 (root).
  • Process B: Internal UID 1000 (developer).
  • Process C: Internal UID 70000 (special service daemon).

The physical host filesystem contains a sensitive file /etc/shadow with ownership UID: 0 and permissions 0600 (read/write only by host root).

Task:

  1. For Process A, Process B, and Process C, calculate their effective User ID (UID) on the host operating system.
  2. If Process A (running as root inside the container) attempts to read /etc/shadow on the host, determine whether the Linux kernel grants or denies access. Explain the underlying security logic.
  3. What effective UID is assigned to Process C on the host? Explain how the kernel handles UIDs outside the mapping range.

Step-by-Step Solution:

Step 1: UID Translation Calculations:

The mapping rule specifies:

  • Container UID Start = 0
  • Host UID Start = 100,000
  • Length = 65,536 (covers container UIDs 0 through 65,535).

The translation formula for any container UID U_{ctr} where 0 ≤ U_{ctr} < 65,536 is:

Host UID = Host UID Start + U_{ctr} = 100,000 + U_{ctr}- Process A (U_{ctr} = 0):

Host UID_A = 100,000 + 0 = {100,000}- Process B (U_{ctr} = 1000):

Host UID_B = 100,000 + 1,000 = {101,000}- Process C (U_{ctr} = 70000):

Since 70,000 ≥ 65,536, Process C falls outside the mapped range!

Step 2: Security Evaluation for Process A:

  • Inside the container, Process A has UID 0 and appears to be root.
  • When Process A issues a read system call against /etc/shadow, the Linux kernel evaluates the access using the process's effective Host UID.
  • On the host, Process A's UID is 100,000 (an unprivileged standard user).
  • File /etc/shadow is owned by host UID 0 with permissions 0600.
  • Because 100,000 ≠ 0, the kernel immediately DENIES access with an EACCES (Permission Denied) error.
  • Security Guarantee: Even if an attacker compromises Process A and gains root inside the container, they possess zero root privileges on the physical host system.

Step 3: Handling Process C:

When a process inside a user namespace has a UID that is not defined in /proc/[pid]/uid_map, the Linux kernel maps the unmapped UID to the overflow UID configured in /proc/sys/kernel/overflowuid (typically 65534, the nobody user). Process C executes on the host with strictly marginalized privileges.


Problem 4: Container Memory OOM-Killer Invocation vs. Page Cache Reclamation Analysis#

Scenario: A Java backend microservice runs inside a container with a cgroups v2 hard memory limit:

  • memory.max: 1,024 Megabytes (1 GB = 1,073,741,824 bytes).
  • The Java Virtual Machine (JVM) is configured with heap parameters: -Xms512m -Xmx768m.
  • The container's current memory profile:

- JVM Resident Heap Memory (Anonymous): 650 Megabytes.

- JVM Metaspace / C Runtime Threads: 150 Megabytes.

- Linux Page Cache (File-backed buffer cache from reading local logs): 200 Megabytes.

- Total Current Memory = 650 + 150 + 200 = {1,000 Megabytes} (24 MB below memory.max).

The Java application suddenly executes a memory-intensive reporting query allocating an additional 100 Megabytes of heap memory. Simultaneously, an external request reads a 50 Megabyte static asset from disk.

Task:

  1. Calculate the total requested memory and the gross deficit relative to memory.max.
  2. Detail the exact multi-stage reclamation algorithm the Linux kernel executes before resorting to the OOM killer.
  3. Can the kernel satisfy the 150 MB total memory demand without invoking the OOM killer? Show the exact memory balance.
  4. If the application continues allocating heap memory up to 900 Megabytes (while Metaspace remains at 150 MB), calculate the resulting memory state and determine whether the container survives or is terminated.

Step-by-Step Solution:

Step 1: Memory Demand and Gross Deficit:

  • Current Memory: 1,000 MB.
  • Additional Heap Request: 100 MB.
  • Additional Page Cache Request: 50 MB.
  • Total Demanded Memory: 1,000 + 100 + 50 = 1,150 MB.
  • Hard Limit (memory.max): 1,024 MB.
  • Gross Deficit: 1,150 MB - 1,024 MB = {126 MB over limit}.

Step 2: Kernel Reclamation Algorithm:

When aggregate memory attempts to exceed memory.max:

  1. The kernel does NOT immediately kill the process.
  2. The kernel invokes synchronous Direct Page Reclaim:

- It scans the container's Least Recently Used (LRU) inactive file list.

- Because file-backed page caches are clean (backed by storage disk), the kernel evicts them immediately with zero disk writeback.

- Anonymous heap memory cannot be evicted unless swap is enabled.

  1. If page cache eviction reclaims enough memory to satisfy the allocation, the request succeeds.
  2. Only if Direct Page Reclaim fails to yield sufficient memory does the kernel invoke mem_cgroup_out_of_memory() to terminate processes.

Step 3: Can the 150 MB demand be satisfied?:

  • Evictable Page Cache available: 200 Megabytes.
  • Total deficit needed: 126 Megabytes.
  • The kernel evicts 126 MB of clean page cache:

- New Page Cache size: 200 - 126 = 74 MB.

- Anonymous Heap: 650 + 100 = 750 MB.

- Metaspace / Threads: 150 MB.

  • New Total Memory:

Total Memory = 750 MB + 150 MB + 74 MB = {974 Megabytes}- Since 974 MB ≤ 1,024 MB, the allocation succeeds completely without triggering the OOM killer.

Step 4: Scenario with 900 MB Heap:

  • Anonymous Heap: 900 MB.
  • Metaspace / C Runtime: 150 MB.
  • Minimum non-evictable memory: 900 + 150 = {1,050 Megabytes}.
  • Even if the kernel evicts 100% of the page cache down to 0 MB:

Unavoidable Anonymous Usage = 1,050 MB > 1,024 MB limit- The kernel cannot evict anonymous heap memory without swap.

  • Outcome: Direct Page Reclaim yields 0 bytes. The Linux OOM killer is invoked. The JVM process receives SIGKILL, the container terminates immediately with Exit Code 137, and Kubernetes marks the pod as OOMKilled.

Key Architectural Takeaways

  • Containers share the host Linux kernel; namespaces isolate visibility, while cgroups enforce resource consumption limits.
  • The 8 Linux namespaces encompass PID, NET, MNT, IPC, UTS, USER, CGROUP, and TIME.
  • cgroups v2 CFS bandwidth throttling uses cpu.cfs_quota_us and cpu.cfs_period_us to restrict CPU core utilization precisely.
  • OverlayFS merges read-only lower layers with a writable upper layer using whiteout character devices to record file deletions.

Hands-on Code Playground Available

Execute, edit, and experiment with code for this topic in the live sandbox.

Practice Check • Quick Quiz

Test Your Understanding

Which Linux kernel feature is responsible for strictly limiting the maximum amount of CPU time and physical RAM that a containerized process tree can consume?

A
Linux Namespaces (CLONE_NEWNS)
B
Control Groups (cgroups)
C
SELinux Mandatory Access Control
D
AppArmor profiles