Module 09 — Page Cache, Swap & the OOM Killer
Updated 22 August 2026
This is the module that fixes the single most common misunderstanding in Linux operations: "the server has no free memory". It almost always does, and the number people are reading is the wrong one. By the end you will be able to say exactly how much memory a machine really has left, and what happens on the day it genuinely runs out.
🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
From Module 03 — that a file's contents live in blocks on a disk, and what an inode is.
From Module 07 — how to read /proc/pressure/, and that cgroups can cap a workload's resources. Section C3 goes further into cgroup v2's memory files; everything it needs is explained there, and Module 12 is where namespaces and containers are covered properly.
From Module 08 — pages and frames, minor versus major faults, file-backed versus anonymous memory, and the meaning of Private_Dirty. This module depends on all of it.
📚 Part A · The page cache
A1 · Why free memory is wasted memory
Disk is roughly a hundred thousand times slower than RAM. So when Linux reads a file from disk, it does something obvious in hindsight: it keeps the copy. The next process that wants those bytes gets them from memory instead of from the disk.
That store of file contents held in RAM is the page cache. It is not a small buffer. On a healthy, long-running machine it will grow until it has consumed essentially all otherwise-unused memory, because there is no reason to leave RAM empty.
The consequence is the thing that confuses everybody:
The page cache is also not lost memory. Clean cached pages — ones that match what is on disk — can be dropped instantly, with no writing and no waiting, because the data is still on the disk. That is why a machine showing 200 MB free can start a program needing 3 GB without any difficulty.
A librarian works at a desk in front of a very large, very slow archive in the basement. Fetching a volume from the archive takes twenty minutes.
So when a book comes up, she does not send it back down after use. She leaves it on the desk. The desk fills up with books nobody has explicitly asked for again — and a visitor glancing in would say "there is no free space on that desk, she must be overwhelmed".
The opposite is true. An empty desk would mean every single request costs a twenty-minute trip. The full desk is the whole point of having a desk.
And the desk is not really full, because the books on it are copies of things still safely in the archive. When someone needs to spread out a large map, she sweeps a stack of them aside instantly — no need to carry anything back down, because nothing on the desk is unique.
The one exception is a book she has been annotating. That one is not a copy any more. Before that space can be reused, the annotations must be carried back down and filed. Those are dirty pages, and Section A3 is about them.
Where the analogy stops working. A librarian chooses what to keep by judgement. The kernel uses a mechanical rule based on recency and reuse, with no idea what any of the data means.
🧪 Exercise A1.1 — Watch the cache fill, and watch it pay off
# A file big enough to notice, but small enough to be quick
dd if=/dev/urandom of=/tmp/cachetest bs=1M count=512 status=none
# Start from a cold cache: forget everything held from disk
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
# How much file data is the kernel holding right now?
grep -E '^(MemFree|Buffers|Cached):' /proc/meminfo
# First read: this has to come off the disk
echo "--- cold read ---"
time cat /tmp/cachetest > /dev/null
grep -E '^(MemFree|Buffers|Cached):' /proc/meminfo
# Second read: identical command, identical file
echo "--- warm read ---"
time cat /tmp/cachetest > /dev/null
rm -f /tmp/cachetest✅ Expected result — click to reveal
MemFree: 2884416 kB
Buffers: 8104 kB
Cached: 184920 kB
--- cold read ---
real 0m2.914s
user 0m0.004s
sys 0m0.221s
MemFree: 2358140 kB
Buffers: 9216 kB
Cached: 712488 kB
--- warm read ---
real 0m0.118s
user 0m0.008s
sys 0m0.110sWhat to read out of this.
The cold read took 2.9 seconds. The warm read took 0.118 seconds — about 25 times faster, running the identical command on the identical file. Nothing was optimised. The data was simply already in RAM.
Now follow the memory. Cached went from 184 MB to 712 MB — an increase of 527 MB, which is the 512 MB file plus a little. MemFree fell by almost exactly the same amount, from 2884 MB to 2358 MB.
If you were watching a free graph you would see 526 MB of memory "disappear" the moment someone read a file. Nothing disappeared. It moved from the free column to the cache column, and it is available again the instant anything asks for it.
Look at the sys times as well. The cold read spent 0.221 s in the kernel and 2.9 s in total — so 2.7 seconds of it was spent waiting, not computing. The warm read spent 0.110 s in the kernel out of 0.118 s total: almost no waiting at all. That waiting is exactly the wa column from Module 07, and it is why a machine with a cold cache looks like it has a slow disk.
If your two times are almost identical, the drop went to a filesystem that was already serving from a lower-level cache — common on some virtualised storage and on machines with a lot of free RAM on the hypervisor. Try a file several times larger than the guest's RAM if you want to force the difference.
A2 · Reading free and /proc/meminfo properly
free has six columns and most people read the wrong one. Here is what each actually means.
| Column | What it is | Should you care? |
| total | Installed RAM the kernel can use | Yes, as the denominator |
| used | total minus free minus buff/cache | Roughly — it is a derived number, not a measurement |
| free | RAM holding nothing at all | No. On a healthy machine this is near zero by design |
| shared | Mostly tmpfs — files that live only in RAM | Yes if you use /dev/shm or tmpfs mounts |
| buff/cache | The page cache plus filesystem metadata buffers | Yes — this is memory being useful, and mostly recoverable |
| available | An estimate of what a new program could get without swapping | Yes. This is the number you want. |
available comes from MemAvailable in /proc/meminfo, added in Linux 3.14 precisely because everyone was computing it wrong by hand. The kernel calculates it from free memory, the reclaimable part of the page cache, and the reclaimable part of the slab caches — then subtracts what it must keep in reserve to function. Crucially, it knows that not all cache is reclaimable, which is why available is always somewhat less than free + buff/cache.
A car park has 500 spaces. A sign at the entrance reads "Free spaces: 4", and drivers turn away.
But 300 of those spaces hold cars the valet parked there himself, keys in hand, purely because leaving them empty was pointless. He can move any of them in seconds. The honest sign would read "Spaces available: 304" — and that is MemAvailable.
Not all 300 can be moved, though. A few are boxed in behind a delivery. A few belong to people who are actively loading them. The valet knows which, and his figure of 304 already accounts for it. A driver doing the arithmetic from outside — "500 minus 196 parked properly, so 304" — would sometimes be right and sometimes badly wrong, because he cannot see which valet-parked cars are stuck.
Where the analogy stops working. A valet moving a car takes real effort every time. Dropping a clean cached page costs the kernel almost nothing, which is why it is willing to fill every space without hesitation.
🧪 Exercise A2.1 — Make memory "disappear" and prove it did not
# Baseline
free -m
# Make a file about the size of available memory, so that reading it
# genuinely fills the cache. Guard against filling the disk.
AVAIL=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo)
FREEDISK=$(df -Pm /tmp | awk 'NR==2 {print $4}')
SZ=$(( AVAIL < FREEDISK - 1024 ? AVAIL : FREEDISK - 1024 ))
echo "making a ${SZ} MiB file"
dd if=/dev/zero of=/tmp/big bs=1M count=$SZ status=none
# Start from a cold cache so the change is unambiguous
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
free -m
echo "--- after reading the file ---"
cat /tmp/big > /dev/null
free -m
# The numbers that matter, in the order /proc/meminfo prints them
grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|Shmem|SReclaimable):' /proc/meminfo
# Prove the cache is not in the way: ask for a big chunk of memory
echo "--- allocating 800 MB ---"
BIG=$(head -c 800000000 /dev/zero | tr '\0' 'x')
free -m
unset BIG
rm -f /tmp/big✅ Expected result — click to reveal
total used free shared buff/cache available
Mem: 3844 78 3510 12 276 3766
making a 3766 MiB file
total used free shared buff/cache available
Mem: 3844 74 3700 12 86 3770
--- after reading the file ---
total used free shared buff/cache available
Mem: 3844 138 402 12 3439 3706
MemTotal: 3936256 kB
MemFree: 411648 kB
MemAvailable: 3794944 kB
Buffers: 43712 kB
Cached: 3379968 kB
Shmem: 12928 kB
SReclaimable: 98304 kB
--- allocating 800 MB ---
total used free shared buff/cache available
Mem: 3844 940 150 12 2880 2904What to read out of this.
After reading the file, free collapsed from 3510 MB to 402 MB. A monitoring dashboard would call that an 89% memory alert. Look at the same row's last column: available went from 3766 to 3706 — a drop of 60 MB, which is nothing. The memory changed category, not owner.
buff/cache absorbed it: 276 MB to 3439 MB.
Now the proof, in the last block. A program asked for 800 MB on a machine showing 402 MB free. It got it — no error, no ENOMEM. buff/cache dropped from 3439 MB to 2880 MB, so the kernel threw away 559 MB of cached pages and took the rest from what was free. Nobody was consulted and nothing was logged.
available fell from 3706 to 2904, a drop of 802 MB for an 800 MB allocation. That is the only column that tracked reality, and it tracked it almost exactly.
One detail worth noticing for later: Cached: 3379968 kB includes Shmem: 12928 kB. Shared memory and tmpfs files appear inside Cached but cannot be dropped, because there is no disk copy to reload them from. It is a rounding error on this machine; Section D2 shows one where it is 871 MB, and it is one of the reasons MemAvailable exists rather than leaving you to add the columns yourself.
Note on used. On Ubuntu 24.04 and other procps-ng 4 systems, used is total − available — which is why it barely moves in this transcript even as free collapses. On an older procps it would be total − free − buff/cache and would look quite different for the same machine.
A3 · Dirty pages and writeback
Reads are only half the story. When a program writes to a file, the write does not go to the disk. It goes into the page cache, the page is marked dirty, and the program is told the write succeeded immediately.
Kernel threads then write dirty pages out in the background, and that is called writeback. Two thresholds control it, and both are percentages of available memory rather than of total RAM:
| Setting | Default | What happens at that point |
| vm.dirty_background_ratio | 10 | Kernel threads start writing dirty pages out, quietly, in the background |
| vm.dirty_ratio | 20 | The writing process itself is blocked until enough has been written out |
That second one is the interesting one. Once dirty pages reach dirty_ratio, a program calling write() simply stops inside the kernel until the backlog clears. From the application's point of view a write that normally takes microseconds suddenly takes seconds, with nothing in any log to explain it.
You write a letter and put it in the out-tray. The moment it leaves your hand you consider it "sent", and you move on. It is not sent. It is in a tray on your desk.
The post room empties the tray periodically. That is background writeback: invisible, and it keeps the tray from filling.
If you write letters far faster than the post room collects them, the tray overflows onto the floor — so a rule kicks in: once the pile passes a certain height you are not allowed to write another letter until some have gone. You sit there, pen in hand, unable to work. That is dirty_ratio, and to anyone watching you appear to have frozen for no reason.
And the crucial part: if the building burns down while your letters are still in the tray, they were never sent, no matter how certain you were that you had sent them. Walking the letter to the post room yourself and waiting for the stamp is fsync.
Where the analogy stops working. You can see your own out-tray. A program cannot see how much of its data is still dirty, which is why the stall arrives with no warning.
🧪 Exercise A3.1 — See the lie in a fast write, then make it honest
# Watch dirty pages while we write. Start from a clean slate.
sync
grep -E '^(Dirty|Writeback):' /proc/meminfo
# The thresholds on this machine
sysctl vm.dirty_ratio vm.dirty_background_ratio
# Write 1 GiB WITHOUT forcing it to disk.
# The time you see is the time to fill RAM, not the time to write a disk.
echo "--- no fsync ---"
time dd if=/dev/zero of=/tmp/writetest bs=1M count=1024 status=none
# Look immediately - the data is still in memory, unwritten
grep -E '^(Dirty|Writeback):' /proc/meminfo
# Now force it out and see what it really costs
echo "--- the bill arrives ---"
time sync
grep -E '^(Dirty|Writeback):' /proc/meminfo
# The honest version: same write, but wait for the disk
echo "--- with fsync ---"
rm -f /tmp/writetest
time dd if=/dev/zero of=/tmp/writetest bs=1M count=1024 conv=fsync status=none
rm -f /tmp/writetest✅ Expected result — click to reveal
Dirty: 132 kB
Writeback: 0 kB
vm.dirty_ratio = 20
vm.dirty_background_ratio = 10
--- no fsync ---
real 0m0.612s
Dirty: 412160 kB
Writeback: 42112 kB
--- the bill arrives ---
real 0m3.884s
Dirty: 8 kB
Writeback: 0 kB
--- with fsync ---
real 0m4.301sWhat to read out of this.
dd reported writing 1 GiB in 0.612 seconds — about 1.7 GB/s. That is not the disk's speed. It is the speed of copying into RAM. Immediately afterwards, Dirty reads 402 MB: about 40% of what was "written" had not touched the disk at all, and Writeback: 42112 kB shows another 42 MB in flight.
Then sync took 3.884 seconds doing nothing but waiting for the disk — flushing what was still dirty plus what was still queued behind it. Add the two together and you get about 4.5 seconds, which matches the honest measurement at the bottom: conv=fsync took 4.301 seconds for the same gigabyte.
The write was never fast. The cost was deferred, and dd took credit for work it had not done. This is why benchmark numbers without fsync are meaningless, and why "our storage does 1.7 GB/s" claims should always be met with "measured how?".
Notice Dirty was nowhere near the full 1 GiB. Background writeback had already started well before the write finished, which is dirty_background_ratio doing its job. Push the file size up and you can watch dd itself get blocked partway through on dirty_ratio — the write stalls mid-command, for seconds, with nothing in any log to explain it.
A subtle trap in the numbers. Both ratios are percentages of dirtyable memory — free pages plus reclaimable file pages — not of total RAM, which is why you cannot work out the threshold in your head from MemTotal. On a machine whose available memory is shrinking, the dirty thresholds shrink with it, so a write pattern that ran fine yesterday can start stalling today without anyone changing a setting.
🎯 Interview questions — The page cache
Q. What does the free command show in Linux?
Six columns. total is usable installed RAM. free is memory holding nothing at all. buff/cache is the page cache plus filesystem metadata buffers — file data the kernel is keeping in RAM because throwing it away would be pointless. shared is mostly tmpfs. used is a derived leftover: total minus free minus buff/cache. And available is the kernel's own estimate of what a new program could obtain without swapping.
The one to read is available. free being near zero is the normal, healthy state of a Linux machine that has been up for a while.
The details that separate candidates: saying that available is not free + buff/cache. It comes from MemAvailable, added in Linux 3.14, and it deliberately excludes cache that cannot actually be reclaimed — dirty pages awaiting writeback, tmpfs and shared memory that has no disk copy to reload from, and a working reserve the kernel keeps so it does not grind. Anyone still adding the columns by hand is producing a worse number than the kernel already publishes.
Q. A server shows 98% memory used. How worried are you?
Not at all yet, and I would not act on that number. I would run free -m and read the available column, or grep MemAvailable /proc/meminfo. If the memory is in buff/cache, it is the page cache doing its job and it will be handed back the moment anything needs it. If available is also near zero, then there is something to investigate.
The real signal is not a level at all — it is /proc/pressure/memory, which reports how much time processes actually lost waiting for memory. A machine at 98% with zero memory pressure is fine. A machine at 70% with rising pressure is in trouble.
The details that separate candidates: naming this as the classic bad alert and explaining what to replace it with. "Memory usage above 90%" fires constantly on healthy hosts, so teams either raise the threshold until it is useless or learn to ignore it — and then miss the host that is genuinely failing. Alerting on MemFree is alerting on how much RAM you are wasting. The professional answer is MemAvailable as a ratio, plus PSI memory pressure as the leading indicator.
Q. A write() call returned success. Is the data on disk?
No. It is in the page cache, marked dirty, and the kernel will write it out at some point over the following seconds. If the machine loses power before then the data is gone, and the file can be left partially updated. Durability requires fsync() on the file descriptor — or fdatasync(), or opening with O_SYNC, or sync for the whole system.
The details that separate candidates: two follow-ons. First, fsync on the file is not always enough: creating or renaming a file also needs the directory fsynced, which is why the safe write pattern is write-to-temp, fsync the temp file, rename, then fsync the directory. Second, this is exactly why storage benchmarks without fsync are meaningless — dd writing a gigabyte in half a second is measuring the speed of memory, and the disk time arrives later on someone else's stopwatch.
♻️ Part B · Reclaim and swap
B1 · How the kernel takes memory back
When memory runs short the kernel does not panic. It reclaims: it finds pages it can take back and takes them. The order it chooses in is the whole subject.
Module 08 gave you the distinction that decides everything here. File-backed pages have a copy on disk. Anonymous pages do not.
| Page kind | Can it be reclaimed? | What it costs |
| Clean, file-backed | Yes, immediately | Nothing. Drop it; re-read from the file if it is needed again |
| Dirty, file-backed | Yes, after writeback | A disk write first |
| Anonymous | Only if there is swap | A disk write to swap, and a disk read to get it back |
| Anonymous, no swap | No | It cannot be reclaimed at all. This is what makes machines run out |
The kernel keeps pages on LRU lists — least recently used — with a separate pair of lists for file and anonymous pages. Under pressure it scans from the cold end and takes what it finds. There are two lists per kind, active and inactive, so that a page has to be used more than once before it is considered worth keeping.
The librarian's desk is full and someone needs to spread out a map. She looks at what is on the desk.
Reference books she pulled from the archive and did not write on go first. She just puts them on the trolley — no effort, and the archive still has them.
Books she has annotated are more work: the annotations have to be copied back into the archive copy before the book can leave the desk. Slower, but possible.
Her own handwritten notes are the problem. There is no archive copy. She either finds a drawer to file them in — that is swap — or they stay on the desk taking up room, because throwing them away would destroy them.
If there is no drawer and the desk is still too full after the books have gone, something has to give, and it will not be gentle.
Where the analogy stops working. The librarian can look at a note and judge whether it still matters. The kernel cannot read your data; it only knows when each page was last touched.
🧪 Exercise B1.1 — Watch the kernel choose what to sacrifice
# What is the machine holding, split by kind?
grep -E '^(MemTotal|MemAvailable|Cached|Active\(anon\)|Inactive\(anon\)|Active\(file\)|Inactive\(file\)|SwapTotal|SwapFree):' /proc/meminfo
# Fill the page cache with a large file
dd if=/dev/urandom of=/tmp/filler bs=1M count=1500 status=none
cat /tmp/filler > /dev/null
echo "--- cache filled ---"
grep -E '^(MemFree|Cached|Active\(file\)|Inactive\(file\)):' /proc/meminfo
# Now demand a large chunk of ANONYMOUS memory and watch what gets evicted
echo "--- 1 GB of anonymous memory requested ---"
BIG=$(head -c 1000000000 /dev/zero | tr '\0' 'x')
# Note: since Linux 5.8 a freshly allocated anonymous page goes on the
# INACTIVE anon list and is only promoted on a second reference.
grep -E '^(MemFree|Cached|Inactive\(anon\)|Inactive\(file\)):' /proc/meminfo
# How many pages did the kernel scan and reclaim to make that happen?
# The trailing space anchors it - without one, pgscan_direct also matches
# pgscan_direct_throttle. Note /proc/vmstat lists pgsteal_* before pgscan_*.
grep -E '^(pgscan|pgsteal)_(kswapd|direct) ' /proc/vmstat
unset BIG
rm -f /tmp/filler✅ Expected result — click to reveal
MemTotal: 3936256 kB
MemAvailable: 3181204 kB
Cached: 412508 kB
Active(anon): 284116 kB
Inactive(anon): 41208 kB
Active(file): 102344 kB
Inactive(file): 298760 kB
SwapTotal: 0 kB
SwapFree: 0 kB
--- cache filled ---
MemFree: 1332108 kB
Cached: 1948116 kB
Active(file): 118904 kB
Inactive(file): 1821644 kB
--- 1 GB of anonymous memory requested ---
MemFree: 331884 kB
Cached: 1905228 kB
Inactive(anon): 1304552 kB
Inactive(file): 1782108 kB
pgsteal_kswapd 0
pgsteal_direct 0
pgscan_kswapd 0
pgscan_direct 0What to read out of this.
Start with the split. After reading the file, Inactive(file) holds 1821 MB — the file was read once, so its pages went straight onto the inactive list. Pages only reach Active(file) if they are used a second time. That is the kernel refusing to let a single large sequential read push everything else out, and it is why cat-ing a huge file does not destroy your database's cache.
Then the anonymous request. Inactive(anon) jumped to 1274 MB — note inactive, not active: since Linux 5.8 a page that has just been allocated and written once sits on the inactive anon list and is only promoted if something touches it again. MemFree fell by roughly the gigabyte that was asked for, and Cached gave up 42 MB.
On this run the kernel did not have to sacrifice much cache at all, because there was still a gigabyte free. That is the normal case, and it is worth seeing before the interesting one. Repeat the exercise asking for 2.5 GB instead of 1 GB and Cached will collapse: the kernel discards page cache without hesitating, without logging anything, and without any delay you would notice.
Now the counters, which are the part most people have never looked at. pgscan_kswapd and pgsteal_kswapd are reclaim done by the background thread; pgscan_direct and pgsteal_direct are reclaim done by the allocating process itself, while it waits. All four are 0 here, which is exactly what a machine with room to spare looks like — nothing had to be reclaimed at all.
pgsteal_direct climbing is one of the best early warnings of memory trouble there is — it means allocation is no longer free, and processes are doing the kernel's housekeeping on their own time.
Finally, note SwapTotal: 0. Every anonymous page on this machine is unreclaimable. The kernel got away with it here because there was cache to give up; Section C2 is what happens when there is not.
B2 · Swap — what it is actually for
Swap is a place on storage where the kernel can put anonymous pages so it can reuse their frames. That is the entire definition. Everything people believe about swap beyond that is usually wrong.
Three misconceptions, in order of how much damage they do:
"Swap is emergency memory for when you run out." No. Swap is used routinely, long before anything is short, to move pages that nothing has touched in hours out of the way so that RAM can hold things that are actually being used. A machine with 8 GB free can be swapping, correctly.
"Swapping means the machine is thrashing." Also no. Having pages in swap is normal and harmless. What hurts is a high rate of pages moving in and out — and those are two different measurements. SwapUsed is a level; si/so in vmstat is a rate. Only the rate matters.
"Turning swap off makes things faster / safer." It removes the kernel's only option for anonymous pages, which means it cannot reclaim them at all. The machine does not degrade gracefully any more; it goes straight from fine to killing processes.
Two variants are worth knowing because they change the trade-off completely:
| Kind | Where the pages go | When it makes sense |
| Disk or file swap | A partition or file on storage | The default. Cheap capacity, slow to fault back in |
| zram | A compressed block device in RAM | No disk at all; trades CPU for capacity. Common on laptops, containers and small cloud VMs |
| zswap | A compressed cache in RAM, in front of real swap | You have real swap but want to avoid touching it as often |
The librarian's handwritten notes have no archive copy, so they cannot simply be discarded. But most of them have not been looked at in months.
So the library rents a cheap off-site store. Old notes get boxed up and sent there, freeing desk space for work that is actually happening. Retrieving a box takes a day, which is fine, because these are notes nobody has wanted since spring.
Sending boxes off-site is not a crisis. It is good housekeeping, and the library runs better for it.
The crisis is different and looks nothing like it. It is when a note is boxed up, retrieved the next day, boxed up again the day after, retrieved again — vans running back and forth continuously while no actual work gets done. That is thrashing, and the tell is not "how many boxes are off-site" but "how many vans are on the road right now".
zram is a different arrangement: instead of an off-site store, the notes are photographed at high compression and the originals shredded, with the photos kept in a drawer on the desk. Retrieval is instant but every box costs someone time at the scanner.
Where the analogy stops working. A librarian decides which notes to box up by knowing what they are. The kernel only knows when each page was last touched, which is why a nightly backup job reading everything once can push genuinely hot pages out.
🧪 Exercise B2.1 — Find out what your swap situation actually is
# Is there any swap at all, and what kind?
swapon --show
cat /proc/swaps
# Level: how much is currently parked in swap?
grep -E '^(SwapTotal|SwapFree|SwapCached):' /proc/meminfo
# Rate: pages moving in and out RIGHT NOW. The si/so columns.
# On a healthy machine these stay at 0 even if SwapTotal is large and full.
vmstat 1 5
# The tuning knob, and what it means on this kernel
sysctl vm.swappiness
echo "(kernel $(uname -r) - swappiness range is 0-200 since 5.8)"
# Which processes are actually holding pages in swap?
sudo sh -c 'for f in /proc/[0-9]*/status; do
s=$(awk "/^VmSwap:/ {print \$2}" $f 2>/dev/null)
[ -n "$s" ] && [ "$s" -gt 0 ] && echo "$s kB $(awk "/^Name:/ {print \$2}" $f)"
done' | sort -rn | head -10✅ Expected result — click to reveal
On a typical cloud VM you will very often get nothing at all from the first two commands:
SwapTotal: 0 kB
SwapFree: 0 kB
SwapCached: 0 kB
procs -----------memory---------- ---swap-- -----io----
r b swpd free buff cache si so bi bo
0 0 0 98432 43712 1948116 0 0 2 11
0 0 0 98432 43712 1948116 0 0 0 0
vm.swappiness = 60
(kernel 6.8.0-40-generic - swappiness range is 0-200 since 5.8)That empty result is the important one. Most cloud images ship with no swap configured at all. swapon --show prints nothing, /proc/swaps has only its header, and SwapTotal is 0. Every anonymous page on that machine is permanently unreclaimable, and the kernel's only lever under pressure is throwing away page cache. When that runs out, it kills something. There is no gradual phase.
On a machine that does have swap you will see something like:
NAME TYPE SIZE USED PRIO
/swap.img file 2G 412M -2
SwapTotal: 2097148 kB
SwapFree: 1675260 kB
SwapCached: 18432 kB
r b swpd free buff cache si so bi bo
0 0 421888 112884 38104 905228 0 0 14 62
0 0 421888 112884 38104 905228 0 0 0 0
204800 kB mysqld
96256 kB snapd
41984 kB containerdRead the level and the rate separately. swpd 421888 says 412 MB is parked in swap — 20% of the swap file. That is a level, and on its own it means nothing except that some pages have not been touched in a long while. si and so are both 0: nothing is moving. This machine is not swapping. It has swapped, once, some time ago, and everything has been quiet since.
The per-process list tells you who. mysqld has 200 MB parked — almost certainly start-up code and configuration structures it touched once and never again. Forcing that back into RAM would gain nothing and cost 200 MB of cache.
The per-process figures sum to 343 MB while SwapTotal − SwapFree is 412 MB. That 69 MB gap is real and not a rounding error: swapped-out tmpfs and shared-memory pages belong to no single process, so they appear in the totals but in nobody's VmSwap. The per-process list will always undercount, which is worth knowing before you go looking for the missing megabytes.
SwapCached: 18432 kB is a subtlety worth knowing: those pages are in both places, RAM and swap. They were faulted back in but the swap copy was kept, so if they need evicting again the kernel can drop them for free. It is not double-counted waste; it is a saved write.
The alerting rule that falls out of this: never alert on swap used. Alert on si/so sustained above zero, or better, on memory pressure from Section B3.
B3 · Measuring memory pressure honestly
Everything so far has been about levels — how much is cached, how much is in swap, how much is free. Levels are poor at answering the only question that matters: is this machine suffering?
/proc/pressure/memory answers it directly. It reports the percentage of time tasks were stalled waiting for memory — waiting on reclaim, on a major fault, on writeback. It measures harm, not activity.
| Line | Meaning | What a rising number means |
| some | At least one task was stalled on memory | Someone is being slowed down |
| full | Every runnable task was stalled at once | The machine is doing nothing but reclaim. Serious |
Each line carries three running averages — avg10, avg60, avg300 — over 10, 60 and 300 seconds, plus a total in microseconds you can difference yourself.
Thrashing itself is worth naming precisely. It is the state where the working set no longer fits in RAM, so the kernel evicts a page, the program immediately faults it back in, which evicts another page, which is immediately needed too. Almost all CPU time goes to reclaim and major faults. The machine is not out of memory — it will not necessarily be OOM-killed — it is simply spending all of its time moving pages instead of doing work, and it can stay in that state for hours.
The librarian has four projects on the go and desk space for three. Every time she turns to project four, she boxes up project one; when she turns back to project one, she unboxes it and boxes up project two.
Watch the metrics she would report. Desk occupancy: 100%, unchanged all day. Boxes in the store: 1, unchanged all day. Books handled per hour: enormous. Actual work completed: almost none.
Nothing in a level-based measurement shows the problem. The only honest number is what fraction of her day was spent waiting for a box to arrive — and that is exactly what PSI reports.
And notice how it ends. She does not collapse. There is no dramatic failure. She just gets a third as much done, indefinitely, and the report at the end of the week says the desk was fully utilised.
Where the analogy stops working. She would eventually notice and ask for a bigger desk. Nothing in the kernel escalates on your behalf — thrashing is a stable state, which is why it needs an alert.
🧪 Exercise B3.1 — Read pressure, and make some
# Baseline. On an idle machine these are all near zero.
cat /proc/pressure/memory
cat /proc/pressure/io
# Total time lost so far, in microseconds - the number to difference.
# This prints two lines, one for `some` and one for `full`.
grep -o 'total=[0-9]*' /proc/pressure/memory
# Now create real pressure: repeatedly read a file larger than the memory
# available, so the cache can never hold it. Guard against filling the disk,
# and use /dev/zero - we are defeating the cache by size, not by content.
AVAIL=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo)
FREEDISK=$(df -Pm /tmp | awk 'NR==2 {print $4}')
SZ=$(( AVAIL * 2 ))
[ "$SZ" -gt $(( FREEDISK - 1024 )) ] && SZ=$(( FREEDISK - 1024 ))
echo "available: ${AVAIL} MiB - making a ${SZ} MiB file"
dd if=/dev/zero of=/tmp/thrash bs=1M count=$SZ status=none
echo "--- reading it three times ---"
for i in 1 2 3; do cat /tmp/thrash > /dev/null; done &
LOOP=$!
for i in 1 2 3 4 5; do
echo "$(date +%T) mem $(grep '^some' /proc/pressure/memory)"
echo " mem $(grep '^full' /proc/pressure/memory)"
echo " io $(grep '^some' /proc/pressure/io)"
sleep 2
done
wait $LOOP
rm -f /tmp/thrash✅ Expected result — click to reveal
some avg10=0.00 avg60=0.00 avg300=0.00 total=1841203
full avg10=0.00 avg60=0.00 avg300=0.00 total=712044
some avg10=0.12 avg60=0.31 avg300=0.44 total=98412037
full avg10=0.00 avg60=0.02 avg300=0.04 total=2104118
total=1841203
total=712044
available: 3021 MiB - making a 6042 MiB file
--- reading it three times ---
10:22:14 mem some avg10=0.00 avg60=0.00 avg300=0.00 total=1841203
mem full avg10=0.00 avg60=0.00 avg300=0.00 total=712044
io some avg10=1.84 avg60=0.62 avg300=0.19 total=98449237
10:22:16 mem some avg10=4.21 avg60=1.02 avg300=0.28 total=1925403
mem full avg10=0.31 avg60=0.08 avg300=0.02 total=718244
io some avg10=38.72 avg60=12.44 avg300=3.81 total=99223637
10:22:18 mem some avg10=7.88 avg60=2.14 avg300=0.61 total=2083003
mem full avg10=0.44 avg60=0.14 avg300=0.04 total=727044
io some avg10=44.10 avg60=16.92 avg300=5.22 total=100105637
10:22:20 mem some avg10=9.02 avg60=3.31 avg300=0.98 total=2263403
mem full avg10=0.51 avg60=0.19 avg300=0.06 total=737244
io some avg10=46.33 avg60=20.18 avg300=6.90 total=101032237
10:22:22 mem some avg10=8.41 avg60=4.02 avg300=1.34 total=2431603
mem full avg10=0.48 avg60=0.24 avg300=0.08 total=746844
io some avg10=41.27 avg60=22.44 avg300=8.11 total=101857637What to read out of this.
First, the two pressure files are different things and the exercise prints both. io pressure went to 46%; memory pressure only reached 9%. That is the correct diagnosis of what we actually built: reading a file too large to cache is an I/O problem, not a memory shortage. Nothing was short of memory — the kernel simply had to keep fetching from disk.
This is the distinction that makes PSI worth learning. A level-based metric would have shown "memory 100% used" for both a healthy cache and a genuine shortage. Pressure tells them apart, and it tells you which resource is hurting.
Second, watch the three averages move at different speeds. At 10:22:16, avg10=38.72 while avg300=3.81. The ten-second figure has already responded; the five-minute figure has barely noticed. For alerting use avg60 — avg10 is noisy enough to fire on a single backup job, and avg300 arrives after the incident is over.
Third, full for memory stayed near zero throughout. That is the reassuring signal: at no point was every task stalled. A full avg60 climbing past 10 is the one that means the machine is thrashing rather than merely working hard.
Finally, total= is a monotonically increasing count of microseconds lost. Check the arithmetic against the averages and it holds together: the io some total rises by about 800,000 µs per two-second step, and 800000 ÷ 2000000 is 40% — which is what avg10 says. The delta can never exceed the elapsed wall-clock time, so if you ever compute a figure above 100% you have made an arithmetic error, not found a catastrophe. Two readings a minute apart, subtracted and divided by 60 million, give you the fraction of that minute that was wasted — the most honest single memory metric a fleet can collect.
If your memory pressure stayed at exactly 0.00 throughout, the machine had enough RAM that the file still fitted, or the storage was fast enough that nothing stalled long enough to count. Try a file four times MemAvailable.
🎯 Interview questions — Reclaim and swap
Q. What is swap, and what is it actually for?
Swap is space on storage where the kernel can put anonymous pages — heap, stack, anything with no file behind it — so it can reuse the physical frames. File-backed pages never need it, because they can be dropped and re-read from the file.
Its purpose is not emergency memory. It is a release valve that lets the kernel move cold anonymous pages out of the way so RAM holds things that are actually being used. A machine with plenty of free memory can swap, correctly, as ordinary housekeeping.
The details that separate candidates: stating plainly that without swap, anonymous memory is completely unreclaimable, so the kernel's only lever is evicting page cache — and when that runs out it goes straight to killing a process, with no gradual phase. Turning swap off does not make a machine safer; it makes its failure mode abrupt. Also worth adding: vm.swappiness is not an eagerness dial, it is the kernel's assumed relative I/O cost of swapping versus filesystem paging, on a 0–200 scale with default 60, and values above 100 became valid in Linux 5.8 for cases where swap really is cheaper than re-reading files — zram or NVMe.
Q. How would you troubleshoot high swap usage on a Linux system?
First I would establish whether "high swap usage" is a level or a rate, because only one of them is a problem. swapon --show and SwapTotal/SwapFree give the level: how much is parked. vmstat 1 gives the rate: the si and so columns, pages moving in and out per second. A large amount parked with si/so at zero is not a problem at all — those are pages nothing has wanted for hours, and forcing them back would cost cache for no benefit.
If the rate is genuinely non-zero and sustained, I would check /proc/pressure/memory to see how much time is actually being lost, then find which processes hold swap by reading VmSwap from each /proc/PID/status, and look at whether the working set has grown or the available memory has shrunk.
The details that separate candidates: refusing to treat swap-used as an alertable metric, and naming the replacement — si/so sustained above zero, or PSI memory pressure. It is also worth mentioning SwapCached, which is memory present in both RAM and swap: those pages were faulted back in but the swap copy was kept, so evicting them again is free. People see it and assume double-counting or a leak.
Q. What is thrashing, and how would you recognise it?
Thrashing is when the active working set no longer fits in RAM, so the kernel evicts a page that is immediately needed again, faults it back in, and evicts another that is also immediately needed. Nearly all CPU time goes into reclaim and major faults, and almost no useful work gets done.
The trap is that no level-based metric shows it. Memory utilisation sits at 100% — exactly as it does on a perfectly healthy cache-filled machine. Swap used may not move at all. What moves is time lost: /proc/pressure/memory, especially the full line, plus pgsteal_direct in /proc/vmstat and the major-fault rate.
The details that separate candidates: saying that thrashing is a stable state, not a crash. The machine is not out of memory and will not necessarily be OOM-killed; it can sit at a third of its normal throughput for hours while every dashboard reports full utilisation and no errors. Nothing escalates on your behalf, which is precisely why it needs an alert built on memory full avg60 rather than on any utilisation figure.
🔥 Part C · Overcommit and the OOM killer
C1 · Overcommit — promising more than you have
Module 08 showed that address space is handed out without RAM being committed. That has a consequence the kernel has to decide about: what should happen when a program asks for more than the machine could possibly deliver?
Linux's answer is configurable, in vm.overcommit_memory:
| Mode | Name | Behaviour |
| 0 | Heuristic (the default) | Refuse only obviously absurd requests; allow everything plausible |
| 1 | Always overcommit | Never refuse. Intended for programs using enormous sparse arrays |
| 2 | Strict | Total commitment may not exceed swap plus a percentage of RAM. Allocations fail cleanly instead |
In mode 2 the percentage is vm.overcommit_ratio, default 50. The resulting ceiling and the current total are both published in /proc/meminfo as CommitLimit and Committed_AS, and in mode 2 an allocation that would push Committed_AS past CommitLimit simply fails.
An airline knows from experience that some passengers never show up. So it sells 110 tickets for a 100-seat plane. Almost every flight, this is invisible and everyone gets a seat, and the airline runs far fuller than it otherwise could.
That is mode 0. The overselling is deliberate, calculated, and nearly always harmless.
Mode 1 is selling tickets with no limit at all — sensible only if you know something the rest of us do not, like that this particular group books hundreds of seats and boards three.
Mode 2 is the airline that refuses to sell ticket 101. Nobody is ever bumped. The plane also flies with empty seats on every single flight, and customers get told "sold out" when the plane is half empty.
And the reason airlines still oversell is the reason Linux does: the alternative wastes far more than it saves. But it does mean that on the rare full flight, somebody is getting removed from the plane — and Section C2 is about how that person is chosen.
Where the analogy stops working. A bumped passenger is compensated and put on the next flight. An OOM-killed process is sent SIGKILL and gets nothing.
🧪 Exercise C1.1 — Find your commitment ceiling, then hit it
# Which mode is this machine in, and what is the ratio?
sysctl vm.overcommit_memory vm.overcommit_ratio vm.overcommit_kbytes
# The ceiling and the current total. In mode 0 the ceiling is advisory.
grep -E '^(MemTotal|CommitLimit|Committed_AS|SwapTotal):' /proc/meminfo
# Two requests in the current (default) mode: one that fits inside
# RAM+swap, and one that does not.
echo "--- mode 0: asking for 2 GB, then 100 GB ---"
python3 -c "
import mmap
for gb in (2, 100):
try:
m = mmap.mmap(-1, gb * 1024**3)
print(f'{gb} GB: SUCCEEDED - address space reserved, no RAM used yet')
m.close()
except Exception as e:
print(f'{gb} GB: REFUSED:', e)
"
# Now strict mode. WARNING: on a machine where Committed_AS already exceeds
# CommitLimit, mode 2 makes almost every later allocation fail - including
# the command that would put it back. Set a self-healing timer first.
echo "--- switching to strict overcommit (mode 2) for 20 seconds ---"
sudo sh -c 'sysctl -q -w vm.overcommit_memory=2
sleep 20
sysctl -q -w vm.overcommit_memory=0' &
sleep 1
sysctl vm.overcommit_memory
grep -E '^(CommitLimit|Committed_AS):' /proc/meminfo
python3 -c "
import mmap
try:
m = mmap.mmap(-1, 2 * 1024**3)
print('2 GB allocation SUCCEEDED')
m.close()
except Exception as e:
print('2 GB allocation REFUSED:', e)
"
# Wait for the timer to restore it, then confirm
wait
sysctl vm.overcommit_memory✅ Expected result — click to reveal
vm.overcommit_memory = 0
vm.overcommit_ratio = 50
vm.overcommit_kbytes = 0
MemTotal: 3936256 kB
CommitLimit: 1968128 kB
Committed_AS: 2841204 kB
SwapTotal: 0 kB
--- mode 0: asking for 2 GB, then 100 GB ---
2 GB: SUCCEEDED - address space reserved, no RAM used yet
100 GB: REFUSED: [Errno 12] Cannot allocate memory
--- switching to strict overcommit (mode 2) for 20 seconds ---
vm.overcommit_memory = 2
CommitLimit: 1968128 kB
Committed_AS: 2841204 kB
2 GB allocation REFUSED: [Errno 12] Cannot allocate memory
vm.overcommit_memory = 0What to read out of this.
Look at the first block before anything else. Committed_AS is 2841 MB and CommitLimit is 1968 MB. The machine has already promised 44% more than its own strict ceiling would allow — and it is running perfectly. That is overcommit working, and it is the normal state of every Linux machine you have ever used.
CommitLimit here is 1968 MB because it is SwapTotal (0) + 50% of MemTotal. With no swap and the default ratio, strict mode would cap you at half your RAM.
Now the two requests in mode 0. 2 GB succeeded and 100 GB was refused, and the boundary between them is not fuzzy at all: the mode-0 heuristic refuses any single allocation larger than total RAM plus swap, and nothing else. On this machine that is 3.8 GB, so 2 GB sails through and 100 GB is rejected on the spot. Check dmesg after a refusal and you will find the kernel saying so in as many words: __vm_enough_memory: pid: 7647, comm: python3, bytes: 107374182400 not enough memory for the allocation.
That is worth holding next to Module 08. Address space is cheap, but it is not free of all accounting — even the permissive default draws a line, and it draws it at one allocation bigger than the machine.
Then mode 2, and the same 2 GB request that succeeded a moment ago now fails immediately with ENOMEM, on a machine with over 3 GB genuinely available. Nothing was short. The accounting simply said no.
Notice the self-healing timer in the script, and take it seriously. Because Committed_AS already exceeds CommitLimit on this machine, mode 2 makes nearly every subsequent allocation fail — including the sudo sysctl that would set it back. Switching a busy machine to mode 2 without an escape hatch is a good way to need a console.
This is the trade in one screen. Mode 0 says yes and occasionally has to kill something later. Mode 2 says no early and predictably, and wastes capacity to do it. Neither is free, and the right choice depends entirely on whether an unexpected SIGKILL or a failed allocation is worse for your workload.
If your 2 GB allocation succeeded in mode 2, your machine has swap or more RAM than the example — check CommitLimit against Committed_AS and scale the request up until it fails.
C2 · The OOM killer
When the kernel needs a page, has reclaimed everything it can, and still cannot find one, it has run out of options. Failing the allocation is not available — the memory was promised long ago. So it picks a process and sends it SIGKILL.
The choice is made by a function called oom_badness, and it is simpler than most people expect. A process's score is built from:
- its resident pages (RSS),
- its pages currently in swap,
- and its page tables (VmPTE from Module 08).
Then oom_score_adj is applied as a bias: each unit is worth one thousandth of total RAM, added to or subtracted from the score. The range is −1000 to +1000, and at exactly −1000 the process becomes completely exempt regardless of size.
A lift is over its weight limit and will not move. Someone has to get out.
The rule is not "whoever got in last" and it is not "whoever is carrying the most luggage that they did not need to bring". The rule is whoever weighs the most, because that is the fastest way to get under the limit and moving again.
So the heaviest person is asked to leave, even if the reason the lift is overloaded is that six people each brought one extra bag. Nobody investigates fault. The lift just needs to move.
oom_score_adj is a note in your pocket. A note reading "+500" makes you count as heavier than you are, so you get chosen first — that is what you put on a batch job you would rather lose. A note reading "−1000" means you are never asked to leave, whatever you weigh — that is what you put on the process that keeps the building running.
Where the analogy stops working. The person asked to leave the lift can take the next one. An OOM-killed process gets SIGKILL — no handler, no cleanup, no chance to flush anything to disk.
🧪 Exercise C2.1 — See who is at the front of the queue
# Every process, ranked by how likely the OOM killer is to pick it.
# oom_score is the live computed value; higher means killed sooner.
sudo sh -c 'for d in /proc/[0-9]*; do
p=${d#/proc/}
s=$(cat $d/oom_score 2>/dev/null) || continue
a=$(cat $d/oom_score_adj 2>/dev/null)
r=$(awk "/^VmRSS:/ {print \$2}" $d/status 2>/dev/null)
n=$(awk "/^Name:/ {print \$2}" $d/status 2>/dev/null)
echo "$s adj=$a rss=${r:-0}kB $n ($p)"
done' | sort -rn | head -10
# The score is driven by size. Prove it on one process.
sleep 600 &
P=$!
echo "--- a small sleeping process ---"
cat /proc/$P/oom_score /proc/$P/oom_score_adj
# Bias it towards being killed
echo 500 | sudo tee /proc/$P/oom_score_adj > /dev/null
echo "--- after adj=500 ---"
cat /proc/$P/oom_score
# Now make it exempt
echo -1000 | sudo tee /proc/$P/oom_score_adj > /dev/null
echo "--- after adj=-1000 ---"
cat /proc/$P/oom_score
# PID 1 is protected out of the box - check what systemd sets for itself
echo "--- PID 1 ---"
cat /proc/1/oom_score_adj
kill $P✅ Expected result — click to reveal
736 adj=0 rss=412960kB mysqld (1198)
686 adj=0 rss=118204kB python3 (2041)
686 adj=0 rss=116888kB python3 (2042)
686 adj=0 rss=115932kB python3 (2043)
672 adj=0 rss=32872kB dockerd (901)
669 adj=0 rss=14904kB sshd (688)
667 adj=0 rss=5504kB bash (4881)
0 adj=-1000 rss=11208kB systemd-udevd (412)
--- a small sleeping process ---
666
0
--- after adj=500 ---
1000
--- after adj=-1000 ---
0
--- PID 1 ---
-1000What to read out of this.
The ranking follows RSS — but look at how little the numbers move. mysqld at 403 MB scores 736; bash at 5 MB scores 667. A process eighty times larger scores 10% higher.
That is because /proc/PID/oom_score is not the raw badness. It is the badness rescaled into a 0–2000 range as roughly (1000 + rss‰) × 2/3, where rss‰ is the process's share of total RAM in thousandths. Every process starts from a floor of 666, and its size only nudges it upward: 403 MB of a 3.8 GB machine is 105 thousandths, and (1000 + 105) × 2/3 = 736. Do not read oom_score as a percentage or a probability — read the ordering, and get the magnitude from RSS.
Now the bias arithmetic, which is where oom_score_adj earns its place. The sleeping process scored 666 — the floor, because it is too small to register. Setting oom_score_adj=500 took it to 1000: the adjustment adds 500 thousandths of total RAM to the badness, which is about 1.9 GB of apparent size, and (1000 + 500) × 2/3 = 1000. That one line made an 800 kB process look bigger than the database. Setting −1000 short-circuits the whole calculation to 0: it is now exempt, and the kernel will kill anything else first, including processes a thousand times its size.
systemd-udevd shows adj=-1000 without anyone touching it, and so does PID 1. systemd protects itself and a handful of critical services by default, because a machine that OOM-kills its own init has no way back.
The practical lesson. oom_score_adj is the only real control you have over who dies — and as the arithmetic above shows, it dominates the size term completely once you use values in the hundreds. It is a single number written to a file. In a systemd unit it is OOMScoreAdjust=. Set it positive on batch jobs and caches you can afford to lose, and slightly negative — not −1000 — on the one service the host exists to run. Reserve −1000 for things that would make the machine unrecoverable, because a fully exempt process that leaks will take the whole machine down with it.
🧪 Exercise C2.2 — Read a real OOM report
An OOM kill leaves a long, dense record in the kernel log. It is one of the most information-rich messages Linux produces and almost nobody reads past the first line.
# Has this machine ever OOM-killed anything?
sudo dmesg -T | grep -iE 'out of memory|oom-kill|killed process' | tail -20
# Or from the journal, with proper timestamps, across boots
sudo journalctl -k --since "7 days ago" | grep -iE 'oom-kill|killed process'
# The full report for the most recent one, with surrounding context
sudo dmesg -T | grep -B2 -A6 'Out of memory' | tail -40✅ Expected result — click to reveal
On a healthy machine you get nothing, which is the correct and common answer. On a machine that has had an incident:
[Thu Aug 14 03:14:22 2026] python3 invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=0
[Thu Aug 14 03:14:22 2026] Tasks state (memory values in pages):
[Thu Aug 14 03:14:22 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[Thu Aug 14 03:14:22 2026] [ 1198] 114 1198 478112 103240 1130496 0 0 mysqld
[Thu Aug 14 03:14:22 2026] [ 2041] 1000 2041 612884 488204 4325376 0 0 python3
[Thu Aug 14 03:14:22 2026] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1000.slice,task=python3,pid=2041,uid=1000
[Thu Aug 14 03:14:22 2026] Out of memory: Killed process 2041 (python3) total-vm:2451536kB, anon-rss:1952816kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:4224kB oom_score_adj:0What to read out of this, line by line.
python3 invoked oom-killer — this is the process that was asking for memory when the machine ran dry. It is not necessarily the one that gets killed, and confusing the two is the single most common misreading of this log. Here they happen to be the same process, but often they are not.
Tasks state — a full table of every process at the moment of the kill, with rss in pages, not kilobytes. Multiply by 4096. python3 at 488204 pages is about 1.9 GB; mysqld at 103240 pages is about 400 MB. This table is a free memory snapshot from the exact instant of the incident, which is usually the only one you get.
constraint=CONSTRAINT_NONE and global_oom — the whole machine ran out, not a cgroup. If you see CONSTRAINT_MEMCG instead, a container hit its own limit and the machine was fine. That one word changes the entire investigation, and Section C3 is about it.
task_memcg=/user.slice/user-1000.slice — which cgroup the victim was in. On a container host this names the pod.
The final line is the verdict, and it splits RSS usefully: anon-rss:1952816kB against file-rss:0kB says the victim's memory was entirely anonymous — no file backing, therefore unreclaimable, therefore the kernel had no alternative to killing it. On a machine with swap, swapents in the table above would show how much it had already pushed out.
What to do with it. The task table lets you reconstruct where the memory went without any monitoring at all. Sum the rss column, multiply by 4096, and compare to MemTotal: if the sum is far below total RAM, the memory went somewhere that is not a process — kernel slab, huge pages, or a tmpfs mount — and that changes what you go looking for.
C3 · Container limits — a different OOM entirely
Everything in C2 was the global OOM killer: the machine ran out. A container hitting its own limit is a different event with a different scope, and telling them apart is most of what makes container memory incidents tractable.
cgroup v2 gives a workload two memory settings, and the difference between them is the whole section:
| File | What it is | What happens when usage exceeds it |
| memory.max | A hard limit | The kernel reclaims hard; if it cannot get under the limit it invokes the OOM killer inside that cgroup. The victim is chosen from that cgroup's processes only. The rest of the machine is untouched |
| memory.high | A throttle | Processes are slowed and put under heavy reclaim pressure. The OOM killer is never invoked. Under extreme conditions the limit may simply be exceeded |
Kubernetes resources.limits.memory becomes memory.max. That is why a pod that exceeds it is killed rather than throttled, and why the container reports exit code 137 — 128 + 9, signal 9, SIGKILL, exactly the convention from Module 02.
Above the kernel there is now a userspace layer too. systemd-oomd watches cgroup PSI — the pressure metric from Section B3 — and kills a whole cgroup before the kernel gets desperate. The point is that it acts on sustained pressure rather than on an absolute limit, so it catches the thrashing case that never triggers a kernel OOM at all. It is enabled by default on Fedora 34 and later and on Ubuntu Desktop 22.04 and later.
The building has a total electrical supply, and each office also has its own breaker.
Trip the building's main supply and everyone goes dark — that is the global OOM killer, and the heaviest consumer anywhere in the building gets disconnected.
Trip your office's breaker and only your office goes dark. Everyone else carries on with no idea anything happened. That is memory.max: the scope is your cgroup, the victim is one of your processes, and the neighbours are unaffected.
memory.high is a different device: instead of cutting you off, it restricts your supply so everything you run gets slower. Deeply annoying, and infinitely preferable to going dark mid-task.
systemd-oomd is the building manager who notices your office has been running on the edge for ten minutes straight and shuts it down deliberately, before the breaker does it messily.
Where the analogy stops working. A tripped breaker can be reset and you continue where you left off. A cgroup OOM kill is SIGKILL — the work is gone.
🧪 Exercise C3.1 — Get something OOM-killed safely, in its own cgroup
This one kills a process on purpose. It is confined to a cgroup you create, so the rest of the machine is unaffected.
# Confirm this machine uses cgroup v2 (nearly all modern systems do)
stat -fc %T /sys/fs/cgroup
# A cgroup with a 100 MB hard limit
sudo mkdir -p /sys/fs/cgroup/oomtest
echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control > /dev/null
echo 100M | sudo tee /sys/fs/cgroup/oomtest/memory.max > /dev/null
# Counters before we start
cat /sys/fs/cgroup/oomtest/memory.events
# Run a shell INSIDE the cgroup. It moves itself in by writing its own PID
# to cgroup.procs, then asks for five times the limit.
# (Do not use `systemd-run` here: it reports its own exit code for a unit
# killed by the OOM killer, so you would not see the 128+N value.)
echo "--- running a 500 MB allocation under a 100 MB limit ---"
sudo bash -c 'echo $$ > /sys/fs/cgroup/oomtest/cgroup.procs
BIG=$(head -c 500000000 /dev/zero | tr "\0" "x")
echo survived'
echo "exit status: $?"
# What the kernel recorded
echo "--- the cgroup event counters ---"
sudo cat /sys/fs/cgroup/oomtest/memory.events
sudo dmesg -T | tail -4
# Clean up
sudo rmdir /sys/fs/cgroup/oomtest✅ Expected result — click to reveal
cgroup2fs
low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0
--- running a 500 MB allocation under a 100 MB limit ---
Killed
exit status: 137
--- the cgroup event counters ---
low 0
high 0
max 2841
oom 1
oom_kill 1
oom_group_kill 0
[Fri Aug 21 09:41:07 2026] Memory cgroup out of memory: Killed process 7412 (bash) total-vm:512884kB, anon-rss:99328kB, file-rss:1284kB, shmem-rss:0kB, UID:0 pgtables:296kB oom_score_adj:0
[Fri Aug 21 09:41:07 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=/,mems_allowed=0,oom_memcg=/oomtest,task_memcg=/oomtest,task=bash,pid=7412,uid=0What to read out of this.
Exit status 137. 137 − 128 = 9, and signal 9 is SIGKILL. This is exactly the number Kubernetes reports as OOMKilled, and now you know precisely where it comes from. It is not a Kubernetes code and it is not invented by the container runtime: it is the same 128 + N convention you met in Module 02, reported here by the shell and reported by the container runtime in the Kubernetes case.
Now memory.events, which is the file to reach for during a container incident and is almost unknown:
- max 2841 — the limit was hit 2,841 times. Every one of those is a moment where the kernel had to force reclaim to keep the cgroup under its cap. A high max count with oom_kill 0 is a container that is being throttled hard but surviving — slow, and nothing else will tell you.
- oom 1 — the cgroup entered an OOM condition once.
- oom_kill 1 — one process was killed.
The kernel log line is the other half. constraint=CONSTRAINT_MEMCG and oom_memcg=/oomtest.slice say plainly: this was a cgroup limit, not the machine. Compare that with the global_oom line in Exercise C2.2. Same log format, entirely different incident, and one word apart.
Look at anon-rss:99328kB — just under the 100 MB cap, not exactly at it. That is worth understanding: memory.max bounds the whole cgroup, not one process. The head and tr in the pipeline, plus kernel memory charged to the cgroup, take up the remainder, so the victim's own resident size is always a little below the limit. A number that landed exactly on the cap would be a sign the output was made up.
And file-rss:1284kB is tiny: this victim had almost no reclaimable memory, so the kernel had nothing to take back before killing it.
If systemd-run is unavailable, do it the manual way: write the PID into /sys/fs/cgroup/oomtest/cgroup.procs before allocating. The counters and the log line are identical.
If nothing was killed and the command printed survived, the limit did not apply — check that memory.max really reads 104857600 and that the subtree controller was enabled.
🎯 Interview questions — Overcommit and OOM
Q. What is the OOM killer, and how does it choose what to kill?
It is the kernel's last resort when an allocation cannot be satisfied and reclaim has already failed. Because Linux overcommits — it hands out more memory than it has, betting most of it will never be touched — there is no way to refuse the allocation at that point, so it picks a process and sends SIGKILL.
The choice comes from oom_badness, which scores each process on resident pages plus swap pages plus page tables, then applies oom_score_adj as a bias worth one thousandth of total RAM per unit, in the range −1000 to +1000. In practice this means the biggest process is killed, not the one responsible.
The details that separate candidates: saying explicitly that the victim is chosen by size, not by blame — a small leaking script can get the database killed. Then: oom_score_adj at exactly −1000 makes a process fully exempt, systemd already sets that for PID 1, and OOMScoreAdjust= is the unit-file way to set it. And the deprecated oom_adj file was removed in Linux 3.7, so any runbook still writing to it has been doing nothing for a decade.
Q. A container was OOMKilled with exit code 137, but the node had plenty of free memory. Explain.
Two different OOM events exist and this was the cgroup one. resources.limits.memory becomes memory.max on the container's cgroup; when usage cannot be reclaimed below that, the kernel runs the OOM killer scoped to that cgroup, so the victim comes from inside the container and the node is untouched. Exit 137 is 128 + 9 — killed by SIGKILL.
To confirm it I would look at the host's kernel log for the OOM line and check constraint=: CONSTRAINT_MEMCG means the container's own limit, global_oom means the node genuinely ran out. Then memory.events for that cgroup, where oom_kill counts kills and max counts how often the limit was hit at all.
The details that separate candidates: three. The OOM record is written by the host kernel, so it never appears in container logs — which is why the pod seems to restart with no explanation. Page cache counts towards memory.max, so a burst of file I/O can push a container over a limit its own working set fits comfortably inside. And memory.high would have throttled instead of killing, but Kubernetes does not expose it, so the only lever most teams have is sizing the hard limit with headroom.
Q. What is memory overcommit, and would you turn it off?
Overcommit is the kernel granting more address space than it could back with RAM plus swap, on the reasonable bet that most of it will never be touched. vm.overcommit_memory controls it: mode 0 is a permissive heuristic and the default, mode 1 never refuses, mode 2 is strict accounting against CommitLimit — swap plus vm.overcommit_ratio percent of RAM, default 50%.
I would not turn it off casually. On a typical machine Committed_AS already exceeds what strict mode would allow, by a wide margin, while the machine runs perfectly. Mode 2 with the default ratio caps a swapless host at half its RAM, and every runtime that reserves large address space up front — Go, the JVM — fails at start-up.
The details that separate candidates: connecting overcommit to the OOM killer as cause and effect: the OOM killer exists because of overcommit. Mode 2 replaces an unpredictable SIGKILL later with an honest ENOMEM at allocation time, which is the right trade only where a failed allocation is genuinely better than a kill — some embedded and financial systems. And if you do set mode 2, you must set overcommit_ratio or vm.overcommit_kbytes deliberately, because the default is far too conservative to be used as-is.
🔍 Part D · Diagnosing "the server is out of memory"
D1 · Four questions, in order
As with CPU in Module 07, the order matters, because each answer eliminates whole categories.
- Is memory actually short? — MemAvailable, not MemFree. If available is healthy, stop; this is not a memory problem.
- Is anything actually suffering? — /proc/pressure/memory. A level without pressure is not an incident.
- Where did the memory go? — processes (Pss), page cache, or the kernel. These are three different investigations.
- Was anything killed? — the kernel log, and constraint= to tell a container limit from a node shortage.
Diagram source
flowchart TD
A["Memory alarm"] --> B{"MemAvailable<br>healthy?"}
B -->|"Yes"| C["Not a memory problem<br>check the alert threshold"]
B -->|"No"| D{"pressure/memory<br>some avg60 high?"}
D -->|"No"| E["Tight but coping<br>watch, do not page"]
D -->|"Yes"| F{"Anything OOM-killed<br>in the kernel log?"}
F -->|"Yes"| G{"constraint=?"}
G -->|"CONSTRAINT_MEMCG"| H["Container limit<br>size it or use memory.high"]
G -->|"global_oom"| I["Node capacity<br>read the task table"]
F -->|"No"| J{"Sum of PSS close<br>to MemTotal?"}
J -->|"Yes"| K["A process is the cause<br>find it, watch its growth rate"]
J -->|"No"| L["Kernel or tmpfs<br>slabtop, findmnt -t tmpfs"]The shop's stock count is short. The manager who starts by accusing the newest employee will be wrong most of the time and will have spent a day being wrong.
The manager who works outward asks in order: is stock genuinely short, or is the count wrong? Is anyone actually unable to serve customers because of it? Then, where is the stock — on the shelves, in the stockroom, or was it never delivered? And only last: did somebody take something?
Every one of those questions is cheaper than the one after it, and each rules out a whole class of answer. The final question is the interesting one and the one everybody wants to start with.
Where the analogy stops working. A shop can pause and count carefully. A machine under memory pressure is changing while you measure it, which is why capturing /proc/meminfo and the pressure files early matters more than analysing them quickly.
D2 · When the memory is not in any process
Module 08 established that summing Pss across processes gives you the memory mapped by processes. On some machines that total is nowhere near MemTotal, and the gap is where the difficult incidents live. Four places to look:
| Where | What it is | How to see it | Reclaimable? |
| Page cache | File contents held in RAM | Cached in /proc/meminfo | Yes, mostly — this is fine |
| Slab | Kernel data structures: inodes, dentries, network buffers | Slab, SReclaimable, SUnreclaim; slabtop | Partly. SUnreclaim is not |
| tmpfs / shm | Files that exist only in RAM | Shmem; findmnt -t tmpfs; df -h /dev/shm | No. There is no disk copy |
| Kernel overhead | Page tables, kernel stacks, huge pages | PageTables, KernelStack, HugePages_Total | No |
🧪 Exercise D2.1 — Account for every byte on the machine
# The four buckets, from the source
# grep prints file order, which is: ... Cached, AnonPages, Shmem, Slab,
# SReclaimable, SUnreclaim, KernelStack, PageTables
grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|AnonPages|Shmem|Slab|SReclaimable|SUnreclaim|KernelStack|PageTables):' /proc/meminfo
# What processes actually map, summed honestly (from Module 08)
sudo sh -c 'for f in /proc/[0-9]*/smaps_rollup; do grep -h "^Pss:" $f 2>/dev/null; done' \
| awk '{s+=$2} END {printf "sum of PSS : %8.1f MiB\n", s/1024}'
awk '/MemTotal/ {printf "MemTotal : %8.1f MiB\n", $2/1024}' /proc/meminfo
# Which kernel caches are big? (needs root)
# slabtop prints a five-line summary before the table, so allow for it.
command -v slabtop >/dev/null && sudo slabtop -o -s c | head -12
# Every tmpfs on the machine and how full it is - this is RAM
findmnt -t tmpfs -o TARGET,SIZE,USED,USE% 2>/dev/null || df -h -t tmpfs
# Anything hiding in shared memory?
ls -lh /dev/shm/ 2>/dev/null | head✅ Expected result — click to reveal
MemTotal: 3936256 kB
MemFree: 198432 kB
MemAvailable: 1204188 kB
Buffers: 81920 kB
Cached: 1841204 kB
AnonPages: 1284116 kB
Shmem: 892416 kB
Slab: 412884 kB
SReclaimable: 298104 kB
SUnreclaim: 114780 kB
KernelStack: 12208 kB
PageTables: 28416 kB
sum of PSS : 1318.4 MiB
MemTotal : 3844.0 MiB
Active / Total Objects (% used) : 558104 / 560824 (99.5%)
Active / Total Slabs (% used) : 30511 / 30511 (100.0%)
Active / Total Caches (% used) : 118 / 208 (56.7%)
Active / Total Size (% used) : 402884.20K / 412884.00K (97.6%)
Minimum / Average / Maximum Object : 0.01K / 0.74K / 8.00K
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
421204 418992 99% 0.19K 20057 21 80228K dentry
98412 97108 98% 0.58K 7570 13 57080K inode_cache
41208 41208 100% 1.06K 2884 14 43712K ext4_inode_cache
TARGET SIZE USED USE%
/dev/shm 1.9G 872M 45%
/run 384M 12M 3%
/run/lock 5M 0 0%
-rw-r--r-- 1 postgres postgres 872M Aug 21 04:02 /dev/shm/PostgreSQL.184920114What to read out of this.
Do the arithmetic first. Processes map 1318 MiB of a 3844 MiB machine. Where is the other 2526 MiB?
Cached: 1841204 kB is 1798 MiB, so most of it is page cache — normal and mostly reclaimable. But look at the next line: Shmem: 892416 kB, 871 MiB, and Shmem is a subset of Cached. So of that 1798 MiB of "cache", 871 MiB cannot be dropped at all. It has no file on disk to reload from.
The findmnt output finds it immediately: /dev/shm is 45% full with an 872 MB PostgreSQL shared-memory segment. That is not a leak and not a problem — it is a PostgreSQL dynamic shared-memory segment, the kind parallel query allocates — but it is 871 MiB of RAM that every "how much cache can I reclaim" estimate must exclude. MemAvailable at 1204 MiB already excludes it, which is one more reason to use that number rather than adding columns yourself.
Slab: 412884 kB is 403 MiB of kernel data structures, and slabtop says most of it is dentry and inode_cache — the kernel remembering filenames and file metadata. On a machine that walks large directory trees (a backup job, a CI runner, a container host pulling images) this can reach several gigabytes. SReclaimable: 298104 of it can be given back; SUnreclaim: 114780 cannot.
PageTables: 28416 kB is the VmPTE cost from Module 08, summed across every process. Small here, but on a host running many large processes it reaches hundreds of megabytes and appears in no per-process metric.
Do the arithmetic yourself, because it is the point of the section. AnonPages 1254 MiB, plus Cached 1798 MiB, plus Buffers 80 MiB, plus Slab 403 MiB, plus PageTables and KernelStack 40 MiB, comes to 3575 MiB. Add the 194 MiB free and you have 3769 of 3844 — within about 75 MiB, which is kernel text, per-CPU areas and vmalloc that /proc/meminfo does not itemise. That accounts for the machine.
Note that Shmem is not added separately: it is already inside Cached, and it is excluded from AnonPages, so it is counted exactly once. Adding it again is the most common way to get this sum wrong.
If your own arithmetic leaves a large unexplained gap, look at huge pages next — HugePages_Total is reserved memory that appears in none of these lines and is excluded from MemAvailable too.
🎯 Interview questions — Diagnosis
Q. How do you troubleshoot high memory usage on a Linux server?
I start by checking whether it is real. free -m and read available, not free — most "high memory" reports are the page cache doing its job. Then /proc/pressure/memory, because a high level with no pressure is not an incident.
If it is real, I find where the memory went. Sum Pss from smaps_rollup across processes; if that accounts for most of MemTotal, it is a process and I look at its growth rate over minutes rather than its level. If it does not, the memory is in the kernel or in tmpfs, and I check Shmem, Slab/slabtop, and findmnt -t tmpfs. Finally I check the kernel log for OOM kills, and read constraint= to see whether it was a container limit or the node.
The details that separate candidates: separating level from rate — a process steady at 4 GB is doing its job; one at 800 MB climbing 50 MB an hour is the problem, and it is the smaller number. And knowing the three places memory hides outside the process list, because "no process is large but the machine is full" is the case that defeats most people. pgsteal_direct from /proc/vmstat is the early warning I would also graph.
Q. free shows almost no free memory but no process is using much. Where is it?
Four candidates, in the order I would check them. Page cache — Cached, expected and mostly harmless. tmpfs and shared memory — Shmem, which is counted inside Cached but cannot be reclaimed because there is no disk copy; findmnt -t tmpfs and ls -lh /dev/shm find it. Slab — kernel structures, mostly dentry and inode_cache on machines doing heavy filesystem work; slabtop -s c ranks them, and SUnreclaim is the part you will not get back. Kernel overhead — PageTables summed across processes, KernelStack, and reserved huge pages, which appear in none of the usual totals.
The details that separate candidates: knowing that Shmem is a subset of Cached, so a naive "cache is reclaimable" assumption overstates available memory by exactly that amount — and that MemAvailable already accounts for it, which is the practical reason to trust it over your own arithmetic. Naming dentry and inode_cache growth as the classic cause on container hosts and CI runners shows you have actually met this one.
🏁 Part E · Practice, docs and self-check
E1 · Production practice
| Symptom in production | What is really happening | What to run | The fix |
| "Memory 97% used" alerts on healthy hosts | The page cache filled the RAM, as designed | free -m, read available; /proc/pressure/memory | Alert on MemAvailable ratio and PSI, never on MemFree |
| Service slow, CPU mostly idle, disk busy but throughput low, memory "full" | Thrashing — the working set no longer fits | memory full avg60 in /proc/pressure/memory; pgsteal_direct in /proc/vmstat | More RAM, a smaller working set, or move the workload |
| Pod restarts with an empty log and exit 137 | cgroup OOM kill; the record is in the host's kernel log | dmesg -T on the node, check constraint= | Raise the limit, or reduce the container's footprint |
| Container killed although its own data fits the limit | Page cache is charged to the cgroup; heavy file I/O pushed it over | memory.stat in the cgroup, file versus anon | Headroom in the limit, or have the app tell the kernel not to cache its bulk reads (posix_fadvise, covered in Module 10) |
| Node full, no process is large | tmpfs, slab, or huge pages — memory outside the process list | Shmem, Slab, slabtop -s c, findmnt -t tmpfs | Bound the tmpfs size; find what is walking the filesystem |
| Write-heavy host stalls for seconds at a time | vm.dirty_ratio reached; the writer is blocked in the kernel | Dirty in /proc/meminfo, sysctl vm.dirty_ratio | Lower vm.dirty_background_ratio so writeback starts earlier |
| Data lost on power cut although writes "succeeded" | No fsync — the data was in the page cache | Check the app's durability setting; Dirty during load | Enable fsync/O_SYNC in the application, and accept the real throughput |
| Machine goes from fine to OOM with no warning | No swap, so anonymous memory is unreclaimable | swapon --show, SwapTotal | Add swap or zram as a pressure valve; alert on PSI |
| Paged at 3am for "swap usage 40%" | A level, not a rate. Nothing was moving | vmstat 1 — the si/so columns | Alert on sustained si/so, or on PSI. Delete the level alert |
| Wrong process killed by the OOM killer | It kills the largest, not the culprit | /proc/PID/oom_score, the kernel log's task table | OOMScoreAdjust= positive on expendable jobs, mildly negative on the critical one |
E2 · Capstone — four memory tickets
Ticket 1. Monitoring pages the team every night at 02:00: "memory usage 96%" on all twelve database hosts. It clears by 04:00. Nothing has ever gone wrong. The team wants to raise the threshold to 98%. What do you tell them?
Ticket 2. A Java service in Kubernetes is restarting several times a day with exit code 137. Its heap is capped at 2 GB, the pod limit is 3 GB, and the JVM's own metrics show heap usage steady at 1.4 GB. The team wants to raise the limit to 6 GB. Is that the right fix, and how do you find out?
Ticket 3. An application server has become slow. CPU is 25%, the network is clean, the storage team says the volume is nowhere near its throughput or IOPS limits, and free -m shows 60 MB free with 3.2 GB in buff/cache. The team has restarted the service twice; it is fast for ten minutes and then degrades again. What is happening?
Ticket 4. A node reports 94% memory used. The sum of every process's RSS is 1.1 GB on a 16 GB machine. No container is near its limit. Nothing has been OOM-killed. Where is the memory?
✅ Ticket 1 — worked answer
Run, in order: free -m during the window → grep MemAvailable /proc/meminfo → cat /proc/pressure/memory → check what runs at 02:00.
What you will find. Almost certainly the nightly backup. Reading every database file pulls the whole dataset through the page cache, so buff/cache fills and MemFree collapses. MemAvailable will barely move, and /proc/pressure/memory will show some near zero — nothing is being harmed.
What you tell them: the threshold is not the problem, the metric is. Raising 96% to 98% buys a few weeks and then the same page returns, and in the meantime it makes the alert less able to catch a real shortage. The fix is to alert on MemAvailable / MemTotal instead, which will sit comfortably high through the backup, and add memory some avg60 from PSI as the leading indicator.
The evidence that settles the argument is that the two numbers disagree: at the moment free says 96% used, available says perhaps 75% free. Show that side by side once and the conversation is over.
One thing worth checking before you close it. If MemAvailable does drop hard during the window, the backup is competing with the database for cache and the database's morning performance will suffer from a cold cache. That is a real problem with a real fix — posix_fadvise(POSIX_FADV_DONTNEED) in the backup tool, or nocache-style wrappers — and it is worth ten minutes to rule in or out.
✅ Ticket 2 — worked answer
The clue is the gap. Heap is 1.4 GB, the limit is 3 GB, and it is still being killed. So the memory is not in the heap — and a JVM has a great deal of memory that is not heap: metaspace, thread stacks, code cache, direct byte buffers, and any native libraries.
Run, in order, on the node:
sudo dmesg -T | grep -A3 'Killed process' # constraint=? which task?
cat /sys/fs/cgroup/<pod path>/memory.events # oom_kill, and max
cat /sys/fs/cgroup/<pod path>/memory.stat # anon vs file vs kernel
cat /sys/fs/cgroup/<pod path>/memory.currentWhat confirms what. constraint=CONSTRAINT_MEMCG confirms it is the pod's own limit rather than the node. Then memory.stat splits the usage: a large file figure means the container's page cache is being charged to it — very common in services that write logs or read data files — and that memory was reclaimable, so the kill was avoidable. A large anon figure beyond the heap points at JVM off-heap memory, and the next step is -XX:NativeMemoryTracking.
Is raising the limit to 6 GB right? Only as a stopgap, and only after you know which of those two it is. If it is page cache being charged to the cgroup, doubling the limit wastes 3 GB per replica across the fleet to work around an accounting artefact. If it is genuine off-heap growth, doubling the limit doubles the time between restarts and fixes nothing.
The detail worth raising with the team: memory.events's max counter. If it is large while oom_kill is small, the container spends much of its life being aggressively reclaimed right at the limit — slow, and invisible in every dashboard they currently have.
✅ Ticket 3 — worked answer
Read the symptom shape first. Fast for ten minutes after a restart, then degrading, with nothing saturated — that is a working set growing past what RAM can hold. The restart resets it, which is why the restart appears to help and why it keeps coming back.
free -m showing 60 MB free and 3.2 GB cache is not the evidence either way; that is normal. The evidence is pressure.
Run, in order:
cat /proc/pressure/memory # some and full, avg60
grep -E 'pgsteal_direct|pgscan_direct' /proc/vmstat # twice, subtract
grep MemAvailable /proc/meminfo
ps -eo pid,rss,comm --sort=-rss | head -5 # and again 5 minutes later
vmstat 1 5 # si/so if swap existsWhat confirms it. memory some avg60 well above zero, full climbing, and pgsteal_direct increasing between two readings — meaning processes are being made to do reclaim themselves rather than kswapd keeping up. That combination is thrashing, and no level-based metric would have shown it: utilisation reads 100% both when the machine is healthy and when it is in this state.
Two readings of the process list five minutes apart separate the two possible causes. If one process's RSS is climbing steadily, it is a leak or an unbounded cache in the application and the fix is there. If every process is stable and the machine is simply too small for the load it now carries, the fix is capacity.
What to tell them about the restarts. Each restart destroys the evidence and buys ten minutes. Ask them to capture /proc/meminfo, /proc/pressure/memory and the sorted process list before the next restart — one second of work, and it is the difference between diagnosing this today and having the same conversation next week.
✅ Ticket 4 — worked answer
Processes account for 1.1 GB of 16 GB, so 15 GB is somewhere else. Four places, and the commands take a minute:
grep -E '^(MemTotal|MemAvailable|Cached|Shmem|Slab|SReclaimable|SUnreclaim|PageTables|KernelStack|HugePages_Total|Hugepagesize):' /proc/meminfo
findmnt -t tmpfs -o TARGET,SIZE,USED,USE%
sudo slabtop -o -s c | head -10
ls -lh /dev/shm/Read them in this order.
If Cached is large and Shmem is small, it is ordinary page cache and there is no problem — check MemAvailable, which will be high, and the alert is once again measuring the wrong thing.
If Shmem is large, it is tmpfs: files living in RAM with no disk copy, so none of it is reclaimable even though it is counted inside Cached. findmnt finds the mount. The usual culprits are /dev/shm on a container host where an image requested a large --shm-size, and /run on a host where something is writing logs or state into it.
If Slab is large, slabtop -s c will show dentry and inode_cache at the top. That is the kernel remembering filenames and metadata, and it grows on hosts that walk large directory trees — container image pulls, CI runners, backup jobs. SReclaimable comes back under pressure; SUnreclaim does not.
If HugePages_Total is non-zero, that memory is reserved and appears in none of the other lines, including MemAvailable. A host configured with 12 GB of huge pages for a database looks exactly like this ticket.
The reason nothing was OOM-killed is the tell that this is not a shortage at all in most versions of this ticket: the kernel was never unable to allocate. Check MemAvailable before doing anything else — if it is healthy, this is an alerting defect, not a memory incident.
E3 · Documentation reference
| Topic | Where to read it | Why this one |
| Every field in /proc/meminfo | proc_meminfo(5) | Defines MemAvailable, Shmem, SReclaimable precisely |
| The same, with kernel-side detail | The /proc filesystem | Explains how MemAvailable is calculated |
| Every vm.* tunable | Documentation for /proc/sys/vm | swappiness, dirty_ratio, overcommit_*, drop_caches |
| How overcommit accounting works | Overcommit accounting | The authoritative description of modes 0, 1 and 2 |
| Memory pressure | PSI — Pressure Stall Information | some versus full, and how the averages are computed |
| cgroup memory control | Control Group v2 | memory.max, memory.high, memory.events, memory.stat |
| Reading free | free(1) | States plainly that available is the useful column |
| Swap setup and priorities | swapon(8) · mkswap(8) · proc_swaps(5) | Including the PRIO field, which matters with multiple swap devices |
| Compressed swap | zram · zswap | The two are different things and are constantly confused |
| OOM scoring | proc_pid_oom_score(5) · proc_pid_oom_score_adj(5) | Confirms the −1000..+1000 range and that oom_adj is gone |
| The userspace OOM daemon | systemd-oomd.service(8) · oomd.conf(5) | Per-unit knobs live in systemd.resource-control(5), not here |
| Durability | fsync(2) · sync(1) | Read the NOTES section of fsync(2) about directories |
| Telling the kernel your access pattern | posix_fadvise(2) · madvise(2) | POSIX_FADV_DONTNEED is how a backup avoids trashing the cache |
| Reading kernel logs | dmesg(1) · journalctl(1) | dmesg -T for timestamps; journalctl -k to cross boots |
| The short version, for colleagues | Linux ate my RAM | One page. Send it to whoever filed the 97% ticket |
E4 · Self-assessment
Answer these out loud before moving to Module 10. The section to reread is named after each.
- Why does a healthy Linux server show almost no free memory, and why is that correct? (A1)
- Which column of free should you actually read, and why is free + buff/cache the wrong sum? (A2)
- A write() returned success. What has actually happened, and what would make the data durable? (A3)
- What are vm.dirty_ratio and vm.dirty_background_ratio, and which one blocks the writing process? (A3)
- Rank the four kinds of page by how cheap they are to reclaim, and say which kind cannot be reclaimed at all. (B1)
- What is pgsteal_direct, and why is it a better early warning than memory utilisation? (B1)
- What is swap actually for, and what breaks when you turn it off? (B2)
- What does vm.swappiness control, what is its range, and what does 0 mean? (B2)
- Why is "swap used 40%" a bad alert, and what should replace it? (B2, B3)
- What is thrashing, and why does no utilisation metric reveal it? (B3)
- What is the difference between the some and full lines in /proc/pressure/memory? (B3)
- What is memory overcommit, and why does the OOM killer exist because of it? (C1)
- How does the OOM killer choose a victim, and what does oom_score_adj = -1000 do? (C2)
- In a kernel OOM report, what does constraint=CONSTRAINT_MEMCG tell you that changes the investigation? (C2, C3)
- What is the difference between memory.max and memory.high, and which one does Kubernetes give you? (C3)
- Exit code 137 — what happened, in full? (C3)
- A machine is 94% full and no process is large. Name the four places to look. (D2)
- Why is Shmem the dangerous one of those four? (D2)
E5 · Sources
Kernel documentation
· Memory management — concepts overview
· Documentation for /proc/sys/vm
· PSI — Pressure Stall Information
Manual pages
· proc_meminfo(5) · proc_swaps(5) · free(1) · vmstat(8)
· proc_pid_oom_score(5) · proc_pid_oom_score_adj(5)
· systemd-oomd.service(8) · oomd.conf(5)
· fsync(2) · sync(1) · posix_fadvise(2) · madvise(2)
Other
· Linux ate my RAM — the one-page version for colleagues
· mm/page-writeback.c in the kernel source, for the dirty_ratio defaults the documentation omits