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 |
|---|---|---|
|
| Hard cap. |
| 1 to 10000, default 100 | Relative share of CPU, applied only under contention |
| Bytes or | Hard limit. Past it, the kernel reclaims, then OOM-kills inside the group |
| Bytes or | Throttle point. Past it, processes are slowed and pushed into reclaim, but not killed |
|
| Per-device bandwidth and IOPS caps |
| Number or | Cap on processes and threads. |
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.controllerscpuset cpu io memory hugetlb pids rdma miscOn 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/cgroup0::/user.slice/user-1000.slice/session-3.scopeOn 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/democat /sys/fs/cgroup/cgroup.subtree_controlsystemd 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_controlSet the limits:
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.maxecho 200M | sudo tee /sys/fs/cgroup/demo/memory.highecho 256M | sudo tee /sys/fs/cgroup/demo/memory.maxecho 64 | sudo tee /sys/fs/cgroup/demo/pids.maxFor 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.maxStart a root shell so you don't move your login session, move that shell into the group, and start a CPU burner:
sudo bashecho $$ > /sys/fs/cgroup/demo/cgroup.procsyes > /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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Change a running service without restarting it:
sudo systemctl set-property nginx.service CPUWeight=200 MemoryHigh=768M MemoryMax=1Gset-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=768MMemoryMax=1GTasksMax=512IOReadBandwidthMax=/dev/nvme0n1 20MCPUQuota 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=20MemoryMax=4GPut 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.shOr keep it attached to your terminal as a scope:
sudo systemd-run --scope -p MemoryMax=500M -p CPUQuota=50% make -j8The 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 ControlGroupcat /sys/fs/cgroup/system.slice/nginx.service/memory.maxcat /sys/fs/cgroup/system.slice/nginx.service/cpu.weightControlGroup=/system.slice/nginx.service1073741824200memory.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.eventsgrep throttled /sys/fs/cgroup/system.slice/nginx.service/cpu.statA 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 |
|---|---|---|
| The file doesn't exist because the controller isn't enabled in the parent's | Write |
| The controller isn't listed in that cgroup's | Enable it one level up first |
| The cgroup holds processes, which the no-internal-processes rule forbids | Move the processes into a child cgroup first |
Service exits with | Memory hit | Raise |
Latency spikes while CPU usage looks low |
| Check |
| The | Use the whole-disk |
| The limit is on a different disk than the workload uses, or reads come from the page cache | Check the device with |
| The group still has processes or child groups | Empty |
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.