Open10

Cloudflare R2の画像をCache APIでキャッシュして返すメモ

syumaisyumai

2022/12/30 追記

今はもう Public Buckets の機能があるので、このスクラップの内容は基本的に必要ありません。
Bucket Accessの設定からDomain Accessを有効化することで、キャッシュも効くようになります。

下記の記事が参考になりそうです。

https://dev.classmethod.jp/articles/cloudflare-r2-ga/


Cloudflare R2の画像をCache APIでキャッシュして返したのでそのメモ。

syumaisyumai

1. R2にバケットを作って画像をアップした

バケットの作成、アップロードは普通にブラウザからポチポチ出来たのでそれでやった。

バケットは、手元で動かす用とpublish先の環境用で分けろとwrangler (CLI) に怒られるので2つ作っている。

アップした画像はひとまず下の2枚。

コンソールのスクショ

R2バケット一覧

オブジェクト一覧

syumaisyumai

2. R2の画像を返すだけのWorkerを書いた

基本的にはR2のドキュメントに従って書く。

https://developers.cloudflare.com/r2/get-started/

wrangler.tomlにバケットの変数の名前を決める項目があったので、今回は BUCKET にした

https://github.com/syumai/workers-playground/blob/71eade8037cfbd103292f785b0626a4a4a795704/r2-image-viewer/wrangler.toml#L9

BUCKET 変数に TypeScript の型を付けるために、.d.tsファイルを別途用意した

https://github.com/syumai/workers-playground/blob/71eade8037cfbd103292f785b0626a4a4a795704/r2-image-viewer/bindings.d.ts#L4

Workerのコードはこんな感じ。

const url = new URL(req.url);
const imgPath = url.pathname.slice(1);
const imgObj = await BUCKET.get(imgPath);

if (imgObj === null) {
  return new Response("Not found", {
    status: 404,
  });
}
return new Response(imgObj.body, {
  headers: {
    "Content-Type":
      imgObj.httpMetadata.contentType ?? "application/octet-stream",
  },
});

URLパスからR2オブジェクトのキー名を取ってきて、取得できたObjectからbodyを引っ張って、Responseに突っ込んで返すだけ。

R2Objectの定義はここにある。
https://developers.cloudflare.com/r2/runtime-apis/#r2object-definition

ゆーすけべーさんのブログにもあった通り、fetchのResponseに似ている。
Responseに加えて、version、etagなどのR2Object固有のプロパティが増えている感じ。

syumaisyumai

3. 返すだけだと遅かったので、Cache APIを使ってキャッシュするようにした

2 の実装は、遅い。どれくらい遅いかと言うと、4kBの画像を取得するだけのリクエストが1秒近くかかる。
実際にはバラつきがあって、260ms、800ms、1.3sあたりが出る感じ。
ほとんどが800msくらい。
ベータだから遅いのか、正式版でもこれくらいの速度なのかはわからない。

これだと正直使い物にならないと感じたので、キャッシュしてみることにした。

Cloudflare Workers で使えるキャッシュの方法について

  • KV
  • Durable Object
  • Cache API

が使える。

KVでの方法については、ゆーすけべーさんのブログに書いてある。
KVは高速かつエッジでのキャッシュが効くキーバリューストアで、しかも各キーに対して 25MiB までのデータを格納することが出来る。
画像ファイルのキャッシュ用途にも十分使えてしまう。

Durable Objectは、自分は現在無料プランなので使っていない…。

Cache APIの方は、より一般的なキャッシュ方法となっている。CDNのキャッシュを生の状態で触れるものだと思ってもらうのが良い。
Cache APIで突っ込んだキャッシュは、Cloudflareのコンソールから簡単にパージも出来る。
今回扱いたいのは破棄しやすい一時的なデータと言うこともあるので、これを使うのが無難と言う感じがした。

上限も違う。

https://developers.cloudflare.com/workers/platform/limits#cache-api-limits

無料枠だと、
Cache APIは、1リクエストあたりの書き込みが5GBまで、読み込みが50件まで。
KVは上限1GBで、書き込みは一日1000回まで、読み込みは100000回まで。

Cache APIの1リクエストあたりの書き込み5GBまでと言うのは、実質無制限ではと言う感じがする。
KVの上限もめちゃめちゃ緩いので、滅多なことでは超えないと思う。

syumaisyumai

キャッシュの実装

Runtime APIのドキュメントに使い方が書いてある。
ブラウザのCache APIをベースに作られていて、記録したRequestに対してResponseを返すと言う形式になっている。

https://developers.cloudflare.com/workers/runtime-apis/cache/

基本的に下記の例の通りにやったらいい。

https://developers.cloudflare.com/workers/examples/cache-api/

キャッシュの生存期間は Response header に Cache-Control ヘッダーを指定すると Cloudflare Cache-Control Directive に従って決まる。
指定しなかった場合は Status: 200 の場合デフォルトで2時間 になる。
それか Expire が使える。

実装は次のようになった。

  const url = new URL(req.url);

  const cacheKey = new Request(url.toString(), req);
  const cache = caches.default;
  const cachedRes = await cache.match(cacheKey);
  if (cachedRes) {
    return cachedRes;
  }

  const imgPath = url.pathname.slice(1);
  const imgObj = await BUCKET.get(imgPath);

  if (imgObj === null) {
    return new Response("Not found", {
      status: 404,
    });
  }
  const res = new Response(imgObj.body, {
    headers: {
      "Cache-Control": "public, max-age=14400",
      "Content-Type":
        imgObj.httpMetadata.contentType ?? "application/octet-stream",
    },
  });
  event.waitUntil(cache.put(cacheKey, res.clone()));

  return res;

cache.match で Response が見付かったらそれを返して、無かったら Cache-Control ヘッダを設定した Response を作って、cache.putで保存する。
cache.putはPromiseを返すが、event.waitUntilを使うことでブロックすること無くresponseを返すことが出来る。

今読んで思ったけど、404 Responseもキャッシュしても良さそう。この辺は作り手の匙加減と言う感じがする

ハマったポイント

キャッシュが効くのはサイト配下のみなので、デフォルトで作られる .workers.dev のURL配下ではキャッシュされない。
自分の場合は、WorkerにCustom Domain (r2-image-viewer.syumai.com) を設定したら解消した。これ以外にも解消方法があるはず。

速度

4kBの画像を返すのに800ms程度だったものが30msになって、めちゃめちゃ速くなった。嬉しい。
9MBの画像は、3sくらいかけて返していたのが、900msくらいになった。

syumaisyumai

4. R2 ObjectのETagを使って304レスポンスを返せるようにした

最後に、上記の実装のみでは304 Responseが返せない事に気付いたので対応した。
CDN Edgeでキャッシュがヒットしても、それを毎回ブラウザに返していたら、その分の時間が余計にかかってしまう。特に、大きい画像などを返す時に顕著になる。
304 Responseを返すことで、Response Bodyの送信を省略してブラウザ自身が持っているキャッシュを使わせることが出来る。

幸いこれは簡単で、R2Objectが持っているETagをそのままResponseに設定し、

https://github.com/syumai/workers-playground/blob/71eade8037cfbd103292f785b0626a4a4a795704/r2-image-viewer/src/index.ts#L39

キャッシュが見付かったタイミングで、そのETagと、If-None-Match ヘッダの ETagが一致しているかを検証するだけで済んだ。

https://github.com/syumai/workers-playground/blob/71eade8037cfbd103292f785b0626a4a4a795704/r2-image-viewer/src/index.ts#L16-L25

速度

4kBの画像ではResponse bodyを返す時と特に差がなく30ms程度だったが、9MBの画像は60msになってめちゃめちゃ速くなった。

一方で、Response bodyを返していないにも関わらず画像サイズによって応答時間が変わっているのが気になる。
もしかしたら、R2Objectのbodyを含まない、ETagを含んだ情報だけを別のCacheに書き込んでおいて使うようにするともっと速く出来るかも知れない。が、キャッシュの一貫性が壊れそうなのでやらない方がいい気はする。

yusukebeyusukebe

面白いっすね。

キャッシュが効くのはサイト配下のみなので、デフォルトで作られる .workers.dev のURL配下ではキャッシュされない。

これなんすよね。僕の認識だと、Cache APIってあくまでバックエンドを持ったCDNのキャッシュをコントロールするものとの認識です。Cache KeyがRequestオブジェクトじゃないといけなかったり。現に以前、UA別にCDNのキャッシュを切り替えるのに使ってましたね。Workersに向いてるかといえば違う気がするんですが、しゅうまいさんの使い方はレスポンスまるごとキャッシュしたいってことなので、ありかと!

syumaisyumai

そうですね。

レスポンスまるごとキャッシュしたい

今回はこのケースなのでCache APIでいいんじゃないかと思いました。

あとは、R2自体に外部への画像配信機能があればWorkersを挟む必要なかったんですが、Workersを挟まないと画像を外に出せないと言うこともあり、実質的にWorker自身がバックエンドを兼ねている状態になっているので、ここでキャッシュを直接触るのも納得感があると思います。

とは言え、Edgeにしかキャッシュが残らないので、各Edgeでの1回目のリクエストはどうしても遅くなります。R2へのリクエスト回数を最小化したかったらKV使った方が良さそうな感じがしました。
KVは容量に合わせて従量課金とのことだったので、画像の枚数が増えてくるとコスパはCache APIの方が良さそうですね。