rack-timeout の仕組み

https://github.com/heroku/rack-timeout
rack-timeoutとは、rackアプリケーションでリクエストが届いてレスポンス返すまでの時間に対してタイムアウトを設定できるgemである。
仕事で開発しているアプリケーションで、rack-timeout によるエラーが多いのでタイムアウトの仕組みを調べてみることにした。

heroku上で動くアプリケーションでは、リクエストを受けつけてから30秒以内にレスポンスを返さなければ強制的にタイムアウトになってしまうため、rack-timeoutを使って30秒未満のタイムアウトを設定することが多いのではないかと思う。
herokuを使っていなくても何らかによって刺さってしまい、アプリケーションプロセスが詰まるということは起こりうるし、ロードバランサーでタイムアウトになってしまうとアプリケーションでタイムアウトを検出することができないため、rack-timeout はすべてのアプリケーションに導入するべきである。

さて、rack-timeout とはrackミドルウェアで実装されており、use Rack::Timeout, service_timeout: 1と記述することで有効となる。

webサーバやherokuロードバランサーが設定するヘッダーを開始時間として記録しておき、rack-timeout 以降の ミドルウェアでは実行時間を測るスレッドがタイムアウトを検出している。
(たぶん)最小限でrack-timeout を動かすコードを下記に示す。

# サーバ側
require 'rack'
require 'rack-timeout'
require 'pp'
require 'pry'

class Middleware
  def initialize(app)
    @app = app
  end

  def call(env)
    sleep 2
    pp 'called middleware'
    @app.call(env)
  end
end

app = Rack::Builder.app do
  use Rack::Timeout, service_timeout: 1
  use Middleware
  run lambda { |env| 
    [200, {'Content-Type' => 'text/plain'}, ['OK']] 
  }
end

t = Thread.start do 
  Thread.current[:stdout] = StringIO.new
  STDOUT.puts(Thread.current[:stdout].string)
  server = Rack::Server.new(app: app).start
end

# クライアント側
require 'open-uri'
require 'net/http'

retry_count = 0
begin
  puts '.'
  res = Net::HTTP.start("localhost", 8080) {|http| http.get('/') }
  puts res.body
  puts res.instance_eval { @header }
rescue Errno::ECONNREFUSED, Errno::ENOENT => e
  sleep(0.3)
  retry_count = retry_count + 1
  retry_count > 5 ? raise : retry
end

実行すると下記のような出力でエラーになるはずである。

[2017-12-18 20:20:40] ERROR Rack::Timeout::RequestTimeoutError: Request ran for longer than 1000ms

use Middleware の順番を移動することでエラーにならないことを確認できる。

以上。