nginxとdjangoのコラボレーティブキャッシング

djangoのようなフレームワークは開発者の生産性を劇的に向上させてくれる。しかしそのためにパフォーマンスは犠牲にされる。djangoのORMなんか非常に便利だが、そのまま使っていると非効率なことがRDB層で起きる羽目になる。でもこれは殆んどのアプリケーション開発において賢いトレードオフだと思う。だって、作って公開してみなければそのアプリが最適化するに値するものかどうかは別らないでしょ。

という具合でとりあえず作ってみたサイトがめでたくトラクションを得るとパフォーマンスが問題になってくる。今作っているサイトは日2万ページビューぐらいでピーク時にアップアップ状態。同じページを作るのに毎回SQLからオブジェクトを作ってレンダリングしているんだから無理ない。ホスティングは月20ドルのVPS。趣味のサイトなので、これ以上の予算はない。そこでキャシュの出番だ。

サーバ構成はnginxの後ろにfastcgi経由でdjangoが動いている。せっかく、 超高速のnginxが前にいるんだから、一度生成したHTMLはdjangoに行く前にnginxに送らせたい。そこで、djangoのキャッシュはアウト。

nginxにもキャッシュ機能がいろいろあるみたい。上手くキャッシュしている実例もあるようだ。でも、nginxは若くてまだドキュメンテーションが機能に追い付いていない感じがする。とにかく設定が面倒。そこでnginxの設定は最低限に抑えたい。

あと、データが更新したらすぐキャッシュを一掃して新しいページを作るようにして、つねに最新のデータを表示したい。つまり「このページは何分有効」というようなチューニングをしたくない。このアプリケーションは更新頻度が低く、リード重視の出版系のサイトなので、これで十分。

以下、ngixnとdjangoのコラボレティブキャッシュのレシピ

/etc/nginx/site/tengu.conf


...
# キャッシュとfcgi
location / {
# キャッシュ ディレクト
root /var/www/tengu/cache/

# もしリクエストURL /hoge/ に対し、/var/www/tengu/cache/hoge/index.html
# があればこれを送り返す。djangoの方針に従い、URLが基本的に/で終るのが前提。
if (-f $request_filename/index.html ) {
rewrite ^(.*)$ $1/index.html break;
}
# else: (nginxの設定ファイルのシンタックスには「else文」が無い!)
# キャッシュファイルが無いときにはバックエンドのdjangoにそのままやらせる。
#
#
if (!-f $request_filename/index.html ) {
# これはテストモード。djangoの開発サーバに繋ぐ。
#proxy_pass http://127.0.0.1:8000;

# プロダクション。fcgiサーバに連結。
fastcgi_pass 127.0.0.1:9000;
}
# 以下、通常のfcgi設定。nginx付随の例を参照。
fastcgi_param PATH_INFO $fastcgi_script_name;
...

要は if (-f $request_filename/index.html) { rewrite ^(.*)$ $1/index.html break; }
を追加して、これのelse文のところに通常のバックエンドのfcgi設定をすればいい。

さて、djangoの方ではリクエストされたファイルを生成しキャッシュに保存し送り返さなければならない。
ポイント
1 Middlewareのprocess_responseで適切な場合、リスポンスをキャッシュに書き込む。
2 キャッシュの一掃は表示しているデータがモデルで変更された場合。これは関連モデルのpost_saveとpost_deleteのシグナルを受けて行う。
3 キャッシュの対象となるURLは正規表現のリストで管理 (urls.pyで管理できるともっと格好いい..)

コードのハイライト


# ミドルウエアはキャッシュオブジェクトに任せるだけ。
class CacheMiddleware(object):
def process_response(self, request, response):
return instance().process_response(request, response)

# settings.pyでこのように設定。
MIDDLEWARE_CLASSES = (
...
'hoge.cache.CacheMiddleware', # 下の方。
)


# キャッシュクラスのメソッド。上で呼ばれるやつ。
def process_response(self, request, response):

# キャッシュすべきか? URLがその対象になり、エラーでない。
if self.applicable(request) and response.status_code==200:

# キャッシュファイルはリクエストURLにindex.htmlをつけたもの
vpath=request.META['PATH_INFO']
if vpath.endswith('/'):
vpath+='index.html'

# キャッシュディレクトリに保存。
self.save(vpath, response.content)

# リスポンスはそのまま返す。
return response


# キャッシュの設定
cache.configure(cache_dir=os.path.join(os.path.join(settings.RUNTIME_ROOT,
'var/cache')),
# データ更新イベント。djangoのシグナルを受け、キャッシュを一掃することにより、
# 常に最新の情報を見せる。
events=[
(blog.Post, post_save), # django signals
(blog.Post, post_delete),
(cmt.Comment, post_save),
(cmt.Comment, post_delete),
],

# キャッシュの対象となるURLの正規表現
path_regexes=[r'^/blog/$',
r'^/foo/',
r'^/bar/']
)


以上の設定でとりあえず、ピーク時にも快適に見れるようになった。静かなときでも、以前より速くなって嬉しい。データの更新頻度が上らなければ、これで20ドルVPSで日何万ページビューもこなせるでしょう。