open atlas
↑ Back to track
Linux, the operating system LIN · 08 · 03

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.

LIN Senior ◷ 22 min
Level
FoundationsJuniorMiddleSenior

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.

Goal

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).

1

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.
2

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      bytes
3

cgroups 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 23456789
4

systemd 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=256M
Worked example

Diagnosing “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.

Check yourself
Quiz

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?

Recap

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.

recallapplystretch0 of 4 done

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.