The Boring Infra ▍

CGROUPS V2

Everything on your box is already in a cgroup

31 July 2026 · 11 minute read

Most people meet cgroups through containers and come away thinking they're a container thing. They aren't. Every process on a modern Linux box is in a cgroup right now, including the shell you're reading this in, and something already built a careful tree of them without asking you.

Ask your shell where it lives.

$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/user@1000.service/app.slice/
   app-org.gnome.Terminal.slice/vte-spawn-e3bcaee1....scope

Six levels deep, and nobody set that up deliberately. The kernel will tell you how many of these exist.

$ grep nr_descendants /sys/fs/cgroup/cgroup.stat
nr_descendants 181

That's 181 below the root, so 182 in all. Two honest caveats before that number does any work. Most of them hold nothing, because slices and mount units are structure rather than processes. And 280 processes sit in the root cgroup outside the tree entirely, because every kernel thread lives there and can't be moved out.

Still, the shape is real and nobody drew it on purpose. That's the thing worth taking from this. The machinery is already running on your box, per service, with nothing installed and nothing configured. This isn't a feature you turn on for containers. It's the structure your machine is already organized by, and learning to read it is worth more than learning to configure it.

What a cgroup is for

A cgroup is a group of processes plus a set of knobs, arranged in a tree. Before the knobs, it accounts. Hold onto that split, because people only ever talk about the knobs, and the accounting is the half that pays rent.

The kernel documents four ways a controller can distribute a resource, and most people only know the second.

  • Limits. A ceiling it can't go past. memory.max, cpu.max. This is the one everybody means when they say cgroups.
  • Weights. Relative share when there's contention. cpu.weight. Usually the better tool, because it costs nothing when the machine is idle.
  • Protections. A floor rather than a ceiling. memory.min, memory.low. Nobody uses these and they're the answer to half the problems people reach for limits to solve.
  • Allocations. An exclusive slice of a countable thing. cpuset.cpus.

All four are useful. Only the accounting underneath is guaranteed to be there when you need it. Here's a service on this box with no memory limit set at all.

$ cd /sys/fs/cgroup/system.slice/containerd.service
$ cat memory.max memory.current
max
163532800

$ head -3 cpu.stat
usage_usec  496236921
user_usec   304889973
system_usec 191346948

No limit anywhere, and the kernel is still counting. 156 MiB of memory, and every microsecond of CPU that service has ever burned, broken out per service, on a machine where nobody installed a metrics agent. That's the free sample. The full audit is already on disk.

How free that actually is

It would be neat to say accounting is always on and costs nothing to enable. That isn't true, and the distinction is worth getting right because it's the first thing that will confuse you when you make your own cgroup.

Two things exist in every cgroup no matter what, with no controller enabled at all. cpu.stat's usage counters, and PSI in cpu.pressure, memory.pressure and io.pressure. Those really are free.

Everything else needs its controller enabled in the parent first. Make a bare cgroup with nothing turned on and memory.current doesn't read zero, it doesn't exist.

$ mkdir -p tbi/child
$ cat tbi/child/cgroup.controllers
$ ls tbi/child/memory.current
ls: cannot access 'tbi/child/memory.current': No such file or directory

So why did containerd.service have it? Because systemd turned it on. DefaultMemoryAccounting, DefaultCPUAccounting and DefaultTasksAccounting all default to yes on most distributions, which is a systemd policy choice rather than anything the kernel promises. DefaultIOAccounting defaults to no, which is why the IO column in systemd-cgtop is mostly empty. If that column is empty on your box, that's not your machine being quiet. That's a default you never looked at.

The tree you didn't know you had

cgroup v2 is a single tree rooted at /sys/fs/cgroup, and every process sits at exactly one place in it. Your shell's path from /proc/self/cgroup reads top down.

Three stacked boxes. The root, /sys/fs/cgroup, contains a slice called
                user-1000.slice, which contains a highlighted scope named vte-spawn.
One path down the tree. Your shell is at the bottom of it.

systemd built that tree, and it names things in three ways that are worth recognizing because they tell you what a directory is for.

  • .slice is a branch. It groups things and carries limits, and by systemd's own convention holds no processes. system.slice, user.slice.
  • .service is a unit systemd started. containerd.service, cron.service.
  • .scope is a unit systemd did not start. An external process asked systemd over D-Bus to make one and adopt it. Your terminal, an SSH login, and your Docker containers.

That last distinction is the useful one. Services are things systemd launched, scopes are things that registered themselves and got organized anyway. It's why containers show up in this tree at all, at least when the runtime is using the systemd cgroup driver. Under the cgroupfs driver they sit at /sys/fs/cgroup/docker/ and aren't systemd units at all, and on a Kubernetes node they're under kubepods.slice.

$ ls -d /sys/fs/cgroup/system.slice/*.scope | head -2
/sys/fs/cgroup/system.slice/docker-1f0da1d5ff11e1de...scope
/sys/fs/cgroup/system.slice/docker-3fa0d58df5a0badf...scope

One caveat on "branch" and "leaf", because it will bite you. Those are not properties of the suffix. A .service that delegates a subtree has children and no processes of its own, which makes it structurally a branch. The one this article is about to hand you, user@1000.service, is exactly that.

Run systemd-cgls to see the whole thing, and systemd-cgtop for a live top style view of it sorted by usage. Neither needs anything installed.

It is a filesystem, and that is the whole API

There's no create_cgroup() syscall. The configuration interface is a filesystem and nothing else.

$ stat -fc %T /sys/fs/cgroup
cgroup2fs

Three operations cover everything.

  • mkdir foo creates a cgroup. The kernel populates it with interface files immediately.
  • echo $PID > foo/cgroup.procs moves a process into it. A process is in exactly one cgroup, so this is a move rather than an add, and forked children inherit it. Its threads can be split up, but only inside a threaded subtree.
  • echo 100M > foo/memory.max sets a limit. The short suffixes work and the IEC spellings do not, so 100M is fine and 100MiB is Invalid argument.

The one asterisk is placement rather than configuration. Since kernel 5.7 clone3() takes a CLONE_INTO_CGROUP flag that spawns a process straight into a cgroup without going near cgroup.procs, and systemd uses it. BPF programs attached to a cgroup are the other exception, and those go through bpf(2) rather than any file. Everything else really is cat and echo, and ls is the documentation.

Controllers, and why your new cgroup has almost no files in it

The knobs come from controllers, and controllers are not automatically available everywhere. Two files govern this and mixing them up is the standard first stumble.

  • cgroup.controllers is read only. It lists what's available to this cgroup, which is whatever its parent handed down. At the root there's no parent, so it lists everything compiled in and not held by a v1 hierarchy.
  • cgroup.subtree_control is writable. It decides what this cgroup hands down to its children, and takes +name or -name.
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc dmem

$ cd /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service
$ mkdir tbi && echo '+cpu +memory' > tbi/cgroup.subtree_control

So if you make a cgroup and find it has no memory.max, the parent hasn't enabled the memory controller for its children. Nothing is broken.

The rule that will cost you an afternoon

v2 enforces something v1 didn't, and it's usually explained wrong. You'll read that "a cgroup with children can't hold processes." That isn't the rule and believing it will send you looking in the wrong place.

A cgroup cannot both contain processes and have controllers enabled in cgroup.subtree_control. Children have nothing to do with it.

It's easy to prove to yourself, and it cuts both ways.

# a cgroup WITH children, no controllers enabled
$ echo $$ > parent/cgroup.procs
# fine. children are irrelevant.

# a cgroup with NO children, one controller enabled
$ echo '+memory' > leaf/cgroup.subtree_control
$ echo $$ > leaf/cgroup.procs
bash: echo: write error: Device or resource busy

# and the reverse fails too
$ echo '+memory' > occupied/cgroup.subtree_control
bash: echo: write error: Device or resource busy

The + is not decoration. cgroup.subtree_control takes +name and -name only, and a bare memory is rejected with Invalid argument before the kernel ever evaluates the rule above. Which is its own trap, because the write fails silently enough that you'd then watch the next command succeed and conclude the constraint isn't real.

The reasoning is that a cgroup can't sensibly compete with its own children for a resource, so the kernel makes you choose. Distribute at the branches, run processes at the leaves.

Two exemptions. The root cgroup, which holds 280 processes and has every controller enabled at once. And threaded subtrees, which exist so a single process's threads can be split across cgroups. Worth knowing that a threaded subtree only gets the threaded controllers, meaning cpu, cpuset, pids and perf_event. memory and io are domain controllers and simply aren't available in there.

The knobs worth knowing

There are a lot of files. These are the ones that earn their place.

  • cpu.max is a quota and a period in microseconds. 50000 100000 is half a core. cpu.weight is a relative share instead, default 100, and it's usually the better choice because it only bites under contention rather than capping you when the machine is idle.
  • memory.max is the hard ceiling. memory.high throttles hard instead of killing, though under pressure it can still be breached. memory.min is a hard reservation the kernel will OOM rather than reclaim, and memory.low is the best effort version.
  • io.max limits bytes and IOPS per device, keyed by major:minor.
  • pids.max caps how many tasks exist in the cgroup. Tasks, not processes, so threads count. It's the fork bomb defence and also the reason a JVM with a large thread pool can hit it without forking anything.
  • cpuset.cpus pins the cgroup to specific cores.

Read before you write

Most of the value here is diagnostic, not configuration. These are the files that answer most questions.

  • memory.current and cpu.stat are what it's using now. Neither exists on the root cgroup, only below it.
  • memory.peak is the high water mark. Unlike a metrics graph it isn't sampled, so it catches spikes that happened between two scrapes. Since kernel 6.12 it's writable, and any write resets it, which makes it usable as a marker around a test rather than only a lifetime maximum.
  • memory.events counts threshold crossings. high counts throttling, and max counts times usage was about to cross the ceiling. Note it's hierarchical and rolls up descendants, so when you want this cgroup's own numbers you want memory.events.local.
  • memory.pressure, cpu.pressure and io.pressure give you PSI, which is stall time rather than usage. It rises before anything dies, and it's there whether or not any controller is enabled.

None of this needs an agent. The agent you'd install reads these same files. When you're trying to understand one process on one box, skip the middleman.

Trying it without root

You don't need to be root, because systemd delegates a subtree to your user manager. It's a privileged helper doing that for you, but the result is a corner of the tree you own outright.

$ cd /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service
$ cat cgroup.controllers
cpu memory pids

Read that path carefully, because it's user@1000.service and not your session. If you SSH in you land in a session-N.scope, which is root owned, and mkdir there gets you Permission denied. The delegated subtree is the user manager's, so that's where you have to be.

Three controllers, enough to do something real. io and cpuset are absent, so the io.max and cpuset.cpus bullets above need root or a delegation that includes them. pids is there, so the fork bomb experiment is available to you.

The boring conclusion

cgroups landed in 2008, five years before Docker existed. They aren't part of it, and they're running on your laptop right now holding a tree of 182 groups you never configured.

The thing worth taking away isn't how to set a limit. It's that the accounting is already switched on, per service, on every systemd box you have, and a directory listing will tell you what a machine is really spending its memory and CPU on before you install a single agent.

Where this goes next is how cgroups and namespaces compose into a container, and then what a memory limit actually does when you hit it.

What's the thing you assumed needed a tool, and turned out to be a file?