Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: さようならRack::BodyProxy、こんにちはrack.response_finished(翻訳)

概要

CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

日本語タイトルは内容に即したものにしました。

参考: Rails と Rack - Railsガイド

Rails: さようならRack::BodyProxy、こんにちはrack.response_finished(翻訳)

今やrack.response_finishedが私の心の友です。

🔗 Rackは想像以上に奥深い

rack/rack - GitHub

Rackについて少しでもご存知であれば、おそらく以下のような書き方を見たことがあるでしょう。

# config.ru

class Application
  def call(env)
    [200, {}, ["Hello Rack"]]
  end
end

run Application.new

このアプリケーションは、environment引数を1個持つ#callに応答し、statusheadersbodyを要素として含む配列を返しています。どの概念も全然難しくありませんよね?statusは整数、environmentとレスポンスのheadersはハッシュ、bodyは文字列の配列です。

これはRackアプリケーションとして有効ですが、話はまだ終わりません。この全体像を把握するには、Rackの仕様を読む必要があります。

本記事では、Rackのbodyの仕様に注目します。要件は時とともに進化しますが、Rackのごく初期1からまったく変わっていない、「enumerableなbodyは#eachメソッドに応答して文字列をyieldしなければならない」という仕様が存在します。

つまり、アプリケーションでレスポンス全体をメモリにバッファしたくない場合は、bodyを以下のように実装できるということです。

# config.ru

class Body
  def each
    yield "Hello Rack"
  end
end

class Application
  def call(env)
    [200, {}, Body.new]
  end
end

run Application.new

ここから話が複雑になってきます。

上の「文字列の配列」の例では、bodyの生成後にミドルウェアが何かを実行するのは簡単です。

class LoggerMiddleware
  def call(env)
    response = @app.call(env)

    logger.info "Request processed!"

    response
  end
end

しかしBodyクラスの例では、Webサーバーでbodyに対して#eachが呼び出されるまで、bodyの内容は生成されません。この呼び出しがミドルウェアの#call呼び出しが返された後で行われるのであれば、ミドルウェアは処理後に何をしてやれるでしょうか?

🔗 BodyProxyの登場

ありがたいことに、Rackの仕様にはこんなフックも含まれています。

bodyが#closeに応答する場合、これはイテレーションの後で呼び出される。

ここでProxyオブジェクトが登場します。Proxyオブジェクトは、bodyに対する#close呼び出しをインターセプトして、bodyのイテレーション後にミドルウェアが何らかの処理を行う機会を提供します。

RackでProxyクラスが最初に導入された理由は、enumerableなbodyがイテレーションされる前にRack::Lockがアンロックされてしまう問題を修正するためでした。それから間もなく、この機能がRack::BodyProxyクラスに切り出され、現在では広く用いられています。

class LoggerMiddleware
  def call(env)
    status, headers, body = @app.call(env)

    body = Rack::BodyProxy.new(body) do
      logger.info "Request processed!"
    end

    [status, headers, body]
  end
end

🔗 BodyProxyの困った点

BodyProxyを使えば、レスポンスのbodyが生成された後でミドルウェアでの処理を可能にしますが、このソリューションは完璧とは言えません。

最も明らかな欠点は、ミドルウェアごとに独自のBodyProxyをアロケーションしてしまうことです。ミドルウェアが増えてくると、レスポンスのbodyは最終的に以下のような感じになります。

BodyProxy.new(BodyProxy.new(BodyProxy.new(BodyProxy.new(["actual body"]))))

Rubyオブジェクトのアロケーションは高速化されつつあるとはいえ、今もパフォーマンスのボトルネックになりがちです。アロケーションの数だけガベージコレクタの作業が増えるため、アプリケーションが遅くなります。アロケーションを完全に回避できる代替案が欲しいところです。

もうひとつの問題は、BodyProxyがリクエストのライフサイクルでタスクを実行するタイミングが早すぎるために、特定のタスクを実行できなくなる可能性があることです。GitHubのブログ記事でも、メトリクスを発行するときにRack::Eventsを利用できなかった(BodyProxyが使われていて、メトリクスの発行が完了するまでページの読み込みが完了しないように見えるため)ことについて言及しています。

Shopifyでもこれと同様の問題が発生しました。メトリクスを発行している間、PitchforkというWebサーバーがリバースプロキシへの接続を開きっぱなしにするため、オープン中のプロキシ接続数が増えてパフォーマンスが悪化したのです。

この問題に対するGitHub(と私たちShopify)のソリューションは、メトリクスを発行するタイミングをBodyProxyよりも後にすることで、コネクションが完全にクローズするようにすることです。その方法こそ、rack.after_replyです。

🔗 友情が生まれた

rack.after_replyが2011年にPumaに追加されたのは、BodyProxyのすぐ後のことでした。rack.after_replyは、リクエストのenvironmentに保存された呼び出し可能なオブジェクトのシンプルな配列で、レスポンスのbodyがcloseした後で呼び出されます。

この機能はその後Unicornにも追加され(673c15e)、さらにrack.response_finishedとしてRack 3の仕様にオプションとして組み入れられました(856c4f9)。

Webサーバーは、このrack.response_finishedをサポートしていることを、リクエストのenvironmentに含めることで示せます。ミドルウェアは、コールバックを追加する形でコールバックを登録できます。

class LoggerMiddleware
  def initialize
    @callback = ->(env, status, headers, error) {
      logger.info "Request processed!"
    }
  end

  def call(env)
    # ほら、アロケーションが発生しない!

    if response_finished = env["rack.response_finished"]
      response_finished << @callback
    end

    @app.call(env)
  end
end

このコールバックは、「リクエストのenvironment」「レスポンスのheadersstatus」「error」という4つの引数を受け取らなければなりません2environmentは省略不可ですが、status/headerserrorは相互排他なのでいずれか一方のみを受け取ります。

🔗 BodyProxyがまだある理由

rack.response_finishedの採用は随分と遅れました。しかし、これは「卵が先かニワトリが先か」という問題そのものとも言えます。Webサーバーがこの機能を実装していないうちは、アプリケーションやフレームワークがこの機能を取り入れる理由はありません。そしてアプリケーションやフレームワークがこの機能をサポートしていないうちは、Webサーバーもこの機能を取り入れる理由がありません3

Falconも、Rack 3のリリースを見越してrack.response_finishedを実装しました(10b8ade )が、Pitchforkが昨年rack.response_finishedを追加したとき(7ac65bb)まで、Rack 3仕様に基づいた実装はFalconしかありませんでした。

しかし、Railsで「構造化イベントレポーター」機能のプルリク(#55334)がオープンされると、 ついにrack.response_finishedが勢いづき始めました。

既に述べたように、Shopifyではメトリクスの発行をrack.after_replyや rack.response_finishedのコールバック内で行うようにすることで、レスポンスの送信後に接続を不必要にオープンしたままにしないようにしています。同じ理由で、リクエストの概要も(イベントレポーターを用いて)ここでログに出力しています。

これがきっかけで、イベントレポーターを本家Railsにアップストリームする際に、ある興味深い課題が発覚しました。イベントレポーターのcontextは、リクエスト間で漏洩しないよう、その都度クリアされなければなりませんが、既存のリクエスト分離メカニズム(ActionDispatch::Executorミドルウェア)でBodyProxyが使われていたのです。BodyProxyが使われているということは、contextがクリアされるタイミングが、rack.response_finishedでリクエストの概要をログ出力可能になるよりも「」になるということです。

この部分を期待通りに動かすため、同僚のAdriannaと私がrack.response_finishedActionDispatch::Executorに追加しました(#55425)。

Executorはこれによって、イベントレポーターのcontextrack.response_finishedによってリクエストの合間のタイミングでクリアされるようになり、リクエストサマリーのログ出力からイベントレポーターにアクセス可能になりました!

🔗 こうしてrack.response_finishedが心の友となった

ActionDispatch::Executorrack.response_finishedを実装して以来、アプリケーションで使われている他のRackミドルウェアで動いていたBodyProxyを置き換える機会を伺っていました。

Rack 3.2でもBodyProxyがさらに削減される予定で、Rack::ConditionalGetRack::HeadはどちらもBodyProxyを使わなくなります。

また、Rack::TempfileReaper#2363、マージ済み)とActiveSupport::Cache::Strategy::LocalCache#55447、オープン中)をサポートするプルリクもオープンしました。

最後に、Pumaでも最近rack.response_finishedのサポートがマージされました(1b08ed7)。

この調子でrack.response_finishedの勢いが続けば、Rack::BodyProxyとのお付き合いも終わりを迎えることでしょう。

関連記事

Ruby 3.5でClass#newのアロケーションが6倍高速化される(翻訳)


  1. 原注: 実は、Rackのこの要件がどのぐらい昔からあったのかに興味が湧いたので、git-spelunkで掘ってみたところ、Rack::Lint最初に追加されたときに入ってきたことがわかりました。 
  2. 原注: 「受け取らなければならない(MUST)」という文言は、実は最近になって仕様に追加されました(-> {}のような引数を受け取らないコールバックの定義を防ぐため)。 
  3. 原注: 公平のために申し添えておくと、PumaもUnicornもPitchforkも既にrack.after_replyをサポートしていましたが、Rackの仕様の一部としてではありませんでした。 

CONTACT

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