by MintJams

Cache-Controlの“攻め”:stale-while-revalidateで配信を止めない

モダンなWeb開発に必須のstale-while-revalidate、stale-if-error、immutableを徹底解説。CDNと組み合わせる「攻めのキャッシュ戦略」で、配信を止めずUXを改善する方法を、Nginx/Fastlyの設定例からトラブルシューティングまで網羅的に解説します。

現代のWeb開発において、パフォーマンスと安定性は最優先事項です。特に、モバイル環境や不安定なネットワーク状況下では、いかにユーザー体験を損なわずにコンテンツを届け続けるかが重要になります。従来のCache-Controlは、キャッシュの有効期間を厳密に管理するものでしたが、近年登場した新しいディレクティブは、より柔軟で、かつ強力なキャッシュ戦略を可能にします。これらのディレクティブは、2020年代のWebエンジニアにとって欠かせない武器であり、CDNと組み合わせることでその真価を発揮します。この記事では、stale-while-revalidatestale-if-errorimmutableといった“攻め”のキャッシュ戦略を支えるディレクティブに焦点を当て、その実用的な活用方法を解説します。

これらのディレクティブを適切に使いこなすことで、APIレスポンスから画像、スタイルシートに至るまで、あらゆるリソースの配信を最適化し、ユーザーにストレスのない体験を提供できます。特に、頻繁に更新されるが、数分程度の遅延が許容できるJSON APIの配信では、これらのディレクティブがその真価を発揮します。

この記事でわかること

  • stale-while-revalidatestale-if-errorimmutableの各ディレクティブの役割と挙動
  • サーバー(Nginx)と主要なCDN(Fastly, Cloudflareなど)における設定例とその意味
  • ブラウザ、CDN、オリジン間のキャッシュの挙動と、デバッグに役立つヒント
  • Service Workerとの使い分けや、誤解されやすいポイント
  • 困った時のトラブルシューティングとチェックポイント

本編:実践的なキャッシュ戦略の構築

stale-while-revalidate:古いデータを表示しつつ裏で更新

stale-while-revalidateは、キャッシュが古くなっても、すぐにサーバーへリクエストするのではなく、古いキャッシュをユーザーに表示しつつ、非同期で新しいコンテンツをサーバーに取りに行く仕組みです。これにより、ユーザーは常にリソースを即座に受け取ることができ、パフォーマンスを体感できます。

このディレクティブは、max-ageと組み合わせて使用します。max-ageでキャッシュの最大有効期間を定義し、その期間を過ぎた後もstale-while-revalidateで指定された期間内であれば古いキャッシュが利用されます。主要なブラウザ(Chrome、Firefox)で広くサポートされており、Safariもバージョン14以降で対応しています。CloudflareやFastly、Akamaiといった主要なCDNもこの機能をサポートしています。

max-ageはブラウザのキャッシュ有効期間を制御しますが、CDNなどの共有キャッシュ専用の有効期間を設定したい場合はs-maxageディレクティブを使用します。 s-maxageが指定されている場合、共有キャッシュではmax-ageよりもs-maxageが優先されます。

このディレクティブは主にCDNやプロキシキャッシュ向けに設計されています。 ブラウザも対応していますが、リクエストがCDNでキャッシュされた場合、ブラウザまで再検証リクエストが届かないことも多いため、CDNでの設定がより重要となります。意図した動作にならない場合は、どのキャッシュレイヤー(ブラウザ、CDN/プロキシ、オリジン)が働いているかを確認することが重要です。

stale-while-revalidateの動作シーケンス

以下の図は、Cache-Control: public, max-age=600, stale-while-revalidate=86400を設定した際の流れです。

1. ユーザー ----[GET /data]----> CDN (キャッシュなし)
   └─ CDNはオリジンからデータを取得し、キャッシュして返却

2. ユーザー ----[GET /data]----> CDN (600秒以内)
   └─ CDNはキャッシュを即座に返却

3. ユーザー ----[GET /data]----> CDN (600秒経過後、86400秒以内)
   └─(1) CDNは即座に古いキャッシュを返却
   └─(2) CDNは“裏で”オリジンへ新しいデータ取得リクエスト
      └─ オリジン ----[新しいデータ]----> CDN
         └─ CDNは新しいデータをキャッシュして更新

例:Nginxでの設定

server {
    listen 80;
    server_name example.com;

    location /api/data {
        # キャッシュの有効期間を600秒(10分)に設定
        # キャッシュが古くなった後も、最大86400秒(24時間)は古いキャッシュを利用
        add_header Cache-Control "public, max-age=600, stale-while-revalidate=86400";
        # ... その他の設定
    }
}

この設定では、クライアントは最初の10分間はキャッシュされたレスポンスをそのまま利用します。10分経過後、次にリクエストがあった際には古いキャッシュを即座に表示し、同時にバックグラウンドで新しいデータを取得しに行きます。これにより、ユーザーは待つことなくコンテンツを閲覧できます。

Service Workerとの違い

Service Workerも同様のキャッシュ戦略をクライアント側で実装できますが、サーバーやCDN側でCache-Controlヘッダーを設定する利点は以下の通りです。

  • シンプルさ: クライアント側のJavaScriptを記述・デプロイする必要がなく、サーバー設定のみで実現できる。
  • 汎用性: ブラウザだけでなく、CDNやプロキシサーバーなど、HTTPキャッシュを扱うすべての場所で効果を発揮する。
  • CDNの恩恵: CDNがこの機能に対応している場合、オリジンサーバーへのリクエストを減らし、高速なキャッシュ配信の恩恵を最大限に享受できる。

stale-if-error:エラー時でもコンテンツを配信

stale-if-errorは、オリジンサーバーに接続できなかったり、5xx系のサーバーエラーが発生した場合に、有効期限切れのキャッシュをフォールバックとして利用するディレクティブです。これにより、サーバーの障害時でも、ユーザーへのサービス提供を継続できます。この機能は、主要なCDN(Fastly, Cloudflareなど)で広くサポートされていますが、ブラウザでの実装は限定的です。

例:CDN(Fastly)での設定

FastlyのVCL (Varnish Configuration Language) では、カスタムヘッダーを使ってこのディレクティブを追加できます。以下の例は vcl_fetch を使っていますが、Varnish 4+相当の環境では vcl_backend_response を使うこともあります。

sub vcl_fetch {
    if (beresp.http.Content-Type ~ "application/json") {
        # JSONレスポンスにstale-if-errorを追加
        set beresp.http.Cache-Control = "public, max-age=60, stale-if-error=3600";
    }
}

この設定は、JSON APIのレスポンスに対して、max-ageを60秒、stale-if-errorを3600秒(1時間)に設定しています。オリジンサーバーがダウンした際も、キャッシュが1時間以内であれば、有効期限切れであってもクライアントにそのキャッシュが提供されます。

immutable:キャッシュを再検証しない

immutableは、キャッシュされたリソースがサーバー側で変更されることがない場合に利用します。このディレクティブが設定されていると、ブラウザはキャッシュされたリソースが古くなっても、再検証リクエスト(If-None-MatchIf-Modified-Since)を送らず、ローカルキャッシュをそのまま利用します。

注意点: immutableは、ファイル名にバージョンハッシュを含めるなど、ファイル名自体が変更されない限り内容が変更されないリソースに対してのみ使用すべきです。例えば、app.1a2b3c.jsのようなファイルです。

例:Nginxでの設定

server {
    # ...
    location ~* \.(css|js|jpg|png)$ {
        # バージョンハッシュを含むリソースに設定
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

この設定では、CSS、JS、画像ファイルが1年間キャッシュされ、その期間中は再検証リクエストが行われません。これにより、リクエスト数を大幅に削減し、ロード時間を短縮できます。


トラブル時のチェックポイント

これらの強力なディレクティブを導入する際には、意図しない挙動を防ぐためにいくつかの点を確認する必要があります。

Q. APIレスポンスにユーザーごとのパーソナライズデータが入る場合、public, stale-while-revalidateを付けても大丈夫?

A. 絶対にNGです。 publicディレクティブは、そのレスポンスがCDNや共有プロキシキャッシュに保存され、すべてのユーザーに再利用可能であることを意味します。ユーザー固有のデータを含むレスポンスにpublicを設定すると、他のユーザーに意図しない情報が漏洩する可能性があります。

このような場合は、Cache-Control: private, max-age=...のようにprivateを指定するか、そもそもキャッシュをしないように設定する必要があります。

  • キャッシュヒット率の確認: 標準のCache-Status(RFC 9211)もしくはベンダー固有ヘッダー(Cloudflare: CF-Cache-Status、Fastly: X-Cacheを活用すると、リクエストがどのキャッシュレイヤーでヒットしたかを簡単に確認でき、デバッグが捗ります。Cache-Statusヘッダーの仕様はIANAのHTTP Cache-Status登録ページで確認できます。
  • ヘッダーの重複: サーバー設定やCDNの設定で、同じCache-Controlヘッダーが複数回追加されていないか確認しましょう。後から追加されたヘッダーが前の設定を上書きすることがあります。Nginx 1.7.5+ではadd_header ... always;を利用することで、エラー応答時にもヘッダーが付与されるようになります。
  • Varyヘッダー: Vary: Accept-Encodingなど、リクエストヘッダーに応じて異なるレスポンスを返す場合は、適切にVaryヘッダーを設定しているか確認します。設定が不十分だと、ユーザー間でキャッシュが混在する可能性があります。

チェックリスト

導入前

  • どのリソースにどのディレクティブを適用するか、戦略を策定したか?
  • バージョンハッシュを使用する静的リソースにimmutableを適用するか?
  • 数分程度の遅延が許容できるAPIにstale-while-revalidateを適用するか?
  • サーバーダウン時にでもユーザー体験を確保したいリソースにstale-if-errorを適用するか?

動作確認時

  • 開発者ツールでCache-Controlヘッダーが期待通りに設定されているか?
  • リクエストがキャッシュから返されているか(200 OK (from disk cache)など)?
  • キャッシュが期限切れになった後、stale-while-revalidateで古いコンテンツが表示され、同時に再検証リクエストが送信されているか?
  • 意図的にサーバーエラーを発生させ、stale-if-errorで古いキャッシュが返されるか?

まとめ

この記事では、Cache-Controlの新しいディレクティブであるstale-while-revalidatestale-if-errorimmutableについて解説しました。これらは、単にキャッシュを有効化するだけでなく、ユーザーにシームレスな体験を提供するための攻めのキャッシュ戦略を可能にします。

stale-while-revalidateは、パフォーマンスと鮮度のバランスを取り、ユーザーに待たせない体験を提供します。stale-if-errorは、サーバーの信頼性を補完し、障害時でもサービスを継続させるための保険となります。そしてimmutableは、バージョン管理された静的リソースの配信を極限まで効率化します。

これらのディレクティブを適切に組み合わせることで、堅牢で高速なWebアプリケーションを構築できます。この記事を読めば、キャッシュで困る確率が大幅に下がるはずです。ご覧いただきありがとうございました。


参考資料