Processes & Privileges
Cgroups
Limiting CPU, memory, and IO so one process cannot starve the system.
Isolation is not only about confidentiality. A process that cannot read your files can still take the machine down by eating all the memory, pinning every CPU, or flooding the disk with IO. Availability is part of security, and denial of service, whether malicious or accidental, is a real threat. Control groups (cgroups) are the kernel mechanism for accounting and capping resource use per group of processes. They are the third leg of container isolation alongside namespaces (what you can see) and capabilities (what you can do): cgroups govern how much you can consume.
cgroup v2: one unified hierarchy
Modern Ubuntu uses cgroup v2, which replaced the tangle of independent v1 hierarchies with a single tree. Every process belongs to exactly one cgroup, and controllers (cpu, memory, io, pids, and others) are enabled per subtree. The filesystem interface lives under /sys/fs/cgroup.
# Confirm cgroup v2 and see which controllers are available
stat -fc %T /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers
Expected output:
cgroup2fs
cpuset cpu io memory hugetlb pids rmiss misc
The key controllers from a security standpoint:
| Controller | Limits | DoS it prevents |
|---|---|---|
| memory | RAM and swap use (memory.max, memory.high) | Memory exhaustion / OOM of the whole host |
| pids | Number of processes/threads (pids.max) | Fork bombs |
| cpu | CPU time share and hard quota (cpu.max, cpu.weight) | CPU starvation of other services |
| io | Block IO bandwidth and IOPS (io.max) | Disk saturation starving other workloads |
The pids controller deserves special mention: a pids.max of a few hundred neutralises the classic fork bomb entirely, no matter what the contained process does.
Setting limits with systemd
You rarely poke /sys/fs/cgroup directly. systemd is the cgroup manager on Ubuntu, and every service, scope, and slice is a cgroup. You set limits with resource properties on a unit, and systemd translates them to the controller files. This is the supported, persistent way to cap a service.
# Cap a service at 512M RAM, 200 processes, and half a CPU
sudo systemctl set-property myapp.service \
MemoryMax=512M TasksMax=200 CPUQuota=50%
systemctl show myapp.service -p MemoryMax -p TasksMax -p CPUQuota
Expected output:
MemoryMax=536870912
TasksMax=200
CPUQuota=50%
For a permanent setting you put the same properties in the [Service] section of the unit file. MemoryMax is a hard wall: exceed it and the kernel OOM-kills processes inside that cgroup, protecting the rest of the host. MemoryHigh is a softer throttle that reclaims aggressively before the hard limit. TasksMax maps to pids.max.
Hands-on: watch a limit bite
Demonstrate the memory cap with a transient scope so nothing persists. systemd-run drops a command into its own cgroup with the limits you specify.
# Try to allocate 400M inside a cgroup capped at 100M
sudo systemd-run --scope -p MemoryMax=100M \
python3 -c 'x = bytearray(400*1024*1024); print("allocated")'
Expected output:
Running scope as unit: run-r9f3.scope
Killed
The allocation never printed allocated; the kernel OOM-killed the process the moment it pushed past 100M, and crucially it killed only that scope, not any other service. You can confirm which units exist and their cgroup paths with systemd-cgls.
# Show the cgroup tree for the user and system slices
systemd-cgls --no-pager | head -12
Expected output:
Control group /:
-.slice
├─system.slice
│ ├─nginx.service
│ │ └─4471 nginx: master process
│ └─myapp.service
│ ├─8801 /usr/bin/myapp
│ └─8815 /usr/bin/myapp worker
└─user.slice
└─user-1000.slice
Each service is a distinct cgroup, so the limits you set apply to the service and all the workers it spawns, which is exactly the containment you want.
Cgroups as a DoS defence
Framing this as security: an unlimited service is a single point of failure for the whole host. One memory leak, one runaway loop, or one fork bomb takes everything down. Baseline resource limits turn those incidents into a single killed service that systemd can restart, rather than a full outage. Container runtimes apply the same controllers automatically, and in the Kubernetes Security foundation these appear as pod resource requests and limits, which the kubelet converts into exactly these cgroup settings. A missing memory limit on a pod is the same risk as a missing MemoryMax on a service.
Practical Guidance
- Set
MemoryMaxandTasksMaxon every non-trivial service. They are the cheapest defence against memory exhaustion and fork bombs, and they contain damage to a single unit. - Use
CPUQuota(a hard cap) for untrusted or bursty workloads, andCPUWeight(a relative share) to prioritise important services without starving others. - Prefer systemd unit properties over writing to
/sys/fs/cgroupdirectly; the manual writes do not survive a reboot or a systemd reload. - Test limits with
systemd-run --scopebefore baking them into a unit, so you learn where the real service breaks rather than guessing. - Treat a missing resource limit as a denial-of-service finding, on the host and in Kubernetes alike; availability is part of the security posture.