Tech Racho エンジニアの「?」を「!」に。
  • インフラ

次世代Ruby on RailsサーバーUnicorn(汎用のRackアプリケーションサーバ)を使ってみた

2010.07.20追記: prefixを指定した運用も可能でした。ご指摘頂きありがとうございます。
2010.07.28追記: 関連記事「RailsサーバUnicornを飼いならす! 運用時の便利技」へのリンクを張りました。

Railsサーバはたくさんあってややこしいですね!
最近さらにUnicornというものが頭角を表してきたようで、Twittergithubも使っているようなので使ってみましたので、特徴や使い方などレポートしてみたいと思います。
このブログの他にもEngine Yardのブログ記事「Everything You Need to Know About Unicorn」やgithubの記事「Unicorn!」が非常に参考になると思いますので、あわせてご覧ください。
(そもそもUnicornは用途をRailsに限定しない汎用のRackアプリケーションサーバです。タイトルは煽り気味ですね。すいません。)

ざっくりと、Unicornのアーキテクチャとそれにまつわるメリットデメリットをリスト形式で。

  • thinやmongrelみたいなマルチプロセスによるclusterではなく、forkを使ったmaster-slave
    • マルチプロセスモデルよりメモリ効率がいいかも?(copy-on-write)
    • ふくれあがったメモリ食い過ぎプロセスを殺しても、サービスにダウンタイムが発生しない
      • Monitとかでふくれあがったプロセスに対してQUITを送ると、そいつは処理中のリクエストを処理したら死ぬ
        • 親がそいつの代わりをすぐrespawnする
    • デプロイが早い
    • デプロイ時のダウンタイムがない
  • apache => app-server-clusterという風なpush requestではなくapache <= unicornというpullリクエスト
    • slaveプロセスが共通のソケットを通じてリクエストを受け取る
    • ひまなプロセスが処理を開始する(ソケットからリクエストを取り出す)
    • 処理中のもっさりアプリケーションサーバにあたることがない
      • もっさりサーバは処理がおわってないのでリクエストを取りにいくことがないから

それでもって、以下が実際に使ってみた印象です。

  • CPUあんまり食わない
  • メモリあんまり食わない
  • 確かにデプロイ(起動/再起動)早い。
  • たまに重いアクションを叩かれるような場合ではそのリクエストを処理しているworkerにあたることがなくなるので、全体のスループットを向上できる?
  • prefixを指定した運用ができない prefixを指定して運用可能です:unicorn_railsの--pathオプションで指定できます。

RailsアプリをMongrelやThinのクラスターで運用するとメモリをたくさんお食べになられるのが、結構悩みのタネだと思います。
スモールスタートのプロジェクトではフロントのロードバランサ、アプリケーションサーバ、DBサーバも全て1台でやるのが経済的理由からあたり前ですので、ThinやMongrelがメモリを食いまくるからといってサーバをもう1台追加しなくてはならないようでは積極的に使いにくいですよね。
しかしこのメモリ食いまくる現象にも理由はあり、Engine Yardのブログ記事「That’s Not a Memory Leak, It’s Bloat」でもActiveRecordのインスタンスが大量生成され、Rubyがそれを解放しないからふくれあがってしまうという主な理由が説明されていました。
もちろん優れたハッカーが多数いるRailsコミュニティでは対策も当然あり、Monitやそれに似たRubyベースのモニタリング(&再起動)ツールGodなどを駆使してCPUを使いすぎていたり、メモリを使いすぎているインスタンスに対して自動的に再起動をかけるのが一般的です。

しかしこのモデルでは以下のような一般的な構成で、サービスの安定性を追求した際に問題があります。

ロードバランサとThinクラスタ

ロードバランサは重み付けなしの設定を行うと、リクエストごとに、バックエンドのアプリケーションサーバに対して順番にリクエストを行います。ここで3番目(右はじ)のサーバに対して「レスポンスを返すまで時間がかかる地雷アクション」へのリクエストが来たとします。

クラスタの一部が重くなってる状態

このまま6回目のアクセスがくると、6回目のリクエストには3回目のリクエストが終わってないので処理が返せません。
こ6回目のアクセスがくる前に、左側のthinインスタンスと真ん中のthinインスタンスが4回目のリクエストと5回目のリクエストの処理を終えていれば、処理を肩代わりしてほしいところですが、リクエストの振り分けはapacheが上流で行っているので、難しいという状況です。

これと同じことがプロセスの再起動時にもおこります。ここで同様に右端のインスタンスが激重アクションによりメモリ使用量が爆増し、監視しているMonitなりGodなりが再起動をかけているとします。Railsのスタックのロードには1〜2秒程度時間がかかるので、アクセス数の多いサイトだったらこの間にリクエストをapacheから振られる可能性はゼロではありません。

再起動中のインスタンスがある場合

この例では右端のインスタンスが再起動を開始した1秒以内に3つのアクセスがくると、3つ目が準備が終わっていない3つめのインスタンスに来てしまいます。ここでは直感的には1つめの左端のインスタンスが、リクエストが終わり次第処理してほしい気がします。

ここでUnicornのロードバランシングのアーキテクチャを図にして見ました。

Unicornのアーキテクチャ

Unicornでは、MasterとSlave(図ではchildと書いています)がおり、Masterは起動するとSlaveをforkして生産します。mongrelをたくさん立ち上げるのと同じイメージですね。そしてApacheからのロードバランシングでは、Apache(ロードバランサ)が接続する先はMaster1つで、Unicornの内部でMasterとSlaveがリクエストをやりとりし、Slaveが処理した結果をMaster経由でApache(ロードバランサ)に返します。このやりとりというのがミソで、ひまになったSlaveがMasterに対して「次に処理するリクエストをくれ」という風にPULLしにくるのです。Masterは一番最初にpullしてきたSlaveに処理を行わせるだけでよいですね。こうすると、なにがうれしいかというとさっきのような例で「重い処理をしている」「再起動中」といったプロセスがリクエストの処理を担当することが原理的に起こりえません。このような状態では処理すべきリクエストをmasterに対してpullすることができないからですね。

また、forkを使うことによってOSのcopy-on-write機能により実際に使う実メモリ使用量が減る、Railsのコードをロードしなおさなくてよいなどのメリットがあるようです。

また、プログラムを更新する際にダウンタイムを作らない仕組みをうまく作っているのも面白いです。MongrelやThinクラスターをproductionモードで動かしている場合はクラスタ全体のの再起動が必要で、1〜2秒 * インスタンス数という時間が必要です。慌てて何度もやっているとサービスに影響が出てしまいますし、そもそもアプリケーションサーバがたくさんあるような場合では時間がかかってめんどくさいですね。Unicornではこの辺もエレガントに解決できており、UnicornのマスタープロセスにUSR2シグナルを送ると、もうひとつmasterプロセスを作って引き継ぎを開始します。新しいmasterプロセスは古いmasterプロセスから、上流のロードバランサと通信しているポートの引き継ぎを行います。古いmasterプロセスはUSR2シグナルを送っただけでは死なないのですが、後半に載せてあるgithubに公開されている設定スクリプトのように古いmasterに対してQUITシグナルを送る処理を自動化することも可能です。USR2シグナルを使って新しいmasterを起動するときはだいたい古いmasterはいらないと思われるので、before_forkでQUITシグナルを送ってしまうのがよいでしょう。

$ sudo kill -USR2 (masterのpid)

だけですみます。楽ですね!しかも今までthinインスタンス10個でクライアントの処理が完了するのを待っているのを含めて10秒以上かかっていたのがものの2〜3秒でできてしまいました。

という感じで、非常にいい感じのUnicornくんですが、日本語ではまだあまり情報がないので以下にハウツーとして実際に使えるコマンドや設定などを列挙していきたいと思います。

インストール

$ sudo gem install unicorn

※Windowsではまともに動かないっぽいです

起動

# productionモード(-E), デーモン化する(-D), 詳細をconfigファイルで指定
$ cd RAILS_ROOT
$ cd unicorn_rails -c config/unicorn-config.rb -E production -D

-Dオプションでデーモン化を指定しない場合フォアグラウンドプロセスとして動き、production.logに記録される内容も標準出力として出力されます。フォアグラウンドプロセスとして動いている場合、Ctrl+Cでunicorn masterと全てのunicorn workersの動作を停止させることができます。

設定ファイル(ここではconfig/unicorn-config.rbとして指定されている)の書き方については後述。

停止

そもそもUnicornではダウンタイムなしで新しいコードをproductionモードでデプロイできるので、停止の必要はめったにありませんので注意。
Unicornにおいては設定最読み込みとどうようにhogehoge stopというようなコマンドはなく、INT(TERM)/QUITシグナルを送ることにより、停止させます。QUITシグナルはgraceful shutdownで、全てのworkerがリクエストの処理を終えるのを待ちます。INTまたはTERMシグナルを送った場合は、すぐにworkerプロセスを全て皆殺しにします。(quick shutdown)

# unicornのmasterプロセスのIDを特定する
$ sudo pgrep -f 'unicorn_rails master'
12345

# graceful shutdown: 現在処理中の全てのリクエストの処理が終わるのをまってからシャットダウン
$ sudo kill -QUIT 12345

# quick shutdown: 現在処理中の全てのリクエストの処理を中断し、シャットダウン
$ sudo kill -INT 12345

設定再読み込み

Unicornにおいては設定を再読み込みさせるにはhogehoge reloadというようなコマンドはなく、HUPシグナルを送ることによって実現される。シグナルを送る先はunicornのmasterプロセス。masterのプロセスID(pid)を特定するには以下のようにする。
preload_app(デフォルトfalse)をtrueにしていると、プログラムコードはリロードされません。(逆にいえばpreload_appがfalseのままならmasterにHUPを送るとリロードされる)

# unicornのmasterプロセスのIDを特定する
$ sudo pgrep -f 'unicorn_rails master'
12345

# masterプロセスにHUPシグナルを送る
$ sudo kill -HUP 12345

プログラムのデプロイ

手順は公式のSIGNALのページに詳しく書いてあります。

# unicornのmasterプロセスのIDを特定する
$ sudo pgrep -f 'unicorn_rails master'
12345

# masterプロセスにUSR2シグナルを送る
$ sudo kill -USR 12345

再起動

先にも述べましたが、そもそもUnicornではダウンタイムなしで新しいコードをproductionモードでデプロイできるので、停止の必要はめったにありません。
それでも再起動するなら、停止→起動ですね。

設定ファイルの書き方

まずは、公式で参考として配布されている2つを見てみてフィーリングをつかむのがよさそうです:

githubのブログ記事で公開されているものを改変して、うちで使っているものも公開しちゃいます。

$default_env = "production" # デフォルトRailsEnv
$unicorn_user = "bps" # slaveの実行ユーザ
$unicorn_group = "bps" # slaveの実行グループ

$dev_processes = 4 # dev環境用子プロセス数
$prod_processes = 16 # 本番環境用子プロセス数

$timeout = 75 # タイムアウト秒数。タイムアウトしたslaveは再起動される

# String => UNIX domain socket / FixNum => TCP socket
#$listen = "/home/bps/tmp/unicorn.sock"
$listen = 5000

# ---- end of config ----

# Main Config for Unicorn
rails_env = ENV['RAILS_ENV'] || $default_env
worker_processes (rails_env == 'production' ? $prod_processes : $dev_processes)
preload_app true
timeout $timeout
listen $listen, :backlog => 2048

# For RubyEnterpriseEdition: http://www.rubyenterpriseedition.com/faq.html#adapt_apps_for_cow
if GC.respond_to?(:copy_on_write_friendly=)
  GC.copy_on_write_friendly = true
end

# workerをフォークする前の処理
before_fork do |server, worker|
  ##
  # When sent a USR2, Unicorn will suffix its pidfile with .oldbin and
  # immediately start loading up a new version of itself (loaded with a new
  # version of our app). When this new Unicorn is completely loaded
  # it will begin spawning workers. The first worker spawned will check to
  # see if an .oldbin pidfile exists. If so, this means we've just booted up
  # a new Unicorn and need to tell the old one that it can now die. To do so
  # we send it a QUIT.
  #
  # Using this method we get 0 downtime deploys.

  old_pid = RAILS_ROOT + '/tmp/pids/unicorn.pid.oldbin'
  if File.exists?(old_pid) && server.pid != old_pid
    begin
      # 古いマスターがいたら死んでもらう
      Process.kill("QUIT", File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
      # someone else did our job for us
    end
  end
end

# workerをフォークしたあとの処理
after_fork do |server, worker|
  ##
  # Unicorn master loads the app then forks off workers - because of the way
  # Unix forking works, we need to make sure we aren't using any of the parent's
  # sockets, e.g. db connection

  ActiveRecord::Base.establish_connection
  #CHIMNEY.client.connect_to_server
  # Redis and Memcached would go here but their connections are established
  # on demand, so the master never opens a socket

  ##
  # Unicorn master is started as root, which is fine, but let's
  # drop the workers to git:git

  begin
    uid, gid = Process.euid, Process.egid
    user, group = $unicorn_user, $unicorn_group
    target_uid = Etc.getpwnam(user).uid
    target_gid = Etc.getgrnam(group).gid
    worker.tmp.chown(target_uid, target_gid)
    if uid != target_uid || gid != target_gid
      Process.initgroups(user, target_gid)
      Process::GID.change_privilege(target_gid)
      Process::UID.change_privilege(target_uid)
    end
  rescue => e
    if RAILS_ENV == 'development'
      STDERR.puts "couldn't change user, oh well"
    else
      raise e
    end
  end
end

nginxをロードバランサにした場合の設定例

upstream backend-unicorn {
        server 192.168.xxx.xxx:3000;
}

server {
        listen   80;
        server_name hogehoge.bpsinc.jp;

        access_log  /var/log/nginx/hogehoge.bpsinc.jp.access.log;

        location / {
                proxy_pass_header Server;
                proxy_set_header Host $host;
                proxy_set_header X-Forwarded-Host $host;
                proxy_set_header X-Forwarded-Server $host;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                #proxy_set_header X-Geo $geo;
                proxy_read_timeout 75; # unicorn設定ファイルのtimeoutも忘れずに
                proxy_pass http://backend-unicorn; # upstreamで定義したバックエンド

                # 通常と違うポートでフロントサーバ(ロードバランサ)を動かしているときはこれが必要
                #proxy_redirect http://hogehoge.bpsinc.jp/ http://hogehoge.bpsinc.jp:8080/;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
                root   /var/www/nginx-default;
        }
}

参考リンク

どうでしたでしょうか?
BPSでもまだUnicornはノウハウが十分に蓄積されていませんが、積極的に使っていってノウハウをためていこうと思います。

運用時のTipsについて、続編を書きましたので合わせてごらんください:RailsサーバUnicornを飼いならす! 運用時の便利技

※7/13追記: USR2シグナルをmasterプロセスに送ったときの挙動の記述について、一部間違いがあったので修正。古いmasterは自動では死にませんね... あとnginxでの設定例を追加してみました。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。