CapybaraでUnicornを使う際の注意点と対策
Capybaraがサポートしているサーバーについて
現在Capybaraが公式に対応しているサーバーはWebrickとPumaのみです。ただし、ユーザーが任意のサーバーに対応させるための仕組みは用意されており、適切なハンドラを書き Capybara.register_server
することで利用可能なサーバーとして登録することができます。実際に利用する際は Capybara.server = :puma
の様にサーバー名を指定します。
公式実装のサーバーは以下のファイルで定義されています。
Unicornを利用する方法
さて、前述の通りユーザーが任意のサーバーに対応させるための仕組みは用意されているため、Unicornを利用したい場合も自作のハンドラを書いてしまえばCapybaraを利用可能です。
Unicorn 版のサーバーは以下のような定義となるでしょう。
# spec/support/capybara_unicorn.rb
Capybara.register_server :unicorn do |app, port, host, **options|
require 'unicorn'
default_options = {
listeners: ["#{host}:#{port}"],
worker_processes: 1,
timeout: 60, # テスト実行中のタイムアウトを避ける
preload_app: false,
rewindable_input: true,
logger: Logger.new(nil), # ログを抑制
check_client_connection: false,
}
unicorn_options = options.fetch(:unicorn_options, {}).with_defaults(default_options)
Unicorn::HttpServer.new(app, unicorn_options).start.join
end
以下のように利用します。
# spec/support/system_spec_helper.rb
Capybara.server = :unicorn, {
unicorn_options: {
logger: Rails.logger,
worker_processes: 2,
before_fork: ->(_server, _worker) {
if defined?(ActiveRecord::Base)
ActiveRecord::Base.connection.disconnect!
end
},
after_fork: ->(_server, _worker) {
if defined?(ActiveRecord::Base)
ActiveRecord::Base.establish_connection
end
},
},
}
ですが、これだけではUnicornがPrefork型アーキテクチャを採用していることに起因した問題が発生します。
Prefork型アーキテクチャによる問題
Capybaraはサーバープロセスをメインプロセス内の別スレッドとして起動します。pumaなどのサーバーであれば問題はありませんが、サーバーがUnicornなどのマルチプロセス型サーバーの場合、いくつかの問題が発生します。ここでは、それぞれの問題とその解決方法を解説します。
1. debug gem利用時、Workerプロセスの終了を無限に待機してしまう
debug
gem は at_exit
hook にて全てのサブプロセスが終了するのを待つため、debug
gem 使用時は明示的に Unicorn の Worker プロセスを終了させないと無限に待機してしまい、RSpec実行が終了しないという問題があります。
対策として、Unicorn は Master プロセスに SIGQUIT
を送信すると Graceful Shutdown を行うので、これを利用することができます。
その際、Unicorn Master プロセスは Capybara の仕組み上 RSpec 実行時のメインプロセスに同居し別スレッドとして起動しているため、自身のPIDに対してシグナルを送れば良いです。ただし、メインプロセス内の他の部分で SIGQUIT
を Trap しているとそちらにも影響が出る可能性があるため、注意しなくてはなりません。
参照: https://github.com/ruby/debug/issues/1113
# spec/support/system_spec_support.rb
# debug gem 用のワークアラウンド
RSpec.configure do |config|
config.after(:suite) do
# debug gem は at_exit hook にて全てのサブプロセスが終了するのを待つため、
# debug gem 使用時は明示的に Unicorn の Worker プロセスを終了させないと無限に待機してしまい、RSpec実行が終了しない。
#
# Unicorn は Master プロセスに SIGQUIT を送信すると Graceful Shutdown を行うので、これを利用する。
# ここで、Unicorn Master プロセスは Capybara の仕組み上 RSpec 実行時のメインプロセスに同居し別スレッドとして起動しているため、
# 自身に対してシグナルを送れば良い。
#
# メインプロセス内の他の部分で SIGQUIT を Trap しているとそちらにも影響が出る可能性があるため、注意すること。
#
# ref: https://github.com/ruby/debug/issues/1113
if defined?(DEBUGGER__)
Process.kill(:QUIT, Process.pid)
end
end
end
2. アプリケーションで発生した例外をCapybara/RSpecが検知できない
System Specでアプリケーションが例外を吐いた場合、本来は Capybara.raise_server_errors = true
設定にてRSpec側で補足することができます。
通常の場合、CapybaraがRackアプリケーションを立ち上げる際、Capybara::Server::Middleware
というRackミドルウェアがアプリケーションに差し込まれます。アプリケーションで発生した例外はこのミドルウェアで補足され、ミドルウェアの error
というインスタンス変数に格納します。
Capybara.raise_server_errors = true
と設定されている場合、Capybara::Session#raise_server_error!
を呼ぶことでミドルウェアに格納された例外がそのタイミングで再度 raise されます。Capybara::Session#raise_server_error!
は適切なタイミングで自動で呼び出されます。
ここで、Unicorn使ってる場合はprefork型サーバーになるため、例外を補足しようとしているRSpecプロセスではなくUnicorn Workerプロセスにて例外が発生することとなります。その結果として、エラー情報が格納されるのはforkされたWorkerプロセス側となり、RSpecプロセスでは例外の補足に失敗してしまいます。
対策として、何らかのIPCを用いてWorkerプロセスで発生した例外情報をRSpecのプロセスに渡してあげる必要があります。
私の場合、Capybara::Server::Middleware
にモンキーパッチし、エラー補足処理をマルチプロセス対応させるという対策を行いました。
具体的には、Unixドメインソケット経由でWorkerプロセスから例外情報をMarshal.dumpして書き込み、Masterプロセスから読み込んでMarshal.loadすることで例外情報を復元するというものです。
# spec/support/capybara_unicorn_support.rb
require 'capybara-playwright-driver'
# Socket を使って Unicorn の Worker プロセスで発生した例外をメインプロセスに伝えるための拡張
require 'socket'
module CapybaraMiddlewareExtension
def initialize(app, server_errors, extra_middleware = [])
# Worker と Master で使うソケットペアを生成
@reader, @writer = Socket.pair(:UNIX, :DGRAM, 0)
super
end
# エラー情報をセット(Writer 側から送信)
def error=(error)
data = Marshal.dump(error)
@writer.send(data, 0)
end
# エラー情報を取得(Reader 側で受信)
def error
begin
loop do
data, *_ = @reader.recvmsg_nonblock(exception: false)
break if data == :wait_readable
break if data == :wait_writable
if data
@error = Marshal.load(data) # rubocop:disable Security/MarshalLoad
end
end
# ソケットが閉じられていた場合
rescue EOFError
return @error
end
return @error
end
def call(env)
begin
status_code, headers, body = super
rescue *@server_errors => e
# Unicorn の Worker プロセスで発生した例外をソケット経由でメインプロセスに伝える
self.error = e
raise e
end
[status_code, headers, body]
end
end
Capybara::Server::Middleware.prepend(CapybaraMiddlewareExtension)
Capybara.raise_server_errors = true # サーバーエラーを例外として扱う
Railsアプリケーションの場合、以下のようにアプリケーションがケアしない例外が握りつぶされない様な設定にしておく必要がある点に注意してください。
# config/environments/test.rb
Rails.application.configure do
# テスト環境ではエラーは画面表示ではなく直接例外をRSpec側で補足する
config.consider_all_requests_local = false
# Rails がケアする例外でない場合、握りつぶさない
config.action_dispatch.show_exceptions = :rescuable
end
終わりに
以上です。Unicornだけでなく、Pitchforkなどのサーバーでも同様の対策が必要になってくるでしょう。ほぼ同じアプローチで解決できるはずです。
Discussion