キャッシュフレンドリーなステートレスアプリケーション設計について考える #CDN_Study
CDN_Study という勉強にいってきた。 https://http2study.connpass.com/event/81469/
そこで、Akamaiの方が、「個人の意見だけど、アプリケーション側がもっと基礎設計でステートレスでキャッシュフレンドリーな設計になってないといけないよね」という旨の発言をしていて、最近そのことにアプリケーションエンジニアとして同じようなことを考えていたので、書き出してみる。
SPAとかSSRとかフロントの不毛な話は出さないようにしてるが、主にサーバレス環境を意識している。
前提
- 世の中のアプリケーション内のモジュールは、Statefull or Stateless に分類でき、それをツリー状に表現できれば差分検知できる、という React の仮想 DOM 的な世界観が自分にある
- 以下の話は、基本的には Fastly のサロゲートペアーとそのためのミドルウェアを作れば既に実現できる話ではある
- (Akamaiは自分は使ったことないです。ごめんなさい…)
参考: https://docs.fastly.com/ja/guides/purging/single-purges
ウェブアプリケーションへの需要/要求
現状のウェブアプリの設計において、動的ページの HTML をキャッシュさせるのは、それ専用の設計をしないととても難しい。まず、何が入力に対してべき等で、何が外部IOを含んでいるかを事前に分類する必要がある。
で、それを乗り越えると何が嬉しいか。PWAやAMPみたいなフロントエンドからみたパフォーマンスチューニングでは、ある一定以上の体験を求めると、キャッシュ済みの *.html
を CDN のエッジに配信させる必要がある。基本的にアプリケーションサーバに飛ばしたら負け。ここで同意が取れない人もいると思うが、そういう前提とする。
HTMLをエッジに配信させると、体感値としては、1000~5000ms ぐらいのオーダーだったものが50~150ms のオーダーの世界になる。このインパクトは大きい。しかし現状そのアーキテクチャを取るための手法は発達していないので、誰でも出来るといった状況にはない。
具体的にあったらいいなと思うもの
fastly にはサロゲートペアーという機能がある。これはパスに対してタグを打っておいて、そのタグがついてるものをまとめてパージするというもの。
これを使って、たとえば /article/1
というURLがあって、HTMLレスポンスが変える時、そのページのキャッシュ判定を行うサロゲートペアーは次のようなオブジェクトで表現できるかもしれない。
var Relations = { '/': ['APP_VERSION:0.0.1', 'app.js', 'app.css'], '/article/1': [ 'APP_VERSION:0.0.1', 'app.js', 'app.css', 'function:getArticle?id=1:update_at=1523717246877' ], // ... }
まず、デプロイの度に全破棄するような実装のために、 APP_VERSION みたいなキーを張っておく。 他は、ある他のリソースに対して、サロゲートキーへのリレーションの表現をする。
app.js, app.css に違和感はないだろうが、3番目のサロゲートキーで update_at をキーにしたものを登録することで、なんらかの function(AWS Lambdaなどを想定) へ渡すまえにキャッシュ判定をして、CDN内で外部IOが発生することなく高速にキャッシュを返せる判定ができるはず。
これをどのように実装したいか。たとえばこれがDBアクセスを仮定すると、基本的には何らかのDBのレコードのtimestamp を収集することになると思う。
これを実現するような、Node.JS の擬似コードを書くならこう。
// なんらかのORMの post-update を想定 Article.onUpdate(item => { AssetCache.invalidate('function:getArticle?id=1:*') AssetCache.registerNewSalogateKey( 'function:getArticle?id=1:' + item.update_at ) })
あえて冗長な表現をしている。
で、事前に宣言した Relations に対して、 invalidate したアセットに関連するものを再帰的にパージすれば、「id=1 で表現した入力とその結果が本当にべき等なら」、これで更新後に最初の一回だけ function を実行してあとはキャッシュを返し続けるような実装が、たぶんうまくいく。新規デプロイ時はAPP_VERSION更新すれば全部消える。function を個別に管理するならAPP_VERSIONも不要。運用難しいけど。
ここがうまくいくと嬉しいのは、キャッシュルールはデプロイごとに静的として、サーバーは単方向に invalidate を発行し続けるだけ、クライアントのリクエストも常に同一、動的なのはキャッシュミドルウェアのKVS的な内部状態だけで、デプロイまたはDBのレコード更新に動的に反応するだけのステートレスなアプリケーションが出来る。
こんな実装は可能なのか
構成としてはこうなるだろう。
Function(Application) <=> CasheMiddleware <=> CDN <=> Browser
上に述べた実装を仮に CasheMiddleware という名前にしたが、こいつで上のサロゲートキーのリライトルールを実装する。内部ステートは大雑把に redis かなんかで作れる。転地インデックス作るぐらいでそんなに難しくない。
Fastly のキャッシュパージ速度は公称150msなので(正直実測はしてない)、一時的な不整合も最小限に抑えられる。CloudFront は10分ぐらいかかるので、そうもいかなかったりするが。
それで、実際にはアプリケーションコードから、関連サロゲートキーをフレームワークの機能として、動的に決定できるようにする。たとえば、アセットは自明として、なんらかのビューライブラリで
<x-article-component data-json={ useCache(fetchArticle(1)) }/>
みたいなヘルパをかまして、中のエンドポイントを収集してヘッダに埋め込むなりして紐付けたうえでViewとそのリソースへのリレーションを生成すればいい。
この実装を作る気があるか
NO
なぜならこの設計はfastlyのみで動くサロゲートキーの仕組みに強く依存している。fastly でしか動かないものを作りたくない。(個人的な話で言えばフリーランスなんで fastly が使えないと最適化できませんみたいな人材になってしまうのが一番のリスク)
また、セッションを考慮してないので、クッキー依存の動的ページをJSまたはwebcpomonts/iframeなどに必ず切り離さないといけない。そうしないとちょっと前のメルカリで他人のクレカが見えちゃった的なミスが起こる。JS書く人間には抵抗はないが、これを受け入れられない人も多いだろう。だいたいHeaderの右側のログイン情報とか。
また、全てのAPI実装が関連するレコードのタイムスタンプに対してステートレスである、という制約が厳しく、運用で事故る未来しか見えない。ここは人間を信用してはいけないレイヤー…。
しかし、ApplicationServerでHTMLを作って返すより、CDNエッジからHTMLを返した際に得られる速度はわかりやすく圧倒的なので、「fastly のサロゲートペアーまたはそれに準ずる仕様が何らかの標準にあるなら」、「フレームワーク組み込みでサロゲートキー生成を込みで」実装する恩恵が出てくる。ここまできてはじめてフレームワークかなんらかのヘルパ作って嬉しくなるなぁ、という感じ。そうなったら 特定のCDN 使わなくても nginx middleware とか書けばいいし。
ただ、「こういうカスタマイズ要素を標準化しちゃうと、各プラットフォームの売りがなくなっちゃうよね」みたいな話があるのもわかる…ビジネス難しい…
個人的には、CDNはキャッシュのパージ速度、IOへの課金、ストレージサイズへの課金で選ぶので、DevelopperExperience 以外の内部拡張がどうこうって話は正直知りたくすらないみたいな気持ちもある。あるいは世の中すべてが fastly になるならサロゲートキーにロックインされに行くぞ!ってなりますね。
終わりに
なんかこういうの既にありそうだけど面倒で調べなかった。 キャッシュファーストなアプリ設計、初心者がハマりがちで、あんまり流行らなさそうという実感もある。 パフォーマンスチューニングおじさんしてると、諦めてスクラッチで書き始める。辛い。