大容量ファイルをphpでダウンロードさせる方法

大容量ファイルをphpでダウンロードさせる方法の画像

これは結構苦労したので、ログとして残しておく。
phpでダウンロードさせる方法を調べると、ほとんどがreadfile()関数を使えと出てくる。
まあ、間違っていないんだけれども、僕の場合大容量ファイルのダウンロードでなにも考えずにreadfile関数をつかってしまい、サーバーメモリー馬鹿みたいに使用しまくるし、DiskIO負荷は10MB/s にまでなるしで、ヤバい状況になった。

事の顛末はこんな感じ、
ダウンロードさせるなら readfile関数 使え! ⇒ ほー、OK。使ってみよう。 ⇒ 実装してみた。でも20MB超えるファイルをダウンロードすると失敗するよ? ⇒ php.ini ファイルの post_max_size, upload_max_filesize, memory_limit の設定メモリを増やせ! ⇒ 分かった。全部1GBに設定してみたわ。 ⇒ お、正常にアップもダウンもできたわ。⇒ 一日経過。サーバーが悲鳴を上げてるやんけ!なんとかせな!
っていう感じで、なんとかすることにしました。

本来のソースから随分簡略化しているけど、当初動かしていたphpソースが以下の感じ。

<?php
  header('Content-Type: application/zip');
  header('Content-Disposition: attachment; filename="'.basename($path).'"');
  header('Content-Length: ' . filesize($path));
  readfile($path);
?>

これで動くこたー動くんだけどね。それはもうファイルサイズが1MBとかの小さいファイルを一か所からダウンロードさせるとかなら別にいいんですけど、今の時代そんな小さいファイルあまりないでしょ。
1ファイルが500MB以上の大容量ファイルなのに、readfile関数をそのままつかっちゃったからもう大変。
readfileってそのまま、メモリ展開しちゃうみたい。
500MBのファイルダウンロードさせるために、バッファをそのまま500MB確保しちゃう。
複数人同時にダウンロードされたら、それこそ余裕でメモリー使用量がGB単位になる。そりゃサーバー死ぬわ。
ネット上では安易にreadfile関数使ったらいいと書いているの多いけど、もう時代にそぐわない。他にreadfileの使い方あるのかもしれないけど。どっちにしろそのまま使わないでください。ヤバいから。

なので、readfile関数をやめて、fread関数を使って、以下のように修正しました。

<?php
  header('Content-Type: application/zip');
  header('Content-Disposition: attachment; filename="'.basename($path).'"');
  header('Content-Length: ' . filesize($path));
  
  // out of memoryエラーが出る場合に出力バッファリングを無効
  while (ob_get_level() > 0) {
    ob_end_clean();
  }
  ob_start();
  
  // ファイル出力
  if ($file = fopen($path, 'rb')) {
   while(!feof($file) and (connection_status() == 0)) {
     echo fread($file, '4096'); //指定したバイト数ずつ出力
     ob_flush();
   }
    ob_flush();
    fclose($file);
  }
  ob_end_clean()
?>

サイズが大きいファイルも扱う場合は、readfile関数ではなくfread関数を使って少しずつ読み込んで出力を繰り返すというのが定石みたい。上記の例では4096 byteずつ読み込みしている。
修正してから、サーバーは悲鳴をあげることなく平常運転。ダウンロードも出力を区切っているからといって遅いわけでもなくサクサクで問題ありません。