As Sloth As Possible

可能な限りナマケモノでありたい

タグ:Unicorn

随分長いことブログ放置してしまったのだけどネタ見付けたので久々の記事。

UnicornはPassengerより遅かった?

なんかTwitterで「アクセス少ないときはPassengerよりUnicornのが速いのに、アクセス多くなってきたらその逆になった」って話をみかけたので、それ単にUnicornのworkerが足りないんじゃないの、と返したのだけど、どういうことかという話を少しまとめる。

まず、Unicornのworkerは1プロセスにつき1度に1リクエストしか処理しない。だから例えば、凄い大雑把な計算だけど、平均50msくらいでレスポンスを返すアプリだとすれば、1workerは20req/secくらいは返せるかなと見積もって、ピーク時に100req/secくらいアクセスがありそうだったらworkerを5個くらい立てとくかな、足んなかったらもうちょっとかな、みたいに考える。実際どんくらいのアクセスなのかは聞いてないので知らないけど、Nginxが10workerいるのにUnicornが2workerだって言ってたから、普通逆じゃない、とつっこんだ。Nginxは逆に1workerで複数リクエスト捌ける(数千くらい同時にアクセスされても耐えられるらしい)のでCPUのコア数と同じかちょっと多いくらいでよくて、Unicornのworkerの方をガンガン立ち上げるべき。

Passengerもそれ自体は同じなんだけど、Passengerの場合負荷上がってきたら空気読んで設定した上限までプロセス数を増やしてくれるので、「アクセス増えても強い」のではなくて「アクセス増えたらいつの間にか仲間呼んで倍の人数で戦ってた」とかそういうことじゃないかと思う。上記逆転が起こるのは「Unicornのworkerが少ない」「リクエスト数が増えても1個1個の処理時間は大して変わらない」「メモリやCPUには余裕がある」って条件のときだと思うので、多分Unicornのworker数を適切な数に調整すれば解決する。

workerの数増やしたらメモリ食うんじゃ、って言う話なら、Unicornはworkerプロセスを立ち上げる前にアプリをpreloadする仕組みがある。アプリ初期化時点で必要なライブラリを全部読み込んであれば、workerプロセスの起動は単なるforkなので、CopyOnWriteで親プロセスとメモリを共有して効率的に使ってくれそうな気配はある。それでも食うっちゃ食うけども、worker一杯立ち上げたらメモリ足んなくなるならPassengerでも同じ話。平常時には節約してくれるけども、裏を返せば急激なアクセス増大に際しては慌ててプロセスを沢山作るコストで負荷上がることもあるし、単体で比較するとUnicornのがまぁ普通に強い。大量アクセスがある程度定期的に来るの分かってるならUnicornで、滅多にないならお手軽なPassengerで、みたいな使い分け。

Rainbows!って何

ここまでの話はあくまで、1個1個のリクエストは大して重くないけど一度に沢山のリクエストが来ちゃったら遅くなった、みたいな場合。例えばさっきと違って、平均で見れば100msくらいなんだけど、20回に1回くらいの割合で1500msくらいかかるリクエストがくることがある、とする。それがたまたま5人同時にリクエストしてしまったら、5workerがそれぞれ1.5秒ずつ拘束されてその間一切レスポンスを返せなくなる。内部ではファイルサーバにデカいファイル書いてるとか、DBに重いクエリ投げてるとか、API叩いて外部からデータ取って来てるとかで、実際にはアプリ自体は待ってるだけでCPUもメモリもスッカスカ、みたいな状態であっても。それ以外のリクエストは30msくらいで即答できるのだとしても。

Rainbows!の場合はUnicornをベースにしているけれども、EventMachineとかRevacatorみたいなイベント駆動ライブラリを使って1workerで並列にリクエストを処理できるようにしている。さっきみたいに長時間待ちになるリクエストが来てても、待ってる間に他の数十ms程度で返せるリクエストを受け取って処理して返せる。1プロセス1リクエストの枷が外れるので素のUnicornよりはworker数減らせてコンパクトになるかもしれない。そしてイベントループの中でアプリが動くのでAsyncSinatraみたいなフレームワークを使ってアプリ内で非同期処理を書ける。なので重いリクエストがある場合はこっちを使うといい、って話になる。

なんだ良いことづくめじゃん、Unicornいらない子じゃん、ってなるかと思えばそうでもない。Unicornがそもそもpreforkで1プロセス1リクエストでみたいな、ひどく割り切った、見ようによっては割と古典的な仕組みになってるのは、先行していたイベント駆動型のThinみたいなアプリケーションサーバに対しての「シンプルな方が作るのも管理も楽だし、なんだかんだでパフォーマンスも出るじゃん」みたいなアンチテーゼ的なところだったりするので、わざわざ複雑さを戻してるRainbows!はそういうメリットとのトレードオフになる。使いこなすと素敵な感じにはなるけど、worker増やせば解決する程度の話なら素のUnicornでも大して困らない。

追記

「Unicornシグナル送ればプロセス増やしたり減らしたりできるから、netstatとか見て詰まってそうだったら自動で増えるようにしてるー。滅多に増えないけど」っていう情報を貰った。TTINでプロセス増やす、TTOUでプロセス減らすとかできるので、自動で増減して欲しければなんか適当に監視スクリプト書いて適宜シグナル送るようにしとけばいいかも。まぁ手で送ってもいいし。

どうやらUnicornというのが良いらしいという噂を聞きつけたので、どんなもんじゃろと試してみることにした。

Route 477 - 大規模Railsサイトのための新しいHTTPサーバ、Unicorn

Unicornてのは何者なのかと言うと、Rack及びRailsに対応したRubyのWebアプリ用のHTTPサーバ。詳しくは上の記事を読んで下さい。githubでも使ってるそうだ。あと、名前が格好良い(あんまり関係ない)。

まずはunicornの設定

と言っても、gem install unicornしてconfig.ruがあるディレクトリでunicornコマンドを叩けば、thinとかと同じようにサーバが起動する。rackup互換のオプションも付いてるので特に悩むこともないと思う。あとは普通にApacheとかでプロキシの設定してやるなりなんなりすればすぐ使える。

それだけだと大して面白くないので、折角だから上の記事に書いてあるように、nginxでソケットでっていう設定をしてみる。先にUnicornの方の設定。ちなみにサンプルに使ったのは例によって彼女が404のやつ。今現在はApache2+Passengerで動いている

# unicron.conf
worker_processes  4
working_directory '/var/www/rackapp'
listen '/tmp/rackapp.sock', :backlog => 1
listen 4423, :tcp_nopush => true
timeout 10
pid '/tmp/rackapp.pid'
preload_app  true
stderr_path '/var/log/rackapp.log'

before_fork do |server, worker|
  old_pid = "#{server.config[:pid]}.oldbin"
  if old_pid != server.pid
    begin
      sig = (worker.nr + 1) >= server.worker_processes ? :QUIT : :TTOU
      Process.kill(sig, File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
    end
  end

  sleep 1
end

after_fork do |server, worker|
  addr = "127.0.0.1:#{4423 + worker.nr}"
  server.listen(addr, :tries => -1, :delay => 5, :tcp_nopush => 1)
end

中身はほとんどUnicornの設定例からコピペ。ちなみに、nginxを推奨してるだけあってnginxの設定に似せたDSLになってるけど、実態は見ての通りRuby。その気になれば動的に設定をごにょごにょみたいなこともできる。ログはThinみたいによしなにやってはくれないけど、その辺は設定でLoggerを渡すなりRackのミドルウェアを使ってなんとかするなりすればOK。

んでこれを適当なファイル名で保存したら、次のようにunicornコマンドを叩く。

$ unicorn -D -c /var/www/rackapp/unicorn.conf /var/www/rackapp/config.ru

-Dオプションでデーモン起動、-cオプションは設定ファイルの指定、最後の引数はrackupファイルのフルパス。ちなみにこれを/var/www/rackappじゃないところで実行してみたんだけど、最初は「working_directoryとか指定してるから、そこにあるconfig.ruを勝手に読んでくれたりしないかな」とか思ってたんだけどやっぱりダメで、その後相対パスで指定してみたらrequireでコケて「そんなファイルねーよ、寝言は寝て言え」と怒り出したので、絶対パスにしたら行けた。

この時点でhttp://localhost:4423/がちゃんと見られることと、unicorn.sockが出来てることを確認。よし次はnginx。

nginxのプロキシ設定

こんな感じ。

# nginx.unicorn.conf
upstream rackapp {
    server unix:/tmp/rackapp.sock;
}

server {
    listen       80;
    server_name  localhost;

    location / {
        proxy_pass  http://rackapp;
    }
}

これを大本のnginx.confのhttpのブロックのどこかでincludeしてやる。一応言っとくとこれ以外にも普通のnginxの細かい設定はしてるけど、上の例では省略してます。

ちなみに、ソケットでやるように設定してるからこうなってるけど、TCPで良ければproxy_passのところをhttp://localhost:4423とかにしてやっても普通に動く。

とりあえずこれで完成。あとはnginxを起動してやれば、ちゃんと見られる。素敵。

インターフェースが統一されてるって素敵よね

いやぁ、やっぱRackでインターフェースが統一されてるって、良いですよね。アプリ側あんまり弄らなくてもさくっと移行できるわけですし(っていう話をこないだ書いた)。あと、ついでに同じサーバで動いてたHTTP::Engineのアプリも、apache+modperlからnginx+fcgiに移行してみたけど、こっちもちょこっと設定変えたくらいでアプリ側は全然弄ってない。良いなー。こうして新しい環境が出てきたりしたときにはやっぱりRackやなんやらが真価を発揮する。折角なので次はPlackでH::Eのときと同じことをやってみようと検討中。

んで、結局Unicornは良いの?イイの?

さぁ…ベンチ取ってないのでなんとも。取っても「彼女が404」じゃあんまり参考にならないんだよなー。ただ、アプリをロードし終わってからforkするとかnginx側に気付かせない用に再起動できるとかその辺だけでも十分魅力的ではある。あと、再起動は早かった。流石。

↑このページのトップヘ