Skip to main content
RunBook Academy

LinuxXXXVII · Resource ManagementResource accounting

cgroup accounting - proving which limit was actually hit

Advanced⏱ ~20 minsystemd-cgtopsystemctl

What you'll learn

  • Read cpu.stat and prove CPU throttling with nr_throttled and throttled_usec
  • Read memory.events to distinguish a reclaim event from a limit event from an OOM kill
  • Use memory.peak to size a limit from evidence rather than from a guess
  • Attribute an accounting number to the service that produced it with systemd-cgtop and systemctl status

Prerequisites

Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-11

Not yet marked complete on this device.

Setting a limit is easy. Proving that a limit was hit is the part that closes an incident, and it is a different skill. A service that is slow because it is CPU-throttled and a service that is slow because the database is slow look identical from the application logs. cgroup v2 records the difference, per service, as a counter you can read.

This lesson is about the accounting files, not the limit files. The previous lessons in this part taught you to write CPUQuota=, MemoryMax= and TasksMax=. This one teaches you to read what happened afterwards.

Where the numbers live

Every systemd unit has a cgroup, and every cgroup directory contains the accounting files for the controllers enabled on it. Find the path first:

systemctl show nginx.service -p ControlGroup
ControlGroup=/system.slice/nginx.service

The files are under /sys/fs/cgroup plus that path:

ls /sys/fs/cgroup/system.slice/nginx.service/
cgroup.controllers  cpu.pressure  cpu.stat        io.pressure  memory.current
cgroup.events       cpu.max       cpu.stat.local  io.stat      memory.events
cgroup.procs        cpu.weight    cpu.uclamp.max  io.max       memory.max
cgroup.stat         cpu.idle      cpuset.cpus     io.weight    memory.peak
cgroup.threads      cpu.max.burst cpuset.mems     memory.high  memory.pressure
pids.current        pids.events   pids.max        pids.peak    memory.stat

A file that is absent means the controller is not enabled on that cgroup, which is itself a finding: no io.stat means no I/O accounting for this service, so you cannot answer an I/O question about it until you enable the controller.

cat /sys/fs/cgroup/system.slice/cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma misc dmem

That line lists the controllers a parent has enabled for its children. Controllers propagate downwards only when the parent enables them in cgroup.subtree_control, so a missing controller deep in the tree is usually a parent that never turned it on.

CPU: throttling is a counter, not an inference

cpu.stat is the single most useful accounting file on the system, because it separates “used a lot of CPU” from “was prevented from using CPU”:

cat /sys/fs/cgroup/system.slice/nginx.service/cpu.stat
usage_usec 86005392007
user_usec 66877054358
system_usec 19128337649
nice_usec 0
core_sched.force_idle_usec 0
nr_periods 0
nr_throttled 0
throttled_usec 0
nr_bursts 0
burst_usec 0

The first four lines are consumption. The interesting ones are the last four:

FieldMeaning
nr_periodsEnforcement periods that have elapsed since a quota was set
nr_throttledPeriods in which the cgroup exhausted its quota and was stopped
throttled_usecTotal microseconds the cgroup spent stopped, waiting for the next period
nr_bursts / burst_usecPeriods where cpu.max.burst credit was consumed

nr_periods 0 above means no quota is configured. With no CPUQuota=, there is no enforcement period, so nothing is counted and nothing can be throttled. That is the expected reading on an unlimited service, and it is also how you confirm that the limit you thought you deployed is not actually in effect.

On a service that does have a quota, the reading looks like this:

nr_periods 145200
nr_throttled 41833
throttled_usec 902114000

Turn it into a percentage, which is the number worth alerting on:

CG=/sys/fs/cgroup/system.slice/api.service
awk '/nr_periods/ {p=$2} /nr_throttled/ {t=$2} END {
  if (p > 0) printf "throttled in %.1f%% of periods (%d of %d)\n", 100*t/p, t, p
  else print "no quota configured, nothing to throttle"
}' "$CG/cpu.stat"
throttled in 28.8% of periods (41833 of 145200)

Nearly 29% of enforcement periods ended with the service stopped and waiting. That is a latency problem the application team cannot see and cannot fix, and it is caused entirely by CPUQuota=.

The fix is usually to raise the quota, or to remove it and rely on CPUWeight= instead. CPUWeight= is a share under contention rather than a hard ceiling: an unloaded host lets the service use everything, and a loaded host divides CPU proportionally. A ceiling wastes idle CPU; a weight does not. Reach for CPUQuota= when you need a predictable ceiling for capacity planning or for a tenant guarantee, and for CPUWeight= otherwise.

Memory: events, current, and peak

memory.current is a live gauge and tells you almost nothing on its own. The two files that carry evidence are memory.events and memory.peak.

cat /sys/fs/cgroup/system.slice/api.service/memory.events
low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0
sock_throttled 0

Each counter names a different event, and they escalate:

CounterWhat happened
lowUsage went above memory.low, so protection from reclaim was dropped
highUsage hit memory.high; the kernel throttled the cgroup and reclaimed
maxUsage hit memory.max; allocation was blocked while the kernel reclaimed hard
oomReclaim failed and the cgroup entered OOM
oom_killA process in this cgroup was killed by the cgroup OOM killer
oom_group_killThe whole cgroup was killed together, because memory.oom.group was set

The distinction that matters most in practice is high versus max. A rising high count means the service is being slowed down by reclaim - it is alive, it is meeting its limit, and it is paying for it in latency. A rising max count means it is being stopped. Neither produces a log line in the application.

An all-zero memory.events on a service you believe was memory-limited proves the limit was never reached, which usually means the kill you are investigating came from the system-wide OOM killer or from systemd-oomd, not from this cgroup’s limit.

Size the limit from memory.peak

memory.peak is the high-water mark of memory.current since the cgroup was created, and it is the number to size a limit from:

CG=/sys/fs/cgroup/user.slice/user-1000.slice
printf 'current %s\npeak    %s\nmax     %s\n' \
  "$(cat "$CG/memory.current")" "$(cat "$CG/memory.peak")" "$(cat "$CG/memory.max")"
current 5492432896
peak    47019118592
max     max

5.5 GB right now, 47 GB at some point since boot. A limit set from the current reading would have killed this workload; a limit set from the peak plus headroom would not. Read it in human units with numfmt:

numfmt --to=iec 5492432896 47019118592
5.2G
44G

Processes and I/O

pids.current against pids.max catches fork bombs and thread leaks, and pids.events records the refusals:

CG=/sys/fs/cgroup/user.slice/user-1000.slice
printf 'current %s / max %s / peak %s\n' \
  "$(cat "$CG/pids.current")" "$(cat "$CG/pids.max")" "$(cat "$CG/pids.peak")"
cat "$CG/pids.events"
current 54 / max 124286 / peak 675
max 0

max 0 in pids.events means no fork was ever refused. A non-zero value there is the definitive answer to “why did the service fail to start a worker” - the application will have logged Resource temporarily unavailable and nothing more.

io.stat gives per-device byte and operation counts for the cgroup:

cat /sys/fs/cgroup/system.slice/io.stat
7:4 rbytes=14336 wbytes=0 rios=11 wios=0 dbytes=0 dios=0
259:0 rbytes=48213770240 wbytes=9917349888 rios=1264810 wios=331477 dbytes=0 dios=0

The first field is major:minor; resolve it with lsblk:

lsblk -o NAME,MAJ:MIN,SIZE,TYPE
NAME        MAJ:MIN  SIZE TYPE
nvme0n1     259:0    1.8T disk
├─nvme0n1p1 259:1      1G part
└─nvme0n1p2 259:2    1.8T part

Attribute the number to a service

Reading one cgroup requires knowing which one to read. systemd-cgtop ranks them:

systemd-cgtop -n 3 -b --depth=2 --order=memory
CONTROL GROUP                            TASKS   %CPU   MEMORY  INPUT/S OUTPUT/S
/                                          687      -     3.0G        -        -
system.slice                               439      -     2.1G        -        -
user.slice                                 248      -     5.1G        -        -
system.slice/snapd.service                   18      -    58.1M        -        -
system.slice/unattended-upgrades.service      2      -    18.4M        -        -

Two things to know before you trust this output.

A dash in a column means no data, not zero. The %CPU column needs two samples to compute a delta, so a single-iteration run shows dashes; -n 3 fixes it. The INPUT/S and OUTPUT/S columns show dashes wherever the io controller is not enabled, for the reason above.

Second, systemd-cgtop is a live ranking, so it is a triage tool rather than an evidence tool. Once it points at a unit, read that unit’s accounting files directly - those are the numbers you can paste into an incident review.

systemctl status presents a subset inline, which is often enough:

systemctl status nginx.service
● nginx.service - A high performance web server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)
     Active: active (running) since Mon 2026-08-10 09:12:04 UTC; 1 day 6h ago
      Tasks: 9 (limit: 38350)
     Memory: 412.3M (peak: 1.1G, max: 2.0G, available: 1.6G)
        CPU: 4h 21min 8.442s
     CGroup: /system.slice/nginx.service

peak: 1.1G against max: 2.0G is the sizing conversation in one line: the limit is roughly double the observed peak, which is a defensible margin.

A worked attribution

A service is reported slow. Three files answer it in under a minute:

CG=$(systemctl show api.service -p ControlGroup --value)
cd "/sys/fs/cgroup${CG}"

grep -E 'nr_periods|nr_throttled|throttled_usec' cpu.stat
cat memory.events
cat memory.pressure
nr_periods 145200
nr_throttled 41833
throttled_usec 902114000
low 0
high 1204
max 0
oom 0
oom_kill 0
oom_group_kill 0
some avg10=0.00 avg60=0.12 avg300=0.31 total=88214551
full avg10=0.00 avg60=0.08 avg300=0.19 total=61003422

Read together: the service is CPU-throttled in 29% of periods, and it has been throttled by memory reclaim 1204 times without ever hitting a hard limit or an OOM. Both limits are too tight. Neither fact is visible in the application logs, in top, or on a CPU-utilisation dashboard - and both are recorded, for free, on every host.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A service cgroup shows nr_periods 0, nr_throttled 0 in cpu.stat. What does that tell you?

  2. Q2. A service can be CPU-throttled repeatedly while its average CPU utilisation over a minute sits around 30%.

  3. Q3. Which memory.events counters indicate the cgroup was actively harmed by its memory limit? Select all that apply.

  4. Q4. Which file should you size a MemoryMax= from?

  5. Q5. systemd-cgtop shows dashes in the INPUT/S and OUTPUT/S columns for every unit. What is the most likely reason?

Passing score: 75%. Answers are checked in this browser.