cgroups and the OOM killer: who gets killed, why the log is empty, and what 137 really means
The container is gone, the application log has nothing in it, and the orchestrator reports exit code 137. That is not a mystery but two mechanisms doing their job: memory accounted per group of processes, and a kill by a signal that cannot be caught. The lesson takes both apart and shows by measurement that usage climbed to the limit and stopped there: the kill is not for exceeding the limit but for having nothing left to free inside the group.
Full technical treatment
TL;DR
A memory limit belongs to a group of processes, not to a process. Accounting is per group as well, and when the group has nowhere left to grow the kernel first frees what it can inside it and, if there is nothing left to free, ends one of its processes. The kill is for the group's total, so "my process is small" is no protection.
Hence the consequence that matters: the program sees neither an error nor an
exception. What kills it is SIGKILL, and SIGKILL cannot be caught:
measured — exit code −9, 137 from the outside, empty stdout, empty stderr.
An empty log here is not lost messages but a sign of how the process died. Nor
does usage "swell past the limit": in a run with a 64 MiB limit the program
asked for 1600 MiB and the group's peak stopped at 64.0 MiB.
Beyond that is what separates knowing from having read. Hitting the limit is
an ordinary event rather than a death: in the same run the failcnt counter
reached 36, and up to the last of those thirty-six the group carried on working,
because the kernel freed what it could. The victim is chosen by memory held
rather than by who asked last: in a group limited to 160 MiB the small process
survived and the big one was killed — the kernel computes a badness score whose
basis is "the amount of memory used by the process". Exit code 137 does not mean
"OOM", it means "killed by SIGKILL": what separates the two is one kernel
line, oom-kill:constraint=CONSTRAINT_MEMCG, naming the group that ran out. The
same code with the same request finished normally under a 2048 MiB limit —
failcnt zero, exit code 0; and page cache, though charged to the group, is
given back: a 256 MiB file read through a group limited to 64 MiB gave a peak of
63.2 MiB, failcnt 95 and exit code 0. As for the peak arriving exactly at the
limit: that is the result of our run, not a guarantee — cgroup v2 documents
memory.max as a limit at which usage may temporarily go higher under some
circumstances.
- a running program occupies memory, and how much can be measured;
- a container runs on a shared machine, next to other containers;
- a machine's memory is finite: when it runs short, somebody has to give way.
- what a cgroup is,
memory.limit_in_bytes,failcnt,memory.max; - the badness score,
oom_score_adj,CONSTRAINT_MEMCG; - how anonymous memory differs from page cache.
What is actually being asked
The ladder usually runs like this, and the first two rungs matter more than they look:
- "What are cgroups?" — the warm-up: grouping, accounting, limits.
- "What happens when a process hits its memory limit?" — where everyone
expecting a
MemoryErroris filtered out. - "Why is there nothing in the application log?" — whether you connected
this to
SIGKILLfrom the previous lesson. - "Who gets killed when a container runs several processes?" — whether you know the badness score and what it is built on.
- "How do you tell an OOM kill from an orchestrator's kill if the code is the same?" — a question about evidence, not guesswork.
- "How do you choose a limit?" — whether you see that
failcntand peak usage answer different questions.
The numbers below come from running bench/cgroups/oom.py and
bench/cgroups/practice.py on cgroup v1. In v2 the same quantities have other
names (memory.max, memory.events, memory.peak) — the mechanism is the
same, the spelling is not.
Base: a container has a memory budget, and it is not the application that counts it
Before taking apart who gets killed and what for, three things are worth naming in ordinary words.
A control group (cgroup) is a Linux kernel mechanism that accounts for the resources of a group of processes and limits them. Not one process but a group: the kernel keeps counters for it — how much memory is held right now, how many times the group has hit its ceiling — and holds the number that bounds that usage.
A container is exactly such a group. When its settings say "512 megabytes of memory", that number goes into the group's file, and from then on the kernel is what enforces it — not the application, not the runtime, not a library.
The processes inside share one budget. There may be one of them or several: the main one, a helper, a child started by accident. All of them take memory from the same number, which is why "my process is small" guarantees nothing — what is counted is not it but all of them together.
Hence the question the rest of the lesson answers: what happens when the group's processes need more memory than the group is allowed? The answer is short and uncomfortable: the kernel does not refuse, it either frees or kills. First it tries to take back, inside that same group, whatever can be taken, and hand the memory over after all; when there is nothing left to take, it picks one of the group's processes and ends it. No notice, no request to shut down, no last word — it ends it.
That is already enough to answer the basic interview question. What follows is about who exactly gets picked, why the application log is empty afterwards, and what evidence separates this death from an ordinary container stop.
Mechanism 1: the limit belongs to the group, not the process
Now the same thing in the documentation's words. A control group is a set of
processes plus accounting of what they consume plus limits on that consumption.
On memory, cgroups(7) is brief:
The memory controller supports reporting and limiting of process memory, kernel
memory, and swap used by cgroups.
The key word is cgroups, plural: both the accounting and the limit belong to the group. Two consequences follow, and each gets asked about separately.
First, if a container runs several processes, they spend a shared limit. A helper process that "only takes a hundred megabytes" takes those hundred megabytes away from the main one.
Second, the kill is an event of the group rather than of a process. The kernel notices that the group has nowhere to grow and picks which of its members to kill. The one it picks need not have done anything wrong.
The group exposes three files: the limit, the peak usage, and a counter of
refused charges (failcnt). The script creates a group, sets a 64 MiB limit and
runs a program in it that asks for 1600 MiB and touches every page it gets:
1. THE KILL: the limit is 64 MiB, the program asks for 1600
-----------------------------------------------------------
limit (memory.limit_in_bytes) 64 MiB
exit code from waitpid -9
as the shell reports it 137
stdout of the program ''
stderr of the program ''
peak usage before the kill 64.0 MiB
times the limit was hit (failcnt) 36
The rest of the lesson takes those seven lines one at a time, because each answers a different rung of the ladder.
Mechanism 2: usage stops at the limit rather than growing through it
The most common wrong picture is a process that "swelled past its limit and burst". Look at the peak: 64.0 MiB against a 64 MiB limit. In this run usage climbed to the limit and did not go past it.
The boundary of that observation is worth naming right away, because the
practice below rests on it. "The peak equals the limit" is what we measured, not
something the kernel promises: cgroup v2 documents memory.max as a limit at
which usage may temporarily go higher under some circumstances. So the rule is
written from the cause rather than from the precision of a number: the kill
comes not after a process outgrows the limit but because there was nothing left
to free inside the group.
The program in the run does not merely ask for memory, it touches every page — and what reaches the group's counters is what is held, not what was asked for: 1600 MiB requested, a group peak of 64.0 MiB. When the next page would not fit inside the limit, the kernel simply does not hand it over. From there two paths exist. The ordinary one is to free something inside the same group and hand the memory over after all. The extreme one, when there is nothing left to free, is to kill.
The first path is what failcnt counts — that same line from the block above:
times the limit was hit (failcnt) 36
Thirty-six times the group hit its ceiling, and up to the last of them it
carried on working. The
program never knew: from where it stood, memory was simply granted. Hence a
practical conclusion worth saying out loud in an interview: a non-zero
failcnt is not a crash report, it is a sign that the group lives at its
boundary. It still works, but every further request is served through
reclaiming, which is to say more slowly.
Now the two empty lines in the same block:
stdout of the program ''
stderr of the program ''
No MemoryError, no traceback, no final message. The reason is known from the
previous lesson and written in signal(7): SIGKILL cannot be caught, blocked
or ignored. The program was not asked to stop; it was removed.
So the third rung has its answer: an empty log is not lost messages, it is a
sign of how the process died. Memory exhausted inside the process would leave
an exception and a traceback; a SIGTERM would leave whatever the handler
wrote. Empty is what SIGKILL leaves.
Mechanism 3: the victim is chosen by memory held
Now the question the ladder was built for: with several processes in a group,
who gets killed? The answer is in proc(5), and it is not about ordering:
The badness heuristic assigns a value to each candidate task ranging from 0
(never kill) to 1000 (always kill) to determine which process is targeted. The
units are roughly a proportion along that range of allowed memory the process
may allocate from, based on an estimation of its current memory and swap use.
For example, if a task is using all allowed memory, its badness score will be
1000.
And separately, what counts as "allowed memory" in a container's case:
If it is due to a memory limit (or swap limit) being reached, the allowed memory
is that configured limit.
So the score is the share of the group's limit a process occupies. Check it: a group limited to 160 MiB with two processes in it. The small one takes 40 MiB and holds them; the big one asks for 600.
3. WHO GETS KILLED: the biggest, not the last one
-------------------------------------------------
small process (40 MiB, holding it) alive
big process (asked for 600 MiB) killed
victim big
That is good news and bad news at once. Good: a well-behaved neighbour usually survives someone else's mistake. Bad: "usually" is not "always". The score is computed from the share held, and when two processes hold about the same, small things decide the outcome.
The choice can be nudged by hand — oom_score_adj is added to the score, and
its range is documented:
The lowest possible value, -1000, is equivalent to disabling OOM-killing
entirely for that task, since it will always report a badness score of 0.
Use it carefully: a protected process does not stop consuming memory, it stops being a candidate. The memory still runs out and the kernel kills somebody else — possibly the one the protected process cannot work without.
Mechanism 4: the evidence that separates OOM from a stop
Exit code 137 is the same for a process killed by the OOM killer and for one
finished off by somebody else's SIGKILL — the one that arrives at the end of a
grace period when a container is stopped, as in the previous lesson. Both are
128 + 9. What separates them is not the code but the kernel's own record — the
second block of the run:
2. THE KERNEL SAYS IT, THE APPLICATION DOES NOT
-----------------------------------------------
constraint reported by the kernel CONSTRAINT_MEMCG
cgroup that ran out /de_oom_kill
victim named by the kernel python3
The constraint field is what says where memory ran out. In the run the kernel
named a group: CONSTRAINT_MEMCG — so the group's limit is the cause. Had the
line named something other than a group, the cure would be entirely different:
the machine's memory rather than the container's limit.
So here is the check, and it is worth giving in this order: an OOM kill has a
witness, and the witness is the kernel. Three questions in a row — is there an
oom-kill:constraint= line near that moment; which group does it name; and does
the timing coincide with an orchestrator event? If you could read dmesg and
the line is not there, there are no grounds for calling it an OOM kill: the
search moves to whoever may send signals.
Deeper: the same code, a different limit, a different outcome
The fourth block of the run is the same code on the same machine with the same request for 1600 MiB, under a 2048 MiB limit:
4. THE SAME PROGRAM AND THE SAME REQUEST, A LIMIT THAT FITS
-----------------------------------------------------------
limit 2048 MiB
exit code 0
stdout of the program allocated 1600 MiB
times the limit was hit (failcnt) 0
Zero in failcnt and zero as the exit code. Nothing in the program changed —
the permission did.
The fifth block answers the question that follows from it: is all memory equally dangerous? A 256 MiB file — four times the limit — was read through a group limited to 64 MiB:
5. PAGE CACHE COUNTS TOO, BUT IT CAN BE GIVEN BACK
--------------------------------------------------
limit 64 MiB
page cache dropped before the read yes
file read through the group 256 MiB
exit code 0
stdout of the program read 256 MiB
peak usage 63.2 MiB
times the limit was hit (failcnt) 95
Ninety-five hits against the ceiling, a peak right at it — and exit code zero. Page cache is charged to the group, but it can be given back, and the kernel gave it back. What cannot be given back is anonymous memory — the kind that killed the process in the first block.
Hence a distinction that usually goes unmade: a large failcnt in a program
that reads a lot from disk more often means lost speed than an approaching
death. What to watch is anonymous memory.
From this follows the practical way of setting limits that gets asked as "how do you choose a limit". Two different quantities have to be read:
- peak usage says how much the group needs in the worst case seen so far;
failcntsays how often it has hit its current ceiling.
A peak close to the limit with a growing failcnt means the headroom is gone
and the next spike ends in a kill. A failcnt of zero with a peak three times
below the limit means the limit is too generous — and on a densely packed
machine somebody else is paying for that headroom.
How to answer in an interview
Short answer: a memory limit is set on a group of processes, and when the
group hits it the kernel first tries to free memory inside the group; when that
fails, it kills one of the group's processes with SIGKILL. That is why the
application log is empty and why the outside sees 137, which is 128 + 9. Usage
does not run away past the limit either: in the measured run the group's peak
climbed exactly to it and stopped there.
That is enough for a correct answer. What follows is what you add when the interviewer digs.
If the interviewer digs deeper
Three details separate a good answer. First, you say that hitting the limit
is not the same as dying: failcnt grows in a perfectly healthy group and
means life at the boundary. Second, you name what the victim is chosen by: the
share of the limit a process holds, not the order of requests. Third, you know
the evidence that separates an OOM kill from an orchestrator's stop — the kernel
line with constraint=CONSTRAINT_MEMCG and a group name; the exit code alone is
not enough.
And one thing that is easy to overdo. Saying "a process never exceeds the limit
by a byte" passes a measurement off as a guarantee. Our peak did climb to the
limit and stop there, but cgroup v2 documents memory.max as a limit at which
usage may temporarily go higher under some circumstances. The precise phrasing
is this: the kill is not for exceeding the limit but for having nothing left
to free inside the group — and that is checked with the group's peak and
failcnt files rather than with arithmetic.
Next they ask
Why can the application not catch the shortage and free its own cache?
Because nobody asks it. The kernel does not tell a process "your group is
running out" — it either frees pages itself or kills. SIGKILL cannot be
intercepted, so there is no "last breath" handler to write.
Knowing you are near the boundary is possible only in advance and on your own
initiative: read the group's usage and failcnt — the very files this lesson's
numbers come from — and release what you hold on a threshold of your choosing.
That is polling, not an exception inside your code: the kernel does not discuss
"too late" with the program.
Does raising the limit make the problem go away?
In exactly one case: the program really did need more than it was allowed, and
its peak is finite. Then the new limit sits above the peak and failcnt stays
at zero, which is what the fourth block of the run shows.
If consumption grows over time — a leak, an unbounded cache, a buffer that keeps accumulating — the limit only postpones the kill. Telling the two apart takes no code reading: check whether the peak levels off. A peak that keeps climbing is not cured by a limit.
Is the file cache counted against the limit?
Yes, and the fifth block of the run measures it: a 256 MiB file read through a group limited to 64 MiB gave 95 hits against the ceiling and a peak right at it — with exit code zero.
The difference from anonymous memory is that cache can be given back. So a
large failcnt in a program that reads a lot from disk more often means constant
cache eviction — lost speed — than an approaching death. What to watch is
anonymous memory: that cannot be given back.
How does a limit differ from a resource request in an orchestrator?
The kernel knows exactly one quantity — the group's limit: it is written in
the group's own file and enforced as shown above. The numbers by which tasks are
placed on machines live outside the kernel, and /sys/fs/cgroup has none of
them.
Hence the classic failure: a task is placed by one number and killed by another. The kernel enforces the limit regardless of the reasoning that put the task there — and whether the limits on a machine add up is not something it checks.
Common misconceptions
the process exceeded its memory limit, so it was killed
The kill is not for exceeding: accounting counts resident pages, and a page that would not fit inside the limit is simply not handed over. Measured: a 64 MiB limit and a group peak of 64.0 MiB against a request for 1600. That is not a "never by a byte" guarantee, though — cgroup v2 documents memory.max as a limit at which usage may temporarily go higher under some circumstances. The cause of death is elsewhere: there was nothing left to free inside the group.
on a shortage the program gets a MemoryError and can do something about it
It does not: the measured stdout and stderr of the killed process are empty. MemoryError happens when memory runs out inside a process — hitting the address space, for instance. Here it ran out for the group, and the kernel has nothing to discuss with the program: it does not ask, it kills — and SIGKILL cannot be caught.
a non-zero failcnt means something broke
It means the group lives at its boundary. Measured: 36 hits against the ceiling in a single run, and all but the last ended in reclaimed memory rather than a death. It is a "no headroom, and work is slower" signal, not a crash report.
the one that asked for memory at the wrong moment gets killed
The choice follows the share of the limit a task holds: "if a task is using all allowed memory, its badness score will be 1000". Measured: in a group limited to 160 MiB the small process holding 40 MiB — started first — survived, and the big one was killed. Asking and being chosen are different roles, and they do not always coincide.
exit code 137 means out of memory
137 is 128 + 9, that is "killed by SIGKILL"; the very same code appears when an orchestrator finishes a process off after a grace period. Only the kernel's record separates them: oom-kill:constraint=CONSTRAINT_MEMCG and the group's name. No line, no grounds for calling it an OOM kill.
oom_score_adj = -1000 protects the application from OOM
It protects it from selection, not from the shortage: the task stops being a candidate but does not stop consuming. Memory still runs out and the kernel kills somebody else in the same group — possibly the one without which the protected process is useless.
since the limit is per group, only the container's total size matters
How many processes it consists of matters too: the limit is shared, and a helper takes memory away from the main process. For the same reason "my process is small" guarantees nothing — the kill happens in a group, not in a process.
Practice
Two exercises. Answer first, then check against the real output: in both, the correct answer comes from a script's committed output rather than being written by hand.
Practice · predict the output
group = make_group("de_practice_kill", 64)
done = subprocess.run(
[sys.executable, "-c", EATER, group, "1600"], capture_output=True, text=True
)
print(128 - done.returncode)
print(done.stderr.strip() or "none")
print(victim_of(make_group("de_practice_victim", 160)))Practice · estimate
Knowledge check
A container is given a 512 MiB limit. The program allocates and touches memory in a loop. What will the group's peak usage show at the moment of death?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A memory limit belongs to a group of processes, not to a process. Accounting is per group as well, and when the group has nowhere left to grow the kernel first frees what it can inside it and, if there is nothing left to free, ends one of its processes. The kill is for the group's total, so "my process is small" is no protection.
- Hence the consequence that matters: the program sees neither an error nor an exception. What kills it is
SIGKILL, andSIGKILLcannot be caught: measured — exit code −9, 137 from the outside, emptystdout, emptystderr. An empty log here is not lost messages but a sign of how the process died. Nor does usage "swell past the limit": in a run with a 64 MiB limit the program asked for 1600 MiB and the group's peak stopped at 64.0 MiB. - Beyond that is what separates knowing from having read. Hitting the limit is an ordinary event rather than a death: in the same run the
failcntcounter reached 36, and up to the last of those thirty-six the group carried on working, because the kernel freed what it could. The victim is chosen by memory held rather than by who asked last: in a group limited to 160 MiB the small process survived and the big one was killed — the kernel computes a badness score whose basis is "the amount of memory used by the process". Exit code 137 does not mean "OOM", it means "killed bySIGKILL": what separates the two is one kernel line,oom-kill:constraint=CONSTRAINT_MEMCG, naming the group that ran out. The same code with the same request finished normally under a 2048 MiB limit —failcntzero, exit code 0; and page cache, though charged to the group, is given back: a 256 MiB file read through a group limited to 64 MiB gave a peak of 63.2 MiB,failcnt95 and exit code 0. As for the peak arriving exactly at the limit: that is the result of our run, not a guarantee — cgroup v2 documentsmemory.maxas a limit at which usage may temporarily go higher under some circumstances.
In fact
- The kill is not for exceeding: accounting counts resident pages, and a page that would not fit inside the limit is simply not handed over. Measured: a 64 MiB limit and a group peak of 64.0 MiB against a request for 1600. That is not a "never by a byte" guarantee, though — cgroup v2 documents
memory.maxas a limit at which usage may temporarily go higher under some circumstances. The cause of death is elsewhere: there was nothing left to free inside the group. - It does not: the measured
stdoutandstderrof the killed process are empty.MemoryErrorhappens when memory runs out inside a process — hitting the address space, for instance. Here it ran out for the group, and the kernel has nothing to discuss with the program: it does not ask, it kills — andSIGKILLcannot be caught. - It means the group lives at its boundary. Measured: 36 hits against the ceiling in a single run, and all but the last ended in reclaimed memory rather than a death. It is a "no headroom, and work is slower" signal, not a crash report.
- The choice follows the share of the limit a task holds: "if a task is using all allowed memory, its badness score will be 1000". Measured: in a group limited to 160 MiB the small process holding 40 MiB — started first — survived, and the big one was killed. Asking and being chosen are different roles, and they do not always coincide.
- 137 is
128 + 9, that is "killed bySIGKILL"; the very same code appears when an orchestrator finishes a process off after a grace period. Only the kernel's record separates them:oom-kill:constraint=CONSTRAINT_MEMCGand the group's name. No line, no grounds for calling it an OOM kill. - It protects it from selection, not from the shortage: the task stops being a candidate but does not stop consuming. Memory still runs out and the kernel kills somebody else in the same group — possibly the one without which the protected process is useless.
- How many processes it consists of matters too: the limit is shared, and a helper takes memory away from the main process. For the same reason "my process is small" guarantees nothing — the kill happens in a group, not in a process.
What is covered
- What is actually being asked
- Base: a container has a memory budget, and it is not the application that counts it
- Mechanism 1: the limit belongs to the group, not the process
- Mechanism 2: usage stops at the limit rather than growing through it
- Mechanism 3: the victim is chosen by memory held
- Mechanism 4: the evidence that separates OOM from a stop
- Deeper: the same code, a different limit, a different outcome
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
5 SOURCES
- cgroups(7), Linux man-pages 6.7Official documentation. What the memory controller is and what it can do: "The memory controller supports reporting and limiting of process memory, kernel memory, and swap used by cgroups". The property that matters for this lesson follows from it: both the accounting and the limit belong to the GROUP, not to an individual process.https://man7.org/linux/man-pages/man7/cgroups.7.html
- proc(5): /proc/pid/oom_score_adj, Linux man-pages 6.7Official documentation. How the kernel picks a victim: "The badness heuristic assigns a value to each candidate task ranging from 0 (never kill) to 1000 (always kill) to determine which process is targeted. The units are roughly a proportion along that range of allowed memory the process may allocate from, based on an estimation of its current memory and swap use. For example, if a task is using all allowed memory, its badness score will be 1000". And what makes that heuristic applicable to a container: "If it is due to a memory limit (or swap limit) being reached, the allowed memory is that configured limit". Plus the bounds of manual tuning: "Acceptable values range from -1000 (OOM_SCORE_ADJ_MIN) to +1000 (OOM_SCORE_ADJ_MAX)", and what the lower bound buys: "The lowest possible value, -1000, is equivalent to disabling OOM-killing entirely for that task, since it will always report a badness score of 0".https://man7.org/linux/man-pages/man5/proc_pid_oom_score_adj.5.html
- proc(5): /proc/pid/oom_score, Linux man-pages 6.7Official documentation. What the badness score is built on first of all: "The basis for this score is the amount of memory used by the process". That is the answer to "who gets killed": not whoever asked last, but whoever holds more.https://man7.org/linux/man-pages/man5/proc_pid_oom_score.5.html
- Bash Reference Manual, Exit StatusOfficial documentation. Where 137 and 143 come from: "When a command terminates on a fatal signal N, bash uses the value of 128+N as the exit status". It is the convention of whoever prints the result rather than the kernel's: the kernel reports the signal number separately from the exit code.https://www.gnu.org/software/bash/manual/bash.html#Exit-Status
- signal(7), Linux man-pages 6.7Official documentation. Why nothing is left in the application log after the kill: "SIGKILL and SIGSTOP cannot be caught, blocked, or ignored". No handler, no final log line, no flushed buffers.https://man7.org/linux/man-pages/man7/signal.7.html