Reading an OOM kill in a journalctl export

The kernel’s out-of-memory report names the thread that asked, the process it killed and every page it counted. systemd then records the unit’s result, and two settings decide what happens next.

Before the Linux kernel kills a process for memory, it writes a report. The report names the thread that asked for memory, the pages each eligible process held and the victim it chose.1

systemd then writes its own lines about the unit, and two of its settings decide what happens next.7 Read both in the right units, and the logs separate what happened from what you suspect.

An example to read along

The report here is INC-0142, an example from the Foxborne example dataset, not a field report. A Q4 recon quad carries a Jetson Orin NX 16 GB companion computer running Linux 5.15.148-tegra. Its perception_node process streams obstacle distances to PX4 and keeps a cache of map tiles.

At 14:32:04.118 UTC the companion’s journal recorded the kill, and a telemetry gap followed. This post reads only the kill, line by line, in the order the kernel printed it.

In the export, each line of the report is a separate entry. Source excerpts below are from the v5.15 tag of mainline Linux.

Who asked: the invoking thread

uas04-orin-journal.jsonExample dataset, INC-0142, kernel entries, abridgedlog
tile_decode invoked oom-killer: gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
CPU: 5 PID: 2247 Comm: tile_decode Tainted: G           O      5.15.148-tegra #1
Hardware name: Harrow Q4 compute carrier, Jetson Orin NX 16GB (DT)
Call trace:
...
 out_of_memory+0xf4/0x35c
 __alloc_pages_slowpath.constprop.0+0x7d0/0x964
 __alloc_pages+0x2a8/0x2c0
 alloc_pages_vma+0x9c/0x264
 __handle_mm_fault+0x7e0/0x1060
 handle_mm_fault+0xe8/0x260
 do_page_fault+0x15c/0x530
 do_translation_fault+0x8c/0xa0
...

The first line comes from dump_header(). Its first field is the name of the task that was allocating when memory ran out:1

mm/oom_kill.cv5.15, lines 457 to 459C
pr_warn("%s invoked oom-killer: gfp_mask=%#x(%pGg), order=%d, oom_score_adj=%hd\n",
	current->comm, oc->gfp_mask, &oc->gfp_mask, oc->order,
		current->signal->oom_score_adj);

That task is tile_decode, and the next line gives its ID, 2247. It has no row in the tasks table further down, because that table lists processes.1 In the example it is a thread of perception_node, pid 2213.

The rest of the line describes the request. gfp_mask holds the allocation flags, printed once as a number and once by name. GFP_HIGHUSER_MOVABLE is the kernel’s mask for user-space pages that reclaim or migration can move.3

order=0 gives the size as a power of two: 2⁰ pages, a single page.2 oom_score_adj=0 is the asking process’s score adjustment, the same value the tasks table shows for it.

Read the call trace from the bottom up. tile_decode took a page fault, and the kernel needed a new page to map into its memory. alloc_pages_vma asked for one, and the allocator’s slow path called out_of_memory.2

The frames cut from the top of the excerpt are the report printing itself: oom_kill_process calls dump_header(), which calls dump_stack().1 So one ordinary page triggered the kill. The kill line reads “Out of memory”, not “Out of memory (oom_kill_allocating_task)”, so the kernel picked its victim by score.1

Who died, and why: the tasks table

Next come Mem-Info, the kernel’s page counts for the whole machine, and then the tasks table.1 Its heading says what matters most:

mm/oom_kill.cv5.15, lines 428 to 429C
pr_info("Tasks state (memory values in pages):\n");
pr_info("[  pid  ]   uid  tgid total_vm      rss pgtables_bytes swapents oom_score_adj name\n");

The memory columns count pages, except pgtables_bytes, which is in bytes despite the heading.1 These are six of the example’s 147 rows:

uas04-orin-journal.jsonExample dataset, INC-0142, tasks table, abridgedlog
Tasks state (memory values in pages):
[  pid  ]   uid  tgid total_vm      rss pgtables_bytes swapents oom_score_adj name
...
[    412]     0   412    12644      987          94208        0          -250 systemd-journal
[    611]   104   611    40211      612          98304        0             0 chronyd
[   1388]  1001  1388    18233     2210         131072        0             0 mavlink-routerd
[   1502]  1001  1502   257411    28310         720896        0             0 collector
[   2186]  1001  2186   512993    60521        1150976        0             0 depth_driver
[   2213]  1001  2213  2968312  1708439       15073280        0             0 perception_node

You do not have to assume the page size. The kill line below prints the victim’s virtual size in kB. It matches this table only at 4 KiB a page: 2,968,312 pages × 4 KiB = 11,873,248 kB.

So the rss column converts to MiB as pages × 4 ÷ 1,024. For perception_node: 1,708,439 × 4 = 6,833,756 KiB, and 6,833,756 ÷ 1,024 = 6,673.6 MiB. depth_driver holds 60,521 pages, 236.4 MiB, and mavlink-routerd holds 2,210 pages, 8.6 MiB.

02,0004,0006,000 MiBProcessResident at the kill: rss pages × 4 KiB ÷ 1,024perception_node6,673.6 MiB, killeddepth_driver236.4 MiBcollector110.6 MiBmavlink-routerd8.6 MiBsystemd-journal3.9 MiBchronyd2.4 MiB141 other tasks46.4 MiB between them
Example incident INC-0142. Resident memory at the kill, converted from the tasks table. The kernel killed the one process that held nearly all of it.

The kernel does not pick by rss alone. oom_badness() adds swap entries and page-table pages, then adds oom_score_adj in units of a thousandth of the memory in play:1

mm/oom_kill.cv5.15, lines 233 to 239, in oom_badness()C
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
	mm_pgtables_bytes(p->mm) / PAGE_SIZE;
task_unlock(p);

/* Normalize to oom_score_adj units */
adj *= totalpages / 1000;
points += adj;

For perception_node that is 1,708,439 + 0 + 15,073,280 ÷ 4,096 = 1,708,439 + 3,680 = 1,712,119. depth_driver scores 60,521 + 0 + 281 = 60,802, so the choice was not close: 28 to 1.

A negative adjustment, like the -250 on systemd-journal, lowers a score. A systemd unit sets it with OOMScoreAdjust=.7

Where it ran out: the constraint line

uas04-orin-journal.jsonExample dataset, INC-0142log
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/perception.service,task=perception_node,pid=2213,uid=1001

dump_oom_summary() builds this line from several pieces.1 CONSTRAINT_NONE means no cpuset, memory policy or memory cgroup narrowed the search, so the kernel counted all RAM and swap as the pool.1

global_oom means the whole machine ran out, not a cgroup at its limit, which would print oom_memcg= instead.5 task_memcg is the victim’s memory cgroup, and its path names the systemd unit.5 This is where the kernel’s report meets systemd’s.

The kill line, in MiB

uas04-orin-journal.jsonExample dataset, INC-0142, line 18,314log
Out of memory: Killed process 2213 (perception_node) total-vm:11873248kB, anon-rss:6823516kB, file-rss:10240kB, shmem-rss:0kB, UID:1001 pgtables:14720kB oom_score_adj:0
mm/oom_kill.cv5.15, lines 891 to 899C
do_send_sig_info(SIGKILL, SEND_SIG_PRIV, victim, PIDTYPE_TGID);
mark_oom_victim(victim);
pr_err("%s: Killed process %d (%s) total-vm:%lukB, anon-rss:%lukB, file-rss:%lukB, shmem-rss:%lukB, UID:%u pgtables:%lukB oom_score_adj:%hd\n",
	message, task_pid_nr(victim), victim->comm, K(mm->total_vm),
	K(get_mm_counter(mm, MM_ANONPAGES)),
	K(get_mm_counter(mm, MM_FILEPAGES)),
	K(get_mm_counter(mm, MM_SHMEMPAGES)),
	from_kuid(&init_user_ns, task_uid(victim)),
	mm_pgtables_bytes(mm) >> 10, victim->signal->oom_score_adj);

The kernel sends SIGKILL to the whole process first, then prints the line. K() shifts a page count left by PAGE_SHIFT-10 bits, which multiplies by 4 on a 4 KiB page, so the kernel’s kB are KiB.1

  • total-vm: 11,873,248 kB ÷ 1,024 = 11,595.0 MiB of virtual address space.
  • anon-rss: 6,823,516 kB ÷ 1,024 = 6,663.6 MiB of anonymous memory, backed by no file.
  • file-rss: 10,240 kB = 10.0 MiB. shmem-rss: nothing.
  • pgtables: 14,720 kB, which is the table’s 15,073,280 bytes ÷ 1,024.

The three resident counters add to 6,833,756 kB, the table’s 1,708,439 pages × 4. That is by construction: get_mm_rss() returns exactly that sum.4 Resident at the kill: 6,673.6 MiB.

What systemd writes next

uas04-orin-journal.jsonExample dataset, INC-0142, UNIT=perception.service, times in UTClog
14:32:04.121  perception.service: A process of this unit has been killed by the OOM killer.
14:32:04.133  perception.service: Main process exited, code=killed, status=9/KILL
14:32:04.134  perception.service: Failed with result 'oom-kill'.
14:32:09.141  perception.service: Scheduled restart job, restart counter is at 1.
14:32:09.214  Started Perception service.

Three milliseconds after the kernel line, systemd logs the OOM kill inside the unit. At .133 the main process is gone, killed by signal 9. At .134 the unit fails with the result oom-kill.

OOMPolicy= sets what systemd does when the kernel kills a process of the unit: continue, stop or kill. Under stop and kill, the man page says the unit ends in the oom-kill failed state, which is what this journal shows. kill also has the kernel kill every other process in the unit.7

Here the policy mattered little. The victim was the main process, and by default systemd considers a service stopped when its main process exits.7 If the unit sets no policy, it gets DefaultOOMPolicy=, or continue when Delegate= is on.7

Restart= decides whether the service comes back. The man page’s table lists termination due to OOM as a restart cause for on-failure, on-abnormal and always.7

The restart job was scheduled 5.007 s after the failure: 14:32:09.141 minus 14:32:04.134. RestartSec= defaults to 100 ms, so this unit’s settings ask for something slower.7 Ask the running system rather than the file you think it loaded:8

terminalshell
systemctl show perception.service -p OOMPolicy -p Restart -p RestartUSec

The new process, pid 3398, was sampled at 412 MiB a second after it started.

Observed, and still a hypothesis

Together, the kernel’s report and systemd’s lines prove a machine-wide shortage, a victim chosen by score and a unit that failed and restarted. They do not prove why perception_node grew. The companion’s journal points one way: at 14:13:53.118 the process logged “map tile cache: cache_max_mb not set; eviction disabled”.

At 14:32:03.412, 0.706 s before the kill, it reported “tile cache: 4,679 tiles, 5,849 MiB, eviction off”. An unbounded cache fits the numbers, but the kernel counts anonymous pages, not tiles. A heap profile, or a bench run with a cache limit set, would turn that into a finding.

The same care applies to the last second. The process samples read 6,402 MiB at 14:32:02.204 and 6,655 MiB at 14:32:03.204, a climb of 253 MiB. The kill line, 0.914 s later, shows only 18.6 MiB more.

The reviewer on this example read that as allocations stalling in reclaim before the kill. It fits, and the report’s next step tests it: record /proc/pressure/memory at 10 Hz during a bench repeat.

Pull it from your own journal

Two commands recover everything above. Pin the boot ID from the export, so a reboot cannot shift what you read:

terminalshell
# The kernel's report: find its first and last lines, with microsecond times
journalctl -k -b 9b2e4c1d0f8a4e57b3c2a1d9e8f7c6b5 -o short-precise -g 'invoked oom-killer|killed process'
# systemd's lines about the unit, one JSON object per entry
journalctl -b 9b2e4c1d0f8a4e57b3c2a1d9e8f7c6b5 -u perception.service -o json

-k adds the match _TRANSPORT=kernel. -u matches the unit’s own messages and systemd’s messages about it, and an all-lowercase -g pattern ignores case.9

In JSON, each entry carries __REALTIME_TIMESTAMP and __MONOTONIC_TIMESTAMP in microseconds.11 Both mark when journald received the entry, not when the kernel wrote it.10 The monotonic timestamps give the same 16 ms from kill to failure: 1,152,920,000 minus 1,152,904,000 µs.

What to write down

The kernel’s report is strong evidence about memory and weak evidence about cause. Write the finding the way the report supports it.

What the evidence shows

  • The kernel killed perception_node, pid 2213, at 14:32:04.118 on a machine-wide shortage.
  • It held 6,673.6 MiB resident and outscored the next process 28 to 1.
  • A page fault in its own thread, tile_decode, asked for the page that tipped it over.
  • systemd recorded oom-kill 16 ms after the kernel line and scheduled a restart 5.007 s later.

What it does not

  • Why the tile cache grew. The journal says eviction was off, not why.
  • That allocations stalled in reclaim. That needs pressure data from a repeat.
  • That the kill caused the telemetry gap that followed. That link runs through another unit.

Sources

  1. 1mm/oom_kill.cLinux kernel v5.15. Accessed September 26, 2026.
  2. 2mm/mempolicy.c, alloc_pages_vma() and alloc_pages()Linux kernel v5.15. Accessed September 26, 2026.
  3. 3include/linux/gfp.h, GFP_HIGHUSER_MOVABLELinux kernel v5.15. Accessed September 26, 2026.
  4. 4include/linux/mm.h, get_mm_rss()Linux kernel v5.15. Accessed September 26, 2026.
  5. 5mm/memcontrol.c, mem_cgroup_print_oom_context()Linux kernel v5.15. Accessed September 26, 2026.
  6. 6include/linux/printk.h, pr_err(), pr_warn() and pr_info()Linux kernel v5.15. Accessed September 26, 2026.
  7. 7systemd.service: OOMPolicy=, Restart=, RestartSec=, ExitType=systemd man pages. Accessed September 26, 2026.
  8. 8systemctl: show, --property=systemd man pages. Accessed September 26, 2026.
  9. 9journalctlsystemd man pages. Accessed September 26, 2026.
  10. 10systemd.journal-fieldssystemd man pages. Accessed September 26, 2026.
  11. 11Journal Export Formatssystemd project. Accessed September 26, 2026.

More field notes

Companion computersWhen BindsTo= takes your MAVLink router down with itA dependency in the unit graph can silence telemetry with no radio fault at all. Here is how to spot one in the journal, and how to prove it on the bench before anyone swaps a radio.Time and clocksBoot time, wall time, arrival time: one incident, three clocksA PX4 flight log counts from boot, the companion journal keeps wall time and the ground station logs arrival. Give each an explicit error bound, and you know which events you can put in order and which you cannot.Ground linksA telemetry gap is not a radio failureA hole in the ground receive log proves only that nothing arrived. A stopped router, a radio that left the USB bus and a berm all look the same there, and other logs tell them apart.

A pilot on your own data

Bring your hardest incident.

Send one failure you have already investigated. We rebuild it on your data, beside your current tools, and show where the evidence agrees with your conclusion and where it doesn’t.

  1. 1
    Send one incidentA failure you have already investigated, with the flight log and whatever companion or ground evidence you kept.
  2. 2
    We reconstruct itBeside your current tools, on your data, with every claim traced to its source.
  3. 3
    Compare the answersWhere the evidence agrees with your conclusion, where it does not, and what it cannot decide.