BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

Cloudflare でショップページをちょっとだけ速くしてみた - キャッシュ/Workers 編

この記事はBASEアドベントカレンダー 2025 の 7 日目の記事です。

エンジニアの右京です。BASE では今年、表示速度の改善を目標にすべてのショップへ Cloudflare を導入しました。これは、その過程や技術面の簡単な解説です。

記事は前後半になっており、この記事は後半で、Cloudflare Workers を利用したコンテンツのキャッシングの話題となります。

前半はこちら: Cloudflare でショップページをちょっとだけ速くしてみた - 導入/SSL for SaaS 編

ショップページのレスポンス速度を改善したい

レスポンス速度を改善するにあたって、Cloudflare Workers を利用して前段でコンテンツをキャッシュするアプローチが有効なことは事前にイメージがついていました。この方針について特に以下の記事が参考になりました:

zenn.dev

しかし、現状のショップページが前段でキャッシュされることを想定して作られているわけではありません。例えば在庫数は、アクセス毎にサーバーサイドで都度計算を行い HTML として書き出しているため、長くキャッシュを持ってしまうと「商品ページは在庫がある表示なのにカートに商品が入らない」といったことが起こってしまいます。他には、時間経過によって販売状態が変化する商品であったり、抽選販売への応募期間といった時刻が関係するものも、現状の実装では長くキャッシュすることができません。

一方で、実際の購入フローでは必ずカートでの購入操作がある上、厳密な在庫や時刻に関する処理はカートで行われるので、ショップページに在庫数などがリアルタイムに反映され続ける必要というのは実はそこまでありません。そこで、数秒であればこのズレは納得できる範囲だろうと判断し、まずは小さく始めることができ、かつ大きな効果が期待できる「商品ページを数秒間マイクロキャッシングする」を実装することにしました。

最終的にはほとんど静的な作りにし、長くキャッシュを持つことで高速なレスポンスにするという目標はありつつも、まずはアクセススパイク時のインフラ面への負荷を抑えることを主軸としていきます。

Cloudflare Workers の設計と実装

まず、Cloudflare を利用する上での大前提として「Cloudflare ありきの設計にはせず、何かあった場合は外せるようにする」ということを設定しました。これには Workers 単体での障害程度であれば Workers を外すことでサービスを維持できるように、最悪 Cloudflare をやめることになってもサービスを維持できるように、という想いがあります。

ストレージの選択

当初想定していた Workers KV ではなく Cache API を採用することにしました。Cache API は前半でも登場した Cloudflare の Cache (あいまいさ回避のため以後 Cf Cache と呼ぶ)を Workers から操作できる API で、同一 DC 内であれば高速な書き込みと読み出しが行えます。

developers.cloudflare.com

Cf Cache を Workers から扱うもう一つの方法として fetch を行う際に独自のフィールドを持った Request を利用する、というものがあります。

developers.cloudflare.com

Cache API と fetch を比較した場合、性能だけを見ると fetch の方が次の 2 点で優秀です:

  • Cache API では Tiered Caching が働かない
  • fetch は同一のリクエストと判定できる場合リクエストをまとめてくれる(Request Collapsing)

その上で今回 Cache API を選択したのは、その柔軟さにあります。Cache API は Cf Cache に乗せる API と Origin(BASE の Web アプリケーション本体) へのリクエストが分かれているので、キャッシュする前に Header を加工したいようなケースで有効になります。

また、これは自分の調査検証不足もあると思うのですが、 Request Collapsing を利用するにはレスポンスがキャッシュできる前提が必要であるような挙動をします。シークレット EC 機能を実現する際に、Request Collapsing を利用するとどうしてもどこかがキャッシュされてしまったため、Cache API を利用することにしました。

次に Workers KV を見送った理由ですが、書き込み制限と反映の遅延にあります。 KV は同一キーには 1 秒間に一度しか書き込みできず、かつその仕組み上反映が最大で 60 秒遅延します。

developers.cloudflare.com

この特性から、今回の「数秒のマイクロキャッシング」には適していない判断としました。ただし KV を完全に利用していないわけではなく、後述する X-Webapp-Version のストアには KV を利用しています。コンテンツの更新頻度が頻繁ではないデータを、長期間に渡って信頼できるソースとして扱うことに向いているようです。

そして、KV ではなく Cf Cache を使う最大の利点が「 Cache-Control を元々うまく扱える」というところで、コアとなる仕組みはこれを活用した設計になっています。

Cache-Control を利用したキャッシング

よくmax-age=86400 などが指定されている Response Header で、コンテンツをキャッシュする際の挙動を指示するためのものです。このディレクティブにはいくつか CDN 向けのものがあります。

www.cloudflare.com

CDN 向けのものに s-maxage というディレクティブがあり、これが今回のコアとなっています。 s-maxage は端的に言えば CDN 用の max-age で、例えば s-maxage=2 であれば 2 秒間 CDN にキャッシュできる、ということを示しています。

Cf Cache はこれを扱えるので、Header に Cache-Control: s-maxage=2 を持つ Response を Cache API から put することで、 2 秒間生存するキャッシュを作ることができます。作られたキャッシュは match で取り出せるので、これらを合わせると次のようなコードで Workers でのキャッシュを実現できます:

export default {
  async fetch(request, env, ctx) {
    const cache = caches.default;
    const cacheKey = new Request((new URL(request.url)).toString(), request);
    const cachedResponse = await cache.match(cacheKey);
    if (cachedResponse) {
      return cachedResponse;
    }
    const newResponse = await fetch(request); // Cache-Control: s-maxage=2
    ctx.waitUntil(cache.put(cacheKey, newResponse.clone()));
    return newResponse;
  }
}

Origin がキャッシュしたいページで Cache-Control: s-maxage=2 を返すと、Workers でこのコードを通って 2 秒間コンテンツがキャッシュされます。この s-maxage と合わせて 3 種類の Cache-Control を Origin が返すことでキャッシュをコントロールしています:

  • s-maxage=N - コンテンツを N 秒間キャッシュする
  • private - CDN にキャッシュできないことを示すディレクティブ、公開だがキャッシュしたくないページ、未対応のページで使用
  • no-store - CDN にもローカルにもキャッシュしない、シークレット EC で使用

上記のコードは一見すべてのレスポンスをキャッシュするように見えますが、 Cache API は Cache-Control がキャッシュできないことを指示していたり、 Set-Cookie が含まれている場合にはそのコンテンツをキャッシュしません。実際のコードでは put する条件として s-maxage を含んでいることを条件にしてはいますが、このままでも Origin がキャッシュ可能なレスポンスを返さない限りは何もしないようになっているので、安心して利用することができます。

この実装をコアとして、Origin との整合性を担保するための仕組みと、 Cache Stampede を緩和するための機能を加えています。

キャッシュキーの設計

ショップページでは一つの URL からユーザーの環境や設定に合わせた複数のレスポンスが返されるため、 Request Header や Cookie からキャッシュに利用するキーを計算することで、URL に対して複数のキャッシュを紐づけています。このキーにはキャッシュの世代管理のための値も含まれていて、Origin がデプロイされた場合にキャッシュのパージを行うのではなく、利用するキャッシュの参照を切り替える方式を取っています。

ざっくりと次のようなキーになっています:

shop.example.com/items/1234?webapp_version=xxxx-yyyy-zzzz&accept_language=ja,en&i18n_language=ja&i18n_currency=JPY

webapp_version はキャッシュの世代管理のための値で Origin から取得します。Origin にはデプロイ毎に一意の値が割り振られており、ショップページのすべてのレスポンスと専用の API に X-Webapp-Version という独自の Response Header を含んでいます。

X-Webapp-Version: d8643bc9-02ae-49db-b37f-81c26e77cb39

Workers から定期的に API をコールして最新の値を取得していて、Workers ではこの値に一致するキャッシュのみを有効なものとして扱っています。また、各ページのレスポンスにもこれを含んでおくことで、早い段階でのデプロイの検知や、Blue / Green デプロイ中で Origin が新旧を混合で返す状態でも古いキャッシュを作成しないようにしたり、ということに役立てています。

accept_language には BASE でサポートしている日本語と英語にあわせて、Request Header の Accept-Languageja,en もしくは en,ja に丸めた値が入ります。 Accept-Language をそのまま使用してもよいですが、種類が増えキャッシュヒットレートが下がってしまうので Workers で丸めています。

i18n_languagei18n_currency はユーザーが選択した言語と通貨の情報で、Cookie に入っています。 Origin ではこの Cookie の値が accept_language よりも優先され、指定された言語と通貨で HTML をレンダリングするため、キャッシュを細かく分ける必要があります。

基本的には静的な作りにしていく方針ですが、言語通貨のようにどうしてもユーザーによってレスポンス内容を変えたい場合はキャッシュキーを拡張して対応します。

X-Fresh-For

stale-while-revalidate な動作を実現するための仕組みで、 s-maxage と組み合わせて使用します。コンテンツが新鮮な時間を表す Response Header で、任意の値が Origin から返されます。

Cache-Control: s-maxage=10
X-Fresh-For: 2

キャッシュから取得した Response の Age とこの値を比較し、指定されていた分の時間が過ぎていたら、ユーザーにはキャッシュが古いことを表す STALE 状態でキャッシュを返し、バックグラウンドでキャッシュの更新をします。上記であれば、最大 10 秒間キャッシュし 2 秒を超えた時点でアクセスがあれば更新を行う、という動作になります。

キャッシュが切れた際にオリジンへのアクセスが再度集中してしまう、 Cache Stampede を緩和する仕組みとして導入しました。キャッシュ時間を s-maxage のみの場合よりも遥かに長くすることができ、 STALE している間に次のキャッシュを作ることで、キャッシュが完全にない状態を減らすことが目的です。

概念としては次のようなコードで実装されています(このコードは動作しません)。さっきは Cache API と fetch からの Cf Cache 利用を比較していましたが、ここではこの 2 つを組み合わせていることがポイントです。 STALE 状態のキャッシュは「キャッシュできるコンテンツである」という前提があることになるので、安全にリクエストをまとめることができます。

const cachedResponse = cache.match(cacheKey);
if (isStale(cachedResponse)) {
  ctx.waitUntil(() => {
    const revalidateKey = cacheKey + `&revalidate=${cacheResponse.header.get('X-Cache-Id')}`;
    const newResponse = fetch(request, {
      cf: {
        cacheKey: revalidateKey,
        cacheTtl: 1
      }
    });
    newResponse.headers.set('X-Cache-Id', uuid());
    cache.put(cacheKey, newResponse);
  });
}
return cachedResponse;

キャッシュされてから時間が経ったものを検知すると、アクセスに対しては古いレスポンスを返しつつ、裏で更新を行っています。この時に再検証用のキャッシュキーを別途作り、それを使って Request Collapsing の利用を目的とした fetch を呼び出し、その結果を Cache API で実際に使用するキャッシュとして改めて put します。このような実装にすることで、複数の再検証リクエストが一つにまとまり、Origin へ到達するリクエストを削減することが可能になりました。

2025/12/15 追記: この利用方法の場合、 Request Collapsing は fetch のレスポンスが Cf-Cache-Status: MISS の場合に動作するようです。MISS ではなく Cf-Cache-Status: EXPIRED となる場合、リクエストがまとめられていないことがあります。実際に動作しているものは Response にユニークな Id を割り振ったものをキャッシュし、更新リクエストに含めています。上記のコードも修正済みです。

Cache-Control には stale-while-revalidate ディレクティブがあり、これを利用したいと考えていたのですが、Cache API ではこれを利用できないという制約があり独自に実装するような形になりました。例えば s-maxage=2, stale-while-revalidate=10 の場合、キャッシュとしては STALE になりつつも 12 秒間生存してほしいのですが、Cache API の場合は 2 秒でキャッシュが蒸発してしまいます。キャッシュ時間自体を伸ばすためには s-maxage を伸ばす必要があり、このような形になりました。

developers.cloudflare.com

Origin 側の変更点

Workers だけではキャッシュが動作しないようになっているので、ここまでに解説してきた各種 Response Header を Origin が返すように改修を行いました。CDN でのキャッシュを禁止する Cache-Control: private をすべてのページで返すことを基本としつつ、キャッシュしたいページでは次のように返します:

Cache-Control: s-maxage=6
X-Webapp-Version: d8643bc9-02ae-49db-b37f-81c26e77cb39
X-Fresh-For: 2

キャッシュ動作に関して Origin は Response Header を追加しただけで、これによって動作がなにか変わることはありません。Workers がなくなっても動き続ける設計を達成できたように思います。

また、商品の特性によってキャッシュ時間をコントロールすることも可能なので、例えば販売前→販売開始のようにステータスが遷移する時刻をまたぐ場合直前には s-maxage を短く設定するようにしています:

Cache-Control: s-maxage=1
X-Webapp-Version: d8643bc9-02ae-49db-b37f-81c26e77cb39

ただし、さすがに既存機能のコードをまったく変更せずに、とはいかなかったので事前にいくつか以下のような調整を行っていました:

  • 言語通貨設定が CakePHP の Session 機能に依存していたため、 Plain な Cookie での実装へ変更し、Cloudflare Workers でも読み出せるように
  • 商品の閲覧履歴を CakePHP の Session 機能から localStorage を用いたものに変更
  • 一部ログイン状態によってラベルやメニューが変更される箇所の改修、元々非ログイン状態の表示に統一したい認識があったため、これにあわせて変更
  • query として付与させる referrer 情報を事前にサーバー側で処理するコードがあったため、クライアント側で処理ができるように調整

効果

まず、レスポンス速度についてです。キャッシュの導入以前の商品ページは、利用している拡張機能やアクセスの状況にもよりますが、Chrome DevTools で確認する限りでは大体 600ms ~ 2s 程度のサーバー応答待ち時間(Waiting for server response の値)がありました。

キャッシュが有効な場合はこの値が大きく改善され、100ms ~ 150ms 程度で安定するようになります。平均的に 1 秒を超えてくるようなショップだと、1/10 程度になったことになります。ただしあくまでキャッシュが存在する前提なので、すべてのアクセスでこの恩恵を受けられるわけではありません。

では、キャッシュがどの程度働いているかをとある日のアクセススパイクを含む 30 分で見てみます。縦軸がキャッシュヒット率(%)、横軸が時刻、赤が全体のキャッシュヒットレート、緑が HIT、青が STALE でそれぞれ返した割合です。

12:00 頃にアクセスが集中し、キャッシュヒット率が 80-90% 付近まで跳ね上がっています。具体的な数字で言えば、商品ページ毎にざっくり 50,000 程度のアクセスがあり、そのうち 40,000 を HIT 、5,000 を STALE で返しているようなイメージ感で、高いものだと 90% のリクエストキャッシュから返しています。このログには Bot も含まれているのですべてが人間に向けて返されたものではありませんが、Origin への到達を 90% キャッシュで捌けていると考えるとそれなりに効果があるように思えます。

このタイミングで商品ページにアクセスすると 100ms 程度でレスポンスが返ってくるので、全体でみるとちょっとだけショップページが速くなったことになります、なりませんか?

おわりに

ということでショップページがちょっとだけ速くなった話でした。ショップページは改良の余地が多くがあり、Cloudflare の活用もまだまだこれからです。こういった領域に興味が湧きましたら採用情報もぜひご覧ください。

binc.jp

明日は @takashima です、お楽しみに!