レスポンスヘッダの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
ボタンを押下する。
そうすると、エッジサーバにキャッシュされたコンテンツが返ってくる。
オリジンサーバにリクエストを送らずエッジサーバで返しているため、レスポンスが高速になる。
上がオリジンサーバに問い合わせたケースで、下がエッジサーバがキャッシュを返したケース。スピードに大きな違いが出ている。
レスポンスヘッダを見るとcf-cache-status
が HIT になっている。これは、エッジサーバでキャッシュが見つかりそれを返したことを意味する。
age: 8
は、エッジサーバにキャッシュされてから8
秒経過したことを意味している。
次に、30
秒が経過してから改めてシークレットウィンドウを開き直し、200
ボタンを押下する。
そうすると、既にキャッシュの有効期限が切れているため再度オリジンサーバに問い合わせることになる。レスポンスヘッダを確認するとcf-cache-status
は EXPIRED になっている。
そして再びエッジサーバにコンテンツがキャッシュされ、30
秒間キャッシュが有効になる。
同様のやり方で有効期限(30
秒)内に503
ボタンを押下すると、やはりエッジサーバのキャッシュがヒットし、それが返ってくる。そのためステータスコードは200
になる。
これは意図した挙動であり、問題ない。/api/score
のCache-Control
をmax-age=30
にしているのだから、その URL にアクセスすると30
秒間は、キャッシュされた内容が返ってくる。
問題は、30
秒以上経過してから503
ボタンを押下したときである。
そうすると、ステータスコードは200
になり、期限が切れたはずのコンテンツが返ってきてしまう。
レスポンスヘッダを確認すると、age
が30
以上になっており、有効期限は間違いなく切れている。そしてcf-cache-status
は STALE であり、有効期限切れのコンテンツを使っていることが分かる。
なお、キャッシュを使っているとはいえオリジンサーバへの問い合わせは発生しているため、速度は遅い。
このように、有効期限が切れたキャッシュも、状況によってはそのまま再利用されてしまうのである。
有効期限は一種のフラグと言うか、キャッシュの状態を示すものである。有効期限が切れるとキャッシュの状態が変化するが、それは、キャッシュが削除されることを意味しない。有効期限が切れてもキャッシュが残り続ける可能性がある。
逆に、有効期限以内であっても、キャッシュが削除されてしまうこともあり得る。クライアントやエッジサーバの記憶領域は有限であるため、そのようなことが起こりうる。
キャッシュの存在の有無と有効期限は、それぞれ別の概念なのである。
そしてキャッシュが存在する場合、有効期限が切れていたとしても使われる可能性があるのは、ここまで見てきた通りである。
これを防ぐためのディレクティブが、must-revalidate
である。
/api/score
のレスポンスヘッダを以下のように書き換えて、オリジンサーバにデプロイする。
headers: new Headers({ "Content-Type": "application/json", "Cache-Control": "max-age=30, must-revalidate", }),
must-revalidate
は、有効期限が切れた際にオリジンサーバへの問い合わせを強制するディレクティブ。
そのため、Cache-Control
をmax-age=30, must-revalidate
にすると、30
秒間はキャッシュを使いそれを経過したら必ずオリジンサーバに問い合わせるようになる。
早速、確認する。
Cloudflare のダッシュボードでキャッシュをパージしたあと、200
ボタンを押下する。
そしてサイトを開き直して503
ボタンを押下する。
有効期限内なら先程と同様にキャッシュされたコンテンツが返ってくるが、有効期限が切れている場合はキャッシュは再利用されず503
が返ってくる。
cf-cache-status
も EXPIRED になっている。