読者です 読者をやめる 読者になる 読者になる

ブログのしゅーくりーむ

技術的なメモとかライフログとか。

mruby の実装を読んで知見をまとめてみている

これは mruby Advent Calendar 2016 3 日目の記事です。

mruby に関するプロダクトを実装していると、 mruby 本体の実装に関して情報が欲しくなる場面があります。(ありますよね?) そんな時は Qiita 等巷で共有されている情報とか、あるいは 組込Ruby「mruby」のすべて を読んだりすると思うのですが、しばしばそれで満たされず mruby のソースコード を追うことがあります。 ご多分に漏れず僕もソースコードを読んだりとかしていたのですが、そこから得た知見をまとめておきたいなぁという気持ちがふつふつと湧き上がってきました。

というわけで mrubook というリポジトリを作成し、ソースコードから読み取ったノウハウをこの配下にしこしことドキュメント化してみています。

https://syucream.github.io/mrubook/

現状、下記のようなポリシーでドキュメント化を行っています。

  • 基本的に mruby のリポジトリの src/ 以下の各ファイルに対応するドキュメントを個別のファイルとして書く
  • フォーマットは 1) 概要 2) 詳細 3) C や mruby 向けの API に関する情報 の 3 本立て
  • ドキュメントを html 化し、 gh-pages で閲覧可能にする
    • 現在は Sphinx で html 生成をしている。 master ブランチに変更が入った際に travis-sphinx を用いてビルドを行い gh-pages ブランチを更新

まだまだ内容が乏しいですが、そのうち誰かの役に立ったら良いな・・・なんて思っております。 また PullRequest など大歓迎です!

さて短い内容で申し訳ありませんが、本記事としては以上になります。 明日 4 日目の記事は k0kubun さんによる「Rubyを評価するCLIツールはCRubyとmrubyのどちらで実装するべきか」とのことです。よろしくお願いいたします!

ApacheTrafficServer と mruby で、 Key ヘッダ処理を実装してみる

こんにちは。一番好きな Key 作品は Air です。

本記事では draft-ietf-httpbis-key-01 - The Key HTTP Response Header Field により定義される、 "Key" という HTTP ヘッダフィールドによって実現されるセカンダリキャッシュキーの処理を、拙作プロダクト ts_mruby を用いて mruby で実装してみた体験などについて記述します。 ts_mruby 自体や、関連技術である Apache Traffic Server(ATS) 、はたまた mruby については本記事では詳しくは触れません。もしよろしければ、 僕が以前執筆したブログエントリ や巷のイイカンジの記事など参考にしていただければ幸いです。

順序としては 1) 前述の I-D で定義される Key ヘッダについて簡単に説明を行い、 2) ts_mruby 上で Key の実装を行う上での大雑把な指針と要件を確認し、 3) ATS が持つセカンダリキャッシュキーの取り回しのロジックを確認し、 4) 今回行った Key 実装の設計の話に触れて、 5) 簡単に Key の実装に関して解説し、 6) 実際の動作結果を見つつ 7) Key の仕様や周辺技術に関する懸念について記述してみます。

1. Key ってなんぞや?

1-1. Key 登場の背景

Key はざっくり言ってしまうと、高機能な Vary の代替です。 Vary というのは HTTP レスポンスヘッダフィールドのひとつであり、 HTTP レスポンスのキャッシュを制御するために用いられます。

HTTP レスポンスのキャッシュに関しては RFC7234 に規定されており、セクション 2 で触れられている通り URI もしくはメソッドと URI のペア毎にキャッシュを持つ(このペアがプライマリキャッシュキーとなる)ことになります。 ただしこの方針では、同一のキャッシュキーであるにも関わらず、リクエストの内容によってレスポンスを出し分けてかつキャッシュしたい場合に要件を叶えることができません。 これは例えば、 http://www.example.com/ への GET リクエストに対して、日本のユーザには日本語の文書 、その他のユーザには英語の文書を返す場合などに困ることになります。

Vary はこの課題を解決するものであり、その解決方法として HTTP リクエストヘッダによってレスポンスの内容が変化(vary)するため、キャッシュも個別管理する必要がことを示すことが可能になります。 前述の例だと、 Vary: Accept-Language などを返すことにより、 GET リクエストに含まれる Accept-Language の内容によってキャッシュを別々に分けることが可能になり、言語毎にキャッシュを混在されることはなくなります。 Vary によってプライマリキャッシュキーに加え、キャッシュキーを追加して管理している(セカンダリキャッシュキーを導入している)格好になります。

Vary は指定したヘッダフィールド名のヘッダフィールド値をシンプルにセカンダリキャッシュキーとして扱うという、分かりやすい機構でありますが、それゆえに効率が犠牲になっている場合があります。 また前述の例を出しますと、例えば日本語・英語圏以外のユーザにも共通して英語の文書を提供したいと考えても、 Accept-Language の値が異なれば個別のキャッシュを保持してしまいます。 これによりキャッシュヒット率の低減や、キャッシュを保持する領域の浪費につながることになります。

そこで表題の Key ヘッダフィールドが殺伐としたキャッシュ界隈に降り立ちます。 Key では指定のヘッダフィールド値の内容まで踏み込み、指定文字列にマッチするかどうかなどを判定した上でキャッシュを出し分けるなど、 Vary より高度なセカンダリキャッシュキーの算出が可能になります。

1-2. Key の持つ機能

Key では I-D の draft-01 の時点で、指定のヘッダフィールド値について 5 パターンのパラメータを持たせることができ、これによって柔軟なキャッシュキーの算出を可能にしています。 Key によって、例えば substr パラメータを用いると下記のようにキャッシュのパターンを 3 つに抑えることができます。

f:id:syu_cream:20161002212608p:plain

以下では各項目について、ざっくりと取り上げてみます。ちなみに下記に上げるものはほとんど I-D の draft-01 から引用しています。オリジナルの内容もぜひ合わせてご参照ください。

1-2-1. div

div パラメータでは整数値で指定のヘッダフィールド値を除算した結果(余りは切り捨て)をセカンダリキャッシュキーとして用いります。

下記のような Key ヘッダを返した際は・・・

Key: Bar;div=5

下記が含まれたリクエストに対してはキャッシュキー '0' になり、同一のキャッシュを参照するようになります。

Bar: 1
Bar: 3 , 42
Bar: 4, 1

下記の場合はキャッシュキー '2' となり、前述のものとは別のキャッシュとして扱われます。

Bar: 12
Bar: 10
Bar: 14, 1

1-2-2. partition

partition パラメータではカンマ区切りの整数値でキャッシュキーの分割を行います。

下記のような Key ヘッダを返した際は・・・

Key: Foo;partition=20:30:40

下記が含まれたリクエストに対しては「20 未満のセグメント」に所属する扱いになり、キャッシュキー '0' となります。

Foo: 1
Foo: 0
Foo: 4, 54
Foo: 19.9

下記が含まれたリクエストに対しては「20 以上30未満のセグメント」に所属する扱いになり、キャッシュキー '1' となります。

Foo: 20
Foo: 29.999
Foo:  24   , 10

1-2-3. match

match パラメータでは指定の文字列と完全一致するヘッダフィールド値を持つかどうかでキャッシュキーを生成します。

下記のような Key ヘッダを返した際は・・・

Key: Baz;match="charlie"

下記が含まれたリクエストに対しては charlie というヘッダフィールド値を持つので、キャッシュキー '1' となります。

Baz: charlie
Baz: foo, charlie
Baz: bar, charlie     , abc

下記が含まれたリクエストに対しては charlie というヘッダフィールド値を持たないので、キャッシュキー '0' となります。

Baz: theodore
Baz: joe, sam
Baz: "charlie"
Baz: Charlie
Baz: cha rlie
Baz: charlie2

1-2-4. substr

substr パラメータは match パラメータと似ているのですが、こちらは文字列の部分一致で判定を行います。

下記のような Key ヘッダを返した際は・・・

Key: Abc;substr=bennet

下記が含まれたリクエストに対しては bennet を含むヘッダフィールド値を持つので、キャッシュキー '1' となります。

Abc: bennet
Abc: foo, bennet
Abc: abennet00
Abc: bar, 99bennet     , abc
Abc: "bennet"

下記が含まれたリクエストに対しては bennet を含むヘッダフィールド値を持たないので、キャッシュキー '0' となります。

Abc: theodore
Abc: joe, sam
Abc: Bennet
Abc: Ben net

1-2-5. param

param パラメータは、 ,, ;, = セパレータで区切られたヘッダフィールド値の指定の値をそのままキャッシュキーとして用いります。 主に Cookie の一部の値を用いてキャッシュを出し分けたい場合に使われる気配がありますね。

下記のような Key ヘッダを返した際は・・・

Key: Def;param=liam

それぞれ liam で指定された値をセカンダリキャッシュキーとして扱います。

Def: liam=123           // '123'
Def: mno=456            // ''
Def:                    // ''
Def: abc=123; liam=890  // '890'
Def: liam="678"         // '"678"'

1-3. 閑話: なんで本記事で Key を扱うの?

本記事執筆段階で Key はあまり盛り上がっていないっぽくて??? I-D の draft-01 が expire してもうじき一ヶ月といった状態です。 ではなぜ扱ったのかというと、ドックフーディング的に ts_mruby でなにかお手頃なアプリケーションを記述したく思い、ちょうどよい難易度の題材かなと思ったためです。 なんだか不純な気配がありますが、ともかく話を進めていきます。

2. 大雑把な指針

ATS はコア機能としてメモリ・ディスクキャッシュ機能を提供しており、今回はこれに Key によるセカンダリキャッシュキーを解釈することを可能にすることを考えていきます。 Key の解釈のための処理フローを、今回はクライアントのリクエストに対する処理とオリジンサーバからのレスポンスに対する処理に分割して考えてみます。

まずは下図のようにオリジンサーバからのレスポンスヘッダから Key ヘッダを読み出します。この時読み出した Key ヘッダの値は後続のリクエストで使用したいので、どこかに保持しておく必要があります。

f:id:syu_cream:20161002182837p:plain

これが済んだ後、次に下図のように、クライアントからのリクエストに対して保持しておいた Key ヘッダに基づく処理をしていきます。大きく分けて 3 つのステップを踏むことになります。

  1. 保持した Key ヘッダの値の読み出し
  2. Key ヘッダに基づくセカンダリキャッシュキー計算
  3. セカンダリキャッシュキーを用いたキャッシュ分離

f:id:syu_cream:20161002183137p:plain

この処理フローを実現するためには、現状 ATS に Key ヘッダに関する特別な処理は実装されていない都合、その部分は mruby スクリプトでゼロから実装する必要があります。 セカンダリキャッシュキーの扱いに関しては、似たような機能(次に触れるキャッシュジェネレーション)は既にサポートしているので、今回はそれをうまく利用する方法を考えていきます。

3. ATS で実現するセカンダリキャッシュキー

ATS には キャッシュジェネレーション という、キャッシュを出し分ける機能が存在します。 この機能は整数値のパラメータを取り、その値によってキャッシュキーから生成するハッシュ値を分離します。 具体的には、 ATS はプライマリキャッシュキーとして URL を取り、 MD5 ハッシュ値を生成してキャッシュを特定するロジックを持つのですが、キャッシュジェネレーションを設定すると MD5_Update() が呼ばれてこのハッシュ値が変化するという次第です。

ATS のキャッシュジェネレーションは本来は「世代をインクリメントし、従来の世代のキャッシュを一斉にパージする」という目的に使われます。 そのため、本来想定されるキャッシュジェネレーションのユースケースと今回の Key 実装を併用すると生成されるハッシュ値が想定するものとは異なるものになってしまって(アカン)となる可能性があるのですが・・・今回はプロトタイプ実装ということで一旦許容することとします。

ちなみにキャッシュジェネレーションは、 ATS のリクエスト毎に上書き可能な設定となっており、この上書き可能な設定は ts_mruby-0.1 の時点で既に ATS::Records というクラスを通して読み書き可能になっています。 今回はキャッシュジェネレーションの整数値の ID (以下、ジェネレーション ID)を算出し、 ATS::Records#set を用いてセットすることでセカンダリキャッシュキーの設定を実現することを考えます。

4. 今回の Key 実装の設計について

Key 処理の設計は下記の通りです。 ts_mruby で実行するスクリプトに 2 つのコンポーネントを実装します。また、いくつかのメタデータを Redis で管理します。

f:id:syu_cream:20161002213555p:plain

4-1. Redis によるメタデータ管理

前述の通り、 Key 実装では Key ヘッダの記録や読み出しが行えなければなりません。 またセカンダリキャッシュキーを実現するのにキャッシュジェネレーション機構を用いるため、セカンダリキャッシュキーとジェネレーション ID のマッピング情報も保持する必要があります。

今回はこの課題に対応するため、 Key ヘッダの内容とセカンダリキャッシュキーとジェネレーション ID のマッピング情報を Redis で管理することにしました。 Redis を用いる理由としては下記が挙げられます。

  1. 2 段階のキャッシュキーを用いるという構造を Hash 型で表現しやすい
  2. コマンドが豊富で実装が容易
  3. 高パフォーマンス
  4. mruby-redis という便利な mrbgem が提供されている

Redis で管理する Key-Value の構造は下記のように規定しました。これらにより、 Key ヘッダの値自体と、それぞれのセカンダリキャッシュキーとジェネレーション ID のマッピング情報を管理可能にしています。

{
  <URL>: {
    'key': <Key ヘッダの内容>,
    'max-genid': <現在割り当てられているジェネレーション ID の最大値>,
    '<セカンダリキャッシュキーA>': <A のジェネレーション ID>,
    '<セカンダリキャッシュキーB>': <B のジェネレーション ID>,
    ...
  },
  ...
}

4-2. Key 処理のコンポーネント

これまでの内容からセカンダリキャッシュキー処理とメタデータの管理はどうにななりそうなので、残りの課題を含めて mruby スクリプトでどのように Key の処理を実現するか考えてみます。 今回は下記の処理を行う 2 個のコンポーネントから構成します。

  1. KeyHeaderHandler

    • ATS のレスポンスヘッダ取得時のフックで動作
    • Key ヘッダが存在した場合 Redis に保存する
  2. KeyProcessor

    • ATS のリクエストヘッダ取得時のフックで動作
    • Redis から Key ヘッダを読み出す
    • リクエストヘッダを読み出す
    • キャッシュキーを計算する
    • キャッシュキーとジェネレーション ID を対応付ける
    • ATS::Records#set でジェネレーション ID をセットする

上記は一見するとそのまま実装できそうですが、 1 点だけ課題があります。 以前 こちらのエントリ で触れた通り、 ts_mruby には 0.1 段階では「レスポンスヘッダ取得時のフックで動作」して「レスポンスヘッダを読み出す」機能を提供していないのです。 その課題を解決するため、今回は新たに mruby スクリプトから任意のフックを差し込む機能を提供する ATS::EventSystem クラスのプロトタイプを実装してみました。 このクラスの実装において幾つかの工夫や追加の課題があるのですが、それについては後日別のエントリで触れさせていただくとして・・・今回はこのクラスを使用して実装を進めてみます。

5. Key の実装ざっくり解説

今回の Key 実装は ts_mruby の master ブランチの example/ に配置済みです。 mruby-redis正規表現ライブラリを使用してはいますが・・・ 200 行以下とまぁまぁサイズを抑えることができたように思えます。

以下では、こちらの実装の幾つかの点に絞って詳細を記述していきます。

5-1. セカンダリキャッシュキーの計算

セカンダリキャッシュキーの計算ロジックのエントリポイントは KeyProcessor#calculate_key_ メソッドになります。 いろんなことをやっているように見えますが、そのほとんどは I-D のセクション 2-2 を実装に起こしただけになっています。

また、パラメータの処理は長くなりそうだったので KeyProcessor#calculate_keyparam_ メソッドに分離してあります。

5-2. Redis の操作周り

今回 Redis には前述の通り 2 種類の情報を格納しています。

  1. Key ヘッダの値
  2. セカンダリキャッシュキーとジェネレーション ID のマッピング情報

ATS はマルチスレッドで並列にリクエストを処理する都合、これらのデータが並列に更新される場合を考慮する必要があります。 さて、今回はそれぞれが下記のように扱われることを正解としました。

  1. Key ヘッダの値は最初に Redis に格納した値を正とする
  2. セカンダリキャッシュキーに対するジェネレーション ID は最初に Redis に格納した値を正として、実行タイミングに依らず変化しないようにする

これらを実現するため、 Redis のハッシュのフィールドに 1 度だけ値を set することが可能な HSETNX コマンドを使用することにしました。 この HSETNX コマンドを扱うインタフェースは実装開始段階では mruby-redis ではサポートしていなかったため、 これを実装する PR を投げることにしました。 これについでに、新しいジェネレーション ID を算出する際に使用するカウンタの実装のために HINCRBY を実装する PR も投げたり。 ものすごい勢いで PR レビューしていただいた @matsumotory せんせいに圧倒的感謝です!🙏🙏🙏

6. 実際の動作結果

ここでは例として、比較的動作が分かりやすいであろう substr パラメータを取り上げ動作確認をしてみます。 まず ATS のレスポンスヘッダの Via ヘッダにキャッシュに関する情報を乗せるため、 こちらの設定値3 にしました これにより、リクエストに対して レスポンスの Via ヘッダでキャッシュヒットしたかどうかを確認する ことができます。

今回は ATS のオリジンサーバとして nginx を使用し、下記のように nginx.conf を記載し Cache-ControlKey ヘッダをレスポンスするようにしました。

location / {
  ...
  add_header Cache-Control 'public, max-age=60';
  add_header key 'abc;substr=bennet';
}

まずは substr パラメータ処理の結果 、文字列が部分一致しセカンダリキャッシュキーが1 になるパターンのリクエストを投げてみます。 下記のように、最初はキャッシュミス(cM)するものの、何度かリクエストを投げるとキャッシュヒットし出す(cH , cR)のが分かります。

$ curl -s -H "abc: bennet" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cMsSfW])
$ curl -s -H "abc: bennet" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cMsSfW])
$ curl -s -H "abc: bennet" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cHs f ])
$ curl -s -H "abc: bennet" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cRs f ])

キャッシュが expire する前に、同じくセカンダリキャッシュキーが1 になる別のリクエストを投げてみます。 これらに対しても、先のキャッシュがヒットしている(cR)のが分かります。

$ curl -s -H "abc: foo, bennet" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cRs f ])
$ curl -s -H "abc: abennet00" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cRs f ])
$ curl -s -H "abc: bar, 99bennet     , abc" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cRs f ])
$ curl -s -H "abc: \"bennet\"" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cRs f ])

さて、そこにセカンダリキャッシュキーが0 になるパターンのリクエストを投げてみると・・・下記のように、またキャッシュミス(cM)しました! リクエストの内容によって Key ヘッダの値に基づいてキャッシュを出し分けることに成功しているようです。

$ curl -s -H "abc: theodore" http://127.0.0.1:8080/ -o /dev/null --dump-header - | grep Via
Via: http/1.1 Macintosh.local (ApacheTrafficServer/6.0.0 [cMsSfW])

7. Key に感じる課題

今回の作業を通していくつか Key に関して考えた課題があります。折角なのでそれらについてつらつら記します。

7-1. キャッシュキーの複雑化について

Vary と比較して Key ではキャッシュが柔軟に共有される都合、「あれっ、 A と B のキャッシュは共有するつもりなかったのに・・・」なんて想定外の動作を招くことが懸念されます。 これは特に、 Key で複雑なパラメータを与えたときに誘発し易いように感じます。 Key を実際に運用する際には、使用ルールを厳密に決めたり、 match や substr など動作が分かりやすい一部パラメータだけを使用するようにした方がよい・・・ように感じます。

7-2. I-D に感じた細かい懸念

まだ draft-01 ですし、そこまで突っ込むべきかな悩みどころですが・・・。 example に複数パラメータが与えられたときの例があったり、オリジンサーバが返す Key ヘッダの値が変わった時の挙動などのエッジケースがもう少し補完されると嬉しいように感じました。

7-3. Key というヘッダ名

ここまで書いておいて何ですが、そもそも Key ってヘッダ名はどうなんでしょうか・・・。 キャッシュに関すること、あるいはその内容でレスポンスの内容が変化すること(Vary のことを考えると・・・)を彷彿とさせる名前になってくれると嬉しく感じます。

おわりに

思いのほか長いエントリになってしまいましたが、 mruby で実装する Key の処理、いかがでしたでしょうか? 個人的には、インタフェースさえ整っていれば mrbgems をガチャガチャ組み合わせてサクッと実装できるという手段が生じるのは、それだけで魅力を感じるところだなぁと感じました。

ts_mruby, Apache Traffic Server mruby extension version 0.1 released.

(日本語版は こちら)

I released ts_mruby version 0.1, Apache Traffic Server(ATS) plugin that enables you write simple logic and configuration by mruby. In this entry, I write ts_mruby-0.1's features.

ts_mruby-0.1

ts_mruby-0.1 supports features of large part of ngx_mruby.

What can ts_mruby-0.1 do?

I show some example what ts_mruby-0.1 can do. See GitHub Wiki about full examples.

Load balancing

Below example works load balancing by simply randomizing a target host of ATS origin server.

backends = [
  "127.0.0.1:8001",
  "127.0.0.1:8002",
  "127.0.0.1:8003",
]
upstream = ATS::Upstream.new
upstream.server = backends[rand(backends.length)]

Override TSHttpOverridableConfig variables

ATS has various overridable configs.

ts_mruby supports overriding them by ATS::Records class.

Below example enables ATS memory/disk cache in a HTTP transaction. ts_mruby also enables you write more complex overriding logic by mruby control syntax.

records = ATS::Records.new
records.set ATS::Records::TS_CONFIG_HTTP_CACHE_HTTP, 1

The conf_remap plugin managed in ATS core repo provides simply overriding, but it doesn't support complex control syntax.

Switch maintenance mode based on Redis value

Its similar to ngx_mruby' Redis maintenance mode example. Below example enables switching logic by Redis.

redis = Redis.new '127.0.0.1', 6789

if redis['ts_maint']
  body = <<'EOS'
<html>
  <head>
    <title>Message from your proxy ...</title>
  </head>
  <body>
    <p>Sorry, maintainance mode now ...</p>
  </body>
</html>
EOS
  ATS::echo body
end

Redis class is provided by mruby-redis. You must write below build_config.rb and build mruby to use it simply. (mruby power is awesome!)

MRuby::Build.new do |conf|
  toolchain :gcc

  conf.gembox 'default'
  conf.gem :mgem => 'mruby-redis'
end

Quickstart

ts_mruby provide 2 methods for starters.

using Docker

Example docker image has already published to Docker Hub.

$ docker pull syucream/ts_mruby:0.1
$ docker run docker run -it syucream/ts_mruby /bin/bash

The image contains a test script(simply responds by ATS::echo), so you can test ts_mruby quickly:

root@ad7a82be8a65:/opt# trafficserver start
root@ad7a82be8a65:/opt# curl http://localhost:8080/
ts_mruby test

using Homebrew

If you're a Mac OS X user, you can use Homebrew to install ts_mruby:

$ brew install https://raw.githubusercontent.com/syucream/ts_mruby/master/Formula/trafficserver-atscppapi.rb
$ brew install --HEAD https://raw.githubusercontent.com/syucream/ts_mruby/master/Formula/ts_mruby.rb

ts_mruby-0.1's design

API/Library stacks

ts_mruby calls atscppapi, wrapped TS API by C++ classes basically.

https://github.com/syucream/ts_mruby/wiki/images/stack.png

ATS server hooks

Apache Traffic Server supports various hooks. But current ts_mruby is supporting a fews because:

  • I think mruby scripts will become complex if various hooks users can use.
  • handling these hooks is difficult. Users can't understand relationship between hooks and ATS's behaviors intuitively.

So ts_mruby uses READ_REQUEST_HDR_HOOK, early hook mainly and response body transform if necessary(ATS::Headers_out#[]= and ATS::Filter#transform! needs this control). Other hooks are unsupported yet.

https://github.com/syucream/ts_mruby/wiki/images/hooks.png

Thread shared/local Data

ATS process model is multi-thread and event-driven I/O. Therefore ts_mruby (and other ATS plugins) must ensure thread-safety. Current ts_mruby resolve the issue by below ways:

  • All of thread share mruby scirpts as string.
  • Each thread has own mruby VM(mrb_state) and RProc's poinging to compiled code.
    • To get/set thread specific data, using pthread(get|set)specific because ATS worker thread's impl is pthread. :D

https://github.com/syucream/ts_mruby/wiki/images/threads_and_data.png

ts_mruby-0.1 をリリースしてみました。さーて今後の課題は?

(English version is here.)

このブログで何度か紹介している、 Apache Traffic Server(ATS) の設定やちょっとした制御ロジックを mruby で記載することを可能にするプラグインts_mruby の 0.1 バージョンをリリースしました。 せっかくなので本記事ではその 0.1 の機能やできることについてつらつらと書いていき、今後の課題についても述べてみます。

ts_mruby-0.1

0.1 ってなんだよ!なにをもって 0.1 とバージョン切ってるんだ!という感じですが、ぶっちゃけ明確な基準が存在しません() ngx_mruby でできるようなことが ATS でもできると良いと考えていた都合もあり、 ngx_mruby の example と似たようなことがある程度出来るようになったら一区切りとしていました。

ts_mruby-0.1 でできること

0.1 段階でなにができるのか、下記でいくつか紹介してみます。 ここに記載する内容は GitHub Wiki にも記載しているので、詳細についてはどうぞそちらをご覧ください。

負荷分散する

原始的な方法と思われるかも知れませんが、下記のように ATS のオリジンサーバのホスト/ポートをランダムに選出し設定することで、簡易的な負荷分散が行えます

backends = [
  "127.0.0.1:8001",
  "127.0.0.1:8002",
  "127.0.0.1:8003",
]
upstream = ATS::Upstream.new
upstream.server = backends[rand(backends.length)]

(ATSの上書き可能な)設定を書き換える

ATS は 上書き可能な設定 を持ちます。(設定と言っても具体的にどんな内容なのよと思われそうですが、多岐にわたっているため実際にドキュメントを参照いただくのが良いかと・・・) なお、ここで上書きした設定はその HTTP トランザクション内でのみ有効になり、別の処理中のトランザクションには影響を及ぼしません。

ts_mruby は ATS::Records クラスによって、その上書き可能な設定に対する読み書き操作をサポートしています。 下記サンプルスクリプトは ATS のメモリ・ディスクキャッシュを有効にするものです。この例は非常にシンプルなものですが、もちろん mruby で条件分岐を書いて条件に一致した場合のみキャッシュを有効にすることもできます。

records = ATS::Records.new
records.set ATS::Records::TS_CONFIG_HTTP_CACHE_HTTP, 1

ATS 公式プラグインconf_remap を使えば、値の上書き自体は行えます。しかし条件分岐などによる柔軟な設定変更はサポートされていません。

Redis と連携して外部から制御可能なメンテナンスモードを設ける

これについては ngx_mruby の example にも同じような例があります。 mruby + mrbgems エコシステムが使えることによりメリットが得られることがわかる例だと思うのですが、下記のように Redis と通信して対象のキーに対する値が設定されていたら別の振る舞いをする、なんてことが簡単にできます。

redis = Redis.new '127.0.0.1', 6789

if redis['ts_maint']
  body = <<'EOS'
<html>
  <head>
    <title>Message from your proxy ...</title>
  </head>
  <body>
    <p>Sorry, maintainance mode now ...</p>
  </body>
</html>
EOS
  ATS::echo body
end

Redis クラスは mruby-redis によって提供されます。例えば下記のような build_config.rb を書いて mruby をビルド、 ts_mruby とリンクすることですぐに上記スクリプトが動かせるようになります。えむるびーの ちからって すげー!

MRuby::Build.new do |conf|
  toolchain :gcc

  conf.gembox 'default'
  conf.gem :mgem => 'mruby-redis'
end

クイックスタート

今回の 0.1 リリースに際して、自分で頑張って ts_mruby をシコシコとビルドしなくても試せる手段を用意してみました。

Docker で試す

ビルド済の Docker Image を Docker Hub に push してあります。下記のようにすれば手元でコンテナを動作させることが可能かと思われます。

$ docker pull syucream/ts_mruby:0.1
$ docker run docker run -it syucream/ts_mruby /bin/bash

この image には ATS::echo するための簡単なサンプルスクリプトを同梱しています。下記のようにして ATS を起動してから HTTP リクエストを投げることで動作確認を行うことも可能です。

root@ad7a82be8a65:/opt# trafficserver start
root@ad7a82be8a65:/opt# curl http://localhost:8080/
ts_mruby test

Homebrew で試す

Mac OS X ユーザの方はおなじみ Homebrew でインストールすることもできます。(この場合はサンプルスクリプトは同梱していません。。。)

$ brew install https://raw.githubusercontent.com/syucream/ts_mruby/master/Formula/trafficserver-atscppapi.rb
$ brew install --HEAD https://raw.githubusercontent.com/syucream/ts_mruby/master/Formula/ts_mruby.rb

ts_mruby-0.1 の設計面の話

今後の話をする前に、現状の 0.1 の設計面のいくつかのトピックに関して記述してみます。

ATS API 参照周り

ts_mruby は C++ で実装しており、 ATS の C++API を叩いてサーバ設定の読み書きなどの処理を行っています。 ただしほんの一部分、 C++API でサポートしていない機能に関しては直接 C API を参照している部分が存在します。

https://github.com/syucream/ts_mruby/wiki/images/stack.png

ATS フック周り

ATS には HTTP トランザクション中の幾つかの時点でプラグインによる制御を挟み込むための いくつかのフック が用意されています。 ts_mruby は現状では、 下記の点に配慮して、任意のフックで実行する処理を追加することはできなくしています。

  • フックを意識し出すとユーザの記述しなければならない ATS の設定ファイル、あるいは mruby スクリプトが複雑になりがちになる
  • どのフックでどの操作が行えるか直感的に分かりにくい ことを鑑みて、ユーザはフックを意識せずシンプルなスクリプトを記述すれば良いようにしたかった

現在の ts_mruby は基本的に最も早い段階のフックである READ_REQUEST_HDR で動作し、スクリプトの内容を評価して必要であれば後続のフックをユーザに隠蔽しつつ差し込むということをしています。(ATS::Headers_out#[]=ATS::Filter#transform! がそれを行っています)

https://github.com/syucream/ts_mruby/wiki/images/hooks.png

排他制御周り

ATS はマルチスレッド、イベント駆動 I/O で HTTP トランザクションを処理するモデルの HTTP サーバです。 その HTTP トランザクション内で動作するプラグインである ts_mruby も、複数スレッドであるスクリプトの処理が走ることを意識しなければなりません。 現状の ts_mruby では下記のような具合でスレッド固有データを用いて排他制御の必要を無くしています。

  • mruby スクリプトは ts_mruby 初期化時に読み込んでおき、スレッド間で共有する
  • mrb_state インスタンスに関してはスレッド固有データとして持っておく
    • スレッド固有データへのアクセスは ATS の API では提供されていないが、 ATS のスレッドの実装は pthread なので、 pthread の specific API を直接叩くようにしている(えっ、いいの?ってカンジですが)

https://github.com/syucream/ts_mruby/wiki/images/threads_and_data.png

今後の課題

ts_mruby の機能を今後拡張していくにあたり、大きく分けて2つの扱いかねている課題があるので、それらについて記述してみます。

フック周りの設計見直し

先述の通り、 ts_mruby では基本的に任意のフックでの処理をサポートしておらず、基本的に READ_REQUEST_HDR 、つまりクライアントからのリクエストヘッダを読み終わった段階でほぼ全ての処理を行うことを想定しています。 しかしながらこの制約はユーザへ提供できる機能は限られてしまいます。例えばオリジンサーバからのレスポンスヘッダの内容に基づいた条件分岐などが行えなくなります。

このデメリットを考慮して、フックの扱いを今後変更して行こうと考えています。現状では具体的には下記のような対応を考えています。

  • ユーザがフック周りの設定等を明示しない限り、現状の通り READ_REQUEST_HDR でほぼ全ての処理を完結する
  • ATS の設定ファイル(plugin.config, remap.config) で ts_mruby をロードする際に渡すパラメータでフックを指定可能にする?
  • mruby スクリプト中で任意の処理を実行するフックを指定できるようにする?
    • かなりラフですが、下記のようなイメージ
# similar to EventMachine?
module MyEventHandler
  def send_request_hdr
    hin = ATS::Headers_in.new
    hin['X-Foo'] = 'Bar'
  end

  def read_response_hdr
    hout = ATS::Headers_out.new

    # rewrite response body if origin server allows
    if (hout['X-Allow-Transform'])
      hout.delete 'X-Allow-Transform'
      f = ATS::Filter.new
      f.transform! do |body|
        body + 'rewritten by ts_mruby :D'
      end
    end
  end
end

# TODO: consider to how to register
ATS::EventSystem.register_handler MyEventHandler

mruby スクリプト上でのイベント駆動 I/O のサポート

上で紹介した Redis を使ったメンテナンスモードの例ですが、パフォーマンス面での問題があります。 前述の通りマルチスレッド、イベント駆動 I/O で処理するため、ワーカスレッドでブロッキング I/O を発行するとワーカスレッドが次のイベント処理に進んでくれず、イベント駆動 I/O による恩恵を殺してしまうのです。 (h2o の話になりますが、同様の問題は この記事あの記事 でも取り上げられています)

後者の記事で kazuho さんが提案されている方式(の ruby バインディング)は個人的に良いとは思うのですが、どう実装したら良いのか、下記の点について悩み中です。 (あまり検討が進んでいない為、的外れな懸念を抱いている可能性があります...)

  • ts_mruby が Fiber の使用を想定していない問題
  • ts_mruby がスレッド固有データを使っている問題
    • resume したくなった際、 fiber の持ち主のスレッドをどう探すのか?
  • イベントループの持ち方問題
    • ATS とは別にイベントループ走らせるのか?
    • もしそうするなら、いつ、どうやって走らせる?
  • ワーカスレッドへの仕事の振り方
    • ATS とそれ以外のイベントシステムでワーカスレッドが取り合いにならないか?

これについては少し長期的にアイデア出しと精査を行いフィジビリティスタディして、目処が立ったらブログエントリに書くなどしてから実装に取り掛かろうかなと思案中です。

つらつらと書いたけど

ATS のユーザが国内にあまり居ない気がするので、 ts_mruby の開発どこまで頑張るかはすごい微妙なところですね。。。うーん。

C++ のラムダ式のデフォルトキャプチャの対象となる変数について確認していた

C++初学者なので勉強のため会社の有志の人々と Effective Modern C++ の輪読会をやっているのですが、その際にラムダ式のデフォルトキャプチャの挙動に関する話題になり、個人的に無性に気になってしまったので調べてみました。 もし誤りや補足などございましたら、コメント等でご指摘いただければ幸いです。

ながすぎる; よんでない

疑問

デフォルトコピー・参照キャプチャ([=], [&])を記述した際のラムダ式のデータメンバとしては、ラムダ式を定義した外のスコープの変数すべてが対象になるのか、必要なメンバしか持たないのか、という点が気になりました。 例えば外のスコープで下記のようにクソデカイ容量の変数が存在したとして、必要であろうがなかろうが問答無用でコピーされうるのか・・・。

using namespace std;

int main() {
  vector<int> large_vec;

  ... // 紆余曲折あって large_vec のキャパシティがクソデカくなっていると想定する

  auto lambda = [=]{}; // lambda のメンバにはクソデカイ vector は、例え使ってなくても含まれるのか??

  return 0;
}

輪読会参加メンバーで打ち立てた予測としては、必要な変数のみコピーされるというものでした

回答

言語仕様上、必要な変数のみコピーされるようです。 (・・・但し後述の通り、実際にデバッガ当てて調べていった感じ、そうではない箇所もあり・・・)

調査結果

言語仕様を読んでみた

今回は The Standard : Standard C++ に掲載されている C++14 の仕様のドラフト N4296 を眺めてみました。

ラムダ式に関する記述がある 5.1.2 節の 12,13 番 に、今回扱う疑問点に関するっぽい記述を発見しました。曰く

デフォルトキャプチャを持つラムダ式は ... (中略) ... compound-statement(ラムダ式の {} の中の文のこと) が下記の条件である場合エンティティ(this と変数のこと) を暗黙的にキャプチャする。 エンティティが odr-used である、もしくは ... (略)

... (略) ラムダ式によってキャプチャされるエンティティは、ラムダ式を含むスコープで ord-used である ... (略)

odr-used な変数がキャプチャの対象になりそうです。 ・・・で、 odr-used というのは何だよという話になりますが、大雑把にいうと定義を必要とする変数の使用のことのようです。 つまるところ、上記ドラフトの記述から「定義を必要としない変数の使用しかされていない(not odr-used な)変数はデフォルトキャプチャを書いてもキャプチャされない」と言えそうです。

実際のコンパイル結果

実際どのようにキャプチャされるのか確認してみました。今回は下記のソースコードを手元の Mac OS X 10.11 にて下記の環境でコンパイルし、 lldb でラムダ式のキャプチャ結果を拾ってみます。

$ g++ --version
Configured with: --prefix=/Applications/Xcode.app/Contents/Developer/usr --with-gxx-include-dir=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.11.sdk/usr/include/c++/4.2.1
Apple LLVM version 7.3.0 (clang-703.0.31)
Target: x86_64-apple-darwin15.6.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin

デフォルトコピーキャプチャ以外の振る舞いもついでに見てみたいので、幾つかのキャプチャとラムダ式内の文のパターンを用意してみました。

using namespace std;

int main() {
  vector<int> unused_data = {0};
  vector<int> data = {0};

  auto cpcap_ex = [data]{};
  auto cpcap = [=]{ cout << data.size() << endl; };
  auto cpcap_emptyimpl = [=]{};

  auto refcap = [&]{ cout << data.size() << endl; };
  auto refcap_ex = [&data]{};
  auto refcap_emptyimpl = [&]{};

  return 0;
}

デバッガで動作確認したい都合、今回は -g を付けてコンパイルします。

$ g++ -g -std=c++0x test.cpp

コンパイル後に lldb で return あたりの行でブレークポイントを貼って各変数の内容を見てみます。

$ lldb a.out
...
(lldb) b test.cpp:<行数>
...
(lldb) r
...
(lldb) p cpcap_ex
((anonymous class)) $0 = {
  data = size=1 {
    [0] = 0
  }
}
(lldb) p cpcap
((anonymous class)) $1 = {
  data = size=1 {
    [0] = 0
  }
}
(lldb) p cpcap_emptyimpl
((anonymous class)) $2 = {}
(lldb) p refcap
((anonymous class)) $3 = {
  data = size=1
}
(lldb) p refcap_ex
((anonymous class)) $4 = {
  data = size=1
}
(lldb) p refcap_emptyimpl
((anonymous class)) $5 = {}

表題とは外れてしまいますが、まずデフォルトキャプチャではなく明示的にキャプチャした場合は、 odr-used とか関係なくキャプチャされているようですね。 また、デフォルトキャプチャかつラムダ式の文内で data.size() みたいな感じで odr-used すると、対象の変数はキャプチャされているようです。 更にデフォルトキャプチャでラムダ式の文を空にした場合は、何もキャプチャされないという結果になりました。

ちなみに下記のようにラムダ式の文内に odr-used な式を書いてみたのですが・・・

class undef *up;
auto cpcap_undef = [=]{ up; }; // {} 内の式は discarded-value expression であり、 up は odr-used

この場合はキャプチャされているっぽく見えます。自分の仕様の解釈が誤っているのか、あるいは何か読み落としているのか・・・教えてえろいひと!

(lldb) p cpcap_undef
((anonymous class)) $0 = (up = 0x0000000000000000)

ts_mruby + mruby-mrmagick で画像リサイズするリバースプロキシをサクッと実装してみた

今日は夏コミのようですね!僕は今年は特に原稿を描いておらず用事も無いためコミケに縁のない夏を過ごしてしまいました。

ところで最近拙作の ATS(Apache Traffic Server) プラグインである ts_mruby にレスポンスボディをいじくるメソッドを追加し、ふと思い立って画像リサイズのロジックを mruby で書いてみました。

そのレスポンスボディ加工メソッドは ATS::Filter#transform! になります。 このメソッドではブロックまたは Proc オブジェクトを渡すとブロックパラメータに加工前のレスポンスボディが渡され、評価値でレスポンスボディを差し替えます。

f = ATS::Filter.new
f.transform! do |body|
  # append text to the end of response body
  body + "rewritted by ts_mruby :D"
end

ちなみに ngx_mrubyNginx::Filter#body 相当のメソッドは実装できていません。また ATS::Filter#transform! ではブロックの外の変数へのアクセスを許可していません(ブロックの内外で実行時の context を共有していないため)。これらは現状、 ts_mruby を実装する上でフックの挿入をシンプルにして、 mruby スクリプトを書く側に意識させない作りにしているために生じている制限になっています。。。 将来的には 任意のフック時点に挿入可能にして Nginx::Filter#body 相当のメソッドを実装する・・・かもしれません。

話が逸れましたが、 ATS::Filter#transform! を使えばボディの加工はできます。後は画像ファイル取得時のレスポンスボディを ImageMagick でイイカンジに加工してみます。 今回は ImageMagick の mruby バインディング mruby-mrmagick を使ってみます。 要件としては下記を念頭に置きました。

  • ファイルへの読み書きを行わない
    • ATS の主要用途はプロキシだしワーカスレッドに極力 I/O させず処理したい
  • リクエストのクエリ文字列でリサイズの挙動を変更可能にする
  • ATS::Filter#transform! の制約をなんとか突破する

これらの要件を満たす mruby スクリプトを、下記の工夫を織り込んで書いてみました。(もろもろのファイルなどはこちら

  • ImageMagick の blob でボディを加工・取得
  • クエリ文字列を ATS::Request#args を使って取得
  • ATS::Filter#transform! には必要なパラメータを予め Proc#curry で部分適用しておいた Proc オブジェクトを渡す
# parse parameters
params = {}
req = ATS::Request.new
req.args.split('&').each do |arg|
  key, value = arg.split('=')
  params[key] = value
end

# set resizing logic
resizer = nil
if params.has_key?('scale')
  resizer = Proc.new do |scale, body|
    img = Mrmagick::ImageList.new
    new_img = img.scale(scale)
    new_img.from_blob(body)
    new_img.to_blob
  end.curry.(params['scale'].to_f)
end

# set filter if params is valid
if (resizer)
  filter = ATS::Filter.new
  filter.transform! resizer
else
  ATS::rputs 'Parameters are invalid ...'
  ATS::return ATS::HTTP_BAD_REQUEST
end

mruby-mrmagick でラクをしているとは言え、割とシンプルに記述できた気がします!

ちなみに ATS のキャッシュが有効 な場合、 ATS::Filter#transform! で加工した結果がキャッシュに入ります。 これを活用すれば、このリサイズのロジックもキャッシュヒットしている間は都度走らせずに済み、 CPU リソースの削減が可能かと思います。 ・・・ただし、そのためには上記スクリプトにキャッシュヒットしたかどうかのロジックも入れる必要があります(上記ではそれが無いため、キャッシュヒットすると段々とサイズが大きくor小さくなってしまう場合が!)

週報仲間というやつを試してみる

プライベートにおける作業、たとえば英語の勉強だとかちょっとしたプロダクトを実装してみるとか、はたまたプラモデルを組み立てるだとかを実行しモチベーションを維持するのは、どうにも難しいように思えます。 僕の最近の実体験でいうと、去年電子ピアノを購入して趣味で弾いてみていたのですが、うまくモチベーションを管理できず、現在では電子ピアノは触れられることなく部屋を彩るオサレなインテリアのポジションに収まってしまっています(モッタイナイ!) プライベートでやってる作業なんてものは、多くの場合できなかった場合でも人に迷惑を掛けることもなく義務感も生じにくいため、「やめた」と言いやすいですよねー、困ったことに。

さて最近週報仲間なんてエントリを読みまして、これがモチベーション維持に使えそうなんじゃないかなーと思っています。(この話、最近の Rebuild.fm でも紹介されてた気がする!) 今のところ仲間内で毎週日曜に一週間内でやったこと、翌週やること、その他雑感を書いていって、各週の計画や振り返りを共有することによって、そこはかとない作業への義務感を自身に植えつけられ、作業実行やモチベーション維持をスムーズに行える効果が期待できるかなーと。 ......実際のところ、この手法が自分に合っているかはしばらく継続してみないと分からないので、まずは取り組んでみて 1,2 ヶ月後くらいに効果を評価してみようと考え中です。