1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Linux OOM Killerの真実:oom_score_adj の罠、PSI駆動サーキットブレイカーの設計、そして監視自体が窒息するカーネルの極限状態

1
Posted at

はじめに

Linuxシステムを運用する上で、誰もが一度は遭遇する「OOM Killer」。

モダンなシステム、特にcgroup v2によるリソース隔離環境において、アプリケーションが取るべき真の生存戦略は、OSに殺されるのを待つことではなくLinuxカーネルから提供される PSI(Pressure Stall Information) を epoll 駆動でリアルタイムに捕捉し、アプリケーション自身がトラフィックを遮断・自己治癒へと向かう「UPS(無停電電源装置)型」の自律防衛システムが必要。

本稿では、2コア/2GBの検証環境を用い、Linuxカーネル(mm/oom_kill.c)の oom_badness() ロジックの検証からスタートし、cgroup v2環境における「ホストOOM」と「MemCG OOM」の二重評価構造を明らかにした上で、C++によるPSI駆動のサーキットブレイカーの実装と検証を行う。


検証環境

5.14.0-701.el9.x86_64


1.ホストレベル oom_badness の動的評価と oom_score_adj の限界

OOM Killerの基礎評価ロジック

Linuxカーネル(mm/oom_kill.c 内の oom_badness())におけるスコア計算の基本原則は以下の通り。

  1. メモリ消費割合(ベース):
    システム全体のメモリ(またはcgroupの制限値)に対するプロセスの実質的なメモリ使用量(RSS + Swap)の割合から、0〜1000の範囲でベーススコアが算出される。
  2. rootプロセスの優遇:
    root権限(CAP_SYS_ADMIN)を持つプロセスは、一律でスコアが減算され、保護される。
  3. oom_score_adj による静的調整:
    /proc/[pid]/oom_score_adj(-1000 〜 1000)の値で加算・減算される。

検証コード

import sys
import time

# 引数: 確保サイズ(MB), 待機時間(秒)
size_mb = int(sys.argv[1])
hold_sec = int(sys.argv[2])

print(f"Allocating {size_mb}MB...")
# 実際に書き込んで物理ページを割り当てる(Touch)
data = b'A' * (size_mb * 1024 * 1024)

print("Allocation done. Holding memory...")
time.sleep(hold_sec)
print("Exiting.")

検証1: 同一条件におけるスコアの一致とroot優遇

2GBメモリ環境下で、1000MBのメモリを確保するプロセス(mem_eater.py)を複数起動し、ベーススコアの挙動を確認します。

# 一般ユーザーでの実行(プロセス1)
$ python3 mem_eater.py 1000 300 &
[1] 1830
$ cat /proc/1830/oom_score
834

# 一般ユーザーでの実行(プロセス2)
$ python3 mem_eater.py 1000 300 &
[2] 1832
$ cat /proc/1832/oom_score
834

同一条件であれば、別プロセスであっても同じスコア(834)が割り当てられる。

# rootユーザー(sudo)での実行
$ sudo python3 mem_eater.py 1000 300 &
[2] 1861
$ cat /proc/1861/oom_score
668

root実行時はスコアが大幅に低下し、カーネルレベルで保護されていることが確認できる。

検証2: メモリ消費量とスコアの線形関係

メモリ消費量を変動させた場合のスコア推移は以下。

# 500MB確保
$ python3 mem_eater.py 500 300 &
$ cat /proc/1834/oom_score
750

# 50MB確保
$ python3 mem_eater.py 50 300 &
$ cat /proc/1836/oom_score
676

メモリ消費量が小さくなるにつれてスコアは減少しますが、50MB(システム全体の約2.5%)しか消費していなくてもベーススコアが「676」と高くなる。これは、システム全体の空きメモリやキャッシュバッファの状況に依存するため、消費量に対して非線形に見えるケースがある。


oom_score_adj 調整による「逆転現象」

次に、/proc/[pid]/oom_score_adj を手動で書き換え、カーネルの判定を歪ませた場合の挙動を検証する。

設定パラメータ

3つのプロセス(1000MB, 500MB, 50MB確保)に対し、以下のように oom_score_adj を注入する。

PID 確保メモリ量 設定した oom_score_adj 最終 oom_score
1896 1000 MB 0 (デフォルト) 834
1897 500 MB -500 (保護) 418
1915 50 MB 600 (犠牲) 1076

OOM発火時の挙動

この状態でさらに1500MBのメモリ負荷(PID 1945)をかけ、システムを強制的にOOM状態へ追い込む。

$ python3 mem_eater.py 1500 300 &
[5] 1945
Allocating 1500MB...

# [結果] 50MBのプロセスがKillされる
[3]   強制終了            python3 mem_eater.py 50 300

カーネルログ(dmesg)

kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0-1,global_oom,task_memcg=/user.slice/user-1000.slice/session-2.scope,task=python3,pid=1915,uid=1000
kernel: Out of memory: Killed process 1915 (python3) total-vm:278960kB, anon-rss:112kB, file-rss:128kB, shmem-rss:0kB, UID:1000 pgtables:172kB oom_score_adj:600

考察:カーネルは「現在のサイズ」や「生存期間」を見ない

もっともメモリを大食いしているのは1000MBのプロセス(PID 1896)だが、カーネルに殺されたのはわずか50MBのプロセス(PID 1915)。
カーネルの select_bad_process() ループは、「その瞬間の oom_score が1点でも高いもの」を絶対的な基準として処理するため、oom_score_adj によるバイアスがメモリ消費実態を完全に上書きしたことを示している。


oom_score_adj の限界

最も深刻なリスクは、メモリ大食いプロセスに対して中途半端な保護(-999 など)を与え、システム全体のメモリが完全に枯渇した場合の挙動。

検証3: 極端な保護(-999)とメモリリークの継続

2000MB(=物理メモリ限界)を確保しようとするプロセス(PID 1917)に対し、-999 を設定する。

$ python3 mem_eater.py 2000 300 &
[3] 1917
$ cat /proc/1917/oom_score
1000

# 強烈な保護を設定
$ echo -999 > /proc/1917/oom_score_adj
$ cat /proc/1917/oom_score
335

この状態で当該プロセスがさらにメモリを要求し、システムメモリが完全に逼迫すると、カーネルログには以下のような連鎖が記録される。

カーネルログ(dmesg)

localhost kernel: Out of memory: Killed process 1267 (wireplumber) ... oom_score_adj:200
localhost kernel: Out of memory: Killed process 1266 (pipewire) ... oom_score_adj:200
localhost kernel: Out of memory: Killed process 1268 (pipewire-pulse) ... oom_score_adj:200
localhost kernel: Out of memory: Killed process 1201 (dbus-broker-lau) ... oom_score_adj:200
localhost kernel: Out of memory: Killed process 1651 ((sd-pam)) ... oom_score_adj:100

なぜ無関係なプロセスが死に続けるのか?

  1. 原因である巨大プロセス(PID 1917)は、-999 の補正により oom_score が 335 まで抑え込まれている。
  2. 他のデスクトップサービスやシステムデーモン(wireplumber, pipewire, dbus-broker 等)は、systemdのデフォルト設定などにより oom_score_adj200100 に設定されているため、元々の消費メモリが極小であっても、最終的な oom_score が 335 を超える。
  3. 結果として、「真の元凶(PID 1917)の手前にある、スコアが高くなってしまった無関係なシステムプロセス」から順番にkillされていく。
  4. しかも、これらの軽量プロセスをいくら Kill しても、解放されるメモリは数百KB〜数MB程度であるため、OOM状態が全く解消されず、システムがハングアップするか主要デーモンが全滅するまで OOM Killer がループし続ける。

終着点としての -1000 (OOM_SCORE_ADJ_MIN)

では、値を限界値である -1000 に設定するとどうなるか。

$ cat /proc/2015/oom_score
0

スコアが 0 になったプロセスは、OOM Killerの評価対象アルゴリズムから完全に除外 される。どれだけメモリを逼迫させても、このプロセス自体がカーネルに殺されることは絶対にない。


2. 物理(ホスト)vs 隔離(MemCG)の二重評価構造

検証環境のセットアップと事前準備

cgroup v2 のメモリコントローラ有効化と制限空間の作成

ホストへの影響を遮断するため、スワップを無効化した200MBの制限空間 oom_test を作成する。

# メモリコントローラの有効化
echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# テスト用cgroupの作成
sudo mkdir -p /sys/fs/cgroup/oom_test

# 制限値の設定(メモリ上限: 200MB, スワップ: なし)
echo "200M" | sudo tee /sys/fs/cgroup/oom_test/memory.max
echo "0" | sudo tee /sys/fs/cgroup/oom_test/memory.swap.max

検証コードは最初と同じ


ケース1:MemCG内での独立したメモリ枯渇(密室のOOM)

まず、制限空間(200MB)の内部だけでメモリを逼迫させ、cgroupローカルなOOMキラーを発生させる。

プロセスの起動と oom_score_adj の操作

2つのプロセスを投入し、片方の oom_score_adj を引き上げておく。

# プロセスA(100MB消費ターゲット)の起動とアタッチ
python3 mem_eater.py 100 300 &
PID_A=$!
echo $PID_A | sudo tee /sys/fs/cgroup/oom_test/cgroup.procs # PID: 2059

# プロセスB(50MB消費ターゲット)の起動とアタッチ
python3 mem_eater.py 50 300 &
PID_B=$!
echo $PID_B | sudo tee /sys/fs/cgroup/oom_test/cgroup.procs # PID: 2065

# プロセスBのoom_score_adjを意図的に引き上げる
echo 600 > /proc/2065/oom_score_adj

カーネルによる oom_score の算出結果

この状態での両プロセスの oom_score を確認する。

cat /proc/2059/oom_score
684

cat /proc/2065/oom_score
1076

プロセスB(PID: 2065)は実メモリ消費量(RSS)こそ少ないものの、oom_score_adj の加算によってスコアが逆転し、最優先となっていることがわかる。

トリガープロセスの投入とMemCG OOMの発生

ここに、さらに150MBを要求するプロセスCを同cgroup内に投入し、200MBの上限を突破させる。

python3 mem_eater.py 150 300 &
PID_C=$!
echo $PID_C | sudo tee /sys/fs/cgroup/oom_test/cgroup.procs # PID: 2081

【結果:プロセスB(PID: 2065)の強制終了】

[2]   強制終了            python3 mem_eater.py 50 300

カーネルログの解析

localhost kernel: oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=/,mems_allowed=0-1,oom_memcg=/oom_test,task_memcg=/oom_test,task=python3,pid=2065,uid=1000
localhost kernel: Memory cgroup out of memory: Killed process 2065 (python3) total-vm:278960kB, anon-rss:53740kB, file-rss:5248kB, shmem-rss:0kB, UID:1000 pgtables:168kB oom_score_adj:600

  • constraint=CONSTRAINT_MEMCG および oom_memcg=/oom_test:
    カーネルはホスト全体のメモリ枯渇ではなく、対象のcgroup(oom_test)の制約によってOOMを選択している。
  • この「密室」の評価構造においても、oom_score_adj(600)は正常に機能し、消費メモリの少ないプロセスBが狙い撃ちでkillされる。

ケース2:ホスト全体のメモリ枯渇によるcgroup内への浸食(Global OOM)

次に、cgroup内のメモリは上限(200MB)に達していないものの、ホスト全体の物理メモリが枯渇した場合の挙動を検証する。

状態の初期化

ケース1と同様に、cgroup内に2つのプロセスを配置し、片方のスコアを吊り上げる。

# プロセスA(100MB)
python3 mem_eater.py 100 300 &
echo $! | sudo tee /sys/fs/cgroup/oom_test/cgroup.procs # PID: 2110

# プロセスB(50MB)
python3 mem_eater.py 50 300 &
echo $! | sudo tee /sys/fs/cgroup/oom_test/cgroup.procs # PID: 2116

# プロセスBの調整
echo 600 > /proc/2116/oom_score_adj

  • oom_score 状況:PID 2110(684) / PID 2116(1076

ホスト側(cgroup外)での大規模負荷の実行

今回は、cgroup(oom_test)の枠外で、ホストの物理メモリを食いつぶす巨大プロセス(2500MB要求)を起動する。

python3 mem_eater.py 2500 300 &
# Allocating 2500MB...

【結果:cgroup内のプロセスB(PID: 2116)が強制終了】

[2]   強制終了            python3 mem_eater.py 50 300

カーネルログの解析

localhost kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0-1,global_oom,task_memcg=/oom_test,task=python3,pid=2116,uid=1000
localhost kernel: Out of memory: Killed process 2116 (python3) total-vm:278960kB, anon-rss:236kB, file-rss:128kB, shmem-rss:0kB, UID:1000 pgtables:176kB oom_score_adj:600

  • constraint=CONSTRAINT_NONE および global_oom:
    カーネルはcgroupの境界を意識せず、システム全体(Global)のメモリ枯渇としてOOMを発生させている。
  • ターゲットの選定:
    ホスト全体での評価の結果、oom_score_adj が大きく跳ね上がっていたcgroup内部のプロセスB(PID: 2116)が評価ロジック(select_bad_process)によって最高スコアを記録し、身代わりとしてkillされる。

考察:物理(ホスト)vs 隔離(MemCG)の二重評価構造

この2つの実験結果から、Linuxカーネルにおけるメモリ管理のシビアな設計思想が見える。

異なるコンテキスト、同一のスコア評価

CONSTRAINT_MEMCG(ケース1)であれ、CONSTRAINT_NONE / global_oom(ケース2)であれ、カーネルがプロセスを選択する際の oom_score 計算(oom_badness())のロジックは共通。そのため、oom_score_adj による重み付けはどちらのレイヤーから見ても有効に作用する。

「防壁」であって「シェルター」ではない

cgroupの memory.max は、内部のプロセスが外部へ与える影響を制限する「防壁」に過ぎない。ホスト全体の物理メモリが枯渇した際、カーネルは「システム全体の安定稼働」を最優先するため、cgroupの境界を透過してシステム内で最も「悪い(と評価された)」プロセスを殺しにいく。

コンテナ(cgroup)内にプロセスを閉じ込め、メモリを制限していても、ホスト側のリソース管理が甘ければ、内部のプロセスは常に外部の巻き添えを喰らうリスク(あるいは oom_score_adj の設定次第で身代わりにされるリスク)に晒されていると言える。


まとめ

  • ケース1(MemCG OOM): 内圧の上昇によるバースト。cgroupの壁の内部だけで完結する(CONSTRAINT_MEMCG)。
  • ケース2(Global OOM): 外圧の崩壊による浸食。cgroupの壁を透過してホスト全体からプロセスが選定される(CONSTRAINT_NONE)。
  • 設計上の教訓: マルチテナント環境やコンテナホストにおいて、特定のコンテナを外部のOOMから保護したい場合は、そのコンテナの memory.max を絞るだけでなく、ホスト全体のメモリプロビジョニング、および他プロセスの oom_score_adj の適切な設計(あるいは重要プロセスの保護設定)が不可欠。

3.PSI(some/full)の epoll 駆動リアルタイム・シグナリング

検証環境と初期ステータス

検証は、すでに過去28回のOOMルーチン突入を経験している高負荷状態のcgroup(/oom_test)からスタートしている。

スタート時の memory.events:

low 0
high 1
max 489
oom 28
oom_kill 1
oom_group_kill 0

検証コード

// ==============================================================================
// 【検証用】メモリをミリ秒単位でじわじわ消費し、cgroupを追い詰めるロジック
// ==============================================================================

// 1. 最初にバーチャルアドレス空間だけを確保(この時点ではcgroupの物理メモリ制限にはカウントされない)
uint8_t* addr = static_cast<uint8_t*>(mmap(NULL, total_bytes, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
if (addr == MAP_FAILED) { perror("mmap"); return 1; }

// 2. ページごとに時間差でTouchし、意図的にPage Faultを発生させる
for (size_t i = 0; i < total_pages; ++i) {
    // 4KBごとの先頭バイトに書き込み、物理メモリ(RSS)を割り当て
    addr[i * page_size] = 0xFF;

    // ★重要:ここで微小なウェイトを入れることで、カーネルの回収(Direct Reclaim)や
    // PSIが「some」から「full」へ遷移するタイムラグ・戦闘状態を意図的に作り出す
    if (interval_us > 0) {
        std::this_thread::sleep_for(std::chrono::microseconds(interval_us));
    }
}
// ==============================================================================
// 【監視側】PSI(memory.pressure)と OOM(memory.events)の epoll 駆動リアルタイム監視
// ==============================================================================

// --- 1. PSI(Pressure Stall Information)のトリガー設定 ---
int psi_fd = open("/sys/fs/cgroup/oom_test/memory.pressure", O_RDWR | O_NONBLOCK);

// 「500ms(500000us)の間で、プロセスが合計1ms(1000us)以上メモリ待ちで足止め(some)されたら通知」
std::string trigger = "some 1000 500000";
write(psi_fd, trigger.c_str(), trigger.size()); // リアルタイム通知の閾値をカーネルに登録

// --- 2. memory.events のオープン ---
int oom_fd = open("/sys/fs/cgroup/oom_test/memory.events", O_RDONLY | O_NONBLOCK);

// --- 3. epoll へのイベント登録(cgroupの通知は EPOLLPRI / EPOLLERR を使用する) ---
int epoll_fd = epoll_create1(0);

struct epoll_event ev_psi {.events = EPOLLPRI, .data.fd = psi_fd};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, psi_fd, &ev_psi);

struct epoll_event ev_oom {.events = EPOLLPRI | EPOLLERR, .data.fd = oom_fd};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, oom_fd, &ev_oom);

// --- 4. イベント駆動メインループ ---
while (true) {
    struct epoll_event events[2];
    int nfds = epoll_wait(epoll_fd, events, 2, -1);

    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == psi_fd) {
            log_with_time("[ALERT] Memory Pressure Triggered by Kernel!");

            // ★重要:再通知のために、一度中身を読み出してイベントをクリア(リセット)する
            char dummy[128];
            lseek(psi_fd, 0, SEEK_SET);
            read(psi_fd, dummy, sizeof(dummy));

        } else if (events[i].data.fd == oom_fd) {
            log_with_time("[CRITICAL] OOM Killer Event Detected!");

            // memory.events の中身をダンプ(oom_kill カウンタ等の確認)
            char buffer[1024];
            lseek(oom_fd, 0, SEEK_SET);
            ssize_t bytes = read(oom_fd, buffer, sizeof(buffer) - 1);
            if (bytes > 0) {
                buffer[bytes] = '\0';
                std::cout << "--- memory.events status ---\n" << buffer;
            }
        }
    }
}

タイムライン解析:カーネルの「あがき」とOOM Killerの執行

メモリ確保(200MBを0.5秒に1ページ確保する緩やかなペース)を継続したところ、特定の時間帯において300回ものPSI(Memory Pressure)が発生。

① Direct Reclaimによる強制停止と回避(27回)

以下のログが約27回出力された。

[15:09:57.865] [ALERT] Memory Pressure Triggered by Kernel!
...
[15:16:40.420] [ALERT] Memory Pressure Triggered by Kernel!

この時、カーネルはプロセスを D状態(不可中断スリープ) に落とし、メモリ回収(Direct Reclaim)を全力で回していた。アプリ側を強制停止させて時間を稼ぎ、その間にメモリを捻出するというカーネルの「あがき」により、27回ものOOM Killer襲来を回避できていたことが分かる。

② 限界到達による1回目のOOM(通算28回目)

しかし、ついにあがきが限界に達し、15:16:40.466 に1回目のOOM Killerが執行。

[15:16:40.466] [CRITICAL] OOM Killer Event Detected!

③ 2度目の死刑執行(通算29回目)

さらにメモリ確保が続いた結果、29回目のOOMルーチンに突入。今度はあがきが実らず、2回目の執行。

最終的な memory.events:

low 0
high 3945
max 526
oom 29
oom_kill 2
oom_group_kill 0

カーネルログ(dmesg/syslog):

15:16:40 localhost kernel: oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=/,mems_allowed=0,oom_memcg=/oom_test,task_memcg=/oom_test,task=python3,pid=7953,uid=1000

15:16:40 localhost kernel: Memory cgroup out of memory: Killed process 7953 (python3) total-vm:412080kB, anon-rss:186860kB, file-rss:5248kB, shmem-rss:0kB, UID:1000 pgtables:432kB oom_score_adj:0

ターゲットとなったPythonプロセス(PID: 7953)が、cgroupの制限により確実に仕留められていることが確認できる。


サーキットブレイカーのトリガー選定:なぜ memory.high のカウンタでは駄目なのか?

OOMが発生する前に対策を打つ方法としてサーキットブレイカーというものが存在する。それの検知トリガーとして何を使えるか。

ログとしては省略しているが [CRITICAL] OOM Killer Event Detected!も頻繁に発生している。そのためこれ を検知トリガーに使うことも可能に思える。しかし、これはサーキットブレイカーのトリガーとしては不適切。

なぜなら、このイベントはカーネルが memory.high でプロセスを必死にD状態に落として足止めしている「戦闘中の火花」を毎回拾ってしまっているだけだから。ノイズが多く、そのイベントがどれほど深刻なのか(すぐ回収できるレベルなのか、手遅れ寸前なのか)のブレが大きすぎる。

結論:PSI(ALERT)をトリガーにすべき

拾うべきは、ノイズの多いイベントカウンタではなく、純粋な窒息時間を表す PSI([ALERT] Memory Pressure Triggered by Kernel!)。
PSIをトリガーにすることで、以下の識別が可能になる。

  • カーネルの足止めによってすぐメモリが回収できたのか
  • 本当にアプリが窒息して手遅れ寸前なのか

これを epoll 駆動でリアルタイムにシグナリングし、アプリケーション側でリクエストの受付拒否(503を返すなど)やキャッシュの解放を行うことこそが、真のサーキットブレイカーとして機能する。


4.PSIを活用した自己治癒(サーキットブレイカー)の設計論と検証

検証環境とcgroup v2設定

cgroup v2において、以下のようにリミットを設定

# スロットリング(回収処理)が開始される閾値
echo "80M" | sudo tee /sys/fs/cgroup/mem_test/memory.high

# OOM Killerが発動する絶対上限
echo "200M" | sudo tee /sys/fs/cgroup/mem_test/memory.max

アプリケーションの挙動

  • Webサーバーを起動し、/allocate(POST)が呼ばれるたびにヒープ領域を20MBずつ確保。
  • アプリケーション内部にPSIトリガー(some/full)を監視するモニター(エポール待機など)を常駐させる。
  • PSIを検知した時点でサーキットブレイカーをOPEN(UPS Battery Mode)にし、以降のリクエストに対してはメモリ確保を行わず、即座に503 Service Unavailableを返却する。

検証コード(ウォッチャー)

// 1. memory.pressure のオープンとトリガー設定
int psi_fd = open("/sys/fs/cgroup/mem_test/memory.pressure", O_RDWR | O_NONBLOCK);
if (psi_fd < 0) { perror("Failed to open memory.pressure"); return 1; }

// トリガー条件: 100ms(100,000µs)ウィンドウ内で 50ms(50,000µs)以上 full 状態が継続
std::string trigger = "full 50000 100000";
if (write(psi_fd, trigger.c_str(), trigger.size()) < 0) {
    perror("Failed to write PSI trigger");
    close(psi_fd);
    return 1;
}

// epoll インスタンスの作成とイベント登録
int epoll_fd = epoll_create1(0);

struct epoll_event ev_psi;
ev_psi.events = EPOLLPRI; // PSIはEPOLLPRIで通知される
ev_psi.data.fd = psi_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, psi_fd, &ev_psi);

struct epoll_event ev_oom;
ev_oom.events = EPOLLPRI | EPOLLERR; // memory.events用
ev_oom.data.fd = oom_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, oom_fd, &ev_oom);

// イベントループ
while (true) {
    struct epoll_event events[2];
    int nfds = epoll_wait(epoll_fd, events, 2, -1);
    
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == psi_fd) {
            // 【重要】PSIイベントはリードクリア不要。イベント発火の事実のみで即座に処理へ移る
            log_with_time("[ALERT] PSI 'full' Threshold exceeded!");
            
            // ここでサーキットブレイカーを OPEN に遷移させる
            // circuit_breaker.open();
            
        } else if (events[i].data.fd == oom_fd) {
            // OOMイベント検知時は、内部カウンタ(oom, oom_kill)をパースして詳細状態を取得
            int oom_status = get_event_counter(oom_fd, "oom");
            int oom_kill_status = get_event_counter(oom_fd, "oom_kill");
            
            log_with_time("memory.events change detected -> oom=" + std::to_string(oom_status));
        }
    }
}

Webサーバ

// 共有ステータス(モニターマシンのスレッドとWebサーバー間で共有)
std::atomic<bool> circuit_breaker_open(false);

// --- Webサーバー初期化 ---
httplib::Server svr;

// 【第一防衛線】最速ルーティングハンドラ
svr.set_pre_routing_handler([](const httplib::Request& req, httplib::Response& res) {
    if (circuit_breaker_open.load()) {
        // 例外処理(ホワイトリスト): 復旧コマンドやステータス確認は通す
        if (req.path == "/heal" || req.path == "/status") {
            return httplib::Server::HandlerResponse::Unhandled; // 後続の正規ハンドラへ流す
        }

        // 通常リクエスト(/allocateなど)はビジネスロジックに到達させず、ここで最速で弾く
        res.status = 503;
        res.set_content("503 Service Unavailable - Circuit Breaker Open (UPS Mode)\n", "text/plain");
        return httplib::Server::HandlerResponse::Handled; // 処理完了
    }
    return httplib::Server::HandlerResponse::Unhandled; // 通常時はスルー
});
#include <malloc.h> // malloc_trimのために必要

// 自己治癒エンドポイント
svr.Post("/heal", [](const httplib::Request& req, httplib::Response& res) {
    {
        std::lock_guard<std::mutex> lock(pool_mutex);
        // 1. アプリケーション上のオブジェクトを破棄(デストラクタ呼び出し)
        app_memory_pool.clear();
    }
    // ※この時点では get_cgroup_memory_usage() は減少していない(glibcのプール内にあるため)

    // 2. 【核心】アロケータのプールを解放し、OSへページを強制パージ
    malloc_trim(0); 

    // 3. 【チャタリング・ヒステリシス対策】カーネルのPSI移動平均が落ち着くまでクールダウン
    while (true) {
        size_t current = get_cgroup_memory_usage();
        if (current < MEMORY_RELEASE_THRESHOLD) { // 閾値(例: 60MB)以下まで安全に下がったら
            std::this_thread::sleep_for(std::chrono::milliseconds(300)); // 余韻を持たせる
            break;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }

    // サーキットを閉じてトラフィック再開
    circuit_breaker_open.store(false);
    res.set_content("Successfully healed. Memory trimmed and circuit closed.\n", "text/plain");
});

検証結果

サーキットブレイカーの発火とリクエスト遮断

Webサーバーを起動後、連続してリクエストを送信。100MBに達した時点でPSIが発報し、サーキットブレイカーが正常に機能した。

サーバー側ログ

[Main] Web Server listening on port 8080...
[Monitor] PSI Trigger initialized. Waiting for memory pressure...
[Server] Processing allocation request. Allocating 20MB...
[Server] Processing allocation request. Allocating 20MB...
[Server] Processing allocation request. Allocating 20MB...
[Server] Processing allocation request. Allocating 20MB...
[Server] Processing allocation request. Allocating 20MB...
[Monitor] !!! PSI TRIGGER FIRED !!! Opening Circuit Breaker (UPS Battery Mode).

クライアント(curl)側挙動

100MBに達するまでは正常に応答。閾値を超えた次のリクエストからは503で確実にプロテクトされている。

$ curl -X POST http://localhost:8080/allocate
Allocated 20MB. Current cgroup usage: 100 MB

$ curl -X POST http://localhost:8080/allocate
503 Service Unavailable - Circuit Breaker Open (UPS Mode)

カーネル空間とcgroupイベントの同期状態

サーキットブレイカーがOPENしている間、裏ではメモリ回収(Reclamation)による失速(Stall)が発生し、PSIが連続してアラートを検知している。

[ALERT] PSI 'full' Threshold exceeded! All tasks are completely stalled due to memory reclamation.
[ALERT] PSI 'full' Threshold exceeded! All tasks are completely stalled due to memory reclamation.
[ALERT] PSI 'full' Threshold exceeded! All tasks are completely stalled due to memory reclamation.

この間、memory.eventsを監視していても、oomおよびoom_killは一切上昇していない。

[CRITICAL] memory.events 変化検知 -> oom=0, oom_kill=0

アプリケーションが自律的に要求を遮断しているため、メモリ消費の増大が完全にストップし、OOM Killerの介入を完全に回避できている。

メモリ解放(自己治癒)とリトラフィック

/heal エンドポイント(手動トリガー)経由で、確保したメモリを解放し、malloc_trim(0) を実行してLinuxカーネルへ速やかにページを返却(RSSの強制パージ)。

クライアント側
$ curl -X POST http://localhost:8080/heal
Successfully healed. Memory trimmed and circuit closed.

サーバー側ログ
[Heal] malloc_trim(0)実行後のcgroup使用量: 0 MB (RSSの強制パージ成功!)
[Heal] Circuit Breaker CLOSED. Electricity Restored. Ready for traffic.

メモリ解放に伴いPSIの発報は停止。サーキットブレイカーが CLOSED に遷移し、再度リクエストの受け入れとメモリ確保が可能になる。

$ curl -X POST http://localhost:8080/allocate
Allocated 20MB. Current cgroup usage: 20 MB


考察:なぜ memory.high(80M) を超えて 100M まで確保されたのか?

今回の設計では、「10秒のウィンドウ内で 150ms ストール(full)したら発火する」というPSIトリガーを設定している。

  1. 時間差(レイテンシ)の発生:
    memory.highの80MBを超えた瞬間から、カーネルはバックグラウンドでページ回収(Reclaim)を開始する。この回収処理に伴うタスクの失速時間の累積値が、設定した150msに達するまでの間は、アプリケーションはまだ追加のメモリを確保できる。
  2. アロケーション速度の影響:
    今回は1リクエストあたり20MBという大きな粒度で高速にメモリを要求したため、PSIの累積ウィンドウが閾値に達する前に、100MBのラインまで突き抜けたことになる。

設計論における重要な示唆

cgroupの静的な閾値(high / max)の設計だけでは不十分。
「単位時間あたりに要求されるメモリの割り当て速度(Allocation Rate)」 と 「PSIが発火するまでのカーネル内のストール蓄積速度」 の相関関係を考慮する必要がある。

突発的な大量バーストが予想されるシステムでは、PSIのウィンドウ/閾値をよりタイトに(例:some 50ms / 1s など)設定するか、memory.high から memory.max までのバッファを広めに確保する設計が求められる。アプリケーションが「トラフィック依存型」か「データサイズ依存型」かを見極めることが必須。

また今回は検証のために外部から /heal を叩いて治癒させたが、本番運用においては以下のような自動治癒(Auto-Healing)メカニズムの組み込みが定石となる。

  • アプリケーション内部のインメモリキャッシュの自動パージ
  • Connection Poolの縮小、またはKeep-Aliveの切断による負荷制限
  • malloc_trim のバックグラウンドスレッドでの定期実行

5.cgroup v2環境におけるPSIの限界と「真の打つ手なし」境界のカーネルインサイド

先ほどはサーキットブレーカーを用いてOOMを防いだが、PSIは万能ではなく「PSIによる監視システムそのものが、監視対象であるメモリ枯渇の巻き添えを食らって完全に機能不全に陥る」境界条件が存在する

検証環境とシナリオ

以下の通り、cgroup v2のメモリ制限を設定し、ファイルキャッシュ(Page Cache)を僅かに持たせた状態で、Swapを完全に無効化(memory.swap.max=0)する。

# cgroup制限の設定
sudo sh -c "echo 0 > /sys/fs/cgroup/mem_test/memory.swap.max"
sudo sh -c "echo 100M > /sys/fs/cgroup/mem_test/memory.max"

# 20MBのファイルキャッシュを意図的に作成
dd if=/dev/urandom of=/tmp/dummy.bin bs=1M count=20
cat /tmp/dummy.bin > /dev/null

この状態で、300MBのアノニマスメモリをノーレイテンシで一気に確保・内容を走査(汚す)する検証プログラム(mem_eater_phase4)をcgroup内で実行する。

./mem_eater_phase5 300

検証コード

void memory_stress_thread(size_t half_pages, size_t page_size) {
    size_t trigger_point = (half_pages * 8) / 10; // 境界手前(80%)でBへ合図

    for (size_t i = 0; i < half_pages && !stop_flag; ++i) {
        // [Kernel Inside]: Page Fault 発生に伴い、カーネル内で down_read(&mm->mmap_lock) を取得
        // cgroup 上限超過時、このReadロックを保持したまま Direct Reclaim の空振りループにハングする
        global_addr[i * page_size] = 0xFF;

        if (i == trigger_point) {
            trigger_release.store(true); // スレッドBへの急襲合図
        }
    }
}
void emergency_release_thread() {
    while (!trigger_release.load()) { std::this_thread::yield(); } // スピンビジー待機
    
    // スレッドAが Direct Reclaim に捕まり、カーネル内で泥沼化するタイミングを微調整
    std::this_thread::sleep_for(std::chrono::milliseconds(10));

    // [Kernel Inside]: munmap は内部で down_write(&mm->mmap_lock) を要求する
    // AがReadロックを握ったままメモリ回収でスタックしているため、このWrite要求は強烈にブロックされる
    int ret = munmap(global_addr + half_bytes, half_bytes);
}

ウォッチャー側は前回と同様なので割愛

結果:ミリ秒未満での即時OOM Killer発動

ユーザー空間へのPSI通知や自律制御が挟まる余地もなく、一瞬でOOM Killerがプロセスを射殺する。

[Stress] Starting Page Fault loop (NO SLEEP)...
[16:49:41.801] [CRITICAL] memory.events 変化検知 -> oom=8, oom_kill=6
[16:49:41.810] [CRITICAL] memory.events 変化検知 -> oom=10, oom_kill=7
[16:49:41.810] -> [FATAL] OOM Killer has killed the process.


ftraceから読み解くカーネル内部挙動

この時、カーネル内部で何が起きていたのかをftraceのトレースログから確認する。

<...>-10470   [001] ....1.. 28227.702352: psi_memstall_enter <-try_charge_memcg
<...>-10470   [001] d...2.. 28227.702353: psi_task_change <-psi_memstall_enter

アプリケーション(PID: 10470)がPage Faultを起こし、アノニマスメモリのページを確定させようとした瞬間、try_charge_memcg によってcgroupの制限(memory.max)超過が検出される。
カーネルは即座にメモリ失速と判断し、PSIサブシステムにおいて psi_memstall_enter を記帳する。

Direct Reclaimの空振りと無限ループ

続いて、カーネルは以下の処理へ突入。

<...>-10470   [001] ....1.. 28227.702353: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702354: shrink_lruvec <-shrink_node_memcgs
...(ミリ秒未満のスパンで大量の空振りループ)...
<...>-10470   [001] d...5.. 28227.702356: psi_task_change <-enqueue_task

shrink_node_memcgs から shrink_lruvec が呼び出され、Direct Reclaim(同期的ページ回収)が走る。しかし、今回は以下の状況が重なっている。

  • memory.swap.max=0 のため、アノニマスメモリをスワップアウトできない。
  • 回収可能なファイルキャッシュ(Page Cache)がLRUリスト(特にこのcgroupが所有するコンテキスト内)に存在しない、あるいは極めて少ない。

結果として、ページを1枚も解放できないまま、超高速スパンで回収処理の往復(空振りループ)が発生。この過酷なコードパスの最中に、トレースログのフラグにある通りプリエンプション深度が最大「5」まで上昇。


なぜPSIは機能しなかったのか?:プリエンプション深度の上昇メカニズム

Linuxカーネルにおいて、プリエンプション深度(Preemption Disable Depth)が1以上の場合、現在のCPUでの処理を中断して他プロセスへコンテキストスイッチすることは不可能。そのため、ユーザー空間でPSIイベント(/proc/pressure/memory などの epoll)を待機している監視タスクにCPU時間が割り当てられず、PSIを検知することすらできない。

今回の検証で、深度が5まで跳ね上がったカーネル内部の推定ロックフローは以下の通り。

[深度 0] ユーザー空間でアプリが Page Fault を起こす
 │
 ▼ 【トリガー1: memory.max突破によるDirect Reclaim突入】
[深度 1] ページ回収のため memcg の lruvec->lock (スピンロック) を取得
 │
 ▼ 【トリガー2: 回収不能(空振り)によるタスクのストール判定】
[深度 2] 「このタスクはメモリ待ち(Stall)に入った」と判定され、
         PSIサブシステムが cgroup 内の全タスクの状態を集計するため cgroup->lock を取得
 │
 ▼ 【トリガー3: タスク状態変化に伴うスケジューラ(ランキュー)の操作】
[深度 3] ストールしたタスクをCPUの実行待ちから外す、または kworker を叩き起こすため、
         スケジューラの最深部ロックである rq->lock (raw_spin_lock) を取得
 │
 ▼ 【トリガー4: スケジューラ処理の最中にタイマー割り込みが非同期に直撃】
[深度 4] タイマー割り込み(sched_tick)が発生し、ハードウェア割り込みコンテキストへ移行
 │
 ▼ 【トリガー5: 割り込みハンドラ内で、さらにPSIの統計更新が走る】
[深度 5] 割り込み内部で「現在のCPU時間をPSIの統計に計上(psi_account_irqtime)」するため、
         PSI内部のPer-CPUロックや統計用スピンロックを取得

ただしこれはあくまで推定であり、実際に、わずか1マイクロ秒の間にシステムが窒息する瞬間を捉えたのが以下の ftrace ログである

<...>-10470   [001] ....1.. 28227.702353: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702354: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702354: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702355: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702355: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702355: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702355: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702355: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] ....1.. 28227.702355: shrink_lruvec <-shrink_node_memcgs
<...>-10470   [001] d...5.. 28227.702356: psi_task_change <-enqueue_task

結論
メモリが「真の打つ手なし」まで完全に枯渇し、かつ回収パスが高速に空振りし続けると、カーネル内部の排他制御(スピンロックや割り込みハンドラ)のネストによってプリエンプションが完全に禁止される。
結果、監視対象(メモリ枯渇)の巻き添えを食らい、監視システム(PSI通知とそれを処理するデーモン)自体が窒息するというデッドロック的状況が発生する。


まとめ:そもそもなぜOOM Killerを徹底的に防ぐべきなのか

PSIによる前兆検知が失敗し、最終防衛ラインであるOOM Killerが発動すると、システムは以下の3つの致命的なリスクに直面します。

① システムの連鎖崩壊

既に最初で検証した通り、OOM Killerは独自のアルゴリズム(oom_score)に基づき、メモリ消費量が多く、かつシステムへの影響が少なそうなプロセスを機械的に選別して SIGKILL を送る。
インフラ側で oom_score_adj をいじって特定のアプリだけを無理やり延命させようとすると、カーネルは単に別の生存に必要なプロセス(ミドルウェアやロギングデーモンなど)を殺しに行くだけ。だからこそ、アプリ層自身がPSI等をトリガーに「自律的に503を返して身を引く(UPS型生存戦略)」ことで、他プロセスを巻き込まずにシステム全体を守る必要がある。

② データ破壊

OOM Killerが放つのは SIGKILL (-9) 。これはアプリケーションに対し、クリーンアップの猶予を1マイクロ秒も与えず、メモリ空間ごと消滅させる冷酷な命令。
通常アプリが行うべき、

  • 処理中データのディスク同期(fsync
  • DB接続の正常クローズ
  • 受信中ソケットへの終了通知(TCP FIN)

といったGraceful Shutdownが一切スキップされる。結果として、書き込み途中データの破損、DBインデックスの崩壊、上流ロードバランサにおける突然のコネクション切断(パニック)など、深刻なデータ汚染を引き起こす。

③ コンテナ環境における「サイレントクラッシュ」

Kubernetesなどのモダンなインフラでは、アプリが自ら exit(1) などで正常に異常終了(自爆)すれば、インフラ側がそれを検知して数秒で新しいコンテナを起動(セルフヒーリング)できる。
しかし、OOM Killer(特にcgroup制限ではなくホスト全体のOOM)に刺された場合、カーネルログ(dmesg)には記録が残るものの、コンテナ層からは「何が起きたか不明なまま、あるスレッドが突然消滅した」ように見えるケースがある。最悪の場合、これも検証した通りプロセス全体ではなくメモリを食っていた特定のマルチスレッドだけがピンポイントで射殺され、メインプロセスは生殺しのままゾンビ化して応答不能(サイレントクラッシュ)になるという、極めて質の悪い状態に陥る。


本検証に関するフィードバックについて

本記事で扱っている挙動は、OSの内部仕様や未定義動作に深く依存しています。環境による挙動の差異や、客観的なログを伴う反証・追考をお持ちの方は、以下のGitHubリポジトリ(Issue)までお寄せください。

※ 技術的整合性を保つため、コメントの投稿には「再現コード」「実行環境の情報」「WinDbg等の出力ログ」が必須となります。客観的なデータのないテキストのみの指摘や、再現性のない感情的なコメントは、予告なくクローズまたは削除いたしますので予めご了承ください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?