【実務・中級編】PHPのメモリ制限(memory_limit)とOSレベルのメモリ管理:オーバーコミットとスワップの影響 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

PHPのメモリ制限(`memory_limit`)とOSの深層:オーバーコミットとOOM Killerの恐怖を制するアーキテクチャ設計

コードレビューをしていて、次のような甘い認識に出くわすたびに、私はエンジニアとしての危機感を覚える。

> 「バッチ処理で数万件のレコードを一括取得して処理する? 大丈夫、`ini_set(‘memory_limit’, ‘1G’);` に増やしておいたから!」

PHPの `memory_limit` は、Zendエンジンがスクリプト内でアロケートするヒープメモリの最大値を制限するためのものであり、「安全弁」としては機能するが、OS全体のメモリ管理を保護するものではない。この勘違いが、高負荷時の本番環境で突然のプロセス急死(OOM Killerによる強制終了)を引き起こす。

今回は、PHPプロセスが消費するメモリの正体と、Linuxカーネルのオーバーコミット(Overcommit)、そしてスワップ領域が絡み合ったときにWebサーバー内部で何が起きているのかを、Zend VMとOSの境界線から徹底的に解剖する。

—

1. Zendエンジンのアロケータと `memory_limit` の本当の役割

PHPのコードが実行されるとき、すべての変数、オブジェクト、配列はZendエンジン内部のメモリマネージャー(`emalloc()` / `efree()`)を通じて確保される。`memory_limit` は、この `emalloc()` の累計バイト数を監視するソフトリミットに過ぎない。

[PHPスクリプト]
↓ (emalloc)
[Zend Memory Manager] <-- ここで memory_limit (例: 128M) を監視 ↓ (malloc) [OSのヒープ空間 (brk / mmap)] しかし、ここで重要な事実がある。Zendエンジンが確保したメモリを解放(`efree()`)しても、OSへ即座に返還されるとは限らない。Zendメモリマネージャーは、フラグメンテーションを防ぎパフォーマンスを維持するために、解放されたメモリチャンクを内部プール(Heap)に保持し続ける。

つまり、`memory_limit` に達していなくても、OSから見たPHPプロセス(PHP-FPMワーカー)の物理メモリ消費量(RSS: Resident Set Size)は、Zendが保持しているプール分だけ常に大きくなる。

—

2. OSのオーバーコミット(Overcommit)とOOM Killerの罠

Linuxカーネルは、プロセスが実際に必要とするメモリ量よりも多くのメモリ割り当てを許可する「メモリ・オーバーコミット」という戦略をとっている。これにより、プロセスは安全マージンを見越して多めにメモリを要求(`malloc`)できる。

しかし、実際にプロセスがそのメモリに書き込みを行おうとした瞬間(Anonymous Pageの割当て)、カーネルは物理メモリとスワップ領域の合計が限界に達していないかを確認する。

ここで `memory_limit` とOSの間に深刻なギャップが生じる。

1. 無限肥大化するプロセス:
PHPの `memory_limit` を `2G` に設定し、複数同時リクエストが走ったとする。PHP-FPMのワーカーが最大数(`pm.max_children`)まで起動し、それぞれが `2G` 近くまでメモリを食い潰した場合、理論上の最大要求メモリは `2G × ワーカー数` となり、物理メモリ(RAM)を容易に超越する。
2. スワップの奔流(Thrashing):
RAMが枯渇すると、OSは不活性なメモリページをディスク上のスワップ領域に退避させ始める。しかし、PHPアプリケーションのようにポインタを縦横無尽に参照するヒープ空間に対してスワップが発生すると、I/O待ちが頻発し、CPU使用率が100%に張り付いたままアプリケーション全体が事実上の「フリーズ状態」に陥る。
3. OOM Killerの降臨:
スワップすらも枯渇した極限状態において、Linuxカーネルはシステム全体がクラッシュするのを防ぐため、OOM (Out of Memory) Killerを発動する。カーネルは「最もメモリを喰っている、あるいはスコアの悪いプロセス」を容赦なく強制終了(SIGKILL)する。PHP-FPMのワーカーが突然Killされると、NginxやApache側では `502 Bad Gateway` が返ることになる。

—

3. 実務で防ぐべきアンチパターンと設計ルール

バッチ処理や大規模APIレスポンスの生成において、メモリ枯渇を防ぐための鉄則を以下に定義する。

  • 「一括取得(`fetchAll`)」の全面禁止: 数千件以上のレコードを扱う場合は、必ずジェネレータ(Generator)やカーソルを用いたストリーミング処理へリファクタリングすること。
  • デストラクタとガベージコレクションの強制: 巨大なオブジェクトグラフを扱う処理のループ内では、明示的な変数破棄(`unset`)と、循環参照がある場合は `gc_collect_cycles()` の手動実行を検討する。
  • FPMプールの分離: 重いバッチやインポート処理を行うエンドポイントは、通常のWebトラフィック用FPMプールとは完全に切り離し、`pm.max_children` を厳しく制限する。

—

4. 実装例:安全なメモリ管理を行うチャンク処理パターン

以下のコードは、数百万件のレコードを安全に処理し、OSとZendエンジンのメモリを圧迫しないための「カーソルベースのストリーミング処理」を実装した堅牢なリファレンスである。

  • Class SafeDataStreamer
  • 巨大なデータセットをメモリ溢れを起こさずに処理するためのストリーミングリーダー。
  • PDOのバッファードクエリ(デフォルト)を無効化し、ネットワーク経由でデータを
  • 1行ずつ遅延評価(Yield)することで、Zendヒープの肥大化を防ぐ。
  • /
    final class SafeDataStreamer
    {
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
    $this->pdo = $pdo;
    // 重要: エミュレートされたプリペアドステートメントをオフにし、
    // サーバーサイドカーソルを利用することでメモリ効率を最大化
    $this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
    }

    /

    • 巨大なテーブルからデータをチャンク(カーソル)単位で安全にストリーミング取得する
    • @param string $sql
    • @param int $chunkSize
    • @return Generator>

    /
    public function streamQuery(string $sql, int $chunkSize = 500): Generator
    {
    // サーバーサイドカーソルを有効化(MySQLの場合はBuffered Queryを無効化する効果がある)
    // ※ ドライバや環境によりsetAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false)の併用が必要な場合あり
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute();

    $buffer = [];
    $count = 0;

    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $buffer[] = $row;
    $count++;

    // 指定したチャンクサイズに達したらメモリをyieldし、呼び出し元へ制御を渡す
    if ($count >= $chunkSize) {
    yield $buffer;

    // バッファをクリアしてZendエンジンのヒープ開放を促す
    $buffer = [];
    $count = 0;

    // 任意のガベージコレクション誘発(循環参照や断片化対策)
    if (function_exists(‘gc_enabled’) && gc_enabled()) {
    gc_collect_cycles();
    }
    }
    }

    // 剰余分のバッファを出力
    if (!empty($buffer)) {
    yield $buffer;
    }

    $stmt->closeCursor();
    }
    }

    // ==========================================
    // 実行スクリプトの例(バッチコンテキスト)
    // ==========================================
    /
    try {
    $pdo = new PDO(‘mysql:host=localhost;dbname=heavy_db;charset=utf8mb4’, ‘user’, ‘pass’, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);

    $streamer = new SafeDataStreamer($pdo);
    $sql = “SELECT id, payload, created_at FROM massive_logs WHERE processed = 0”;

    // メモリ制限を厳格に監視下に置く(必要以上に大きくしない)
    ini_set(‘memory_limit’, ‘256M’);

    $totalProcessed = 0;

    // 1チャンクあたり500件ずつメモリにロードして処理
    foreach ($streamer->streamQuery($sql, 500) as $chunk) {
    foreach ($chunk as $record) {
    // ここで重い処理や外部API連携を行う
    // …
    $totalProcessed++;
    }

    // ループのイテレーションごとにログを出力し、進捗とメモリ状況を可視化
    echo sprintf(
    “Processed: %d records. Current Memory Usage: %.2f MB (Peak: %.2f MB)\n”,
    $totalProcessed,
    memory_get_usage(true) / 1024 / 1024,
    memory_get_peak_usage(true) / 1024 / 1024
    );
    }

    echo “Batch completed successfully without OOM risk.\n”;

    } catch (\Throwable $e) {
    // ログ出力と適切なアラート発報
    error_log(“Critical Batch Error: ” . $e->getMessage());
    exit(1);
    }
    /

    —

    5. アーキテクトからの提言:メトリクス監視のすゝめ

    コードレベルの対策を施した上で、インフラストラクチャ側でも次の数値を常時監視すべきである。

    1. `node_memory_SwapFree_bytes` / `node_memory_SwapTotal_bytes`: スワップ領域の使用率が恒常的に高くなっていないか。スワップが起き始めた時点で、アプリの設計(あるいはPHP-FPMのプロセス数)が破綻している。
    2. `php_fpm_process_count` と `memory_limit` の積 vs 物理メモリ(RAM): 同時最大リクエスト処理時に、サーバの物理メモリ容量をオーバーコミット領域含めて完全に使い切っていないかを、キャパシティプランニングの段階で厳密に計算すること。

    `memory_limit` は魔法の杖ではない。OSのハードウェア制約とZendエンジンのメモリライフサイクルを正しく理解し、コントロールすることこそが、止まらない高可用性Webシステムの構築に不可欠である。

    タイトルとURLをコピーしました