Namespaces and cgroups as Container Building Blocks
A Linux container is not a kernel object — there is no
struct containeranywhere in the source tree. What we call a container is the deliberate composition of two orthogonal kernel facilities applied to a process. Namespaces partition visibility: they give a process a private view of one global kernel resource — its own process-ID tree, its own mount table, its own network stack — so that what the process can see is a restricted slice of the whole. Control groups (cgroups) partition consumption: they give a tree of processes a bounded budget of one physical resource — so much CPU time, so much memory, so many bytes of disk I/O per second, so many processes — so that what the process can use is capped and accounted. These two axes are independent: a namespace changes the map, a cgroup changes the meter, and neither implies the other (namespaces(7); cgroups(7)). This note is about the duality itself — why visibility and consumption are separate problems, how each is independently useful, and how composing them yields the thing we call a container. The full four-part recipe (rootfs and confinement on top of these two) lives in What Is a Linux Container.
Mental Model — Two Independent Axes
The single most important idea is that “namespace” and “cgroup” answer two different questions about a process, and the answers do not constrain each other. Namespaces answer “what does this process see?” — they virtualize a global namespace of identifiers (PIDs, mount points, network interfaces, hostnames) so that the process’s view is a private instance rather than the shared system-wide one. cgroups answer “how much can this process consume?” — they attach a resource controller to a group of tasks and enforce limits and accounting on a measurable quantity (CPU nanoseconds, resident bytes, I/O operations). The first is about identity and reachability; the second is about quantity and rate.
Because the axes are independent, every combination is a real, useful, supported kernel configuration:
quadrantChart title Visibility (namespaces) x Consumption (cgroups) are orthogonal x-axis "No private view (host namespaces)" --> "Private view (own namespaces)" y-axis "No budget (unbounded)" --> "Bounded budget (cgroup limits)" quadrant-1 "CONTAINER: own view + capped" quadrant-2 "systemd service: host view, capped" quadrant-3 "ordinary process" quadrant-4 "unshare -n: own netns, uncapped"
The two-by-two of container primitives. What it shows: the horizontal axis is namespaces (does the process get a private view?), the vertical axis is cgroups (does the process get a resource budget?), and all four quadrants are legitimate. The insight to take: “container” occupies only the top-right corner — private view and bounded budget — but the other three corners are everyday Linux. A plain process (bottom-left) has neither. A systemd service with MemoryMax= set (top-left) is budgeted but sees the host’s PID tree and mounts. unshare -n (bottom-right) gives a brand-new network namespace with zero resource limits. None of these is “more correct”; they are points on the isolation spectrum reached by turning the two knobs independently.
The practical consequence: you never need a container runtime to use either primitive. The unshare(1) command-line tool creates namespaces directly, and writing to files under /sys/fs/cgroup creates and configures cgroups directly. A container runtime (runc, crun) is just a program that turns both knobs (plus rootfs and confinement) in one coordinated sequence according to an OCI config. Understanding the two primitives separately is the key to understanding that the runtime is doing nothing magic — it is scripting kernel facilities you can drive by hand.
The Visibility Half — Namespaces Virtualize a Global Namespace
A namespace, in the precise kernel sense, wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of that resource (namespaces(7)). The word “namespace” is literal: many kernel resources are organized as a flat global namespace of identifiers — every process has a unique PID drawn from one system-wide pool, every mount point lives in one system-wide mount table, every network interface has a name unique across the machine. A namespace forks one of those identifier pools so that the same identifier can mean different things in different namespaces.
There are eight namespace types in the modern kernel, each forking exactly one global resource (namespaces(7)); the full catalog — every type, its flag, the resource it isolates, and the version it landed in — is the job of Linux Namespaces Overview, and this note deliberately does not reproduce it. What matters here is the shape they all share. Take two representative examples that show why “visibility” is the right word.
The PID namespace forks the process-ID pool. Inside a fresh PID namespace, the first process is PID 1 — the namespace’s init — and processes inside cannot see, signal, or ptrace processes outside, because those processes simply have no PID in this namespace’s numbering. PID 1 in a namespace carries the same special duties as system init: it reaps orphaned children, and the kernel gives it signal protection — “only signals for which the ‘init’ process has established a signal handler can be sent to the ‘init’ process by other members of the PID namespace”, while SIGKILL/SIGSTOP from an ancestor namespace are still forcibly delivered (pid_namespaces(7)). This is purely about what is visible and addressable — the PID namespace does not give the process more or less CPU; it changes the set of processes that exist as far as this process is concerned.
The network namespace forks the entire network stack: its own loopback device, its own set of interfaces, its own routing table, its own firewall rules, its own socket bindings. Two processes in two different network namespaces can both bind 0.0.0.0:80 without conflict, because each “all addresses, port 80” refers to a different private stack. Again this is visibility: the namespace does not throttle bandwidth (that would be a cgroup io-style concern); it controls which network stack the process is wired into.
Crucially, a namespace alone caps nothing. A process in a brand-new network namespace with a private stack can still spawn ten thousand threads and exhaust the host’s CPU, because nothing about what it sees limits what it consumes. Visibility and consumption have not yet met.
The Consumption Half — cgroups Meter a Physical Resource
A control group is the dual facility: a Linux kernel feature which allow processes to be organized into hierarchical groups whose usage of various types of resources can then be limited and monitored (cgroups(7)). Where a namespace forks an identifier pool, a cgroup attaches a controller — a piece of accounting-and-enforcement machinery — to a group of tasks and regulates a measurable physical quantity. The kernel’s own framing: cgroups v2 is “a mechanism to organize processes hierarchically and distribute system resources along the hierarchy in a controlled and configurable manner” (cgroup-v2 docs).
The full controller catalog and the v1-to-v2 history are owned by Control Groups Overview and cgroups v2 Unified Hierarchy; this note uses just two controllers as representatives of “consumption,” pinned to Linux 6.12 LTS.
The cpu controller meters CPU time two ways. cpu.weight (default 100, range 1–10000) sets proportional share: under contention, a cgroup with weight 200 gets twice the CPU of a sibling with weight 100. cpu.max sets an absolute bandwidth ceiling in the format $MAX $PERIOD, defaulting to max 100000 — meaning “up to $MAX microseconds of CPU every 100 000-microsecond (100 ms) period.” Writing 50000 100000 caps the group at half of one CPU regardless of how idle the machine is (per the v6.12 cgroup-v2.rst). This is consumption: it says nothing about which processes the group can see, only how many CPU-nanoseconds it may burn.
The memory controller meters resident memory. memory.max (default max) is the hard limit — exceed it and the cgroup’s OOM killer fires inside the group. memory.high (default max) is the soft throttle: “going over the high limit never invokes the OOM killer and under extreme conditions the limit may be breached,” instead the kernel puts the group “under heavy reclaim pressure” and throttles its allocations (v6.12 cgroup-v2.rst). The distinction — kill versus slow down — is exactly the kind of quantity control that has no analogue in the visibility world.
A cgroup alone changes no visibility. A process capped at half a CPU and 256 MiB still sees the host’s entire PID tree, the host’s mount table, the host’s network interfaces. It is budgeted but not isolated. Consumption and visibility have not yet met.
Composing the Two — Where “Container” Emerges
Put the two halves on the same process tree and you have the kernel substrate of a container. The runtime, reading an OCI config.json, performs roughly this sequence (rootfs and confinement, the other two recipe parts, are elided here and covered in What Is a Linux Container):
- Create namespaces — call
clone(2)orunshare(2)with the relevantCLONE_NEW*flags so the new process gets a private view: a new PID namespace (it becomes PID 1), a new mount namespace, a new network namespace, and so on. The flags themselves are constants in the kernel uapi header — e.g.CLONE_NEWPIDis0x20000000,CLONE_NEWNETis0x40000000,CLONE_NEWNS(mount) is0x00020000(v6.12 sched.h). - Place the process in a cgroup — create a directory under the cgroup v2 hierarchy, write
cpu.max,memory.max,pids.max, then write the process’s PID into the group’scgroup.procs. From that moment the process and all its descendants are metered and capped. execthe entrypoint.
The result satisfies both axes at once: the process sees only its own PID tree, mounts, and network stack (visibility, from namespaces) and can consume only its allotted CPU, memory, and process count (consumption, from cgroups). That conjunction — private view AND bounded budget — is the top-right quadrant of the mental-model diagram, and it is what people mean by “container.”
The orthogonality also explains why containers are reference-counted and composable independently. A namespace is kept alive as long as any process is a member or a file descriptor in /proc/[pid]/ns/ is held open; a cgroup persists as a directory until removed. You can join an existing namespace with setns(2) without changing the joiner’s cgroup, and you can move a process between cgroups without changing its namespaces. This is exactly how a Kubernetes “sidecar” container joins a pod’s network namespace (shared visibility) while keeping its own cgroup limits (independent consumption) — the two knobs are turned separately. See clone unshare and setns for the lifecycle syscalls.
Driving the Primitives by Hand — Worked Examples
Because nothing here requires a runtime, you can demonstrate each axis in isolation from a shell. These examples make the duality concrete.
A namespace with no cgroup (visibility only). The unshare(1) tool creates namespaces and runs a program inside them:
# New network namespace: a private stack with only a (down) loopback device.
unshare --net ip addr
# 1: lo: <LOOPBACK> mtu 65536 ... <- ONLY loopback; the host's eth0 is invisible
# No CPU or memory limit was applied — this shell can still fork-bomb the host.Line 1 asks the kernel (via CLONE_NEWNET) for a fresh network namespace and runs ip addr inside it. The output shows only loopback because the new stack starts empty — the host’s real interfaces have no presence in this namespace. This is pure visibility partitioning: the process’s map of the network changed; its budget did not.
A cgroup with no namespace (consumption only). Driving cgroup v2 directly via its filesystem:
# Create a group, cap it at half a CPU and 128 MiB, then move this shell in.
mkdir /sys/fs/cgroup/demo
echo '50000 100000' > /sys/fs/cgroup/demo/cpu.max # 50ms per 100ms = 0.5 CPU
echo 134217728 > /sys/fs/cgroup/demo/memory.max # 128 MiB hard limit
echo $$ > /sys/fs/cgroup/demo/cgroup.procs # this shell + future childrenmkdir creates the group (cgroup v2 is a real filesystem; the directory is the group). The cpu.max write caps absolute CPU bandwidth to half a core; the memory.max write sets a 128-MiB hard ceiling whose breach triggers the in-group OOM killer; writing $$ (this shell’s PID) into cgroup.procs enrolls it. From now on the shell is metered and capped — but it still sees the host’s full PID tree and mounts. Pure consumption partitioning: the meter changed; the map did not.
Both together (the container substrate). Compose them and you have isolation plus budget:
# Private PID + mount + network view, AND a CPU/memory budget.
mkdir /sys/fs/cgroup/box
echo '100000 100000' > /sys/fs/cgroup/box/cpu.max # 1.0 CPU
echo 268435456 > /sys/fs/cgroup/box/memory.max # 256 MiB
unshare --pid --mount --net --fork --mount-proc \
sh -c 'echo $$ > /sys/fs/cgroup/box/cgroup.procs; exec sh'unshare --pid --fork --mount-proc puts the new shell in a fresh PID namespace as PID 1 with a /proc that reflects only the namespace’s processes; --mount and --net add private mount and network views; the inner echo $$ > .../cgroup.procs enrolls it in the pre-built cgroup before exec. The shell now has both a private view and a hard budget — the kernel-level definition of a container, minus rootfs and confinement.
Common Misunderstandings
“A namespace limits resources.” No — a namespace only changes visibility. A process alone in a new PID and network namespace can still consume every CPU and all of RAM. Resource limiting is exclusively the cgroup’s job. Conflating the two is the single most common error; the orthogonality is the whole point of this note.
“A cgroup isolates a process.” No — a cgroup only meters and caps. A process in its own cgroup with no namespaces sees, and can interact with, every other process on the host (it can kill them, read their /proc, share their mounts). Isolation is exclusively the namespace’s job.
“cgroups v2 added namespaces” / “namespaces and cgroups are the same subsystem.” They are entirely separate kernel subsystems with separate maintainers, separate syscalls, and separate user interfaces. cgroups are configured through a filesystem (/sys/fs/cgroup); namespaces are created through clone/unshare/setns and inspected through /proc/[pid]/ns/. The cgroup namespace (CLONE_NEWCGROUP) is a genuine point of confusion: it is a namespace that virtualizes the view of the cgroup hierarchy root — i.e. it applies the visibility primitive to the consumption subsystem’s directory tree, so a container sees its own cgroup as / rather than its true host path. It is still a namespace (visibility), not a controller (consumption).
“Isolation equals security.” Namespaces plus cgroups partition resources but the process still runs against the host kernel’s full system-call surface. Real confinement needs the security primitives — dropped capabilities, a seccomp filter, an LSM profile — which are a separate concern entirely (see Containers vs Virtual Machines for why sharing the host kernel is the central security trade-off, and Linux Security MOC for the confinement primitives).
Why the Split Exists — Design Rationale
The separation is not accidental; it reflects that visibility and consumption are genuinely different engineering problems solved by different machinery. Visibility is a naming problem — fork an identifier pool so the same name resolves differently per namespace — and it is implemented by giving each task_struct a set of namespace pointers consulted whenever the kernel translates an identifier. Consumption is an accounting-and-scheduling problem — measure a physical quantity and throttle or reject when a budget is exceeded — and it is implemented by hooking the scheduler, the page allocator, and the block layer to charge each operation to a cgroup.
This independence is what makes the isolation spectrum possible. You can have a fully namespaced process with no cgroup (common in quick unshare experiments), a fully cgrouped process with no namespaces (a classic systemd service with MemoryMax=/CPUQuota=), or any blend. Orchestrators exploit this freely: a Kubernetes pod’s containers share some namespaces (network, IPC) for co-locality while keeping separate cgroups for independent resource accounting — only possible because the two knobs turn independently. The runtime’s job is simply to set both knobs (and rootfs and confinement) coherently from one declarative spec.
See Also
- What Is a Linux Container — the full four-part composition (these two primitives plus rootfs and confinement); read it next
- Linux Namespaces Overview — the catalog of all eight namespace types, flags, and versions (the visibility half in full)
- Control Groups Overview — the catalog of cgroup v2 controllers (the consumption half in full)
- cgroups v2 Unified Hierarchy — why v2 replaced v1’s per-controller hierarchies
- The Container Isolation Spectrum — the continuum the two-by-two diagram lives on
- Containers vs Virtual Machines — why “shared host kernel” (the cost of this composition) is the central security trade-off
- clone unshare and setns — the three syscalls that create, detach, and join namespaces
- runc · crun — the runtimes that turn both knobs from an OCI spec
- Linux Containers and Isolation MOC — parent map (section A, “The Big Idea — Isolation as Composition”)