DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

Every docker run invocation produces a running process in under a second. That process has its own filesystem root, its own network stack, its own PID 1, its own hostname. Kill it and nothing leaks. On the surface it looks like virtualization. It is not. There is no guest kernel, no hypervisor, no emulation. Everything running inside a container shares the same host kernel. What changes is the kernel’s view of the world for that specific group of processes.

This article works through the full stack: namespaces, cgroups, OverlayFS, the OCI spec, the containerd shim, content-addressed image storage, and the veth/bridge network model. Understanding these layers removes guesswork from production debugging and lets you reason clearly about the isolation guarantees (and non-guarantees) that containers provide.

What docker run Actually Calls

When you type docker run alpine sh, the call chain is:

  1. The Docker CLI sends an HTTP request to the Docker daemon (dockerd) via a Unix socket at /var/run/docker.sock.
  2. dockerd delegates to containerd, a lower-level daemon responsible for managing container lifecycles.
  3. containerd writes an OCI bundle (a root filesystem directory and a config.json spec file), then calls runc via a containerd shim process.
  4. runc reads config.json, makes the appropriate Linux kernel syscalls to construct namespaces and cgroups, and exec’s the container’s entrypoint.
  5. The shim process stays alive as a thin supervisor even after runc exits, keeping the container’s stdio open and reporting exit status back to containerd.

The key point: runc is a single-purpose binary. It reads a spec, makes syscalls, and exits. It does not stay running. The shim (containerd-shim-runc-v2) stays alive in its place. This architecture means containerd and dockerd can restart without killing running containers.

# Observe the actual process tree while a container is running
ps auxf | grep -A5 containerd-shim

# Output (simplified):
# containerd-shim-runc-v2 --bundle /run/containerd/io.containerd.runtime.v2.task/...
#   └─ sh   (the container's process, visible on the host with its real host PID)

Linux Namespaces: Controlling What a Process Sees

A namespace is a kernel data structure that partitions a global resource. Processes in different namespaces get different views of the same underlying resource. Docker creates six namespaces per container by default.

PID Namespace

Inside the container, the first process gets PID 1. From the host, that same process has a different PID. The container process cannot see host processes, but the host can see container processes.

import { readFileSync } from "fs";

// Read the namespace inode to confirm isolation
function pidNamespaceId(): string {
  // /proc/self/ns/pid is a magic symlink: "pid:[inode]"
  return readFileSync("/proc/self/ns/pid").toString().trim();
}

function visibleProcessCount(): number {
  const entries = readFileSync("/proc/self/mountinfo")
    .toString()
    .split("\n")
    .filter((l) => l.includes("/proc/") && l.match(/\/proc\/\d+$/));
  return entries.length;
}

console.log("PID namespace:", pidNamespaceId());
// Inside container: pid:[4026532345]  (different inode from host)
// On host:         pid:[4026531836]  (the root namespace inode)

PID 1 in a new namespace carries a sharp semantic: when it exits, the kernel sends SIGKILL to every other process in the namespace immediately. Node.js does not implement init-level signal forwarding. Running CMD ["node", "server.js"] as PID 1 means docker stop delivers SIGTERM, and if Node does not call process.exit() after draining, the daemon waits 10 seconds and then force-kills the container. The fix is a minimal init that forwards signals and reaps zombie processes:

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

Network Namespace (NET)

A new network namespace starts with only a loopback interface. Docker wires it to the host by creating a veth pair: two virtual Ethernet devices linked at the kernel level. One end stays in the host namespace and is attached to the docker0 bridge. The other is moved into the container’s network namespace and renamed to eth0.

# On the host: see the veth half in the host namespace
ip link show type veth
# veth3a4b2c1@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> ...

# From inside the container:
ip addr show eth0
# 5: eth0@if6: <BROADCAST,MULTICAST,UP,LOWER_UP>
#     inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0

# Check the bridge
ip link show docker0
# docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
#     inet 172.17.0.1/16

# Enter a container's network namespace from the host without docker exec
CONTAINER_PID=$(docker inspect --format '{{.State.Pid}}' my-app)
nsenter --target "$CONTAINER_PID" --net ip route
# default via 172.17.0.1 dev eth0

Packets from the container go through eth0, cross the veth pair, reach docker0, and then go through a NAT rule in iptables (managed by Docker) to reach the outside world. Port publishing (-p 8080:3000) adds a DNAT rule that rewrites the destination address for inbound packets before they cross the bridge.

Kubernetes uses this same veth/bridge primitive but delegates bridge setup and routing to a CNI plugin. Flannel wraps container traffic in VXLAN. Calico propagates routes via BGP. Cilium replaces the iptables NAT with eBPF programs attached directly to the veth ingress/egress hooks. The namespace boundary is identical across all of them.

Mount Namespace (MNT)

The mount namespace controls which filesystem mounts a process sees. When runc sets up a container it creates a new mount namespace, assembles the container image as an OverlayFS mount, and calls pivot_root(2) to swap the process’s filesystem root to the overlay mount point. The old root is unmounted. The process now believes it is the root of a complete Linux system.

UTS, IPC, and User Namespaces

UTS: Each container can have an independent hostname and domain name. docker run --hostname api-server sets the hostname visible to the container process without touching the host.

IPC: Isolates System V IPC objects (semaphores, message queues, shared memory segments). Two containers cannot share IPC resources unless explicitly put in the same IPC namespace (--ipc=container:<id>).

User: Maps UIDs inside the namespace to different UIDs on the host. A process running as UID 0 (root) inside the container maps to an unprivileged UID on the host. Docker does not use user namespaces by default because they complicate volume ownership semantics. Rootless Docker and Podman rely on user namespaces for security: a container escape lands as an unprivileged user on the host, not as root.

Cgroups v2: Controlling What a Process Can Consume

Namespaces answer “what can it see.” Cgroups answer “how much of the machine can it use.” Cgroups v2, the unified hierarchy default since kernel 5.2 and required by Kubernetes since 1.25, exposes resource controllers through a filesystem at /sys/fs/cgroup/. Each container gets a directory in this tree. Resource limits are set by writing to files in that directory.

import { readFileSync } from "fs";

interface CgroupStats {
  memoryCurrentBytes: number;
  memoryMaxBytes: number | "unlimited";
  oomKillCount: number;
  cpuUsageMicros: number;
  cpuThrottledMicros: number;
  cpuThrottledPct: number;
}

function readContainerCgroupStats(): CgroupStats {
  const base = "/sys/fs/cgroup"; // v2 unified hierarchy

  const memCurrent = parseInt(
    readFileSync(`${base}/memory.current`, "utf8").trim(),
    10
  );

  const memMaxRaw = readFileSync(`${base}/memory.max`, "utf8").trim();
  const memMax: number | "unlimited" =
    memMaxRaw === "max" ? "unlimited" : parseInt(memMaxRaw, 10);

  const memEvents = readFileSync(`${base}/memory.events`, "utf8");
  const oomKillLine = memEvents
    .split("\n")
    .find((l) => l.startsWith("oom_kill"));
  const oomKills = oomKillLine ? parseInt(oomKillLine.split(" ")[1], 10) : 0;

  const cpuStat = readFileSync(`${base}/cpu.stat`, "utf8");
  const parseCpu = (key: string): number => {
    const line = cpuStat.split("\n").find((l) => l.startsWith(key));
    return line ? parseInt(line.split(" ")[1], 10) : 0;
  };

  const usageMicros = parseCpu("usage_usec");
  const throttledMicros = parseCpu("throttled_usec");
  const cpuThrottledPct =
    usageMicros + throttledMicros > 0
      ? (throttledMicros / (usageMicros + throttledMicros)) * 100
      : 0;

  return {
    memoryCurrentBytes: memCurrent,
    memoryMaxBytes: memMax,
    oomKillCount: oomKills,
    cpuUsageMicros: usageMicros,
    cpuThrottledMicros: throttledMicros,
    cpuThrottledPct,
  };
}

const stats = readContainerCgroupStats();
const memMB = (stats.memoryCurrentBytes / 1024 / 1024).toFixed(1);
const limitMB =
  stats.memoryMaxBytes === "unlimited"
    ? "unlimited"
    : (stats.memoryMaxBytes / 1024 / 1024).toFixed(1) + " MB";

console.log(JSON.stringify({
  memory: `${memMB} MB / ${limitMB}`,
  oom_kills: stats.oomKillCount,
  cpu_throttle_pct: stats.cpuThrottledPct.toFixed(2),
}));

Memory: OOM Kills

When a container exceeds memory.max, the kernel’s OOM killer fires within that cgroup. It kills processes in the cgroup, not across the system. In Kubernetes this shows up as exit code 137 with reason: OOMKilled. The oom_kill counter in memory.events increments each time. Exposing this counter from your process’s /metrics endpoint gives you a proactive signal: an oom_kill_count > 0 on startup means the process was killed and restarted by the cgroup, which can indicate a slow memory leak rather than a one-time spike.

CPU: Throttling (Silent Performance Degradation)

Memory enforcement is visible: the process dies. CPU enforcement is silent: the kernel stops scheduling the process until the next quota period begins. There is no log. The application just slows down. cpu.max sets the quota in microseconds per period (200000 1000000 means 200ms of CPU time per 1-second period, i.e., 20% of one core). A container exceeding this quota is throttled. At 20-30% throttle ratio, you will see p99 latency spikes in production. At 50%+, median latency degrades noticeably.

The OCI Runtime Spec and runc

The Open Container Initiative Runtime Specification defines a portable contract: a directory containing a root filesystem and a config.json that describes everything needed to run a process in a container. Any OCI-compliant runtime reads this spec and produces a running container.

The spec’s core fields:

{
  "ociVersion": "1.0.2",
  "process": {
    "terminal": false,
    "user": { "uid": 1001, "gid": 1001 },
    "args": ["node", "dist/index.js"],
    "env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "cwd": "/app"
  },
  "root": { "path": "rootfs", "readonly": false },
  "linux": {
    "namespaces": [
      { "type": "pid" },
      { "type": "network" },
      { "type": "ipc" },
      { "type": "uts" },
      { "type": "mount" }
    ],
    "cgroupsPath": "/system.slice/containerd.service/kubepods/pod-abc123/container-xyz",
    "resources": {
      "memory": { "limit": 536870912 },
      "cpu": { "quota": 200000, "period": 1000000 }
    },
    "seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "syscalls": [...] }
  }
}

When runc create is called with this spec, it:

  1. Calls clone(2) with CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC to atomically create all namespaces.
  2. In the child, calls pivot_root(2) to swap the mount namespace’s root to rootfs/.
  3. Writes the container’s PID to the cgroup at cgroupsPath, which the kernel uses to enforce memory.limit and cpu.quota.
  4. Applies the seccomp filter and capability set before exec’ing the entrypoint.
  5. Exits. The container process is now fully isolated.

runc itself exits after start. The containerd shim (containerd-shim-runc-v2) stays alive to own the container’s stdio file descriptors and report the exit code back to containerd when the container process terminates. This design means containerd can crash and restart without sending SIGKILL to every running container.

Content-Addressable Storage: How Images Are Stored

Every image layer is identified by the SHA-256 digest of its compressed content. This is called content-addressable storage (CAS). Two images sharing a base layer (e.g., node:20-alpine) store that layer exactly once on disk, regardless of how many images reference it.

On disk, Docker’s overlay2 driver stores layers under /var/lib/docker/overlay2/. Each layer has a directory named by a hash, containing:

  • diff/: the filesystem changes for that layer (files added, modified, or whiteout-marked for deletion)
  • link: a short alias for the directory name
  • lower: a colon-separated list of parent layer aliases (defining the stack order)
import { execSync } from "child_process";

interface LayerInfo {
  diffId: string;
  shortId: string;
}

function getImageLayerStack(image: string): LayerInfo[] {
  const raw = execSync(
    `docker inspect ${image} --format '{{json .RootFS.Layers}}'`
  )
    .toString()
    .trim();

  const layers = JSON.parse(raw) as string[];

  return layers.map((diffId) => ({
    diffId,
    shortId: diffId.replace("sha256:", "").slice(0, 12),
  }));
}

const layers = getImageLayerStack("node:20-alpine");
console.log(`${layers.length} layers:`);
layers.forEach((l, i) => console.log(`  [${i}] ${l.shortId}`));

// When two images share base layers, those layers appear with identical digests
const appLayers = getImageLayerStack("my-app:latest");
const shared = appLayers.filter((l) =>
  layers.some((base) => base.diffId === l.diffId)
);
console.log(`Shared with node:20-alpine: ${shared.length} layer(s)`);

When you pull a new image, Docker checks each layer’s digest against its local store. Layers that already exist locally are not transferred. This is why adding FROM node:20-alpine to a second image in your registry causes near-instant pulls on machines that have already pulled that base.

OverlayFS: Stacking Layers into a Single Filesystem

OverlayFS (the overlay kernel module, Docker storage driver overlay2) mounts multiple directories as a unified view using four paths:

  • lowerdir: read-only image layers, colon-separated, ordered newest-to-oldest
  • upperdir: a writable directory, empty at container start, accumulates all writes
  • workdir: a scratch directory OverlayFS requires for atomic operations
  • merged: the unified view seen by the container process

Read path: check upperdir first, then walk lowerdir in order. Write path: if the file exists only in lowerdir, copy the entire file to upperdir first (copy-on-write), then apply the write. The lower layers are never mutated.

# Inspect the overlay mount for a running container
CONTAINER_ID=$(docker ps -q --filter name=api-server)
docker inspect "$CONTAINER_ID" \
  --format '{{.GraphDriver.Data.MergedDir}}'
# /var/lib/docker/overlay2/abc123xyz/merged

# The merged dir is the container's actual root filesystem on disk
# From the host you can read (and modify, carefully) any file in the container
ls /var/lib/docker/overlay2/abc123xyz/merged/app/dist/

# See the full layer stack
docker inspect "$CONTAINER_ID" \
  --format '{{.GraphDriver.Data.LowerDir}}' | tr ':' '\n'
# /var/lib/docker/overlay2/layer3/diff
# /var/lib/docker/overlay2/layer2/diff
# /var/lib/docker/overlay2/layer1/diff  (base image)

Copy-on-write has a specific performance implication: writing a 500MB file that exists in a lower layer incurs a 500MB copy to upperdir before the first byte of your write is applied. For workloads that write large files inside the container root (logs, temporary artifacts), mount a dedicated volume (-v /host/path:/container/path). Volume mounts bypass OverlayFS entirely: they are bind-mount entries in the container’s mount namespace that point directly to host filesystem paths.

The containerd Shim Architecture

The containerd shim exists to solve a specific problem: decoupling the container’s process lifecycle from the containerd daemon’s availability. Without the shim, restarting containerd would require killing all running containers.

The shim’s responsibilities:

  1. Own the container’s stdio file descriptors (stdin, stdout, stderr) so they remain available even if containerd disconnects.
  2. Forward signals from containerd to the container process.
  3. Collect the exit code when the container terminates and report it back to containerd via a gRPC call.
  4. Reap zombie child processes so they do not accumulate on the host.

There is one shim instance per container. The shim is a small binary (containerd-shim-runc-v2) that uses a Task API over a Unix socket in the container’s bundle directory. Higher-level runtimes (gVisor’s runsc, Kata Containers’ kata-agent) implement the same shim API, which is why containerd can use them as drop-in replacements for runc without changes to the upper-level orchestration.

import { execSync } from "child_process";

// Identify shim processes and their container mappings
function listShimProcesses(): Array<{ pid: number; containerId: string }> {
  const output = execSync(
    "ps aux | grep containerd-shim | grep -v grep"
  ).toString();

  return output
    .trim()
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      const parts = line.trim().split(/\s+/);
      const pid = parseInt(parts[1], 10);
      // The bundle path contains the container ID
      const bundleArg = parts.find((p) => p.includes("bundle")) ?? "";
      const containerId = bundleArg.split("/").at(-2) ?? "unknown";
      return { pid, containerId };
    });
}

listShimProcesses().forEach(({ pid, containerId }) =>
  console.log(`shim PID ${pid} -> container ${containerId.slice(0, 12)}`)
);

Networking: veth Pairs and Bridge Mode in Detail

When Docker creates a container in bridge mode (the default), the network setup sequence is:

  1. Create a veth pair: two virtual interfaces linked at the kernel level. Packets into one end emerge from the other.
  2. Move one end of the pair into the container’s network namespace.
  3. Rename that end to eth0 inside the container.
  4. Attach the host end to the docker0 bridge.
  5. Assign an IP to eth0 from the bridge subnet (default 172.17.0.0/16).
  6. Add a default route in the container pointing to the bridge IP (172.17.0.1).
  7. Add an iptables MASQUERADE rule so outbound packets are NAT’d to the host’s external interface.

Port publishing adds a DNAT rule:

# See Docker's NAT rules
iptables -t nat -L DOCKER -n --line-numbers

# DNAT for -p 8080:3000
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.3:3000

This means every container network call crosses:

  • Container process → eth0 (container namespace) → veth pair → docker0 bridge → iptables → host network interface

The iptables traversal adds latency. For high-throughput services, consider using --network=host to skip the namespace entirely (the container shares the host network stack), or using a CNI plugin that implements eBPF-based packet forwarding to bypass iptables DNAT lookup chains.

Tradeoffs: Docker, containerd, Podman, Kata, Firecracker, gVisor

RuntimeKernel SharedIsolation MechanismCold StartCPU OverheadKubernetes CRIBest Fit
Docker/containerd + runcYesNamespaces + cgroups + seccomp100-400msMinimalYes (native CRI)Standard workloads, CI, most production
Podman + runcYesSame as above + user namespaces100-400msMinimalVia CRI-ODeveloper machines, rootless environments, security-conscious deployments
gVisor (runsc)PartialIntercepts syscalls in user-space kernel200-600ms10-20% CPUYes (via shim)Untrusted code, multi-tenant platforms, sandbox execution
Kata ContainersNoLightweight VM per container (QEMU or Firecracker)1-2s5-10% vs bare VMYes (kata-shim)Regulated workloads (PCI, HIPAA), co-tenancy of untrusted payloads
Firecracker microVMsNoKVM-based microVM, stripped QEMU replacement125-250msVery lowVia Kata or customServerless function isolation (AWS Lambda model), high-density microVM workloads
Bare metal processYesNoneImmediateZeroN/AFully trusted single-tenant services on dedicated hardware

runc is the right default for the vast majority of workloads. The namespace and cgroup boundary is sufficient when your threat model assumes trusted code running on infrastructure you control.

gVisor intercepts every syscall from the container process and handles it in a user-space Go binary that implements a subset of the Linux kernel API. This means a kernel exploit inside the container process cannot directly reach the host kernel: there is a user-space boundary in the way. The tradeoff is CPU overhead (10-20%) and incomplete syscall coverage. Some applications (those using obscure syscalls or io_uring) do not run under gVisor without configuration changes.

Kata Containers boots a lightweight Linux kernel per container using QEMU or Firecracker as the hypervisor. A container escape lands inside a microVM, not on the host. The cost is startup latency (1-2s with QEMU, closer to 250ms with Firecracker backend) and slightly higher memory overhead per container. This is appropriate when you need hardware-level isolation guarantees for compliance, or when you are running untrusted workloads that you genuinely cannot sandbox at the syscall level.

Firecracker is Amazon’s open-source microVM monitor designed for high-density, low-overhead VM execution. It uses KVM but strips QEMU down to a minimal device model: virtio-net and virtio-block only, no legacy peripherals, no PCI bus emulation. The result is a VMM that starts in 125ms, uses 5MB of memory overhead per VM, and can run thousands of VMs per host. AWS Lambda and AWS Fargate are built on Firecracker. It is not a general-purpose container runtime: you need a shim layer (Kata Containers supports Firecracker as a backend) to use it with standard container tooling.

Podman uses the same runc runtime as Docker but removes the central daemon. Each container is a direct child of the calling process. There is no privileged long-running daemon to compromise. Podman enables user namespaces by default in rootless mode, which means a container escape lands as an unprivileged host user. Volume ownership requires explicit UID mapping (--userns=keep-id or explicit --uidmap), which is the main operational friction compared to Docker.

Production Considerations

Debug Namespace Membership Without docker exec

# Enter a container's namespaces without the Docker daemon
CONTAINER_PID=$(docker inspect --format '{{.State.Pid}}' my-app)

# Check what the container sees on its network stack
nsenter --target "$CONTAINER_PID" --net ip addr

# Check what mounts the container has
nsenter --target "$CONTAINER_PID" --mount cat /proc/self/mountinfo | grep overlay

# All nsenter flags to fully enter the container's namespace set
nsenter --target "$CONTAINER_PID" \
  --pid --net --mount --uts --ipc \
  -- bash

This bypasses the Docker daemon entirely. Useful when dockerd is unhealthy or when you need to enter namespaces from a tooling script that does not have Docker installed.

Image Layer Audit: Find Layer Bloat

import { execSync } from "child_process";

interface LayerSize {
  index: number;
  digest: string;
  sizeBytes: number;
  sizeMB: string;
}

function auditImageLayers(image: string): LayerSize[] {
  // docker history gives per-layer size information
  const output = execSync(
    `docker history ${image} --no-trunc --format '{{.ID}}\t{{.Size}}\t{{.CreatedBy}}'`
  ).toString();

  return output
    .trim()
    .split("\n")
    .map((line, i) => {
      const [digest, sizeRaw] = line.split("\t");
      // Parse Docker's human-readable sizes (e.g., "23.4MB", "1.2GB")
      const sizeBytes = parseDockerSize(sizeRaw ?? "0B");
      return {
        index: i,
        digest: digest?.slice(0, 12) ?? "unknown",
        sizeBytes,
        sizeMB: (sizeBytes / 1024 / 1024).toFixed(1),
      };
    })
    .filter((l) => l.sizeBytes > 0)
    .sort((a, b) => b.sizeBytes - a.sizeBytes);
}

function parseDockerSize(s: string): number {
  const units: Record<string, number> = {
    B: 1,
    kB: 1024,
    MB: 1024 * 1024,
    GB: 1024 * 1024 * 1024,
  };
  const match = s.match(/^([\d.]+)\s*(B|kB|MB|GB)$/);
  if (!match) return 0;
  return parseFloat(match[1]) * (units[match[2]] ?? 1);
}

const layers = auditImageLayers("my-app:latest");
console.log("Largest layers:");
layers.slice(0, 5).forEach((l) =>
  console.log(`  [${l.index}] ${l.digest}: ${l.sizeMB} MB`)
);

Large intermediate layers usually mean a RUN instruction created files that a later RUN deleted. The deletion creates a whiteout in the newer layer, but the original bytes are still in the earlier layer and are transferred on every pull. Combine the RUN instructions or use a multi-stage build to prevent intermediate artifacts from reaching the final image.

Verify cgroup Enforcement from Inside the Container

import { readFileSync } from "fs";

function verifyCgroupEnforcement(): void {
  const base = "/sys/fs/cgroup";

  const memMax = readFileSync(`${base}/memory.max`, "utf8").trim();
  const cpuMax = readFileSync(`${base}/cpu.max`, "utf8").trim();
  const pidsMax = readFileSync(`${base}/pids.max`, "utf8").trim();

  console.log(JSON.stringify({
    event: "cgroup_enforcement_check",
    memory_limit: memMax === "max" ? "unlimited" : `${parseInt(memMax) / 1024 / 1024} MB`,
    cpu_max: cpuMax, // e.g., "200000 1000000" = 20% of one CPU
    pids_max: pidsMax,
    cgroup_v2: true,
  }));

  if (memMax === "max") {
    console.warn("WARNING: No memory limit set. Container can consume all host memory.");
  }

  const [quota, period] = cpuMax.split(" ").map(Number);
  if (!isNaN(quota) && !isNaN(period) && period > 0) {
    const cpuPct = (quota / period) * 100;
    if (cpuPct < 10) {
      console.warn(`WARNING: CPU quota is ${cpuPct.toFixed(0)}%. High throttle risk under load.`);
    }
  }
}

verifyCgroupEnforcement();

Run this at container startup as a sanity check. In Kubernetes, pods without explicit resource limits (resources.limits.memory) get memory.max = max inside the container, meaning the cgroup enforces nothing and the node’s OOM killer (not the pod’s cgroup) decides what dies when the node runs out of memory. That is a much worse situation: the node OOM killer may kill unrelated pods on the same node.

The Mental Model

A container is a Linux process running inside a set of kernel namespaces, constrained by a cgroup hierarchy, with its filesystem root swapped to an OverlayFS mount assembled from content-addressed image layers. The containerd shim owns the process’s stdio handles and exit reporting so the daemon can restart without disrupting running containers. Networking is a pair of virtual Ethernet devices bridged at the host, with NAT rules for outbound traffic and DNAT rules for published ports.

Every production failure in this stack traces to one of these mechanisms. OOMKilled: memory.max was hit; read memory.events. CPU throttle: cpu.max quota too tight; read cpu.stat. Container takes 90 seconds to start on a new node: the image is large and cold; reduce layers or prepull. Network latency spike: iptables chain traversal under load; consider eBPF-based CNI or --network=host for latency-critical paths. Once you know which layer owns the problem, the fix is usually straightforward.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.

How Containers Actually Work: Namespaces, Cgroups, and the Linux Primitives Behind Docker
DevOps ·

How Containers Actually Work: Namespaces, Cgroups, and the Linux Primitives Behind Docker

A deep dive into the Linux kernel primitives that make containers possible: PID, network, and mount namespaces for isolation, cgroups v2 for resource enforcement, union filesystems for layered images, and how to debug resource limit failures in Kubernetes production workloads.