7月3日、Ubicloudが「PostgreSQL and the OOM Killer: Why We Use Strict Memory Overcommit」と題した記事を公開した。この記事では、PostgreSQLをOOM Killerから守るためにStrict Memory Overcommitを採用する理由と、運用中に遭遇した「8GBのサーバで651GBのコミット済みメモリ」というLinuxカーネルバグの調査・特定の経緯が詳しく紹介されている。
1件のOOM Killがサーバ上の全PostgreSQL接続を道連れにする——これがPostgreSQLの本質的な弱点だ。Linuxのvm.overcommit_memory=2(Strict Overcommit)を適用することで「破滅的な全停止」を「単一トランザクションのエラー」に格下げできる。しかしUbicloudがこれを有効化したところ、Linux 6.5カーネルの潜在的なバグが初めて実害として顕在化した。以下にその経緯を紹介する。
なぜPostgreSQLはOOM Killに耐えられないのか
Linuxはデフォルトで「Overcommit(過剰なメモリ予約)」を許可している。プロセスがmalloc()でメモリを要求しても、カーネルは仮想アドレス空間を予約するだけで、実際に物理メモリを割り当てるのはそのメモリに実際にアクセスされた時点だ。物理メモリが枯渇した場合、カーネルはOOM Killer(Out of Memory Killer)を起動し、ヒューリスティックに選んだプロセスを強制終了してメモリを確保する。
多くのプロセスにとってOOM Killは「再起動すれば済む」話だ。しかしPostgreSQLは違う。
PostgreSQLのpostmaster(監視プロセス)は、各接続に対してバックエンドプロセスをforkする。これらのバックエンドはshared buffers、WALバッファ、ロックテーブルなどを格納する共有メモリセグメントを共有している。OOM Killerはこのアーキテクチャを理解しない。単に「最もメモリを使っているプロセス」を終了させるだけだ。
そのバックエンドが共有メモリセグメントを書き換えている最中だった場合、セグメントが不整合な状態で残る。OSレベルで共有メモリにトランザクション保証はない。shared buffersの書きかけのページはサイレントなデータ破損を意味する。
postmasterはこのリスクを知っている。子プロセスの一つが強制終了されたことを検知すると、共有メモリが破損している可能性があると判断し、すべての接続を落としてクラッシュリカバリに入る。書き込み量が多ければWALのリプレイに長時間を要し、長時間の停止につながる。
Strict Overcommitで「遅い破滅」を「早い失敗」に変える
Linuxはvm.overcommit_memoryで3つのポリシーを提供している。
- Mode 0(Heuristic、デフォルト): 現実的に提供できないほど巨大な単一アロケーションは拒否するが、それ以外は自由にovercommitを許可する
- Mode 1(Always): いかなるアロケーション要求も拒否しない。物理メモリが足りなくなったらOOM Killerが動く
- Mode 2(Strict):
Committed_AS(全プロセスの仮想メモリ合計)を追跡し、CommitLimitを超えるアロケーションを即座にENOMEMで拒否する
CommitLimitは以下で計算される(overcommit_kbytesはコミット上限の絶対値指定、overcommit_ratioはメモリ比率指定のカーネルパラメータ)。
# overcommit_kbytes が設定されている場合
CommitLimit = overcommit_kbytes + swap
# overcommit_kbytes が未設定の場合
CommitLimit = overcommit_ratio / 100 * available_memory + swap
Strict Overcommit(Mode 2)下でアロケーションがENOMEMで失敗した場合、PostgreSQLはこれをグレースフルに処理する。そのバックエンドはクライアントにエラーを返してトランザクションをキャンセルするが、postmasterは生き続け、他の接続には影響しない。「破滅的な後処理」を「早期の失敗」に変換するのがStrict Overcommitの本質だ。
ただし、このアプローチはマシンがPostgreSQLと限られたサイドカープロセス専用の場合に最も有効だ。多様なワークロードが混在する共有マシンでは、無関係なプロセスがcommit budgetを使い切り、DB負荷が正常でもPostgreSQLがENOMEMを受け取る可能性がある。
8GBマシンに651GBのコミット済みメモリ——カーネルバグとの遭遇
UbicloudはPostgreSQLにStrict Overcommitを有効化したが、数週間後に問題が発生した。物理メモリには余裕があるにもかかわらず、一部のDBでOOMエラーが出始めた。設定を無効化して調査を開始すると、8GBのサーバでCommitted_ASが651GBを示していることを発見した。
$ cat /proc/meminfo | grep "Committed_AS"
Committed_AS: 683547672 kB
同じ構成の正常なサーバでは約2.7GBだった。数値が2〜3桁狂っている。
VMAフラグで仮説を潰す
PostgreSQLはhuge_pages = onで動作しており、shared_buffers(2GB)はhugetlbから確保される。各バックエンドのVSZが約2GBを示しているのは、この共有メモリ領域が各プロセスのアドレス空間にマップされているためだ。「hugetlbのアカウンティングが誤ってCommitted_ASに加算されているのでは」という仮説を立て、/proc/<pid>/smapsでVMAフラグを確認した。
VmFlags: rd wr sh mr mw me ms de ht sd
acフラグ(VM_ACCOUNT、committed memoryへの計上を示す)がない。hugetlbは正しく除外されていた。全プロセスの計上可能メモリを合計すると2.43GBで、報告値の651GBとは648GBの乖離がある。カーネルのカウンタが漏れていると確信した。
フリート全体で統計分析
当時、フリートには2種類のカーネルが混在していた。全PostgreSQLサーバでCommitted_AS / MemTotalの比率をカーネルバージョン別に集計したところ、Linux 6.5の異常が統計的に鮮明に現れた。
| 指標 | Linux 6.5 | Linux 6.8 |
|---|---|---|
| 中央値 | 0.55 | 0.27 |
| 平均値 | 24.97 | 0.32 |
| 最大値 | 3,405 | 1.86 |
| 比率 > 1.0のサーバ | 23% | 1%未満 |
6.5カーネルのサーバは52倍の確率でCommitted_ASが膨張していた。また6.5では稼働時間と膨張が正の相関を持ち、週あたり約4.7%の複利で増大していた。
!1文字が生んだバグ
Linux 6.5.0〜6.8.0間の変更を調査したところ、原因が特定された。
バグはLinux 6.5のコミット408579cで導入された。do_vmi_align_munmap()の戻り値の規約変更(成功時に「1」を返していたものを「0」に統一)に伴い、mm/mremap.c内のmove_vma()でエラーチェックの条件が誤って変換された。
/* 修正前(正しい): 負の値(エラー)の場合に実行 */
if (do_vmi_munmap(...) < 0) {
vm_acct_memory(old_len >> PAGE_SHIFT); // カウンタを戻す
}
/* 修正後(壊れている): 0(成功)の場合に実行してしまう */
if (!do_vmi_munmap(...)) {
vm_acct_memory(old_len >> PAGE_SHIFT); // 成功のたびに加算してしまう
}
< 0を!に変えただけで条件が反転した。move_vma()はメモリ移動の際に古い領域のCommitted_ASを一旦デクリメントし、unmapが失敗した場合のみ戻す設計だった。条件が逆になったことで、mremap成功のたびにカウンタが加算され続けた。
Linuxのvm.overcommit_memoryデフォルト(Mode 0)ではCommitted_ASは参考情報に過ぎず、このバグはStrict Overcommitを有効化した環境でのみ実害として現れる。PostgreSQLのような「Strict Overcommitを積極的に使うソフトウェア」が増えていなければ、長期間発見されなかった可能性が高い。このバグは1行の修正でLinux 6.8にてすでに修正済みだ。
CommitLimitの設定指針:なぜ「物理メモリの80% + 2GB」か
Ubicloudが採用しているヒューリスティックは以下だ。
- **物理メモリの80%**:カーネル自身のメモリ使用、ファイルシステムキャッシュ、バッファなど、常駐するが計上されないメモリを考慮したマージン
- +2GB:PostgreSQLのshared_buffers(hugetlbで確保されCommitted_ASに計上されない)以外のPostgreSQLコンポーネントや、監視エージェントなどのサイドカープロセス向けの余裕
これにより、CommitLimitはswapなしで以下のように計算される。
CommitLimit = 物理メモリ × 0.80 + 2GB
Strict Overcommitは、PostgreSQLにおけるOOM Killの被害を「全接続の道連れ停止」から「単一トランザクションのENOMEM失敗」に抑える有効な手段だ。ただしその効果は、カーネルのCommitted_AS計算が正確であることを前提とする。Linux 6.5のバグはその前提を崩すものだったが、Linux 6.8以降ではすでに修正済みだ。本番環境でStrictモードを採用する場合は、/proc/meminfoのCommitted_AS値を定期的に監視することも記事では推奨されている。
詳細はPostgreSQL and the OOM Killer: Why We Use Strict Memory Overcommitを参照していただきたい。