ブログのしゅーくりーむ

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

mruby を使ってワンバイナリで Serverspec のテストを実行可能にするツール "sssspec" を作った

ながすぎる;よんでない

リポジトリは以下にあります。

以下のように spec を書いて…

def __main__(argv)
  describe file('mrblib') do
    it { should be_readable.by('yourgroup').by_user('you') }
  end
end

sssspec をビルドして…

$ rake

生成されたバイナリひとつで spec を実行することができます

$ ./sssspec
.
Total: 1
   OK: 1
   KO: 0
Crash: 0
 Time: 0.16 seconds

背景

Serverspec はプロセスの起動やパッケージのインストール状態、パッケージの存在確認などサーバの状態をテストするのに有用なツールです。

しかしながらこれをローカル実行するには当然 Ruby 処理系や関連 gem をインストールしておく必要があります。Serverspec の為だけに Ruby を導入する必要が出て来る場合、これはちょっとした障壁となると思われます。 また Serverspec は ssh 経由でリモート実行もできますが、構成上、自動化したい際はデプロイツールの都合、社内ルールの問題で利用できない可能性があります(と、ぼくは思ってるんですが、実際のところそういった制約はどの程度あるのだろう)

Serverspec の導入障壁を減らしたいと考えた僕は、 mruby-cli を利用してワンバイナリで実行可能にした Itamae であるところの MItamae を参考になんとかすることにしました。 そうだ、 mruby 、やろう。

構成など

必要なものたち

mruby で Serverspec を実行するために最低限必要なものは、以下のものと思われます。

  • Serverspec 本体の mrbgem
  • Specinfra の mrbgem
  • RSpec の mrbgem
  • mruby で spec を実行可能にする mruby-cli ベースのコード

これらのうち、 “Specinfra の mrbgem” については @k0kubun さんによって作られた mruby-specinfra が利用できそうです。 というわけで残り 3 コンポーネントを自前で用意することにしました。

mruby-rspec

mruby-rspec は mruby で RSpec を実行可能にする mrbgem です。

経緯の話をすると、当初は Ruby 向けの rspec-core などを mruby 向けに作り直そうと試みたのですが、 RSpec の既存資産がなかなか大きく、 mruby 向けに改修していく作業の途中で心が折れてしまいました。 現在は、限られた機能のみ mruby 向けに移植している(途中だったらしい) sagmor/mruby-spec をベースに mrbgem を実装しています。

mruby-rspec は未だかなり機能が少ないため、本記事の “sssspec” をまともに使い倒して行こうとすると、もう少し機能追加が必要なものと思われます。

mruby-serverspec

mruby-serverspec は本家 Serverspec の matcher, type をほぼ使いまわしで構成した mrbgems です。 ssh 経由でのリモート実行など一部のロジックは削っております。

この辺りは本家 Serverspec のコードを引っ張り出して無理やり mrbgems 化して、動かない箇所を片っ端から修正していくという大変泥臭い作業をして構成しました。

sssspec

sssspec は mruby-cli ベースのテンプレートを修正し、かつこれまで紹介した mrbgems を組み合わせて実現した、ワンバイナリで spec 実行可能なツールです。 実装は 9 割くらい mruby-cli のテンプレそのままで、あり解説すべき点は特に無いので省きます。 ちなみに名前は rrrspec を意識し、 “Single Shot ServerSpec” の略としています。

今後の展望

ひとまず動くところまで作りたかったので、 sssspec と周辺 mrbgems はかなり大雑把な作りになっています。まずは全体的にもう少しリファクタリング等していきたいです。

また現状 mruby-rspec の機能が少ないせいで実用に耐えない気がしています。個人的には少なくとも、 shared_examples が無いと辛いなと思っております。従ってそのあたりの RSpec 便利機能を mruby-rspec にも実装していきたいと思っております。

本記事と関係のない余談

ルビィの話書いてて思い出したんですが、 秋からラブライブ!サンシャイン!! の二期が放映開始されますね! ちなみに僕は渡辺曜ちゃんが好きです。

技術書典2でmrubyに関する薄い本をだします

表題のとおりです。

4/9(日) アキバ・スクエアで開催される 技術書典2 にて mruby に関する薄い本を出します! う-10 サークル名「なるはやでいい感じにやるマン*1です。 https://techbookfest.org/event/tbf02/circle/5686683802533888

主なターゲット読者を Rubyist として、 mruby の言語処理系周り (mruby-compiler とか RiteVM とか) や CRuby との差異 (khash とか GC とか) をいい感じに書いていければと思ってます。 もしご参加の際はぜひよろしくお願いいたします!

*1:なお原稿の進捗具合は、なるはやともいい感じとも言えない模様

KVS をゆるくつなぐ mrbgem "mruby-neco" を作ってみた

あけましておめでとうございます。正月は実家に帰省してネコと戯れて過ごしました。

新年一発目の記事は、新しく作成した mrbgem "mruby-neco" を紹介してみます。

mruby-neco について

mruby-neco は NoSQL Elastic Command Organizer の略としています。はい、言いたかっただけな名前です。

さて mruby-neco ですが、あまた在る NoSQL 、というか特に KVS について、 mruby スクリプト向けにひとまとめしたインタフェースを提供するものです。 具体的には現状下記プロダクトをサポートしています

これらのプロダクトに対して、下記のインタフェースを提供します。

  • get
    • key に対する value を取得する
  • put ,
    • key に対する value を保存する
  • delete
    • key, value のペアを削除する
  • each
    • key, value のペアを受け取るブロックを回すイテレータ
    • Enumerable を include してるので map とか filter とか reduce も使える

それぞれの操作に対して、特定プロダクトを対象にするか、いくつかのプロダクトをひとまとめにした集合を対象にするかを選択できるようにしています

モチベーション

mruby-neco を作ったモチベーションですが、以前の記事で mruby-k2hash というmrbgem を実装した際に、他の KVS から値を持ってきていろいろできてもいいなぁと思ったのと、各プロダクトに付与されている場合がある cli ツールの操作感に差異があり、それを埋める手段が欲しいなぁと思った次第です。 既存の手段でその望みはかなえられる気がしないでもないですが、ここは mruby マンとして mruby でいい感じに KVS 間のデータのやり取りや加工をできると面白いのでは・・・と考えサクッと実装してみました。 ちまたに mrbgem も充実していて、苦労せず実装に踏み出せると考えたという背景もあります。

実際の mruby-neco 利用例

mruby-neco を利用して値を参照更新する例を掲載してみます Org という構造にメンバーを追加し、 Org を介して操作する、みたいな構造を取っています。 Org と各プロダクトのドライバ間の際は Adapter クラスで吸収する形になります。

org = Neco::KvsOrg.new

org.add_member(:etcd, {:url => 'http://127.0.0.1:2379/v2'})
org.add_member(:lmdb, {:path => '/tmp/tmp.lmdb', :options => {:flags => MDB::NOSUBDIR}})
org.add_member(:redis, {:host => 'localhost', :port => 6379})
org.add_member(:k2hash, {:path => '/tmp/tmp.k2hash', :options => {:mode => 0666, :flags => K2Hash::NEWDB}})

org.put_to :etcd, 'key', 'etcd'
org.put_to :lmdb, 'key', 'lmdb'
org.put_to :redis, 'key', 'redis'
org.put_to :k2hash, 'key', 'k2hash'
p org.get_from_all 'key' #=> {:redis=>"redis"}

org.put_to_all 'key', 'value'
p org.get_from_all 'key' #=> {:etcd=>"value", :lmdb=>"value", :redis=>"value", :k2hash=>"value"}

puts 'each_for :redis'
org.each_for(:redis) do |key, value|
  puts "key: #{key}, value: #{value}"
end

puts 'each'
org.each do |key, value|
  puts "key: #{key}, value: #{value}"
end

コマンドラインから neco を利用する

ダンな mruby マンとしては mruby-cli も使ってみたいよね・・・というわけで、 neco-cli という、上記の get と put 操作をコマンドラインから行えるツールを作ってみました。 下記のような使用感になります。

$ neco-cli help
  usage: neco-cli <subcmd>
         neco-cli get <key> [--open <db options as JSON>]
         neco-cli put <key> <value> [--open <db options as JSON>]

  example: neco-cli set "key1" "value1" --open '{"name": redis, "addr": "127.0.0.1", "port": 6379}'

$ neco-cli put key hoge --open '{"name": "redis", "host": "127.0.0.1", "port": 6379}'
$ neco-cli get key --open '{"name": "redis", "host": "127.0.0.1", "port": 6379}'
{"redis":"hoge"}

$ neco-cli put key fuga --open '{"name": "redis", "host": "127.0.0.1", "port": 6379}' --open '{"name": "etcd", "url": "http://127.0.0.1:2379/v2"}'
$ neco-cli get key --open '{"name": "redis", "host": "127.0.0.1", "port": 6379}' --open '{"name": "etcd", "url": "http://127.0.0.1:2379/v2"}'
{"redis":"fuga","etcd":"fuga"}

open オプションの内容と get で得た値のフォーマットは現状 JSON としています。

ネコについて

冒頭で触れた実家のネコですが、 4 ヶ月ほど顔を合わせてなかったにも関わらず僕のことを覚えているようで、全力で甘えに来てくれました(かわいい) しかしながらネコの中で何らかのポリシーがあるのか、あちらから触れてくることは多々あるのですが、こちらから頭を撫でるなど触れるとコレジャナイ感を覚えているような顔をして去っていってしまいます(かなしい)

Yahoo! JAPAN がオープンソースにした KVS "K2HASH" を mruby スクリプトから利用するための mgem "mruby-k2hash" をつくってみた

この記事は mruby Advent Calendar 2016 26 日目の記事です。・・・というのは嘘です! mruby Advent Calendar 2016 、つつがなく完走しましたね!みなさまお疲れ様でした!

本記事では表題の通り、 mruby-k2hash という mgem を作ってみたことに関する共有になります。

K2HASH とは

K2HASHYahoo! JAPANのブログ でちょろっと紹介されている KVS です。 ソースコードGitHub で閲覧することができます。

上記の記事では簡潔な紹介のみになっていますが、 GitHub の wiki を読む限り下記の点から興味深いプロダクトなような気がします。

こうして列挙してみると、個人的な知識から考えるに Redis に近い機能を保有している気がします。 ただデーモンを立ち上げたりファイルに書き出すことすら必須ではないことから、結構気軽に利用できるように見えます。

mruby-k2hash について

基本方針

今回ご紹介する mruby-k2hash はこの K2HASH への操作を mruby スクリプトから行うことを可能にします。 この mruby-k2hash 、基本的には K2HASH の C インタフェースへの橋渡しをするだけになります。ですがその設計は少し考えた上で行っています。 K2HASH の基本的な機能を目を細めてみると・・・うーん・・・なんだか Ruby の標準ライブラリに存在する DBM に近い気がしてきます。

というわけで mruby-k2hash で実装しているメソッドは DBM が持つものを模倣する 形で行っています。 each も実装していることから Enumerable のメソッドも使えるようになっています。 mruby で dbm の mgem を実装したという例は僕が知る限り無い(あったらコッソリ教えてください!)ので、 mruby-k2hash は mruby で気軽に扱える dbm 代替のひとつ としても利用できるのではないかと考えています。

実際の動作例

mruby-k2hash の実際の動作の例としては下記のようになります。

K2Hash クラスのコンストラクタにファイルパスなどなどを渡すとそのファイルを open しつつインスタンスが生成されます。 fetch, [] メソッドで参照、 store, []= メソッドで更新が行えます。

> k2hash = K2Hash.new('/tmp/tmp.k2hash', 0666, K2Hash::WRCREAT)
 => #<K2Hash:0x127ada0>
> k2hash.store('key', 'value')
 => nil
> k2hash.fetch('key')
 => "value"
> k2hash.store('key', 'new value')
 => nil
> k2hash.fetch('key')
 => "new value"
> k2hash['key2'] = 'value2'
 => "value2"
> k2hash['key2']
 => "value2"
> k2hash.close
 => nil

each メソッドや、それを実装したことによって Enumerable インタフェースのメソッド、例えば map なども利用できます。

> k2hash = K2Hash.new('/tmp/tmp2.k2hash', 0666, K2Hash::WRCREAT)
 => #<K2Hash:0xcef7c0>
> k2hash['key1'] = 'value1'
 => "value1"
> k2hash['key2'] = 'value2'
 => "value2"
> k2hash.each do |k,v|; puts k + ': ' + v; end
key1: value1
key2: value2
 => nil
> k2hash.map do |k,v|; "key is #{k}, value is #{v}"; end
 => ["key is key1, value is value1", "key is key2, value is value2"]

気軽に試す

もろもろビルド済みの Docker image を push してあるので、 pull してご利用ください。

# コンテナ外
$ docker run -i -t syucream/mruby-k2hash /bin/bash

# コンテナ内
root@9cc669af1607:/opt# ./mruby/bin/mirb
mirb - Embeddable Interactive Ruby Shell

> ... # あとはいいかんじにやる

テストとか

ちょっとだけ単体テストを書きました。テストコードを書くに際して IIJ が公開している mruby-mtest を使ってみました。 Travis CI でビルドする際はこの mgem を含めて mruby をビルドし、テストコードを bin/mruby に食わせる形でテストするようにしました。楽でいいですわゾ〜! :D

おわりに

現状の mruby-k2hash は K2HASH の多くの機能(サブキーとかキューとか)を利用できない状態なので、なんとかしたいです。 そのうち気が向いたら機能追加します。

mrb_state を跨いで Proc オブジェクトをやり取りしたい話

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

Advent Calendar のネタとしてはやっぱりイカした mrbgem を作りました的な内容をブチ上げて行きたいところですが、残念ながらそのようなネタは思いつかず。 本記事では自分が拙作プロダクト ts_mruby を実装中ハマった問題をちょろっと共有するシンプルな内容になっています。

背景

ことのおこり

ts_mruby は Apache Traffic Server(以下ATS) に組み込むプラグインとして、 mod_mruby, ngx_mruby のような役割を担うことを目的に開発を続けています。 また、今年 9 月に バージョン 0.1 をリリースしています。

ATS は主な用途が HTTP プロキシサーバであり、プラグイン向けにいくつかのフックが用意されています。 ts_mruby-0.1 ではそのうちクライアントから ATS へリクエストが発生したタイミングでの mruby スクリプトによる制御はサポートしていたのですが、その他の、例えばオリジンサーバへリクエストを転送するタイミングやレスポンスを受け取るタイミングのフックに関しては、サポートが非常に弱い状態になっています。

f:id:syu_cream:20161211160808p:plain

このフックがサポートできないと、例えばレスポンスヘッダに関する情報の参照や修正など重要ないくつかの操作を受け付けることができません。 そこで ts_mruby-0.2 (今年中にリリース予定)ではこのサポートを厚くすることにしました。 具体的には下記のように mruby スクリプトからフックとイベントハンドラを登録可能にするクラス ATS::EventSystem を実装し、フックの動作タイミングでイベントハンドラを実行するという方針で対応することにしました。

class MyHandler 
  def on_send_request_hdr
    hout = ATS::Headers_out.new
    hout["x-on_send_request_hdr"] = "1"
  end

  def on_read_response_hdr
    hout = ATS::Headers_out.new
    hout["x-on_read_response_hdr"] = "2"
  end

  def on_send_response_hdr
    hout = ATS::Headers_out.new
    hout["x-on_send_response_hdr"] = "3"
  end
end

es = ATS::EventSystem.new
es.register MyHandler

上記の MyHandler クラスの各メソッドは、 ATS のフック における TS_HTTP_SEND_REQUEST_HDR_HOOK, TS_HTTP_READ_RESPONSE_HDR_HOOK, TS_HTTP_SEND_RESPONSE_HDR_HOOK に相当しています。

問題

ATS::EventSystem を実装するのは良いですが、下記の 2 点に配慮すべき事項があります。

まず ATS はマルチスレッド・イベント駆動で処理します。 そのためもちろんながらスレッドを跨いでリソースを共有する際はロックを取る必要がある場面が生じます。 また、先述のフックを処理するスレッドがどれになるかは不定です。

また、 ts_mruby はロックを取ることによる実装の煩雑さとロックを取ることによるパフォーマンス低下をきらい、スレッドローカルに mrb_state インスタンスを持つ設計にしています。

f:id:syu_cream:20161211160829p:plain

上記 2 点に配慮すると、最初のフックで ATS::EventSystem を用いてフックとイベントハンドラを仕込んでも、その後のフックを処理するスレッドがイベントハンドラを知らないといけなくなります。

f:id:syu_cream:20161211161049p:plain

というわけで (別のスレッドがもつ)mrb_state インスタンスに、(イベントハンドラの) Proc オブジェクトを渡したい という要求が発生しました。

irep を dump することによるスレッド間の Proc オブジェクト受け渡し

irep の dump

Proc オブジェクト(C の型としては RProc) は実行する命令などの情報を保持する mrb_irep 型のメンバを持ちます。 上記の問題を解決するため、今回は Proc オブジェクトの irep からバイトコードを dump し、それをスレッド間で持ち回すことを考えます。

RProc の mrb_irep メンバをバイトコードに戻す関数 mrb_dump_irep() が mruby/dump.h に宣言されています。 mrb_dump_irep() dump したバイトコードを、格納するのに必要な領域を mrb_malloc() で確保しつつ、 uint8_t* 型で返却します。

ここで取り出したバイトコードは同じく mruby/dump.h に宣言されている mrb_read_irep() で mrb_irep 型に戻すことができます。 後はこれを mrb_closure_new() で RProc 型に変換してから mrb_obj_value()mbr_value 型の Proc オブジェクトに変換し、最後に mrb_yield() で proc オブジェクトを実行すれば元のイベントハンドラの実行まで完了です。

全体の流れを図示すると、下記のようになります。

f:id:syu_cream:20161211161132p:plain

ts_mruby への機能追加

上記で検討した irep を dump することによるイベントハンドラ受け渡しですが、実際の実装としては下記のようになります。 下記のソースコードは断片的なものですが、実際に組み込んだものを既に ts_mruby の master ブランチ にマージ済みなのでそちらでご確認いただけます。

イベントハンドラ登録処理

イベントハンドラを登録する側の処理は下記のようになります。 下記では省いていますが、 ATS 固有のフックの登録も行う必要があります。 また、 send_request_hdr_irep_ に格納したバイトコードはどうにかしてイベントハンドラを処理するスレッドに渡るようにする必要があります。 ts_mruby としては HTTP トランザクション処理に紐づくデータを登録できるので、そこに格納しています。

static const char* SEND_REQUEST_HDR_HANDLER  = "on_send_request_hdr";
...
mrb_sym sym = mrb_intern_cstr(mrb, SEND_REQUEST_HDR_HANDLER);
RProc* rproc = mrb_method_search(mrb, rclass, sym);

uint8_t* send_request_hdr_irep_ = NULL;
size_t binsize = 0;
mrb_dump_irep(mrb, rproc->body.irep, DUMP_ENDIAN_NAT, &send_request_hdr_irep_, &binsize);

イベントハンドラ実行処理

イベントハンドラを処理する側の処理は下記のようになります。 (どのようにかして) send_request_hdr_irep_ を取り出し、前述の通りの変換を行って最終的に mrb_yield() で実行します。

uint8_t* handler_irep = ... // send_request_hdr_irep_

RProc* closure = mrb_closure_new(mrb, mrb_read_irep(mrb, handler_irep));
mrb_value proc = mrb_obj_value(closure);
mrb_value rv = mrb_yield(mrb, proc, mrb_nil_value());

所感

今回紹介したやり方ですが、やはり Proc オブジェクトを一旦 irep に dump した後に再度 irep に戻すという変換を行っているので、ややコストが高いです。 これとは別のやり方として下記の方法で Proc オブジェクトの mrb_value を直接複数スレッド複数 mrb_state インスタンス間でやり取りすることも検討し、一度は実装していました。

  • イベントハンドラ登録処理を実行するスレッドの持つ mrb_state インスタンスで Proc オブジェクトを生成する

    • この際に GC に回収されないよう mrb_gc_register()GC root に登録しておく
  • イベントハンドラ実行スレッドで先程の mrb_value を直接参照し、処理する

  • 処理が終わったらスレッドセーフな「mbr_value を使い終わりましたメッセージ」を格納するキューにメッセージ追加
  • イベントハンドラ登録処理を実行したスレッドが次回実行する際、メッセージを確認する

    • 使い終わった mrb_value に対しては mrb_gc_unregister()GC root から取り外す

ただしこの方針は実装が汚くなりがちで、あまりコストも低くないであろうことから廃止してしまいました。

おわりに

あまり一般的な問題という訳ではないでしょうが、本記事が誰かの役に立つと嬉しいです :D また、本記事の内容に要改善点などあればご指摘いただけると嬉しいです! (ちゃんと調べてないですが、本記事の工夫の代わりに mruby-marshal を使うなども有用かも?)

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 をガチャガチャ組み合わせてサクッと実装できるという手段が生じるのは、それだけで魅力を感じるところだなぁと感じました。