ブログ・ア・ラ・クレーム

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

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