fbpx

メニュー

nginx + PHP-FPMで巨大なファイルをダウンロードさせる

高橋文樹 高橋文樹

この投稿は 11年半 前に公開されました。いまではもう無効になった内容を含んでいるかもしれないことをご了承ください。

さて、つい昨日Tips記事を収益化できないかなという邪な感情から「WordPressで管理画面以外から投稿させる機能を作る」というエントリーをぶち上げたのですが、コンテンツ公開後、コーディング画面を録画したビデオがダウンロードできないという報告を受けてしまいました。

端的にいうと、270MBという巨大なファイルだからダウンロードできなかったわけです。

この動画がダウンロードできなかった
この動画がダウンロードできなかった

一応有料で販売しているものなので、こりゃまずいと直しにかかったのですが、異常に時間がかかりました。これはその闘争の記録です。

Apache + mod_phpで巨大ファイルをPHPでダウンロードさせる

さて、今回のように、サーバにぽこっとファイルを置いておくだけではなく、PHPでユーザーにアクセス権があるかどうかを確認してからファイルをダウンロードさせる場合、PHPでファイルを出力させる必要があります。

ファイルの場合は普通ヘッダーを出力します。

//ファイルへのパス
$path;
//IEの場合はキャッシュさせないようにしないとSSLでエラー
global $is_IE;if($is_IE){
	header("Cache-Control: public");
	header("Pragma:");
}
header('Content-Type: video/quicktime');
header('Content-Disposition: attachment; filename="'.basename($path).'"');
header("Content-Length: ".filesize($path));

で、あとは本体を出力するだけなのですが、ごく小さなファイルの場合はこんな風にやればオッケーです。

echo file_get_contents($path);

ところが、今回のように動画などのサイズが大きいファイルの場合は”Allowed memory size of xxxxxx bytes exhausted”などと表示され、メモリ使い過ぎでPHPが止まってしまいます。これを防ぐためには、ファイルをちょっとずつ読み込んで出力というのを繰り返すのが定石です。

$handle = fopen($path, "r");
while(!feof($handle)){
	//指定したバイト数だけ出力
	$bytes = fread($handle, 1000 * 1024);
	echo $bytes;
	//出力
	flush();
	//1秒休む
	sleep(1);
}
//ファイルを閉じる
fclose($handle);
//終了
exit;

PHPは基本的に出力すべき内容をいったんバッファと呼ばれる領域に保存してから一気に出力します。これをレストランに喩えると、寿司ではなく定食ですね。寿司のように注文があるたび出すのではなく、定食のように全部揃ってから出すというわけです。動画のように巨大なファイルの場合、読み込みが終わるまでけっこうな時間がかかるので、それを全部待っていると時間がかかりすぎてしまいます。そんな寿司屋行きたくないですよね。したがって、flush()を使ってPHPを寿司屋モードに変える必要があります。

ローカルもApache + mod_phpだったのでこれで動いたのですが、本番環境では動きませんでした。なぜか”Allowed memory size〜”のエラーが出てるんですね。

NginxもPHPも出力バッファがあるんだって

まず環境の違いで動かなかったので、Nginxを疑って色々ぐぐったのですが、そこで”PHP, Nginx, and Output Flushing“というエントリーを見つけました。このエントリーによると、NginxはバックエンドのPHP(FastCGIでもPHP-FPMでも同じだと思いますが)のすべての挙動を一度バッファリングするらしいです。

PHPもバッファリングを行うのですが、そんなに大きなサイズではないので設定をいじる必要はなさそうです。今回の場合、ダウンロードリンクはSSL環境だったので、そこでだけバッファリングを極端に小さくしました。

location ~ \.php$ {
    include /etc/nginx/fastcgi_params;
    fastcgi_pass  127.0.0.1:9000;
    fastcgi_index index.php;
    fastcgi_param  SCRIPT_FILENAME  /path/to/public_html/$fastcgi_script_name;
    fastcgi_read_timeout 600;
    fastcgi_buffer_size   1k;
    fastcgi_buffers       128 1k;  # up to 4k + 128 * 4k
    fastcgi_max_temp_file_size 0;
    gzip off;
}

これで直るかなーと思いましたが、Allowed memory size〜のエラーが出てダウンロードできず。

PHPはflushだけじゃ出力バッファまでは出さないんだって

ここから数時間をググって過ごしたのですが、おなじみDo You PHP はてなで「出力バッファとflush()・ob_flush()」というエントリーを発見しました。これによると、「flushが動作する条件は出力バッファがないこと」とあるので、そもそもflushだけじゃダメだったんじゃないかと思うようになりました。そこでこんな風に直します。

//出力バッファを一度出力
ob_end_flush();
ob_start('mb_output_handler');
//ファイルを読み込む
$handle = fopen($path, "r");
while(!feof($handle)){
	//指定したバイト数だけ出力
	$bytes = fread($handle, $per_size);
	echo $bytes;
	//出力
	ob_flush();
	flush();
	//1秒休む
	sleep(1);
}
//ファイルを閉じる
fclose($handle);

これで無事ダウンロードできるようになりました。flushとob_flushはセットということを覚えておこうと思います。

ログアウトできなくなる

さて、ダウンロードできるようになって嬉しーなと思った矢先、なんとログアウトできなくなるという事態に見舞われました。”upstream sent too big header while reading response header from upstream”というエラーが出ていたので、おそらくヘッダーがデカ過ぎだぞということだと思います。

デカイと言っても、そんなにでかくないんじゃないかと思うのですが、たしかに先ほどバッファーの値を1kとか小さい値にしてしまっていました。なので、nginx.confの設定を見直し、これだけあればいいだろうという設定にしたのがこちら。バッファサイズの指定をなくしています。

location ~ \.php$ {
	include       fastcgi_params;
        fastcgi_pass  127.0.0.1:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME /path/to/server$fastcgi_script_name;
	fastcgi_read_timeout 600;
        fastcgi_max_temp_file_size 0;
	gzip off;
}

これでログアウトもダウンロードもできるようになり、一安心です。しかし、nginxはまだ情報少なくて大変ですね。

追記@20120724

コメント欄に書いてありますが、NginxにはX-Accel-Redirectという非公開領域にバイパスを通すような仕組みがあるようです。PHPで認証かましてからダウンロードさせるならこの方法がいいみたいですね。Apacheにも同様のmod_xsendfileといのがあるようです。詳しくはググっておくんなまし。

なお、この記事で書いたのはWordPressプラグインだったので、サーバ環境が決定できない以上使えませんが、CakePHP + Nginxなどでサイトを構成している場合はX-Accel-Redirectの方がシャレオツです。

すべての投稿を見る

高橋文樹ニュースレター

高橋文樹が最近の活動報告、サイトでパブリックにできない情報などをお伝えするメーリングリストです。 滅多に送りませんので、ぜひご登録お願いいたします。 お得なダウンロードコンテンツなども計画中です。