2012-04-03

Railsで遅いMiddlewareを探す

AさんはRailsで書かれたある遅いコードの検証をしていました。
X-Runtimeヘッダを見ると

$ curl -Is localhost:3000/hello | grep X-Runtime
X-Runtime: 5.008580

5秒もかかってる。
しかしRailsのログを見ると

Started HEAD "/hello" for 127.0.0.1 at Tue Apr 03 13:04:11 +0900 2012
Processing by HelloController#index as */*
  Rendered text template (0.0ms)
Completed 200 OK in 10ms (Views: 9.7ms)

こんな感じで10msで返していることになっている。なんだこれは?

こういう状況で疑わしいことの一つとして、Rack等のMiddlewareのどれかが重いのではという懸念があります。そこで、今日は各Middlewareの速度を測ってみようと思います。

測り方

ActionDispatch::MiddlewareStack::Middleware.class_eval do
  def build_with_benchmarker(app)
    app.class_eval do
      def call_with_benchmark(env)
        result = nil 
        ms = Benchmark.ms { result = call_without_benchmark(env) }
        puts "MIDDLEWARE END #{self.class.to_s} (#{ms.to_i}ms)"
        result
      end 
      alias_method_chain :call, :benchmark
    end 
    build_without_benchmarker(app)
  end 
  alias_method_chain :build, :benchmarker
end

こんな感じのコードを environment.rb のロードの前あたりに入れます。で、リクエストをしてみると

MIDDLEWARE END ActionDispatch::Routing::RouteSet (2ms)
MIDDLEWARE END SlowMiddleware (5003ms)
MIDDLEWARE END ActionDispatch::BestStandardsSupport (5003ms)
MIDDLEWARE END Rack::ETag (5004ms)
MIDDLEWARE END Rack::ConditionalGet (5004ms)
MIDDLEWARE END ActionDispatch::Head (5004ms)
MIDDLEWARE END ActionDispatch::ParamsParser (5005ms)
MIDDLEWARE END ActionDispatch::Flash (5006ms)
MIDDLEWARE END ActionDispatch::Session::CookieStore (5006ms)
MIDDLEWARE END ActionDispatch::Cookies (5006ms)
MIDDLEWARE END ActionDispatch::Callbacks (5006ms)
MIDDLEWARE END ActionDispatch::Reloader (5007ms)
MIDDLEWARE END ActionDispatch::RemoteIp (5007ms)
MIDDLEWARE END ActionDispatch::DebugExceptions (5007ms)
MIDDLEWARE END ActionDispatch::ShowExceptions (5007ms)
MIDDLEWARE END Rails::Rack::Logger (5008ms)
MIDDLEWARE END ActionDispatch::RequestId (5008ms)
MIDDLEWARE END Rack::MethodOverride (5008ms)
MIDDLEWARE END Rack::Runtime (5008ms)
MIDDLEWARE END ActiveSupport::Cache::Strategy::LocalCache::Middleware (5008ms)
MIDDLEWARE END Rack::Lock (5008ms)

こういう感じで出力されます。

  :
MIDDLEWARE END ActionDispatch::Routing::RouteSet (2ms)
MIDDLEWARE END SlowMiddleware (5003ms)
  :

となっており、このSlowMiddlewareが遅そうというのが分かりました。よかったね。

補足

Middlewareは共通の親クラスがなくて、callメソッドをダックタイピングしているだけなので、全部のMiddlewareのcallに共通の処理を差し込むのはちょっと面倒でした。で、Middlewareの追加(build)時にcallを上書きしてしまうのがよいかなーと思ってこうしました。

2012-02-27

capistranoで今タスクを実行中のホスト名を取得する

問題

$ cap hoge ROLES=app HOSTS=app-01,app-02,app-03

としたときに、各ホストに、ホスト名をファイル名にしたファイルをtouchするようなタスク hoge を記述せよ。

ただし、/etc/hostnameを参照してはならない。

こたえ

task :hoge do
  run "touch $CAPISTRANO:HOST$"
end

$CAPISTRANO:HOST$ はcapistranoが内部的に使ってるプレースホルダなので、ドキュメントにも無いし、今後サポートされるかは不明。

結論

可能なら/etc/hostnameを配布しましょう
とはいいつつもクラウドだとホスト名けっこう困るよね

参考

Getting the currently running host name in Capistrano | Code for Concinnity
@bongole さん情報ありがとうございます!

2012-02-21

ActionView::Templateのcompile済み中間コードをキャッシュする

まずRailsのviewについて説明する。

Railsのviewはerbとかhamlとかで書く。この人間が書いたviewファイルはtemplateと呼ばれ、ActionView::Templateクラスで扱われる。一つのviewファイルが一つのTemplateオブジェクトになる。
このTemplateクラスはTemplate#compileというメソッドを持っている。このcompileメソッドは、人間が書いたerbなりhamlなりのコードをrubyのコード文字列に変換する。controllerでrenderメソッドを呼んだ時、実際に実行されるのは、このrubyコードである。

このcompile処理はそれなりに重いため、何度も呼びたくない。手元の環境では、でかいテンプレートのコンパイルには数百ミリ秒かかることもちょいちょいある。

しかしcompileは、render時に毎回走るわけではなく、一度compileされたtemplateは、ActionVew::Baseのメソッドとして保存されるため、同じtemplateを二回以上renderしても、二回目以降はcompileは走らない。*1つまりcompileが重い=Railsの起動直後に重い処理が走るっていうことになる。

compileが重くてなぜ問題になるのか

重いcompileが走るのが起動直後だけなら、運用上問題ないじゃんって思うかもしれないけど、世の中には、unicornのGCを止めてレスポンスを高速化するっていう、 id:secondlife 氏考案の変態ハックがあって、これを使ってるとRailsの初期化コストが問題になってくる。

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

このハックを使っているとレスポンスタイムが高速化する代わりに、unicornのworkerプロセスが肥大してどんどん死に、どんどん生まれ変わるから、初期化処理がしょっちゅう走ることになる。だからこなしているリクエストの数のわりにはCPU負荷が高めになる。ちょっとでも初期化コストを軽くしたい気持ちが伝わったかな???

キャッシュの時間です

というわけで、一度目のコンパイル後に中間コード(ruby)をファイルとして保存しておき、コンパイル処理が軽くなるようにする。中間コードがファイルに保存されていれば、unicornのworkerが生まれ変わっても、もう一度中間コード生成する必要がない。長い目で見るとCPU負荷を下げる効果が期待できる。

actionpack/lib/action_view/template.rb にモンキーパッチをあて、FileStoreキャッシュを入れる。

まずTemplate#compileのメソッドを丸コピしてくる。

ActionView::Template.class_eval do
  def compile_with_caching
    #    :
    # (略)
    #    :
    code = @handler.call(self)
    #    :
    # (略)
    #    :
  end
  alias_method_chain :compile, :caching
end

この @handler.call が、HamlなりERBなりのコンパイラを呼んでrubyコードを生成して文字列で返している部分。というわけでこれをキャッシュすればいい。

ActionView::Template.class_eval do
  def compile_with_caching
    #    :
    # (略)
    #    :
    cache_key = "template/#{self.inspect}"
    code = ActionController::Base.cache_store.fetch(cache_key) do
      @handler.call(self)
    end
    #    :
    # (略)
    #    :
  end
  alias_method_chain :compile, :caching
end

他にも色々書くべきことはあるけど、概念としてはこんな感じ。cache_storeはActiveSupport::Cache::FileStoreにしておいてね。

2012-02-19

unicornのタイムアウト時にもRailsのログをちゃんと出力させる

unicornはconfで

timeout 20

とかやっとくと、20秒以上かかったらそのworkerが殺される。それはいい。問題はその殺され方にあって、タイムアウトしたunicorn workerはmasterにKILLシグナルで強制的に殺される。KILLで殺されてしまうと、worker側でtrapして安全に終了処理をすることができない。
一番困るのは、Railsのloggerは(production環境のデフォルトだと)リクエストが終了するまでバッファリングしているので、リクエストの途中でKILLされてしまうとloggerがflushされない。つまり、unicornのタイムアウト時には、リクエストのログは一切production.logには出力されない。異常時のログが出ないとか、まじで困ると思うんだけど、みんなどうしてんだろ。

これに対処するためにはunicornのコードに手を入れるのが手っ取り早い。方法はいくつかあると思うけど、タイムアウトでunicorn workerを殺すときのシグナルをconfで指定できるようにしてみた。
Commit d0b24198af57383d1c09f7be79c7e59c161bcfe5 to cookpad/unicorn - GitHub

timeout 20
timeout_signal :INT

このtimeout_signalというのが今回unicornに追加したオプション。これを指定するとKILL以外のシグナルでworkerを殺すようになる(デフォルトはKILL)。たとえばINTならこんなふうにworker側でtrapできる。

after_fork do |server, worker|
  trap (:INT) do
    Rails.logger.error "Unicorn Timeout (trapped SIGINT)"
    Rails.logger.flush
    Process.kill :KILL, $$
  end
end

workerがINTをtrapしたら、Rails.loggerをflush(バッファに溜まっているログを吐かせる)してから、自分自身をKILLする。これでとりあえず目的は果たせた。

応用編:タイムアウト時に実行していたSQLをログに出す

タイムアウト時のログを出すだけじゃなくて、何をやっていたときにタイムアウトしたかをちゃんとログに出力したい。タイムアウトしがちなのはやはりDBのスロークエリだろう。というわけで、unicornタイムアウト時に実行していたSQLをログに出すようにしてみる。
これも色々やり方はありそうだけど、今回はmysql2_adapterにモンキーパッチをあてることにする。

::ActiveRecord::ConnectionAdapters::Mysql2Adapter.class_eval do
  def execute_with_timeout_logging(sql, name = nil)
    result = nil
    @@current_query_info = {:sql => sql, :start_time => Time.now}
    result = execute_without_timeout_logging(sql, name)
    @@current_query_info = nil
    result
  end
  alias_method_chain :execute, :timeout_logging

  def self.current_query_info
    unless defined?(@@current_query_info)
      @@current_query_info = nil
    end
    @@current_query_info
  end
end

で、さっきのunicornのconfに、ログの処理を追加する。

trap(:INT) do
  info = ActiveRecord::ConnectionAdapters::Mysql2adapter.current_query_info
  if info
    ms = ((Time.now - info[:start_time])*1000).to_i
    ::Rails.logger.error "Query Aborted (#{ms}ms) #{info[:sql]}"
  end
  Rails.logger.error "Unicorn Timeout (trapped SIGINT)"
  Rails.logger.flush
  Process.kill :KILL, $$
end

これでタイムアウトしたときに実行していたSQLがproduction.logに出るようになる。

おわりに

今回はunicornにtimeout_signalオプションを実装した。unicornは多くの部分がrubyで書かれているので、自分で簡単にいじれるから助かる。このtimeout_signalオプションをしばらくINTで運用してみてるけど、うまく動いているので、ちゃんとドキュメントとtestを書いて本家にpush requestしたいと思ってる。

注意しなくちゃいけないのは、KILL以外のシグナルでworkerを殺すようにすると、暴走したworkerがちゃんと死んでくれないという状況が想定される。暴走してるworkerを見つけてはKILLするcronを書くなり、そういう運用があわせて必要になると思う。

2012-01-31

bundlerでgithub上のunicornを強引に入れる

みんな、unicornをカスタマイズして使う時は、オフィシャルのgithubリポジトリ(defunkt/unicorn)からforkしていじると思う。
そしたらそれを↓こんな感じにbundlerから入れてみたいと思うのが人情

gem 'unicorn', :git => 'git://github.com/mirakui/unicorn.git', :ref => '〜〜'

でも、unicornのgemspecはbundler friendlyになってないので、このままだとインストール出来ない。なんでかっていうと

  • make等で生成される、gitリポジトリには含まれてないファイルがgemspecで呼ばれている
  • gem build時に'VERSION'環境変数が無いとエラーになるようになってる

から。

これを強引にbundler対応させる。以下のようにする。(unicorn-4.2.0前提)

1. defunkt/unicornをforkする

あきらかに汚い方法なので、自分のリポジトリとしてforkしてからやりましょう
https://github.com/defunkt/unicorn

2. unicorn.gemspecを修正

VERSION環境変数を無理やり指定する。

@@ -1,4 +1,5 @@
 # -*- encoding: binary -*-
+ENV["VERSION"] = "4.2.0"
 ENV["VERSION"] or abort "VERSION= must be specified"
 manifest = File.readlines('.manifest').map! { |x| x.chomp! }
 require 'wrongdoc'

3. gem buildに必要なファイルをmakeする

$ make ragel
$ make .manifest

make ragelは、ext/unicorn_http/unicorn_http.rlからext/unicorn_http/unicorn_http.cを生成する。
make .manifestは、文字通り.manifestを生成する。このファイルにはリポジトリに含まれてるファイルの一覧が書かれる。
あと.manifestのmakeにはpandocが必要になると思う

4. .manifestを修正する

.manifestから、gemの使用には直接関係ないけど書かれていることによってビルドがこけるファイルをめんどうなので消す。ちゃんとそれぞれmakeしてもいいんだけど…
以下の6つを消せばいい。

5. git add -f 〜 git push する

.manifestとunicorn_http.cは.gitignoreに書かれているんだけど、強引にaddしちゃう。自分のリポジトリならいいよね。

$ git add -f .manifest ext/unicorn_http/unicorn_http.c
$ git commit
$ git push

6. 完成

ここまでやってpushしたリポジトリならbundle installできる。

# Gemfile
gem 'unicorn', :git => 'git://github.com/mirakui/unicorn.git', :ref => '〜〜'
$ bundle install

やりましたね

結論

こんな感じに、だいぶダーティにやらないといけないので、githubに置いたオレオレunicornをGemfileに書くのは現状厳しいと思う。別の手段がとれるなら(gemにして配布するとか)そっちを検討するべきだと思う

ちなみに

unicornは"v4.2.0"みたいなgit tagがそのリビジョンについてないと、unicorn-4.2.0.gemが生成できないようになってるみたい。ガッチガチですな

2012-01-19

rails runner的なことをrails runnerを介さずに直接やる

rails runner コマンドを使うと、各modelsとか、config.autoload_pathsに指定したファイルとかをロードした上でスクリプトを実行できる。

$ rails runner hoge.rb

ただ、毎回 rails runner とか書くのはだるいので、hoge.rbを独立したスクリプトにしちゃいたい。
そんなときは下みたいに書くと、rails runnerと同等の環境をロードした状態にできる。

#!/usr/bin/env ruby
require File.expand_path('../../config/application',  __FILE__)
Rails.application.require_environment!

# 以下、いつものようにスクリプトを書く

これをscript/ 以下とかに配置すればいける。

ついでにいうと、thorを組み合わせると便利な感じになる。

#!/usr/bin/env ruby
require File.expand_path('../../config/application',  __FILE__)
Rails.application.require_environment!

require "thor"

class CLI < Thor
  desc "hello", "say hello"
  def hello
    puts "hello world!"
  end
end
CLI.start

(この例だとhelloの中でrails関係のファイルを使ってないから意味ないんだけど、まあやりたいことは伝わりますよね)

$ script/hoge hello
hello world!

こんな感じでオレオレコマンドを作れるし便利。

結論

rakeでやれ

2011-12-16

ActiveRecordのSQL実行をフックしていろいろやる

ActiveRecordで、あるSQLが発行された時に、そのSQLが実行されるDBのコネクション情報を調べたいときがある。

Hoge.find() したときにどういうSQLが発行されているのかというのはdevelopment環境ならデフォルトでdevelopment.logにクエリログが出る。
このログ処理は active_record/connection_adapters/abstract_adapter.rb に書かれてる。

# active_record/connection_adapters/abstract_adapter.rb
198         def log(sql, name)
199           name ||= "SQL"
200           @instrumenter.instrument("sql.active_record",
201             :sql => sql, :name => name, :connection_id => object_id) do
202             yield
203           end
204         rescue Exception => e
205           message = "#{e.class.name}: #{e.message}: #{sql}"
206           @logger.debug message if @logger
207           raise translate_exception(e, message)
208         end
209 
210         def translate_exception(e, message)
211           # override in derived class
212           ActiveRecord::StatementInvalid.new(message)
213         end

これをフックして処理を書けばいい気がするけど、クエリログがオフになってる環境ではこのメソッドは実行されないのでちょっと不便。
だからこう書くことにする。*1

::ActiveRecord::ConnectionAdapters::Mysql2Adapter.class_eval do
  def execute_with_logging(sql, name = nil)
    result = execute_without_logging(sql, name)
    # ここで解析処理を書く。
    # DBの情報は @config というハッシュに入ってる。
    # たとえば @config[:database] でDB名、 @config[:username] でDBのユーザ名が取得可能
    result
  end
  alias_method_chain :execute, :logging
end

ActiveRecordが発行するSQLは必ずDBアダプタのexecuteメソッドで実行されるので、それをフックしている。