毎晩Javaプロセスが突然停止する ― OOM Killer 強制終了の調べ方と対策

本番の Web アプリケーションサーバで、毎晩決まった時間に Java プロセスが突然停止します。

しかしアプリケーションログには、終了直前まで正常な処理記録しか残っていません。
エラーメッセージは一切ありません。
systemd の自動再起動ですぐに復帰するため、翌朝の業務開始時には何事もなかったように正常稼働しています。

このような「ログが無いのにプロセスが止まる」障害は、アプリではなく OS(カーネル)のメモリ管理=OOM Killer を疑うサインです。
この記事では、OOM Killer の発動をカーネルログから特定し、対策まで実機で確認した流れをまとめます。


1. 症状

本番の Web アプリケーションサーバ(メモリ 16GB)で、次の症状が出ています。

  • 毎晩、深夜帯に Java アプリケーションのプロセスが突然停止する
  • アプリケーションログには終了直前まで正常な処理記録があり、エラーメッセージは一切ない
  • systemd の Restart=on-failure 設定で停止後すぐに自動再起動され、翌朝には正常稼働している
  • 停止する時間帯は バッチ処理が集中する時間 と重なっている
  • アプリチームは「アプリ側に問題はない」と主張している

ポイントは2つです。
エラーログが無いのにプロセスが停止すること、そして 停止する時間がバッチ集中時間と重なっていること

この2点がそろったとき、まず疑うべきは OS のメモリ枯渇、すなわち OOM Killer です。


2. 原因の仕組み ― OOM Killer とは

Linux はメモリが枯渇すると、システム全体の停止を避けるため、カーネルがあるプロセスを選んで強制終了します。
この仕組みが OOM Killer(Out Of Memory Killer) です。
この強制終了は、俗に「プロセスが殺される」とも表現されます。

では、どのプロセスが選ばれるのか。
OOM Killer は、プロセスごとに oom_score という「終了されやすさの点数」を持っています。
値が大きいほど優先的に選ばれ、その主因は使用メモリ量(RSS)です。
管理者は oom_score_adj(-1000〜+1000)でこの点数を調整でき、値を下げれば保護、上げれば狙われやすくなります。

OOM Killer の挙動を観点ごとに整理すると、次のとおりです。

観点内容
何が起きるかカーネルがプロセスに SIGKILL(シグナル9)を送って強制終了する
なぜアプリログに何も残らないかSIGKILL は即死で、プロセスは終了処理もログ出力もできない
誰が強制終了されるかoom_score(おおむね使用メモリ量=RSS に比例)が最も高いプロセス
なぜ Java が狙われやすいかJVM はヒープをまとめて確保するため使用メモリが大きく、oom_score が高くなりやすい
なぜ朝には正常かRestart=on-failure で自動再起動され、ログだけが手がかりとして残る

OOM Killer の重要な性質は、犠牲になるのは「暴走したプロセス」とは限らないことです。
選定の基準は使用メモリ量です。
そのため、一時的に大量のメモリを使った深夜バッチが「引き金」を引いても、実際に強制終了されるのは、常時メモリを多く抱えている Java アプリという形になります。

メモリ不足の構図はシンプルです。

[常駐プロセス(Java) のメモリ] + [深夜バッチの一時的なメモリ] > [物理メモリ + swap]
                                              ↓
                              OOM Killer が発動し、RSS 最大の Java を強制終了

補足:システム全体のメモリ枯渇で起きるものを global OOM、cgroup(systemd スライスやコンテナ)の上限超過で起きるものを cgroup OOM と呼び、ログの文言が異なります(後述)。イメージは、global OOM=ホスト全体のメモリ不足、cgroup OOM=コンテナやサービス単位で割り当てた上限に達したケースです。本記事は本番想定の global OOM を扱います。


3. 調査手順 ― カーネルログから証拠を出す

ここからは、実機で再現した調査の流れです。
検証環境は Oracle Linux 8.10・メモリ約1.68GB・swap 無しの仮想マシン(ホスト名 app-node)。
Java アプリを systemd サービス化し、深夜バッチの代役として tail /dev/zero(ゼロを無限に読み込み、メモリを際限なく膨張させるワンライナー)を走らせ、意図的にメモリを枯渇させて OOM Killer を発動させました。
以降、ログに登場する tail は、この「暴走した深夜バッチの代役」だと読んでください。

Step 1. 平常時の状態を押さえる

まず、対象 Java プロセスの「標的になりやすさ」を見ておきます。

$ cat /proc/$(pgrep -x java)/oom_score
1138
$ cat /proc/$(pgrep -x java)/oom_score_adj
0
$ free -m
              total        used        free      shared  buff/cache   available
Mem:           1680        1353          71          16         254         161
Swap:             0           0           0

oom_score が 1138 と高く、Java がシステムで最もメモリを使っているプロセスであることが分かります。
oom_score_adj は既定の 0。
空きメモリは 71MB しかなく、ここに一時的な負荷がかかれば OOM Killer が発動する状態です。

Step 2. OOM Killer の発動をカーネルログで確認する(最重要)

プロセスが停止した後、最初に打つべきはこれです。

$ dmesg -T | grep -iE 'invoked oom-killer|Out of memory|Killed process|oom-kill:'
[土  6月 27 22:00:32 2026] gmain invoked oom-killer: gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[土  6月 27 22:00:32 2026] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/demoapp.service,task=java,pid=1371789,uid=0
[土  6月 27 22:00:32 2026] Out of memory: Killed process 1371789 (java) total-vm:3189492kB, anon-rss:1199848kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:2560kB oom_score_adj:0

この3行が決定的な証拠です。
読み方は次のとおりです。

  • constraint=CONSTRAINT_NONEglobal_oom … システム全体のメモリ枯渇(cgroup 上限ではない)
  • task=java,pid=1371789 … 犠牲に選ばれたのは Java
  • Out of memory: Killed process 1371789 (java) … その Java を強制終了した
  • anon-rss:1199848kB … 強制終了された時点で約1.2GB のメモリを使っていた(最も太かった)

なお、先頭の gmain invoked oom-killergmain は「最初にメモリ確保に失敗したタスク」であって、原因ではありません。
引き金はバッチのメモリ需要、犠牲の選定は RSS 最大の Java、という因果です。

dmesg と同じ内容は journalctl -k でも確認できます。
発生時刻や頻度を時系列で追いたいときは journalctl -k --since today | grep -i oom が向きます。

Step 3. systemd 側の記録 ― 「外部から強制終了された」証拠と自動再起動

サービスを systemd で管理している場合、systemd 側にも記録が残ります。

$ journalctl -u demoapp --since "-3 min" --no-pager
 6月 27 22:00:33 app-node systemd[1]: demoapp.service: Main process exited, code=killed, status=9/KILL
 6月 27 22:00:33 app-node systemd[1]: demoapp.service: Failed with result 'signal'.
 6月 27 22:00:35 app-node systemd[1]: demoapp.service: Service RestartSec=2s expired, scheduling restart.
 6月 27 22:00:35 app-node systemd[1]: demoapp.service: Scheduled restart job, restart counter is at 1.
 6月 27 22:00:35 app-node systemd[1]: Stopped Demo Java App.
 6月 27 22:00:35 app-node systemd[1]: Started Demo Java App.

code=killed, status=9/KILL が決定的です。
これは「プロセスが自分のエラーで終了した」のではなく、外部から SIGKILL(シグナル9)で強制終了されたことを示します。
その2秒後に自動再起動しており、これが「朝には正常稼働している」の正体です。

Step 4. アプリログに何も残らないことを対比する

最後に、アプリ視点を確認します。

$ tail -n 5 /var/log/demoapp/app.log
Sat Jun 27 22:00:24 JST 2026 heartbeat ok
Sat Jun 27 22:00:29 JST 2026 heartbeat ok
Sat Jun 27 22:00:36 JST 2026 demoapp started, heap held ~1000MB
Sat Jun 27 22:00:36 JST 2026 heartbeat ok

22:00:29 の正常ログを最後に途切れ、22:00:36 でいきなり「起動」のログが出ています。
エラーは一行もありません。

このように何も残らないことは、アプリチームが「うちは悪くない」と主張する材料になりがちです。
しかし実は、それこそが OS 側を疑うべきサインです。

ここまでで、アプリログに記録なし × カーネルログの Killed process (java) × systemd の自動再起動 の三点がそろい、global OOM による強制終了と確定できます。


4. 対策 ― 3つの打ち手とトレードオフ

OOM Killer への対処は複数あります。
それぞれを同じ手法(再びメモリを枯渇させる)で実機検証し、効果と代償を確認しました。

対策① Java を OOM Killer から保護する(systemd OOMScoreAdjust

oom_score_adj を下げると、そのプロセスは OOM Killer に選ばれにくくなります。
ここで注意点があります。
/proc/<PID>/oom_score_adj への直接書き込みは、プロセスが再起動すると消えます
OOM Killer は対象を再起動させるので、この用途では最悪のタイミングで設定が失われます。

本番で確実なのは、systemd ユニットに設定することです。

[Service]
OOMScoreAdjust=-1000

適用後、同じバッチを撃つと結果が変わりました。

$ dmesg -T | grep -iE 'Out of memory|oom-kill:' | tail -2
[土  6月 27 22:04:27 2026] oom-kill:constraint=...,global_oom,...,task=tail,pid=1374602,uid=0
[土  6月 27 22:04:27 2026] Out of memory: Killed process 1374602 (tail) total-vm:252176kB, anon-rss:246532kB, ...

犠牲が Java から、暴走したバッチ(tail)自身 に移りました。
Java は再起動せず生存しています。

ただし、ここにトレードオフがあります。
あるプロセスを守ると、代わりに別のプロセスが身代わりになるということです。
今回はたまたま「暴走したバッチが死ぬ」という望ましい結果でしたが、それはバッチが次に太かったからにすぎません。
守る対象を間違えると、基盤プロセスを巻き込む可能性があります。

注意:OOMScoreAdjust が効くのは systemd が起動したプロセスだけです。 たとえば Oracle を手動起動や dbstart/oratab で上げている場合、そのバックグラウンドプロセスは systemd 管理外のため、この設定では保護できません。Oracle は必須プロセス(PMON・LGWR など)が1つ強制終了されるだけでインスタンス全体が停止するため、影響は大きくなります。この場合は Oracle Restart / Grid Infrastructure での管理や HugePages による SGA の固定などで別途守ります(Oracle 特有の挙動は稿を改めて実機検証します)。

対策② JVM ヒープ上限(-Xmx)を絞る

-Xmx で JVM のヒープ上限を設定すると、Java の使用メモリに天井ができます。
-Xmx は Java の起動オプションで、systemd 管理下なら該当ユニットの ExecStart(例:ExecStart=/usr/bin/java -Xmx512m -cp /opt/demoapp App)に指定します。
天井に達した場合、OS に強制終了される前に JVM 自身が OutOfMemoryError を送出 します。

$ journalctl -u demoapp --since "-1 min" | grep -i OutOfMemory
6月 27 22:09:09 app-node java[1376318]: Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

失敗の形が「記録の残らない OS Kill(status=9/KILL)」から「可視のアプリ例外(status=1/FAILURE +スタックトレース)」に変わりました。
原因がログに残るうえ、JVM が自分の枠内で落ちるため、システム全体を巻き込みにくくなります。
代償は、上限到達でアプリが例外停止することです。
適正なヒープサイジングが前提になります。

注意:-Xmx が制限するのはヒープだけです。 JVM の総メモリはヒープに加えて Metaspace・スレッドスタック・ダイレクトバッファなども含むため、ヒープ以外(オフヒープ)が膨らむケースでは、-Xmx を設定していても OS に強制終了され得ます。今回のような「ヒープが太る」典型パターンでは有効ですが、-Xmx = Java の総メモリ上限ではない点に注意してください。

対策③ swap を追加する

swap があると、メモリ逼迫時にページを退避でき、OOM Killer の発動までに猶予が生まれます。

$ swapon -a
$ # 同じバッチを撃った後
$ free -m
              total        used        free      shared  buff/cache   available
Mem:           1680         718         904           6          57         857
Swap:          2047         708        1339

swap が 708MB 使われ、同じ条件でも OOM Killer は発動しませんでした
ただし swap への退避が増えると I/O 遅延につながります。
即死は防げますが、根本対策ではなく時間稼ぎです。

対策の線引き

対策効果(実証)トレードオフ
OOMScoreAdjust=-1000犠牲が Java→バッチへ移動。Java は生存別プロセスが身代わり。守る対象を誤ると基盤を巻き込む
-Xmx 制限失敗が OutOfMemoryError で可視化。巻き込み抑制上限到達でアプリは例外停止。適正サイジングが前提
swap 追加同条件で OOM 不発。即死回避スワップ多発で遅延。根治ではない
メモリ増設・配置分離恒久対策コスト・停止

これらはいずれも 緩和策 です。
根本原因は「常駐プロセスとバッチのピーク時メモリの合計が、物理メモリを超えること」。
本筋は、常駐+バッチのピーク総量 < 物理メモリ(+適正な swap) になるメモリ設計です。


5. よくある Q&A

Q1. なぜアプリログにエラーが出ないのですか?
OOM Killer は SIGKILL(シグナル9)でプロセスを即座に強制終了します。
プロセスは終了処理を行う間もなく消えるため、自分でエラーを記録できません。
「ログが無いこと」自体が OS 側を疑う手がかりになります。

Q2. なぜ Java が狙われやすいのですか?
oom_score はおおむね使用メモリ量に比例します。
JVM はヒープをまとめて確保するため使用メモリが大きく、oom_score が高くなりやすいためです。

Q3. oom_score_adj の範囲は?
-1000(ほぼ保護)〜 +1000(優先的に排除)です。
保護した分、別のプロセスが選ばれやすくなる点に注意してください。

Q4. 特定のプロセスを OOM Killer の対象から除外できますか?
できます。対策①がそれにあたり、systemd の OOMScoreAdjust=-1000(または oom_score_adj=-1000)を設定すると、そのプロセスは oom_score が最低になり、選定対象から実質外れます。
ただし完全な免除ではありません。他に終了できるプロセスが無ければ、保護したプロセスでも最終的に選ばれることがあります。

Q5. OOM Killer 自体を無効化できますか?
実質的に止める設定はありますが、本番では推奨しません。
以下はいずれもカーネルパラメータ(sysctl)で、sysctl -w vm.overcommit_memory=2 で一時的に、/etc/sysctl.conf(または /etc/sysctl.d/*.conf)に記述して sysctl -p で永続的に反映します。

  • vm.overcommit_memory=2 は、プロセスを強制終了する前にメモリ確保要求そのものを失敗させます。ただし JVM は起動時に大きな仮想メモリを予約するため、これで起動不能になりやすい点に注意が必要です。
  • vm.panic_on_oom=1 は、プロセスを殺さない代わりにカーネルパニック(サーバ全体の停止)になります。

いずれもメモリ不足という根本は解決せず、失敗の形が変わるだけです。

Q6. 再起動で直るなら、放置でよいのでは?
よくありません。
systemd ユニットの [Service] に設定された Restart=on-failure(異常終了時に自動再起動する設定)は問題を隠しているだけで、毎晩落ちている事実は変わりません。
処理の欠損やデータ不整合のリスクがあります。
カーネルログで顕在化させ、メモリ設計を見直すべきです。

Q7. global OOM と cgroup OOM の違いは?
ログ文言が異なります。
システム全体の枯渇は Out of memory:、cgroup の上限超過は Memory cgroup out of memory: と出ます。
systemd スライスやコンテナでメモリ上限を設定している場合は cgroup OOM になります。


6. まとめ

「ログが無いのにプロセスが止まる」は、OOM Killer を疑う典型パターンです。

調査の流れは次のとおりです。

エラーログが残らずプロセスが止まる
   + バッチ集中時間と一致
   + 自動再起動で朝は正常
        │
        ▼  まず OS(カーネル)を疑う
  dmesg -T | grep -i 'out of memory'
        │
        ▼  Killed process (java) を確認
  journalctl -u <svc>  →  status=9/KILL(外部killの証拠)+自動再起動
  /proc/<PID>/oom_score →  なぜ Java が選ばれたか
        │
        ▼  対策(緩和)
  OOMScoreAdjust=-1000(保護/別プロセスが身代わり)
  -Xmx 制限(失敗を可視化)
  swap 追加(即死回避)
        │
        ▼  根本
  常駐+バッチのピーク総量 < 物理メモリ(+適正swap)になる設計

覚えておくべき初動は、dmesg -T | grep -i 'out of memory' の一撃です。
ここに Killed process が出れば、アプリは無罪、原因は OS のメモリ枯渇、と即断できます。