VPSの常駐プロセスがOOM Killerに殺されたので、cgroups v2で檻に入れた
深夜2時、n8nのワークフローが全部止まった。
VPSにSSHしようとしたら応答がない。コンソールから入ってdmesgを叩いた瞬間、嫌な文字列が並んでいた。
Out of memory: Killed process 1847 (myapp) total-vm:2048000kB
ext4_journal_aborted
EXT4-fs error: remounting filesystem read-only
常駐プロセスが殺されていた。しかもファイルシステムがread-onlyになっている。load averageは48。4コアのVPSで48。もう何も受け付けない状態だった。
何が起きたのか
構成はこうだ。VPS(4コア/6GB)で、Wine経由の常駐アプリを2つ動かしている。24時間止められないやつ。
同じVPSで、重めのPythonスクリプトを走らせた。データ処理系の重い計算。これがメモリを3GB持っていった。
常駐アプリ×2で約2GB、Docker上のn8nやDifyで約1.5GB。合計6.5GB。物理メモリ6GBのマシンで。
Swapが溢れ、OOM Killerが目を覚まし、一番メモリを食っていた常駐プロセスを仕留めた。プロセスが死ぬ→ログ書き込みが中途半端に止まる→ext4のジャーナルが壊れる→ファイルシステムがread-only。連鎖的に全部死んだ。
正直、やらかしたと思った。止められないプロセスを、メモリ制限もかけずに野放しにしていた自分が悪い。
復旧
VPSコンソールからシングルユーザーモードで入り、fsckをかけた。
fsck -y /dev/vda1
いくつかorphan inodeが出たが、致命的な破損はなかった。再起動して常駐プロセスを立ち上げ直す。幸い、中途半端な状態で止まっていたデータはなかった。あったら冷や汗では済まない。
復旧自体は30分で終わった。問題は「次にまた同じことが起きたらどうする」だ。
二度と殺させない設計
やることは3つ。OOM Killerの優先度を下げる、systemdで永続化する、cgroups v2でメモリの檻を作る。
1. oom_score_adj で守るプロセスを指定する
Linuxの OOM Killer は、各プロセスにoom_scoreというスコアをつけている。メモリを多く使っているプロセスほどスコアが高い。OOM発動時、スコアが一番高いやつから殺される。
oom_score_adjに負の値を入れると、そのプロセスのスコアが下がる。つまり「こいつは後回しにしろ」と指示できる。
# 守りたいプロセスのPIDを取得してoom_score_adj設定
for pid in $(pgrep -f "myapp"); do
echo -500 > /proc/$pid/oom_score_adj
done
-500は「かなり殺されにくい」レベル。-1000にすると完全に対象外になるが、それはやりすぎだ。どうしようもないときは殺してもらわないと、カーネルパニックで全滅する。
設定を確認する。
for pid in $(pgrep -f "myapp"); do
echo "PID $pid: $(cat /proc/$pid/oom_score_adj)"
done
ただし、この方法には弱点がある。プロセスが再起動すると設定が消える。VPS再起動のたびに手で設定するのは現実的じゃない。
2. systemd drop-in で永続化
systemdサービスに、drop-inファイルでOOMScoreAdjustを追加する。
mkdir -p /etc/systemd/system/myapp.service.d/
# /etc/systemd/system/myapp.service.d/oom-protect.conf
[Service]
OOMScoreAdjust=-500
systemctl daemon-reload
systemctl restart myapp
これでプロセスが再起動しても、自動的にoom_score_adj=-500が適用される。drop-inファイルなので、サービス本体の.serviceファイルを直接いじらなくていい。アップデートで上書きされる心配がない。
3. cgroups v2 memory slice でメモリに上限を設ける
ここが本丸。OOM Killerの優先度を下げるだけでは、根本的に解決していない。暴走プロセスがメモリを食い尽くせば、結局全体が巻き添えになる。
やるべきは「守りたいプロセスをsliceに入れてメモリ上限を設ける」こと。檻の中で動かせば、暴走しても他に影響しない。逆に、他が暴走してもslice内のメモリは守られる。
systemdのsliceを使う。
# /etc/systemd/system/myapp-memory.slice
[Unit]
Description=MyApp Memory Limit Slice
[Slice]
MemoryMax=3G
MemoryHigh=2560M
MemoryMaxはハードリミット。3GBを超えたらOOM Killerが介入する。MemoryHighはソフトリミット。2.5GBを超えるとカーネルがメモリ回収を積極的にかけ始める。この2段構えが地味に効く。
サービスファイルで、このsliceに所属させる。
# myapp.service の [Service] セクションに追加
[Service]
Slice=myapp-memory.slice
systemctl daemon-reload
systemctl restart myapp
これで、常駐プロセスは合計3GBの檻の中で動く。Pythonスクリプトが暴走しても、常駐アプリのメモリ領域には手を出せない。
現在の使用量はsystemctl status myapp-memory.sliceで見える。
systemctl status myapp-memory.slice
# Memory: 1.8G (max: 3.0G available: 1.2G)
現在の監視体制
cgroups v2で檻を作った後も、Swapの使用量は注視している。
free -h
# total used free shared buff/cache available
# Mem: 5.8Gi 4.2Gi 312Mi 48Mi 1.3Gi 1.3Gi
# Swap: 2.0Gi 1.4Gi 624Mi
Swap 1.4GB/2.0GB。正直まだ高い。常駐アプリ + Docker群を6GBに詰め込んでいる以上、ある程度のSwap使用は避けられない。ただ、MemoryHighのおかげで急激なスパイクは抑えられている。
n8nのワークフローで30分おきにSwap使用率をチェックして、80%を超えたらChatworkに通知を飛ばすようにした。次の一手はVPSのメモリ増設か、重い計算処理の別環境分離だろう。
まとめじゃないけど
止められないプロセスを守れないサーバーは、サーバーじゃない。
oom_score_adjは5分で設定できる。cgroups v2のsliceも、systemdの設定ファイルを2つ書くだけだ。30分もあれば終わる。その30分をサボったせいで、深夜2時にfsckを叩く羽目になった。
VPSでWine経由のプロセスを動かしている人がどれだけいるかわからないが、Wine経由のプロセスはoom_scoreが高くなりがちだ。メモリの檻、作っておいて損はない。