【入門編】Swoole/RoadRunner環境におけるリクエスト間でのメモリ状態の分離とクリーンアップ – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。大規模なWebシステムの設計や、他言語からの移行で「PHPの裏側」に直面し、少し立ち止まっていませんか?

Node.jsやGo、Javaといった常駐型のプロセスモデルに慣れたエンジニアが、モダンなSwooleやRoadRunnerといった常駐型PHPランタイムに触れたとき、最初に直面するのが「リクエストをまたいでメモリが汚染される(メモリリークする)」という壁です。

従来のApache + mod_phpや、伝統的なPHP-FPMの「1リクエスト=プロセス生成・破棄(あるいはリクエスト終了時の完全なプロセスリセット)」という温室に守られてきたPHPアプリは、メモリリークに対して無頓口でも動いてしまう側面がありました。しかし、SwooleやRoadRunnerでは、ひとつのPHPプロセスがメモリ上に常駐し続け、数万、数十万のリクエストをハンドルし続けます。

今回は、PHPのエンジン(Zend VM)がメモリをどう扱い、常駐環境においてなぜリクエスト間のクリーンアップが絶対に必要なのか、その裏側のメカニズムと実践的な防衛策を一緒に紐解いていきましょう。ここを理解すれば、PHPの裏側が驚くほどクリアに見えるようになりますよ。

—

1. 伝統のPHP-FPMと常駐型ランタイム(Swoole/RoadRunner)の決定的な違い

まず、実行モデルの根本的な違いを整理しておきましょう。

PHP-FPMの世界:OSによる強制的なメモリ回収

PHP-FPM環境では、Nginxなどから渡されたリクエストをWorkerプロセスが処理します。スクリプトの実行が終わり、レスポンスが返されると、Zendエンジンがどれだけ複雑なオブジェクトグラフを構築していようが、OS自体がそのプロセスを丸ごと解放(`exit()`)します。
極端な話、コード内でどれだけメモリリークを起こしていようが、プロセスが消滅すればOSの仮想メモリ空間ごとキレイさっぱりリセットされるため、リクエスト間の汚染は原理的に起きません。

Swoole / RoadRunnerの世界:メモリ空間の永続化

一方、SwooleやRoadRunnerは、起動時にPHPのスクリプトを一度だけメモリ上に読み込み、Zend VMのOPcacheやシンボルテーブルをメモリ上に保持したまま、非同期またはマルチプロセスでリクエストを待ち受けます。

[Swoole Master / Workerプロセス] <-- メモリ上に常駐! ├── リクエスト1 処理 -> グローバル変数にデータを蓄積… (残る)
├── リクエスト2 処理 -> 前の人のデータが見えてしまう!? (State Pollution)
└── リクエストN 処理 -> メモリリークが蓄積し、やがて OOM Killer発動

ここで何が起きるかというと、「あるリクエストで汚染されたグローバル状態、静的プロパティ、DIコンテナ内のインスタンスが、次のリクエストにそのまま持ち越される」という、他言語の常駐型サーバーでお馴染みの恐怖の現象(State Pollution)が発生します。

—

2. Zend VMのメモリ管理と「参照カウント」の限界

なぜ、リクエストが終わってもメモリに残ってしまうのでしょうか?それはPHPのメモリ管理の根幹である「参照カウント(Reference Counting)」と「ガベージコレクション(GC)」の仕様に起因します。

PHPのすべての変数やオブジェクトは、内部構造体である `zval`(Zend Value)としてメモリ上に存在します。
変数やオブジェクトが別の変数に代入されたり、関数の引数として渡されるたびに、その `zval` が持つ参照カウンター(`refcount`)がインクリメントされます。スコープを抜けるなどして使われなくなるとデクリメントされ、`refcount` が 0 になった瞬間にメモリから解放されます。

循環参照の罠

ここで問題になるのが、オブジェクト同士が互いを参照し合う「循環参照(Circular Reference)」です。

class Node {
public ?Node $parent = null;
public array $children = [];
}

// 親子関係を作ると…
$parent = new Node();
$child = new Node();
$parent->children[] = $child;
$child->parent = $parent;

// スコープを抜けて $parent や $child の変数が消えても、
// お互いを指し示す refcount が「1」ずつ残るため、通常の参照カウントでは解放できない!

PHPのGCはこの循環参照を回収する仕組みを持っていますが、これは「バッファがいっぱいになった時」や「明示的に `gc_collect_cycles()` が呼ばれた時」に動くものであり、リアルタイムにミリ秒単位で完全にクリーンアップされるわけではありません。

SwooleやRoadRunnerの高速なイベントループの最中に、この「回収されきらなかったメモリ断片」や「シングルトンに蓄積されたリクエスト固有のデータ」が積み重なると、リクエストを重ねるごとにメモリ使用量が右肩上がりに増え、最終的に `Allowed memory size exhausted` または OS の OOM Killer(Out of Memory)によってプロセスが強制終了させられます。

—

3. 実践:常駐環境でやってはいけないアンチパターンと対策

では、実際のコードではどのように気をつけるべきでしょうか。Swoole/RoadRunner環境で絶対に避けるべき設計と、その対策を見ていきましょう。

アンチパターン①:静的プロパティ(`static`)へのリクエスト依存データの保持

クラスの静特性は、プロセスが生存している間ずっと値を保持し続けます。ここにリクエストごとのユーザー情報やデータベースのコネクションなどを保持してしまうと、次のリクエストで他のユーザーのデータが見えてしまうという致命的なセキュリティインシデント(データ漏洩)に繋がります。

NGな例:

class RequestContext {
private static ?array $userData = null;

public static function setUserData(array $data): void {
self::$userData = $data; // プロセス寿命の間、消えない!
}

public static function getUserData(): ?array {
return self::$userData;
}
}

正しいアプローチ:
常駐環境では、リクエストごとにスコープが完結するDIコンテナ(PSR-11互換のコンテナなど)を利用し、「リクエストの開始時にコンテナを初期化し、終了時に破棄する」ライフサイクル管理を徹底します。

// リクエストハンドラー内
$container = new Container(); // リクエストごとに新規生成
$container->set(RequestUser::class, new RequestUser($requestData));

// リクエスト処理終了後、$container はスコープを抜けて自動的に破棄される

—

アンチパターン②:大きな配列やオブジェクトの意図しないスコープ残留

イベント駆動型のフレームワーク(SwooleベースのLaravel OctaneやWorkermanなど)では、ブートストラップ時にアプリケーションをメモリ上にロードします。

もし、コントローラーやサービスの中に「大きなログバッファ」や「キャッシュ配列」をインスタンスプロパティとして持たせ、そこにデータを蓄積していく実装にしていると、リクエストが進むにつれてメモリが枯渇します。

対策:明示的なリセットフックの利用
多くのモダンな常駐型フレームワークやサーバーは、「リクエスト終了時のフック(Defer/Cleanup hook)」を提供しています。これを利用して、リクエストが終わるタイミングでメモリを明示的に解放・初期化します。

// RoadRunnerやSwooleのミドルウェア/リクエスト終了時のイメージ
use Swoole\Http\Request;
use Swoole\Http\Response;

$server->on(‘request’, function (Request $request, Response $response) {
try {
// 1. リクエスト処理の実行
$app = bootstrap_application();
$response = $app->handle($request);

} finally {
// 2. 【最重要】リクエスト終了時のクリーンアップ
// グローバルな状態やシングルトン内のリクエスト固有ステータスをクリア
Registry::clearRequestState();

// 強制的にGCを回して循環参照を即座に回収(必要に応じて)
if (gc_enabled()) {
gc_collect_cycles();
}
}
});

—

4. デバッグとメモリリーク検知の極意

「うちのアプリ、本当にメモリリークしてないかな?」と不安になったときは、感覚でデバッグするのではなく、Zendエンジンの内部関数を使って数値で状態を把握しましょう。

PHPには、現在のメモリ消費量を取得する強力な関数が用意されています。

// リクエスト処理の前後でメモリ使用量を計測するミドルウェアの例
$startMemory = memory_get_usage(true); // OSから割り当てられたリアルなメモリ量
$startPeak = memory_get_peak_usage(true);

// — リクエスト処理の実行 —
$response = $handler->handle($request);

$endMemory = memory_get_usage(true);
$endPeak = memory_get_peak_usage(true);

// ログやヘッダーに出力して監視する
if (($endMemory – $startMemory) > 1024 1024) {
// 1MB以上メモリが戻ってきていない場合、リークの可能性をアラート
logger()->warning(“Memory might be leaking! Diff: ” . ($endMemory – $startMemory));
}

また、開発環境では `memory_get_usage()` の推移をロギングし、同じリクエストを100回連続で送り込んだときにメモリ使用量がフラットに戻るか(ノコギリ波のように、ピーク後にしっかりベースラインまで落ちるか)を必ずテストするようにしてください。右肩上がりに増え続けていたら、どこかで参照が切れていない証拠です。

—

5. 先輩アーキテクトからのメッセージ

SwooleやRoadRunnerといった常駐型ランタイムは、PHPの弱点であった「毎リクエストのフレームワーク起動オーバーヘッド」を完全に破壊し、GoやNode.jsに匹敵する爆速のWebアプリケーション体験をもたらしてくれます。

その一方で、PHPという言語が長い間「リクエスト終了時の自動消滅」というセーフティネットに甘えてきた歴史があるため、エンジニア側が「メモリのライフサイクルをコントロールする意識」を持つことが成功の鍵になります。

  • 「この変数はどのスコープに属しているか?」
  • 「この静的プロパティやシングルトンは、次のリクエストのユーザーに見えても安全か?」
  • 「リクエストが完了したとき、きれいに後始末をしているか?」

この3つを常に頭の片隅に置いてコードを書くだけで、あなたの作るPHPアプリケーションは、数百万リクエストをノーリスタートで裁き続ける、極めて堅牢で美しいモンスターシステムへと生まれ変わります。

さあ、裏側の仕組みを掌握したあなたなら、もう怖れるものは何もありません。最高のアーキテクチャを構築してくださいね!

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