修正済みのはずのバグについて、ユーザーから問い合わせが続いていました。CI は緑。デプロイ画面も「成功」。コミットも main に乗っています。それでも本番を開くと、直したはずの挙動が古いまま残っている。
原因は拍子抜けするほど単純でした。ビルドは走ったけれど、エッジのキャッシュが前のバンドルを配り続けていたのです。「デプロイ成功」という緑のチェックが意味していたのは「パイプラインが最後まで走った」ことだけで、「ユーザーが新しいコードを見ている」ことではありませんでした。
この二つは、似ているようでまるで別の事実です。ここでは個人開発で運用しているアプリを題材に、出荷したコミットが本当にエッジまで届いたかを、ビルドに焼き込んだ印で検証する後段ゲートの作り方を、運用ログの実測とあわせて残しておきます。私自身、この取り違えで二度ほど静かな事故を出してから、ようやくこの形に落ち着きました。
なぜ「デプロイ成功」を信じてはいけないのか
デプロイの緑は、いくつもの独立した工程のうち「CI が例外なく終わった」ことしか保証しません。そこから先、ユーザーの画面に新しいコードが届くまでには、まだ落とし穴がいくつも残っています。
エッジや CDN のキャッシュが古いバンドルを配り続けている。アトミックな差し替えに遅れがあって、一部のリージョンだけ旧版のまま。あるいは、そもそも意図した本番プロジェクトではなく、プレビュー環境や別ブランチに出してしまっていた。どれも CI から見れば「成功」で、けれどユーザーから見れば「何も変わっていない」状態です。
本番運用で怖いのは、この手の食い違いがエラーとして現れないことです。500 も出ず、ログにも残らず、ただ古い挙動が静かに配られ続ける。だからこそ、成功したと信じ込む代わりに、届いたかどうかを自分で取りに行って確かめる工程が要ります。
ビルドに検証できる印を焼き込む
まず、いま動いているのがどのコミットのビルドなのかを、本番から機械的に読み取れるようにします。ビルド時にコミット SHA を成果物へ埋め込み、/version.json のような固定の場所から返すのが素直です。
// 何をするコードか:
// ビルド時にコミット SHA とビルド時刻を version.json として書き出し、
// 本番の静的アセットとして配信できるようにする
// scripts/stamp-version.mjs
import { execSync } from "node:child_process" ;
import { writeFileSync, mkdirSync } from "node:fs" ;
const sha = execSync ( "git rev-parse --short HEAD" ). toString (). trim ();
const builtAt = new Date (). toISOString ();
mkdirSync ( "public" , { recursive: true });
writeFileSync (
"public/version.json" ,
JSON . stringify ({ sha, builtAt }, null , 2 )
);
console. log ( `stamped version.json: ${ sha } @ ${ builtAt }` );
ビルドスクリプトの手前でこれを走らせ、"build": "node scripts/stamp-version.mjs && next build" のように繋ぎます。こうすると、本番の https://your-app.example/version.json を叩けば「いまエッジが配っているのはどのコミットか」が一目で分かるようになります。印は SHA でなくても、単調増加するビルド番号でも構いません。大切なのは、出荷側が期待する値と、本番が返す値を突き合わせられることです。
デプロイ後にエッジから実物を取りに行く
印を焼き込んだら、デプロイ直後に本番の version.json を取得し、いま出したはずの SHA と一致するまで待つゲートを後段に置きます。一致しなければ、その出荷は「まだ届いていない」か「別の場所に出た」かのどちらかです。
# 何を検証するコードか:
# デプロイ後、本番の version.json をキャッシュ回避で取得し、
# 期待する SHA に一致するまで予算内でポーリングする
import sys, time, json, urllib.request
def fetch_live_sha (url: str ) -> str :
# クエリを付けて中間キャッシュを避け、オリジンの実物を取りに行く
bust = f " { url } ?t= { int (time.time() * 1000 ) } "
req = urllib.request.Request(bust, headers = { "Cache-Control" : "no-cache" })
with urllib.request.urlopen(req, timeout = 10 ) as r:
return json.loads(r.read())[ "sha" ]
def verify_deploy (url: str , expected_sha: str , budget_sec: int = 180 ) -> bool :
deadline = time.time() + budget_sec
interval = 5
while time.time() < deadline:
try :
live = fetch_live_sha(url)
except Exception as e:
print ( f "probe error: { e } " )
live = None
if live == expected_sha:
print ( f "converged: edge now serves { live } " )
return True
print ( f "still stale: edge= { live } expected= { expected_sha } " )
time.sleep(interval)
interval = min (interval * 2 , 30 )
return False
if __name__ == "__main__" :
url, expected = sys.argv[ 1 ], sys.argv[ 2 ]
ok = verify_deploy(url, expected)
sys.exit( 0 if ok else 1 )
このスクリプトを CI のデプロイ工程の最後に置き、verify_deploy が失敗したらパイプライン全体を赤にします。こうして初めて、緑は「ユーザーに新しいコードが届いた」という意味を取り戻します。
伝播の遅れと「別の場所に出した」を切り分ける
検証が予算内に収束しない場合、原因は大きく二つに分かれます。単なる伝播の遅れなら、印はやがて期待値へ収束します。一方、いつまで経っても旧 SHA のままなら、伝播ではなく「出し先を間違えた」可能性が高い。プレビュー環境や別ブランチに出していないか、本番ドメインのエイリアスが正しく張り替わっているかを疑います。
切り分けの目安として、ログに「収束したか」「最終的にエッジが返した SHA」「何秒かかったか」の三つを必ず残します。旧 SHA が一定のままなら出し先の問題、旧と新の間で揺れているならリージョン差やキャッシュ層の問題、と読み分けられます。
キャッシュが古い印を返してくる罠
検証そのものがキャッシュに騙されることがあります。version.json が CDN にキャッシュされていると、オリジンはすでに新しいのに、検証はいつまでも古い値を掴んでしまう。これでは「届いていないように見える」だけの偽陰性です。
対処は二段構えにします。取得側はクエリ文字列でキャッシュを避け、Cache-Control: no-cache を送る。配信側は version.json にだけ短い、あるいはゼロの TTL を設定し、この一点は常にオリジンを見に行かせる。アプリ本体のバンドルは通常どおり長期キャッシュで構いません。検証に使う印だけを、意図的にキャッシュの外へ置くのが勘所です。
実測: 検証ゲート導入で何が変わったか
古いビルドがユーザーに届いてしまう事故が、検証ゲートの導入前後でどう変わったかを並べます。母集団は同一アプリの約 4 か月ぶんの出荷です。
指標 ゲート導入前 ゲート導入後
古いビルドがユーザーに届いた事故 / 月 1〜2 件 0 件
反映不良に気づくまでの平均時間 約 5 時間(問い合わせ経由) 約 3 分(ゲート即失敗)
出し先間違い(別ブランチ)の検知 ほぼ不可 デプロイ時に検知
ゲート起因のデプロイ失敗 / 月 — 1〜2(正しく止めた分)
ゲートが原因でデプロイが赤くなる回数は増えました。けれどその赤は、古いビルドをユーザーに配る代わりに、出荷の瞬間に自分たちを止めてくれた赤です。事後に問い合わせで気づく 5 時間と、その場で止まる 3 分。この差が、導入して一番効いた部分でした。
運用への組み込みで気をつけること
検証ゲートは、ロールバックの引き金と必ず結びつけておきます。ゲートが失敗したら、直前の安定版へ自動で戻すか、少なくとも新しいトラフィックを旧版へ寄せる。届いていない新版を掴んだまま放置するのが、いちばん危ういからです。
もう一つ、検証の予算は伝播特性に合わせて実測から決めます。私はこのゲートの導入をまず推奨しますが、予算を短くしすぎると、正常な伝播遅れを誤って赤にしてしまいます。最初は長めに取り、収束時間の分布を見てから締めていくのが安全です。私の環境では正常な出荷のおよそ 95% が 40 秒以内に印を揃えていたため、予算は余裕をみて 180 秒に設定しています。正常系のデプロイ手順から見直したい場合は、Cloudflare Workers へのデプロイ実装 もあわせて読んでいただくと、印を差し込む場所が掴みやすくなるはずです。
「成功したはず」を信じる運用から、「届いたかを取りに行って確かめる」運用へ。工程は一つ増えますが、静かに古いコードを配り続ける事故は、これで確実に減りました。同じ食い違いに悩んでいる方の、明日の一手の参考になれば幸いです。お読みいただき、ありがとうございました。