30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

有効期限切れのキャッシュをそのまま再利用させないための must-revalidate ディレクティブ

レスポンスヘッダのCache-Controlフィールドに設定できるディレクティブとして、must-revalidateがある。
これは、有効期限が切れたキャッシュをそのまま再利用することを許可せず、必ずオリジンサーバに問い合わせることを指示するディレクティブである。
逆に言うと、must-revalidateがない場合、既に有効期限が切れているキャッシュが再検証なしに使われてしまう可能性がある。

この記事では、must-revalidateの有無で動作がどのように変わるのか、CDN として Cloudflare を利用した構成を使って確認していく。
API の返り値をキャッシュしたいので、「キャッシュ レベル」を「Cache Everything」にしている。
また、クライアントのキャッシュが再利用されないように、シークレットウィンドウを使って動作確認している。

結論を先に書くと、must-revalidateを指定していない場合、オリジンサーバが 500 番台のエラーを返したときに古いキャッシュが使われてしまうことがある。

それを検証するためにまず、特定のヘッダフィールドがあった場合に503を返す API を作る。
Deno で以下のようなサーバを書いた。

    case /^\/api\/score$/.test(req.url): {
      const isError = req.headers.get("x-error") === "on";
      const status = isError ? 503 : 200;
      const body = JSON.stringify(
        isError ? { message: "error" } : { score: 90 }
      );
      req.respond({
        status,
        headers: new Headers({
          "Content-Type": "application/json",
          "Cache-Control": "max-age=30",
        }),
        body,
      });
      break;
    }

/api/scoreへのリクエストでヘッダにx-error: onがある場合は503になる。そうでない場合はリクエストが成功しスコアを得る。
また、max-age=30としたため、30秒間はキャッシュが利用される。

あとは、x-error: onがあるリクエストとないリクエストをそれぞれクライアントから送信できるようにすればよい。
今回はそれぞれのボタンを用意し、それを押下するとfetch/api/scoreにリクエストを送信する仕組みにした。
以降、リクエストに成功するボタンを200ボタン、503が返ってくるボタンを503ボタンと呼ぶことにする。

この内容でオリジンサーバにデプロイし、サイトにアクセスしてみる。
CDN として Cloudflare を利用しているため、以下の流れでリクエストが送られる。

クライアント -> Cloudflare のエッジサーバ -> オリジンサーバ

まず200ボタンを押下する。そうするとエッジサーバがオリジンサーバにリクエストを送り、レスポンスを受け取る。
エッジサーバはそれをクライアントに返すのだが、その際、自身にキャッシュを行う。そのため、次にリクエストを受け取ったときはそのキャッシュをクライアントに返すようになる。

30秒以内に改めてシークレットウィンドウでサイトを開き、再び200ボタンを押下する。
そうすると、エッジサーバにキャッシュされたコンテンツが返ってくる。
オリジンサーバにリクエストを送らずエッジサーバで返しているため、レスポンスが高速になる。

上がオリジンサーバに問い合わせたケースで、下がエッジサーバがキャッシュを返したケース。スピードに大きな違いが出ている。

f:id:numb_86:20210615020239p:plain

レスポンスヘッダを見るとcf-cache-statusが HIT になっている。これは、エッジサーバでキャッシュが見つかりそれを返したことを意味する。
age: 8は、エッジサーバにキャッシュされてから8秒経過したことを意味している。

f:id:numb_86:20210615020311p:plain

次に、30秒が経過してから改めてシークレットウィンドウを開き直し、200ボタンを押下する。
そうすると、既にキャッシュの有効期限が切れているため再度オリジンサーバに問い合わせることになる。レスポンスヘッダを確認するとcf-cache-statusは EXPIRED になっている。
そして再びエッジサーバにコンテンツがキャッシュされ、30秒間キャッシュが有効になる。

同様のやり方で有効期限(30秒)内に503ボタンを押下すると、やはりエッジサーバのキャッシュがヒットし、それが返ってくる。そのためステータスコードは200になる。
これは意図した挙動であり、問題ない。/api/scoreCache-Controlmax-age=30にしているのだから、その URL にアクセスすると30秒間は、キャッシュされた内容が返ってくる。

問題は、30秒以上経過してから503ボタンを押下したときである。
そうすると、ステータスコードは200になり、期限が切れたはずのコンテンツが返ってきてしまう。

レスポンスヘッダを確認すると、age30以上になっており、有効期限は間違いなく切れている。そしてcf-cache-statusは STALE であり、有効期限切れのコンテンツを使っていることが分かる。

f:id:numb_86:20210615020337p:plain

なお、キャッシュを使っているとはいえオリジンサーバへの問い合わせは発生しているため、速度は遅い。

f:id:numb_86:20210615020348p:plain

このように、有効期限が切れたキャッシュも、状況によってはそのまま再利用されてしまうのである。
有効期限は一種のフラグと言うか、キャッシュの状態を示すものである。有効期限が切れるとキャッシュの状態が変化するが、それは、キャッシュが削除されることを意味しない。有効期限が切れてもキャッシュが残り続ける可能性がある。
逆に、有効期限以内であっても、キャッシュが削除されてしまうこともあり得る。クライアントやエッジサーバの記憶領域は有限であるため、そのようなことが起こりうる。
キャッシュの存在の有無と有効期限は、それぞれ別の概念なのである。
そしてキャッシュが存在する場合、有効期限が切れていたとしても使われる可能性があるのは、ここまで見てきた通りである。

これを防ぐためのディレクティブが、must-revalidateである。
/api/scoreのレスポンスヘッダを以下のように書き換えて、オリジンサーバにデプロイする。

headers: new Headers({
  "Content-Type": "application/json",
  "Cache-Control": "max-age=30, must-revalidate",
}),

must-revalidateは、有効期限が切れた際にオリジンサーバへの問い合わせを強制するディレクティブ。
そのため、Cache-Controlmax-age=30, must-revalidateにすると、30秒間はキャッシュを使いそれを経過したら必ずオリジンサーバに問い合わせるようになる。

早速、確認する。
Cloudflare のダッシュボードでキャッシュをパージしたあと、200ボタンを押下する。
そしてサイトを開き直して503ボタンを押下する。
有効期限内なら先程と同様にキャッシュされたコンテンツが返ってくるが、有効期限が切れている場合はキャッシュは再利用されず503が返ってくる。
cf-cache-statusも EXPIRED になっている。

f:id:numb_86:20210615020401p:plain

参考資料