at posts/single.html

Apache の mod_cache で日記の高速化に挑戦してみた

tDiary + Ruby1.9 環境での応答時間を40%以上高速化に成功したので、この日記の表示もかなり速くなったんだけど、引き続き高速化に挑戦。

どこかのブログ(忘れちゃった…)を読んで、 mod_cache という Apache モジュールが存在することを知った。 mod_cache はリクエストへの応答を Web サーバレベルでディスクやメモリにキャッシュするためのモジュール。 よくよく考えたら日記の内容なんて、日記を更新したときかツッコミが入った時しか変わらないので、毎回 tDiary のスクリプトを動かさなくてもいい。 ということで早速入れてみることにした。

mod_cache モジュールのインストール

うちのVPSサーバ(CentOS)では、すでに mod_cache モジュールが入っていた。

$ ls -l /etc/httpd/modules/*_cache.so 
-rwxr-xr-x 1 root root 30328  4月  5 06:24 /etc/httpd/modules/mod_cache.so*
-rwxr-xr-x 1 root root 22044  4月  5 06:24 /etc/httpd/modules/mod_disk_cache.so*
-rwxr-xr-x 1 root root  9772  4月  5 06:24 /etc/httpd/modules/mod_file_cache.so*
-rwxr-xr-x 1 root root 22108  4月  5 06:24 /etc/httpd/modules/mod_mem_cache.so*

mod_cache モジュールの設定

mod_cache はキャッシュデータをメモリとディスクのどちらかに保存できる。 今回は以下の理由から、ディスクに保存することを選択した。

  • VPSサーバで使えるメモリ容量が限られている (384MB)
  • ディスクに保存した方がキャッシュの動作をチェックしやすい

モジュールの設定を /etc/httpd/conf.d/cache.conf に書く。

LoadModule cache_module modules/mod_cache.so
LoadModule disk_cache_module modules/mod_disk_cache.so

<IfModule mod_cache.c>
    <IfModule mod_disk_cache.c>
        # リクエストの Cache-Control: no-cache ヘッダを無視してキャッシュを返す
        CacheIgnoreCacheControl On
        # レスポンスにLast-Modifiedヘッダがなくてもキャッシュ対象にする
        CacheIgnoreNoLastMod On
        # Set-Cookie ヘッダをキャッシュしない
        CacheIgnoreHeaders Set-Cookie
        # キャッシュ対象とするパスの指定
        CacheEnable disk /diary/
        # キャッシュを保存するディレクトリ
        CacheRoot /var/www/cache
    </IfModule>
</IfModule>

/etc/httpd/conf/httpd.conf に cache.conf を読み込む指定を追加して、 HTTP サーバを再起動するとキャッシュが有効になる。

include conf.d/cache.conf
$ sudo /etc/init.d/httpd restart

キャッシュの仕組み

再起動して日記にアクセスしてみると、 CacheRoot で指定した /var/www/cache にキャッシュファイルが作られる。 キャッシュファイルは *.header と *.data の2つのファイルから構成されている。 .header にはリクエストとレスポンスのヘッダが保存されていて、 .data は(おそらく)HTTPボディ (HTMLや画像ファイルなど)が保存されている。 おそらく mod_cache モジュールは HTTP リクエストを受け付けたときに、リクエストヘッダに対応する .header ファイルが存在するかどうかをチェックして、存在すれば .header と対になる .data を返すようにしているんだろう。

ところで、 ab コマンドで日記の応答時間を計測しても、スループットは1req/sec前後、レスポンスタイムは1.2秒程度でキャッシュ前と変化が無い。 キャッシュディレクトリを見てみたら、スタイルシートや画像ファイルはキャッシュされているけど、肝心の日記本体がキャッシュされていない。 いろいろ調べていたら、どうやらスクリプトが Last-Modified ヘッダと Expiresヘッダを返さないといけないみたい。 以下、でじたま日記2.2 - Apache 2.2 on FreeBSDより引用。

◇キャッシュされる条件 mod_cacheは次の条件に従い、リクエストされたコンテンツがキャッシュ可能かどうかを判定します。

6. URLにクエリ文字列がある場合は、そのレスポンスにExpires:ヘッダがない限りキャッシュしない

これは Apache2.2 の Caching Guide を訳されたものみたい。 なるほど。クエリ付きの場合は、 Expires で明示的に有効期限を示さないといけないのか(RFCで決められている模様)。

ちなみに、日記のURLは 20100613.html のように URL にクエリが付いていないようにみえるけど、内部では mod_rewrite モジュールによって index.fcgi?date=20100613 のようにクエリ付きへと変換されている。

tDiary で Expires キャッシュを返すようにしてみる

tDiary では Last-Modified, ETag ヘッダを返しているけど、 Expires ヘッダは返していない*1。 そこでスクリプトを修正して Expires ヘッダを返すようにしてみた。 とりあえず有効期限を1時間(現在時刻 + 3600秒)に設定する。

Index: index.rb
===================================================================
--- index.rb    (リビジョン 3594)
+++ index.rb    (作業コピー)
@@ -66,6 +66,7 @@
                head['status'] = status if status
                body = ''
                head['Last-Modified'] = CGI::rfc1123_date( tdiary.last_modified )
+               head['Expires'] = CGI::rfc1123_date( Time.now + 60 * 60 )
 
                if /HEAD/i =~ @cgi.request_method then
                        head['Pragma'] = 'no-cache'

本来だと index.rb ではなく expires プラグインを作って対応したほうがいいけど、まぁご愛敬。

応答時間は速くなったけど…

Expires ヘッダを返すようにしたら、日記自体もキャッシュされるようになった。 もういちど ab コマンドで計測したら、以下のようになった。 (スレッド数2、リクエスト数10で計測)

指標キャッシュ無しキャッシュ有り
スループット0.88req/sec5.7req/sec (約6倍)
レスポンスタイム1260msec180msec (約7倍)

すごく速くなった。これで安心!と喜んだのもつかの間。 そういえば携帯やスマートフォンからのアクセスはどうなるんだろうと思い、調べてみたら mod_cache は以下の挙動を示していることが分かった。

  • 同一 URL であっても、異なる User-Agent に対しては異なるキャッシュデータを保持している
  • つまり、同じ URL に対して同じ User-Agent での2回目以降のアクセスに対してのみ、キャッシュは有効になる

PC, 携帯, スマートフォンで異なるキャッシュを返してくれる(PCでアクセスしたときに携帯用の画面が表示されることはない)のはいいんだけど、 PC の User-Agent なんてほとんどバラバラなんだから、実質的にキャッシュにヒットする可能性はほとんどないという状態。 ダメじゃん。

URLとキャッシュの関係を対応づけるVary ヘッダ

どうして mod_cache は User-Agent ごとにキャッシュデータを変えているんだろうと思って調べたら、やはりでじたま日記2.2 - Apache 2.2 on FreeBSDに答えがあった。

そもそも、Varyヘッダというのは、ある一連のHTTPリクエストとレスポンスがあった場合に、そのレスポンスのVaryヘッダに指定されたヘッダの値が同じリクエストがあれば、この応答をキャッシュした結果を返してもよい、とサーバ側で指定するものです。

Expires に続いて今度は Vary ヘッダか…。 この日記が返しているヘッダを調べてみた。

$ curl -i http://www.machu.jp/diary/index.fcgi

HTTP/1.1 200 OK
Date: Sun, 27 Jun 2010 03:16:29 GMT
Server: Apache/2.2.3 (CentOS)
Content-Length: 46336
Vary: User-Agent
Expires: Sun, 27 Jun 2010 04:16:29 GMT
ETag: "f2357216dbd23ea429f8400e76f41e82"
Last-Modified: Sun, 13 Jun 2010 09:20:42 GMT
Connection: close
Content-Type: text/html; charset=UTF-8

おおっ。 Vary: User-Agent が入っている。 この Vary ヘッダは tDiary (のindex.rb) が明示的に付与していた。 mod_cache モジュールは tDiary が付与した Vary ヘッダを見て、「User-Agentごとに異なるキャッシュデータを保持しなくちゃいけない」と判断していたのか。

キャッシュを使う前には計画が必要

ここでいったん行き詰まる。

  • Vary: User-Agent ヘッダがあると、ほとんどキャッシュにヒットしない(User-AgentにはOSバージョンや.NETランタイムなどの情報も含まれているため)
  • Vary: User-Agent ヘッダを削除すると、PC, 携帯, スマートフォンで同一のキャッシュが使われてしまう

やっぱりキャッシュを使うには事前にしっかり計画しないといけなさそう。

  • 最終更新日時 (Last-Modified) と有効期限 (Expires) による制御
  • ETag ヘッダによる制御
  • Vary ヘッダによる User-Agent ごとのキャッシュの使い分け

この辺をうまく使い分けることが必要か。 User-Agent ごとの使い分けは、 nginx のリバースプロキシを構築した(2) - HsbtDiary のように Web サーバ側で判定する方法もありそう。 mod_cache で出来るかどうかは分からないけど。

将来的に、本来あるべき姿としては、以下のようになるかなぁ。

  • 更新頻度が低いデータ (日記本文) はキャッシュ対象として、PC, 携帯, スマートフォンごとに保存
    • データが更新されたときにキャッシュを無効にできればなお良い
  • 頻繁に更新されるデータ (セクションフッタのブクマ数・RT数, サイドバーの最近の日記一覧など) は、 Ajax を使って動的に取得

まとめ

  • mod_cache によるキャッシュで動的コンテンツを高速化できる
  • キャッシュを使うには事前にしっかりと計画が必要(有効期限やPC・携帯対応)
  • Ajaxを使えばキャッシュを使いつつ一部データのみ動的に取得できる

とりあえずは、 User-Agent の使い分け方法について引き続き考えてみる。

参考資料

関連する日記