Cgroup Namespaces
A cgroup namespace (created by the
clone(2)/unshare(2)flagCLONE_NEWCGROUP) virtualizes one specific thing: a process’s view of its own position in the control-group hierarchy. It does not change which cgroup a process is in, nor what limits apply — it changes only what path the process sees for that cgroup. Inside a fresh cgroup namespace, the cgroup that the creating process happened to occupy becomes the apparent root, so/proc/[pid]/cgroupand the cgroupfs mount report paths relative to that root rather than the host’s absolute path. It was added in Linux 4.6 (released 2016-05-15), which “adds support for cgroup namespaces, which provides a mechanism to virtualize the view of the/proc/$PID/cgroupfile and cgroup mounts” (kernelnewbies Linux 4.6). The payoff is twofold: it hides the host cgroup layout from a container (no information leak about ancestor pathnames), and it makes cgroup delegation clean — a delegated container sees itself sitting at/, exactly as if it owned the whole hierarchy.
This note is pinned to Linux 6.12 LTS (released 2024-11-17), with the mechanism unchanged through 6.18 LTS (2025-11-30). The cgroup-namespace logic lives in kernel/cgroup/namespace.c and kernel/cgroup/cgroup.c and has been structurally stable since the feature merged. For what cgroups are and the unified-hierarchy model this virtualizes, see cgroups v2 Unified Hierarchy and Control Groups Overview; for the delegation model this namespace cleans up, see cgroup Delegation and the No Internal Process Rule. This is the narrowest and least-discussed of the eight namespace types — see Linux Namespaces Overview for the full set — but it is the one that makes a container’s cgroup view coherent.
Mental Model
The control-group hierarchy is a tree of directories under a single mount point (in cgroups v2, one unified tree mounted at /sys/fs/cgroup). Every process is a member of exactly one cgroup in that tree, and the kernel can always answer “what is task’s cgroup?” by naming a path from the tree’s true root — for example /machine.slice/docker-abc123.scope. Without a cgroup namespace, that absolute path is exactly what a process reads back from /proc/self/cgroup. The problem: a containerized process can now see the host’s entire organizational scheme. It learns that it is docker-abc123, that it lives under machine.slice, that there is a system.slice next door — naming details of the host that the container has no business knowing, and that leak across what is supposed to be an isolation boundary.
A cgroup namespace fixes this by introducing a per-namespace cgroup root. Think of it as a viewpoint shift: when a process calls unshare(CLONE_NEWCGROUP), the kernel snapshots the cgroup the process is currently in and pins it as the new namespace’s root. From then on, every path the namespace renders is computed relative to that pinned root. The process that was at /machine.slice/docker-abc123.scope now simply sees /. A child cgroup it creates, say /machine.slice/docker-abc123.scope/init, renders as /init. The host’s system.slice and the machine.slice ancestor are simply not reachable by name from inside — they are above the root and therefore invisible, exactly as a chroot hides everything above the new root directory in a filesystem.
flowchart TB subgraph host["Host view (init cgroup namespace)"] ROOT["/ (real cgroup root)"] MS["/machine.slice"] SCOPE["/machine.slice/docker-abc123.scope<br/>← container's cgroup"] INIT["/machine.slice/docker-abc123.scope/init"] SS["/system.slice"] ROOT --> MS --> SCOPE --> INIT ROOT --> SS end subgraph cont["Container view (after unshare CLONE_NEWCGROUP)"] CROOT["/ (= scope, the pinned root_cset)"] CINIT["/init"] CROOT --> CINIT end SCOPE -. "root_cset pinned at namespace creation" .-> CROOT
How a cgroup namespace reframes the hierarchy. What it shows: at creation time the kernel records the creating process’s cgroup (docker-abc123.scope) as the namespace’s root (root_cset in the source); thereafter all rendered paths are relative to it, so the scope appears as /, its child appears as /init, and everything at or above machine.slice — including the sibling system.slice — is unreachable by name. The insight to take: the process did not move and its limits did not change; only the naming origin moved, so the container sees a tidy /-rooted subtree instead of the host’s sprawl.
Mechanical Walk-through
Creation: pinning the root
The whole mechanism hinges on one saved pointer. When a process passes CLONE_NEWCGROUP to clone() or unshare(), the kernel runs copy_cgroup_ns() in kernel/cgroup/namespace.c (v6.12). If the flag is absent it just bumps the refcount of the existing namespace and returns it. If present, it does three things that matter:
struct cgroup_namespace *copy_cgroup_ns(unsigned long flags,
struct user_namespace *user_ns, struct cgroup_namespace *old_ns)
{
...
/* Allow only sysadmin to create cgroup namespace. */
if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return ERR_PTR(-EPERM);
...
spin_lock_irq(&css_set_lock);
cset = task_css_set(current); /* the creator's CURRENT cgroup set */
get_css_set(cset);
spin_unlock_irq(&css_set_lock);
new_ns = alloc_cgroup_ns();
...
new_ns->user_ns = get_user_ns(user_ns);
new_ns->root_cset = cset; /* PIN it as the namespace root */
return new_ns;
}Line by line: the ns_capable(user_ns, CAP_SYS_ADMIN) check means you need CAP_SYS_ADMIN in the owning user namespace to create a cgroup namespace — which is why rootless containers create a user namespace first, becoming root within it, and only then unshare the cgroup namespace. Then task_css_set(current) reads the creator’s current css_set — the kernel object that records which cgroup the task belongs to in each hierarchy — and new_ns->root_cset = cset stores it. That root_cset is the entire definition of the namespace’s root: a frozen reference to “the cgroup the creator was in at the instant of creation.” This matches the man page’s wording precisely: when a process creates a new namespace, “its current cgroups directories become the cgroup root directories of the new namespace” (cgroup_namespaces(7)).
Rendering: paths relative to root_cset
When anything reads /proc/[pid]/cgroup, or when the kernel formats a path for a cgroupfs lookup, it does not render from the true tree root — it renders relative to the reading process’s root_cset. The relevant helper is current_cgns_cgroup_from_root() in kernel/cgroup/cgroup.c (v6.12):
static struct cgroup *
current_cgns_cgroup_from_root(struct cgroup_root *root)
{
struct css_set *cset;
...
cset = current->nsproxy->cgroup_ns->root_cset; /* MY namespace's root */
res = __cset_cgroup_from_root(cset, root);
...
return res;
}This resolves “which cgroup is the root for the current namespace on this hierarchy,” and the path printer then computes the target cgroup’s path as a suffix below that node. The reader’s own namespace root is the reference point — note the subtle but important detail the man page stresses: paths in /proc/[pid]/cgroup are “relative to the reading process’s root directory,” not the target’s. Two processes reading the same target cgroup can therefore print different strings, because each renders against its own root.
The /../ escape hatch
What happens when the target process lives outside (above, or in a sibling subtree of) the reading process’s root? The kernel cannot render a clean /foo path because the target is not under the reader’s root. Per the man page, in that case “the pathname will show /../ entries for each ancestor level in the cgroup hierarchy” (cgroup_namespaces(7)). The documented worked example: a process whose namespace root is /sub, reading the init process’s cgroup, sees 7:freezer:/..; reading a sibling cgroup sub2, it sees 7:freezer:/../sub2. The .. is the same relative-path device a filesystem uses to express “up one level.” This is only visible to a sufficiently privileged reader who can see the target at all; a confined container reading its own processes never produces .. because they are all at or below its root.
The mount-time subtlety
There is a sharp gotcha with the cgroupfs mount itself. Creating a cgroup namespace does not retroactively reroot a cgroupfs mount that already existed in the old namespace — that mount keeps showing the old (host) perspective. To get a cgroupfs whose / is the namespace root, the process must mount cgroupfs fresh from inside the new namespace (or remount), at which point the new mount’s root is the namespace root. The man page describes exactly this: cgroupfs mounts initially reflect the original perspective, and remounting from within the new namespace corrects / to the namespace root. Container runtimes therefore always mount a fresh cgroupfs inside the container after setting up the namespace, which is why cat /sys/fs/cgroup/cgroup.controllers inside a well-built container shows the container’s own root, not the host’s.
Configuration / Code / Walk-through Example
A self-contained demonstration using unshare(1), adapted from the pattern in the man page. Assume cgroups v2 mounted at /sys/fs/cgroup:
# 1. Create a sub-cgroup and move our shell into it.
# (needs write access to the parent — i.e. a delegated subtree or root)
$ sudo mkdir /sys/fs/cgroup/sub
$ echo $$ | sudo tee /sys/fs/cgroup/sub/cgroup.procs
$ cat /proc/self/cgroup
0::/sub # we are in /sub, absolute path shown
# 2. Enter a new cgroup namespace AND a new mount namespace,
# then mount a fresh cgroup2 so the mount reflects the new root.
$ sudo unshare --cgroup --mount bash
# cat /proc/self/cgroup
0::/ # /sub now appears as the ROOT
# 3. Prove the host path is gone: mount fresh cgroupfs inside.
# mount -t cgroup2 none /sys/fs/cgroup
# ls /sys/fs/cgroup
cgroup.controllers cgroup.procs cgroup.subtree_control ...
# this IS /sub's directory, seen as /Commentary: in step 1, /proc/self/cgroup reports 0::/sub — the 0:: prefix is the cgroups-v2 hierarchy id (always 0 for the unified hierarchy), and /sub is the absolute path. After unshare --cgroup in step 2, the very same process — same actual cgroup, same limits — reads 0::/. Nothing about its resource budget changed; only the rendered path collapsed because /sub became the root. Step 3’s fresh mount -t cgroup2 is the essential bit that makes the mount (not just the proc file) reflect the new root; skipping it leaves /sys/fs/cgroup showing the host layout. Note we also unshared the mount namespace (--mount) so the fresh mount does not perturb the host’s /sys/fs/cgroup.
To inspect a cgroup namespace’s identity, follow the per-namespace symlink, exactly as for any namespace type (namespaces(7)):
$ readlink /proc/self/ns/cgroup
cgroup:[4026531835] # the inode number identifying this cgroup nsTwo processes printing the same cgroup:[N] inode share a cgroup namespace; different inodes mean different namespaces. A runtime can setns(2) into an existing cgroup namespace by opening this file and passing the fd — the cgroupns_install() handler in namespace.c enforces CAP_SYS_ADMIN in both the caller’s user namespace and the target namespace’s owning user namespace before swapping nsproxy->cgroup_ns.
Failure Modes and Common Misunderstandings
“The cgroup namespace limits my container’s resources.” No — it does nothing to limits. A cgroup namespace is purely cosmetic with respect to enforcement: it changes the rendered path and nothing else. The actual CPU/memory/IO/pids limits come from the cgroup the process is in (set via cpu.max, memory.max, etc.) and apply identically with or without the namespace. Confusing the view with the budget is the single most common error here. See The cgroup cpu Controller and The cgroup memory Controller for the things that do enforce.
Stale mount showing the host layout. The classic bug: a runtime unshares the cgroup namespace but reuses an inherited /sys/fs/cgroup mount instead of mounting a fresh one. Result — /proc/self/cgroup correctly shows /, but ls /sys/fs/cgroup still shows the host’s machine.slice, system.slice, etc., because the mount predates the namespace. Symptom: tools that read the cgroupfs directory tree (not the proc file) see host structure. Fix: mount cgroup2 fresh inside the namespace (the step-3 mount above).
Reading another process’s cgroup shows ... If a privileged tool inside a container reads the cgroup of a process that is outside the container’s cgroup subtree, it gets /../ strings. This is not corruption — it is the kernel honestly saying “the target is above your root.” It usually indicates the reader can see a process it arguably should not (e.g. a shared PID namespace without a matching cgroup-namespace boundary).
Creation needs CAP_SYS_ADMIN. copy_cgroup_ns() rejects the unshare with -EPERM if the caller lacks CAP_SYS_ADMIN in the owning user namespace. Unprivileged users hit this unless they first establish a user namespace where they hold CAP_SYS_ADMIN. This is why an unprivileged unshare --cgroup alone fails but unshare --user --map-root-user --cgroup succeeds.
Uncertain
Verify: that no behavioral change to cgroup-namespace path rendering or the
root_csetpinning was introduced between 6.12 and 6.18 LTS. Reason: the v6.12 source was read directly and the mechanism confirmed, but the 6.18 tree was not diffed line-by-line for this note. To resolve: diffkernel/cgroup/namespace.cand thecurrent_cgns_cgroup_*helpers inkernel/cgroup/cgroup.cbetweenv6.12andv6.18tags. uncertain
Alternatives and When to Choose Them
A cgroup namespace is not the only way to hide host cgroup structure, but it is the cleanest. The alternatives:
- Do nothing (no cgroup namespace). The container sees absolute host paths like
/machine.slice/docker-…. This is the legacy behavior and still works — Docker ran for years before cgroup namespaces existed. The downside is the information leak and the fact that anything inside the container that parses/proc/self/cgroup(some application runtimes, JVMs detecting limits, language runtimes) sees a host-flavored path it must special-case. cgroupns=privatevscgroupns=host(the runtime knob). Modern runtimes expose the choice directly.runc/Docker default to private (CLONE_NEWCGROUP) on a cgroups-v2 host so the container sees/;hostmode skips it, exposing the absolute path — occasionally needed for tools that must see the real hierarchy (some monitoring agents). This is a configuration of the same kernel feature, not a different mechanism.- Bind-mounting only the container’s subtree. Before cgroup namespaces, a partial substitute was to bind-mount just the container’s cgroup directory into the container’s
/sys/fs/cgroup, hiding siblings by simply not exposing them. This hides directories but does not fix the/proc/self/cgrouppath (which still shows the absolute host path), so it is strictly weaker. The cgroup namespace fixes both surfaces at once.
Choose a cgroup namespace (private mode) for essentially every container; choose host mode only when a workload genuinely needs to read or manage the real host hierarchy.
Production Notes
The combination of cgroup namespace + cgroups v2 + delegation is what makes modern rootless and nested-container setups coherent. The kernel’s own cgroups-v2 documentation describes delegation as handing a subtree to a less-privileged owner; the cgroup namespace is the matching view primitive so the delegatee sees its subtree as a complete /-rooted hierarchy and can run its own cgroup manager (e.g. a nested systemd, or a nested container runtime) without ever seeing or touching the host’s cgroups (cgroup-v2 docs). This is precisely why systemd in a container works: systemd expects to own /sys/fs/cgroup and manage a tree rooted at /; the cgroup namespace gives it exactly that illusion over a delegated subtree.
The man page lists the security motivation explicitly: cgroup namespaces “prevent information leaks whereby cgroup directory paths outside of a container would otherwise be visible to processes in the container,” and enable “better confinement of containerized processes” by preventing access to ancestor directories. The migration motivation is equally real — because the container only ever knows itself as /, it can be checkpointed and restored (or live-migrated) into a different spot in a different host’s cgroup tree without any path mismatch, since there were no absolute paths baked into its view to begin with. This dovetails with the same checkpoint/restore use case that drives Time Namespaces.
On a cgroups-v2 host, Docker and containerd/runc enable cgroupns=private by default; on the legacy cgroups-v1 hosts Docker historically defaulted to host for compatibility. As of the 6.12/6.18 era with v2 the universal default, private cgroup namespacing is the norm, and seeing a host-absolute path inside a container is now a sign of either an old runtime or a deliberate host-mode choice.
Uncertain
Verify: the specific claim that Docker defaults to
cgroupns=private“since 20.10” on v2 hosts (and defaulted tohoston v1). Reason: this is from memory and secondary knowledge — no Docker/runc primary source (changelog, docs) was fetched while writing this note. The kernel-side mechanism (private =CLONE_NEWCGROUP, host = skip it) is confirmed from source; only the runtime’s default-version history is unverified. To resolve: check the Docker Engine 20.10 release notes /docker run --cgroupnsdocs and the runc spec-conformance defaults. uncertain
See Also
- Linux Namespaces Overview — the eight namespace types; cgroup namespace is the narrowest
- cgroups v2 Unified Hierarchy — the single tree this namespace reroots the view of
- Control Groups Overview — what cgroups are and the resource-budget model
- cgroup Delegation and the No Internal Process Rule — the delegation model a cgroup namespace makes clean
- The cgroup cpu Controller / The cgroup memory Controller — what actually enforces limits (unchanged by this namespace)
- User Namespaces — why
CAP_SYS_ADMIN(and thus a user namespace) is needed to create a cgroup namespace - Mount Namespaces — paired with cgroup ns so a fresh cgroupfs can be mounted without touching the host
- Time Namespaces — sibling namespace sharing the checkpoint/restore migration motivation
- clone unshare and setns — the syscalls that create/join it
- Linux Containers and Isolation MOC — parent map (section B, Namespaces)