リリース直後に「本棚が開けない」という問い合わせが届きました。本棚は購入済み作品の一覧を表示する画面です。リリース前から開いたままのタブで操作を続けたケースでした。ブラウザが読み込んでいるフロントエンドは古く、バックエンドだけが新しい状態です。

CDNのキャッシュはデプロイ時に削除済みです。それでも直りませんでした。

原因はキャッシュが1層ではなかったことです。CDNのパージが届かないキャッシュを、ブラウザ側に2種類残していました。加えて、フロントエンドとバックエンドのバージョンがそろわない状態への備えも足りていませんでした。

先に結論を3点書きます。

  • CDNのキャッシュ削除が消せるのはエッジまでで、各ユーザーのブラウザHTTPキャッシュには効かない
  • 静的エクスポートしたHTMLにCache-Controlを付けないと、ブラウザが独自の基準でキャッシュする
  • キャッシュ制御とバージョン不整合の検知は、片方だけ実装しても機能しない

前提とする構成

担当しているサービスのフロントエンドは、次の構成で動いています。

  • Next.js 13.4.7の静的エクスポート(TypeScript、Pages Router)
  • 成果物をオブジェクトストレージ(Alibaba Cloud OSS)に配置し、CDNで配信
  • APIはGraphQL(graphql-requestと自動生成SDK)
  • バックエンドはフロントエンドと別リポジトリで、デプロイも別
flowchart LR
    Build["ビルド<br/>next build → 静的エクスポート"] --> OSS["オブジェクトストレージ<br/>Alibaba Cloud OSS"]
    OSS --> CDN["CDN<br/>エッジキャッシュ"]
    CDN --> Browser["ブラウザ<br/>HTTPキャッシュ / sessionStorage"]
    Browser -->|"GraphQL + Client-Version"| API["バックエンド<br/>別リポジトリ・別デプロイ"]

重要なのは、HTMLとJavaScriptがサーバー側でレンダリングされない点です。ブラウザが取得したHTMLと、そこから読み込むJavaScriptチャンクの組み合わせが、そのユーザーにとっての「アプリのバージョン」です。この組み合わせが古いまま固定されると、リリース内容が届きません。

何が起きたか

報告された症状は3つです。

  • リリース前から開いていたタブで本棚を開くと、画面が壊れる
  • ログアウトして再ログインするまで、リリース内容が反映されない
  • 既存のタブから派生した別のタブで、前のバージョンのデータが表示される

3つには共通点がありました。全ユーザーには起きておらず、CDNのキャッシュを削除しても直りませんでした。そしてログアウトすると直りました。

CDNのキャッシュ削除で直らない事実は、原因の切り分けに使えます。CDNのパージはエッジのキャッシュを消す操作です。エッジを消しても直らないなら、原因はエッジより先、つまりクライアント側にあります。

ブラウザ側にもキャッシュが2種類あった

整理すると、キャッシュはCDNエッジだけではありません。ブラウザ側にも2種類あります。

主に保持するもの消えるタイミングCDNのパージで消えるか
CDNエッジHTML・JavaScript・画像TTL満了、またはパージ消える
ブラウザHTTPキャッシュHTML・JavaScriptCache-Controlの指定次第消えない
sessionStorage画面表示用のデータタブを閉じる、または明示的な削除消えない

ブラウザHTTPキャッシュ側の問題は2つありました。

1つは、静的エクスポートしたHTMLにCache-Controlが付いていなかったことです。レスポンスに明示的な有効期限がない場合、ブラウザはヒューリスティックキャッシュを行います。つまりLast-Modifiedなどから独自に鮮度を推測して、一定時間は再取得しません。開発者が指定していないだけで、キャッシュしない設定にはなりません。

もう1つは、アップロード方法です。ossutil cp --updateは更新されたファイルを上書きしますが、以前のビルドで生成された古いチャンクを削除しません。結果として、古いHTMLをキャッシュしているブラウザは、そのHTMLが参照する古いJavaScriptチャンクをそのまま読み込み続けます。

sessionStorage側の問題は、クリアするタイミングです。ユーザー情報や作品情報の一部をsessionStorageにキャッシュしており、これをログアウト時にしか削除していませんでした。

加えて、開き方によっては新しいタブにsessionStorageの内容が引き継がれます。HTML標準では、openerを保持したまま新しい閲覧コンテキストを開く場合にsessionStorageを複製すると定義されています。window.open()で開いたタブや、タブの複製が該当します。

一方、target="_blank"は2021年以降、暗黙にnoopenerを含みます。この場合は別のブラウジングコンテキストグループに新しい閲覧コンテキストが作られるため、標準どおりならsessionStorageは複製されません。実際にChrome 150で確認したところ、target="_blank"では複製されず、window.open()でopenerを保持した場合のみ複製されました。今回の障害では、Safariで引き継がれる挙動を確認しています。

なお、今回のアプリのコードには、同一オリジンをopener付きで開く箇所がありません。window.open()の呼び出し先はすべて外部ドメインです。したがって引き継ぎは、タブの複製のようなユーザー操作によって起きたと考えられます。アプリ側で開き方を制御していても、この経路は塞げません。

引き継ぎを防ぐ手段がないため、ログアウト時にしか削除しない設計では古いデータが残ります。削除のタイミングそのものを設計し直す必要がありました。

切り分けで一度間違えた

最初に疑ったのはsessionStorageでした。開いたままのタブでは、sessionStorageの内容が生き続けます。ログアウト時にしか削除していなかったため、古い表示データが残ったままになります。

しかし「本棚が開けない」はsessionStorageでは説明できませんでした。本棚の画面はsessionStorageを使っていないためです。絞り込み条件はコンポーネントのstateとURLクエリで持ち、認証状態はCookieを参照します。

本棚の真因は、古いフロントエンドが新しいバックエンドと通信していたことです。このリリースで本棚のGraphQLクエリとJavaScriptを変更しました。古いJavaScriptは古いクエリを送るため、画面が壊れます。

古いフロントエンドが残る経路は2つありました。リリース前から開いたままのタブと、古いHTMLおよびJavaScriptのブラウザキャッシュです。どちらの経路でも、ブラウザが実行するコードはリリース前のまま固定されます。

ログアウトで直ったのは、ログアウト処理がwindow.location.hrefによる遷移でページ全体を再読み込みし、新しいJavaScriptを取得していたからです。

ここが今回いちばんの学びです。「ログアウトすると直る」は、sessionStorageが原因である証拠になりません。ログアウトはsessionStorageの削除とフルリロードを同時に行うため、2つの仮説を同時に満たしてしまいます。症状が同時に発生していても、原因が同じとは限りません。

切り分けの決め手は、対象の画面がsessionStorageを読んでいるかをコードで確認したことでした。仮説の検証には、症状ではなく実装を見る必要があります。

HTMLとアセットでCache-Controlを分ける

ブラウザHTTPキャッシュへの対策として、アップロード時にCache-Controlを付与しました。方針は次のとおりです。

対象Cache-Control理由
_next/配下のアセットpublic, max-age=31536000, immutableファイル名にビルドごとのハッシュが付くため、内容が変わればURLも変わる
HTMLno-cacheURLが変わらないため、毎回サーバーへ問い合わせて新しさを確認させる

no-cacheは「キャッシュしない」ではなく「再検証してから使う」という指定です。HTMLはURLが固定なので、キャッシュを許しつつ毎回検証させる指定が適切です。

付与方法にも選択肢がありました。アップロード後にset-metaで既存オブジェクトのメタデータを書き換える方法は、対象を全走査するため時間がかかります。オブジェクト数が多い環境では現実的ではありません。そこで、アップロードと同時に指定する方法を選びました。

1
2
3
4
5
6
7
8
# ハッシュ付きアセットは長期キャッシュ + immutable
ossutil cp -r ./out/_next oss://$BUCKET/_next/ --update \
  --cache-control "public, max-age=31536000, immutable"

# HTML は毎回再検証させる
ossutil cp -r ./out oss://$BUCKET/ --update \
  --include "*.html" --exclude "*" \
  --cache-control "no-cache"

このオプションはossutil v2で利用しています。オプション名と対応状況はバージョンで変わるため、手元のossutil cp --helpで確認してください。

設定時の注意点を2つ挙げます。

1つ目は、CDNがオリジンのCache-Controlを尊重する設定になっているかを確認することです。CDN側で独自のTTLを設定していると、オリジンの指定が上書きされます。その場合はCDNの配信ルールでもHTMLをno-cache扱いにする必要があります。

2つ目は、この対策の効果範囲です。すでに古いHTMLをキャッシュしているユーザーは、そのキャッシュが失効するか、ハードリロードやログアウトを行うまで直りません。つまりこれは再発防止策であり、発生中の障害を即座に解消する手段ではありません。

CDNの刷新は既定で再検証になる

デプロイ後にキャッシュが完全に消えない事象も併発していました。原因は、キャッシュ刷新の既定動作でした。

Alibaba Cloud CDNのRefreshObjectCachesをディレクトリ指定で実行した場合、既定の動作は再検証です。エッジは配下のオブジェクトに「次のリクエストでオリジンに確認する」という印を付けます。このときオリジンが304を返すと、エッジは古いキャッシュを使い続けます。強制的に無効化したい場合は、強制パージのオプションを明示する必要があります。

1
2
3
4
aliyun cdn RefreshObjectCaches \
  --ObjectPath "https://<domain>/" \
  --ObjectType "Directory" \
  --Force true

ここで回り道をしました。配下まで消したいと考えてObjectPathの末尾に/*を付けたところ、InvalidObjectPath.Malformedが返りました。ワイルドカードはディレクトリ指定では使えず、正規表現タイプとして扱われます。正規表現タイプは1日あたりの実行回数の上限がディレクトリ指定より厳しく、しかも既定が再検証である点は変わりません。上限値は変更される可能性があるため、公式ドキュメントで確認してください。

そもそもディレクトリ指定は子ディレクトリを含む再帰的な操作です。ワイルドカードは不要でした。必要だったのは範囲の拡大ではなく、動作の変更でした。

フロントエンドが古く、バックエンドが新しい状態が生まれる

ここまではキャッシュの話です。ここからは冒頭の問い合わせに戻ります。フロントエンドとバックエンドを別々にデプロイする構成では、両者のバージョンがそろわない期間は必ず生まれます。リリース前から開いたままのタブは、この期間に取り残された状態です。

キャッシュの問題を放置すると、この期間が「数分」ではなく「そのユーザーのキャッシュが失効するまで」に伸びます。2つの問題は独立していません。

GraphQLでは旧クライアントのクエリがそのまま壊れる

この構成では、GraphQLがバージョン不整合に弱くなります。クエリの文字列を組み立てるのはクライアント側だからです。

古いクライアントは、古いクエリを送ります。バックエンドのスキーマからフィールドが削除されていれば、そのフィールドを要求した時点でエラーです。引数が必須に変わっていれば、送っていない引数が原因でバリデーションエラーになります。エンドポイントが生きていても、リクエストの内容そのものが無効です。

REST APIであれば、追加されたフィールドを無視する形で動き続ける余地があります。GraphQLではクライアントが要求を明示するため、その余地が小さくなります。

Client-Versionヘッダと426で検知する

対策として、すべてのGraphQLリクエストにクライアントのバージョンを載せています。バックエンドは許容範囲より古いバージョンを検出すると、HTTPステータス426を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const CLIENT_VERSION_MISMATCH_EVENT = 'client-version-mismatch';

export const createGraphQLClient = (authorization?: string) => {
  const headers: Record<string, string> = { 'Client-Version': CLIENT_VERSION };
  if (authorization) {
    headers.authorization = authorization;
  }

  return new GraphQLClient(process.env.NEXT_PUBLIC_API_URL ?? '', {
    headers,
    responseMiddleware: (response) => {
      // 古いクライアントと判定された場合は、通知役に渡すためのイベントを発行する
      if (
        response instanceof ClientError &&
        response.response.status === 426 &&
        typeof window !== 'undefined'
      ) {
        window.dispatchEvent(new Event(CLIENT_VERSION_MISMATCH_EVENT));
      }
    },
  });
};

426はUpgrade Requiredです。本来はプロトコルの切り替えを要求するステータスであり、アプリのバージョン更新を促す用途に転用しています。既存のGraphQLエラーと混ざらず、HTTPのレイヤーだけで判定できる点を優先しました。

responseMiddlewareから直接スナックバーを表示していないのは、クライアントの生成箇所がReactのコンポーネントツリーの外にあるためです。ユーティリティ関数の中でフックは呼べません。そこでCustomEventを発行し、通知とリロードはReact側のリスナーに任せています。イベントを挟むことで、通信層と表示層を分離できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const useClientVersionMismatchHandler = () => {
  const { showSnackbar } = useSnackbar();
  const hasHandledRef = useRef(false);

  useEffect(() => {
    const handleMismatch = () => {
      // 複数の API が同時に 426 を返してもスナックバーを多重表示しない
      if (hasHandledRef.current) return;
      hasHandledRef.current = true;

      showSnackbar('最新バージョンに更新するため、ページを再読み込みします。', 'error');
      setTimeout(() => {
        window.location.reload();
      }, RELOAD_DELAY_MS);
    };

    window.addEventListener(CLIENT_VERSION_MISMATCH_EVENT, handleMismatch);
    return () => {
      window.removeEventListener(CLIENT_VERSION_MISMATCH_EVENT, handleMismatch);
    };
  }, [showSnackbar]);
};
sequenceDiagram
    participant B as ブラウザ・旧クライアント
    participant A as バックエンド
    participant L as リスナー・_app 配下
    B->>A: GraphQL リクエスト + Client-Version
    A-->>B: 426 Upgrade Required
    B->>L: CustomEvent を発行
    L->>L: スナックバーを表示・初回のみ
    L->>B: 一定時間後に location.reload

実装で効いた工夫が2つあります。

1つは、リロードまで数秒待つことです。即座にリロードすると、ユーザーは何が起きたか分からないまま画面が切り替わります。文言を読める時間を確保しました。

もう1つは、ハンドラーを1回だけ動かす制御です。1つの画面が複数のAPIを同時に呼ぶため、それらがまとめて426を返します。制御しないとスナックバーが多重に表示され、リロードのタイマーも重なります。

426が404に飛ばされていた

ここで不具合が出ました。一部の画面で426を受けても元の画面に戻らず、404ページが表示された状態でリロードが走りました。

原因は、共通処理ではなく画面側のエラーハンドリングでした。当時のページには、次のようなcatchが多数ありました。

1
2
3
4
5
6
7
} catch (err) {
  if (err instanceof ClientError && err.response.status === 503) {
    router.push('/503');
    return;
  }
  router.push('/404');
}

「503以外のエラーはすべて404へ」という書き方です。ここに426も吸い込まれます。画面が404へ遷移した後にリロードが走るため、ユーザーは元の画面に戻れません。求めていた挙動は「同じ画面のまま再読み込みする」ことです。

修正は、426を共通処理へ素通しさせるガードの追加です。

1
2
3
4
5
6
7
8
9
} catch (err) {
  // 426 は responseMiddleware 側で通知とリロードを行うため、ここでは何もしない
  if (err instanceof ClientError && err.response.status === 426) return;
  if (err instanceof ClientError && err.response.status === 503) {
    router.push('/503');
    return;
  }
  router.push('/404');
}

このとき、末尾のrouter.push('/404')は残しました。426以外のエラーの挙動を変えずに、回帰のリスクを最小にする判断です。

得られた教訓は、横断的な関心事の扱い方です。バージョン不整合の検知を通信層のミドルウェアに集約しても、画面ごとのcatchがステータスを握りつぶすと機能しません。エラーのステータスと遷移先の対応を画面ごとに書いていると、共通処理を追加するたびに全画面へガードを足す作業が発生します。

現在は、ステータス別の分岐を1つのフックに集約する方向へ寄せています。判断のポイントは、データが存在しないことによる正当な404と、通信エラーによる404を区別することです。前者は残す必要があります。

2つの対策は両輪である

最後に、キャッシュ制御とバージョン検知の関係を整理します。

426を受けてリロードする仕組みは、リロードによって新しいHTMLとJavaScriptを取得できる前提に立っています。HTMLにno-cacheを付けていない場合、この前提は崩れる可能性があります。ブラウザがキャッシュから同じ古いHTMLを返せば、読み込まれるのは同じ古いクライアントです。同じ426を繰り返す状態になりえます。

実際にこのループを踏んだ記録はありません。リロード操作では、ブラウザが再検証をかけるのが通常の挙動です。それでも、設計としてバージョン検知がキャッシュ制御に依存している事実は変わりません。検知の仕組みだけを入れて安心はできません。

まとめ

今回の障害から得た知見は次の4点です。

  • CDNのパージはエッジまでしか届かない。クライアント側のキャッシュは別の手段で扱う
  • 静的エクスポート構成では、HTMLとハッシュ付きアセットでCache-Controlを分けて設計する
  • 「ログアウトで直る」のような症状の一致は、原因の一致を意味しない。実装を読んで確認する
  • フロントエンドとバックエンドを別にデプロイする構成では、バージョン不整合の検知とキャッシュ制御をセットで設計する

キャッシュは、正しく設定していないと「設定していない」状態にはなりません。ブラウザとCDNがそれぞれの基準で判断を代行します。どこで何をどれだけ保持するかを、レイヤーごとに決めておく必要があります。

参考リンク