続・Railsの画面生成を10倍高速化する方法: フィルタ編

さて、昨日は SSIとの組み合わせでPageキャッシュの適用範囲を広げる話 をした。 なぜSSIかというと、これは組込みの手軽なフィルタ機構だからだ。Apache 1系統ではSSIはハンドラとして実装されているけれども、2系統では新たにフィルタ機構が加わって、SSIはこちらで再実装されている。 フィルタ機構ならmongrelからの出力にも加工できる。Pageキャッシュとキャッシュでないものを透過的に扱えてうれしい訳だ。

ただ、確かにちょっとDRYさに欠ける。どうせならRailsのレイアウトファイルにPHPコード片を直接書きたいではないか。で、これを出力するとPHPとして処理してその結果がクライアントに伝わる、と。 id:yamazさんが「 rhtmlで直接phpを吐き出して処理する方法を模索したいのです。 」と言ってるのはたぶんそういうことだ。私もそれが理想だと思う。今日はそれに挑戦してみた。

  • 初回アクセスではmongrelにリクエストを転送。

  • RailsがRHTMLを処理して、PHPコードを出力。これをキャッシュもしておく。

  • PHPコードをフィルタが処理

  • 次回からはRailsが作ったキャッシュファイルを読み込み。

  • PHPコードをフィルタが処理。

Apache ━ (mod_php5 filter) ┳ (mod_rewrite) ━ (mod_proxy_balancer) ━ mongrel_cluster
                 │       ┃                                              │   
                 │       ┗ キャッシュファイル                           │   
                 │                                                      │   
                 └───→ [Memcached] (session情報) ←──────────────────────┘

方針

要は、PHPコードを受け付けて実行結果を出力するApacheフィルタがあればいいわけですよ。普通のmod_php5の使いかただとPHPプログラムはHandlerで処理されている。フィルタではない。これをちょこっといじってフィルタ版を作ればいいんだろうと思って、ソースを見てみた。

と、sapi/apache2filterというディレクトリがある。中を見てみるとap_register_output_filterを呼んでるから、これってApacheフィルタそのものじゃないのさ。どうやら、私が知らないだけでフィルタ版は存在したらしい。 ただし、いくつか問題があった。

  • Experimentalと書いてある
  • Debianとかの標準設定だとフィルタモードは有効にならない
  • Proxyに対しては有効にならない模様。

Experimentalでも、私が0から書くよりましでしょう。人柱上等。標準設定の問題は--with-apxs2filterを付けて再コンパイルすればOK。残る問題はProxyに対しては効かない仕様になってること。だからキャッシュに対しては有効だけれども、初回アクセス時は処理してくれない。コードを見てみると、わざわざProxy経由からの出力に対しては無効化するようにしてある。

sapi/apache2filter/sapi_apache2.c:450: php_output_filter

if (f->r->proxyreq) {
     zend_try {
          zend_ini_deactivate(TSRMLS_C);
     } zend_end_try();
     return ap_pass_brigade(f->next, bb);
}

まあ、そうかもしれない。一般的には他のサーバーから送出されてきたPHPコードを処理するのは恐すぎるし、使いどころが少なそうだ。ただ、今回は必要なわけなので上のコードをコメントアウトする。

方法

実験環境はUbuntu Linux 7.04で、次のようにした。

  • apt-get source php5
  • debian/rulesの"configure-apache2-stamp"のルールの中、"--with-apxs2=/usr/bin/apxs2"を"--with-apxs2filter=/usr/bin/apxs2"に置き換え
  • debian/changelogを適当に。
  • sapi/apache2filter/sapi_apache2.cの前述部分をコメントアウト
  • fakeroot dpkg-buildpackage

できあがったモジュールをインストールしたら、次のように設定する。基本的には昨日のPageキャッシュ版と同じだけれども、フィルタの設定とRailsのlayouts/application.rhtmlだけ違う。

apacheの設定

PHPという名前のフィルタが導入されているのでこれを使う。

<Proxy balancer://mongrel>
  BalancerMember http://localhost:8000
  BalancerMember http://localhost:8001
  BalancerMember http://localhost:8002
  Allow from all
  SetOutputFilter PHP
</Proxy>

NameVirtualHost *
<VirtualHost *>
        DocumentRoot /path/to/app/public
    # 以下、普通のvirtual hostの設定
    # (略)...

        <Directory />
                Options FollowSymLinks
                AllowOverride None
        </Directory>
        <Directory /path/to/app/public>
               Options Indexes FollowSymLinks MultiViews
               AllowOverride None
               Order allow,deny
               allow from all
        </Directory>
        RewriteEngine on
        RewriteRule ^/?$ index.html [QSA]
        RewriteRule ^([^.]+?)/?$ $1.html [QSA]

        RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f
        RewriteRule ^(.*)$ balancer://mongrel%{REQUEST_URI} [P,QSA]
        AddOutputFilter PHP .html
</VirtualHost>

app/views/layouts/application.rhtml

helperメソッドが出力したダブルクォーテーションがPHPのsyntaxエラーを生じたりして一瞬戸惑った。

<html>
  <head>
    <title>test</title>
  </head>
  <body>
    <p>
      <?php
      $memcache = new Memcache;
      $memcache->pconnect('localhost', 11211) or die('cannot connect');
      $json = $memcache->get("session:" . $_COOKIE['_simple_layout_session_id']);
      $hash = json_decode($json);
      $user_name = $hash->user_name;
      if ($user_name) {
        echo '<h1>ようこそ' . $user_name .'さん</h1>';
      } else {
        echo '<p><%= link_to 'ログイン', :action => 'login' %></p>';
      }  
      ?> 
    </p>
    <hr />
    <%= @content_for_layout %>
  </body>
</html>

ベンチマーク

昨日とおなじ条件で測ったらこうなった。

Concurrency Level:      100
Time taken for tests:   6.951062 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      23683594 bytes
HTML transferred:       21641211 bytes
Requests per second:    1438.63 [#/sec] (mean)
Time per request:       69.511 [ms] (mean)
Time per request:       0.695 [ms] (mean, across all concurrent requests)
Transfer rate:          3327.26 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        3   32  11.5     32      69
Processing:    15   36  11.6     36      72
Waiting:        2   31  11.9     31      69
Total:         57   68   7.2     66     133

Percentage of the requests served within a certain time (ms)
  50%     66
  66%     67
  75%     68
  80%     69
  90%     81
  95%     83
  98%     85
  99%    100
 100%    133 (longest request)

理論上はSSIの解析プロセスが無くなった分だけ速くなるはずだけれども、結果は誤差の範囲だ。まぁ、ファイルサイズとリクエスト数がこの程度なら、今時のマシンはSSIぐらい気にすることはないということなんだろうか。 とにかく、設定がシンプルになってview templateがDRYになったのは嬉しい。

補遺または蛇足

  • 今回作成したフィルタは外部からPHPコードを受け取って処理するという危険極まりない代物になる。こちらで指定したmongrel以外を受け付けないように設定には注意が必要だ。
  • SetOutputFilterしたりしているのはいささか乱暴JSONを出力する場合なんかに対応できない。Apache 2.2なら本当はmod_filterを使うべき。
  • キャッシュファイルの拡張子は私は.htmlのままで満足。.phpに変えたい場合はActionController::Base:: page_cache_extensionを設定せよと 本家のドキュメント に書いてある。
  • 「可能だから」といってRHTML内PHPを肥大化させるのはおすすめしない。何のためにRailsを使ってるのか分からなくなるし、はっきり言ってきつい。昔、TeXプリプロセスするJavaプログラムを生成するPerlプログラムを書いたけれども異言語のネストはきつい。