インフラ

デプロイ後のキャッシュ無効化:パージすべきものと、そうでないもの

陳腐化したエッジやオリジンの過負荷を発生させることなく、毎日デプロイします。アセットを、決してパージしない不変のハッシュキーと、パスによってパージする可変のHTMLに分類します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

CDNエッジキャッシュは、サイトを高速にする要因であると同時に、デプロイが反映されない原因でもあります。近くの都市から10ミリ秒でページを配信するその同じキャッシュが、修正を公開してから1時間もの間、先週のバージョンを提供し続けるのです。安直な対応は、デプロイのたびにすべてをパージすることですが、それではキャッシュが空になり、次のトラフィックの波がオリジンに直接送られてしまいます。それはまさに、キャッシュが吸収するために存在していた負荷そのものです。現実的な解決策は、単一の設定ではありません。それは、アセットタイプごとに選択される、小さなルールの集合です。このルールでは、バイトの大部分は永久にキャッシュされ、決してパージされることはありません。そして、実際に変更されたごく少数のファイルのみが無効化されます。この記事では、それらのルール、それらを支えるキャッシュヘッダー、そしてその正確な作業を行うパージコールについて説明します。

デプロイ後にエッジキャッシュが古いコンテンツを配信する理由

CDNがレスポンスをキャッシュするのは、あなたがそう指示したからです。通常はCache-Controlヘッダーとmax-ageを使って指示します。エッジノードがコピーを持つと、その有効期限が切れるか、明示的にパージするまで、そのコピーから応答します。エッジはあなたがデプロイしたことを知りません。gitの履歴やリリースパイプラインを監視しているわけではありません。エッジから見れば何も起こっていないので、持っているものを配信し続けます。

そのギャップが問題のすべてです。2つの障害モードは、1つのダイヤルの両端に位置しています。ダイヤルを長いキャッシュ有効期間と高いヒット率の方向に回すと、デプロイがユーザーに届くまで1時間かかることがあり、ブラウザもコピーを保持している場合はさらに長くなる可能性があります。短い有効期間と積極的なパージの方向に回すと、デプロイのたびにオリジンにコールドトラフィックが殺到し、ヒット率は低下し、出荷頻度が高くなるほどレイテンシの数値は悪化します。どちらの極端な設定も、望ましい状態ではありません。

この問題からの脱出方法は、サイトを1つのキャッシュ可能なものとして扱うのをやめることです。ファイルごとに変更されるスケジュールは異なり、それぞれに異なるルールを適用すべきです。

アセットを不変なものと可変なものに分割する

コンテンツサイトが提供するほぼすべてのファイルは、2つのバケットのいずれかに分類されます。そして、この分割こそが、残りの戦略をシンプルにするものです。

1つ目のバケットは、一度ビルドされると決して変更されないコンテンツです。バンドルされたJavaScriptファイル、スタイルシート、フォント、処理済みの画像などです。バイトは固定されています。ソースを編集すると、ビルドは別のファイルを生成し、古いものをその場で変更するのではなく、新しいものとして提供することになります。

2つ目のバケットは、安定したアドレスの下でその場で変更されるコンテンツです。HTMLページが最も分かりやすい例です。URL /blog/some-post は、機能し続け、その投稿の最新バージョンを指し示し続けなければなりません。タイプミスを修正しても、アドレスは変わりませんが、その背後にあるバイトは変わります。

これら2つのバケットは、正反対のキャッシュルールを必要とします。下の表は、戦略全体を1ページにまとめたもので、記事の残りの部分で各行を説明します。

アセットタイプ アドレス形式 Cache-Control デプロイ時のアクション
JS、CSS、フォント、ビルド済み画像 ハッシュ化された名前、app.a1b2c3.js public, immutable, max-age=31536000 なし、新しいビルドは新しい名前になる
レンダリングされたHTMLページ 安定したパス、/blog/post public, max-age=60, stale-while-revalidate=86400 変更されたパスのみをパージ
APIおよびJSONフラグメント 安定したパス public, max-age=30, stale-while-revalidate=300 サロゲートキーでパージ
サイトマップ、フィード 安定したパス public, max-age=300 公開時にパージ
ユーザー固有のレスポンス 安定したパス private, no-store エッジでキャッシュしない

一行で要約すると、不変なコンテンツは1年間キャッシュされ、決してパージされません。そして、可変なコンテンツは短時間キャッシュされ、正確にパージされます。トラフィックのほとんどはパージを必要としないはずです。なぜなら、バイトのほとんどすべてが不変だからです。

不変のアセット: 名前にハッシュを含め、決して削除しない

最初のバケットの手法は、ファイルコンテンツのハッシュをその名前に含めることです。ビルドステップで app.js の最終的なバイトを読み込み、短いダイジェストを計算し、app.a1b2c3d4.js を出力します。それを読み込む HTML は、その正確な名前を参照します。ソースを 1 行変更するとダイジェストが変わり、そのため次のビルドでは app.e5f6g7h8.js が出力され、HTML は代わりにそちらを指すようになります。

名前はコンテンツから派生するため、ある名前が意味するのは、常にただ 1 つの正確なバイトセットのみです。これにより、Web が持つ最も強力なキャッシュヘッダーを送信できます。

Cache-Control: public, immutable, max-age=31536000

max-age=31536000は1年です。immutableディレクティブは、ブラウザにリロード時に再検証しないように指示します。なぜなら、その名前が存在する限り、その名前のバイトは変更されないという約束が真に守られるからです。CDNはファイルを一度キャッシュし、使用されなくなったために削除されるまでエッジから提供します。オリジンは、そのファイルへのリピートトラフィックをほとんど受信しません。

デプロイにおける利点は、これらのファイルを決してパージしないことです。デプロイはapp.a1b2c3d4.jsを上書きしません。新しい名前で新しいファイルをシップし、HTMLの参照を更新します。新しい名前はどのキャッシュにも存在したことがないため、クリアすべき古いデータはなく、古い名前はそれを依然として要求するページに古いバイトを提供し続けます。最大のアセットに対するキャッシュの無効化は、あなたが行うアクションではなくなります。それは起こり得ない状態になります。なぜなら、名前がその意味を決して変えないからです。

変更可能なHTML: 短いTTLと対象を絞ったパージ

HTMLページは、リンク、ブックマーク、検索エンジンのためにアドレスを安定させる必要があるため、ハッシュ化された名前を使用できません。そのため、短いキャッシュ生存期間と、変更された特定のパスの明示的なパージという、逆の扱いを受けます。

短いTTLだけでも機能しますが、それだけでは鮮度とオリジン負荷の間の選択を迫られます。stale-while-revalidate はその緊張を解消し、それについては後のセクションで説明します。現時点では、ヘッダーは次のようになります。

Cache-Control: public, max-age=60, stale-while-revalidate=86400

max-age=60は、エッジがページを1分間新鮮なものとして扱うことを意味し、これにより人気のあるURLへのトラフィックの急増を吸収します。しかし、実際のデプロイが反映されるのを待つには1分は長すぎるため、有効期限には依存しません。デプロイの最後に、変更した正確なパスをパージします。

パージとは、特定のキャッシュされたオブジェクトを削除するための、CDNのAPIへの認証された呼び出しです。その形式はプロバイダー間で同じです。削除するものを指定します。

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files": [
        "https://example.com/blog/some-post",
        "https://example.com/blog/some-post/"
      ]}'

重要なのは、対象を絞るということです。ゾーン全体ではなく、このデプロイで変更があったページをパージします。あるリリースで3つの投稿とインデックスページが変更された場合、4つのURLをパージします。他のすべてのキャッシュされたページはホットな状態を維持するため、デプロイは残りのトラフィックからは見えず、オリジンサーバーもデプロイがあったことに気づきません。

問題は、どのパスが変更されたかを知る必要があるという点です。ビルドがすでにマニフェストを生成している場合、直近2回のビルド出力の差分を取ることで、変更されたセットを取得できます。そうでない場合は、次のセクションで、URLをまったく列挙せずにパージする方法を説明します。

URLではなく、サロゲートキーによるパージ

1つの変更が多くのページに波及する場合、URLによるパージは破綻します。著者名を編集すると、その著者のすべての投稿、著者ページ、およびいくつかのタグページを更新する必要があるかもしれません。これらのURLを手作業でリストアップすることは、脆弱で間違いやすいです。

サロゲートキーはこれを解決します。オリジンがレスポンスをレンダリングする際、1つ以上のラベルでレスポンスをタグ付けするヘッダーを付加します。ほとんどのCDNは Surrogate-Key のようなヘッダーやキャッシュタグヘッダーを読み取り、すべてのキャッシュされたオブジェクトをそのタグによってインデックス化します。

Surrogate-Key: post-1024 author-42 tag-infra

投稿ページは、自身のID、著者のID、およびタグを保持しています。後で著者が変更されたとき、1つのキーをパージすると、CDNはその数に関係なく、そのキーを持つすべてのオブジェクトを破棄します。

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["author-42"]}'

どのURLが影響を受けるかを追跡する必要はなくなります。レスポンスにその依存関係でタグを付け、変更された依存関係に基づいてパージを行います。これにより、影響範囲が広くてもパージを正確に保つことができ、デプロイステップでURL構造を考慮する必要がなくなります。どのデータが各ページに含まれたかをすでに知っているオリジンが、これらの依存関係を宣言するのに適切な場所です。

デプロイの最後にパージを自動化する

人間が覚えておく必要があるパージはスキップされがちであり、スキップされたパージは、壊れたデプロイのように見える古いサイトになります。解決策は、新しいバージョンが公開された後、パイプラインの最後のステップとしてパージを自動的に実行することです。

順序が重要です。最初に新しいオリジンをデプロイし、それが稼働していることを確認してから、パージします。新しいバージョンが公開される前にパージすると、エッジはオリジンから古いバイトを再フェッチして再キャッシュするため、一時的に余分な負荷がかかるだけで、何も達成できません。後でパージすると、クリアされた各パスへの次のリクエストはエッジでミスし、新しいオリジンにヒットして、新しいバージョンを再キャッシュします。

# 1. deploy and wait for the new revision to receive traffic
deploy_release

# 2. compute the changed paths from the build manifest diff
CHANGED=$(diff_manifest build/prev build/current)

# 3. purge only those paths, at the very end
purge_paths "$CHANGED"

パージをデプロイに組み込むことで、何が無効化されたかを正確に確認できる唯一の場所も得られます。パージされたパスやキーをログに記録してください。誰かがなぜページが更新されたのか、あるいはされなかったのかを尋ねたとき、その答えは誰かの記憶の中ではなく、デプロイログにあります。

再検証中に古いコンテンツを配信

stale-while-revalidateは、レイテンシーを犠牲にすることなくTTLを短く保つことを可能にします。これは上記のHTMLヘッダーに記載されており、頻繁な有効期限切れを恐れる最後の理由を取り除いてくれるため、それ自体を理解する価値があります。

Cache-Control: public, max-age=60, stale-while-revalidate=86400

最初の60秒間、エッジはキャッシュされたページを新鮮なものとして提供します。その後、次の1日間、エッジはバックグラウンドでオリジンから新しいコピーを取得しながら、古いコピーを即座に提供することが許可されます。更新をトリガーしたユーザーはオリジンを待ちません。そのユーザーは少し古いページをエッジの速度で取得し、次の訪問者は更新されたものを取得します。新鮮さは、オリジンへの完全なラウンドトリップではなく、1つのリクエスト分だけ遅れます。

これにより、短いmax-ageの体感が変わります。これがない場合、60秒のTTLは、URLごとに毎分1人の不運なユーザーがオリジンを待つことを意味します。これがあれば、そのユーザーは即時のレスポンスを得て、オリジンからの再取得は帯域外で行われます。ほぼリアルタイムの新鮮さと、ユーザーが体感するほぼゼロのオリジンレイテンシーを同時に得ることができます。明示的なパージは、必要なときに即時更新を強制するため、この2つは連携して機能します。パージはデプロイを処理し、stale-while-revalidateはそれらの間の緩やかな変化を処理します。

トレードオフと、全面的なパージが問題ない場合

これらにはすべてコストがかかります。ハッシュ化された不変の名前は、ビルドパイプラインがダイジェストを計算し、HTML内のすべての参照を書き換え、前のバージョンをまだ読み込んでいるページが壊れないように、古いファイルを十分に長く保持する必要があることを意味します。それは真の複雑さであり、ビルドツールの中に永久に存在し続けます。

対象を絞ったパージにも独自のコストがあります。何が変更されたかを知る必要があるのです。ビルドが差分を取れるマニフェストを出力するか、オリジンが正しく維持するサロゲートキーでレスポンスにタグ付けするかのいずれかです。古いタグや欠落したマニフェストエントリは、ページが静かに更新されないことを意味し、これはサイト全体が一様に古い状態であるよりも気づきにくいです。

これらのコストと比較されるのが全面的なパージで、これにはそのどれも必要ありません。一度の呼び出しでゾーンがクリアされ、すべてが確実に最新の状態になります。いくつかのケースでは、これが正直なところ正しい答えです。トラフィックの少ない小規模なサイトであれば、誰も気づくことなくオリジンへのコールドヒットを吸収できます。提供してはならないページを削除するような緊急事態では、大雑把な手段を取る価値があります。そして、本当にすべてのページに影響する移行では、精度から得られるものはありません。

通用するルールは次のとおりです。バイトの大部分を不変にしてパージが不要になるようにし、可変の残りの部分はパスまたはキーでパージします。そして全面的なパージは、状況が気にする必要がないほど小さい場合や、考える余裕がないほど緊急である場合に頼るツールとして取っておきます。日々のデプロイでは、いくつかのパスにのみ触れ、キャッシュの残りの部分は、シップする直前とまったく同じようにウォームな状態に保つべきです。