Cycle's hosted MCP is live. Your AI tooling just learned DevOps.Read the announcement

Configuring cgroups v2.

Control groups (cgroups) are the Linux kernel feature that limits, prioritizes and accounts for the CPU, memory, I/O and process count of a group of processes. cgroups v2 is the current interface: one unified hierarchy mounted at /sys/fs/cgroup, where every resource controller works on the same tree.

They solve the noisy-neighbor problem. Without them, one runaway process can take every CPU cycle, or eat memory until the OOM killer picks a victim that may not be the process at fault. With cgroups, you decide in advance how much each workload can use and which one wins under contention. Container runtimes and systemd are both built on them.

Resource limits by default on Cycle

You might be here because you manage cgroups yourself, or because your tooling leaves resource limits up to you. Setting a limit on one service is a single command. Keeping the right limits on every workload across every server is where it gets hard.

On Cycle, each container's CPU and RAM settings live in its config, where you can see and change them, and Cycle enforces them for you. CPU is measured in shares, where 10 shares equal one thread. A reserve is capacity set aside on the server when the instance is placed, and a limit is the most it can use. Reserves count against a server's capacity, so Cycle won't place an instance on a server that's full.

Defaults and the CPU overcommit ratio are covered in container resources on Cycle. The rest of this page walks through configuring cgroups v2 by hand.

How cgroups v2 works

Every cgroup is a directory under /sys/fs/cgroup. Every process belongs to exactly one cgroup, listed in that directory's cgroup.procs file. Children sit inside their parent's limits, so a child can never use more than its parent allows.

Controllers are the resource types: cpu, memory, io, pids and a few more. cgroup.controllers lists the ones available to a cgroup, and writing to cgroup.subtree_control turns them on for its children. Once a controller is on, its interface files appear in each child directory.

Two details trip people up. A non-root cgroup that hands memory or io to its children can't hold processes itself, so processes live in the leaves. cpu and pids are exempt. And unlike v1, which had a separate tree per controller, a process sits in one place for every resource.

File

Format

Effect

cpu.max

$MAX $PERIOD in microseconds, default max 100000

Hard cap. 50000 100000 is half a CPU, 200000 100000 is two

cpu.weight

1 to 10000, default 100

Relative share of CPU, applied only under contention

memory.max

Bytes or max

Hard limit. Past it, the kernel reclaims, then OOM-kills inside the group

memory.high

Bytes or max

Throttle point. Past it, processes are slowed and pushed into reclaim, but not killed

io.max

MAJ:MIN rbps= wbps= riops= wiops=

Per-device bandwidth and IOPS caps

pids.max

Number or max

Cap on processes and threads. fork() fails past it

memory.high is the one to reach for first. It applies back-pressure before anything dies, and memory.max set somewhat above it becomes the backstop.

Before you start

The examples run on one host, node-01 (10.0.0.31). You need root and a distribution that mounts cgroups v2 by default: Ubuntu 22.04 or later, Debian 11 or later, RHEL 9 and its rebuilds, or current Fedora. The systemd steps work on systemd 249, the version in Ubuntu 22.04, and newer. Check yours with systemctl --version.

Step 1: Check which cgroup version is mounted

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

cgroup2fs means v2. tmpfs means v1 or a hybrid layout. On v2, list the available controllers:

cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc

On an older distribution still running v1, switch with the kernel parameter systemd.unified_cgroup_hierarchy=1 and reboot. On RHEL 8 that's sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1". systemd 258 removed v1 support entirely, so plan the move rather than working around it.

Step 2: Find where a process lives

cat /proc/self/cgroup
0::/user.slice/user-1000.slice/session-3.scope

On v2 there's one line, starting with 0::, followed by the path under /sys/fs/cgroup. systemd-cgls prints the whole tree with the processes in each group.

Step 3: Create a cgroup by hand

Building one manually shows what every higher-level tool does underneath. Create a group and check which controllers the root hands down:

sudo mkdir /sys/fs/cgroup/demo
cat /sys/fs/cgroup/cgroup.subtree_control

systemd turns controllers on only as units need them, so this may show as little as memory pids. Enable the ones you need:

echo "+cpu +memory +io +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

Set the limits:

echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max
echo 200M | sudo tee /sys/fs/cgroup/demo/memory.high
echo 256M | sudo tee /sys/fs/cgroup/demo/memory.max
echo 64 | sudo tee /sys/fs/cgroup/demo/pids.max

For io.max, find the device numbers with lsblk -o NAME,MAJ:MIN and cap it at 10 MB/s each way:

echo "259:0 rbps=10000000 wbps=10000000" | sudo tee /sys/fs/cgroup/demo/io.max

Start a root shell so you don't move your login session, move that shell into the group, and start a CPU burner:

sudo bash
echo $$ > /sys/fs/cgroup/demo/cgroup.procs
yes > /dev/null &

top shows yes holding at about 50% of one CPU, and nr_throttled in /sys/fs/cgroup/demo/cpu.stat keeps climbing. Run kill %1 and exit to leave the root shell, then remove the empty group with sudo rmdir /sys/fs/cgroup/demo.

systemd owns the cgroup tree and doesn't track groups you create by hand. Use manual groups to learn and debug. For anything that should survive a reboot, use systemd.

Step 4: Limit a service with systemd

systemd exposes each controller file as a unit property:

systemd property

cgroup file

CPUQuota=50%

cpu.max (50000 100000)

CPUWeight=200

cpu.weight

MemoryHigh=768M

memory.high

MemoryMax=1G

memory.max

IOReadBandwidthMax=/dev/nvme0n1 20M

io.max (rbps)

TasksMax=512

pids.max

Change a running service without restarting it:

sudo systemctl set-property nginx.service CPUWeight=200 MemoryHigh=768M MemoryMax=1G

set-property writes a persistent drop-in under /etc/systemd/system.control/. Add --runtime to apply the change until the next reboot only. To keep limits next to the rest of the unit's config, use a drop-in instead with sudo systemctl edit nginx.service:

[Service]
CPUQuota=150%
MemoryHigh=768M
MemoryMax=1G
TasksMax=512
IOReadBandwidthMax=/dev/nvme0n1 20M

CPUQuota above 100% grants more than one CPU, so 150% is one and a half. systemctl edit reloads systemd when you save, and the new limits apply to the running service without a restart. More on unit files is in systemd usage.

Step 5: Group workloads into a slice

Slices are systemd's interior cgroups. A limit on a slice applies to everything inside it. Create /etc/systemd/system/batch.slice:

[Unit]
Description=Batch and background jobs
[Slice]
CPUWeight=20
MemoryMax=4G

Put a service in it with Slice=batch.slice in its [Service] section. Under CPU contention, batch.slice gets a fifth of the share of a sibling at the default weight of 100, such as system.slice. When the CPU is idle, batch work can still use all of it. That's the difference between weight and quota.

Step 6: Run a one-off command with limits

systemd-run creates a transient unit with the properties you pass. Run a job in the background as a service:

sudo systemd-run --unit=reindex --slice=batch.slice \
-p MemoryMax=2G -p CPUQuota=100% /usr/local/bin/reindex.sh

Or keep it attached to your terminal as a scope:

sudo systemd-run --scope -p MemoryMax=500M -p CPUQuota=50% make -j8

The unit disappears when the command exits. It's the fastest way to test whether a limit holds before writing it into a unit file.

Step 7: Verify the limits

Find the service's cgroup and read the files directly:

systemctl show nginx.service -p ControlGroup
cat /sys/fs/cgroup/system.slice/nginx.service/memory.max
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.weight
ControlGroup=/system.slice/nginx.service
1073741824
200

memory.max reports bytes, so 1G reads back as 1073741824. For a live view across groups, run sudo systemd-cgtop. It shows tasks, CPU, memory and I/O per cgroup, and -m sorts by memory.

To see whether a limit is biting, read the event counters:

cat /sys/fs/cgroup/system.slice/nginx.service/memory.events
grep throttled /sys/fs/cgroup/system.slice/nginx.service/cpu.stat

A rising high count in memory.events means memory.high is throttling. oom_kill counts processes killed at memory.max. A rising nr_throttled means cpu.max is holding the group back.

Troubleshooting

Start with journalctl -u <service> for the unit's own view and journalctl -k | grep -i oom for kernel OOM kills.

Symptom

Likely cause

Fix

Permission denied writing cpu.max as root

The file doesn't exist because the controller isn't enabled in the parent's cgroup.subtree_control

Write +cpu to the parent's cgroup.subtree_control

No such file or directory writing cgroup.subtree_control

The controller isn't listed in that cgroup's cgroup.controllers

Enable it one level up first

Device or resource busy enabling memory or io

The cgroup holds processes, which the no-internal-processes rule forbids

Move the processes into a child cgroup first

Service exits with status=9/KILL

Memory hit memory.max and the OOM killer fired

Raise MemoryMax, or set MemoryHigh below it for back-pressure

Latency spikes while CPU usage looks low

cpu.max throttling inside each period

Check nr_throttled, then raise CPUQuota or switch to CPUWeight

No such device writing io.max

The MAJ:MIN is a partition, not a whole disk

Use the whole-disk MAJ:MIN from lsblk

io.max seems to have no effect

The limit is on a different disk than the workload uses, or reads come from the page cache

Check the device with lsblk, then test with direct I/O, such as dd if=/dev/nvme0n1 of=/dev/null bs=1M count=200 iflag=direct

rmdir fails with Device or resource busy

The group still has processes or child groups

Empty cgroup.procs and remove children first

Running cgroups past one server

The files are the easy part. The hard part is the numbers. A good limit comes from measured usage, so you need per-workload history before you set anything, and limits drift out of date as the code changes.

Set MemoryMax too low and the service dies under load. Set it too high and it protects nothing.

Then there's the server as a whole. If the MemoryMax values on a host add up to more than its RAM, you're overcommitted, and something has to decide which workload goes where so that doesn't happen. cgroups enforce limits on one machine. Placement across machines, and moving a workload's limits with it, is outside their scope.

cgroups also don't isolate what a process can see. That's the job of namespaces.

Reserves, limits and placement onto servers with room are what Cycle's control plane handles for you.

A cgroup enforces a number, and choosing that number for every workload on every server is the real work.

Cookies

Cookies Preferences

We run basic, anonymous analytics by default to measure site traffic. By clicking "Accept," you allow additional cookies for advanced app improvements and tailored advertising. Choose what you share by clicking "Customize."