cgroups v2 Controllers in Detail: Limiting CPU, Memory, and I/O Precisely
AI generated
$
/etc
Linux
cgroups v2 Controllers in Detail
Limiting CPU, memory, and I/O precisely

Anyone who only knows container resource limits as a CLI flag like Docker --memory or Kubernetes resources.limits rarely understands what actually happens underneath. Both mechanisms are just thin wrappers around the cgroups v2 controllers cpu, memory, and io, whose direct handling through systemd offers considerably more control than any container tool ever exposes on the surface.

11 min read Linux Kernel cgroups

1. The unified hierarchy as the foundation of cgroups v2

cgroups v1 organized every controller into its own, independent hierarchy, which meant a process could sit in several unrelated tree structures for CPU, memory, and blkio at the same time, with correspondingly complex and error prone bookkeeping. cgroups v2 does away with that fragmentation and introduces a single, unified hierarchy in which every process is assigned to exactly one node, with all relevant controllers activated inside that same tree.

This unified hierarchy is typically mounted under /sys/fs/cgroup/ and managed automatically by systemd as the init system, which creates its own cgroup for every service, scope, and slice unit. For administrators this means resource limits can largely be configured today directly through systemd unit directives, without touching the cgroup filesystem structure under /sys/fs/cgroup/ by hand, even though the underlying model still exposes plain virtual files for every controller.


# Check whether cgroups v2 is active as the unified hierarchy
mount | grep cgroup2

# List the controllers available for the root cgroup
cat /sys/fs/cgroup/cgroup.controllers

2. The CPU controller: quota, weight, and bursting

The cpu controller governs CPU allocation through two complementary mechanisms: cpu.max sets a hard ceiling as a quota and period pair, typically expressed as a number of microseconds of CPU time per defined period, while cpu.weight controls relative priority when several cgroups at the same level compete for available CPU time, ranging from 1 to 10000 by default with a default value of 100.

These two mechanisms complement each other: a quota limit absolutely caps how much CPU time a cgroup may claim at most, regardless of whether the CPU would otherwise sit idle, while the weight only kicks in when actual competition for CPU time exists. Since kernel 5.14, cpu.max.burst additionally supports briefly exceeding the quota within a predefined burst budget, which is more realistic than a hard limit with zero tolerance for workloads with occasional, short load spikes, such as PHP FPM processes hitting a cache miss.


# Limit CPU quota to 50 percent of one CPU (50000 out of 100000 microseconds)
echo "50000 100000" > /sys/fs/cgroup/shop-php.slice/cpu.max

# Set relative priority against other cgroups at the same level
echo 200 > /sys/fs/cgroup/shop-php.slice/cpu.weight

3. The memory controller: from low to OOM

The memory controller offers four staged thresholds instead of a single limit: memory.low defines a protection floor below which memory only gets reclaimed under system wide pressure, memory.high sets a soft ceiling beyond which the kernel actively throttles the process and forces reclaim, memory.max is the hard ceiling whose breach triggers an out of memory kill inside the cgroup, and memory.min guarantees a minimum amount that never gets reclaimed even under system wide memory pressure.

This staging allows considerably finer control than the binary limit cgroups v1 offered: a memory.high value set just below memory.max gives a process the chance to actively counteract via reclaim and throttling before the hard OOM killer strikes, which makes the decisive difference between a controlled slowdown and an abrupt crash for database or PHP processes with internal cache management.


# Set a hard memory ceiling and a soft throttling threshold
echo "900M" > /sys/fs/cgroup/shop-php.slice/memory.max
echo "750M" > /sys/fs/cgroup/shop-php.slice/memory.high

# Check current memory usage and OOM events
cat /sys/fs/cgroup/shop-php.slice/memory.current
cat /sys/fs/cgroup/shop-php.slice/memory.events

4. The I/O controller: bandwidth and IOPS per device

The io controller limits read and write throughput as well as IOPS per block device via io.max, with every device addressed individually through its major and minor number, since different physical or virtual storage devices carry different performance profiles and a blanket limit across all devices would be nearly meaningless. Separate limits are configurable for rbps and wbps (read and write bandwidth in bytes per second) as well as riops and wiops.

For relative prioritization between several cgroups under I/O contention, io.weight exists as well, analogous to the CPU weight, kicking in only when actual I/O saturation occurs. In practice, the I/O controller matters especially for database workloads on shared storage, where a single container with excessive write behavior could otherwise drive up I/O latency for every other cgroup sharing the same physical storage device.


# Determine the device major:minor number
lsblk -d -o NAME,MAJ:MIN

# Limit read and write bandwidth for device 8:0
echo "8:0 rbps=50000000 wbps=20000000" > /sys/fs/cgroup/shop-db.slice/io.max

5. Setting limits on the fly with systemd-run

Rather than editing the virtual cgroup files directly, which is error prone and gets lost on process restart, systemd-run offers a much more convenient way to launch a command directly as a transient systemd unit with defined resource limits. The relevant properties like MemoryMax, CPUQuota, and IOWriteBandwidthMax map one to one onto the underlying cgroup v2 controller files, but systemd expresses them in human readable units instead of raw byte or microsecond values.

This approach is excellent for quickly testing how a process behaves under a given limit, for example a database import under a reduced memory budget, without having to create a permanent systemd unit file right away. For production, long lived limits, the same properties belong in a regular unit file under /etc/systemd/system/ instead.


# Run a command transiently with a CPU and memory limit
systemd-run --scope -p CPUQuota=50% -p MemoryMax=512M \
  mysqldump shop_db > /srv/backup/shop_db.sql

# Limit I/O bandwidth for a single command
systemd-run --scope -p IOWriteBandwidthMax="/dev/sda 10M" \
  rsync -a /srv/data/ /srv/backup/

6. Permanent limits through systemd unit directives

For long lived services, resource limits belong directly in the systemd unit file, either in the [Service] section of a regular service unit or centrally in a slice unit that several related services attach to. Slice units form a hierarchical grouping: a limit set at the slice level automatically caps the sum of all services running inside it, which works great for assigning a shared total budget to, say, all of one customer's PHP FPM workers together, regardless of how many individual worker processes happen to be active.

Changes to unit directives require a systemctl daemon-reload followed by a restart after editing, or, for already running services, many properties can also be adjusted live via systemctl set-property without restarting the service itself, since systemd passes the change straight through to the corresponding cgroup file.


# /etc/systemd/system/shop-php.slice
[Unit]
Description=Resource slice for all PHP FPM workers of the shop

[Slice]
CPUQuota=200%
MemoryMax=2G
IOWeight=100

7. Delegation: nested cgroups for container runtimes

Container runtimes like Docker or Podman create their own nested cgroup for every container underneath the systemd managed hierarchy, which requires systemd to explicitly hand control over certain controllers to that subtree, a process systemd calls delegation. Without delegation enabled, the container runtime cannot set further limits for individual containers inside its own cgroup branch, since systemd keeps controller control at the top level by default.

On modern systemd versions, delegation for the cpu, memory, io, and pids controllers inside a unit marked Delegate=yes is already correctly configured for Docker and Podman out of the box, as long as the runtime uses systemd as its cgroup driver. Anyone creating custom slice units for container groups needs to set this delegation explicitly, otherwise resource limits the runtime itself tries to set will fail with a permission denied error.


# /etc/systemd/system/shop-containers.slice
[Slice]
CPUQuota=400%
MemoryMax=4G

[Unit]
# Allows the container runtime to set its own limits inside this slice

8. How this maps to Docker and Kubernetes resource limits

Docker CLI flags such as --cpus=1.5 and --memory=512m translate internally one to one into the cgroups v2 properties cpu.max and memory.max for the cgroup the container runtime creates, with the difference being that Docker abstracts away the human readable syntax and Kubernetes layers yet another abstraction on top with resources.requests and resources.limits. A Kubernetes limits.memory: 512Mi ultimately lands as exactly the same memory.max value in the underlying pod cgroup that you could also set manually via systemd.

The practical value of understanding the underlying cgroup layer shows up during debugging: when a Kubernetes pod gets OOM killed unexpectedly despite a seemingly generous memory limit, a direct look at memory.current and memory.events for the corresponding cgroup under /sys/fs/cgroup/kubepods.slice/ often gets you an answer faster than any Kubernetes native debugging tool, because it shows the actual kernel numbers with no abstraction layer in between.


# Determine the cgroup path of a running Docker container
docker inspect --format '{{.Id}}' shop-php
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max

# Check current memory usage and OOM counters directly at the kernel
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.events

9. Troubleshooting: verifying limits and finding bottlenecks

When behavior looks unexpected, start with cpu.stat, which among other things reports cumulative throttled time, meaning how long a process has actually been throttled by its CPU quota. A high value in nr_throttled is a clear sign the configured CPU limit is too tight and the application regularly bumps against it, even if average CPU utilization looks unremarkable at first glance.

For memory issues, memory.events provides the decisive counters: high shows how often the soft ceiling was exceeded, max how often the hard ceiling was hit, and oom_kill the actual number of out of memory kills triggered inside the cgroup. These counters are cumulative since the cgroup was created and therefore give a more reliable historical picture than a single snapshot from free or top, which only captures the current moment.

Controller Key files Hard limit Soft limit / priority
cpu cpu.max, cpu.weight, cpu.stat cpu.max (quota/period) cpu.weight for relative priority
memory memory.max, memory.high, memory.min, memory.events memory.max (OOM kill) memory.high (throttling), memory.low (protection)
io io.max, io.weight, io.stat io.max (rbps/wbps/riops/wiops) io.weight for relative priority
pids pids.max, pids.current pids.max (process count) none, hard limit only

Mironsoft

Server administration, Docker hosts, and performance tuning

Linux servers nobody on the team really understands anymore?

We handle setup, hardening, and performance tuning of Linux servers and Docker hosts for Magento deployments, documented and traceable instead of grown and unclear.

Server Audit

Review the existing server configuration for security gaps and performance bottlenecks.

Docker Host Setup

Set up and secure production-ready Docker environments for Magento cleanly.

Monitoring & Tuning

Measure resource usage and tune systemd, kernel, and services with purpose.

10. Summary

cgroups v2 Controllers

Foundation

One unified cgroup hierarchy instead of separate trees per controller

CPU

cpu.max for a hard quota, cpu.weight for relative priority

Memory

Four stages from memory.min to memory.max instead of one limit

Practical path

systemd-run for testing, unit directives for permanent limits

11. FAQ: cgroups v2 Controllers

1What is the main difference between cgroups v1 and v2?
cgroups v1 used separate hierarchies per controller, so a process could sit in several independent trees at once. cgroups v2 introduces a single, unified hierarchy in which every process is assigned to exactly one node.
2How do I set a CPU limit for a running systemd service?
The simplest way is systemctl set-property servicename.service CPUQuota=50%, which applies the limit live immediately without restarting the service. For permanent configuration, the directive belongs in the unit file itself.
3What does memory.high mean compared to memory.max?
memory.high is a soft ceiling beyond which the kernel actively throttles the process and reclaims memory without terminating it immediately. memory.max is the hard ceiling whose breach triggers an out of memory kill inside the cgroup.
4What is memory.low actually used for?
memory.low protects a minimum amount of memory from reclaim as long as there is no system wide memory pressure. That prevents important processes from getting throttled unnecessarily under general host memory pressure before they even reach their own limit.
5How do I find out whether a process is being throttled by CPU quota?
The nr_throttled value in cpu.stat counts how often a process has been throttled by its configured quota. A continuously rising value alongside an otherwise unremarkable average CPU utilization points to a limit that is set too tight.
6What is delegation in the context of cgroups v2?
Delegation lets a nested cgroup, for example one belonging to a container runtime, exercise control over certain controllers within its own subtree. Without delegation enabled, a runtime like Docker cannot set its own limits for individual containers.
7How does Docker --memory relate to cgroups v2 memory.max?
Docker translates the --memory flag directly into the memory.max value of the cgroup created for the container. Technically it is the same mechanism, Docker just provides a more convenient CLI syntax on top of it.
8Can I set different I/O limits per storage device?
Yes, io.max addresses every block device individually via its major and minor number, so different bandwidth and IOPS ceilings can be defined for fast NVMe storage versus slower network attached storage.
9What happens if I use systemd-run without a permanent unit file?
The command runs as a transient unit with the given resource limits but disappears completely once the process ends. For recurring limits, a regular unit file is the more appropriate, permanent solution.
10Where do I find the actual cgroup values of a Kubernetes pod?
Typically under /sys/fs/cgroup/kubepods.slice/, with the exact path derived from the pod and container name under the Kubernetes cgroup driver. A direct look at memory.current and memory.events often clarifies things faster than Kubernetes native debugging tools.