Linuxでsystemd-oomdを設定する:サーバーフリーズを防ぐプロアクティブなメモリ不足対策

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

6ヶ月前、Node.jsアプリケーションを稼働させていたVPSインスタンスの一つが深夜3時に完全にフリーズした。SSHの接続はタイムアウトし、監視ダッシュボードは横一直線を示した。唯一の解決策はハードリブートだった。原因は何か?システムがメモリ不足に陥り、カーネルに組み込まれた最後の手段であるLinux OOMキラーが何を終了させるか決めかねてストールしてしまったのだ。OOMキラーがついに動作したときには、マシンはすでに応答不能な状態だった。

3年間で10台以上のLinux VPSインスタンスを運用してきた経験から言うと、このようなシナリオは誰もが認める以上によく起こる。対策としてsystemd-oomdのテストを始め、2 vCPU / 2 GB RAMのDigitalOceanドロップレット上で6ヶ月間本番環境で稼働させた結果、実際に何が起きるかを具体的にお伝えできる。

本当の問題:なぜサーバーは復旧せずにハングするのか

LinuxサーバーがRAMを使い果たしたとき、理論は単純だ:カーネルが何かを終了させ、メモリが解放され、処理が続く。しかし現実は違う:

  1. アプリケーションが利用可能なRAMをすべて消費する
  2. スワップが存在する場合、システムが大量のスワップを使い始める
  3. すべてがスワップイン・アウトするためディスクI/Oが急増する
  4. スワップ処理に追われてシステムが入力に応答できなくなる
  5. 最終的にOOMキラーが動作するが、その頃にはマシンはすでにフリーズしている

「メモリが危機的に低下している」から「OOMキラーがついに動作する」までの空白時間、そこでサーバーは死ぬ。SSDバックのVPSでは、スワップサイズとディスクスループットによって、この時間が30秒から2分以上に及ぶのを目撃してきた。その間、SSHは応答せず、cronジョブは止まり、Webトラフィックは横ばいになる。結局リブートせざるを得ない。

根本原因分析:OOMキラーのアキレス腱

設計上リアクティブ——それがカーネルOOMキラーを一言で表す言葉だ。メモリ圧迫が真の危機に達して初めてトリガーされる:具体的には、カーネルが新しいリクエストに対してメモリの割り当てに失敗した後だ。その時点ではシステムがスワップI/Oに埋没しているため、OOMキラーのコード自体をほとんど実行できない状態になっている。

ターゲティングも問題だ。OOMキラーはメモリ使用量、実行時間、その他の要素に基づくヒューリスティックで各プロセスにスコアを付ける。予測不可能なのだ。あるインスタンスでは、600 MBを消費する暴走cronジョブが生き残る中、PostgreSQLプロセスが終了させられた——私が望んでいたのとまったく逆の結果だ。

現在システムにかかっている負荷を確認しよう:

cat /proc/meminfo | grep -E "MemAvailable|SwapFree|MemTotal"
vmstat -s | head -20
dmesg | grep -i "oom\|kill"

dmesgでOOMキルがすでに発生しているか?カーネルは完全な危機モードで反応している。systemd-oomdが埋めるように作られているのはその空白だ。

解決策の比較:メモリ圧迫に対処する3つの方法

オプション1:カーネルOOMキラーを直接チューニングする

プロセスごとにoom_score_adjを調整して、最初に何が終了させられるかに影響を与えられる:

# プロセスが終了させられにくくする(-1000 = 免疫)
echo -500 > /proc/$(pidof your-critical-service)/oom_score_adj

# プロセスが終了させられやすくする(1000 = 最初のターゲット)
echo 1000 > /proc/$(pidof disposable-worker)/oom_score_adj

ターゲティングは改善される。しかし、タイミングは変わらない。OOMキラーは依然として遅すぎる——システムがすでに応答を保つのに苦労している後で動作する。

オプション2:earlyoom

earlyoomはユーザースペースのデーモンで、カーネルのOOMキラーが動作する前にメモリ使用量を監視してプロセスを終了させる。シンプルで効果的、そして長年実績がある:

sudo apt install earlyoom
sudo systemctl enable --now earlyoom

シングルマシン環境では効果的だ。知っておくべき2つのデメリット:サードパーティの依存関係であること、そしてsystemdのcgroup階層を理解しないこと。サービスユニット全体ではなく個別のプロセスを終了させる。アプリがワーカープロセスをフォークする場合、一つのワーカーを終了させてもメモリの流出は止まらない——残りは動き続けるのだ。

オプション3:systemd-oomd

6ヶ月の検討の末に落ち着いたのがこれだ。systemd-oomdはsystemdに同梱されており(バージョン247以降で利用可能)、cgroups v2とネイティブに統合し、個別プロセスではなくサービスユニット全体を終了させる。このサービスレベルのキルが重要な違いだ。サービスが誤動作したとき、欲しいのは完全な終了だ——4つのワーカーがメモリを食い続ける中で1つだけ終了させるのではなく。

また、メモリ圧迫をPSI(Pressure Stall Information)を使用して監視する。PSIはシステムがメモリ待ちでストールしている時間を計測する。PSIは生のメモリカウントよりも早い警告を提供する。oomdはシステムが実際に有用なことを実行できるほど応答している間に動作できる。

systemd-oomdの設定:本番環境向けセットアップ

前提条件の確認

systemd-oomdにはcgroups v2とPSIのサポートが必要だ。まず両方を確認しよう:

# cgroups v2がマウントされていることを確認
mount | grep cgroup2
# 期待される出力: cgroup2 on /sys/fs/cgroup type cgroup2 ...

# PSIが利用可能かを確認
cat /proc/pressure/memory
# このファイルが存在すれば、PSIが有効

cgroups v2はFedora 31+、Ubuntu 21.10+、Debian 11+ではデフォルトだ。古いシステムでは、/etc/default/grubのカーネルコマンドラインにsystemd.unified_cgroup_hierarchy=1を追加し、update-grubを実行してリブートする。

インストールと有効化

# Ubuntu 22.04+ / Debian 12+
sudo apt install systemd-oomd

# Fedora / RHEL 9+
sudo dnf install systemd-oomd

# 有効化して起動
sudo systemctl enable --now systemd-oomd

# 動作確認
systemctl status systemd-oomd

/etc/systemd/oomd.conf の設定

本番環境で実際に使用している設定だ。保守的なしきい値を設定している——システムがフリーズする間oomdがアイドルのままでいるより、少し早めに動作する方が良いからだ:

[OOM]
# スワップが80%使用された時(残り20%)に監視をトリガー
SwapUsedLimit=80%

# メモリ圧迫が30秒間60%を超えたらcgroupを終了
DefaultMemoryPressureLimit=60%

# 30秒のウィンドウで一時的なスパイクによる誤検知を防ぐ
DefaultMemoryPressureDurationSec=30s

編集後、リロード:

sudo systemctl daemon-reload
sudo systemctl restart systemd-oomd

サービスをOOM候補としてマーク

systemd-oomdは明示的にオプトインしたcgroupにのみ動作する。メモリ圧迫時に犠牲にしてもよいサービスごとに、systemdオーバーライドを作成する:

sudo systemctl edit your-background-service.service

オーバーライドファイルに以下を追加:

[Service]
# スワップが危機的に高い場合にoomdがこのサービスを終了できるようにする
ManagedOOMSwap=kill

# 持続的なメモリ圧迫時にoomdがこのサービスを終了できるようにする
ManagedOOMMemoryPressure=kill

# このサービスのグローバル圧迫しきい値をオーバーライド(オプション)
# 低い値 = より敏感;不要なバックグラウンドジョブに有用
ManagedOOMMemoryPressureLimit=40%

ユーザーセッションとインタラクティブなワークロードには、ユーザースライスをグループとして終了可能に設定する:

sudo systemctl edit user.slice
[Slice]
ManagedOOMSwap=kill
ManagedOOMMemoryPressure=kill

重要なサービスを保護する

oomdに触れてほしくないサービス——データベース、SSHデーモン、監視エージェント——には明示的にautoを設定する。これにより、終了の判断はすべてカーネルのOOMキラーに最後の手段として委ねられる:

sudo systemctl edit postgresql.service
[Service]
ManagedOOMSwap=auto
ManagedOOMMemoryPressure=auto

oomdが監視していることを確認する

# oomdが現在監視しているすべてのcgroupを表示
oomctl

# oomdのアクティビティをリアルタイムで追跡
journalctl -u systemd-oomd -f

# 現在のPSI値を直接確認
cat /proc/pressure/memory
# 出力例: some avg10=0.03 avg60=0.15 avg300=0.08 total=12345678
# avg10が30秒間60%を超えると上記の設定でキルがトリガーされる

oomctlはどのcgroupが候補でその現在の圧迫値を示す。設定したサービスが表示されない場合は2点を確認する:そのユニットでcgroups v2が有効かを確認し、systemctl cat your-serviceでオーバーライドが正しく適用されているかを確認する。

信頼する前にテストする

本番デプロイの前に2週間ステージングVPSで動かす——それが私のルールだ。本番環境のクローンに対してstress-ngを実行し、本番システムに触れる前に持続的なメモリ圧迫をシミュレートした。あなたも同じことができる:

# 負荷テストツールのインストール
sudo apt install stress-ng

# 60秒間の重いメモリ圧迫をシミュレート
stress-ng --vm 2 --vm-bytes 80% --timeout 60s

これを実行中に別のターミナルでjournalctl -u systemd-oomd -fを実行する。oomdが正しく設定されており対象サービスがオプトインされていれば、候補を特定して終了させるのが見える——そしてSSHセッションはその間ずっと生きたままだ。その最後の部分が重要だ。OOMイベントを吸収するシステムと、ただより派手に死ぬシステムの違いだ。

6ヶ月後:正直な評価

oomdをデプロイしてから、本番インスタンスで2回のOOMイベントが発生した。どちらの場合も、圧迫がしきい値を超えてから数秒以内にバックグラウンドジョブキューを終了させた。メモリは解放され、メインアプリケーションはトラフィックへの応答を続け、どちらのインシデントもリブートを必要としなかった。oomd導入前なら、どちらも深夜3時のハードリブート対応になっていただろう。

PSIベースの監視は早期に圧迫を検出する——通常、システムがまだクリーンに終了を実行できるだけの余裕がある間に。30秒のウィンドウは、持続的な圧迫が見逃されることなく蓄積されるのを防ぎながら一時的なスパイクをフィルタリングする。6ヶ月間、oomdは本当の問題ではないスパイクでトリガーされたことは一度もない。

ほとんどの作業は最初に行う:どのサービスが犠牲にできてどれを保護する必要があるかをマッピングする。oomdに関係なくやる価値がある——各ホストでサービスの優先順位について明示的に考えさせられるからだ。その後、oomdはバックグラウンドに消える。深夜3時のフリーズ対応を心配しなくなる。それが最適化する価値のある結果だ。

Share: