Cloudflare Image ResizingをR2, Cache APIで多段キャッシュしてコスト削減する
はじめに
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画像を投稿・シェアするサービスです。
プラハの有志メンバーと共に開発しています。
詳細については以下の記事をご覧いただければと思います。
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
に変換する必要があります。
下記のようなR2ObjectBody
をResponse
に変換する関数を定義しておくと便利です。
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
を利用して多段キャッシュする処理を実装します。
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
のレスポンスをR2
、Cache API
に保存する処理を実装しました。
Cloudflare Image Resizing
は便利なサービスですが、そのまま利用すると課金額が高くなってしまいます。
課金額の高さに悩んでいる方は、是非この記事を参考にしてみてください。
最後まで読んでいただき、ありがとうございました。
Discussion