はじめに
「画像を丸ごとメモリに読み込むとメモリ効率が悪い」と聞いたことはありましたが、実際どの程度差が出るかは測ったことがありませんでした。
今回は、受け取った画像を外部ストレージへ中継する小さなサーバをPHPで書き、メモリ使用量を比較してみました。
試した構成
ブラウザやアプリから画像を受け取り、S3やAzure Blob、Supabase Storageといった外部ストレージへ流すだけの中継です。
図にすると下記のイメージです。
この中継はファイルを保存しません。受け取って外部へ流すだけなので、「受信した中身をどう保持するか」がそのままメモリ使用量を決めます。
中継時の受け取りはraw bodyで検証
今回の検証で確認したいのは「受け取った画像データをPHPがメモリにどう持つか」です。
raw body(画像を加工せずそのままリクエストボディに入れる方式)なら、php://inputを一気に全部読む(buffer)か少しずつ読む(stream)かをコードで切り替えるだけなので、今回はraw bodyで検証しました。
補足①:multipart/form-dataでもメモリの問題がある場合
<input type="file">のフォーム送信はmultipart/form-dataです。raw bodyとは中身の構造が違い、PHPの扱いも変わります。
| raw body | multipart/form-data | |
|---|---|---|
| ボディの中身 | 画像バイト列そのまま | boundaryで区切られた複数のパート |
| PHPの自動処理 | なし | 自動解析して、ファイルは一時ファイル(tmp)へ書き出し、$_FILESを生成 |
| 既定のメモリ蓄積 | なし(読み方しだい) | なし(SAPIがディスクへ流す) |
| メモリ問題が出る場所 | file_get_contents('php://input') |
file_get_contents($_FILES['x']['tmp_name']) |
php://input |
使える(ストリーム源) | 使えない(空になる) |
押さえておきたいのは2点です。
multipartのファイルは、既定ではメモリに溜まりません。PHP(SAPI)が受信しながら一時ファイルへ流してくれるからです。メモリ爆発が起きるのは、そのtmpファイルを自分でfile_get_contents()で丸ごと読んだときです。そしてもう一つ、multipartのときはphp://inputが空になります。PHPが解析のためにボディを先に消費してしまうからです。
multipartを真にストリームで扱おうとすると、enable_post_data_reading = 0で自動解析を止め(受信前に効かせる必要があり、ini_set()では変えられません)、php://inputを生のmultipartとして自分でパースする、という重めの作業になります。
補足②:送信側の実装例
送信側(今回はフロントの`fetch`)はこう書きます。fetch('http://localhost:8080/index.php?mode=stream', {
method: 'PUT',
headers: { 'Content-Type': file.type }, // 例: image/jpeg
body: file, // File/Blobをそのままボディに
});
コードの違いはほんの数行
bufferとstreamの違いは、受信ボディの読み方を変えるこの数行だけです(計測用のコードは省いています。計測方法は後述)。
// buffer
$data = file_get_contents('php://input'); // 丸ごとメモリへ読む
$uploader->upload($path, $data, $contentType, strlen($data));
// stream
$in = fopen('php://input', 'rb'); // 開くだけ。あとは読みながら流す
$uploader->upload($path, $in, $contentType, $contentLength);
bufferはfile_get_contentsで全部読んでから、
streamはfopenで開いたハンドルのまま、$uploaderへ渡します。
なぜこれでメモリの差が出るのか
差はPHPの文字列の性質から来ます。
file_get_contentsはphp://inputを最後まで読んで中身を1本のPHP文字列として返します。
文字列は全バイトを連続した値として持つので、100MBのボディなら100MBの文字列がメモリに載ります。これがそのままmemory_limitに数えられます。
memory_limitは1リクエストで確保できるメモリの上限です。
これを超えて確保しようとすると、その場でFatal error: Allowed memory size ... exhaustedになって止まります。
file_get_contentsはボディを読みながら文字列を伸ばしていくので、100MBを一度に確保するのではなく、読み進める途中で確保量が64Mを超えた時点で落ちます。これがbufferの落ちる理由です。
streamは中身を文字列にしません。fopenはハンドルを開くだけです。
送るときにGuzzle(内部のcURL)が、小さな固定サイズのバッファへ少し読んでは送信、を繰り返します。このバッファのサイズは環境によりますが、数KB〜数十KB程度です。
同じバッファを使い回すので、メモリに載るボディは常に1チャンク分だけです。だからファイルが何MBでもピークは増えません。
計測方法
計測は、処理の頭でmemory_reset_peak_usageを呼び、最後にmemory_get_peak_usageでピークを読みました。
条件はmemory_limit=64M、送信先は/dev/nullへ捨てるダミーです(受信側のメモリだけを見るため)。
bufferはサイズに比例、streamは一定だった
| ファイル | bufferのピーク | streamのピーク |
|---|---|---|
| 1MB | 3.3MB | 2.3MB |
| 10MB | 12.3MB | 2.3MB |
| 50MB | 52.3MB | 2.3MB |
| 100MB | 落ちた(memory_limit超過) | 2.3MB |
bufferのピークはファイルサイズにほぼ比例して増えます。ピークはほぼファイルサイズそのもので、読み込んだ文字列がそのままメモリを消費しているのが分かります。
だから100MBではmemory_limitを超えて落ちました。
streamはどのサイズでも2.3MB前後で一定です。受信ボディを保持しないためで、サイズが増えてもピークが変わっていないことがわかりました。
おわりに
bufferはファイル分だけメモリを抱えるのでいつかmemory_limitに達しますが、streamは抱えないのでメモリ使用量がファイルサイズに依存せず一定になることがわかりました。
raw bodyに限らず、アップロードを扱うときは「全部を一度に読まず、流せるところは流す」を意識すると、サイズに振り回されにくくなるのかなと思います。