💎

CapybaraでUnicornを使う際の注意点と対策

に公開

Capybaraがサポートしているサーバーについて

現在Capybaraが公式に対応しているサーバーはWebrickとPumaのみです。ただし、ユーザーが任意のサーバーに対応させるための仕組みは用意されており、適切なハンドラを書き Capybara.register_server することで利用可能なサーバーとして登録することができます。実際に利用する際は Capybara.server = :puma の様にサーバー名を指定します。

公式実装のサーバーは以下のファイルで定義されています。

https://github.com/teamcapybara/capybara/blob/master/lib/capybara/registrations/servers.rb

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 gemat_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 というインスタンス変数に格納します。

https://github.com/teamcapybara/capybara/blob/b3325b198464b806f07ec2011ceb532d6d5cf4ab/lib/capybara/server/middleware.rb#L59-L66

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