人間とウェブの未来

「ウェブの歴史は人類の歴史の繰り返し」という観点から色々勉強しています。

mod_mrubyとngx_mrubyの設計思想とスクリプト間でオブジェクトを共有するためのアーキテクチャ概論

本記事はmod_mruby ngx_mruby advent calendar 2014 17日目の記事です。

昨日は、 @hkusu さんによる「静的リソースをリバースプロキシで配信する《mod_mruby ngx_mruby Advent Calendar 2014》」でした。

httpd.conf
# workerプロセス初期化時
# ngx_mrubyだとmruby_init_workerディレクティブ
mrubyChildInitMiddle /etc/apache2/hooks/geo_init.rb
<Location />
  # リクエスト毎にアクセスチェック
  # ngx_mrubyだとmruby_access_handlerディレクティブ
  mrubyAccessCheckerMiddle /etc/apache2/hooks/geo_check.rb
</Location>
geo_init.rb
# pidをキーにユーザデータにオブジェクトを保存
Userdata.new("geoip_#{Process.pid}").geoip = GeoIP.new("/usr/share/GeoIP/GeoIPCity.dat")
geo_check.rb
# mod_mrubyだとApacheクラス、ngx_mrubyだとNginxクラスが得られる
Server = get_server_class

# pidをキーにユーザデータからオブジェクトを取り出し
geoip = Userdata.new("geoip_#{Process.pid}").geoip
geoip.record_by_addr Server::Connection.new.remote_ip

if geoip.country_code != "JP"
  Server.return Server::HTTP_FORBIDDEN
end

上記のように書く事で、Apacheのworkerプロセス起動時にgeo_init.rbを実行することで、geoipという生成コストの高いオブジェクトをユーザーデータとして保存しておき、リクエストフック時に呼ばれるgeo_check.rbでは、そのオブジェクトを取り出して利用する、という事ができます。

これによって、例えばデータストアへのアクセスを同様にworkerプロセス起動時に実行してセッションオブジェクトを作っておけば、リクエスト時はそのセッションオブジェクトを使いまわす事で、リクエストの度にセッションを貼りに行かなくて良く、大幅にコストを削減することができます。

というように、mod_mrubyやngx_mrubyはソフトウェアの特性上、Webサーバへのリクエスト単位でスクリプトが実行される場合が多いのですが、その都度生成する必要のないようなコストの高いオブジェクトはできるだけ生成を避けるべきです。

そこで、今日は、どういう理屈でオブジェクトを各スクリプト間で取り出せるようになっているか、また、その仕様を決定するまでの試行錯誤を、mod_mrubyとngx_mrubyの設計思想やアーキテクチャの基本もとに解説したいと思います。

アーキテクチャの基本

mod_mrubyとngx_mrubyの設計思想

そもそものmod_mrubyとngx_mrubyの設計思想として、

  • mrubyの処理がなるべくオーバーヘッドにならないようにする
    • 静的コンテンツ処理時にフックしてもそれなりに性能劣化させないようにしたい
  • 複数のRubyスクリプトで特定のオブジェクトを共有できるようにする
    • データストアへのコネクションオブジェクト等
  • 性能よりも利便性が要求される場合は、サーバプロセスを再起動しなくてもRubyスクリプトの変更を反映できるようにする
    • 厳密には変更後の次のリクエスト処理から
  • 利便性よりも性能が要求される場合は、サーバプロセスを再起動しないとスクリプトの変更は反映できないが、高速に動作するようにする
    • 設定の第二引数にcacheオプションを渡すあれ
  • 上記2つの要件は利用者の環境や使い方によって自由に選択できるようなシンプルな作りにする
    • 設定で自由に任意のフックで任意のオプションを設定できるようにする
  • mod_mrubyとngx_mrubyでクラスやメソッドの設計をなるべく同じにする
    • 学習コストの低減
    • Webサーバの拡張実装の差は面倒なので吸収したい
  • 機能追加はできるだけmrbgemを使う事でmod_mrubyとngx_mruby両方に同一の機能を持たせる
    • 各種Webサーバに密に結合しないように機能を追加する
    • クラスやメソッドの設計を同じにする効果もある
    • mrubyのライブラリへの貢献にもつながる

などがあります。(ざっと思い出して書いたので抜けてるかも)

以上を簡単にまとめると、 性能だけではだめ、使いやすいだけでもだめ、どうにかそれらを高いレベルで両立しながらも、利用者の多様性を受け入れる事に試行錯誤する、という思想を持って開発しています。

mrubyインタプリタの確保はコストが非常に高い

mod_mrubyとngx_mrubyは、Apacheやnginxのサーバプロセス起動時にフックして、mrubyのインタプリタ(mrubyではmrb_stateという構造体を指す)の初期化とライブラリの読込を行っています。

実はこの処理が、Webサーバ内部のリクエスト処理と比較した場合、非常にコストの高い処理で、現行のmod_mrubyで自身のベンチマーク環境で仮に10000req/secさばけるような処理内でインタプリタの初期化・開放処理を都度行うと、1000req/sec程度にまで落ち込む事が分かっています。これでは、静的コンテンツを処理するリクエストのフックには、オーバーヘッドとなり過ぎてさすがに使えないと判断し、サーバ起動時にこれらの処理を行うようにしています。

また、リクエスト単位でインタプリタを確保・開放をすると、上記サンプルで示したような、オブジェクトを複数のスクリプトで共有できない(mrubyでは非常に面倒)という問題もありました。

利用者が使い方を自由に決められる中でいかに性能を最適化するか

このアーキテクチャに行きつくまでは、どうせならRubyコードのコンパイルまでサーバ起動時にやれば良いんでは?と誰もが思いつくような話に行きつくのですが、それを単純にやってしまうと今度は、サーバプロセスを再起動しなくてもRubyのコードを変えるだけで振るまいを変更できるというメリットを消してしまう事になります。

mod_mrubyとngx_mrubyの思想として、できるだけ利用者が自身の環境によって自由に使い方を決められて、かつ、その使い方の中で効率良く最適化するにはどうしたらいいかがあります。

そう考えた結果、サーバ起動時にはインタプリタの初期化とライブラリ読込までを行っておき、利用者が性能を重視しキャッシュオプションを使っている場合は、そのまま起動時にバイトコードまでコンパイルして、バイトコードをメモリに保存し、リクエスト時にはバイトコードを直接VM上で実行するようにしました。

また、利用者が利便性を重視したりコンテンツの性質上、mrubyがボトルネックにならない(コストの高い動的コンテンツのフック等)と判断して、キャッシュオプションを渡さず、サーバの再起動無しに振る舞いを変えたい場合は、サーバ起動時にはインタプリタの初期化とライブラリ読込みまでにとどめ、リクエスト時にファイル読み込みからparseしてバイトコード生成しVM上で実行するようにしました。

これによって、設定上は各フックでのキャッシュオプションOn/Offという、非常にシンプルな仕様に落とす事ができました。

また、最もコストの高いインタプリタの初期化とライブラリ読み込みは共通してサーバ起動時に行うため、コストも大きく減らす事ができます。

インタプリタを共有することのデメリット

ただし、やはりインタプリタを共有するというアーキテクチャにはデメリットもあって、

  • グローバル変数や例外フラグ、クラス等のグローバルな状態を同一のインタプリタ上で実行するスクリプト間で共有してしまう
  • インタプリタの開放処理を行わないためメモリが増加傾向にある

というデメリットがあげられます。

このデメリットをそれなりに担保しないことには、単にメリットをとっただけに過ぎず、両立できているとは言えません。

ということで、このデメリットのお話については、 次回の担当のアドベントカレンダーで紹介したい と思います。

workerプロセス単位でユーザーデータの取り出しを可能に

話を戻して、初期化したインタプリタを各workerプロセス上でそれぞれ保存しておき、各workerプロセスがリクエストを受けた際にはそのインタプリタを使いまわす動きをします。

そのアーキテクチャを利用して、mruby-userdataというmrbgemの機能では、Rubyスクリプト側からでは見えないRubyのグローバル変数を、mrubyのC言語側で定義しておき、そのグローバル変数上にハッシュのオブジェクトを作り、任意の文字列をキーにハッシュからオブジェクトを保存したり取り出したりできるようにしています。

ですので、何も考えずにグローバル変数を多用するよりは、明示的にユーザデータとしてオブジェクトをC言語側に存在するRubyのグローバル変数に保存し、必要な時に取り出すという事によって、比較的安全にオブジェクトの受け渡しを行えるようにしています。

workerプロセス単位であることに注意

ユーザーデータの取り出しは、同一のworkerプロセス内でのみ有効です。そのため、例えばクライアントからのアクセスをキーにユーザーデータにオブジェクトを格納し、次のクライアントのアクセスでそのユーザーデータを参照するような場合には注意が必要です。

マルチworkerプロセスでリクエストを待ち構えている場合、別のworkerプロセスがリクエスト処理を担当する場合があり、1回目に格納したオブジェクトを持つworkerプロセスとは別のworkerプロセスからオブジェクトを取り出そうとしてしまい、最初に格納したオブジェクトを取り出せません。

まとめ

ということで、今回はmod_mrubyとngx_mrubyで生成コストの高いオブジェクトを共有すべく考えたアーキテクチャの概要について述べました。

これで、Apacheやnginxの裏側でmrubyのインタプリタがいて、C側に存在するRubyのグローバル変数であるハッシュテーブルにオブジェクトを入れたり出したりしているのだなぁ、と想像しながら実装できるんじゃないかと思います。

こういう記事でmod_mrubyやngx_mrubyの内部仕様について理解を深める事ができたら、Pull Requestなども飛んでくるんじゃないかと期待しつつ、本日はここまでにします。

明日、17日目は @hkusu さんによる記事です!