人間とウェブの未来

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

H2Oのmruby拡張が実用的になってきた件

H2Oにmruby拡張の提案を行いmergeされてから幾つかのPRを経て、少しずつ実用的になってきました。

github.com

今日は簡単にその使い方を紹介しようと思います。

h2o_mrubyを有効化したh2oをビルド

h2o_mrubyを有効化してビルドするのは簡単で、OSのライブラリ環境(/usr/lib/以下とか)にlibmruby.a等のmrubyライブラリがある状態で、

cmake -DWITH_MRUBY=ON .
make h2o

するだけで、h2o_mrubyが有効化されたh2oバイナリがカレントにビルドされます。簡単ですね。

またこの記事も参考にすると良いかもしれません。

qiita.com

使えるメソッド

mod_mrubyやngx_mruby程メソッドはまだ充実していませんが、少しずつ使えるものを実装しています。また、mod_mrubyやngx_mrubyとの互換性を考慮したクラス・メソッド設計にしています。

H2Oクラス

H2Oクラスは以下のメソッドを持ちます。

H2O.return

このメソッドは、任意のステータスコードとレスポンスを返したり、またmrubyハンドラ自身でレスポンスを返さずに、次のハンドラに処理を渡す(DECLINED)事ができます。

例えば、クライアントに404を返したい場合は、

H2O.return 404, "not found", "Your request file was not found on this server"

とかけます。

次のハンドラに処理を渡す場合は、単に、

H2O.return H2O::DECLINED

と書くだけで良いです。この意味については後述します。

H2O::Requestクラス

主にクライアントからのリクエストを受けて処理する間に得られるパラメータを操作するメソッドを定義しています。少し数が多いので、最初にコードの一例を紹介します。

# curl http://127.0.0.1:58080/index.html?a=1 -H User-Agent:curl-dayo

r = H2O::Request.new

r.hostname    #=> "127.0.0.1"
r.authority   #=> "127.0.0.1:8080"
r.uri         #=> "/index.html?a=1"
r.path        #=> "/index.html?a=1"
r.method      #=> "GET"
r.qeury       #=> "?a=1"
# If not found query, r.query return nil

# request headers
r.headers_in["User-Agent"]  #=> "curl-dayo"
r.headers_in["User-Agent"] = "new-#{r.headers_in["User-Agent"]}"

# response headers
r.headers_out["hoge"] #=> nil
r.headers_out["hoge"] = "fuga"

r.log_error "Accept request to #{r.uri}"

H2O.return H2O::DECLINED

H2O::Request#hostname

リクエストのあったhostnameを得られます。ポートを除いたホスト名です。

H2O::Request#authority

リクエストのあったauthorityを得られます。ポートも含んだホスト情報になります。

H2O::Request#uri

リクエストのURLが得られます。この値はunparsedなURLでqueryも込みのURLになります。

H2O::Request#path

H2O::Request#uriと同じ処理です。

H2O::Request#method

GETやPOSTといったHTTPのメソッドを得られます。

H2O::Request#query

H2O::Request#uriにおける?以降のqueryパートを得られます。

H2O::Request#{headers_in,headers_in=}

リクエストヘッダの値を取得したり生成・上書きすることができます。上記例では、例としてUser-Agentを上書きしています。これによって、例えばアクセスログのUser-Agent箇所は新しいnew-prefixの付いた値になっているはずです。

H2O::Request#{headers_ouot,headers_out=}

レスポンスヘッダの値を取得したり生成・上書きすることができます。H2O.return H2O::DECLINEDと組み合わせることでレスポンスヘッダを追加したレスポンスを返す事ができます。

ざっと紹介すると以上のようなメソッドになります。これだけでも、アクセス制御のような処理は簡単に実装できそうなのが想像できたでしょうか。

その他細かい仕様

  • Rubyコードの戻り値がnilだった場合は次のハンドラに処理を渡す
  • Rubyコードの戻り値が文字列だった場合は200ステータスコードと共にその文字列をレスポンスとして返す
  • ただし、H2O.returnメソッドが書かれていた場合はその処理が優先される

という仕様です。明示的にH2O.returnを書いておくと混乱しないと思います。

アクセス制御の実装例

ということで、最後はよりイメージしやすくするためにアクセス制御の実装例を紹介します。

やりたい事としては、

  • warui-uaというUser-Agentからアクセスがあったら403を返す
  • それ以外は普通にレスポンスを返す

という処理だったとします。

ではコードと設定例を紹介します。

  • Rubyコード(warui.rb)
r = H2O::Request.new

if r.headers_in["User-Agent"].to_s == "warui-ua"
  r.log_error "denied #{r.uri}"
  H2O.return 403, "Forbidden", "your user-agent is warui\n"
else
  H2O.return H2O::DECLINED
end
  • h2o.conf
listen: 8080
hosts:
  "127.0.0.1.xip.io:8080":
    paths:
      /:
        file.dir: examples/doc_root
        mruby.handler_path: examples/h2o_mruby/warui.rb
    access-log: /dev/stdout

上記のようになります。

この状態で、以下のようにアクセスすると、

$ curl 127.0.0.1:8080/ -v
* About to connect() to 127.0.0.1 port 8080 (#0)
*   Trying 127.0.0.1... connected
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.19.7
> Host: 127.0.0.1:8080
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Wed, 22 Jul 2015 00:05:44 GMT
< Server: h2o/1.4.0-alpha1
< Connection: keep-alive
< Content-Length: 177
< content-type: text/html
< last-modified: Tue, 28 Apr 2015 05:21:29 GMT
< etag: "553f18d9-b1"
< accept-ranges: bytes
< 
<!DOCTYPE html>
<html>
  <head>
    <title>Welcome to H2O</title>
  </head>
  <body>
    <p>Welcome to H2O - an optimized HTTP server</p>
    <p>It works!</p>
  </body>
</html>
* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0

悪いUser-Agentじゃないので、普通にindex.htmlのレスポンスが帰ってきます。これはmrubyハンドラがレスポンスを返すのではなく、H2O.return H2O::DECLINEDによって処理を次のハンドラに渡しているからです。

つまり、headers_outによって新しいレスポンスヘッダを追加していると、このindex.htmlのレスポンスのレスポンスヘッダに任意のヘッダを追加できたりします。

一方で、以下のようにアクセスすると、

$ curl 127.0.0.1:8080/ -H User-Agent:warui-ua -v
* About to connect() to 127.0.0.1 port 8080 (#0)
*   Trying 127.0.0.1... connected
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> Accept: */*
> User-Agent:warui-ua
> 
< HTTP/1.1 403 Forbidden
< Date: Wed, 22 Jul 2015 00:05:27 GMT
< Server: h2o/1.4.0-alpha1
< Connection: keep-alive
< Content-Length: 25
< content-type: text/plain; charset=utf-8
< 
your user-agent is warui
* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0

というように、謎メッセージと共に403がきちんと返って来ました。ちゃんとアクセス制御できていますね。

ログにも以下のように出力されていました。

127.0.0.1 - - [21/Jul/2015:11:41:19 +0900] "GET / HTTP/1.1" 200 177 "-" "curl/7.37.1"
[h2o_mruby] in request:/:denied /
127.0.0.1 - - [21/Jul/2015:11:41:35 +0900] "GET / HTTP/1.1" 403 25 "-" "warui-ua”

うまく動いてそうですね。

ベンチマーク

さて、最後にこのアクセス制御がどの程度オーバーヘッドがあるかを計測してみましょう。今回はHTTP/1.xとHTTP/2両方で評価したいと思います。設定と使用するコードは上記の設定からログ出力を除いたものにします。また、ベンチマーク環境は、IIJGIOのV240を使いました。

アクセス制御の処理は常に実行されるので、通常のUser-Agentでアクセスしindex.htmlが返ってくる処理に対してベンチマークを行い、それに対して、まったくmrubyの処理が実行されずにindex.htmlが返ってくる場合と比較しました。

  • HTTP/1.xのベンチマークコマンド
ab -k -c 100 -n 100000 http://127.0.0.1:8080/
  • HTTP/2のベンチマークコマンド
h2load -m 100 -c 100 -n 1000000 http://127.0.0.1:8080/

以下結果です。(値はrequests/secです)

HTTP/1.x HTTP/2
h2o_mruby 40441.10 212225
h2o 40495.10 214328

上記のように、大きな性能劣化は見られませんでした。むしろ、何度かベンチマークをかけているとmruby側が性能速かったりと、基本的にはこの上記の性能差は誤差の範囲でありほぼ同等の性能といえるでしょう。mrubyはH2O起動時に既にコンパイルされ、各スレッド単位でバイトコードの状態で保持しているので、HTTPサーバの処理から見るとほとんどオーバーヘッドにはならない処理のように思えます。とはいえ、Rubyの実装の仕方や規模の増加に伴って、この辺りは左右されていく事は言うまでもありません。

ですが、index.html程度の軽量な処理に対して、mrubyハンドラを処理しているにも関わらずほとんどオーバーヘッドにならない結果は、比較的良い結果といえるでしょう。

まとめ

ということで、h2oのコアに取り込まれているh2o_mruby機能の実用的な使い方について紹介しました。また、性能面においてもそれほど気にしなくても良いレベルでの実装になっていると思います。

是非一度、h2oを触ってみるついでにh2o_mruby機能も有効にして色々と試して頂けるとおもしろいと思います。

今後はH2O::ServerクラスやH2O::Connectionクラスを実装して、H2Oの設定を取得したりコネクション情報を元に制御できるようにしていく予定です。

後は、プロキシまわりのサポートやそれにともなってfilterモジュールの実装も進むとより実用性が増しそうですね!