天然パーマです。

動的コンテツをエッジのKVにキャッシュする

Web APIのパフォーマンス向上に「Dynamic Content Storing = DCS」という戦略を考えている。 Web APIに限らず、サーバーサイドで動的に作られるコンテンツ全てへ適応できるものである。 本番環境で運用したわけではないが、実際に動くモックを作ってみた。 背景とともに紹介しよう。

要点

「Dynamic Content Storing」とは「動的コンテンツをエッジのkey-valueストアに保存する」ことを言う。

  • ユーザーには(初回以外)KVストアから取得したコンテンツを返す。
  • 有効期限もしくはコンテンツの更新をトリガーに新しくコンテンツを生成する。
  • どんなバックエンドにも適応できる「Incremental Static Regeneration」と考えることができる。
  • 原理は「Stale-While-Revalidate」と同じだがこの場合はコンテンツがより永続的であることが期待される。
  • Cloudflareの「Cache Reserve」もDCSのひとつと考えることができる。
  • 現状は、Cloudflare Workers、Cloudflare Workers KVで実装する。

エッジサイドのKVを使うことで、コンテツをキャッシュしつつ、それを静的コンテンツのように扱えるのだ。

対象

今回対象にしたいのは、ユーザーからのリクエストに応じて動的にコンテンツを生成し、 返却するようなWebアプリケーションだ。 そのパフォーマンスを向上させたい。 つまり応答速度を短くする、もしくはマシンの負荷を下げたい。

既存の戦略

まず、 Webアプリ・サイトのパフォーマンスを向上させるために取られている戦略をピックアップしてみよう。

フロントエンドの文脈

コンテンツをどのタイミングで生成し、保存し、ユーザーに返却するか。 昨今では以下の略語が使われている。

  • CSR - Client Side Rendering
  • SSR - Server Side Rendering
  • SSG - Static Site Generation
  • ISR - Incremental Static Regeneration

この中で効率がいいのはSSGである。 生成された静的なコンテンツをCDNにおけば、数msでレスポンスを返せる。 ただ、今回の対象はWeb APIなど予め作っておくことができないので、SSGは難しい。

Web APIはその名の通り「SSR」である。 フロントエンドの文脈では、 ヘッドレスCMSをバックエンドに持ち、ReactやVueのフレームワークでレンダリングする、というのが典型的なパターンなので、少し毛色が違う。 とはいえ、動的にコンテンツを生成するという点では同じだ。

SSGのパフォーマンスとSSRの柔軟さのいいところ取りをしようというのが、ISRだ。 ユーザーからのアクセス時、予め存在するHTMLを返却する。revalidateの秒数を超えてアクセスがきたら、裏でHTMLの再構築が走る、という具合である。後述するStale-While-Revalidateとよく似ている。

ISRの問題はそれを可能にできるプラットフォームがあまりにも限られている。 そもそもISRはNext.jsのVercelが提唱するもので、純粋にはVercelで動く。 今回のDCSは、CDNのエッジこそCloudflare Workersで実装しているが、 バックエンドのオリジンはどんなプラットフォームでも構わない。 AWSやGCP動いていようが、フレームワークがRailsだろうがLaravelだろうが、HTTPを喋っていればよい。

Stale-While-Revalidate

Stale-While-Revalidateというキャッシュ戦略がある。 Service WorkerやHTTPのCache Control、そして、FastlyやCloudflareといったCDNで使われている。 Fastlyのドキュメントより引用する。

新しいコンテンツの取得中に古いコンテンツを配信する

コンテンツの種類によっては、生成に時間がかかるものもあります。一度キャッシュされたコンテンツはすぐに配信されますが、最初にアクセスしようとしたユーザーはコンテンツが生成されるのを待たなければなりません。

これはコールドキャッシュの場合には避けられないことですが、オブジェクトがキャッシュにあり、 TTL が期限切れになっている時にこのようなことが起こるのであれば、Fastly は新しいコンテンツがバックグラウンドでフェッチされている間、失効済みコンテンツを表示するように設定することができます。

https://docs.fastly.com/ja/guides/serving-stale-content

Dynamic Content StoringはこのStale-While-Revalidateと原理が一緒である。

ただし、CDNではSWRの有効期限内でもStaleが存在していることは「必ず」保証されることではない。 FastlyでいうPOPは各国・リージョンに存在してそれぞれがキャッシュを持つことになる (ちなみに、Fastlyではオリジンシールドという仕組みでキャッシュヒット率をあげることができる)。 CloudflareのCDNでも同じような構造である。AというPOPにはキャッシュが存在しているが、 Bには存在しない場合、Bへのアクセス時にはオリジンへリクエストがいく。

Fastlyを使っていて、以下のLRUの仕組みでStaleが飛ぶことがある。

最近使用していない場合(LRU): Fastly は LRU リストを採用しているため、オブジェクトは TTL (キャッシュ保持時間) の期間中に継続的にキャッシュを保持することを必ずしも保証しません。キャッシュの保持と削除は、ファイルに対するリクエストの頻度、TTL の値、ファイルの配信元となる POP など、数多くの要因を踏まえて行われます。例えば、3700秒以上の TTL のファイルがディスクへ保管される一方で、3700秒未満の TTL の場合はメモリーのみに一時的に保管されます。TTL は可能な限り3700秒以上に設定することをお勧めします。

https://docs.fastly.com/ja/guides/serving-stale-content

そこで、Stale-While-Revalidateの仕掛けを使いつつ、エッジのKVストアにコンテンツを置くことを考えた。 それがDynamic Content Storingである。

Cache Reserve

Cloudflareでは同じようなコンセプトの「Cache Reserve」という機能が privateベータとして提供されている。 Cache ReserveはR2にコンテンツを置いてキャッシュヒット率を高める仕組みだ。

アクセス権をまだもらってないので、試せていないが、概要を読む限りではDynamic Content Storingの一種と考えることができる。

動的コンテンツをキャッシュする

Dynamic Content Storingの具体的な流れは以下の通りだ。

  • 初回アクセス時のみ直接レスポンスを返す。
  • レスポンスは後述するKVに保存される。
  • 以降、クライアントからのアクセス時にはKVに保存されたレスポンスを返す。
  • トリガーをフックにして、コンテンツの生成、つまりオリジンへのアクセスが走り、返却されたレスポンスをKVへ保存し、既存のものと入れ替える。
  • トリガーは有効期限とオンデマンドがある。

実装

さて、どのように実装するか。 今のところ、エッジで実現できるのはCloudflare WorkersとCloudflare Workers KVを使う方法しかない。 ただ、今後、Fastly Compute@EdgeでもKV相当のものができれば可能である。

Cloudflare Workers KV

Cloudflare WorkersはCloudflareのCDNエッジで動く環境。 Cloudflare Workers KVはWorkersから使えるkey-valueストアである。 「エッジ」というのが特徴で高速である。 Cloudflare WorkersではCache APIも使えるが今回はKVを使う。

Cloudflare Workers KVは非常に素朴なkey-valueストアであるが、今回の要件では十分である。

  • メソッドはgetputdeleteのみ
  • 有効期限を設定できる
  • 即時反映は保証せず最大60秒かかる
  • メタデータを付加できる
  • prefix指定してリスト取得できる
await NAMESPACE.put(key, value, {
  metadata: { someMetadataKey: "someMetadataValue" },
});

これをCloudflare Workersから使う。

検証用に作ったアプリケーションを紹介しよう。

プロキシする

Cloudflare Workersはオリジンにしたいリソースをfetchしてそのまま返すことでプロキシできる。 今回は例としてこのブログをオリジンに指定する。 このブログはまさにCloudflareでホストしていて「ダイナミック」ではないのだが、 手頃なホストがなかったのでこれにした。強引だがWeb APIと見立ててほしい。

例えば、/posts/* にアクセスが来た場合、yusukebe.com/posts/*のコンテンツを「そのまま」見せるとしたら以下のようなコードになる。

app.get("/posts/*", async (c) => {
  const url: URL = new URL(c.req.url);
  const originURL = `https://${c.env.ORIGIN_HOST}${url.pathname}`;
  console.log(`fetch from ${originURL}`);
  const response = await fetch(originURL);
  return new Response(response.body, response);
});

今回のDCSやキャシュを適応したくないパスがあれば、この処理を書けばよい。

ルーティング

このCloudflare Workersの仕組みのおかげで、前段にWorkersをおけば、オリジンはなんでもいいし、 パスのマッピングも自在である。 なので、プログラマブルなルーティング装置としても優秀だ。

また、パスに限らず、リクエストヘッダの値によって、オリジンを変えることもできる。 A/Bテストもできる。

KVを使う

レスポンスをKVにストアしてみよう。/posts/ というパスに限ってDCSを適応する。

まず、originURLをキーにした場合、最初にKVにあるか確認する。

let { value, metadata } =
  await c.env.DYNAMIC_CONTENT_STORE.getWithMetadata<Metadata>(originURL);

getWithMetadataでメタデータも一緒に取得できる。 valueにはレスポンスボディ、metadata.headersにはレスポンスヘッダの値がオブジェクトで入っている。

もしこれらが存在してれば、そのまま新しいレスポンスにする。

if (value && metadata) {
  console.log(`${originURL} already is stored`);
  response = new Response(value, { headers: metadata.headers });
}

もし、存在しなければ、オリジンからfetchしてKVにストア、返却するレスポンスとする。 ストアする際executionCtx.waitUntilを使っている。 これで、ブロックせずにレスポンス返却のバックグラウンドで処理ができる。

response = await fetch(originURL);
const { body, headers } = response.clone();
if (body) {
  console.log(`store ${originURL}`);
  c.executionCtx.waitUntil(
    c.env.DYNAMIC_CONTENT_STORE.put(originURL, body, {
      expirationTtl: 60 * 60,
      metadata: { headers: headersToRecord(headers) },
    })
  );
}

有効時間「60分」以内ならKVにストアされているコンテツが返り、有効期限が過ぎると 新しくコンテンツを生成し、ストアするという流れができた。

Staleの実装

ここからが肝である。Staleの実装だ。 これができるとStaleが存在する限り、コンテンツの生成は全てバックグラウンドで行われる。

コードは冗長になるが、ロジックは単純である。 TTL内ならば、freshというprefixのキーを使う。 同時にstaleというprefixのキーでコンテンツをストアしておく。 TTLは極力長めにする。 freshの期限が切れたら、staleのレスポンスを返却。 バックグラウンドでfetchが走り、コンテンツを生成する。

console.log(`try to get ${originURL} from kv`);
let { value, metadata } =
  await c.env.DYNAMIC_CONTENT_STORE.getWithMetadata<Metadata>(
    `fresh: ${originURL}`
  );
let response: Response;

if (value && metadata) {
  console.log(`${originURL} already is stored`);
  response = new Response(value, { headers: metadata.headers });
} else {
  let { value, metadata } =
    await c.env.DYNAMIC_CONTENT_STORE.getWithMetadata<Metadata>(
      `stale: ${originURL}`
    );
  if (value && metadata) {
    console.log(`${originURL} is expired but the stale is found`);
    response = new Response(value, { headers: metadata.headers });
    c.executionCtx.waitUntil(createCache(c, originURL));
  } else {
    console.log(`fetch from ${originURL}`);
    response = await fetch(originURL);
    c.executionCtx.waitUntil(createCache(c, originURL, response));
  }
}

以下がログである。 TTLが切れていてもStaleが存在する限り、それを返す。 fetchが走り、遅れてキャッシュをストアしている様子が分かる。

更新をトリガーにする

トリガーは有効期限だけではない。 REST APIにおける「CRUD」で考えると面白い。 Read、つまりGETリクエストの場合は常にストアされたコンテンツを返却する。 Create/Update/Deleteという更新系が走ったら新しくコンテンツを生成する、というのはどうだろう。 以下の例はDELETEメソッドでキャッシュを更新している。

app.delete("/posts/", async (c) => {
  const originURL = `https://${c.env.ORIGIN_HOST}/posts/`;
  c.executionCtx.waitUntil(createCache(c, originURL));
  return c.redirect("/posts/");
});

今回のコード

こちらがリポジトリ。

課題

万能そうでも、課題がいくつかあるので、気をつけたい。

  • KVはCache APIと比べると遅い。
  • KVは即時ではなく反映に最大60秒かかる。
  • KVの使用料。
  • 今のところ、Cloudflareに依存する。

参考記事

今後

以上、Dynamic Content Storingという仕組みとその実装を紹介してきた。 うまく機能すれば、Web APIの上にかぶせるだけで、パフォーマンスを向上させることができる。 モックを作った限り、正しく動作しているので、実際に運用してみたい。