A container on a plain server has no scheduler deciding its fair share, no namespace budgeting its neighbors, and nothing between an application bug and a host-level outage except the limits the administrator writes down in advance. Cgroups v2 are the kernel mechanism for writing those limits, and on every current distribution they are the native layer underneath systemd, Podman, Docker and every other process manager on the box. This article works the mechanism from the metal upward: what the unified hierarchy changed, how memory limits behave at their edge, the two different CPU limit models, the systemd interface that makes daily work one command, and the observation loop that tells you whether the numbers you chose are the numbers the workload needed.

The unified hierarchy and what v2 changed

The v1 world split resource control across parallel trees, one per controller, each with its own path under /sys/fs/cgroup, its own rules and its own failure modes, and the v2 redesign collapsed that sprawl into a single tree where every cgroup has one set of resource files. The change is visible with one command:

mount | grep cgroup cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)

A single cgroup2 mount means the unified hierarchy is active, which is the default on RHEL 9, Debian-family current releases and every distribution young enough to ship systemd without v1 baggage. The practical consequences of the redesign are three. All controllers see the same process membership, so a limit and its monitoring attach to the same object. Process accounting is centralized, one cpu.stat per group instead of scattered per-controller files. And the delegation story works, a subtree can be handed to an ordinary user, the mechanism that rootless containers ride.

The v2 layout reads as files with plain values. A cgroup under /sys/fs/cgroup/<path>/ carries memory.max for the hard memory ceiling, cpu.max for the CPU quota, pids.max for the process count, io.max for storage limits, and memory.current with cpu.stat for the live readings. The file interface is the ground truth, and everything comfortable in daily work, systemd, the runtimes, the monitoring agents, is a writer and reader of those same files.

Memory limits and the behavior at the edge

The memory controller offers two ceilings with different personalities, and choosing between them is the first policy decision. The hard limit, memory.max, is the wall:

echo 2G > /sys/fs/cgroup/myapp/memory.max

When the group's total memory, anonymous pages, file caches, everything charged to the group, reaches the wall, the kernel reclaims what it can, and when reclamation fails, the out-of-memory killer selects a process inside the group and ends it. The hard limit is the outage preventer: a runaway container can consume its own budget and no more, and the host keeps its remaining memory for everything else. The companion file, memory.swap.max, bounds the swap the group may use, and setting it to zero makes the hard limit the true ceiling, the standard production choice for latency-sensitive services.

The soft limit, memory.high, is the throttle instead of the wall. Groups crossing the soft limit experience reclaimed allocations and slowing pages rather than kills, which makes it the tuning tool for workloads that degrade gracefully, caches that can shrink, request handlers that can slow, and the production pattern of pairing the two, a soft ceiling where the workload adapts and a hard ceiling where the host is protected, is the shape that handles both the well-behaved and the runaway in one configuration.

The observation files close the memory loop. memory.current is the live charge, and memory.events is the honest ledger, its counters naming how many times the group was throttled, how many reclaim attempts failed, and how many oom_kill events occurred. A container with a correct memory budget shows zeros in the oom column; a container with an undersized budget shows oom_kill climbing and the investigation writes itself.

The two CPU limit models, quota and weight

The CPU controller carries two mechanisms that answer different questions, and conflating them is the classic misconfiguration. The quota, cpu.max, is the absolute cap:

echo "200000 100000" > /sys/fs/cgroup/myapp/cpu.max

The format reads quota then period, in microseconds, and the example grants two hundred milliseconds of CPU per hundred-millisecond period, two full CPUs of compute. Below the cap, the group runs freely; at the cap, the group is throttled for the remainder of each period, and cpu.stat's nr_throttled counter records every occurrence. The quota is the mechanism behind the familiar --cpus=2 of the container runtimes, the guarantee that a container never exceeds its declared share regardless of host idleness.

The weight, cpu.weight, is the relative share for the opposite situation: the host is contended, and the kernel must decide who yields. Weights range from 1 to 10000 with a default of 100, and two contending groups with weights 200 and 100 split scarce CPU two to one. The weight answers the fairness question, the quota answers the containment question, and the production pattern for a plain server is quota per container as the fence and weight as the tiebreaker between equals when the fence is never reached.

The systemd interface, one command for daily limits

On a systemd host, the raw files are the substrate, and the working interface is the service manager, which maps its own vocabulary onto the cgroup knobs and applies them at every service start. The one-command form for a running service:

systemctl set-property myapp.service MemoryMax=2G CPUQuota=200%

changes both limits on the live service, and the same command without --runtime writes the values into the service's persistent configuration under /etc/systemd/system.control, surviving reboots without touching the unit file. The property names map one to one, MemoryMax to memory.max, CPUQuota to the cpu.max quota, and the family extends to MemoryHigh for the soft ceiling, TasksMax for the process count and IOWeight for the storage scheduler.

Units can also declare their budgets in the unit file itself, the declarative version of the same settings:

[Service]
MemoryMax=2G
MemoryHigh=1800M
CPUQuota=200%
MemoryAccounting=yes

The accounting lines matter as the enabling flags on distributions where they default to off, and their absence is the silent reason a MemoryMax appears not to work on some stock systems. The slice hierarchy above the service composes the design: every service sits inside system.slice, custom slices group services into budget pools, and a MemoryMax on the slice bounds a whole application tier, while the per-service limits stay the fine print below it.

Container runtimes and the knobs that surface cgroups

Every container runtime exposes the same two controllers through its own flags, and the flags are worth reading as cgroup statements. Podman and Docker share the grammar:

podman run --memory=2g --memory-swap=2g --cpus=2 --pids-limit=200 app:1.4

where --memory becomes the group's memory.max, --cpus becomes the quota, the swap flag with the same value as memory closes the swap ceiling to zero, and the pids limit sets the process-count wall. Under the hood, the runtime creates a cgroup for the container inside its own slice, system.slice/runtime.scope on the rootful path, and the container's group inherits the slice budgets while carrying its own per-container limits.

The quadlet pattern from the previous article integrates budgets natively, because the .container unit accepts the same limits in systemd syntax, and a quadlet-declared container with MemoryMax and CPUQuota gets its cgroup written by the service manager rather than by the runtime, one declarative file expressing the image, the command and the budget together. For rootless containers, the delegation from the v2 design carries the same limits into the user's delegated subtree, with the cgroup ceiling the user can grant themselves bounded by the slice above, which is how a multi-tenant host keeps every tenant's containers inside their floor without any central authority.

Observing, alerting and tuning the numbers

Limits without observation are guesses, and the observation loop is what converts the first week's estimates into the steady-state numbers. systemd-cgtop shows every group live, memory and CPU per service and per container, the one-screen answer to who is consuming what. The cgroup files feed monitoring directly: memory.current scraped on an interval becomes a time series, memory.events counters become alert conditions, oom_kill above zero pages the on-call, and nr_throttled climbing on a latency-sensitive service is the signal that the quota was set below the workload's real need.

The tuning discipline that closes the article is a measured cadence rather than a one-time ceremony. Watch a workload through its real week, note the peaks in memory.current and the throttle counters, set the soft ceiling just above the observed peak and the hard ceiling with headroom above that, and revisit after every application release, because the release that doubles the working set is the release that turns a well-budgeted container into a throttled one. The whole practice compresses to one sentence: containers on plain servers get their budgets from the same kernel layer that orchestrators use, written by the same systemd that starts them, verified by the same files that everything else reads.
## The pids limit and the process-count wall

The least glamorous controller prevents the most embarrassing outage, the fork bomb that eats process table space until the host cannot even spawn a shell to fix itself. The v2 controller exposes it as one file:

echo 200 > /sys/fs/cgroup/myapp/pids.max

and the number means exactly what it says: the group may hold two hundred processes and threads combined, and the two-hundred-first fork fails with a resource error inside the container while the host remains fully responsive. The container runtimes surface the same wall as --pids-limit, systemd as TasksMax, and the recommended production value comes from observation rather than folklore: count the workload's real process count through its busy week, double it for headroom, and round to a number the runbook can defend. A hundred for a simple service, two hundred for a mid-tier application, a thousand for the one JVM that lives its own life, and the fork bomb dies at the wall with its parent still debuggable.

The controller also polices the slow leak, the application that gains a thread a day through a subtle bug, because the pids ceiling converts the leak into a visible error in the application's own logs weeks before it would have become a host-level memory problem, and the memory.events ledger frequently names the same group in both counters, the leak that hit both walls being the leak that was caught twice.

The I/O controller and storage fairness

Storage is the third shared resource, and v2 moved its control into the same unified tree with io.max as the absolute knob:

echo "259:0 rbps=104857600 wiops=1000" > /sys/fs/cgroup/myapp/io.max

reading as device then read-bytes-per-second and write-operations limits, the format a statement of exactly what the group may ask of that disk. The heavier daily knob on most setups is io.weight, the relative share under contention, the same fairness semantics as cpu.weight, and the two together answer the noisy-neighbor question for the workload whose bottleneck is the disk: a backup process that would otherwise saturate the array gets a weight, the latency-sensitive service gets a higher one, and the scheduler divides the disk without anyone paging through manual nice values.

The honest caveat belongs in the same section: the I/O controller speaks to the block layer, and the effectiveness depends on the storage path. Direct-attached devices and most cloud volumes honor the limits through their queues, while some network filesystems and exotic storage stacks sit above the layer the controller reaches, and the verification is empirical, run the workload, watch the counters in io.stat, and trust the observation over the specification. The controller earns its keep in exactly the environments this article's fleet lives in, containers on plain servers with direct storage, and the file ceiling completes the resource picture: memory, CPU, processes and disk, four walls around every workload, each one file.

A closing example that assembles everything

A three-container tier on one server, budgeted end to end, reads as four commands per container and one slice above them, and the shape is what a plain-server fleet inherits when the orchestrator is absent but the discipline is not. The slice carries the tier's total budget, MemoryMax=12G on the slice unit, the web container takes four with two CPUs, the worker takes six with four, the log shipper takes two with one, each with a pids wall and a swap ceiling of zero, and the tier survives its worst day, the runaway worker, the leaky shipper, the fork-bombed anything, because every failure hits its own wall and the slice keeps the sum inside the host. The configuration is ten lines per service, written in the unit files, versioned with the deployment, and read back by anyone in thirty seconds, which is the whole argument for doing resource control at the layer that was always there.