ブログに戻る
2026年8月13日Sergei Solod30 分で読めます

古い Next.js タブがデプロイ後に壊れる理由:古い HTML、消えたチャンク、バージョンずれ

あるデプロイ後、本番監視で自分の Next.js アプリに属するチャンクの読み込み失敗を記録しました。ログが証明していたのは失敗が起きたことだけで、原因ではありません。この記事ではその事例を起点に、長時間開いたタブ、古い HTML、欠落した /_next/static リソース、バージョンずれ、旧資産の保持、deploymentId、公開順序、監視、制御された復旧を整理します。

Next.jsデプロイバージョンずれウェブキャッシュフロントエンド信頼性静的リソース

デプロイ後の本番環境で見たエラーのうち、特に役に立ったものの一つは、見た目だけなら拍子抜けするほど単純でした。

Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js

同じエラー流には、解析ツールの失敗、広告スクリプト、一般的な Script error.、途中で止まった動画再生なども混ざっていました。その大半は雑音です。しかしこれは違いました。読み込めなかったリソースが、自分の Next.js アプリそのものに属していたからです。ブラウザが本当にそのファイルを取得できなければ、ページの一部が動かなくなる可能性があります。

ただし、そのログだけではチャンクがなぜ失敗したのかは分かりませんでした。一時的なネットワーク障害かもしれません。プロキシや CDN の障害かもしれず、ファイル自体が欠けていた可能性もあります。あるいは、サーバーがすでに新しいビルドへ置き換わったあとも、古いページが前回のデプロイに属するチャンクを要求していたのかもしれません。

最後のケースは過小評価されがちです。新しいデプロイ自体は完全に正常でも成立するからです。新規訪問者には新しい版が問題なく表示される一方、数時間前から開かれているタブは、気付かれないまま旧版のクライアントとして動き続けます。

この記事で扱うのは、この互換性の空白です。古い Next.js タブがデプロイ後に壊れる理由、古い HTML と消えた /_next/static リソースがどのようにバージョンずれを生むのか、過剰な削除がなぜ問題を悪化させるのか、そして既にアプリを開いていた利用者を壊れたページに取り残さないために、デプロイ、旧資産の保持、監視、復旧をどう設計するかを説明します。

最初の教訓:すべてのスクリプト失敗をデプロイ障害と決めつけない

元のエラー流には、性質のまったく異なる失敗が混在していました。第三者の解析・広告スクリプトは、コンテンツ遮断、DNS フィルタ、プライバシー機能、地域制限、ウイルス対策ソフト、利用者のネットワークなどで止められます。動画の play() の Promise は、その後の pause() によって中断されることがあり、アプリ自体が壊れているとは限りません。オリジンをまたぐ一般的な Script error. も、診断に足る情報をほとんど持たない場合があります。

一方、自分の Next.js アプリに属するチャンクの読み込み失敗は、優先度を分けるべきです。有用な境界は「JavaScript エラーがあるかないか」ではなく、次のような区別です。

第三者リソースの読み込み失敗
    -> 通常は計測や任意機能への影響

自サイトの /_next/static/*.js の読み込み失敗
    -> アプリのコードが利用できない可能性

この区別は重要です。通知が騒がしすぎると、実際に壊れたページと結び付く失敗が埋もれてしまいます。私の場合、重要だったのは /_next/static/chunks/9253.647385b4be0958e4.js への要求でした。ログから、自サイトのスクリプト読み込みが失敗したことは分かります。しかし、デプロイ後のバージョンずれが原因だとまでは証明できません。

私はこの証拠の境界を意識して明確にしています。ありそうな原因と、確認済みの原因は別物です。

開いたままのブラウザタブは、実質的に旧リリースのクライアントである

この問題の見方を変えてくれた考え方は単純です。デプロイ後も、アプリの複数バージョンが同時に生き続けることがあります。

リリース A が 10:00 に稼働していたとします。利用者がページを開き、その経路に必要な HTML と JavaScript を受け取ります。10:30 にリリース B が A を置き換えます。新しい訪問者には B が配られます。しかし、すでに開いているタブは、サーバーが変わっただけで自動的に B へ変わるわけではありません。

そのタブには、まだ次のものが残っている可能性があります。

  • リリース A から読み込んだ JavaScript 実行環境。
  • A が生成した経路やチャンクへの参照。
  • A で先読みされたナビゲーションデータ。
  • A の実行中に作られた React の状態。
  • A からすでに取得済みの分割モジュール。
  • A に属するが、まだ取得していないモジュールへの参照。

最後の項目で、問題が目に見える形になります。

そのページが今後必要とするすべてのチャンクが既にブラウザキャッシュに入っていれば、利用者は何も気付かず使い続けられるかもしれません。しかし現代の Next.js アプリはコードを分割します。経路変更、動的 import、モーダル、編集画面、後から使う機能などが、別の JavaScript ファイルを必要とすることがあります。そのとき旧実行環境は、リリース A では正しかった URL を使ってリソースを要求します。

サーバーがそのリソースをまだ保持していれば、処理はそのまま続けられます。デプロイ時に削除済みなら、リリース B 自体は正常でも、旧クライアントは 404 を受け取る可能性があります。

内容ハッシュ付きのチャンクは長期キャッシュを前提にしている

Next.js は、本当に不変なリソースに対して意図的に長いキャッシュ期間を設定します。現在のセルフホスティング文書では、ファイル名に SHA ハッシュを持つ不変リソースは、たとえば次のような 1 年間の方針で配信されると説明されています。

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

内容が変われば URL も変わるため、これは合理的です。内容から名前が決まるファイルは、毎回再検証する必要がありません。後のビルドが異なるバイト列を生成するなら、別のリソース URL になるべきです。

ここで見落としやすい重要な点があります。古い文書や古い実行環境が参照できる限り、古い URL にも意味が残ります。

ブラウザがハッシュ付きリソースを 1 年キャッシュできても、その特定のファイルをデプロイ前に一度も取得しておらず、初めて必要になった時点でオリジンサーバーが既に削除していれば助けにはなりません。

つまり「静的ファイルは不変である」と「前リリースの静的ファイルをすぐ削除してよい」は同じ意味ではありません。不変性によって古いリソースを安全に保持できますが、旧クライアントが要求しなくなるわけではありません。

現在の Next.js セルフホスティングガイドは、複数サーバーやローリングデプロイで生じるバージョンずれの症状として、JavaScript や CSS の欠落を明示しています。同時に動く二台のサーバーではなく、古いタブと更新済みのオリジンとの間にずれがある場合も、同じ種類の問題です。

バージョンが食い違う経路はいくつもある

「キャッシュの問題」だけでは診断として曖昧すぎます。私は少なくとも四つの仕組みに分けて考えます。対処法がそれぞれ異なるからです。

1. 旧タブがデプロイ前に一度も読み込んでいないリソースを要求する

長時間開いているタブで起こる典型例です。文書と実行環境はリリース A のものです。リリース B がサーバー上のファイルを置き換えます。その後、利用者が A の遅延読み込みチャンクを必要とする操作を行います。A のリソースが削除されていれば、要求は失敗します。

2. 古い HTML が、すでに存在しないチャンクを指している

CDN、リバースプロキシ、Service Worker、ブラウザキャッシュ、静的ホスティング層などが、予想以上に長く古い HTML 文書を保持することがあります。その HTML はまだ A の参照を含んでいる一方、オリジンには B しか残っていないことがあります。

HTML に誤って長い immutable 方針を与えると特に危険です。ハッシュ付き JavaScript と HTML を同じキャッシュ対象として扱うべきではありません。チャンクは URL が内容でバージョン化されるため不変にできます。どのチャンク URL を一組として使うか決めるのは HTML 側です。

3. ローリングデプロイや複数インスタンスが異なるリリースを混在させる

負荷分散装置の後ろに Next.js インスタンスが二つあるとします。一方は既に B、もう一方はまだ A です。文書は一方の版から届き、その後のナビゲーション要求が別の版へ届くことがあります。現在の Next.js 文書ではこれをバージョンずれと呼び、リソース欠落、Server Functions の不一致、ナビゲーション失敗を引き起こし得るとしています。

安全な基本方針は、一度だけビルドし、同じデプロイに参加するすべてのインスタンスで同一のビルド成果物を動かすことです。Next.js のセルフホスティング文書も、各複製を独立して再ビルドするのではなく、同じビルドと一貫したビルド識別子をコンテナ間で使うことを勧めています。

4. デプロイ自体が誤った順序でファイルを公開する

古いタブがなくても、非原子的なアップロードは一時的に成立しない状態を作れます。

新しい HTML はすでに見えている
+
新しいチャンクファイルはまだ利用できない

または逆に、

古い HTML はまだ見えている
+
古いチャンクファイルはすでに削除されている

ごく短い時間でも十分です。利用者が一度その瞬間に当たれば問題になります。

危険なのは「全部置き換えて古いツリーを削除する」デプロイ

単純なデプロイスクリプトは、よく次のような形から始まります。

build
rsync --delete new-output/ production/
restart

本番ディレクトリが常に最新ビルドと完全一致するので魅力的です。しかし、長時間生きているクライアントには不親切です。

ハッシュ付き静的リソースでは、ディレクトリを一つのリリースだけにすることはブラウザにほとんど利点がありません。URL が違うので旧ファイルと新ファイルは競合しません。削除で得られるのは主にディスク容量ですが、その代わり旧クライアントが持つ有効な参照を潜在的な 404 に変えてしまいます。

今では旧チャンクをゴミではなく、デプロイ互換性を支える資材として考えています。

すべてのビルドを永久に残す必要はありません。削除は新リリース公開の副作用ではなく、独立した保持方針として行うべきです。

旧資産の保持は有効だが、有限の保持期間だけでは完全には解決できない

セルフホスティングでは、古い /_next/static リソースを一定の猶予期間残せます。適切な長さは利用形態次第です。ページを開いて 2 分読んで離れるサイトと、一日中開いて使うアプリでは危険度が違います。

最低限の保持期間は、次のように考えると分かりやすくなります。

保持期間 >=
    古い HTML が残ると見込まれる時間
    + 長時間開いたタブの現実的な存続時間
    + ロールバック可能期間
    + デプロイ反映の余裕

これは数学的な保証ではありません。ブラウザタブは数週間開いたままにできます。有限の時間を設定しても、旧クライアントの失敗を完全に不可能にはできません。

そこで私は複数層で守る設計を好みます。

  1. 通常の旧セッションが継続できるだけの期間、前のリリースの不変リソースを保持する。
  2. バージョンずれを検出し、クライアントが現行リリースへ移れるようにする。
  3. リソースが本当に利用不能なら、安全な一度だけの再読み込み、または利用者に見える復旧手段を用意する。
  4. 自サイトのチャンク欠落を監視し、実データに基づいて保持期間を調整する。

保持層が大半の失敗を防ぎます。復旧層は、有限の保持期間では消し切れない残りのケースを扱います。

ファイルの古さだけで旧チャンクを機械的に削除しない

「7 日より古いファイルはすべて削除する」といった単純な規則も誤ることがあります。現行リリースが、内容が変わっていないため更新日時だけ古いハッシュ付きファイルを再利用しているかもしれないからです。

より強い不要資産回収は、リリースを認識する必要があります。

  1. 互換期間内にある各リリースのマニフェストや資産一覧を保持する。
  2. それらのリリースから参照される資産パスの和集合を作る。
  3. 保護対象の集合に含まれるものは削除しない。
  4. 参照されなくなった資産も、さらに猶予期間を置いてから削除する。

小規模なデプロイには仕組みが大きすぎるなら、静的資産ディレクトリを意図的に広めに保つほうが、まれなクライアント障害を調査するより安く済むことがあります。ハッシュ付きファイルは特に相性がよく、同じ内容は自然に安定した URL を再利用でき、少なくとも同一のハッシュ名で無関係な内容を上書きすることはありません。

避けたい規則は単純です。共有の /_next/static ツリーに対する --delete を、新リリースを昇格させる同じ操作に含めないことです。

Next.js には明示的なバージョンずれ対策があるが、旧資産の保存機能ではない

現在の Next.js は、バージョンずれ対策として deploymentId をサポートしています。設定例は次のとおりです。

// next.config.js
const nextConfig = {
  deploymentId: process.env.DEPLOYMENT_VERSION,
}

module.exports = nextConfig

現在の Next.js deploymentId 文書によると、これを設定すると、フレームワーク管理の静的リソース URL に ?dpl=<deploymentId> が付与され、クライアント側ナビゲーション要求にデプロイ情報が入り、サーバーも応答でデプロイ識別子を返します。Next.js がナビゲーション中に不一致を検出すると、互換性のないデータでクライアント内遷移を続ける代わりに、ページ全体のナビゲーションを実行できます。

?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-id

これは有用ですが、実際以上の能力を期待してはいけません。文書には、Next.js は受信した ?dpl= を読んで特定バージョンへルーティングするわけではないと明記されています。この値は古いキャッシュを避けるためのものです。セルフホストのオリジンが旧ファイルを物理的に削除していれば、問い合わせパラメータがファイルを再生することはありません。

そのため私は deploymentId を、ずれの検出と復旧の仕組みとして扱います。整ったデプロイや旧資産保持の代用品ではありません。

バージョンを考慮したルーティングを持つプラットフォームなら、さらに進んだ対応ができます。たとえば現在の Vercel のバージョンずれ保護文書では、フレームワーク管理の要求を、そのクライアントに最初に応答したデプロイへ引き続き到達させるバージョン固定が説明されています。これはプラットフォームの機能であり、任意の Nginx や CDN 構成に存在すると私は仮定しません。

ビルド識別子とデプロイ識別子は、関連する別々の問題を解く

Next.js は next build の実行時にビルド識別子も生成します。複数コンテナが同じデプロイを提供するなら、各サーバーが独立してビルドした結果、ひそかに別々のビルドになってはいけません。

決定的なビルド識別子は、Git のコミットなどリリース識別子と結び付けられます。

// next.config.js
const nextConfig = {
  generateBuildId: async () => process.env.GIT_SHA,
  deploymentId: process.env.GIT_SHA,
}

module.exports = nextConfig

これは説明用の例で、私の本番コードからコピーしたものではありません。重要な設計原則は、一つの論理リリースを提供する全インスタンスが、一つの整合したビルド成果物と一つのデプロイ識別を共有することです。

generateBuildId は Next.js のビルドを識別します。deploymentId はバージョンずれ保護と古いキャッシュ回避のために明示的に文書化されています。関連はしますが、同義語として扱うと調査が難しくなります。

新しい文書へトラフィックを切り替える前に資産を公開する

より安全なデプロイ順序は、意図的に非対称です。新しい不変リソースは、誰からも参照される前に存在してかまいません。逆に、新しい HTML がまだ利用不能なリソースを参照してはいけません。

概念的には次の順序にします。

1. リリース B を一度だけビルドする
2. B の /_next/static リソースをアップロードする
3. 必要なリソースを実際に取得できるか確認する
4. B のサーバーと実行環境を起動または準備する
5. B の健全性を確認する
6. 新しい文書へのトラフィックを原子的に B へ切り替える
7. A の静的リソースは利用可能なまま残す
8. B を監視する
9. 旧リソースは後で回収する

アプリが静的書き出しでも原則は同じです。まずバージョン化されたリソースを置き、その後でそれらを参照する HTML を公開します。リバースプロキシの後ろでサーバー側レンダリングする場合は、新サーバーを準備し、正常性を確認してからトラフィックを切り替えます。

ロールバックも対称に考えるべきです。前リリースのディレクトリと静的リソースを保持しておけば、古いファイルを後から再構築せずにアプリを戻せます。

ただし、すべてのロールバックが安全になるわけではありません。データベース移行や互換性のないバックエンド契約によって、JavaScript が残っていても旧アプリが動かないことがあります。静的資産の保持が解決するのは静的互換性であり、システム全体のあらゆるリリース互換性ではありません。

共有の不変リソースディレクトリは単純なセルフホスティングに向いている

小規模な Nginx デプロイでは、現在のアプリリリースと共有の静的リソース置き場を分ける方法が分かりやすいです。

例としては次のような構成です。

/srv/app/releases/2026-08-13-a/
/srv/app/releases/2026-08-13-b/
/srv/app/current -> /srv/app/releases/2026-08-13-b/

/srv/app/shared/_next/static/...

各デプロイは新しい /_next/static ファイルを共有ディレクトリへ追加し、保持対象になっている過去リリースのファイルは削除しません。Nginx はそのパスを不変方針で配信できます。

location ^~ /_next/static/ {
    root /srv/app/shared;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

この設定は例であり、私が使った正確な Nginx 設定だという意味ではありません。実環境では権限、MIME タイプ、圧縮形式、CDN の挙動、出力配置の詳細を考慮する必要があります。

重要なのは構造です。現在のリリースを指す可変なポインタと、主に追加されていくバージョン化資産の置き場では、ライフサイクルが異なります。

HTML には、ハッシュ付きチャンクとは別のキャッシュ方針が必要

この問題を最も簡単に再現する方法は、HTML を内容ハッシュ付きリソースと同じようにキャッシュすることです。

動的に描画される Next.js ページでは、利用者固有の動的出力に対して通常はキャッシュしない応答方針が使われます。静的ページや ISR ページには別の方針があり、CDN が正当にキャッシュできる場合があります。Nginx が配信する静的書き出しは、管理者が設定するヘッダーにさらに強く依存します。

そのため私は「サイト全体」に一つのキャッシュ規則を当てません。対象の種類ごとに考えます。

/_next/static のハッシュ付きリソース
    長い max-age
    immutable
    保持して安全

HTML / 経路文書
    新リリースへ移行できなければならない
    方針は描画方式に依存する
    参照先リソースより長く残してはいけない

RSC / ナビゲーションデータ / API データ
    互換性と鮮度について別の規則を持つ

CDN を使う場合、キャッシュ設計によってはデプロイ後に新しい文書のパスを無効化する必要があります。一方、新しいリリースが出たという理由だけで古いハッシュ付きチャンクを CDN から削除するのは、たいてい逆効果です。オリジン側でも削除済みなら、旧クライアントを救えた最後のコピーまで消えてしまいます。

Next.js の CDN キャッシュガイドは、ページのキャッシュと /_next/static に適用される 1 年の immutable 方針を分けて説明している点で役立ちます。

自動再読み込みは復旧手段であり、主要なデプロイ戦略ではない

チャンクが失敗したときの典型的な反応は「ページを再読み込みすればよい」です。ページ全体のナビゲーションで、現行ビルドを参照する現行文書を取り直すため、多くの場合は直ります。

しかし、あらゆるスクリプトエラーで無条件に再読み込みすると別の問題が生まれます。

  • 第三者スクリプトの失敗でも無意味な再読み込みが起こる。
  • 本当のサーバー障害では無限再読み込みになる可能性がある。
  • 未保存フォームの入力を失う可能性がある。
  • ページ全体のナビゲーションで React コンポーネントの状態が失われる。
  • 同じ壊れたデプロイが再度失敗するだけかもしれない。

現在の Next.js 文書でも、バージョンずれから復旧するためのページ全体のナビゲーションでは useState などのコンポーネント状態が失われ得る一方、URL や永続的なブラウザ保存領域にある状態は残り得ると注意されています。

クライアント側復旧を入れるなら、対象を狭くし、一度だけにしたいです。例としては次のような実装があります。

const RECOVERY_KEY = 'next-chunk-recovery-attempted'

function isOwnNextAsset(url: string) {
  try {
    const parsed = new URL(url, window.location.href)
    return (
      parsed.origin === window.location.origin &&
      parsed.pathname.startsWith('/_next/static/')
    )
  } catch {
    return false
  }
}

window.addEventListener(
  'error',
  (event) => {
    const target = event.target
    if (!(target instanceof HTMLScriptElement)) return
    if (!isOwnNextAsset(target.src)) return

    reportChunkFailure({
      page: window.location.href,
      asset: target.src,
    })

    if (sessionStorage.getItem(RECOVERY_KEY)) return

    sessionStorage.setItem(RECOVERY_KEY, '1')
    window.location.reload()
  },
  true,
)

これはあくまで例です。本番実装では、スタイルシートのチャンク、既知のフレームワークエラー形式、再読み込みで利用者の作業が壊れる場面、正常な読み込み後に復旧印をどう消すかも考える必要があります。

編集画面、購入手続き、長いフォームなら、強制更新より「新しいバージョンがあります。作業を保存してからページを再読み込みしてください」という案内を出すほうが適切なことがあります。

監視データは、本当にバージョンずれなのか判断できる内容にする

「スクリプトを読み込めなかった」というだけでは不十分です。削除された旧チャンクと偶発的なネットワーク障害を区別するには、デプロイに関する文脈が必要です。

役立つ項目には次があります。

  • 読み込みに失敗したリソースの URL。
  • 現在のページ URL。
  • そのリソースが自サイトのものかどうか。
  • クライアントから見えるリリースまたはデプロイ識別子。
  • ブラウザと OS。
  • navigator.onLine は接続の証明ではなく、弱い手掛かりとして扱う。
  • ページ読み込みからの経過時間。
  • デプロイ直後に起きたかどうか。
  • 最初の復旧試行だったかどうか。
  • サーバー側で観測できる場合の HTTP 状態。
  • オリジンまたはプロキシで、その要求を実際に処理していたリリース。

ここまで揃うと、傾向はかなり読み取りやすくなります。

異なるネットワークの多数の利用者が古いハッシュ付きチャンク URL を要求し、リリース直後にオリジンが 404 を返しているなら、旧資産を保持していなかったことは強い説明になります。一人だけが HTTP 応答のないネットワーク障害を見ているなら、バージョンずれの確度は大きく下がります。チャンクが 200 でも MIME タイプが誤っている、または HTML のエラーページが返っているなら、問題は保持だけではなくルーティングやプロキシ設定です。

自サイトのチャンク失敗は、第三者リソースの失敗とも分けて警告します。これは元のログから最も直接導ける監視改善です。意味のある信号が、アプリ故障とは無関係な大量のブラウザ雑音に混ざっていたからです。

再現テストは単純だが、古いタブをそのまま残す必要がある

この種の不具合は通常のリリース試験で見逃しやすいです。デプロイ直後、技術者はついページを更新します。しかし更新すると、試したい条件そのものが消えてしまいます。

より良い手動試験は次のとおりです。

  1. リリース A をデプロイする。
  2. ブラウザキャッシュを有効にした、本番に近い条件でタブを開く。
  3. アプリの一部だけを使い、いくつかの経路や遅延機能は未読み込みのままにする。
  4. そのタブを開いたままにする。
  5. リリース B をデプロイする。
  6. 古いタブは更新しない
  7. それまで読み込んでいなかったコードを必要とする経路や動的機能を実行する。
  8. Network と Console を確認する。
  9. 古いリソース URL が引き続き 200 を返すか確認する。
  10. 必要な場合に、バージョンずれ検出が制御されたページ全体のナビゲーションを行うか確認する。

同じ試験を、前段に CDN がある状態、ローリングデプロイで二つのサーバーインスタンスがある状態、設定した保持期間を過ぎた状態でも繰り返します。

見落としやすい試験上の誤りは、DevTools の「Disable cache」をすべてに対して有効にすることです。特定の診断には便利ですが、ブラウザの動作が変わります。長時間開いたタブの検証は、ブラウザキャッシュ自体がシステムの一部である以上、現実的なキャッシュ条件でも行う必要があります。

旧ファイルを残せばすべてのチャンク失敗が直るわけではない

保持が有効なのは、狭く具体的な一つの仕組みを解決するからです。万能な説明にしてはいけません。

自サイトのチャンクは、次のような理由でも失敗します。

  • 要求がサーバーまで届かなかった。
  • 接続が途中で切れた。
  • ブラウザ拡張が遮断した。
  • CDN の端点で一時障害が起きた。
  • Nginx がパスを誤ってルーティングした。
  • サーバーが JavaScript ではなく HTML のエラー文書を返した。
  • 圧縮や Content-Encoding が壊れた。
  • ファイル権限が誤っていた。
  • 不完全なデプロイでチャンク自体がアップロードされなかった。
  • ファイルは存在したが、早すぎる時点で削除された。
  • クライアントとサーバーが互換性のないリリースだった。

応答コードと発生時刻が重要です。毎回のリリース直後に古い内容ハッシュ URL が繰り返し 404 になる状況は、ある一つの携帯回線で ERR_CONNECTION_RESET が一度起きた状況とはまったく違う意味を持ちます。

だから私は元の出来事を「古い HTML がサイトを壊したと証明した」とは書き換えません。そこまでは証明していません。自サイトのチャンクに実際の失敗があり、バージョンずれを設計上つぶす価値のある重大な失敗要因の一つとして特定しただけです。

最も安全なデプロイは、旧クライアントもリリース範囲の一部として扱う

根本的な誤りは、デプロイがある瞬間に A を B へ完全置換すると考えることです。

サーバー上では、シンボリックリンクやコンテナ管理の状態を見るとそう見えるかもしれません。しかしネットワークには古い CDN オブジェクトが残ります。ブラウザでは B の公開後も A の文書が長く動き続けることがあります。ローリングリリース中は二つのサーバーバージョンが同時に有効です。ロールバック時は B が消え、A が再び現行版になることもあります。

つまり実際のリリース範囲は一点ではなく、時間の区間です。

私は今、Next.js アプリのデプロイ規則をこの考え方に合わせています。

  • 一つの論理リリースにつき一度だけビルドする。各複製に異なる出力をひそかに作らせない。
  • 不変リソースを、それを参照するものより先に公開する。
  • 古いハッシュ付きリソースを、意図して決めた互換期間だけ保持する。
  • 変化する HTML に、ハッシュ付きチャンクと同じキャッシュ方針を与えない。
  • デプロイ方式でバージョンずれが起こり得るなら deploymentId を使う。
  • ホスティング基盤が本当にバージョン認識ルーティングを提供する場合だけ、基盤側のずれ保護を使う。
  • 復旧は一度だけにし、利用者の状態を考慮する。
  • 自サイトのチャンク失敗を独立した本番信号として監視する。
  • 古いタブを開いたままデプロイを試験する。
  • 旧資産の回収は昇格時ではなく後から行う。

今使っている原則

ビルドが成功し、新しく開いたページが正常でも、すでに利用中だった人にとってデプロイが安全だったとは証明できません。

古いタブは不要な残骸ではありません。実際の旧リリースを動かしている、実際のクライアントです。

こう考えるようになってから、チャンクの問題はそれほど不可解ではなくなりました。内容ハッシュはリソースに安定した識別を与え、長期キャッシュはその識別を効率よく使えるようにします。しかしデプロイは、その識別を十分な期間尊重するか、クライアントが制御された形で先へ進める仕組みを用意する必要があります。

すべての古いリリースを永久に生かす必要はありません。旧クライアントと新サーバーが正当に共存する期間を、システムが無事に乗り切れればよいのです。

私が重視するデプロイ上の契約はこれです。新しい利用者は新しいリリースを受け取り、以前からいる利用者は現在の版がまだ要求するファイルを失わず、残ったバージョンずれは壊れたページではなく、意図した復旧経路へ進む。