30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Cloudflare Workers KV をキャッシュとして使う

Cloudflare Workers KV を使うと、ストアに保存した値を HTTP レスポンスとして使うことができる。
そのため、オリジンサーバに問い合わせて得られた結果をストアに保存しておくことで、キャッシュのように使うことができる。
この記事では、Workers KV をキャッシュとして使う Workers スクリプトのサンプルを書いていく。

Workers KV のセットアップについては以下を参照。

numb86-tech.hatenablog.com

処理の大まかな流れ

今回は、以下の仕様の Workers スクリプトを作ることにする。

  • 対象の URL は/js/foo.js/js/bar.js
  • 対象の URL にアクセスがあった場合、KV にデータが格納されていないか確認する
    • 格納されていた場合はそれをクライアントに返す
    • 格納されていなかった場合はオリジンサーバに問い合わせ、そのレスポンスをそのままクライアントに返す
      • その際にオリジンサーバからのレスポンスを KV に格納しておき、以降のアクセスに対してはそれを返せるようにしておく
  • KV にレスポンスを格納する際にx-from-kvというフィールドをヘッダに付与し、KV からのレスポンスだとクライアントが分かるようにしておく

コードで表現すると以下のようになる。

const TARGET_PATHNAME_LIST = ["/js/foo.js", "/js/bar.js"];

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const url = new URL(event.request.url);

  if (TARGET_PATHNAME_LIST.includes(url.pathname)) {
    // URL をキーにして KV の中身を探し、見つかればそれをクライアントにレスポンスする
    // 見つからなければ以降の処理を続行する

    const freshResponse = await fetch(url);

    // オリジンサーバからのレスポンス(freshResponse)をクローンして KV に保存する
    // その際に x-from-kv フィールドを付与しておく

    return freshResponse;
  }

  return fetch(url);
}

コメントで書いた 2 箇所を実装すれば、実際に動くようになる。

オリジンサーバからのレスポンスを KV に保存する

まずは KV への保存から実装する。

オリジンサーバからのレスポンスを元にして新しくレスポンスオブジェクトを作り、x-from-kvフィールドを付与する。

const freshResponse = await fetch(url);

let response = freshResponse.clone();
response = new Response(response.body, response);
response.headers.append("x-from-kv", "true");

そしてこのレスポンスオブジェクトを KV に保存すればよいのだが、KV に保存できるのはstreamarrayBuffertextのみ。
そのため、レスポンスオブジェクトをそのまま保存することはできない。そこで、ヘッダとボディをそれぞれで保存することにする。ヘッダはtextに変換できるし、ボディはstreamなのでそのまま保存できる。

const SUFFIX_OF_HEADER = "::HEADER";
const SUFFIX_OF_BODY = "::BODY";

function putHeaderAndBody(key, header, body) {
  return Promise.all([
    MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header)), // 渡されたオブジェクトを文字列に変換して保存している
    MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body),
  ]);
}

// ヘッダをオブジェクトに変換する
const headerObj = Array.from(response.headers.entries()).reduce(
  (acc, entry) => {
    return {
      ...acc,
      [entry[0]]: entry[1],
    };
  },
  {}
);

event.waitUntil(putHeaderAndBody(url.href, headerObj, response.body));

これで、MY_KVという名前の KV に保存されるようになる。
https://example.com/js/foo.jsにアクセスがあった場合、https://example.com/js/foo.js::HEADERというキーでヘッダを、https://example.com/js/foo.js::BODYというキーでボディを、それぞれ保存する。

KV からレスポンスを取り出す

次に、保存したレスポンスを KV から取り出す。getメソッドにキーを渡せばよいだけなので、難しいことは何もない。
typeとして"json"を指定すると、JSON をオブジェクトに変換して返してくれる。

const cachedHeader = await MY_KV.get(`${url.href}${SUFFIX_OF_HEADER}`, {
  type: "json",
});
const cachedBody = await MY_KV.get(`${url.href}${SUFFIX_OF_BODY}`);

if (cachedHeader && cachedBody) {
  return new Response(cachedBody, {
    headers: cachedHeader,
  });
}

KV は全てのエッジサーバで共有されるので、保存を行ったエッジサーバ以外でも値を取り出すことができる。
ただ、変換が即時で反映されるわけではない。結果整合性なので、強整合性が求められるケースでは使えない。

KV achieves this performance by being eventually-consistent. Changes are immediately visible in the edge location at which they're made, but may take up to 60 seconds to propagate to all other edge locations.

https://developers.cloudflare.com/workers/learning/how-kv-works

完成形

ここまでの内容をまとめ、関数の切り出しなどを行って整理したのが、以下のコード。

const TARGET_PATHNAME_LIST = ["/js/foo.js", "/js/bar.js"];

const SUFFIX_OF_HEADER = "::HEADER";
const SUFFIX_OF_BODY = "::BODY";

async function getHeaderAndBody(key) {
  const cachedHeader = await MY_KV.get(`${key}${SUFFIX_OF_HEADER}`, {
    type: "json",
  });
  const cachedBody = await MY_KV.get(`${key}${SUFFIX_OF_BODY}`);
  return { cachedHeader, cachedBody };
}

function putHeaderAndBody(key, header, body) {
  return Promise.all([
    MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header)),
    MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body),
  ]);
}

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const url = new URL(event.request.url);

  if (TARGET_PATHNAME_LIST.includes(url.pathname)) {
    const { cachedHeader, cachedBody } = await getHeaderAndBody(url.href);

    if (cachedHeader && cachedBody) {
      return new Response(cachedBody, {
        headers: cachedHeader,
      });
    }

    const freshResponse = await fetch(url);

    let response = freshResponse.clone();
    response = new Response(response.body, response);
    response.headers.append("x-from-kv", "true");

    const headerObj = Array.from(response.headers.entries()).reduce(
      (acc, entry) => {
        return {
          ...acc,
          [entry[0]]: entry[1],
        };
      },
      {}
    );
    event.waitUntil(putHeaderAndBody(url.href, headerObj, response.body));

    return freshResponse;
  }

  return fetch(url);
}

このコードをデプロイすると、/js/foo.js/js/bar.jsにアクセスがあった場合はオリジンサーバからのレスポンスを KV に保存し、以降のアクセスでは KV から取り出した値をクライアントに返すようになる。
KV から取り出した場合はレスポンスヘッダにx-from-kvフィールドが付与されているので、クライアント側で確認できる。

有効期限を設定する

上記のコードだと、明示的に削除しない限り、KV に保存したデータはそのまま残り続ける。
データを保存する際に有効期限を設定することで、有効期限を経過した際に自動的に KV から削除されるようになる。そうすることで、Cache-Controls-maxage相当のことを実現できる。

具体的には、putメソッドにexpirationTtlオプションを渡せばよい。
以下のように記述すると、レスポンスを保存してから60秒後に自動的に KV から削除される。

MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header), { expirationTtl: 60 }),
MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body, { expirationTtl: 60 }),

キャッシュとの競合

ここまで、KV をキャッシュのように使う方法を書いてきたが、そもそも Cloudflare には KV とは別にキャッシュ機能が備わっている。KV をキャッシュとして使おうとするのなら、本来のキャッシュとの競合や二重管理について考えないといけない。

例えば、先程データの有効期限を60秒に設定した。このような設定をした場合、60秒間は同じデータを使い続け、それ以降はオリジンサーバに問い合わせを行うことが期待されていると思う。
しかし、60秒経過して KV からデータが削除されたとしても、その時点でエッジサーバにキャッシュが残っていれば、それが使われてしまう。オリジンサーバへの問い合わせは行われない。
具体的には以下のような処理になる。

  1. KV からデータを取り出そうとしたが、既に削除されており見つけられなかった
  2. fetch(url)でリクエストを発行する
  3. オリジンサーバにリクエストを送る前に、エッジサーバにキャッシュが残っていないか確認する
  4. キャッシュが残っていたため、それがfetch(url)の結果として返され、オリジンサーバへのリクエストは行われない

このような事態を避けるためには、エッジサーバにキャッシュさせないようにしたり、Cache API を使ってキャッシュを削除したりする必要がある。

Cache API による削除について検証してみたが、動作が不安定というか、意図通りに消されないことも多かったので、そもそもキャッシュさせないようにするのが確実だと思う。
ページルールで「キャッシュ レベル」を「スキップ」にしたり、Cache-Controlprivateを指定したりすることで、エッジサーバへのキャッシュを防ぐことができる。

参考資料

Cloudflare Workers KV の初歩

Cloudflare Workers KV は、Cloudflare のエッジサーバからアクセスできる、グローバルなキーバリューストア。
Workers スクリプトからアクセスして使う。
各エッジサーバ毎に存在するのではなく全てのエッジサーバが共有するストアであるため、API など様々な用途で使える。
そしてエッジサーバで動くため、レイテンシが小さくなるというメリットもある。

この記事では、既に Cloudflare Workers を利用しているサイトに Workers KV を導入する手順を見ていく。

まず、名前空間を作成し、それを Workers と紐付ける必要がある。
名前空間の作成は Cloudflare のダッシュボードからもできるが、ここでは CLI ツールの wrangler を使う方法を紹介する。

Workers スクリプトのディレクトリで、以下のコマンドを実行する。wrangler.tomlaccount_idが設定されている必要があるので注意。

$ wrangler kv:namespace create "MY_KV"
🌀  Creating namespace with title "my-worker-kv-MY_KV"
✨  Success!
Add the following to your configuration file:
kv_namespaces = [
     { binding = "MY_KV", id = "********" }
]

こうすると、my-worker-kv-MY_KVという名前空間が作られる。
my-worker-kvwrangler.tomlnameに設定していた値。この値と、先程のコマンドで指定した値を-で連結したものが、Workers KV の名前空間になる。

my-worker-kv-MY_KVはプロダクションで使うことにして、それとは別に開発環境用の名前空間を作成する。

$ wrangler kv:namespace create "MY_KV" --preview
🌀  Creating namespace with title "my-worker-kv-MY_KV_preview"
✨  Success!
Add the following to your configuration file in your kv_namespaces array:
{ binding = "MY_KV", preview_id = "xxxxxxxx", id = "********" }

これで新しくmy-worker-kv-MY_KV_previewという名前空間が作られたので、これを開発環境で使うことにする。

Cloudflare のダッシュボードで、これらの名前空間が作られていることを確認できる。

f:id:numb_86:20210622232840p:plain

最後に、名前空間の設定をwrangler.tomlに書き込む。

[env.preview]
kv_namespaces = [
  { binding = "MY_KV", preview_id = "xxxxxxxx" }
]

[env.production]
kv_namespaces = [
  { binding = "MY_KV", id = "********" }
]

これでWorkers スクリプトのなかで、MY_KVという名前で Workers KV にアクセスできるようになった。

早速コードを書いてみる。

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  let value = await MY_KV.get("my-key");

  if (value === null) {
    value = new Date().toUTCString();
    await MY_KV.put("my-key", value);
  }

  return new Response(value, {
    headers: {
      "content-type": "text/plain",
    },
  });
}

MY_KV.getが値の取得、MY_KV.putが値のセットだということが分かれば、上記のコードの内容はすぐに理解できると思う。
初回アクセス時の現在時刻を Workers KV に保存し、それ以降のアクセスでは保存された時刻を使い続ける。

ローカルで動作確認するためには、$ wrangler dev --env previewで開発環境を起動する。

$ wrangler dev --env preview
💁  watching "./"
👂  Listening on http://127.0.0.1:8787

http://127.0.0.1:8787にアクセスすると、my-worker-kv-MY_KV_previewmy-key: 現在時刻というペアを保存する。
my-worker-kv-MY_KVは影響を受けない。

そして$ wrangler publish --env productionでプロダクションにデプロイできる。

プロダクションにアクセスすると、my-worker-kv-MY_KVmy-key: 現在時刻というペアを保存する。