💾

Cloudflare Workers の Cache API は積極的に使いましょう

2022/07/22に公開

ここ最近 Cloudflare Workers を触った実感です。趣味の時間で調べて、外部 API のプロキシをやったり画像をキャッシュして返す程度の処理を書きました。コードとしてはチュートリアル改変レベルです。

TL;DR

  • Cache できる場面では Cache をする設計を意識しないと遅くなる
  • Cache API を利用するには独自ドメインを取得して割り当てる必要あり、*.workers.dev では Cache は使えない模様
    • localhost で動作しないので、 wrangler publish する必要ある
    • Cloudflare の管理画面で独自ドメインを買える。Google Domains よりも安価なので、実験用に持っておくことをお勧めする
  • 例えば R2 バケットに入った画像を配信する場合、 Cache API でキャッシュしたものを返すことで、x0ms 単位で返せるようになる
    • cache しない場合は x00ms 程度なので、10倍以上遅くなる
  • Hello World 程度なものでも、 Workers がレスポンスを返すまで 20~30ms は最低かかるのでそれは許容する

方針: Cache できるならとにかく Cache させる

Cloudflare Workers は各拠点にある CDN 上で FaaS のような処理を書いて実行することができるため、早いレスポンスを返せると期待されていますが、雑な設計だとそこまでレスポンスが早くなりません。

API サーバーや R2 バケットなど、オリジンへのアクセスは基本的に遅いです。
一方で Cache や KV のアクセスはかなり早いです。
なので、できるだけオリジンからのアクセスを減らす戦略が必要です。
ある程度の期間、同じ値を返すなら、Cache または KV に保存して、そこから返すように意識する必要があります。

画像の cache

R2バケットからの画像を読み込んで表示させる場合、何も Cache しない場合は 500ms 程度かかります。特に、画像のダウンロード下り速度が遅いです。
また、200 ステータスを返すので毎回新規に読み込んでいます。
Cache が効く環境だとレスポンスが 80ms 程度、ステータスも 304 を返してくれます。

Cache なし

Cache あり

サンプルコード(R2バケット)

動かす際に使ったコードは下記です。公式の例を組み合わせているだけです。
キャッシュを見に行って、あればそれを返し、なければR2バケットにアクセスして、レスポンスをキャッシュに登録して返しています。

# need to execute `wrangler r2 bucket create images`
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "images"
preview_bucket_name = "images"
export interface Env {
  // need to execute `wrangler secret put AUTH_KEY`
  AUTH_KEY: string
  BUCKET: R2Bucket
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    const url = new URL(request.url)
    const key = url.pathname.slice(1)

    if (
      request.headers.get('X-AUTH-KEY') !== env.AUTH_KEY &&
      request.method !== 'GET'
    ) {
      return new Response('Forbidden', { status: 403 })
    }

    switch (request.method) {
      case 'PUT':
        await env.BUCKET.put(key, request.body)
        return new Response(`Put ${key} successfully!`)
      case 'GET':
        const cacheKey = new Request(url.toString(), request)
        const cache = caches.default
        let response = await cache.match(cacheKey)

        if (response) {
          console.log(`Cache hit for: ${request.url}.`)
          return response
        }

        console.log(
          `Response for request url: ${request.url} not present in cache. Fetching and caching request.`
        )

        const object = await env.BUCKET.get(key)
        if (object === null) {
          return new Response('Object Not Found', { status: 404 })
        }

        const headers = new Headers()
        object.writeHttpMetadata(headers)
        headers.set('etag', object.httpEtag)
        headers.append('Cache-Control', 's-maxage=31536000')

        response = new Response(object.body, {
          headers,
        })

        ctx.waitUntil(cache.put(cacheKey, response.clone()))

        return response
      case 'DELETE':
        await env.BUCKET.delete(key)
        return new Response('Deleted!')
      default:
        return new Response('Method Not Allowed', {
          status: 405,
          headers: {
            Allow: 'PUT, GET, DELETE',
          },
        })
    }
  },
}

https://developers.cloudflare.com/r2/get-started/#5-access-your-r2-bucket-from-your-worker

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

外部APIのレスポンスもcacheしてみる

もちろん外部APIのレスポンスもCacheできます。Cacheによって200msが20ms程度に短縮されました。
例としてRESAS APIを使っています。

export interface Env {
  // need to execute `wrangler secret put API_KEY`
  // read https://opendata.resas-portal.go.jp/docs/api/v1/index.html
  API_KEY: string
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    const url = new URL(request.url)
    if (url.pathname === '/prefectures') {
      const cacheKey = new Request(url.toString(), request)
      const cache = caches.default
      const cacheData = await cache.match(cacheKey)

      // return response from cache
      if (cacheData) {
        return cacheData
      }

      const result = await fetchApi(env.API_KEY)
      const stringified = JSON.stringify(result)

      const response = new Response(stringified, {
        headers: {
          'Content-Type': 'application/json',
        },
      })

      ctx.waitUntil(cache.put(cacheKey, response.clone()))

      return response
    }
    return new Response('This is root')
  },
}

type PrefecturesResult = {
  message: string | null
  result: {
    prefCode: number
    prefName: string
  }[]
}

async function fetchApi(API_KEY: string): Promise<PrefecturesResult> {
  const res = await fetch(
    'https://opendata.resas-portal.go.jp/api/v1/prefectures',
    {
      headers: {
        'Content-Type': 'application/json',
        'X-API-KEY': API_KEY,
      },
    }
  )
  return res.json()
}

最後に

色々調べて動かしてはいますが、レスポンスを早く返す点については Cache API と KV の明確な使い分けがいまいち掴めていません。
KV にデータを入れて、それを取り出すようにしても Cache API と同じぐらい早くなるため、パフォーマンスだけで言えば、メリットデメリットが明確に分けづらいです。

Cache API は 304 ステータスを返すことができますが、レスポンス自体をキャッシュしているので若干取り回しにくい印象です。
How KV works を読んだ限りだと、 KV は入れたデータを複数拠点で共有できるみたいなので、例えば CDN のどれか1拠点がAPIサーバーと通信した結果を、他の拠点と共有したい時などに使えるでしょうか。
または ScheduledEvent で定期的にデータを KV に収集して、 FetchEvent でリクエスト時に取り出す、等が思い浮かびます。

R2バケットの例を書くときに他の画像ホスティングサービスを調べてみましたが、 imgixCloudinary はサーバーレスポンスも早く、画像の下り速度もかなり早いです。
高画質なフリー画像を配布しているUnsplashはレスポンスを見ると imgix を使っているみたいです。
こういうユースケースでは、CDNをうまく活用できるか否かでコストやサービスの質に直結するので流石の選択だと思います。

https://unsplash.com/

Discussion