朝、記事ページの表示崩れを直して push しました。ビルドは通り、デプロイも成功しています。手元で確認すると正しく表示されます。
それなのに、別の端末で開くと崩れたままでした。
シークレットウィンドウでも同じです。もう一度デプロイしても変わりません。コードを何度読み返しても、直っているはずのものが直っていない。この時点で私は、まだキャッシュを疑っていませんでした。ステータスは 200 だったからです。
原因はエッジキャッシュでした。壊れた HTML が正常な応答として保存され、そのまま配信され続けていたのです。修正版のコードは、そのキャッシュの後ろで誰にも読まれずに待っていました。
200 で返る壊れたページ、という盲点
キャッシュの保存判定を書くとき、多くの実装はステータスコードを見ます。私もそうしていました。2xx なら保存し、4xx / 5xx なら保存しない。素直で、たいていの場合は正しく動きます。
問題は、アプリケーションが壊れているのに 200 を返す経路があることでした。
Next.js の App Router では、レンダリング中に例外が起きるとエラー境界(error.tsx / global-error.tsx)が代わりの UI を返します。この応答は「エラー画面を正常に返した」ものなので、HTTP としては 200 です。同じことは、記事本文の読み込みだけが失敗して枠だけが描画された場合にも起きます。ページとしては成立しているのに、中身が空です。
つまりステータスコードは、そのページが読者にとって意味のある内容かどうかを何も語っていません。
応答の状態 HTTP ステータス ステータス基準の保存判定 読者にとって
正常なページ 200 保存する 問題なし
エラー境界が返した画面 200 保存する 壊れている
本文が空のまま描画された枠 200 保存する 壊れている
途中で切れた HTML 200 保存する 壊れている
サーバーエラー 500 保存しない 壊れているが再取得される
表の2行目から4行目が、私が踏んだ穴です。これらは一度保存されると、TTL が切れるまで、あるいは明示的に無効化するまで残り続けます。原因が数分で解決していても、配信は何時間も壊れたままになります。
ここで一番厄介なのは、障害が「継続している」のではなく「固定されている」ことです。ログを見ても、その瞬間のエラーはとっくに収まっています。再現しようとしても再現しません。それでも読者には壊れたページが届きます。デプロイのたびに首を傾げていた朝の正体は、これでした。
事故の起点は、たいてい一瞬の失敗です
なぜ壊れた HTML が生まれたのかも書いておきます。
個人開発で運用している4サイトは、Next.js を Cloudflare Workers に載せています。記事の本文 HTML は JSON のメタデータとは分けて配置し、静的アセットのバインディング経由で読み出す構成です。Worker のサイズ上限に収めるための設計で、その経緯は別の記事に書きました(Cloudflare Workers の 62 MiB 制限とコンテンツ分割アーキテクチャ )。
この構成では、本文の取得は「アセットを読む」という一手が挟まります。デプロイ直後の数秒間、あるいは瞬間的な失敗で、この読み取りが空を返すことがありました。頻度としては稀です。1日に何度も起きるものではありません。
ただ、稀であることは救いになりませんでした。キャッシュは、稀な失敗を恒久的な状態に変える装置 だからです。1万リクエストのうち1回だけ失敗しても、その1回がキャッシュに載れば、残りの9,999人はその失敗を見ます。
確率の低さで安心していると、この非対称性を見落とします。私自身、稀にしか起きないのだから急がなくてよいと考えていて、実際には毎日誰かが同じ壊れたページを見ていました。
保存してよいかを、本文の中身で決める
対処は単純です。キャッシュへ書き込む直前に、その HTML が完成品かどうかを見ます。ステータスコードは前提条件のひとつに過ぎず、判定の主役ではありません。
私が採用した条件は3つです。
1. エラー境界のマーカーが含まれていないこと
エラー境界のコンポーネントに、機械的に検出できる目印を1つ入れておきます。属性であれば、表示には影響しません。
// app/[locale]/error.tsx
'use client'
export default function Error ({ reset } : { error : Error ; reset : () => void }) {
return (
< div data-error-boundary = "1" className = "mx-auto max-w-2xl px-6 py-24" >
< h1 className = "text-xl font-semibold" >ページを表示できませんでした</ h1 >
< p className = "mt-4 text-sm opacity-80" >
時間をおいて再度お試しください。
</ p >
< button onClick = { reset } className = "mt-6 underline" >
再読み込み
</ button >
</ div >
)
}
data-error-boundary は人間には見えませんが、キャッシュ層からは確実に見えます。global-error.tsx にも同じ属性を入れておきます。エラー画面の見た目を変えても、この属性さえ残っていれば判定は壊れません。
2. HTML が最後まで届いていること
途中で切れたストリームは </html> を含みません。終端の有無を見るだけで、切断された応答をかなり弾けます。
3. 本文コンテナが空でないこと
記事本文を入れる要素に一定の中身があるかを見ます。ページの骨格だけが返っている状態を捕まえるための条件です。
この3つを1つの関数にまとめます。
// cache-worker.js
const ERROR_MARKER = 'data-error-boundary'
const ARTICLE_CONTAINER = /<div [ ^ >] + id="article-content" [ ^ >] * >( [\s\S] *? )< \/ div>/
// 記事ページとして「保存してよい HTML」かどうかを判定する
function isCacheableHtml ( html , { requireArticle }) {
if ( ! html || html. length < 512 ) return false // 極端に短い応答
if (html. includes ( ERROR_MARKER )) return false // エラー境界が返した画面
if ( ! html. includes ( '</html>' )) return false // 途中で切れている
if (requireArticle) {
const m = html. match ( ARTICLE_CONTAINER )
if ( ! m) return false
// タグを除いた実テキストが十分にあるか
const text = m[ 1 ]. replace ( /< [ ^ >] * >/ g , '' ). trim ()
if (text. length < 200 ) return false
}
return true
}
requireArticle を引数にしたのは、記事ページ以外(一覧・タグ・ホーム)に同じ条件を当てると誤検知が出るからです。一覧や検索結果を別のテンプレートで持っている場合は、この場合は本文長の判定を外し、終端とマーカーの2条件だけで運用することをお勧めします。一覧ページには article-content がありません。ページの種類ごとに、要求する条件を変えます。
キャッシュへ書き込む側は、この判定を必ず通します。
async function handleRequest ( request , env , ctx ) {
const cache = caches.default
const cached = await cache. match (request)
if (cached) return cached
const response = await fetch (request)
const contentType = response.headers. get ( 'content-type' ) || ''
// HTML 以外は従来どおりの判定でよい
if ( ! contentType. includes ( 'text/html' )) {
if (response.ok) ctx. waitUntil (cache. put (request, response. clone ()))
return response
}
// HTML は本文まで読んでから保存可否を決める
const body = await response. clone (). text ()
const url = new URL (request.url)
const requireArticle = / \/ articles \/ [ ^ /] + \/ [ ^ /] +$ / . test (url.pathname)
if (response.ok && isCacheableHtml (body, { requireArticle })) {
ctx. waitUntil (cache. put (request, response. clone ()))
}
return response
}
保存しなかった場合でも、読者にはその応答をそのまま返します。壊れたページを1人に見せることと、全員に見せ続けることは、まったく別の問題 だからです。ここで応答自体を差し止める設計にすると、瞬間的な失敗が可用性の低下に化けます。止めたいのは配信ではなく、固定化のほうです。
判定関数は、壊れた形を作って確かめておきます
条件を3つ並べただけでは、それが本当に効くのか分かりません。私は実装したあと、壊れた HTML を意図的に組み立てて判定関数に通しました。手元で数分あれば終わります。
入力の形 保存してよいか 効いた条件
正常なページ 保存する —
エラー境界の出力を含む 保存しない マーカー検出
途中で切れた HTML 保存しない 終端の欠落
本文コンテナが空 保存しない 本文長
本文が極端に短い 保存しない 本文長
ナビゲーションだけの骨格 保存しない コンテナ不在
6通りのうち正常な1件だけが通り、壊れた5件(83%)はすべて弾かれました。数字そのものより、「どの条件がどの壊れ方を捕まえているか」が1対1で見えること に意味があります。あとで条件を1つ削ろうとしたとき、何を捨てることになるかが分かるからです。
しきい値を動かして同じ表を作ると、線引きの影響も見えます。本文200文字を要求する設定では、実テキスト150文字相当の極端に短いページだけが落ち、300文字以上のページはすべて通りました。500文字に上げると300文字相当のページまで巻き込みます。自分のサイトで最も短い正常な記事が、どのあたりに立っているかを知っておくと迷いません。
5xx には no-store を明示する
もう一箇所、抜けていた場所がありました。サーバーエラーの応答に、キャッシュ制御のヘッダーが付いていなかったのです。
自前のキャッシュ層は 5xx を保存しません。しかしその手前には、ブラウザのキャッシュや中間のプロキシがあります。ヘッダーで意思表示をしていなければ、そちらで保存される余地が残ります。実際、エラー画面を1度見た端末だけが、修正後もしばらく同じ画面を出し続けるという報告に悩まされました。
// エラー応答を返す箇所で明示する
return new Response (errorHtml, {
status: 500 ,
headers: {
'content-type' : 'text/html; charset=utf-8' ,
'cache-control' : 'no-store, must-revalidate' ,
},
})
たった1行ですが、これが無いと「誰の手元でどれだけ残っているか分からない」状態になります。エラー応答は短命であるべきで、短命であることを自分で宣言しなければなりません。
原因側も1回だけ手当てする
キャッシュのガードは、あくまで被害の封じ込めです。壊れた HTML が生まれること自体は減りません。
そこで、本文アセットの読み取りに1回だけリトライを入れました。
async function readStaticAsset ( env : Env , path : string ) : Promise < string | null > {
for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
try {
const res = await env. ASSETS . fetch ( new URL (path, 'https://assets.local' ))
if (res.ok) {
const text = await res. text ()
if (text. length > 0 ) return text
}
} catch {
// 次の試行へ
}
if (attempt === 0 ) await new Promise (( r ) => setTimeout (r, 50 ))
}
return null
}
回数を2回に留めたのは、リトライが有効なのは瞬間的な失敗に対してだけだからです。恒久的な失敗(そもそもファイルが無い、パスが違う)に対して何度試しても、遅延が増えるだけで結果は変わりません。50ms の待機を1回挟んで駄目なら、素直に失敗として扱い、ガードに任せます。
戻り値を null にしているのも意図があります。空文字を返すと「本文が空の記事」として正常に描画されてしまい、まさにキャッシュに載せたくない HTML が出来上がります。失敗は失敗として型で表現し、呼び出し側でエラーへ倒します。
誤検知の線引きは、運用しながら決めました
入れた直後、想定していなかった挙動に気づきました。保存されないページが増えると、オリジンへの負荷が上がります 。判定が厳しすぎると、正常なページまで毎回オリジンまで取りに行くことになります。
調整が必要だったのは、主に本文の長さのしきい値でした。
しきい値 起きたこと 判断
タグ除去後 1,000 文字以上 短い記事とプレミアム記事のプレビューが弾かれ、キャッシュヒット率が目に見えて落ちた 厳しすぎる
タグ除去後 200 文字以上 空の枠だけは確実に弾き、正常なページはほぼ通る 採用
長さの判定なし 本文取得だけが失敗したページを取り逃す 緩すぎる
プレミアム記事は本文の一部だけを表示し、残りを隠す構成にしています。そのためプレビュー部分が短い記事では、思ったより文字数が出ません。自分のサイトの構造を知らないまま一般的な数字を置くと、こういうところで外します。
しきい値を決めるときは、勘で置かずに実際の配信物から分布を見るのが確実です。本番の HTML をいくつか取得し、判定関数に通してみるだけで済みます。
# 代表的なページを取得して、判定関数の入力に近い形で長さを見る
for path in /articles/claude-code/some-slug /articles/claude-ai/other-slug /articles ; do
len = $( curl -s "https://example.com${ path }" \
| sed -n 's/.*id="article-content"[^>]*>\(.*\)<\/div>.*/\1/p' \
| sed 's/<[^>]*>//g' | tr -d '[:space:]' | wc -c )
echo "${ path } -> ${ len }"
done
数字を眺めてから決めた 200 という値は、その後も特に問題を起こしていません。逆に言えば、最初に置いた 1,000 は完全に思い込みでした。
修正が効かないときの切り分け手順
同じ状況に陥ったとき、最初の数分で見るべきものを整理しておきます。私は毎回この順で確認しています。
キャッシュを迂回して取得し、コードとキャッシュのどちらが原因かを切り分ける
キャッシュのヒット状況と保存された時刻を確認する
原因がキャッシュ側なら、配信バージョンを変えて一括で無効化する
順序には理由があります。1 を先にやらずに無効化してしまうと、証拠が消えて原因が分からなくなるためです。
1. キャッシュを迂回して取得する
クエリ文字列を1つ足すだけで、多くのキャッシュは別 URL として扱います。
curl -s "https://example.com/articles/claude-code/some-slug?cb=$( date +%s)" | head -c 400
これで正しい HTML が返ってくるなら、コードは直っています。疑うべきはキャッシュです。逆にここでも壊れているなら、コード側にまだ問題が残っています。この1本で、探す場所が半分になります。
2. キャッシュのヒット状況を見る
応答ヘッダーの cf-cache-status(あるいは自前で付けている目印)を確認します。HIT が返っていれば、配信されているのは保存済みの内容です。
curl -sI "https://example.com/articles/claude-code/some-slug" \
| grep -iE 'cf-cache-status|age|cache-control'
age の値は、その内容がいつから保存されているかの目安になります。障害が起きた時刻と重なっていれば、ほぼ確定です。
3. 無効化する
私は配信バージョンを表す定数をキャッシュキーに含めており、その値を変えると全体が一括で無効化されます。個別 URL の削除より確実で、手順を覚えなくて済みます。
デプロイのたびに変えるのではなく、必要なときだけ変える運用にしています。毎回変えるとキャッシュが常に冷えたままになり、そもそもキャッシュを置いている意味が薄れるためです。
Claude Code に任せた部分と、自分で決めた部分
この一連の対処で、判定関数やリトライの実装は Claude Code に書いてもらいました。パターンが明確で、条件も自分の中で固まっていたので、実装そのものは短時間で片付きます。
一方で、任せなかった判断が2つあります。
ひとつは、保存しないと決めた応答を読者に返すかどうか です。ここは可用性と正確性のどちらを優先するかという話で、サイトの性格によって答えが変わります。私は「1人に壊れたページが届くのは許容するが、全員に届き続けるのは許容しない」と決めました。この線を引かずに実装だけ頼むと、応答自体を差し止める安全側の実装が出てきて、瞬間的な失敗が可用性の問題に化けます。
もうひとつは、しきい値です 。200 文字という数字は、自分のサイトの実際の配信物を見て決めたものです。一般論としては何とでも書けますが、プレミアム記事のプレビューが短いという事情は、外から分かるものではありません。
コードを書く速さが上がったぶん、こういう「何を正しいとするか」を決める時間の比重が上がったように感じています。実装が速いからこそ、決める前に実装が出来上がってしまうことの怖さもあります。私は最近、頼む前に紙に条件を書き出す時間を意識して取るようにしました。
まず1つだけ確かめてみてください
長く書きましたが、今日試せることは1つです。
自分のキャッシュ層が、保存の可否を何で判定しているか を見てください。ステータスコードだけで決めているなら、そこに本文の完全性チェックを1つ足すところから始められます。エラー境界にマーカーを1つ入れて、そのマーカーを含む HTML を保存しない。この2行分の変更だけで、今回のような固定化はほとんど止まります。
キャッシュは、正しいものを速く届けるための仕組みです。同じ性質が、間違ったものにもそのまま働きます。速く届く先に何を載せるかは、こちらで決めておくしかありません。
同じ朝を過ごさずに済む方が一人でも増えれば、この記録を書いた甲斐があります。最後までお読みいただきありがとうございました。