cgroups and resource limits
cgroups v2 cap CPU and memory for groups of processes; systemd slices use MemoryMax and CPUQuota to set those caps. rlimits (ulimit) cap per-process resources. The two mechanisms are complementary: rlimits are per-process, cgroups are per-group.
A process that leaks memory or spins a CPU at 100% does not just hurt itself — it starves everything else on the host. The Linux kernel has two complementary mechanisms for capping resource use. rlimits are per-process ceilings: open file count, number of child processes, stack size. cgroups (control groups) are per-group ceilings: a set of processes sharing a CPU budget and a memory budget. systemd uses cgroups v2 to implement every service boundary. The OOM killer (next lesson) is what happens when neither mechanism stopped a process from consuming all available memory.
After this lesson you can read and set rlimits with ulimit, identify the most common rlimit failures, navigate the cgroups v2 filesystem, set CPUQuota and MemoryMax on a systemd service, and explain the key difference between rlimits (per-process) and cgroups (per-group).
rlimits: per-process resource ceilings enforced by the kernel.
Every process inherits a set of rlimits from its parent. Each limit has a soft value (the current enforced ceiling, which the process may lower but not raise above hard) and a hard value (the ceiling for the soft limit — only root can raise it).
# View current shell's rlimits:
ulimit -a
# core file size (blocks, -c) 0
# data seg size (kbytes, -d) unlimited
# scheduling priority (-e) 0
# file size (blocks, -f) unlimited
# pending signals (-i) 63296
# max locked memory (kbytes, -l) 8192
# max memory size (kbytes, -m) unlimited
# open files (-n) 1024 ← RLIMIT_NOFILE soft
# pipe size (512 bytes, -p) 8
# POSIX message queues (bytes, -q) 819200
# real-time priority (-r) 0
# stack size (kbytes, -s) 8192
# cpu time (seconds, -t) unlimited
# max user processes (-u) 63296 ← RLIMIT_NPROC
# virtual memory (kbytes, -v) unlimited
# file locks (-x) unlimited
# The two limits you will hit most often in production:
# RLIMIT_NOFILE: maximum number of open file descriptors
# Default soft: 1024. Default hard: 1048576 (on modern Ubuntu).
# Hit it and you get: "Too many open files" (errno EMFILE)
# A Node.js or Java server under load needs far more than 1024 FDs.
# RLIMIT_NPROC: maximum number of processes/threads the user may have
# Hit it and fork() returns EAGAIN: "Resource temporarily unavailable"
# Fork bombs are stopped here.Setting rlimits: ulimit for the session, /etc/security/limits.conf for persistence.
# Raise the open file limit for the current shell and its children:
ulimit -n 65536
# Verify:
ulimit -n
# 65536
# This only affects the current shell session.
# For a service, set it in /etc/security/limits.conf:
# (format: <domain> <type> <item> <value>)
cat /etc/security/limits.conf
# * soft nofile 65536
# * hard nofile 1048576
# www-data soft nproc 4096
# www-data hard nproc 8192
#
# * = all users; soft = soft limit; hard = hard limit
# For systemd-managed services, prefer LimitNOFILE in the unit file:
# [Service]
# LimitNOFILE=65536
# This is the right way — limits.conf is bypassed for services that do
# not go through PAM (i.e., most systemd services).
# Check effective limits of a running process (PID 1234):
cat /proc/1234/limits
# Limit Soft Limit Hard Limit Units
# Max open files 65536 1048576 files
# Max processes 63296 63296 processes
# Max locked memory 8388608 8388608 bytescgroups v2: the unified hierarchy.
cgroups v2 (the current default on Ubuntu 22.04+) uses a single unified filesystem hierarchy at /sys/fs/cgroup. Every process belongs to exactly one cgroup. Child cgroups inherit from parents. systemd maps services to cgroups automatically.
# See your cgroup hierarchy:
systemd-cgls
# Control group /:
# -.slice
# ├─system.slice
# │ ├─nginx.service
# │ │ ├─987 nginx: master process
# │ │ └─988 nginx: worker process
# │ └─ssh.service
# │ └─1234 sshd: root@pts/0
# └─user.slice
# └─user-1000.slice
# └─session-1.scope
# └─2345 -bash
# See the cgroup of any process:
cat /proc/$$/cgroup
# 0::/user.slice/user-1000.slice/session-1.scope
# Inspect resource controllers available on the system:
cat /sys/fs/cgroup/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma misc
# 'cpu' and 'memory' are the two you will use most
# Current memory usage of a cgroup:
cat /sys/fs/cgroup/system.slice/nginx.service/memory.current
# 45678592 (bytes — about 44 MB)
# Current CPU usage in microseconds:
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.stat
# usage_usec 123456789
# user_usec 100000000
# system_usec 23456789systemd CPUQuota and MemoryMax: setting limits on a service.
systemd translates unit-file directives into cgroup writes. You almost never need to write to /sys/fs/cgroup directly.
# Set a memory cap and CPU quota on an existing service:
sudo systemctl edit nginx
# This opens a drop-in editor. Add:
# [Service]
# MemoryMax=512M
# CPUQuota=50%
# MemoryMax: cgroup memory.max — hard cap. When the cgroup exceeds this,
# the kernel invokes the cgroup-local OOM killer (kills a process in this
# cgroup). NOT the system-wide OOM killer.
# CPUQuota=50%: the cgroup gets at most 50% of one CPU core per period
# (default period is 100ms). On a 4-core machine, 50% means one core at
# full rate, NOT 50% of all 4 cores.
# Reload:
sudo systemctl daemon-reload
sudo systemctl restart nginx
# Verify the limits are applied:
systemctl show nginx | grep -E 'MemoryMax|CPUQuota'
# MemoryMax=536870912
# CPUQuotaPerSecUSec=500000 (500ms out of 1000ms = 50%)
# Or read directly from the cgroup:
cat /sys/fs/cgroup/system.slice/nginx.service/memory.max
# 536870912
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.max
# 50000 100000 (50ms budget per 100ms period = 50%)
# Transient limits (no unit file change, useful for testing):
sudo systemctl set-property nginx.service MemoryMax=256MDiagnosing “too many open files” in a production Node.js service.
# Symptom: error logs show "EMFILE: too many open files" or
# "Error: EMFILE: too many open files, open '/app/logs/access.log'"
# Step 1: find the service PID
systemctl show node-api --property MainPID
# MainPID=7823
# Step 2: check its current rlimits
cat /proc/7823/limits | grep 'open files'
# Max open files 1024 1048576 files
# ↑ soft=1024 is the problem — ancient default, insufficient for production
# Step 3: check how many FDs it currently has open
ls /proc/7823/fd | wc -l
# 1019 ← already near the soft limit of 1024
# Step 4: what FDs is it holding?
ls -la /proc/7823/fd | head -20
# lrwx------ socket:[12345] (TCP connections)
# lr-x------ /app/logs/access.log
# lr-x------ /app/node_modules/.bin/node
# ...
# Step 5: fix — add to the systemd unit file
sudo systemctl edit node-api
# [Service]
# LimitNOFILE=65536
sudo systemctl daemon-reload
sudo systemctl restart node-api
# Verify:
systemctl show node-api --property LimitNOFILE
# LimitNOFILE=65536
cat /proc/$(systemctl show node-api --property MainPID --value)/limits | grep 'open files'
# Max open files 65536 1048576 files
# Problem solved.▸Why this works
On macOS, there are no cgroups — cgroups are a Linux kernel feature. macOS uses launchd resource controls and BSD setrlimit(). The ulimit command works in macOS shells and sets the same POSIX rlimits (RLIMIT_NOFILE, RLIMIT_NPROC, etc.), but the limits are configured differently: /etc/launchd.conf (deprecated on newer macOS) or launchctl limit for system-wide limits, and per-service plist SoftResourceLimits/HardResourceLimits keys. The macOS default RLIMIT_NOFILE soft limit is 256 (historically), which is even lower than Linux’s 1024 — “too many open files” is a very common macOS development pain.
▸Common mistake
Setting LimitNOFILE in /etc/security/limits.conf for a systemd service does nothing. limits.conf is processed by the PAM module pam_limits during login sessions. Services started by systemd do not go through PAM — they inherit their limits from systemd itself, which reads LimitNOFILE from the unit file. The fix is always in the [Service] section of the unit file (or a systemctl edit drop-in). Many operators waste time editing limits.conf and restarting the service, only to find the rlimit unchanged. Verify with cat /proc/<pid>/limits, not with ulimit -a in a shell.
A systemd service has MemoryMax=256M in its unit file. The service's memory usage grows past 256 MB. What happens, and is this the same as the system-wide OOM killer?
rlimits are per-process resource ceilings: RLIMIT_NOFILE caps open file descriptors (default 1024 — too low for production services), RLIMIT_NPROC caps the number of processes a user may have. Set them with ulimit in a session or LimitNOFILE in a systemd unit file (not limits.conf, which systemd bypasses). cgroups v2 organizes processes into a hierarchy under /sys/fs/cgroup and enforces group-wide budgets: cpu.max implements CPUQuota, memory.max implements MemoryMax. When a cgroup exceeds MemoryMax, a cgroup-local OOM kill fires — distinct from the system-wide OOM killer that fires only when the whole system runs out of memory. systemd maps every service to its own cgroup automatically; use systemctl edit to set MemoryMax and CPUQuota. Always verify limits with cat /proc/<pid>/limits, not shell ulimit.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.