なぜ `memory_limit` に達する前に Linux は PHP を殺すのか?
コードレビューの場で、「`memory_limit` を `2G` に設定したから、この大量データ処理バッチは安全だ」という発言を聞くたびに、私は深い絶望とエンジニアとしての危機感を覚える。
PHPの `memory_limit` は、Zendエンジンが仮想的に定めた「論理的な安全装置」に過ぎない。Linuxカーネルがプロセスの生死を決定する基準は、そんなPHPの都合の良い設定値ではなく、物理・仮想メモリのハードウェア的な実消費量である。
この乖離を理解していないと、大規模なAPIやバッチ処理を本番稼働させた瞬間、の前触れもなく `dmesg` に `Out of memory: Kill process` が刻まれ、静かにプロセスが葬り去られる悪夢を見る事になる。
今回は、Zend VMのメモリ管理機構(ZendMM)と、OSレベルのメモリアロケータ(glibcの `malloc`、`brk`、`mmap`)の深い関係性を紐解き、実務で絶対に踏んづけてはならないメモリ管理の極意を伝授する。
—
ZendMM と glibc アロケータの密談:内部で何が起きているか
PHPスクリプトが動作するとき、メモリは直接OSから細切れに確保されているわけではない。パフォーマンスを最大化するため、Zendエンジンは起動時にOSから巨大なメモリブロックをまとめて一括確保する。これが Zend Memory Manager (ZendMM) の正体だ。
1. チャンクとページの幻想
ZendMMは、OS(glibc)に対して `malloc()` を用いて巨大なチャンク(通常は2MB単位、PHP 8以降ではhuge pagesの活用もある)を要求する。
PHPコード内で配列に要素を追加したり、文字列を結合したりすると、ZendMMはこの事前確保したプール内から細かなメモリ(`zend_alloc`)を高速に切り出す。
ここで最初の乖離が生まれる。
- PHPの視点 (`memory_limit`): 「今、スクリプトは 128MB しか使っていない(ZendMM内の管理カウンタ値)」
- OSの視点 (RSS / Virtual Memory): 「いや、プロセス全体の仮想メモリ空間は 512MB に膨らんでいるぞ」
2. `emalloc()` と `efree()` の闇:メモリの返還されない構造
ZendMMは、一度OSから奪ったメモリを、リクエスト処理中に `efree()` さえても容易にOSへ返還(`free()`)しない。
フラグメンテーションを防ぎ、次のアロケーションを高速化するために、ZendMMのプール内に保持し続けるのだ。
つまり、以下のようなループ処理を書いた場合:
// 危険なアンチパターン:巨大な配列を生成・破棄を繰り返す
for ($i = 0; $i < 10000; $i++) {
$hugeData = range(1, 100000); // 大量の配列要素をemalloc
// 何らかの処理
unset($hugeData); // efreeされるが、OSへの返還は保証されない
}
このコードを実行すると、PHPの `memory_limit` のカウンタはある程度で安定する(またはガベージコレクションが働く)が、Linuxプロセスの Resident Set Size (RSS) は右肩上がりに膨れ上がり、二度と下がらない。
結果として、`memory_limit` に到達するはるか手前で、OSのOOM Killerが発動する条件が整ってしまうのである。
—
現場で即効性を持つ:堅牢なメモリ管理設計ルール
この非対称なメモリ管理のメカニズムを踏まえ、実務のWebアプリケーションやAPI、CLIバッチにおいて守るべき設計ルールを明示する。
1. 「一括処理(バルク)」の限界を見極める
数百万件のレコードを一度にメモリ上に展開してはならない。必ずカーソルベース、またはチャンク分割によるイテレーション(ジェネレータ)を使用すること。
2. CLIバッチでは `gc_collect_cycles()` を過信するな
循環参照の解放コストは高い。結合度の高いループ内では、定期的にプロセスそのものを再起動する(Workerパターンの適切なライフサイクル管理)設計が最も確実なOOM対策となる。
3. 拡張モジュール(C記述)のリークに目を光らせる
ZendMMの管理外で `malloc()` を直接叩くサードパーティ製拡張モジュール(古いRedisクライアントや画像処理ライブラリなど)は、`memory_limit` に一切カウントされないままOSメモリを喰らい潰す。
—
実務で使える:安全なメモリ制御とチャンク処理のリファレンスコード
以下に、大量データを安全に処理し、かつメモリリークとOSへの過度な負担を防ぐための、プロダクション品質のPHPコードを示す。
/
class SafeDataProcessor
{
private int $chunkSize;
public function __construct(int $chunkSize = 1000)
{
$this->chunkSize = $chunkSize;
}
/
- 巨大なデータソースをジェネレータで少しずつ読み込み、処理する。
- @param \PDO $pdo
- @return \Generator
/
public function fetchInChunks(\PDO $pdo): \Generator
{
$offset = 0;
while (true) {
$stmt = $pdo->prepare(‘SELECT id, payload FROM large_table LIMIT :limit OFFSET :offset’);
$stmt->bindValue(‘:limit’, $this->chunkSize, \PDO::PARAM_INT);
$stmt->bindValue(‘:offset’, $offset, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
if (empty($rows)) {
break;
}
yield $rows;
// 参照を明示的に断ち切り、ZendMMの解放効率を高める
unset($rows);
$stmt->closeCursor();
$offset += $this->chunkSize;
// 定期的に明示的なGC発動(循環参照の即時回収)
// ※コストが高いため、チャンクごとのような粗い粒度でのみ実行する
if (($offset / $this->chunkSize) % 10 === 0) {
gc_collect_cycles();
}
}
}
/
- 処理を実行するエントリポイント
/
public function run(\PDO $pdo): void
{
// 現在のプロセスが消費している実メモリ(RSS)をメガバイト単位で取得
$initialMemory = memory_get_usage(true);
echo sprintf(“— 処理開始時の実メモリ消費量: %.2F MB —\n”, $initialMemory / 1024 / 1024);
$batchCount = 0;
foreach ($this->fetchInChunks($pdo) as $chunk) {
$batchCount++;
// チャンクごとのビジネスロジック
foreach ($chunk as $row) {
// 例: データの加工や外部APIへの送信
$this->processRow($row);
}
// ログ出力などでメモリの爆発がないか常時監視できるようにする
$currentMemory = memory_get_usage(true);
$peakMemory = memory_get_peak_usage(true);
echo sprintf(
“Batch #%d 完了. 現在メモリ: %.2F MB (ピーク: %.2F MB)\n”,
$batchCount,
$currentMemory / 1024 / 1024,
$peakMemory / 1024 / 1024
);
}
echo “— 全処理正常終了 —\n”;
}
private function processRow(array $row): void
{
// ダミー処理
// 重い文字列結合や配列の複製をループ内で行わないこと
unset($row[‘payload’]);
}
}
—
アーキテクトからの最終言
「動けばいいコード」を書くステージはもう過ぎた。PHPはスクリプト言語であるという手軽さの裏で、C言語ベースの重厚な仮想マシン(Zend VM)とLinuxのメモリ管理機構がせめぎ合う、極めて緻密なランタイム環境の上で動いている。
`memory_limit` は魔法の盾ではない。OSの挙動、ZendMMの特性、そしてプロセスのライフサイクルまでを俯瞰して初めて、深夜にPagerDutyが鳴り響かない、真に堅牢なWebシステムが構築できるのだ。
コードレビューの際は、単に「メモリ制限にひっかからないか」ではなく、「この処理のあと、OSから見たメモリは本当に解放される構造になっているか?」という視点を常に持って臨んでほしい。