Module 09 — Page Cache, Swap & the OOM Killer

Updated 22 August 2026

Module 09 · Page cache, swap and the OOM killer

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)

Before you start, you should already know:

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:

A well-run Linux server shows almost no free memory, and that is the correct state. Free memory is memory doing nothing. The kernel would rather fill it with cached file data — which it can throw away in microseconds the moment a program actually needs the space. Low free is not a warning sign. It is the system working.

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.

Real-world analogy — the librarian's desk

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
bash
# 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
plain text
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.110s

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

Now imagine this at 500 hosts. "Memory usage above 90%" is the most common bad alert in existence. It fires constantly on healthy machines whose page cache has done its job, so teams either raise the threshold until it is useless or learn to ignore it — and then miss the one host that is genuinely in trouble. The correct signal is built from MemAvailable (Section A2) or, better, from memory pressure (Section B3). Alerting on MemFree is alerting on how much of your RAM is being wasted.

A2 · Reading free and /proc/meminfo properly

free has six columns and most people read the wrong one. Here is what each actually means.

ColumnWhat it isShould you care?
totalInstalled RAM the kernel can useYes, as the denominator
usedtotal minus free minus buff/cacheRoughly — it is a derived number, not a measurement
freeRAM holding nothing at allNo. On a healthy machine this is near zero by design
sharedMostly tmpfs — files that live only in RAMYes if you use /dev/shm or tmpfs mounts
buff/cacheThe page cache plus filesystem metadata buffersYes — this is memory being useful, and mostly recoverable
availableAn estimate of what a new program could get without swappingYes. 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.

The old advice free + buffers + cached is wrong, and it was wrong even when everyone was repeating it. Some cached pages are pinned, some are dirty and must be written first, and the kernel needs a working reserve of cache to avoid grinding. MemAvailable accounts for all of that. If you are still adding columns by hand, you are producing a number the kernel already computes better.
Counter-intuitive. used in free is not measured — it is derived, and the derivation changed. Modern procps-ng 4 computes it as total − available, so it already includes the cache the kernel cannot reclaim; older versions computed total − free − buff/cache, which gave a noticeably smaller number on the same machine. If you compare used between two hosts running different distributions, you may be comparing two different quantities. Neither free nor used is a reliable answer to "how much room is left". Only available is.
Real-world analogy — a car park with a valet

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
bash
# 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
plain text
               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        2904

What 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:

SettingDefaultWhat happens at that point
vm.dirty_background_ratio10Kernel threads start writing dirty pages out, quietly, in the background
vm.dirty_ratio20The 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.

The counter-intuitive part. A successful write() means almost nothing about durability. The data is in RAM. If the machine loses power a second later, it is gone, and the file may be left half-updated. Only fsync() — or opening with O_SYNC, or running sync — actually forces it to storage. Every database on earth calls fsync for exactly this reason, and every "we lost data on a power cut but the write returned success" incident comes from not understanding this line.
Real-world analogy — the out-tray

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
bash
# 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
plain text
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.301s

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

Now imagine this at 500 hosts. Two failure modes come out of this section. First, latency spikes on write-heavy hosts that appear in no application log — the fix is usually to lower vm.dirty_background_ratio so writeback starts earlier and never reaches the blocking threshold. Second, and worse: a service that does not call fsync will look fast for months and then lose a few seconds of data on every unclean shutdown. Grep your logging and queueing configuration for the durability setting before an incident does it for you.

🎯 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 kindCan it be reclaimed?What it costs
Clean, file-backedYes, immediatelyNothing. Drop it; re-read from the file if it is needed again
Dirty, file-backedYes, after writebackA disk write first
AnonymousOnly if there is swapA disk write to swap, and a disk read to get it back
Anonymous, no swapNoIt 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.

Interview-grade detail. The reason "no swap" machines fail suddenly rather than gradually is right there in the last row. With swap, the kernel has a pressure release valve and degrades slowly. Without it, anonymous memory is completely unreclaimable, so the kernel can only evict page cache — and once the cache is gone it has nothing left to give and must kill something. That transition is abrupt, and it is why disabling swap makes machines less predictable, not more.
Real-world analogy — clearing the desk before a big job

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
bash
# 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
plain text
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 0

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

Now imagine this at 500 hosts. pgsteal_direct from /proc/vmstat is close to free to collect and almost nobody does. Take the delta per minute and graph it next to MemAvailable: a host doing direct reclaim is a host whose allocations have started costing latency, and it will show there well before it shows in any memory-utilisation panel. It is also the cleanest way to tell a cache-heavy host that is perfectly happy from one that is actually struggling — the utilisation number looks identical on both.

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.

vm.swappiness does not mean what its name suggests, and the meaning changed. In current kernels the documentation defines it as "the rough relative IO cost of swapping and filesystem paging", on a scale of 0 to 200, default 60. It is a ratio, not an eagerness dial: 100 means "treat swapping an anonymous page and dropping a file page as equally expensive". Values above 100 became legal in Linux 5.8, and they make sense when swap is genuinely cheaper than re-reading files — swap on NVMe, or compressed swap in RAM. And swappiness = 0 does not disable swapping; it tells the kernel to avoid it until it has almost no alternative.

Two variants are worth knowing because they change the trade-off completely:

KindWhere the pages goWhen it makes sense
Disk or file swapA partition or file on storageThe default. Cheap capacity, slow to fault back in
zramA compressed block device in RAMNo disk at all; trades CPU for capacity. Common on laptops, containers and small cloud VMs
zswapA compressed cache in RAM, in front of real swapYou have real swap but want to avoid touching it as often
Real-world analogy — the off-site document store

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
bash
# 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:

plain text
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:

plain text
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  containerd

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

Now imagine this at 500 hosts. Two very common misconfigurations. First, Kubernetes historically required swap to be off on nodes, and many clusters still run that way — so every node degrades abruptly rather than gracefully, and pods get OOM-killed with no warning period. (Swap support for nodes has since been added, but it is opt-in and rarely enabled.) Second, teams that do have swap alert on "swap usage above 10%" and get paged for a machine that is behaving perfectly. Replace both with a rate-based signal, and put zram on small nodes where you want a pressure valve without provisioning disk.

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.

LineMeaningWhat a rising number means
someAt least one task was stalled on memorySomeone is being slowed down
fullEvery runnable task was stalled at onceThe 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.

Interview-grade detail. Unlike CPU pressure, memory's full line is meaningful and is the one to alert on. some above a few percent means the machine is working harder than it should; full above about 10% sustained means it is thrashing and no amount of level-based metrics would have told you. This is also exactly what systemd-oomd watches, and it is why PSI is the right foundation for a memory alert rather than a utilisation percentage.

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.

Real-world analogy — the desk that is one drawer too small

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
bash
# 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
plain text
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=101857637

What 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 avg60avg10 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.

Now imagine this at 500 hosts. PSI is the metric that finally lets you write a memory alert that is neither noisy nor useless. A practical set: page on memory full avg60 > 10 sustained for a few minutes, ticket on memory some avg60 > 20, and graph the total= delta per minute for capacity trends. It works identically inside a cgroup — every cgroup v2 directory has its own memory.pressure file — so the same rule gives you per-container answers on a shared node, which no utilisation metric can do.

🎯 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:

ModeNameBehaviour
0Heuristic (the default)Refuse only obviously absurd requests; allow everything plausible
1Always overcommitNever refuse. Intended for programs using enormous sparse arrays
2StrictTotal 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.

The counter-intuitive part. Overcommit is why the OOM killer has to exist at all. In mode 0 the kernel says yes to requests it cannot honour, betting — correctly, almost always — that most of that memory will never be touched. When the bet occasionally loses, there is no polite way out: the memory was already promised, the program is already running, and something has to die. Mode 2 trades that for honest, immediate failure, which is why it is used on machines where an unplanned kill is worse than a failed allocation.
Mode 2 is not free and is usually the wrong answer in production. With the default ratio of 50 on a swapless machine, CommitLimit is only half your RAM — so a machine with 8 GB refuses allocations past 4 GB even though 7 GB is genuinely idle. Programs that reserve large address space up front (Go and JVM runtimes, as seen in Module 08) fail immediately at start-up. If you set mode 2, you must also raise overcommit_ratio or use vm.overcommit_kbytes, and you must expect to hear about it.
Real-world analogy — the airline that sells more seats than it has

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
bash
# 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
plain text
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 = 0

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

Now imagine this at 500 hosts. Committed_AS divided by MemTotal is a genuinely useful fleet metric that almost nobody collects: it tells you how heavily you are relying on the bet, per host. A fleet running at 120% commitment is normal; one at 400% is one unusual traffic pattern away from a wave of OOM kills. And never set vm.overcommit_memory=2 fleet-wide as a "safety" measure without also fixing overcommit_ratio — the default ceiling of half your RAM will break every Go and Java service you own, at start-up, simultaneously.

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.

The counter-intuitive part. The OOM killer does not target the process that caused the shortage. It targets the largest one, because killing it frees the most memory. A small leaking script can drive a machine into OOM and the database gets killed for it. This surprises people badly during incidents, and it is why "the OOM killer killed the wrong thing" is such a common complaint — it did exactly what it is designed to do.
oom_adj no longer exists. It was deprecated in Linux 2.6.36 and removed in 3.7. Any runbook, blog post or configuration-management recipe still writing to /proc/PID/oom_adj has been silently doing nothing for over a decade. The current file is /proc/PID/oom_score_adj, and the scales are different — the old one was −17 to +15.
Real-world analogy — the overloaded lift

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
bash
# 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
plain text
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 ---
-1000

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

bash
# 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:

plain text
[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:0

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

Now imagine this at 500 hosts. Ship kernel-log OOM lines to your log system and alert on them directly: they are rare, unambiguous, and carry a full memory snapshot. Two details make this far more useful than it sounds. First, an OOM kill inside a container is logged by the host kernel, not the container, so it is invisible to anything collecting only container logs — which is why teams so often see a pod restart with an empty log and no explanation. Second, grep for constraint= and route on it: CONSTRAINT_MEMCG is a limit-sizing question for one team, global_oom is a node-capacity question for another.

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:

FileWhat it isWhat happens when usage exceeds it
memory.maxA hard limitThe 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.highA throttleProcesses 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 137128 + 9, signal 9, SIGKILL, exactly the convention from Module 02.

Interview-grade detail. memory.high is the far better tool for most workloads and almost nobody uses it, because Kubernetes does not expose it. A service that briefly exceeds its limit gets killed under memory.max and merely gets slow under memory.high. If you run workloads under systemd rather than Kubernetes, MemoryHigh= and MemoryMax= are both available on any unit, and setting MemoryHigh a little below MemoryMax gives you a soft landing before the hard kill.
Counter-intuitive. A container's page cache counts towards its memory.max. A pod doing heavy file I/O can be OOM-killed by its own cache — memory that was entirely reclaimable and harmless — because the accounting charges cached pages to whichever cgroup first read them. The kernel does try to reclaim before killing, so it is usually fine, but a burst of I/O against a tight limit is a genuine and very confusing cause of OOMKilled.

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.

Real-world analogy — a shared building with per-office limits

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.

bash
# 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
plain text
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=0

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

Now imagine this at 500 hosts. Three things to take away. Collect memory.events per container, not just usage — the max counter finds pods that are being throttled to a crawl without ever being killed, which is invisible in every utilisation graph. Route on constraint= in the kernel log so cgroup kills go to the service team and global kills go to the capacity team. And if a service can tolerate being slow but not being killed, set MemoryHigh below MemoryMax under systemd, or accept that Kubernetes gives you only the hard limit and size it with real headroom.

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

  1. Is memory actually short?MemAvailable, not MemFree. If available is healthy, stop; this is not a memory problem.
  2. Is anything actually suffering?/proc/pressure/memory. A level without pressure is not an incident.
  3. Where did the memory go? — processes (Pss), page cache, or the kernel. These are three different investigations.
  4. 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"]
Set this block to Preview using the ••• menu on its right to see the diagram instead of the code. Notion does not do that automatically.
Real-world analogy — the missing stock

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.

Capture before you mitigate. Restarting the service clears the symptom and destroys the evidence, and the problem returns that night with nobody any wiser. cat /proc/meminfo /proc/pressure/memory /proc/vmstat > /tmp/incident.txt plus ps -eo pid,rss,comm --sort=-rss | head -20 >> /tmp/incident.txt takes under a second and is often the only record you will get.

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:

WhereWhat it isHow to see itReclaimable?
Page cacheFile contents held in RAMCached in /proc/meminfoYes, mostly — this is fine
SlabKernel data structures: inodes, dentries, network buffersSlab, SReclaimable, SUnreclaim; slabtopPartly. SUnreclaim is not
tmpfs / shmFiles that exist only in RAMShmem; findmnt -t tmpfs; df -h /dev/shmNo. There is no disk copy
Kernel overheadPage tables, kernel stacks, huge pagesPageTables, KernelStack, HugePages_TotalNo
The counter-intuitive one is tmpfs. A file written to /dev/shm, /run, or any tmpfs mount is memory that looks like a file. It appears in Cached, which makes it look reclaimable, but it has no backing store — the only way to free it is to delete the file or swap it out. A runaway process writing logs into /run can consume all of RAM while every process on the machine looks small and free reports it as cache.
🧪 Exercise D2.1 — Account for every byte on the machine
bash
# 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
plain text
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.184920114

What 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 nextHugePages_Total is reserved memory that appears in none of these lines and is excluded from MemAvailable too.

Now imagine this at 500 hosts. The two fleet-wide surprises in this section are Shmem and Slab. A tmpfs mount filling up — often /run on a host where something logs to it, or /dev/shm on a container host where an image sets a large --shm-size — consumes real RAM that every dashboard reports as harmless cache. And dentry/inode_cache growth on hosts doing heavy filesystem work is a well-known cause of "the node ran out of memory and no process was large". Collect Shmem, Slab and SUnreclaim alongside MemAvailable; between them they explain almost every incident where the process list adds up to nothing.

🎯 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 cacheCached, expected and mostly harmless. tmpfs and shared memoryShmem, 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 overheadPageTables 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 productionWhat is really happeningWhat to runThe fix
"Memory 97% used" alerts on healthy hostsThe page cache filled the RAM, as designedfree -m, read available; /proc/pressure/memoryAlert 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 fitsmemory full avg60 in /proc/pressure/memory; pgsteal_direct in /proc/vmstatMore RAM, a smaller working set, or move the workload
Pod restarts with an empty log and exit 137cgroup OOM kill; the record is in the host's kernel logdmesg -T on the node, check constraint=Raise the limit, or reduce the container's footprint
Container killed although its own data fits the limitPage cache is charged to the cgroup; heavy file I/O pushed it overmemory.stat in the cgroup, file versus anonHeadroom 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 largetmpfs, slab, or huge pages — memory outside the process listShmem, Slab, slabtop -s c, findmnt -t tmpfsBound the tmpfs size; find what is walking the filesystem
Write-heavy host stalls for seconds at a timevm.dirty_ratio reached; the writer is blocked in the kernelDirty in /proc/meminfo, sysctl vm.dirty_ratioLower vm.dirty_background_ratio so writeback starts earlier
Data lost on power cut although writes "succeeded"No fsync — the data was in the page cacheCheck the app's durability setting; Dirty during loadEnable fsync/O_SYNC in the application, and accept the real throughput
Machine goes from fine to OOM with no warningNo swap, so anonymous memory is unreclaimableswapon --show, SwapTotalAdd swap or zram as a pressure valve; alert on PSI
Paged at 3am for "swap usage 40%"A level, not a rate. Nothing was movingvmstat 1 — the si/so columnsAlert on sustained si/so, or on PSI. Delete the level alert
Wrong process killed by the OOM killerIt kills the largest, not the culprit/proc/PID/oom_score, the kernel log's task tableOOMScoreAdjust= positive on expendable jobs, mildly negative on the critical one

E2 · Capstone — four memory tickets

Four tickets. For each: what you run, in order, what you expect to see, and what the answer is. Attempt them before opening the toggles.

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/meminfocat /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:

bash
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.current

What 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:

bash
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 exists

What 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:

bash
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

TopicWhere to read itWhy this one
Every field in /proc/meminfoproc_meminfo(5)Defines MemAvailable, Shmem, SReclaimable precisely
The same, with kernel-side detailThe /proc filesystemExplains how MemAvailable is calculated
Every vm.* tunableDocumentation for /proc/sys/vmswappiness, dirty_ratio, overcommit_*, drop_caches
How overcommit accounting worksOvercommit accountingThe authoritative description of modes 0, 1 and 2
Memory pressurePSI — Pressure Stall Informationsome versus full, and how the averages are computed
cgroup memory controlControl Group v2memory.max, memory.high, memory.events, memory.stat
Reading freefree(1)States plainly that available is the useful column
Swap setup and prioritiesswapon(8) · mkswap(8) · proc_swaps(5)Including the PRIO field, which matters with multiple swap devices
Compressed swapzram · zswapThe two are different things and are constantly confused
OOM scoringproc_pid_oom_score(5) · proc_pid_oom_score_adj(5)Confirms the −1000..+1000 range and that oom_adj is gone
The userspace OOM daemonsystemd-oomd.service(8) · oomd.conf(5)Per-unit knobs live in systemd.resource-control(5), not here
Durabilityfsync(2) · sync(1)Read the NOTES section of fsync(2) about directories
Telling the kernel your access patternposix_fadvise(2) · madvise(2)POSIX_FADV_DONTNEED is how a backup avoids trashing the cache
Reading kernel logsdmesg(1) · journalctl(1)dmesg -T for timestamps; journalctl -k to cross boots
The short version, for colleaguesLinux ate my RAMOne page. Send it to whoever filed the 97% ticket
Reading docs without a browser. man 5 proc_meminfo settles any argument about what a field means. The kernel's vm sysctl documentation installs with the linux-doc package under /usr/share/doc/linux-doc/. Note that the kernel documentation deliberately does not publish default values for vm.dirty_ratio and vm.dirty_background_ratio — read them from your own machine with sysctl vm.dirty_ratio vm.dirty_background_ratio rather than trusting any blog post, because distributions and tuning profiles override them.

E4 · Self-assessment

Answer these out loud before moving to Module 10. The section to reread is named after each.

  1. Why does a healthy Linux server show almost no free memory, and why is that correct? (A1)
  2. Which column of free should you actually read, and why is free + buff/cache the wrong sum? (A2)
  3. A write() returned success. What has actually happened, and what would make the data durable? (A3)
  4. What are vm.dirty_ratio and vm.dirty_background_ratio, and which one blocks the writing process? (A3)
  5. Rank the four kinds of page by how cheap they are to reclaim, and say which kind cannot be reclaimed at all. (B1)
  6. What is pgsteal_direct, and why is it a better early warning than memory utilisation? (B1)
  7. What is swap actually for, and what breaks when you turn it off? (B2)
  8. What does vm.swappiness control, what is its range, and what does 0 mean? (B2)
  9. Why is "swap used 40%" a bad alert, and what should replace it? (B2, B3)
  10. What is thrashing, and why does no utilisation metric reveal it? (B3)
  11. What is the difference between the some and full lines in /proc/pressure/memory? (B3)
  12. What is memory overcommit, and why does the OOM killer exist because of it? (C1)
  13. How does the OOM killer choose a victim, and what does oom_score_adj = -1000 do? (C2)
  14. In a kernel OOM report, what does constraint=CONSTRAINT_MEMCG tell you that changes the investigation? (C2, C3)
  15. What is the difference between memory.max and memory.high, and which one does Kubernetes give you? (C3)
  16. Exit code 137 — what happened, in full? (C3)
  17. A machine is 94% full and no process is large. Name the four places to look. (D2)
  18. Why is Shmem the dangerous one of those four? (D2)

E5 · Sources

Everything in this module was checked against these. Where a claim is unusual — swappiness running to 200, oom_adj having been removed, memory.high never invoking the OOM killer, the dirty-ratio defaults not being in the kernel docs — the source below is the one to cite.

Kernel documentation

· Memory management — concepts overview

· Documentation for /proc/sys/vm

· Overcommit accounting

· PSI — Pressure Stall Information

· Control Group v2

· The /proc filesystem

· zram · zswap

Manual pages

· proc_meminfo(5) · proc_swaps(5) · free(1) · vmstat(8)

· swapon(8) · mkswap(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)

· dmesg(1) · journalctl(1)

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

Next: Module 10 — I/O, the Block Layer & iowait. Every expensive thing in this module ended at a disk: major faults, writeback, swap-in. Module 10 opens that up — what actually happens between a write() and a platter, why iowait is such a poor metric, how to read iostat properly, and where queueing and I/O schedulers change the answer.
Spotted a mistake or want something added? Send me a note.