LaravelとNginxの快速仕立て、キャッシュを添えないで…

タグ: Laravel4   Laravel5  

注意: 前の記事は完全に削除しました。同じ記事をタイトルを変更し公開します。

記事を書く前にNginxをいろいろいじっており、その時fastcgiのキャッシュを使うとクッキーも含まれることに気づいていました。この対策をしなくてはと思いながらも、別の要件が解決できたため、この記事を作成しましたが、この問題を解決するのを忘れていました。

fastcgiキャシュは、fastcgi側から送られてくる情報をそのまま保存するだけです。Laravelはセッションクッキーを特に意識しなくても付けます。いくら暗号化されているとはいえ、クッキーを含んだキャッシュが返されると、その保存されているセッション情報が他のユーザーに届きます。これはセキュリティリスクです。

そのため、Nginxのデフォルトでは、クッキーが含まれている場合、キャッシュしないようになっています。それではキャッシュできないため、無視する方法をこの記事で紹介しています。

まだ、解決する方法を見つけていませんが、一度前記事を読んだ方がそのまま利用するといけませんので、これを書き加え再掲載します。

フォーム入力やユーザー認証が無いシステムの場合は、セッションクッキーの内容、クッキーが添付されることに意味がありませんので、そのまま使用できるでしょう。

逆に認証が必要なシステムでは、この記事に書かれているfastcgiを利用したキャッシュをそのまま利用しないでください。認証が必要なくても、フォームを使用する場合に入力値をセッションに保存する場合(殆どに当てはまると思います)も、十分な考慮が必要です。

Laravelはバージョン4になり、段々と高機能になり続けています。Laravel5でも、さらに進化を遂げます。また、基本的な方針として「車輪の再発明」を行わないため、積極的に既存のComposerパッケージを採用しています。

これにより、速度が出ないという指摘が時々あります。ええ、いくら必要なサービスの準備だけが実行され、不必要な機能は実行されないようにサービスプロバイダー機構を用意しても、高機能であるために、どうしてもフレームワークの起動が遅くなるのです。

特にベンチマークに使用されるコードは大抵ハローワールドレベルです。初期起動が重いものに不利です。そして通常、比較対象の各フレームワークに精通した開発者が、高速化のための知見を応用したコードではありません。

Laravelフォーラムなどで頻繁に書き込まれるソフトな言い訳は、「時間には実行時間もあるが、開発時間もある。Laravelでの開発時間は早い!」というものです。ハードな質問は、「コードの実行スピードを重視するなら、そもそもなぜPHPを選ぶのか?」です。;)

では、そんなに遅いLaravelを選んでいる多くの開発者は間抜けでしょうか?

いいえ。多くのアクセスが予想されるサイトなら、PHP上で動くフレームワークとサーバー一つだけで全てのアクセスをさばこうなんて、初めから考えていないからです。フレームワークの速度なんて、一要素でしかないと理解しているからです。(だって、ねえ。PHPより遅い処理系を使っても、大型サービス作れるんですよ。Wordpressだけでも多くのアクセスをさばいているサイトは、たくさんあるんですからね。)

最近のWebサーバーの流行りはNginxです。私が使っているサイトも徐々にNginxで動作しているサーバーに移行しています。Laravel界でもNginxをサーバーに使用する記事が多く、逆にApacheで使用するため参考になる記事は、ほとんど見つかりません。

そこで、高速化のため、一番多く使用されるキャッシュ機能について解説します。「Laravelが遅い」なんてことを真に受けて心配するのは、たぶん初心者の方でしょうからね。(こういう記事を書くと、続いて「Laravelはキャッシュ使わないと使い物にならないほど遅いのか」と馬鹿なツイートする人が出るのですが、まあ、放っておきましょう。)一応、優しく書きますが、Laravelの知識は中級レベル、Nginxの設定は行ったことがある人向きです。(記事の最後に、設定例を紹介しますが、Nginx初心者の方はキャッシュを使わない、もっとシンプルな設定から使いはじめることをお勧めしておきます。)

対象はNginxのキャッシュ絡みの設定と、一部はブラウザキャッシュですが、Laravelに関する部分は最後になります。まあ、他の「早い(?)」フレームワークでも応用可能です。Laravel自身が持っている様々なキャッシュ機構には触れません。

Nginxの基本設定

実稼働サーバーはLinuxで稼働しているものが一番多いようです。最近はWindowsサーバーも肉薄してきているようです。Nginxを動かすとなれば、ほぼLinux確定でしょうから、これを前提条件としましょう。ちなみに、UbuntuもLinuxですよ。

Nginx自身の設定ファイルは通常、/etc/nginx/nginx.confです。これを先にチェックしましょう。基本的な部分の設定が異なっていると、いくら高速化しようとしても、他の部分が引っかかります。ベテランさんには当たり前の部分ですが、初心者向きなので、速度に関わる基本的な設定を確認していきましょう。(本当は全ディレクティブ関係するんでしょうけど、まあ基本的なものだけで許してください。)

worker_processes auto;

実際にリクエストを受け、レスポンスを返す役割のプロセスをいくつ動作させるかを指定します。通常はCPUの数を指定します。'auto'で自動的にCPUの数を設定してくれます。(論理コアでなく物理コア数で指定することを進めている書籍があります。)

この値はCPUの数以上指定すると、プロセス間のロックが発生し、そのために遅くなります。要は、例えば荷物をさばくのに2人分のスペースしか無いとしたら、いくら発送する荷物があり、担当の従業員が3人いても、スペースが空くまで一人は待つことになり、2人分の作業しかこなせないどころか、ごちゃごちゃして作業効率が落ちるというわけです。えー、分かりづらいですか?

つまり、プログラマーが3人いて、コンピューターが2台しかなければ、同時にプログラミングできるのは3名でなく2名ということです。こちらのほうが分かりやすいですか?デスマーチが始まったら、人数を投入しても…

ただし、NginxをWebサーバーでなく、プロキシなどとして使用する場合は、プロセスの数を増やしておくことを勧めている情報もあります。Webサーバーとして動作させる場合は、CPUの数にするのが原則と覚えておきましょう。

events {
    ...
    use epoll;
}

多重I/Oを取り扱う手法の指定ですが、詳細は検索してください。ここには書ききれませんし、私もシステムコールを直接使用しないので、コンセプトを知っているだけです。

Linuxで動作させるなら、epollを指定します。デフォルト値としてselectが指定されている場合もあるのですが、このselectがLinuxの機構をそのまま使っているのだとするば、使用できるファイルディスクリプタの数値に制限があり、さらに取り扱う数が多くなると効率が低下します。epollは無制限に取り扱えますし、効率も良いですので、アクセス量が多い場合は、ほぼ必須です。

Windowsの場合はselectしか使用できません。MacOSの場合はkqueueを使用するようです。

sendfile on;
tcp_nopush on;
# tcp_nodelay on;

細かい話が嫌いな方は、Nginxで何かがうまく行かなかったら、この3つの設定をコメントにすることだけを覚えておいてください。

sendfileは、コンテンツファイルの読み込みと、レスポンスの送信にsendfileシステムコールを使用するための設定です。sendfileはread後writeするよりも高効率です。

tcp_nopushはパケットの取り扱いオプションにTCP_CORKを指定します。OSのカーネルレベルでパケットを遅延させ、まとめて大きなパケットとして送信するため、送信効率が上がります。

tcp_nodelayも同様に、ソケットのTCP_NODELAYオプションを付けます。こちらは小さなパケットを待つことなく、そのまま送信するオプションです。待ちがないので早くなりますが、パケット数と送信量が増えます。

Webサーバーは大量データの送信を行うものですので、sendfileにtcp_nopushを組み合わせるのが通常はベストでしょう。

これ以外にも、高速化の要素があります。例えばアクセスログを取らなければスピードは上がりますが、障害時や攻撃を受けた場合に原因追求ができませんので、原則は取得しましょう。

ほとんどの設定値は、サーバースペックとか、そのマシンがWebサーバー専門なのか、他の用途兼用なのかとか、状況により異なります。実測しなければ、最適値は見つけられ無い項目も多いです。チューニングは高度な技術です。チューニングだけを専門に行う会社もあります。早くなる代わりに、よりメモリを使用するオプションもたくさんあります。何もかも最大にして、メモリ不足になり、マシンが不安定になっては元も子もありません。

技術書で見かける数値は、ほどほどなものが多いので、理解しづらい項目ではそのまま利用しましょう。

もし、あるサイトで極端な設定値が紹介されていたら、十分に気をつけます。いくつかのサイトをあたり、比較して見当をつけることもできます。全てを知るには時間が必要です。素早く事を片付けるためには、ほどほどで良い部分は、ほどほどな値を推測する方法と勘を身に着けましょう。

gzip

gzipはサーバー側のファイルをそのまま送らずに、圧縮してからブラウザに送信する機能です。圧縮するためにサーバーのCPUを使いますが、転送量が減らせるため、レスポンスをサーバーが受け取るまでの時間を減らすことができます。

gzip on;

テストに作成した数百バイト程度のファイルではなく、CSSフレームワークで含まれている10K以上あるCSSやJSファイルを転送するなら、このオプションを付ける/付けないでは転送時間に1.5倍位の差が付きます。もちろんonにしたほうが早くなります。

このgzip機能は、対象ファイルにアクセスがあるたびに圧縮を行います。その圧縮にかかる時間+小さくなったファイルを転送する時間+解凍時間が、大きいままのファイルを転送する時間より一般的には早いのです。転送時間は回線速度と転送の距離に影響を受けます。回線が細く、距離が長いほど、転送時間は長くなりますので、圧縮にかかるCPUの消費時間は問題ではなくなります。手前のマシンではローカルに仮想環境を立て、その中のNginxと通信する場合でも、gzipを使用するほうが早いです。

ところが、ファイルが小さい場合は、圧縮すると逆にファイルサイズが大きくなります。CPU消費時間+転送時間が、そのまま送った場合より長くなることがあります。そこで、圧縮して送信する対象の最低サイズを指定できるようになっています。

gzip_min_length 3k;

また、圧縮は「どの程度圧縮するか」を指定できます。レベル1は少ししか圧縮しませんが、時間はかかりません。レベル9は小さく圧縮しますが、時間がかかります。

gzip_comp_level 2;

とことんこだわるなら、この圧縮対象の最低限度サイズとレベルを調整し、サイトにベストな組み合わせを探します。ただ、gzip自体のon/offによる差ほど大きく改善できることは先ずありません。レベルを上げてもさほど圧縮率は上がりませんが、圧縮時間はかかってしまいます。ここでも「程々」にしておいたほうが良いかと思います。

圧縮に関してもう一つ考えておくべきことは、圧縮されたファイルを解凍するときに、ブラウザ側のマシンでCPUが動作するということです。デスクトップやノート型パソコンでは問題にはならないでしょうが、バッテリーがより制限されているスマホの場合、余計に電力を消費させることになります。これについては、余り問題にならないと言うレポートもあります。ですから、スマホからのアクセスが多いサイトを開発する場合は、より調査してください。

大抵の場合、gzip関係は以下のようになるでしょう。

gzip on;
gzip_disable msie6;
# gzip_vary on;
gzip_proxied any;
gzip_comp_level 2;
gzip_min_length 3k;
# gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

gzip_typesが圧縮の対象mimeタイプです。ここで指定されている以外のタイプは圧縮されません。html/textは指定しなくても、デフォルトで対象になっています。

gzip_buffersは指定していません。動作コンピューターのアーキテクチャ(32/64bit)により、"16 8K"か"32 4K"になります。どちらの場合でもデフォルトは128Kで、これで通常十分です。指定を省略し、デフォルトに任せましょう。

gzip_http_versionはgzipの対象のHTTP最低バージョンです。HTTP1.1にしてあります。うまく動作しない場合1.0にすると良いようですが、今更感もあります。何が問題になるのか分かりませんが、1.0ではなく、1.1がデフォルトになっているのには意味があるのでしょう。

最近のnginxバージョンでは、gzip_disableはmsie6を指定すれば、IEの1から6を指定したことになるようです。もしかしたら、1から5までを使っている環境なんて存在しないという前提なのかも知れませんね。最も、それほど古いブラウザーを使用している人はさすがにいないでしょう。

gzip_varyはこの場合、たぶんプロキシサーバーに対して圧縮を受け付けるか、付けないかを知らせるために利用されるのでしょうが、プロキシを建てない場合は無用ですから、付けていません。高速化のため、後述のfastcgiキャッシュではなく、昔ながらのプロキシを建ててのキャッシュを利用する場合は、一考が必要でしょう。

gzip_static

gzipの欠点は、毎回圧縮することです。そのたびごとにCPUを多少消費し、圧縮されるまで送信がその分遅れます。(実際は、さほど時間はかかりません。gzipは複雑な圧縮方法を取りません。)

初めから、圧縮したファイルを用意し、それを送ることができれば、より効率的になります。それを実現してくれるのが、この設定です。

gzip_static on;

圧縮前のファイルのディレクトリーに、拡張子.gzが付いたファイルがある場合、そちらを優先して送信します。例えば、cssディレクトリーのdefault.cssファイルへアクセスがあり、css/default.css.gzファイルが存在するならば、gzファイルの中身が送信されます。

もちろんリクエストでgzipが要求されていないのであれば、通常の未圧縮ファイルを送信します。ですから、両方用意しておく必要があります。

このオプションは便利ですが、紹介されている記事が少ないのは、コアに含まれている機能ではないからです。Nginxでは拡張機能を動的に付けたり、外したりできません。コンパイル時のみ指定したり、解除したりできます。つまり、コンパイルして自分でnginxの実行ファイルをmakeする必要がありました。

ところが、例えばUbuntu14.04の場合、Nginxのパッケージは含まれている拡張により4種類用意されていますが、最小構成のパッケージでも、この機能は含まれています。また、Nginxは複数のLinuxディストリビューションに最新パッケージのリポジトリーを用意していますが、そちらを利用する場合でも、この機能は含まれています。ですから、今ではお手軽に使用できる機能になっています。

gzip_staticの対象は、gzipと同様で、gzip_typesディレクティブによりmimeタイプにより指定されます。gzipと同時に使用することもできます。gzファイルがある場合はそれが利用され、存在しない場合は元のファイルがその都度圧縮されます。

gzip_staticはgzipとは別に動作させられます。つまり、自動的に圧縮されるgzipを行わず、gzip_staticだけをonにして利用することも可能です。

gzファイルに変換するには次のコマンドが使用できます。

find 対象ディレクトリー -name '*.css' -exec gzip -3 -kf {} +

この場合、対象ディレクトリー下の拡張子がcssのファイルを再帰的に見つけ、gzip -3 -kfを実行します。gzipが圧縮を行うコマンドです。数字のオプションは圧縮レベルです。-kオプションは元のファイルをそのまま残す指定です。これを指定しないと、元ファイルは削除されます。-fオプションは、gzファイルが存在する場合、無条件に上書きする指定です。

これを応用すれば、変更される機会が少ない、Laravelの固定ページをhtmlとgzファイルに変換しておき、高速化できますね。

Route::get('fix/sample.html', [ 'after'=>'nocache', function() 
{
    # ビューの出力結果を取得
    $html = View::make('fix.sample')->render();

    # htmlファイルとして書き出す
    File::put(public_path().'/fix/sample.html', $html);

    # gzファイルとして書き出す
    $fp = gzopen(public_path()."/fix/sample.html.gz", "wb3");
    gzwrite($fp, $html);
    gzclose($fp);

    # ビューの出力結果を200レスポンスで送り返す
    return Response::make($html, 200);
}]);

この例は、public下に直接作成しています。ですから、一度動作するとhtmlファイルとgzファイルを削除しない限り、このルートにアクセスは届かなくなります。(実際にはtry_filesディレクティブの設定の影響を受けます。通常は固定URIのチェックをindex.phpより先に記述しますので、Laravelに制御が移る前に、存在するファイルがレスポンスとして送信されます。)編集用のルートと表示のURLは分けたほうが簡単ですね。

'nocache'というafterフィルターについては、後ほど説明します。

ブラウザーキャッシュ

いくらgzipを活用しても、早々内容の変わらないリソースに毎回アクセスされるのは無駄ですし、アクセスの時間分だけ表示が遅れます。それを防ぐため、ブラウザには、アクセスしたファイルへ何度もアクセスしなくても済むように、内容を保存し、アクセスが必要になったとき、ブラウザが保存している内容を使用する仕組みがあります。こちらもキャッシュと呼ばれます。

レスポンスに含まれるヘッダー情報や、ブラウザの種類や個別の設定による違いなどもありますが、以下の方法でブラウザーへキャッシュするように指示することができます。指定は、仮想ホストの設定ファイルで行います。

    location = favicon.ico {
        access_log off;
        log_not_found off;
        expires max;
    }

    location ~ \.(jpg|png|gif|swf|jpeg|ico)$ {
        access_log off;
        log_not_found off;
        expires 1d;
    }

    location ~ \.(css|js)$ {
        charset  UTF-8;
        access_log off;
        expires 8h;
    }

faviconと、画像ファイル、css/jsとに分けて指定していますが、どの程度の期間ブラウザーキャッシュを効かせるかは、状況により異なります。バグが含まれている可能性があるリリース直後は短めにしておき、安定したのが確認できたら長くするのが理想です。

実際にどのようなヘッダーが付加されるかは、Nginxがその他の設定を考慮し、よしなに計らってくれます。

ファイルのアクセス情報のキャッシュ

Nginxがアクセスするファイル情報のキャッシュも行えます。ドキュメントにはファイルディスクリプタを初め、サイズと更新時間などが保存されるようです。一定期間の間に連続してアクセスがあるファイルの情報を保持し続けることで、ファイルのオープン/クローズのオーバーヘッドを無くすためのキャッシュです。

open_file_cache          max=1000 inactive=20s;
open_file_cache_valid    60s;
open_file_cache_min_uses 1;
open_file_cache_errors   on;

maxは最大いくつのファイルの情報を保持するかです。inactiveで指定した時間の間に、open_file_cache_min_usesで指定した回数のアクセスがない場合、その情報は破棄されます。ドキュメントの記述が読み取りづらいのですが、open_file_cache_validは、情報を保持し続けるか、無効にするかの判定を行う間隔のようです。

ファイルが存在しないとか、権限がなくて開けないというエラー情報も保持するには、open_file_cache_errorsをonにします。

これらの値が効率化に有効だとしても、特定のどこかが早くなるというよりは、サーバー全体のレスポンスが早くなるものでしょうから、最適値を探すのが難しいですね。大抵の場合は問題にならないでしょうが、キャッシュされることでファイル情報の更新が設定した時間だけ遅れて認識されるわけです。アクセスが集中するファイルの情報は、ずっとこのキャッシュに残り続けるわけです。そうかといって、チャック間隔を頻繁にセットすれば、それはサーバーへの負荷になります。ここで紹介した60秒ごとにチェック、最低一回のアクセスで保持という設定はデフォルトです。まあ、この程度で良いのかなと思います。

Nginxを利用していて、時々不可解なレスポンス待ちがある場合、それがファイルのオープン/クローズが関わっているのであれば、この指定で解消される可能性があります。

これで、固定的なリソースの部分でいろいろキャッシュを活用出来ました。続いてPHPが生成する、動的なページのキャッシュに移ります。

fastcgiキャッシュ

さて、Laravelに絡む部分はここからです。

Nginxの情報を探すとプロキシを別段に建て、その全面のプロキシで、背後のHTTPサーバーからの内容をキャッシュする手法がよく紹介されています。この方法を利用することで、多くのアクセスを捌けるとなっています。

ほとんどの場合、同一サーバーの中でNginxを利用し、プロキシとHTTPサーバーを立てる例になっています。

NginxでPHPを使用する場合、PHPのFPMを利用します。これは予めPHPの動作プロセスを起動し、準備しておく方法です。これにより、PHPの起動時間が省略できます。ある意味、PHPの動作サーバーが存在しているようなものです。(さほど、遠からずです。)すると、前面がHTTPサーバーで、背後にPHPが動いているイメージです。

ですから、HTTPサーバーがPHPのサーバーから受け取ったレスポンスをキャッシュすれば、従来のプロキシサーバーと同様に、毎回比較的重いPHPへ処理を回すことなく、決まった時間の間は同じレスポンスを使いまわすことで、高速化できます。この仕組みがfastcgiキャッシュです。

例えば、LaravelなどのPHPフレームワークで、動的に情報をアクセスごとに更新できるとしても、変化のないページは多いと思います。そうしたページヘのアクセスはキャッシュして一定期間同じページを返せば、PHPの動作時間が省略でき、キャッシュからのレスポンスはPHPによる生成より早いことが多いですから、その分レスポンスが早くなります。

同じような機能を実現するために、Laravelの豊富なキャッシュを利用することもできます。もちろん、共有サーバーなどでは自分でサーバーの設定は変更できませんので、Laravel側で頑張ります。もし、NginxでもApacheでも、キャッシュをうまく利用できれば、PHPへ制御を渡す前に、サーバーに面倒を見てもらえますので、PHPを通さない分、より高速化できます。

まずは単純に、PHP FPMに制御が渡ったら、それらを全部キャッシュする設定を考えましょう。

Nginx設定

nginx.confに、以下の設定を追加します。(HTMLコンテキストです。)

fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=WHOLE:10m inactive=12h max_size=500m;
fastcgi_cache_key "$scheme:$request_method:$host:$request_uri";
fastcgi_cache_use_stale error timeout invalid_header http_500;
fastcgi_cache_methods GET HEAD;

実際は、fastcgiキャッシュのディレクティブは、HTMLコンテキストでも、serverやlocationコンテキストでも指定できます。これらの数値は各仮想ホスト間共通で良いだろうという判断で、nginx.confの内容として紹介しますが、もちろん皆さんの考えに従い、自由に指定してください。

アクセスが合った時に、それが既にキャッシュされているかを調べるため、ハッシュキーが生成されます。どの情報を元にハッシュを生成するかを指定するのが、fastcgi_cache_keyディレクティブです。この例では最後に$request_uriを指定していますが、これにはクエリー文字列も含まれますので、同じURIでもクエリー文字列が異なれば、別のアクセスと判断されることになります。

fastcgi_cache_pathディレクティブで先ず指定しているのは、キャッシュされたPHP FPMからのレスポンスを保存するディレクトリーです。levelsで指定するのは、ディスクへのアクセスを早くするためののディレクトリー階層を表します。上記の場合、キャッシュの一番最後の文字でディレクトリーが先ず作成され、最後から二番目と三番目の文字でその下の階層のディレクトリーが作成されます。その下にキャッシュ値をファイル名としたレスポンスが保存されます。これは、一度設定し、実際に見て確認すれば、直ぐに理解できます。

key_zoneは2つに分かれています。左側が名前です。これは実際にキャッシュを指示する場合にどこに保存するかの識別子になると同時に、共有メモリに付けた名前にもなります。key_zoneの右側は、共有メモリをどの程度使用するかです。この例の場合20Mバイト使用します。1Mバイト当り約8000個のキャッシュ情報を保存できます。

Inactiveは指定した期間にアクセスがない場合、そのキャッシュは破棄されます。

max_sizeに指定したキャッシュサイズが最大値で、これに達するとアクセスの古いデーターから削除されます。max_sizeを指定するとそれを監視するためのプロセスが作成されます。そのプロセスの動作タイミングを指定するオプションもありますが、今回は省略です。

fastcgi_cache_use_staleディレクティブはドキュメントが読み解きづらいのですが、多分、指定した状態がfastcgi側(今回はPHP FPM)から返された場合、既に保存してあったキャッシュを代わりに返す機能でしょう。指定可能なのはerror、timeout、invalid_header、http_500、http_503、http_403、http_404と、機能を利用しないoffです。デフォルトはoffです。

fastcgi_cache_methodsにはGETとHEADを指定していますが、この値がデフォルトです。わざわざリクエストがPOSTであるかチェックして除外しているサンプルがWeb上には多いのですが、このディレクティブにより、デフォルトではGETとPOSTしかキャッシュされないため、無駄なチャックです。

仮想ホスト設定

各仮想ホストの設定の中で、php拡張子を処理するlocationの中に、次の内容を追加します。

fastcgi_cache WHOLE;
fastcgi_cache_valid  200 301 302 8h;
fastcgi_cache_valid  any 1h;

fastcgi_cacheはfastcgiが送ってくる結果をキャッシュする宣言です。ここで指定するのは識別子に使用する共有メモリに付けた名前です。

あとは、どのHTTPコードが返ってきた時にキャッシュするか、そのレスポンスはどの程度の長さ保存するのかをfastcgi_cache_validで指示します。ここに記述していないコードは、キャッシュの対象になりません。2つ目のfastcgi_cache_validで指定しているanyは、どんなものであってもキャッシュの対象にします。

この例のまま使用すると、エラーページもそのまま保存されてしまいます。サンプルのためにanyを乗せましたが、基本的には外しておいたほうが良いでしょう。

この指定を仮想ホストごとに行うことで、キャッシュの有る無し、どのレスポンスを対象にして、どの程度キャッシュするのかなどをコントロールできます。

もちろん、この設定が全仮想ホスト共通であれば、nginx.confに書くこともできますし、キャッシュを保存するディレクトリーを分割したければ、fastcgi_cache_pathを仮想ホストの設定で行うなど、自由に使い分けてください。

さて、ここまでで、Laravelなどのフレームワークなどを使用せず、生PHPでさくっと作ったシステムであればキャッシュされるでしょう。ところが、フレームワークは「グッド・プラクティス」の集まりであり、それがアダとなっているために、このままではキャッシュされません。続いて、Laravelのレスポンスをどうやってキャッシュするかを見ていきましょう。

Laravelとキャッシュ

Laravelが作成するキャッシュの中身を確認してください。ブラウザーで開発ツールの類を起動し、レスポンスのヘッダーを確認してください。

通常、PHPでレスポンスを生成するということは、その内容を動的にしたい意志があります。固定で良ければ、htmlを使えば済むわけです。動的な内容を志向しているので、キャッシュされないほうが良いわけです。ですから、Laravelのレスポンスヘッダーには、Cache-Controlにno-cacheが付けられています。これにより、明示的にキャッシュされないように指定してあります。

また、意識をしていなくても、裏でLaravelはセッションをきちんと保存しています。セッションを維持するためのセッションクッキーがレスポンスに付けられています。種類はなんにせよ、クッキーがレスポンスにセットされているということは、その内容でレスポンスのコンテンツに変更が起きる可能性があるとNginxは考えます。そのため、クッキーがセットされているとfastcgiキャッシュは行われません。

Laravelは親切で、明示的なキャッシュの拒否と、セッション維持のクッキーを付加するという2つのベスト・プラクティスを実行してくれているのですが、これキャッシュとなると逆に迷惑行為になってしまいます。

そこで、この2つにより、キャッシュが妨げられないように指示する必要があるわけです。

fastcgi_ignore_headers Cache-Control Set-Cookie;

これにより、Laravelが送信する、GETとHEADに対するレスポンスは全部キャッシュされるようになります。

これで一安心…いや、そうは問屋が卸しません。

よくある以下のようなフォームを考えましょう。

Route::get('user/add', function() {
        # 入力フォーム表示
        return View::make('user.create');
});

Route::post('user/add', function() {
        $rules = [
                'username'=>['required'],
                'password'=>['required'],
        ];

        $inputs = Input::only(['username', 'password']);

        # バリデーション実行
        $val = Validator::make($inputs, $rules);

        if ($val->fails()) {
                # バリデーション失敗
                return Redirect::to('user/add')
                        ->withErrors($val)
                        ->withInput();
        }

        return Redirect::to('/');
});

POSTの処理はLaravel4スタイルですが、行っていることが見えるので、よしにしましょう。バリデーションを行い、バリデーション失敗であれば、元の入力フォームを表示し、入力された内容(通常はパスワードは表示しない)とエラーメッセージを表示します。

ところがです。GET側処理で入力フォームを表示していますが、これがキャッシュされているとどうなるでしょうか?前回の何も入力されておらず、エラーメッセージも表示されていないページの内容がキャッシュされていたら、POST側の処理の結果として、エラーメッセージと入力内容と共に、GET側へリダイレクトをかけても表示されるのは、キャッシュされた内容です。つまり、エラーメッセージもなく、入力欄もまっさらなページが表示され続けるわけです。

つまり、フォームの表示ページはキャッシュされてはまずいわけです。

もう一つあります。ペジネーション、つまりページ付け機能です。Laravelのペジネーションは楽です。どのページを表示するかはクエリー文字列で自動的に指定されます。ページキャッシュのキーにはクエリー文字列も含まれていますので、ページごとキャシュされます。

ここで、1ページから3ページまでキャッシュされたとしましょう。そのタイミングで、一番最初のページ表示された、最初のアイテムが削除されたらどうでしょう?削除されたため、本来ならその項目は削除され、表示されるアイテムは一つずつ繰り上がらなくてはなりません。しかし、1から3ページまでキャッシュされているため、ページの内容は変更されません。

例えば、データベーステーブルの中でも、項目が追加・削除されず、変更もされないことが前提で作成されたものであれば、キャシュしても問題ありません。しかし、ペジネーションを使用するテーブルは変更される場合が圧倒的に多いでしょうから、キャッシュしないほうが良いでしょう。

キャッシュを防ぐためには、多少トリッキーな手法を取ります。

server {
    ...
    set $no_cache 0;

    if ($request_uri ~ ^user/add$) {
        set $no_cache 1;
    }
    ...
    location ~ \.php$ {
        fastcgi_cache_bypass $no_cache;
        fastcgi_no_cache $no_cache;
    }
    ...
}

追加点だけを抜き出しました。

$no_cache変数を利用します。Nginxの条件は文字列が空(もしくは未定義)か"0"の場合が偽として扱われます。それ以外は真です。$no_cacheが"0"、つまりキャッシュを行うのがデフォルトです。続いてif文でリクエストされたURIがキャッシュしたくないURIであるかをチェックします。キャッシュシたくないURIの場合は$no_cacheに"1"をセットし、真にします。

fastcgi_cache_bypassは、キャッシュ(されていても、いなくても)の内容を使用しない宣言です。fastcgi_no_cacheはキャッシュを行わない宣言です。その後の値を評価して、真であればキャッシュを行わない、使わないようにします。$no_cacheが真と評価される場合("1")、キャッシュされません。

これを利用し、入力フォームやペジネーションのURIをキャッシュ対象外に指定することができます。

もちろん、URI以外の要素をチェックし、キャッシュをコントロールすることができます。

Laravelからキャッシュを制御する

これで、キャッシュをNginxからコントロールできるようになりました。しかし、URIの管理がLaravelのルーティング定義とNginxの仮想ホスト設定の2つに別れ、二重管理になります。これはトラブルの元です。

Laravelから全部管理できるようにしましょう。先ずフィルターを作成します。

Route::filter('nocache', function($route, $request, $response)
{
        $response->header('X-Accel-Expires','0');
});

このフィルターはレスポンスへ、X-Accel-Expiresヘッダーを追加しています。値は0です。

このフィルターをキャッシュしたくないルート定義に、afterフィルターとして指定します。

Route::get('user/add', [ 'after'=>'nocache', function() {
        return View::make('user.create');
}]);

最後に、各仮想ホストでphp拡張子のURIを処理するlocation中に、以下のディレクティブを指定します。

fastcgi_pass_header "X-Accel-Expires";

PHP側から、なんでも好きなヘッダーを付けられるわけではありません。レスポンスに含めたいカスタムヘッダーを指定する必要があります。

今回使用した、X-Accel-Expiresヘッダーは、キャッシュ時間のコントロールを行いますが、優先度が最高です。優先度は、X-Accel-Expiresヘッダー > Cache-Controlヘッダー > fastcgi_cache_valid,factcgi_no_cache,etc..となっています。この最高優先度のキャッシュ指示ヘッダーを使い、キャッシュをコントロールします。今回はキャッシュをさせないために、キャッシュ時間に0を指定します。これで、キャッシュを防ぐことができます。

最後に、ここまでの成果をまとめた設定ファイルを紹介します。Ubunut14.04のnginx.confで動かしているものです。設定値の調整は皆さんにお任せします。

# 動作ユーザー/グループの設定
user www-data;

# ワーカープロセス数はCPUに合わせる
worker_processes auto;

# マスタープロセスの情報保存ファイル
pid /run/nginx.pid;

# ワーカーの扱う最大ファイルディスクリプター数。
# OSレベルで扱えるディスクリプター総数である、/proc/sys/fs/file-maxを参考に設定する。
worker_rlimit_nofile 9000;

events {
        # 1ワーカープロセスの最大接続数
        worker_connections 3000;

        # 可能な限り多くの接続を一度に受け付ける
        multi_accept on;

        # Linux限定(Ubuntuなので問題なく利用できる)
        # 高効率、大量のファイルディスクリプタを扱える。設定必須。
        use epoll;
}

http {

        ##
        # 基本的な設定
        ##

        # OSのsendfile APIを使用するかどうか。使用すると早くなるが、
        # 問題を起こすことがある。その時はoffにする。
        # 通常はsendfile+tcp_nopushで良い。
        sendfile on;
        tcp_nopush on;
        # tcp_nodelay on;

        keepalive_timeout 10;
        keepalive_requests 200;

        types_hash_max_size 2048;

        # サーバーの情報をヘッダーに含めない
        server_tokens off;

        # 複数の仮想ホスト使用時のため、大きめに設定する。
        server_names_hash_bucket_size 128;

        # server_name_in_redirect off;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        ##
        # ログ保存場所の設定
        ##

        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;

        ##
        # Gzipの設定
        ##

        # ブラウザ側で有効であれば、大抵の場合gzipを有効にするだけで、50%程度早くなる。
        gzip on;
        gzip_disable msie6;
        # gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 2;
        gzip_min_length 3k;
        # gzip_buffers 16 8k;
        gzip_http_version 1.1;

        # 以下のmimeタイプに当てはまるファイルがgzipとgzip_staticの対象となる。
        # text/httpはデフォルトで対象になっている。
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

        # 同じディレクトリーにgzファイルが存在し、ブラウザが対応していればgzファイルを送る。
        # コマンドで一括してgzファイルに変換するには、find 対象ディレクトリー -name '*.css' -exec gzip -2 -kf {} +で行える
        # 数字は圧縮レベル、-1から-9まで。-fは上書きを強制する。-kはオリジナルファイルをそのまま残す。
        gzip_static on;

        ##
        # ファイル情報のキャッシュ
        ##

        open_file_cache          max=1000 inactive=20s;
        open_file_cache_valid    60s;
        open_file_cache_min_uses 1;
        open_file_cache_errors   on;

        ##
        # fastcgiキャッシュの設定
        ##

        # 保存ディレクトリーは予め作成しておき、Nginxの実行ユーザーからアクセス可能に設定しておく。
        # keys_zoneの名前とサイズは共有メモリに対する値、特にサイズが適切か注意する。
        # 1Mで約8000ファイル分保持できる。max_sizeを指定すると、監視プロセスが起動する。
        fastcgi_cache_path /var/cache/nginx-fastcgi levels=1:2 keys_zone=WHOLE:50m inactive=10h;

        # キャッシュのキー。
        fastcgi_cache_key "$scheme$request_method$host$request_uri";

        # キャッシュ対象のhttp動詞。デフォルトのまま。POSTは含まれていないことに注意。
        fastcgi_cache_methods GET HEAD;

        # どのfastcgiのレスポンスに、保存されているキャッシュの内容を使用するか。
        fastcgi_cache_use_stale error timeout invalid_header http_500;

        ##
        # 仮想ホストの設定
        ##

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

Laravelを動作させる仮想ホスト設定は、次のようになるでしょう。

server {
    listen 80;
    server_name your-domain.com; # ドメイン名指定

    root /home/your-id/your-domain.com/public;
    index index.html index.php;

    # デフォルトでキャッシュする。(0:false)
    set $no_cache 0;

    # Nginx側でキャッシュをコントロールする場合の指定
    # (Laravel側からコントロールする方法を推奨します。)

    # クエリーの付いたURLをキャッシュしない場合:
    #if ($query_string) {
    #    set $no_cache 1;
    #}

    # ペジネーション対象URIなど、特定のパターンでキャッシュしない場合:
    # if ($request_uri ~ ^/apples/) {
    #    set $no_cache 1;
    # }

    location / {
        try_files $uri $uri/ /index.php?$query_string;

        location ~ \.php$ {
            include fastcgi_params;

            # SCRIPT_FILENAMEをオーバーライト
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

            fastcgi_split_path_info ^(.+\.php)(/.+)$;
            fastcgi_pass unix:/var/run/php5-fpm.sock;
            fastcgi_index index.php;

            # fastcgiキャッシュの設定
            fastcgi_cache WHOLE;

            # キャッシュ対象の指定。この指定に当てはまらないものは、キャッシュされない。
            fastcgi_cache_valid 200 301 302 8h;

            # Laravelが付加するheader情報により、キャッシュが妨げられないようにする。
            fastcgi_ignore_headers Cache-Control Set-Cookie;

            # Nginxで指定した条件により、キャッシュをコントロールする。
            fastcgi_cache_bypass $no_cache;
            fastcgi_no_cache $no_cache;

            # Laravel側から、キャッシュをコントロールさせる。
            fastcgi_pass_header "X-Accel-Expires";

            # キャッシュ状況をヘッダに含める。必須ではないがキャッシュの成否がわかる。
            # add_header X-Cache $upstream_cache_status;
        }
    }

    location = robots.txt {
        access_log off;
        log_not_found off;
    }

    # よくアクセスされる静的ファイルにブラウザキャッシュを設定する。

    location = favicon.ico {
        access_log off;
        log_not_found off;
        expires max;
    }

    location ~ \.(jpg|png|gif|swf|jpeg|ico)$ {
        access_log off;
        log_not_found off;
        expires 2w;
    }

    location ~ \.(css|js)$ {
        charset  UTF-8;
        access_log off;
        expires 8d;
    }

    # セキュリティーのため、ドット始まりのファイルにはアクセスさせない。

    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

長文のブログは読まれませんね。:D しかし、誰かの役には立つでしょう。(主に私自分ですが。:D :D)

一時追記

先頭に書きました通り、この方法は実用的ではありません。

Laravelは全リクエストに対し、セッションを維持します。毎回セッションクッキーを付けます。もちろん、開発者と閲覧者の利便性のためです。昔は、例えば認証ページから、認証が必要でないページヘ一度移動すると、認証が切れてしまうような作りのサイトが結構ありました。こうした事態を防げます。

たとえ認証を使用しなくても、以前の情報を保持しておくセッションは便利です。セッションの本体はドライバーで指定でき、cookieであればブラウザで、fileやredis、dbであればサーバーマシン側で保持されます。本体はどこであってもセッションを維持するためにIDを保存しておくクッキーがブラウザで保存されます。

fastcgiキャッシュは、fastcgi側、今回はPHP FPMになりますが、送られてきたデータをそのまま保存します。Aさんが最初にアクセスし、AさんのセッションIDを含んだクッキーがキャッシュとして保存されます。ここでBさんが同じページにアクセスすると、Aさんのクッキーを含んだキャッシュがレスポンスとして送り出され、Bさんの接キョンクッキーがAさんのものに置き換わってしまいます。そのため、今回紹介している方法は、まだ実用的ではありません。

例えば、全くセッションが必要でないサイトの場合、つまり認証も行わず、セッションに値を保存しないようなサイトの場合でしたら、Nginxから送られてくるレスポンスから特定のヘッダーを送らせないディレクティブ、fastcgi_hide_headerでSet_Cookieをレスポンスに含めないという手法が取れます。もちろん、これを指定すれば、全くクッキーが使えなくなります。

1.リクエストにセッションクッキーが含まれていない場合は、キャッシュを使用しない。2.キャッシュをヒットしたら、レスポンスのセッションを送り返す。という方法で行けそうだとも思いますが、この方法の場合、セッションの維持期間の間中ずっとキャッシュに保存された内容だけ閲覧していて、期限切れになってからキャッシュされていないベージにアクセスすると、そこでいきなりセッションが切れてしまうことになります。これは、ユーザーにとって突然何かが変わったという印象を与えます。

サイトをどのような使い勝手にするか、ユーザーがどのように使用するかというシナリオにより、対処方法が変わってくるため、一概な回答はありません。

この記事の目的は、「簡単な方法でお手軽にLaravelからNginxのキャッシュを使用する」方法を解説するつもりでしたが、簡単には行きそうもありません。実用にするにはもうちょっと、改善が必要です。