symfonyのルーティングでメモリが肥大化する問題と対処法

こんにちは。小川です。
先日、symfony(v1.2.7)で本番(prod)環境に設定した場合に開発(dev)環境の数倍のメモリが消費されるという状況に陥ってしまいました。原因を追及した結果、ルーティングの設定に問題があることが発覚したので、今日はそのことを書こうと思います。

原因先には述べてあるとおり、ルーティングの設定に問題がありました。symfony1.2ではルーティングのキャッシュということを行っており、そのキャッシュが肥大化してメモリを大量に消費する原因となっていました。
対策としてルーティングのキャッシュを無効にしてキャッシュファイルの読み書きを行わないように設定ファイルを修正したところ、上記の問題は無事に解決しました。


<strong>追記@2009/09/28</strong>
symfony1.2.9以降では初期状態でキャッシュが無効になるように設定されています。symfony1.2.9からのgenerate:appタスクでアプリケーションの生成を行った場合は修正は必要ありません。

というわけで今回は、

1. ルーティングのキャッシュの仕組み
2. なぜそんなにもキャッシュが肥大化してしまったのか
3. ルーティングのキャッシュを無効にする方法、その他対策

上記の3つをテーマにお話しさせていただきます。

symfony 1.2のルーティングについては以前書いた「symfony 1.2のルーティングまとめ」という記事をご覧ください。
symfonyでは通常、 /モジュール名/アクション名(/パラメータ) という形式のURLをとりますが、このURLとモジュールおよびアクションを結びつける仕組みをルーティングと呼んでいます。例えば、/article/newというURLはarticleモジュールのnewアクション、といったものです。

そしてそれぞれのルーティングには名前がついています。/モジュール名/アクション名(/パラメータ) という形式はdefaultという名前がつけられています。これ以外にもホームページにあたるhomepageや各モジュールのインデックスにあたるdefault_indexがデフォルトで定義されています。

実際に symfony generate:app コマンドでアプリケーションを作った際に、routing.ymlには以下のような定義がされています。


homepage:
  url:   /
  param: { module: default, action: index }
default_index:
  url:   /:module
  param: { action: index }
default:
  url:   /:module/:action/*

:(コロン)ではじまる項目はリクエストパラメータとして扱われます。先ほど例に挙げた/article/newというURLはdefaultにマッチし、$request->getParameter('module')としてリクエストオブジェクトからパラメータを取得すればarticleという文字列が取れます。moduleとactionは特別なパラメータで、名前の通りモジュール名とアクション名を特定するための値です。
またhomepageやdefault_indexでparamという項目の中でも同じようにmoduleやactionが指定されていますが、これはURLがマッチした際にURLに含まれていなくてもリクエストパラメータとして指定するというものです。

*(アスタリスク)は任意のリクエストパラメータを/(スラッシュ)区切りで記述可能にするもので、 /article/show/year/2009/month/08/day/20 というURLでアクセスが来た場合にarticleモジュールのshowアクションが呼び出され、リクエストオブジェクトからはそれぞれyearが2009, monthが08, dayが20のように取得することができます。/article/show?year=2009&month=08&day=20 と同じようなものですが、スラッシュ区切りで来ている場合は$_GETには直接はいらず、symfonyが内部でリクエストに定義するということを行うという違いがあります。

もし /article/2009/08/20 というURLでarticleモジュールのshowアクションに行くようにし、2009, 08, 20という値をそれぞれyear, month, dayという名前のリクエストパラメータとして指定したい場合は以下のようにrouting.ymlに定義します。


article_show_at_date:
  url:   /article/:year/:month/:day
  param: { action: show }

これでarticle_show_at_dateがルーティングに定義されます。このルーティングをアプリケーション内から呼び出すときにはlink_toやurl_forといったヘルパーや、アクションのredirectメソッドがよく知られていると思います。
例えばurl_forであれば url_for('@article_show_at_date?year=2009&month=08&day=20') のように指定します。実際にurl_forやredirectがURLのパースを行う処理はsfWebControllerのgenUrlメソッドが呼び出されており、更にその中でsfPatternRoutingクラスのgenerateメソッドを呼び出しており、パース処理が行われています。

このパース処理ですが、routing.ymlを展開した後、各ルーティングを分解して正規表現に変換して1つ1つマッチングを行うなどの処理をして、最終的にURLに変換を行うという流れになります。
この @article_show_at_date?year=2009&month=08&day=20 を /article/2009/08/20 に変換する処理をキャッシュすることがルーティングのキャッシュになります。実装方法は単純で、前者の内部的なURLにコンテキストの情報を付与したものをキーにしてキャッシュを作成する実装になっています。

キャッシュのキーを具体的に生成してみます。


// ルーティングの名前
string 'article_show_at_date' (length=20)
// ルーティングのパラメータ
array
  'module' => string 'default' (length=7)
  'action' => string 'index' (length=5)
  'sf_culture' => string 'en' (length=2)
  'year' => string '2009' (length=4)
  'month' => string '08' (length=2)
  'day' => string '20' (length=2)
// コンテキストの情報
array
  'path_info' => string '/' (length=1)
  'prefix' => string '/frontend_dev.php' (length=17)
  'method' => string 'GET' (length=3)
  'format' => null
  'host' => string 'sf-lab.fivestar.localhost' (length=26)
  'is_secure' => boolean false
  'request_uri' => string 'http://sf-lab.fivestar.localhost/frontend_dev.php' (length=50)

内部的に上記の3つの情報をシリアライズしてキーとしています。デフォルトの設定ではこのキーと値(URL)を単一の連想配列にセットして、1つの大きなルーティング情報を保持している配列を作るということを行っています。この配列はシリアライズしてファイルに書き込まれ、sfPatternRoutingが生成されるときに読み込まれてまた配列として定義される仕組みになっています。

問題はこの配列がどうして肥大化してしまったのかですが、キャッシュのキーにヒントがあります。実は今回開発していたものはモバイル用のサイトで、コンテキストのrequest_uriにセッションIDがくっついてくる仕様になっており、異なるセッションIDでリクエストがくるたびにルーティングのキャッシュを作り直すということが起きていました。セッションIDが変わった瞬間に作成していたルーティングのキャッシュが無意味になるどころか、無駄なルーティングがものすごい勢いで増え続けるというかなり危険な状態ですね。

一応キャッシュには有効時間が設定されており、デフォルトでは31,556,926秒なのでおおよそ365日、つまり1年というまるであってないような状態でもありました。これを短く設定するという方法もありましたが、そもそも定義してあるルーティングも大した量はなくそこまで時間がかかるわけでもなく、アクションキャッシュなどでもある程度代替できるのでキャッシュを無効にして対応することにしました。

ルーティングのキャッシュを無効にする方法はアプリケーションのfactories.ymlで設定することができます。factories.ymlはコントローラやリクエスト、ストレージなどのクラスの指定やクラスのコンストラクタに渡すパラメータなどを設定するファイルです。細かい設定などはThe Symfony Reference Bookをご確認ください。

このfactories.ymlを読み込むためのsfFactoryConfigHandlerというクラスがあり、このクラスを見てみると if (isset($parameters['cache'])) がtrueであればキャッシュオブジェクトを作成するということを行っています。ここで生成したキャッシュクラスがsfPatternRoutingにセットされることでキャッシュが有効になるという仕組みでした。デフォルトではsfFileCacheクラスがルーティングのキャッシュを行うクラスとして設定されているようですが、次のように記述を行うことでキャッシュが無効になります。


all:
  routing:
    class: sfPatternRouting
    param:
      # 他の設定
      cache: ~

~(チルダ)はnullと同じ意味を持っているため、routingのparamという項目の中でcacheをnullにすると先ほどのissetがfalseになるため、キャッシュが無効に設定されます。
これでsymfony cache:clearコマンドを実行すると作られていたキャッシュも削除され、設定も反映されます。設定された後にメモリ使用量などを確認したところ正常な数値で稼働しているのが確認できました。

メモリの原因がルーティングのキャッシュにあることは当初全然見当もついておらず、ここまで内部を追っていくのは中々大変な作業でした。
メモリ使用量についてはWebデバッグツールバーには表示されますが本番環境で表示するわけにも行かず、memory_get_peak_usage関数などログに書き出すなどして場所の特定をしていきました。
特定するにあたって、まずはフロントコントローラ(web/index.phpなど)の処理をsfApplicationConfigurationオブジェクトの生成、sfContextの生成、dispatchの大きく3つにわけてメモリ使用量をみてみたところ、sfContextの生成時にメモリを消費しているようです。

sfContextの生成はsfContext::createInstanceメソッドで行われており、さらに中を見ていくとfactories.ymlを読み込む処理でメモリを大量に消費しているというまで判明しました。
このとき少しはまったところがあり、sfContextは通常symfonyが入っているディレクトリのutilディレクトリ内にあるのですが、本番環境ではsfContextなどのsymfonyのコアクラスをconfig_core_compile.yml.phpという名前でキャッシュされているため、キャッシュファイルの方を修正する必要があります。

sfPatternRouingなども同様にconfig_core_compile.yml.phpに入っているため、このファイル内で徐々に処理を追っていった結果、最終的にルーティングのキャッシュに原因があるというところに到達することができました。
ちなみに開発環境では本番と同様にキャッシュクラス自体は生成されているのですが、デバッグモードを有効にしているため特にルーティングのキャッシュは行わないようになっています。

ルーティングのキャッシュはかなり盲点だったのですが、デフォルトで有効になっているためアプリケーションによっては今回のように知らず知らずのうちにメモリを大量に消費するという問題に陥ってしまうのではないかと感じました。
キャッシュの設定自体はファイルの他にもsfAPCCacheやsfMemcacheCacheなども利用できますし、ルーティングでlookup_cache_dedicated_keysというパラメータをtrueに設定すると複数のルーティングキャッシュを生成するようになるので、アプリケーションによって適切な設定は変わってくると思います。

今回はルーティングのキャッシュについて知識がなかったので結果として悪い方向に働いてしまいましたが、チューニングの材料としてはとても有効だと思います。
symfonyは全体的にキャッシュに頼らないとパフォーマンスが出なかったりしますが、キャッシュを適切に設定してあげると目に見えて高速になるのでキャッシュは是非とも理解していきたいです。