💴

Cloudflare Image ResizingをR2, Cache APIで多段キャッシュしてコスト削減する

2023/09/11に公開

はじめに

LooksToMeでは画像の最適化と加工に、Cloudflare Image Resizingを利用しています。
Cloudflare Image Resizingは内部でキャッシュを保持していますが、そのキャッシュへのアクセスにも課金が発生する仕組みとなっています。
そのため、そのまま利用すると課金額が高くついてしまいます。
今回は、LooksToMeで使用しているCloudflare Image Resizingのコスト削減戦略について紹介したいと思います。

御託はいいから早くコード見せろよって方はこちらをどうぞ

コード全文
type R2CacheKeyParams = {
  path: string;
  format?: 'webp' | 'png' | undefined;
  width?: number | undefined;
};

const getR2CacheKey = (params: R2CacheKeyParams): string => {
  const path = params.path.replace(/^\/|\/$/g, '');
  const key = `caches/${path}/${params.format ?? 'unknown'}`;
  if (params.width) return `${key}/${params.width}`;
  return key;
};

const fetchR2Cache = async (bucket: R2Bucket, key: string): Promise<Response | undefined> => {
  const r2ObjectBody = await bucket.get(key);
  if (!r2ObjectBody) return undefined;

  const headers = new Headers();
  r2ObjectBody.writeHttpMetadata(headers);
  headers.set('etag', r2ObjectBody.httpEtag);
  headers.set('cache-control', 'public, max-age=31536000, immutable');
  return new Response(await r2ObjectBody.arrayBuffer(), { headers });
};

const schema = z.object({
  width: z.coerce.number().optional(),
});

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
    const url = new URL(request.url);
    const origin = url.origin.replace('cdn.', '');
    const format = request.headers.get('accept')?.includes('image/webp') ? 'webp' : undefined;

    const input = schema.parse({
      width: url.searchParams.get('width'),
    });

    const cacheKey = new Request(url.toString(), request);
    const cacheResponse = await caches.default.match(cacheKey);
    if (cacheResponse) return cacheResponse;

    const r2CacheKey = getR2CacheKey({
      path: url.pathname,
      format,
      width,
    });

    const r2Cache = await fetchR2Cache(env.BUCKET, r2CacheKey);
    if (r2Cache) {
      ctx.waitUntil(caches.default.put(cacheKey, r2Cache.clone()));
      return r2Cache;
    }

    const rawResponse =  fetch(`${origin}${url.pathname}`, {
      cf: {
        image: {
          ...(format ? { format } : {}),
          ...(input.width ? { width: input.width } : {}),
        },
      },
    });

    const buffer = await rawResponse.arrayBuffer();
    const response = new Response(buffer, rawResponse);

    if (response.ok) {
      ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
      ctx.waitUntil(env.BUCKET.put(r2CacheKey, buffer));
    }

    return response;
  },
};

LooksToMeの紹介

LGTM画像を投稿・シェアするサービスです。

https://looks-to.me

プラハの有志メンバーと共に開発しています。
詳細については以下の記事をご覧いただければと思います。

https://zenn.dev/praha/articles/d583133c6ecb2f

Cloudflare Image Resizingの料金形態

Cloudflare Image Resizingを利用するにはCloudflare Proへの加入が必要になります。
似た名前のサービスとしてCloudflare Imagesがありますが、これは別物なので注意が必要です。

Cloudflare Proは月額20ドルのプランで、Freeプランでは使えない機能が使えるようになります。
Cloudflare Image Resizingの場合、月間リクエスト数が5万件まで、Cloudflare Proの月額20ドルに含まれており、それを超える場合は5万件ごとに9ドルが追加料金として課金されます。

試算

下記のような条件で、Cloudflare Image Resizingを利用した場合の月間の料金を試算してみます。

画像1毎あたりのサイズ: 1MB
画像枚数: 10万枚
1枚あたりのリサイズバリエーション: 3種類
配信件数: 10万件/日 = 300万件/月

この場合、Cloudflare Image Resizingを単体で使用した場合の料金は以下のようになります。

リクエスト件数: 300万件
月額料金: 20ドル
追加料金: (300万件 - 5万件[無料枠]) / 5万件 * 9ドル = 531ドル
合計: 20 + 531 = 551ドル

551ドルを日本円に換算すると約8万円となります。(2023年9月現在)
円安の影響もありますが、月額8万円は結構な金額です。

R2を用いたコスト削減

Cloudflare Image Resizingへのリクエスト数を削減するために、R2を利用した場合の月間の料金を試算してみます。

R2
保存枚数: 10万枚 * 3種類 = 30万枚 * 1MB = 約300GB
書込回数: 30万件
読取回数: 300万件
保存料金: (300GB - 10GB[無料枠]) * 0.015ドル = 4.35ドル
書込料金: 無料枠内
読取料金: 無料枠内

Cloudflare Image Resizing
リクエスト件数: 10万枚 * 3種類 = 30万件
月額料金: 20ドル
追加料金: (30万件 - 5万件[無料枠]) / 5万件 * 9ドル = 45ドル

合計: 4.35 + 20 + 45 = 69.35ドル

69.35ドルを日本円に換算すると約1万円となります。(2023年9月現在)
Cloudflare Image Resizing単体で使用した場合の料金と比較すると、約1/8のコストで済むことがわかります。

実装例

実際にR2を用いてCloudflare Image Resizingへのリクエスト数を削減するための実装例を紹介します。

Cloudflare Image ResizingへProxyするWorkerの実装

まずは、Cloudflare Image ResizingへProxyするWorkerを実装します。
このWorkerはcdnサブドメインにデプロイされているものとします。
リクエストを受け取った際に、Cloudflare Image ResizingへProxyする仕組みなっており、クエリパラメータにwidthを指定することで、画像の幅を指定できます。
この段階では、R2は使用していない為、Cloudflare Image Resizingを単体で使用する場合と料金は変わりません。

const schema = z.object({
  width: z.coerce.number().optional(),
});

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
    const url = new URL(request.url);
    const origin = url.origin.replace('cdn.', '');
    const format = request.headers.get('accept')?.includes('image/webp') ? 'webp' : undefined;

    const input = schema.parse({
      width: url.searchParams.get('width'),
    });

    return fetch(`${origin}${url.pathname}`, {
      cf: {
        image: {
          ...(format ? { format } : {}),
          ...(input.width ? { width: input.width } : {}),
        },
      },
    });
  },
};

Cloudflare Image ResizingのレスポンスをR2に保存する

次に、Cloudflare Image ResizingのレスポンスをR2に保存する処理を実装します。
R2に保存する際には、画像を保存する際のキーを一意に決定する必要があります。
LooksToMeでは、画像のパスとフォーマット、幅をキーとし、下記のような関数でキーを生成しています。

type R2CacheKeyParams = {
  path: string;
  format?: 'webp' | 'png' | undefined;
  width?: number | undefined;
};

const getR2CacheKey = (params: R2CacheKeyParams): string => {
  const path = params.path.replace(/^\/|\/$/g, '');
  const key = `caches/${path}/${params.format ?? 'unknown'}`;
  if (params.width) return `${key}/${params.width}`;
  return key;
};

また、R2からはResponseではなく、R2ObjectBodyというオブジェクトが返ってくるため、Responseに変換する必要があります。
下記のようなR2ObjectBodyResponseに変換する関数を定義しておくと便利です。

const fetchR2Cache = async (bucket: R2Bucket, key: string): Promise<Response | undefined> => {
  const r2ObjectBody = await bucket.get(key);
  if (!r2ObjectBody) return undefined;

  const headers = new Headers();
  r2ObjectBody.writeHttpMetadata(headers);
  headers.set('etag', r2ObjectBody.httpEtag);
  headers.set('cache-control', 'public, max-age=31536000, immutable');
  return new Response(await r2ObjectBody.arrayBuffer(), { headers });
};

これらの関数を用いて、Cloudflare Image ResizingのレスポンスをR2に保存する処理を実装します。
ctx.waitUntil関数は、非同期関数の存続期間を延長するための関数です。
この関数を用いる事で、R2への保存処理が完了するのを待たずにレスポンスを返すことができます。

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
    const url = new URL(request.url);
    const origin = url.origin.replace('cdn.', '');
    const format = request.headers.get('accept')?.includes('image/webp') ? 'webp' : undefined;

    const input = schema.parse({
      width: url.searchParams.get('width'),
    });

    const r2CacheKey = getR2CacheKey({
      path: url.pathname,
      format,
      width,
    });

    const r2Cache = await fetchR2Cache(env.BUCKET, r2CacheKey);
    if (r2Cache) return r2Cache;

    const rawResponse = fetch(`${origin}${url.pathname}`, {
      cf: {
        image: {
          ...(format ? { format } : {}),
          ...(input.width ? { width: input.width } : {}),
        },
      },
    });

    const buffer = await rawResponse.arrayBuffer();
    const response = new Response(buffer, rawResponse);

    if (response.ok) {
      ctx.waitUntil(env.BUCKET.put(r2CacheKey, buffer));
    }

    return response;
  },
};

Cache APIを利用して、レスポンス速度を向上させる

R2に保存する事で、Cloudflare Image Resizingへのリクエスト数を削減する事ができました。
しかし、Cloudflare WorkersはCDNによるキャッシュが効かない為、都度R2へのリクエストが発生してしまいます。
R2からのサーバー応答はCDNによるレスポンスに比べて遅い為、このままではアプリケーション全体のレスポンス速度が低下してしまいます。
最後に、Cache APIを利用して多段キャッシュする事で、レスポンス速度を向上させます。
Cache APIは利用料金が掛からない為、R2へのリクエスト数を削減しつつ、レスポンス速度も向上出来るため、一石二鳥です。

下記のドキュメントを参考に、Cache APIを利用して多段キャッシュする処理を実装します。
https://developers.cloudflare.com/r2/examples/cache-api/

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
    const url = new URL(request.url);
    const origin = url.origin.replace('cdn.', '');
    const format = request.headers.get('accept')?.includes('image/webp') ? 'webp' : undefined;

    const input = schema.parse({
      width: url.searchParams.get('width'),
    });

    const cacheKey = new Request(url.toString(), request);
    const cacheResponse = await caches.default.match(cacheKey);
    if (cacheResponse) return cacheResponse;

    const r2CacheKey = getR2CacheKey({
      path: url.pathname,
      format,
      width,
    });

    const r2Cache = await fetchR2Cache(env.BUCKET, r2CacheKey);
    if (r2Cache) {
      ctx.waitUntil(caches.default.put(cacheKey, r2Cache.clone()));
      return r2Cache;
    }

    const rawResponse =  fetch(`${origin}${url.pathname}`, {
      cf: {
        image: {
          ...(format ? { format } : {}),
          ...(input.width ? { width: input.width } : {}),
        },
      },
    });

    const buffer = await rawResponse.arrayBuffer();
    const response = new Response(buffer, rawResponse);

    if (response.ok) {
      ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
      ctx.waitUntil(env.BUCKET.put(r2CacheKey, buffer));
    }

    return response;
  },
};

まとめ

今回は、Cloudflare Workersを利用して、Cloudflare Image ResizingのレスポンスをR2Cache APIに保存する処理を実装しました。
Cloudflare Image Resizingは便利なサービスですが、そのまま利用すると課金額が高くなってしまいます。
課金額の高さに悩んでいる方は、是非この記事を参考にしてみてください。

最後まで読んでいただき、ありがとうございました。

アガルートテクノロジーズ/PrAha

Discussion