SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

Cloudflareの画像最適化料金をWorker KVで97%削減した話

BOXILでエンジニアをやっている永井です。前回は入社エントリを書きましたが今回は技術的な記事を書こうと思います。

今回はCloudflareにおける画像の最適化処理のコストカットをした話をします。ざっくりいうとCloudflare内のKVという機能を使い、最適化をした画像をキャッシュしました。似たような問題で悩んでいる方は参考にしてもらえると嬉しいです。

TL;DR

  • Cloudflareで画像のリサイズ(形式変更)を行っていた
  • リサイズ後の画像はデフォルトではキャッシュされず、都度リサイズの処理が実行されていた
  • Cloudflare内のWorker KV機能を使いキャッシュの実装をしたところ、コストがおよそ97%削減できた

前提

BOXILでは画像の配信にCloudflareを使っています。同時にCloudflareの機能で画像のリサイズや形式の変換(WebPなど)を行い、ブラウザ上で素早く表示されるための最適化を図っています。具体的な内容に関してはすでに記事を公開しておりますのでぜひご参考にしてみてください。

tech.smartcamp.co.jp

問題

この画像最適化はCWVのスコアやUXの向上に大きく貢献していましたが、 1~2ヶ月の間運用をしていくと「最適化済みの画像をキャッシュしていない」という事が発覚しました。

これは誰かがブラウザで画像を閲覧するたびに画像の最適化処理が実行されると言うことになります。10人が10個の画像入りページを開いた場合、100回最適化処理が走るため効率が良いとは言えません。

また金銭的コストにも大きな影響が出ていました。Cloudflareでは処理5万件につき$9の費用がかかります。BOXILでは平均して一日におよそ60万~70万回ほどの最適化処理が走っており、月におよそ40,000円のコストがかかっていました。t3.xlargeのインスタンスを2ヶ月借りられる位の額なので大きいですね。

対策

最初に書いたとおり、CloudflareのWorkers KVという機能を使って最適化後の画像をキャッシュしてコストを削減しました。

Workers KVとは

Cloudflare Worker内で使用できるkey-valueストアで、キャッシュした静的ファイルと同じくらい迅速に応答するAPIやWebサイトの構築を可能にするらしいです(公式より)。

www.cloudflare.com

valueにはstring,ReadableStream,ArrayBufferの3つの型のいずれかを格納できるため、今回は最適化後の画像をArrayBufferに変換して格納しています。ざっくり言うと画像をバイナリに変換してkey-valueストアに突っ込むという少し強引な方法を取っています。

料金はストレージ1GBがプランに含まれており、BOXILの最適化後の画像は全体でも1GBに満たない位だったため、追加で料金が発生しませんでした(追加しても1GBで$2)。また読み取りも1M(100万)回につき$1なので、画像最適化の処理5万件につき$9の費用と比べると大きく節約が可能になります。(※料金は2021/12/20時点のものです)

注意事項とか

KVに画像を格納することに関して良いのかという問題はコミュニティーで議論されていました。ブログのようなサービス用に画像、または音声データをKVに格納して使うことに関しては問題ないようです。(動画は駄目)

https://community.cloudflare.com/t/is-it-ok-to-serve-images-from-workers-and-kv/179166community.cloudflare.com

サンプル

では実際にKVを使って画像をキャッシュする方法をサンプルコードを載せつつ説明したいと思います。KVのnamespaceを作成後workerに登録して使用するといった流れです。順に説明していきます。

事前準備

KVのnamespace作成

Workers→KVに遷移し、"Create namespace"からKVのnamespaceを作成しておきます。名前は管理しやすいようにしておきましょう。

KVをworkerに登録

KVを使いたいworkerの設定→変数→KV名前空間のバインディングから、さきほど作成したKVのname spaceをworkerに変数として読み込ませます。変数名は任意なので、ここも管理しやすい名前にすると良いと思います。

流れ

準備ができたので、実際にコードを書いていきます。処理は以下のような流れで行います。

  1. KV内に画像のバイナリがあるかどうか確認
    1. もしあればクライアントに返却して終了
  2. なければ元画像をS3から取得
  3. 元画像に最適化処理を実行する
  4. 最適化した画像をKVにバイナリ変換して格納する
  5. 最適化した画像をクライアントに返却して終了

Keyについて

今回はKeyに画像取得用のURLを直接指定することで、単一性を担保しています。もし同じURLで違う画像を取得するようなユースケースがある場合は何か別のKeyを考える必要があります。

コード

addEventListener("fetch", event => {
  // WebPをサポートしていない場合はそのままプロキシする
  if (!isWebpSupport(event.request)) { return fetch(event.request) }

  // リクエストループ防止
  if (/image-resizing/.test(event.request.headers.get("via"))) {
    return fetch(event.request)
  }

  if (event.request.url.match(/\jpg$|jpeg$|png$/i) && !event.request.url.match(/\+|\%2B|\%40|\%5B|\%5D|\%21|\%EF\%BC\%88|\%EF\%BC\%89|\%28|\%29/)) {
    return event.respondWith(handleRequest(event.request))
  } else {
    return fetch(event.request)
  }
})

async function handleRequest(request) {
  const cachedImageBuf = await RESIZED_IMAGE.get(request.url, { type: "arrayBuffer" })

  if(cachedImageBuf !== null) { return createResponseBy(cachedImageBuf) }

  const originalImageRequest = new Request(request.url, { headers: request.headers })

  const optimizedImageResponse = await fetch(originalImageRequest, OPTIMIZE_IMAGE_OPTION)

  if (optimizedImageResponse.ok || optimizedImageResponse.status == 304) {
    const optimizedImageBuf = await optimizedImageResponse.arrayBuffer()
    await RESIZED_IMAGE.put(request.url, optimizedImageBuf, { expirationTtl: 1209600 }) // keyはURL, 有効期限は2週間
    return createResponseBy(optimizedImageBuf)
  } else {
    return optimizedImageResponse.redirect(originalImageRequest, 307)
  }
}

function createResponseBy(imageBuf) {
   return new Response(
    imageBuf,
      {
        status: 200,
        statusText: "OK",
        headers: {
            "Content-Type": "image/webp",
            "Content-Length": imageBuf.size,
        }
    }
  )   
}

function isWebpSupport(request) {
  return Object.fromEntries(request.headers).accept.match(/image\/webp/)
}

const OPTIMIZE_IMAGE_OPTION = {  
  cf: {
    image: {
      fit: "scale-down",
      width: 1440,
      height: 900,
      quality: 90,
      format : "webp"
    }
  }
}

ポイント

KVから値を取得するときは、バイナリを入れていてもデフォルトではStringで取得するためそのままでは意図した動作になりません。なので以下のように第2引数でtypeを指定してあげることでそのままバイナリとして使用できます。

const cachedImageBuf = await RESIZED_IMAGE.get(request.url, { type: "arrayBuffer" })

逆に値を入れるときは、ArrayBufferに変換して入れてあげましょう。

const optimizedImageBuf = await optimizedImageResponse.arrayBuffer() // レスポンス(画像)を変換

await RESIZED_IMAGE.put(request.url, optimizedImageBuf, { expirationTtl: 1209600 })

結果

実際にこの処理をデプロイして数日後の結果が以下の図の通りです。

どこで今回の施策を反映したか一発で分かる位、ガクッと画像のリサイズ処理の回数が減っています(すごく気持ちよかった)。料金も4万円/月→1,200円/月位に落ち着き、およそ97%のコストカットを行なうことができました。

まとめ

今回はCloudflareの画像最適化処理をWorkers KVでキャッシュすることで効率よく画像の配信ができるようになったのでその方法をご紹介しました。

CloudflareのImage Resizeはそのまま使用するとキャッシュをしなかったためこのような手段を取りました。効果がとても大きかったためしばらくはこれで運用を続けていきますが、Cloudflareの開発はとても活発なので、そのうちデフォルトでキャッシュするようになってくれると嬉しいなぁと思ったりしています。では良いお年を。