OOM Killerは誰を選ぶのか
一言でいうと
OOM KillerのスコアはtopのVIRTではなくRSS基準であり、oom_score_adjは総メモリの1000分の1を単位とする加減点です。そして終了コード137は「SIGKILLで死んだ」という意味にすぎず、OOMを意味するわけではありません。
なぜ必要なのか
「サービスが落ちたのに、アプリケーションログにエラーがありません。最後の行は正常なリクエスト処理です。」
この報告では、ログがきれいであること自体が手がかりです。SIGKILLは捕捉できず、ハンドラーも動かないため、アプリケーションには何も残す機会がありません。痕跡を残すのはアプリケーションではなく、カーネルです。
ここでよくある誤解が4つあります。
- 137 = OOMではありません。128+9、つまりSIGKILLで死んだという意味にすぎません。liveness probe失敗後の猶予切れも、手動の終了も、ノードの終了も、すべて137です。
- 犯人はVIRTではありません。64GBをmmapで予約するだけで、実際には200MBしか使わないプロセスは、候補にすらなりません。
- スワップを入れればOOMは起きないというのは、半分だけ正しいです。防ぐのではなく遅らせるだけで、その間のスラッシングが即死より悪くなることがあります。
- コンテナがOOMKilledなのに、limitを超えていないことがあります。ノード全体のOOMがコンテナ内のプロセスを殺しても、同じ表示になります。この場合、limitを上げても再発します。
どう動くのか
スコアの計算はおおよそ次のとおりです。
points = rss + swapents + (pgtables / PAGE_SIZE) # 단위: 페이지
adj = oom_score_adj * (totalpages / 1000)
badness = points + adj
つまりoom_score_adjの200は、64GiBのマシンで約12.8GiB相当の加点です。RSS 2GBのプロセスが、この加点で11GBのJVMを上回ることもあります。逆に、-1000は特別です。スコア計算から完全に除外され、絶対に選ばれません。ディストリビューションがsshdにこの値を与える理由です。
いつ発動するのか。「メモリが0になったとき」ではなく、「回収(reclaim)を試みたのに進展がない」ときです。そのため、スワップがあると回収が進展を生んでOOMが先送りされ、その間にスラッシングが発生します。
グローバルOOMとcgroup OOMは別の出来事です。dmesgレポートの最後のoom-kill:行にあるconstraintが、これを分けます。CONSTRAINT_NONEはグローバル、CONSTRAINT_MEMCGはcgroupです。cgroup OOMのときだけ、memory: usage/limit/failcntの行が一緒に出ます。この1行が対応を完全に分けます。グローバルならノード全体の配置を見る必要があり、cgroupならそのコンテナの制限と実使用量の問題です。
コンテナの本当の制限はfreeではありません。コンテナの中でfree -mはホスト全体を表示します。実際の上限は/sys/fs/cgroup/memory.maxで、現在の使用量はmemory.currentです。そしてmemory.eventsの3つのカウンターが状態を教えてくれます。maxは上限にぶつかって回収した回数、oomは回収に失敗してOOMの経路に入った回数、oom_killは実際に殺した回数です。maxだけが大きくoom_killが0なら、まだ持ちこたえている最中という意味なので、事後の検死よりこれを捕まえるほうがよいです。
現場での姿
MemFreeとMemAvailableは違います。Linuxは余ったメモリをページキャッシュとして使います。MemFreeが小さいのは正常で、判断基準は、回収可能なキャッシュまで計算に入れたMemAvailableです。freeコマンドでも、free列ではなくavailable列を見ます。
RSSを合計すると、実際より大きくなります。複数のプロセスが共有するライブラリのページを、RSSはそれぞれすべて数えます。ワーカーが20個あるWebサーバーで単純に合計すると、実際の数倍になります。容量の見積もりには、共有ページを参照しているプロセス数で割ったPSS(/proc/PID/smaps_rollup)を使います。
Kubernetesのrequestは、スケジューラーをだます数字ではなく、生き残りの順位です。Burstable Podのoom_score_adjはrequestの比率で計算されるため、requestを小さく書いて多く使うPodが、ノードが逼迫したときに真っ先に殺されます。
死んだ理由をあとで調べる方法
OOMキラーは静かではありません。ただし、調べる場所を知っていれば見えてきます。
dmesg -T | grep -iE 'killed process|out of memory|oom-kill'
journalctl -k --since "1 hour ago" | grep -i oom
カーネルメッセージには、死んだプロセスだけでなく、そのときの候補リストも一緒に残ります。total-vm、anon-rss、oom_score_adjの列を見れば、誰がどれだけ使っていたかがそのままわかります。
コンテナでは、cgroupが先に殺します。ホスト全体には余裕があるのに、そのPodだけが死ぬ場合がここに当たります。終了コード137(128+9)とともに現れます。
cat /sys/fs/cgroup/<경로>/memory.events # oom_kill 이 0이 아니면 이미 죽었다
kubectl get pod <파드> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
OOMKilledなのに、コンテナが死んでいないように見えるときがあります。Pod内の子プロセスだけが死ぬと、PID 1は生きているため再起動されません。そのときサービスは「生きているのに何も処理しない」状態になります。これを捕まえるのがreadinessプローブです。
キャッシュは使用中のメモリではありません。free -hのavailableを見て判断します。usedだけを見ると、キャッシュが増えただけの正常な状態を、不足だと誤解します。逆にavailableが小さいのにbuff/cacheが大きいなら、そのキャッシュは回収できないもの(tmpfs、mlock、dirty page)かもしれません。
問題がメモリではなく予約の場合もあります。オーバーコミットの設定によっては、mallocが成功し、あとで実際に使うときに死にます。そのため「割り当てはできたのに、あとで死ぬ」現象が起こります。
cat /proc/meminfo | grep -E 'MemAvailable|Committed_AS|CommitLimit'
sysctl vm.overcommit_memory vm.overcommit_ratio
優先度を調整できます。絶対に死んではいけないプロセスはoom_score_adjを下げ、先に死んでもよいものは上げます。ただし、根本は制限を正確に設定することであり、スコアの調整はその間の一時しのぎです。
次のラボですること
/proc/meminfoからMemTotalとMemAvailableを直接読み取り、両者の差を確認します。メモリを実際に握るプロセスを起動して、VmSizeとVmRSSがどう違うかを見て、oom_score_adjを上げてスコアが実際に変わる様子を観察します。最後に、RSS上位のプロセスを取り出すツールと、しきい値超過を監視するスクリプトを作ります。