How Linux containers work under the hood
How Linux containers use namespaces for isolation, cgroups for resource limits, overlay filesystems for layered images, and seccomp for syscall filtering.
The Interview Question
Interviewer: "Your team runs microservices in Docker containers on Kubernetes. One container is consuming all the memory on a node and causing other containers to get OOM-killed. Walk me through how Linux actually isolates containers from each other, and why this isolation failed in your scenario."
This question tests whether you understand that containers are not virtual machines. There is no hypervisor, no separate kernel. Containers are regular Linux processes that happen to have restricted views of the system through kernel primitives. The interviewer wants to hear about namespaces, cgroups, overlay filesystems, and where the boundaries of isolation actually are. If you just say "Docker uses cgroups," you get partial credit. If you walk through the specific kernel mechanisms and explain exactly why a misconfigured memory limit caused the OOM cascade, you nail it.
What to Clarify Before Answering
You: "Before I dive in, let me clarify a few things..."
- "Are we running cgroups v1 or v2? The memory accounting model changed significantly between versions, and this affects how OOM kills propagate."
- "Are the containers running as root inside their user namespace, or are we using rootless containers?"
- "What container runtime is in use? Docker with containerd, or a different CRI runtime like CRI-O?"
- "Is there a memory limit set on the container's cgroup, or is it running without limits?"
- "Are we using any seccomp or AppArmor profiles for additional syscall filtering?"
Why this matters: "Containers" is an umbrella term that covers many different Linux primitives working together. A candidate who asks about cgroup versions and runtime choices demonstrates they understand the specific kernel mechanisms, not just the Docker CLI abstraction.
The 30-Second Answer
A Linux container is a regular process with a restricted view of the system, created using three kernel primitives. Namespaces give the process its own isolated view of PIDs, network interfaces, mount points, hostnames, and user IDs. Cgroups (control groups) limit how much CPU, memory, and I/O the process can consume. Overlay filesystems provide a layered, copy-on-write filesystem so containers can share a base image efficiently while maintaining their own writable layer. When you run docker run, the container runtime (runc) calls clone() with namespace flags, configures the cgroup limits, sets up the overlay mount, applies a seccomp-bpf filter to restrict syscalls, then exec()s your entrypoint. The entire process takes about 200-500ms. There is no hypervisor, no separate kernel. The container shares the host's kernel, which is both the source of containers' performance advantage and their security limitation.
The Architecture Overview
This architecture shows the full path from docker run to a running container process. The key insight is the layered runtime stack. Docker's daemon (dockerd) handles the user-facing API, but delegates all container lifecycle management to containerd. Containerd spawns a shim process for each container (so containers survive a containerd restart), and the shim spawns runc which does the actual Linux kernel setup.
I find this layered design important because it means "Docker" is not one thing. The OCI runtime (runc) is the part that actually creates the container using kernel primitives. Everything above it (containerd, dockerd) is orchestration and image management. Kubernetes replaces dockerd entirely and talks to containerd directly through the CRI interface.
The kernel provides the four isolation primitives shown at the bottom. Namespaces create the illusion of a separate system. Cgroups enforce resource limits. OverlayFS provides the filesystem. Seccomp-BPF restricts which kernel calls the process can make. Together, these create what we call a "container."
How docker run Works Step by Step
I want to walk through the exact sequence of events when you type docker run -d --memory=512m --cpus=1 -p 8080:80 nginx:
- Docker CLI parses the command and sends a
POST /containers/createrequest to dockerd's REST API. - dockerd validates the image reference, checks if the image is cached locally, and if not, pulls it from the registry (each layer separately, skipping layers already present).
- dockerd calls containerd via gRPC:
CreateContainer(spec)with the OCI runtime specification describing namespaces, cgroups, mounts, and security profiles. - containerd prepares the image snapshot (overlay filesystem layers) and creates a container metadata record.
- containerd spawns a containerd-shim process. The shim exists so that if containerd restarts (for an upgrade), running containers are not affected.
- The shim forks and execs runc with the OCI bundle directory (containing
config.jsonwith all the isolation settings). - runc calls
clone()with all seven namespace flags, creating a new process in isolated namespaces. - runc writes to the cgroup filesystem:
echo 536870912 > /sys/fs/cgroup/docker/.../memory.maxandecho "100000 100000" > /sys/fs/cgroup/docker/.../cpu.max. - runc mounts the overlay filesystem:
mount -t overlay overlay -o lowerdir=layer1:layer2:layer3,upperdir=writable,workdir=work /merged. - runc applies the seccomp-BPF filter using
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog). - runc sets up the network namespace: creates a veth pair, moves one end into the container's namespace, attaches the other to docker0, and configures iptables rules for port forwarding.
- runc calls
exec()to replace itself with the container's entrypoint (nginx in this case). - The shim becomes the parent of the container process, monitoring its STDIO and exit status.
The entire sequence from CLI command to running nginx process takes 200-500ms with a warm image. The majority of that time is spent on overlay mount setup and network configuration, not on the namespace or cgroup kernel calls (which are microseconds).
The Container Runtime Interface (CRI)
Kubernetes does not use Docker directly. It talks to a container runtime through the Container Runtime Interface (CRI), a gRPC API that any compliant runtime can implement.
The CRI defines three key operations for the container lifecycle:
- RunPodSandbox: Creates the shared namespaces for a pod (network, IPC, UTS). All containers in a pod share these namespaces, which is why containers in the same pod can reach each other on
localhost. - CreateContainer: Prepares the container within an existing pod sandbox (sets up overlay filesystem, cgroup).
- StartContainer: Invokes the OCI runtime (runc) to actually start the container process.
This layered architecture (kubelet β CRI β OCI runtime) means you can swap the OCI runtime without changing Kubernetes. Running kata-runtime instead of runc gives you VM-level isolation for sensitive workloads, configured per pod through a RuntimeClass.
Why this matters for Kubernetes debugging
When a pod is stuck in "ContainerCreating," the problem is somewhere in this CRI chain. Check kubelet logs first, then containerd/CRI-O logs, then look for OCI runtime errors. The error message usually tells you which layer failed: image pull failures come from containerd, namespace setup failures come from runc, and cgroup limit errors come from the kernel.
Namespaces: The Illusion of a Separate System
Linux namespaces give a process its own isolated view of a specific system resource. Each namespace type isolates one thing. A container uses all seven namespace types together to create the illusion of running on its own machine.
Here is what each namespace does:
PID namespace: The container gets its own PID number space. The first process inside the container is PID 1, even though on the host it might be PID 1350. The container cannot see or signal any host processes. This is critical because PID 1 has special responsibilities in Linux (reaping orphaned child processes), which is why you need an init process or a proper signal handler as your container entrypoint.
Network (NET) namespace: The container gets its own network stack with its own interfaces, routing table, iptables rules, and port space. The container's "eth0" is actually one end of a veth pair (virtual ethernet pair). The other end is attached to a bridge (docker0) on the host. This is how containers get network connectivity while remaining isolated.
Mount (MNT) namespace: The container gets its own mount table. It sees the overlay filesystem as its root, and cannot see the host's filesystem at all (unless you explicitly bind-mount directories in). This is the oldest namespace type, dating back to Linux 2.4.19.
UTS namespace: The container gets its own hostname and domain name. Simple, but necessary for applications that depend on hostname for configuration.
IPC namespace: The container gets its own System V IPC objects (shared memory segments, message queues, semaphores). This prevents containers from interfering with each other through shared memory.
User namespace: The container can map its internal root (UID 0) to an unprivileged user on the host. This is the foundation of rootless containers. A process that thinks it is root inside the container has no root privileges on the host kernel. This is the most security-critical namespace and the one I recommend enabling for all production workloads.
Cgroup namespace: The container sees its own cgroup hierarchy as the root. It cannot see the host's cgroup tree or other containers' resource usage. Added in Linux 4.6.
The Namespace Lifecycle
Namespaces have an important lifecycle property. A namespace exists as long as at least one process is inside it, OR a bind mount holds a reference to it. When the last process in a namespace exits (and no bind mounts exist), the namespace is destroyed and its resources are freed.
This has a practical consequence. If you create a network namespace, add iptables rules and veth pairs, then all processes exit, all of that network configuration is automatically cleaned up. This is why container cleanup is so fast compared to VM teardown: you just stop the processes and the kernel reclaims everything.
You can also enter an existing container's namespaces using nsenter:
# Enter all namespaces of container with PID 1350
nsenter --target 1350 --mount --uts --ipc --net --pid
This is exactly what docker exec does internally. It finds the container's PID on the host, calls setns() on each of the container's namespace file descriptors, then exec()s the requested command.
Namespaces do NOT isolate the kernel
All containers on a host share the same kernel. A kernel exploit from inside a container can compromise the entire host. This is the fundamental security difference between containers and VMs. If you need kernel-level isolation, use Firecracker microVMs, gVisor (which provides a userspace kernel), or Kata Containers (which run each container inside a lightweight VM).
How clone() Creates Namespaces
When runc creates a container, it calls the clone() system call with namespace flags:
clone(
child_function,
child_stack,
CLONE_NEWPID | // New PID namespace
CLONE_NEWNET | // New network namespace
CLONE_NEWNS | // New mount namespace
CLONE_NEWUTS | // New UTS namespace
CLONE_NEWIPC | // New IPC namespace
CLONE_NEWUSER | // New user namespace
CLONE_NEWCGROUP | // New cgroup namespace
SIGCHLD,
args
)
This single syscall creates a new process that lives in all seven new namespaces simultaneously. The CLONE_NEWNS flag is named NEWNS (not NEWMNT) because mount namespaces were the first namespace type in Linux, before anyone realized there would be more.
Why this matters in production
When you see a container that can access host processes or host networking, check which namespace flags were set at creation time. Running a container with --pid=host removes PID namespace isolation. Running with --network=host removes network namespace isolation. Each --flag=host removes one isolation layer.
Cgroups: Enforcing Resource Limits
Namespaces create the illusion of isolation. Cgroups create the reality of resource limits. Without cgroups, a container could consume all CPU, memory, and I/O on the host and starve every other container.
Cgroups (control groups) are a kernel mechanism that organizes processes into hierarchical groups and applies resource limits to each group. Every container gets its own cgroup with configured limits for CPU, memory, I/O, and PIDs.
Cgroups v1 vs v2
Cgroups v1 uses separate hierarchies for each resource controller (one tree for memory, another for CPU, another for I/O). This creates inconsistencies because a process can be in different groups for different resources. Cgroups v2 uses a unified hierarchy, which means a process is in exactly one group across all resources.
| Feature | Cgroups v1 | Cgroups v2 |
|---|---|---|
| Hierarchy | Separate tree per controller | Single unified tree |
| Memory accounting | Per-cgroup, no charge for kernel memory by default | Unified accounting, kernel memory charged by default |
| OOM behavior | Kills any process in the cgroup | Kills process with largest RSS in the cgroup |
| CPU | cpu.shares (relative weight) | cpu.weight (1-10000) + cpu.max (bandwidth) |
| I/O | blkio controller, limited | io controller with latency targets |
| PSI (Pressure Stall Info) | Not available | Built-in per-cgroup pressure metrics |
| Adoption | Legacy, still default on many systems | Default on Ubuntu 22.04+, Fedora 31+, RHEL 9+ |
I strongly recommend cgroups v2 for all new deployments. The unified hierarchy eliminates an entire class of resource accounting bugs, and Pressure Stall Information (PSI) gives you per-container metrics on whether the container is actually starved for CPU, memory, or I/O.
Memory Limits in Detail
When you set docker run --memory=512m, the runtime writes 536870912 (512 MB in bytes) to /sys/fs/cgroup/docker/container-id/memory.max. The kernel tracks every page allocated by processes in this cgroup.
When the cgroup exceeds its memory limit, the kernel first tries to reclaim memory by evicting reclaimable pages (page cache, dentries, inodes). If reclamation is insufficient, the OOM killer activates and kills the process with the highest oom_score_adj in the cgroup. For containers, this is almost always PID 1 (your application), which causes the container to exit.
The memory.high setting (a "soft limit") is an important alternative to memory.max. When the cgroup crosses memory.high, the kernel aggressively reclaims memory and throttles allocations, but does not OOM-kill. This gives the application time to release memory gracefully. I recommend setting memory.high at 80% of memory.max for applications that can shed load or shrink caches under pressure.
The memory limit includes page cache
The cgroup memory counter includes file-backed page cache. If your application reads a 400 MB file in a container with a 512 MB limit, the page cache alone consumes most of your budget. Use memory.stat to distinguish between RSS (actual heap), page cache, and kernel slab. The field you care about is anon (anonymous pages, your heap) vs file (page cache, reclaimable).
CPU Limits: Shares vs Quotas
CPU limiting uses two mechanisms. CPU shares (v1) or CPU weight (v2) define relative priority when there is contention. A container with weight 200 gets twice as much CPU as one with weight 100, but only when both are competing for CPU. When there is no contention, any container can use 100% of available CPU.
CPU quotas (cpu.max in v2) define an absolute bandwidth limit. cpu.max: 100000 100000 means the container can use 100ms of CPU time per 100ms period (1 full core). cpu.max: 50000 100000 means 50ms per 100ms (half a core). When the quota is exhausted, the kernel throttles the cgroup until the next period starts.
I/O Throttling
Cgroups v2's io.max controller limits the I/O bandwidth and IOPS for a cgroup. You specify limits per block device:
# Limit to 50 MB/s read, 10 MB/s write on device 8:0 (sda)
echo "8:0 rbps=52428800 wbps=10485760" > io.max
The io.latency controller is even more interesting. Instead of setting a hard bandwidth cap, you set a latency target. The kernel automatically throttles the cgroup's I/O to keep latency below the target, giving I/O bandwidth to other cgroups that need it more. This is a more sophisticated approach than hard limits because it adapts to actual contention.
PID Limits
The pids.max controller limits the number of processes (including threads) in a cgroup. This is your defense against fork bombs. Without a PID limit, a single :(){ :|:& };: inside a container can exhaust the host's PID space and crash every container on the node.
Docker does not set a PID limit by default, which I consider a significant security oversight. Always set --pids-limit in production. A value of 256-1024 is reasonable for most applications.
Overlay Filesystems: Layered Images and Copy-on-Write
Every container image is built from layers. An nginx image might have a Debian base layer, an nginx binary layer, and a configuration layer. These layers are read-only and shared across all containers using that image. When a container modifies a file, the overlay filesystem copies it to a writable layer specific to that container. This is copy-on-write (CoW).
How OverlayFS Works
OverlayFS is a union filesystem that merges multiple directory trees into a single view. It has three key directories:
- Lower directories (read-only): The image layers, stacked on top of each other. Files in higher layers override files in lower layers.
- Upper directory (writable): The container's writable layer. All modifications go here.
- Merged directory: The unified view. This is what the container sees as its root filesystem.
When the container reads a file, OverlayFS checks the upper directory first. If the file is not there, it walks down through the lower directories. When the container writes to a file that exists in a lower layer, OverlayFS copies the entire file to the upper directory first (copy-up), then applies the modification. Deleting a file creates a "whiteout" entry in the upper directory that hides the lower layer's version.
This is why Docker images are so efficient at disk usage. If you run 100 nginx containers on the same host, all 100 share the exact same base image layers. Only the writable layer (typically a few MB) is unique to each container.
Why this matters for image build performance
Each line in a Dockerfile creates a new layer. A COPY . /app command that copies your entire source tree creates a large layer that invalidates the build cache for all subsequent layers. This is why you see Dockerfiles that copy package.json first, run npm install, then copy the rest of the source. The dependency installation layer rarely changes and stays cached.
Image Content Addressing
Docker images use content-addressable storage. Each layer is identified by the SHA256 hash of its contents. When you pull an image, Docker checks which layers are already present locally by their hash. If a layer is already stored (from another image that shares it), Docker skips the download.
This means two images that share the same Debian base layer literally share the same bytes on disk. The layer is stored once and referenced by multiple image manifests.
Dockerfile Best Practices for Layer Efficiency
Understanding how layers work leads directly to Dockerfile optimization:
# Bad: copies everything before installing deps
FROM node:20
COPY . /app
RUN npm install
CMD ["node", "server.js"]
# Good: copies deps manifest first, installs, then copies source
FROM node:20
COPY package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --production
COPY . /app
CMD ["node", "server.js"]
In the good version, the npm ci layer is cached as long as package.json and package-lock.json do not change. Source code changes only rebuild the final COPY layer, saving minutes on each build.
Multi-stage builds take this further by separating the build environment from the runtime environment:
# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app
# Stage 2: Runtime (minimal image)
FROM scratch
COPY --from=builder /app /app
CMD ["/app"]
The final image contains only the compiled binary (a few MB), not the entire Go toolchain (hundreds of MB). This reduces the attack surface and image pull time dramatically.
Container Networking: Veth Pairs and Bridges
Container networking is built entirely on Linux network namespaces and virtual ethernet devices. When Docker creates a container, it creates a veth pair (virtual ethernet pair) connected like a pipe. One end goes in the container's network namespace, the other stays in the host namespace and attaches to a bridge (docker0).
Here is the packet flow when an external client reaches a container:
- The client sends a request to the host's IP on port 8080.
- The host's iptables DNAT rule rewrites the destination to 172.17.0.2:80 (the container's IP and port).
- The packet traverses the docker0 bridge to the veth pair endpoint in the host namespace.
- The packet crosses the veth pair into the container's network namespace and arrives at the container's eth0.
- The container responds. The response follows the reverse path, with iptables performing SNAT to rewrite the source address.
Container-to-Container Communication
Containers on the same host communicate through the bridge. Container A sends a packet to Container B's IP (172.17.0.3). The packet exits Container A through its veth pair, arrives at the docker0 bridge, and the bridge's forwarding table routes it to Container B's veth pair. This is a local layer-2 switch operation with no iptables NAT involved, making it fast (~10-20 ΞΌs overhead).
Containers on different hosts need an overlay network or direct routing. Docker Swarm's overlay network encapsulates container traffic in VXLAN tunnels. Kubernetes CNI plugins take various approaches:
- Calico: Uses BGP to advertise container routes to the physical network. No encapsulation overhead for same-L2 hosts.
- Cilium: Uses eBPF to replace iptables entirely, providing faster packet processing and rich network policy enforcement.
- Flannel: Uses VXLAN or host-gw for cross-host container routing. Simple but less feature-rich.
DNS Resolution Inside Containers
Docker provides an embedded DNS server at 127.0.0.11 for user-defined bridge networks. When a container resolves a hostname:
- The container's
/etc/resolv.confpoints to 127.0.0.11. - Docker's DNS server checks if the hostname matches a container name on the same network.
- If yes, it returns the container's current IP address.
- If no, it forwards the query to the host's configured DNS resolvers.
This is how docker run --name=db postgres makes "db" resolvable from other containers on the same network. The DNS records are updated automatically when containers start and stop, providing basic service discovery without external tools.
In Kubernetes, CoreDNS provides more sophisticated service discovery. Each Kubernetes Service gets a DNS record (my-service.my-namespace.svc.cluster.local), and pod DNS is configurable through the pod spec.
Seccomp-BPF: Syscall Filtering
The final isolation layer is seccomp-BPF (Secure Computing mode with Berkeley Packet Filters). This is a kernel mechanism that filters which system calls a process is allowed to make. Docker applies a default seccomp profile that blocks approximately 44 of the ~435 available Linux syscalls.
The blocked syscalls include dangerous operations like:
reboot(): Reboot the hostmount(): Mount filesystems (would break namespace isolation)swapon()/swapoff(): Manage swap (could affect host memory)init_module()/delete_module(): Load/unload kernel modulesacct(): Process accountingkexec_load(): Load a new kernel
The seccomp filter is loaded as a BPF program attached to the process. Every syscall triggers the BPF filter, which returns ALLOW, KILL, ERRNO, or TRACE. The overhead is minimal (a few nanoseconds per syscall) because BPF programs run in kernel space.
Why this matters for security
Docker's default seccomp profile is a good baseline, but mission-critical containers should use custom profiles that allow only the specific syscalls your application needs. Tools like strace or sysdig can record which syscalls your application actually uses during testing, then you generate a minimal seccomp profile from that recording. This reduces the attack surface dramatically.
Generating Custom Seccomp Profiles
The workflow for creating a minimal seccomp profile:
- Run your application with seccomp in "log" mode, which allows all syscalls but logs which ones are used.
- Exercise the application through its full test suite and normal workload.
- Collect the set of unique syscalls observed.
- Generate a JSON seccomp profile that allows only those syscalls.
- Test the profile in "warn" mode (allow but log violations) before enforcing.
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "open", "close", "fstat",
"mmap", "mprotect", "munmap", "brk",
"rt_sigaction", "rt_sigprocmask", "ioctl",
"access", "pipe", "select", "sched_yield",
"clone", "execve", "exit_group", "futex",
"epoll_create1", "epoll_ctl", "epoll_wait",
"socket", "connect", "accept", "sendto",
"recvfrom", "bind", "listen"],
"action": "SCMP_ACT_ALLOW"
}
]
}
This profile blocks everything except the listed syscalls. If your application tries to call a blocked syscall, it receives EPERM (Permission denied) rather than SIGKILL.
The Full Security Stack
Container security is not just one mechanism. It is a layered defense:
| Layer | Mechanism | What it prevents |
|---|---|---|
| 1 | User namespaces | Container root != host root |
| 2 | Seccomp-BPF | Blocks dangerous syscalls |
| 3 | AppArmor/SELinux | Mandatory access control on files, network, capabilities |
| 4 | Linux capabilities | Drop unnecessary root powers (CAP_NET_RAW, CAP_SYS_ADMIN) |
| 5 | Read-only rootfs | Prevents filesystem tampering |
| 6 | No-new-privileges | Prevents privilege escalation via setuid binaries |
I recommend enabling all six layers for production containers. Docker enables layers 2-3 by default, but user namespaces (layer 1) and the others require explicit configuration.
The minimal secure container configuration
For production, these flags together provide strong defense in depth: docker run --security-opt=no-new-privileges --read-only --cap-drop=ALL --cap-add=NET_BIND_SERVICE --user=1000:1000 my-app. This drops all root capabilities except binding to privileged ports, prevents setuid escalation, makes the filesystem read-only, and runs as an unprivileged user.
Rootless Containers
Traditional Docker runs the daemon as root, and containers start as root by default. This means a container escape gives the attacker root on the host. Rootless containers eliminate this risk by running the entire container stack (daemon, runtime, and container processes) as an unprivileged user.
Rootless mode uses user namespaces to map the container's root (UID 0) to an unprivileged host user (e.g., UID 100000). The container process believes it is root and can perform root operations (like binding to port 80 inside the namespace), but the host kernel treats it as an unprivileged user.
The main limitations of rootless mode:
- Cannot bind to host ports below 1024 (requires host root or
sysctl net.ipv4.ip_unprivileged_port_start=0) - OverlayFS may require fuse-overlayfs (native overlay needs kernel support for unprivileged mounts)
- Some cgroup operations require delegation from systemd
- Network setup uses slirp4netns or rootlesskit instead of direct veth pair creation
Despite these limitations, I consider rootless containers the future of container security. Podman runs rootless by default, and Docker supports rootless mode since version 20.10.
Container Process Signals and Lifecycle
Understanding how signals propagate into containers is essential for graceful shutdown. When you run docker stop my-container, the following happens:
- Docker sends SIGTERM to PID 1 inside the container.
- PID 1 has 10 seconds (configurable with
--stop-timeout) to shut down gracefully. - If PID 1 is still running after the timeout, Docker sends SIGKILL.
The critical detail: PID 1 in a container does not have default signal handlers. If your entrypoint is a shell script (#!/bin/bash), bash does not forward signals to child processes by default. Your application never receives SIGTERM, the timeout expires, and Docker kills it with SIGKILL, causing unclean shutdown (dropped connections, incomplete writes).
Solutions:
- Use
execin shell entrypoints:exec java -jar app.jarreplaces the shell process with the Java process, which becomes PID 1 and receives signals directly. - Use a lightweight init process like
tini(Docker's--initflag) that forwards signals to child processes and reaps zombies. - Write your application to handle SIGTERM explicitly.
Zombie Processes in Containers
PID 1 in Linux has a special responsibility: it must call wait() on orphaned child processes to reap them. If your container's PID 1 does not handle this, zombie processes accumulate and eventually exhaust the cgroup's PID limit.
This is a common problem with applications that spawn child processes (like process-based web servers). If a child process exits and PID 1 does not reap it, the child becomes a zombie, holding a PID slot but consuming no other resources. Over time, hundreds of zombies can accumulate.
The fix is simple: use docker run --init which injects tini as PID 1. Tini forwards signals to children and reaps zombies automatically, with virtually zero overhead.
Check for zombies in production containers
Run ps aux | grep Z inside your containers. If you see processes in the Z (zombie) state, your PID 1 is not reaping children. Add --init to your Docker run command or use tini in your Dockerfile: ENTRYPOINT ["tini", "--", "my-app"].
Container Image Security Scanning
Container images frequently contain vulnerable packages. The image layers are immutable, so a vulnerability in a base layer affects every container using that image. Security scanning is a critical part of the container lifecycle.
Image scanners work by:
- Extracting the installed packages from each layer (reading dpkg/rpm/apk databases)
- Matching package versions against CVE databases (NVD, OS vendor advisories)
- Reporting vulnerabilities with severity, affected package version, and fixed version
Common scanners include Trivy (open source, fast, comprehensive), Grype (Anchore's scanner), and Snyk Container. I recommend running scans in three places:
- At build time in CI: Fail the build if critical/high CVEs are found
- In the registry: Scan images on push and block deployment of vulnerable images
- In production: Continuously scan running images because new CVEs are published daily
The most effective mitigation is using minimal base images. A scratch or distroless image contains only your binary, eliminating hundreds of OS packages that could contain vulnerabilities. Compare: ubuntu:22.04 has ~100 installed packages with potential CVEs. gcr.io/distroless/static has almost none.
Image Signing and Verification
To prevent tampered images from running in production, use content trust:
- Docker Content Trust (DCT): Uses Notary to sign image tags. When enabled (
DOCKER_CONTENT_TRUST=1), Docker refuses to pull unsigned images. - cosign (Sigstore): A more modern approach that signs and verifies OCI artifacts using keyless signing with OIDC identity. Integrates with Kubernetes admission controllers (like Kyverno or OPA/Gatekeeper) to enforce signature verification at deploy time.
I recommend cosign for new deployments because it integrates with CI/CD identity (GitHub Actions OIDC) and does not require managing signing keys manually.
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Container exceeds memory.max | Kernel OOM killer terminates the highest-scoring process in the cgroup. Container exits with code 137 (SIGKILL) | Exit code 137, dmesg shows "Killed process", cgroup memory.events shows oom count | Increase memory limit, fix the memory leak, or reduce page cache pressure with memory.swap.max=0 |
| CPU throttling | Container pauses repeatedly, each period it can only run for its quota (e.g., 50ms of every 100ms) | cpu.stat shows nr_throttled and throttled_usec increasing, application latency spikes | Increase cpu.max quota or optimize CPU-bound code paths |
| Fork bomb (PID exhaustion) | Process spawns unlimited children, exhausting host PID space or cgroup PID limit | pids.current hits pids.max, new fork() calls return EAGAIN | Set --pids-limit (default is unlimited in Docker, which is dangerous) |
| Container escape via kernel exploit | Attacker gains root on host through unpatched kernel vulnerability | Host IDS, anomalous syscall patterns, unexpected processes outside container cgroups | Patch kernel, use gVisor/Kata for defense in depth, enable user namespaces |
| OverlayFS layer corruption | Container sees stale or missing files, writes fail | I/O errors in container logs, mount shows overlay in error state | Stop container, check underlying storage, rebuild image layers |
| Veth pair misconfiguration | Container has no network connectivity | ip link show inside container shows no eth0, ping fails to gateway | Recreate container, verify bridge and veth pair with ip netns list and ip link |
Performance Characteristics
| Operation | Latency | Throughput | Notes |
|---|---|---|---|
| Container start (warm image) | 200-500ms | ~10 containers/sec per host | No image pull, just namespace + cgroup + overlay setup |
| Container start (cold image pull) | 5-30s | Depends on registry bandwidth | Layer download is the bottleneck, shared layers skip download |
| Syscall overhead (seccomp) | 2-5 ns per syscall | Negligible | BPF filter runs in kernel, JIT-compiled |
| Network (veth pair) | ~10-50 ΞΌs added latency | ~8 Gbps typical | Veth pair adds one copy in kernel, bridge adds forwarding table lookup |
| OverlayFS read (cached) | Same as native | Same as native | Lower layer reads are transparent once cached |
| OverlayFS write (copy-up) | 2-10x slower for first write | Depends on file size | First write copies entire file from lower to upper, subsequent writes are native speed |
| Memory accounting (cgroups) | ~100 ns per allocation | Negligible | Kernel hooks on page allocation track cgroup membership |
How This Compares to Alternatives
| Feature | Containers (namespaces + cgroups) | Full VMs (KVM/Xen) | Firecracker microVMs | gVisor |
|---|---|---|---|---|
| Startup time | 200-500ms | 5-30s | 125ms | 150ms |
| Memory overhead | ~5-10 MB | 256 MB+ (full OS) | ~5 MB | ~15-30 MB |
| Kernel isolation | Shared kernel (weak) | Separate kernel (strong) | Separate kernel (strong) | Userspace kernel (medium) |
| Syscall overhead | Near-zero (BPF filter) | None (own kernel) | None (own kernel) | ~2x (intercepted by Sentry) |
| Image ecosystem | Docker Hub, OCI registries | AMIs, box files | OCI images (via conversion) | OCI images |
| Networking | veth pairs, bridges, CNI | virtio-net, SR-IOV | virtio-net (simplified) | netstack (userspace) |
| Best for | Microservices, CI/CD | Strong isolation workloads | Multi-tenant serverless | Untrusted code execution |
I reach for standard containers when running trusted code in controlled environments. The performance overhead is minimal and the tooling ecosystem is unmatched. I switch to Firecracker when running multi-tenant workloads where one tenant must not be able to affect another, even through kernel exploits. gVisor is my choice for running untrusted user code (like a code execution sandbox) where the syscall interception overhead is acceptable.
Interview Cheat Sheet
- When asked "what is a container?": "A container is a regular Linux process with a restricted view of the system. Namespaces isolate its view of PIDs, network, mounts, hostname, IPC, users, and cgroups. Cgroups limit its resource consumption. OverlayFS gives it a layered filesystem. There is no hypervisor or separate kernel."
- When asked about container vs VM security: "Containers share the host kernel, so a kernel exploit affects all containers. VMs have separate kernels isolated by a hypervisor. For defense in depth, use user namespaces, seccomp profiles, and AppArmor/SELinux. For true kernel isolation, use Firecracker or Kata Containers."
- When asked how docker run works: "The CLI sends a REST request to dockerd, which calls containerd via gRPC. Containerd prepares the image snapshot (overlay layers), spawns a shim process, and the shim exec runc. Runc calls clone() with namespace flags, writes to cgroup filesystem for limits, mounts the overlay, applies seccomp, and exec() the entrypoint."
- When asked about cgroups: "Cgroups organize processes into hierarchical groups with resource limits. v2 uses a unified hierarchy (one tree for all controllers). Memory limits trigger OOM killer at the cgroup boundary. CPU limits use quota-based throttling (e.g., 100ms per 100ms period for one core)."
- When asked about OOM kills in containers: "The kernel's OOM killer activates when a cgroup exceeds memory.max. It kills the process with the highest oom_score in that cgroup. Exit code 137 means SIGKILL from OOM. Check memory.events for oom count and memory.stat to distinguish RSS from page cache."
- When asked about container networking: "Each container gets its own network namespace. A veth pair connects it to a bridge on the host. Outbound traffic goes through the bridge and NAT. Inbound traffic uses iptables DNAT rules to forward host ports to container IPs."
- When asked about image layers: "Docker images use content-addressable layers. Each Dockerfile instruction creates a layer. OverlayFS merges read-only image layers with a writable container layer. Writes trigger copy-up (copy entire file to upper layer). 100 containers from the same image share all read-only layers on disk."
- When asked about syscall filtering: "Docker applies a default seccomp-BPF profile that blocks ~44 dangerous syscalls. The filter runs as a BPF program in kernel space with ~2-5ns overhead per syscall. Custom profiles should whitelist only the syscalls your app needs."
Test Your Understanding
Quick Recap
- Containers are regular Linux processes with a restricted view of the system, using kernel primitives instead of hypervisor-based isolation.
- Seven namespace types (PID, NET, MNT, UTS, IPC, USER, CGROUP) create the illusion of a separate system by giving the process isolated views of system resources.
- Cgroups enforce resource limits (CPU, memory, I/O, PIDs) and the kernel's OOM killer terminates containers that exceed their memory allocation.
- Cgroups v2 uses a unified hierarchy with better memory accounting, pressure stall information, and consistent resource management compared to v1.
- OverlayFS provides layered, copy-on-write filesystems where read-only image layers are shared across containers and each container gets its own writable upper layer.
- Container networking uses veth pairs and bridges to connect container network namespaces to the host, with iptables handling NAT and port forwarding.
- Seccomp-BPF filters block dangerous syscalls at the kernel level with minimal overhead (~2-5ns per syscall), and custom profiles should be used for production workloads.
- The fundamental security limitation of containers is that they share the host kernel. For multi-tenant isolation, use Firecracker microVMs, gVisor, or Kata Containers.
Related Concepts
- Kubernetes orchestration: Kubernetes uses the CRI (Container Runtime Interface) to manage container lifecycle through containerd or CRI-O, adding scheduling, service discovery, and self-healing on top of these Linux primitives.
- Container image registries: Docker Hub, ECR, and GCR store and distribute the OCI image layers that OverlayFS mounts as the container's root filesystem.
- Service mesh networking: Tools like Istio and Linkerd extend container networking with sidecar proxies that add mTLS, traffic shaping, and observability on top of the veth pair + bridge model.
- eBPF for container observability: eBPF programs attach to kernel tracepoints to observe container behavior (syscalls, network packets, file access) without modifying the container or adding sidecars.