A Day in the Life

例えば GC を止める・Ruby ウェブアプリケーションの高速化

最近クックパッドでは、アプリケーションサーバの大半が Rails 2.3 から Rails 3 に置き換わったのですが*1、リリース前のベンチマークの時点ではあまりパフォーマンスが出ず四苦八苦していました。具体的には Rails 2.3 の時と比べ MRI 1.8.7 だとレスポンスタムが200%ぐらい遅い結果でした。Rails 3 になって実装が Merb core を取り入れ疎結合で綺麗になった反面、より多くのオブジェクトと・メモリを利用する様になった影響かと思います。

そこで Ruby インタプリタの変更*2を行い検証をしたところ

  • MRI 1.8.7
    • (Rails 2.3と比べ) 約200%遅い
  • MRI 1.8.7 -> Ruby Enterprise Edition 1.8.7 2011.03 (tcmalloc 無効)
    • 約180%低速
  • MRI 1.8.7 -> Ruby Enterprise Edition 1.8.7 2011.03 (tcmalloc 有効)
    • 約140%低速

のような感じで、無視できないほど速度が遅く、これではリリースが危ぶまれました。なお、どこで速度が遅いのかというと、GC の実行でだいぶ時間を使っていました。Rails3 でオブジェクトが増えたため、より GC の実行時間がかかる様になってしまったようです。

Unicorn の導入

次にアプリケーションサーバを Apache + Passenger の組み合わせから Nginx + Unicorn の組み合わせに変更しました。Unicorn について簡単に説明すると、Rack 環境をロードした master プロセスが fork して子の worker を作るため、以下の様な特性を持ちます。

  • fork による worker 生成のため、子 worker の起動が高速
    • そのため再起動が高速になり、再起動時のサーバ負荷が少ない
    • ただユーザからの処理を捌かない master プロセスが必ず存在するため、メモリ使用量が1プロセス分増える
  • epoll / libev 等のイベントでうまく複数接続を捌くモデルでなく、worker 1プロセス1接続のため安定性が向上する
    • そのため、大量のコネクションを要求する必要なウェブサービスには向かない

Unicorn の特徴について、より詳しくは以下のエントリーで述べられてます。

実際に Apache + Passenger の組み合わせから Nginx + Unicorn の組み合わせにすると、わずかにスループットが上がるなあ、といった感じで大きなパフォーマンス改善は見られませんでした。これは Unicorn の特性が高速化よりも安定性や再起動時のメリット*3、シグナルを送るだけで操作できるシンプルさに重みが置かれてるからで、Rack というインターフェイスを通して操作される Ruby アプリケーションの直接的な速度にはあまり関わってこないことを考えても納得の結果でした。

明示的な GC.start

しかし Unicorn について調べている途中、Tuning UnicornUnicorn::OobGC という面白いアプローチを見つけました。
これは指定のリクエスト回数アプリケーションが処理を終えたら、ユーザにコンテンツを返した後に明示的に GC.start するというアプローチです。何が良いのかというと、GC がユーザのリクエスト外で実行するため、リクエスト中の処理では GC が走りにくくなり、結果ユーザへのレスポンス返却速度が高速化されるというわけです。
これを適用したところ、140%ぐらい遅かった速度が 130%ほどへとわずかに高速化されました。たぶん小規模なアプリケーションだと効果が結構見込めそうなのですが、メモリをだいぶ食ってる大規模なアプリケーションだとこれを使っても GC が頻繁に実行され、結果あまり速くならないようでした。

GC を止める

ここでいっそ GC.disable でユーザからのリクエスト処理中は GC を止めてしまい、リクエスト外で GC をしたらどうなるか、と思いついたので試してみました。
Unicorn は worker が master から fork される特徴があり、fork した直後のプロセスに対して設定で処理を書けるので、unicorn.conf.rb の設定に

after_fork do |server, worker|
  GC.disable if RAILS_ENV == 'production'

の、GC.disable で GC が走らない様にする処理を入れ、OobGC のコードを GC.disable 時にも GC.start を実行できる様に変更し、その後再度 GC.disable で止める処理を入れました。

この GC.disable のベンチマークを結果は以下です。
f:id:secondlife:20111006173812p:image
赤のグラフが GC.disable しなかったサーバで、青のグラフが GC.disable するサーバです。あからさまに GC をユーザからのリクエスト処理中は無効化した方が断然高速な結果に終わりました。Rails 2.3 のサーバと比べても65%遅い、もとい150%ほど高速な結果となりました!!

GC を止める Production のアプリケーションサーバに適用

というわけで、このユーザのリクエスト処理中は GC を無効化する方法でだいぶ高速化することが解ったので、一足先に Unicorn 化していた Rails 2.3 サーバ(Ruby のバージョンは MRI 1.8.7 で Ruby Enterprise ではない)に適用したところ、それだけで 130% ほど高速化 & CPU 消費量が減る *4 という素晴らしい結果になりました。

なお Rails3 ほど顕著に高速化しなかったのは、Rails3 にくらべメモリ使用量が少ないからかと思います。その後 Rails3 に刷新後は当初の Unicorn 化する以前の Rails 2.3 アプリに比べ、最終的に 150% ほど高速化しました。

まとめ

Web アプリケーションの速度の高速化は、IO 処理の高速化を除いてしまうと、チューニングすればするほど大きく改善する方法はなくなっていくと思ってましたが、ユーザのレスポンス処理中は GC を止め、その外で実行するだけで大きくパフォーマンスが改善しました。Ruby 1.8 系統を使ってるサービスは試してみる価値があると思います。
なお今後リリースされる予定の Ruby 1.9.3 では nari3 が実装された Lazy Sweep GC が載るので、OobGC 的な事を Ruby 自体が行ってくれ、GC の最大実行時間が減ると思うので、Ruby 1.9 なサービスは 1.9.3 に変えるだけでパフォーマンス改善がされそうですね。

おまけ・運用ノウハウなど

メモリリークの対応

GC.disable している期間が長いと、GC.start したときに元のコードがメモリリークしてると、よりそのメモリーを解放できない様な感じで、どんどん Rails アプリケーションのプロセスサイズが肥大化して行ってしまいました。
Unicorn は worker プロセスに SIGQUIT を送ると、ユーザのリクエスト処理が終わった直後にプロセスが死んで、master プロセスがそれを検知してすぐに fork する仕組みがあるので、以下のユーティリティを書いて Rack レイヤーで対応しました。

use UnicornKiller::Oom, 400 * 1024 # 使用メモリが400Mを超えると自分自身に SIGQUIT
use UnicornKiller::MaxRequests, 1000 # 1000 回リクエストを超えると自分自身に SIGQUIT

プロセスを終了しても即座に fork で新しい worker が作られるため、こんな感じのざっくりとした対応でも問題無く運用できてます。

Unicorn + bundler でのデプロイ時の問題

突然はまるのでちゅうい…。特に Unicorn は master プロセスへの SIGUSR2 (graceful な再起動)で再起動に失敗した場合、何事もなかったのように昔のプロセスは正常に生き続けるため、ぱっと見問題に気づきません…。

アプリケーションサーバのベンチマークとパラメータの調整

ベンチマークには最初 ab (apache bench) でいくつかの URL でベンチ、次に JMeter で本番リクエストの上位80%のリクエストをエミュレートしてベンチをとりました。環境さえ作ればあとは叩くだけなので、ab / JMeter を使いつつ、アプリケーションサーバのメモリー数、CPU の利用状況を見て Unicorn のプロセス数の調整と Ruby Enterprise Edition の GC 周りの環境変数を調整しました。
この辺はサービスごとに最適値が違うと思うので、ちまちま数字変えてやるのが良いと思います。

*1:Rails 2.3 -> 3 移行はクックパッド規模になるとかなり大変でしたが、いろいろな面白いアプローチを試せたので、これはこれで何処かでお話ししたいですね

*2: Ruby 1.9.2 化も考えましたが、テスト通すまでまただいぶ時間がかかる&いっぺんにやると問題切り分けが難しいので一緒にアップグレードすることは見送りました

*3: graceful な再起動がほとんどエラーや負荷無く行える重要性は、ウェブサービスを運用してる方なら解ると思います

*4: CPU 使用率はあまり変わらないと思ってたんですが大きく減ったのが不思議な感じです…

記事の一覧 >

関連するかもエントリー

リクエストを複製し、2台のサーバ両方にリクエストを飛ばす
リクエストを複製し、2台のサーバ両方にリクエストを飛ばすリクエストを複製し、2台のサーバ両方にリクエストを飛ばすし、片方のサーバのレスポンスを返却することで、2台のサーバどちらとも正常にリクエストを処理できるか、というテストに役立てることができる。production (正常に動...
リクエストを複製し、2台のサーバ両方にリクエストを飛ばすリクエストを複製し、2台のサーバ両方にリクエストを飛ばすし、片方のサーバのレスポンス...
Ruby on Rails が簡単というのはウソ
Ruby on Rails が簡単というのはウソ綺麗に手順を踏んでやる場合には Ruby 初心者は躓くところが多い && 最初の学習コスト高いというのは納得。なおWebエンジニアの場合、こんな感じなのかな。たぶん。Windows だといろいろうまくいかないので Mac 買う or...
Ruby on Rails が簡単というのはウソ綺麗に手順を踏んでやる場合には Ruby 初心者は躓くところが多い && 最初の学習コスト高...
Ruby を 1.9.3 p327 から 2.0.0 dev に上げたら Rails の起動時間が2.5倍速、rake spec の速度が1.8倍速になった
Ruby を 1.9.3 p327 から 2.0.0 dev に上げたら Rails の起動時間が2.5倍速、rake spec の速度が1.8倍速になった流しのフェローが Ruby 2.0.0 速いって言ってたので、いやいや速いっていっても〜、と思って社内の Ruby 1.9....
Ruby を 1.9.3 p327 から 2.0.0 dev に上げたら Rails の起動時間が2.5倍速、rake spec の速度が1...