Swoole/RoadRunner環境におけるリクエスト間メモリ分離の極意:カスタムサンドボックスとメモリマネージャーの設計
PHP-FPMの呪縛から解放され、SwooleやRoadRunnerといった常駐型(Persistent)アプリケーションサーバーを導入した途端、多くのチームが「メモリリーク」と「リクエスト間汚染(Cross-request Pollution)」という見えない恐怖に直面する。
Zend Engineのライフサイクルを理解していれば当然の帰結だ。PHP-FPMでは1リクエストの終了と共にプロセスごとメモリ空間がOSに返還され、すべてがリセットされた。しかし、常駐型プロセスではグローバル空間、静的プロパティ(`static`)、そしてシンボルテーブル(`EG(symbol_table)`)に載ったすべてのデータが次のリクエストに持ち越される。
本稿では、常駐型PHP環境におけるリクエスト間メモリ分離のメカニズムを低レイヤの視点から紐解き、外部ライブラリの汚染やメモリリークを完全に遮断するための「カスタムメモリマネージャー」と「サンドボックス化」の実装パターンを、プロダクションクオリティのコードと共に伝授する。
—
1. なぜ常駐型PHPではメモリリークと汚染が起きるのか
Zend Engineのメモリ管理は、主に参照カウント(Reference Counting)と循環参照コレクター(Garbage Collector)によって成り立っている。
1リクエストの処理中、変数はエグゼキューションデータ(`_zend_execute_data`)のローカルシンボルテーブルに紐づき、スクリプト終了時に破棄される。しかし、以下のようなコードは常駐型環境において「時限爆弾」となる。
class DatabaseConnectionPool {
// staticプロパティはプロセス生存期間中ずっと保持される
private static array $connections = [];
public static function getConnection(string $dsn) {
if (!isset(self::$connections[$dsn])) {
self::$connections[$dsn] = new PDO($dsn);
}
return self::$connections[$dsn];
}
}
このコードの何が危険か? `PDO` インスタンスや、リクエストごとに生成されるべきユーザー情報、リクエスト特有のサービスコンテナが `static` な領域にキャッシュされ、次のリクエストの別ユーザーに流出(クロスリクエスト汚染)する。さらに、リクエストごとにオブジェクトが追加され続けることで、ZendMemoryManager(ZMM)が管理するヒープ領域が肥大化し、OOM(Out of Memory)を引き起こす。
これを防ぐためには、リクエスト境界(Request Boundary)を明示的に定義し、そのスコープ内で生成されたすべての動的リソースを完全に追跡・破棄する仕組みが必要となる。
—
2. 実装:カスタムメモリマネージャーとサンドボックスコンテナ
以下のコードは、SwooleやRoadRunnerのワークワーカー内で動作し、リクエストごとに完全に隔離されたサンドボックス空間を作り出すカスタムメモリマネージャーの実装である。
Zend Engineのガベージコレクションに頼るのではなく、明示的な「スコープ・デストラクション」をアプリケーション層でエミュレートする。
declare(strict_types=1);
namespace App\Core\Memory;
use WeakMap;
use Throwable;
/
- リクエスト間のメモリ分離とリソース追跡を行うサンドボックスマネージャー
/
final class RequestSandbox
{
private static ?self $instance = null;
/ @var WeakMap
/ @var array
private array $cleanups = [];
/ @var array
private array $requestRegistry = [];
private function __construct()
{
$this->trackedObjects = new WeakMap();
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/
- 新しいリクエストサイクルの開始を宣言し、状態を初期化する
/
public function enter(): void
{
$this->cleanups = [];
$this->requestRegistry = [];
// WeakMapは参照がなくなれば自動で消えるが、コンテナ自体のエントリをクリア
$this->trackedObjects = new WeakMap();
}
/
- オブジェクトをサンドボックスの管理下に置き、リクエスト終了時の確実な解放を保証する
/
public function track(object $resource, string $identifier = ”): object
{
$this->trackedObjects[$resource] = $identifier !== ” ? $identifier : get_class($resource);
return $resource;
}
/
- リクエストスコープの変数を安全に登録する
/
public function set(string $key, mixed $value): void
{
if (is_object($value)) {
$this->track($value, “registry:{$key}”);
}
$this->requestRegistry[$key] = $value;
}
public function get(string $key): mixed
{
return $this->requestRegistry[$key] ?? null;
}
/
- クリーンアップ用のコールバックを登録
/
public function defer(callable $callback): void
{
array_unshift($this->cleanups, $callback);
}
/
- リクエスト終了時にコールされ、全リソースを強制解放・汚染を除去する
/
public function leave(): void
{
// 1. 登録された遅延クリーンアップの実行
foreach ($this->cleanups as $cleanup) {
try {
$cleanup();
} catch (Throwable $e) {
// ログ出力等に留め、処理を継続してメモリリークを防ぐ
error_log(“Cleanup error: ” . $e->getMessage());
}
}
// 2. リクエストレジストリの参照を完全に断つ
$this->requestRegistry = [];
// 3. WeakMapに依存しない明示的な参照切断のトリガー
// (PHPの循環参照GCを補助するため、エントリを強制的にクリア)
$this->trackedObjects = new WeakMap();
// 4. Zend Engineのメモリエンジンに不要な空きメモリの返還を促す (強制GC)
if (gc_enabled()) {
gc_collect_cycles();
}
}
}
—
3. RoadRunner / Swoole ミドルウェアとしての統合
作成した `RequestSandbox` を、実際の常駐型サーバーのイベントループに組み込む。RoadRunnerのPSR-7ミドルウェア、またはSwooleの `onRequest` コールバックのライフサイクルに完全に同期させる必要がある。
以下は、例外が発生した場合(パニック時)であっても確実に `leave()` が呼ばれ、次のリクエストへのメモリ汚染を完全に防ぐ堅牢なミドルウェアの実装例である。
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Core\Memory\RequestSandbox;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
final class MemorySandboxMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$sandbox = RequestSandbox::getInstance();
// 前リクエストの残骸が万が一残っていたら強制リセット
$sandbox->enter();
// リクエストオブジェクト自体もサンドボックスの監視下に置く
$sandbox->track($request, ‘http_server_request’);
try {
// リクエストスコープにPSR-7のリクエストをバインド
$sandbox->set(‘request’, $request);
// アプリケーションの実行
$response = $handler->handle($request);
// レスポンスも追跡対象に
$sandbox->track($response, ‘http_server_response’);
return $response;
} catch (Throwable $e) {
// 例外時も確実にキャッチしてサンドボックスをパージする
throw $e;
} finally {
// 例外の有無にかかわらず、必ずリクエスト空間を完全に破棄・分離する
$sandbox->leave();
}
}
}
—
4. アーキテクトが教える:デバッグとメモリリーク検知の極意
コードがどれほど美しくても、サードパーティ製ライブラリ(例えばORMやログライブラリ内部での静的キャッシュ)がメモリリークを引き起こすケースは後を絶たない。本番環境でメモリリークを瞬時に特定し、開発チームを救うための実践的なアプローチを授ける。
1. `memory_get_usage(true)` による差分監視
リクエストの「開始時」と「終了時」の割り当てメモリ量(`memory_get_usage(true)` が返す実際のOSヒープサイズ)をミドルウェアの上下でロギングし、許容しがたいデルタ(増加量)が検知された場合にアラートを上げる仕組みを導入する。
$startMemory = memory_get_usage(true);
// … 処理 …
$diff = memory_get_usage(true) – $startMemory;
if ($diff > 1024 1024 2) { // 2MB以上の増加
logger()->warning(“Potential memory leak detected: {$diff} bytes consumed in request.”);
}
2. `gc_status()` の監視
Zend VMの循環参照コレクターの挙動を監視する。`gc_status()[‘buffered’]` がリクエストを重ねるごとに右肩上がりに増えていく場合、それはどこかで意図しない循環参照(Circular Reference)が発生しており、Zendの参照カウントでは回収しきれていないことを意味する。
—
結びにかえて
常駐型PHPにおけるメモリ管理は、もはや「言語任せ」では通用しない。Zend Engineの挙動と、アプリケーションが展開されるメモリ空間の構造を完全に把握した上で、「どこまでがプロセスの寿命で、どこからがリクエストの寿命なのか」をアーキテクトが厳密に設計・制御しなければならない。
ここに提示したサンドボックスパターンとミドルウェアによる明示的なライフサイクル管理を導入することで、SwooleやRoadRunnerの圧倒的なパフォーマンスを享受しつつ、メモリリークやクロスリクエスト汚染という悪夢から完全に解放された堅牢なシステム構築が可能となるはずだ。コードレビューの現場において、「この `static` は本当にプロセスの生存期間中保持されるべきか?」という問いを常に投げかけられるエンジニアであれ。