0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsのlayoutにscriptを1行足したら本番が503になった。トップページは200のままだった

0
Last updated at Posted at 2026-09-02

TL;DR(3行)

  1. Next.js(Cloudflare Workers)の app/layout.tsx<script src="/oc.js" defer />1行足しただけで、動的ルートが HTTP 503(Cloudflare error 1102 = exceededCpu) を返すようになった。
  2. トップページは200のままだった。 だからデプロイ直後の全数検査(106/106 合格)でも、死活監視でも捕まらなかった。
  3. 犯人は 同一URLに20リクエストずつ投げる4版のA/B で確定した。肝は 「変更前を自分で再ビルドした対照版」を必ず取ること

1行の変更で動的ルートが14/20の確率で503になり、トップページは20/20で200のままだった

環境

項目
サイト 方言の変換・クイズサイト(106ページ)
フレームワーク Next.js(App Router)/React 19
ホスティング Cloudflare Workers(Free プラン)
症状が出た面 /translate/[slug]/quiz/[slug] などの動的ルート
症状が出なかった面 トップページ、静的な法的ページ
変更内容 app/layout.tsx<script src="/oc.js" defer /> を1行追加

やりたかったことは地味で、「計測用のJSを1本、全ページで読み込む」だけだった。
AdSense のタグを素の <script src> で置いていて問題が出ていなかったので、同じ書き方をもう1本足した。React 19 は <script> を勝手に <head> へ持ち上げてくれる(リソース・ホイスト)ので、置き場所は気にしなくていい ── そう思っていた。


1. 起きたこと

1-1. デプロイ直後の全数検査は「106/106 合格」だった

このサイトは、全URLに計測タグが入っているかを毎回スクリプトで全数検査している。デプロイ後に走らせて 106面すべて合格。ここで作業を終えていた。

1-2. しばらくして /translate/* が高頻度で 503 を返し始めた

HTTP/2 503
(Cloudflare error code 1102)

/translate/okinawa を叩くと、5回に1回どころではない頻度で503が返る。同じ形の /quiz/[slug] も同様だった。ところが、

1-3. トップページは 200 のままだった

これが一番きつかった点。死活監視は普通トップページを見る。 そしてトップは何事もなく200を返し続けた。
「サイトが落ちた」ではなく「サイトの一部の面だけが、確率的に落ちる」ので、見ている場所が1cmずれているだけで永久に気づけない。

監視が見ていたトップページは常に200、実際に落ちていたのは動的ルートだった

同じ理由で、デプロイ直後の全数検査もすり抜けた。全数検査は各URLを1回ずつ叩く。確率的に落ちるものは、1回ずつの検査では捕まらない。「全ページ200でした」は、「常に200である」の証拠ではない


2. 原因の特定

2-1. wrangler tail が一発で答えを出した

推測する前に、本番のログを見る。

npx wrangler tail --format=json

返ってきたのがこれ。

{
  "outcome": "exceededCpu",
  "cpuTime": 10,
  "exceptions": [{ "message": "Worker exceeded CPU time limit." }]
}

exceededCpuメモリでもタイムアウトでもなく、CPU 時間の超過。

2-2. Workers Free の CPU 上限は 10ms/リクエスト

cpuTime: 10 がそのまま上限値だった。Free プランは 1リクエストあたり 10ms の CPU 時間(壁時計時間ではなく、実際に計算に使った時間)。I/O 待ちは含まれないので普段は余裕があるが、SSR は素で CPU を使う

つまりこのサイトは、事故の前から 常に上限のすぐ手前で走っていた。1行の追加は、最後のひと押しでしかない。

2-3. なぜ <script src> 1行で CPU が足りなくなるのか(ここから先は仮説)

⚠️ ここは実測ではなく、自分の理解を書いています。 実測で確定したのは次の章のA/Bまで ──「素の <script src> を足すと503が出て、next/script なら出ない」であって、その中で何ミリ秒がどこに消えたかは計測していませんcpuTime の内訳を取る手段を持っていない)。

自分が読んでいる筋はこう。React 19 は、コンポーネントツリーのどこに書かれた <script src> でも <head> へ巻き上げる(リソース・ホイスト)。この巻き上げはクライアントの都合ではなく、サーバー側のレンダリング中に処理される

  • 素の <script src>SSR のレンダリング経路に処理が乗る
  • next/scriptafterInteractive)→ クライアント側で挿入されるので、SSR の CPU は増えない

普段なら誤差の差だと思う。上限まで 1ms しか余っていない Worker では、その誤差が致命傷になった ── というのが今の理解。別の説明ができる人がいたら教えてください。

Workers Freeの10ms CPU予算と、素のscriptタグを足したときの模式図


3. 犯人を確定させる ── 20リクエスト × 4版のA/B

「たぶんこの変更が原因」で終わらせると、次に同じことをする。なので実測で確定させた。

3-1. 版の行き来は wrangler で数分

Cloudflare Workers は過去バージョンに戻せる。ロールバックできる=A/Bが取れる、というのがここでの一番の武器だった。

npx wrangler deployments list                    # 版の一覧
npx wrangler rollback <version-id>               # 直前の版へ戻す
npx wrangler versions deploy <version-id>@100%   # 任意の版を100%に載せる

3-2. 測り方

同一URL(/translate/okinawa)・同条件で、各版に 20リクエストずつ、5秒間隔

probe() {  # probe <url> <n>
  local ok=0 ng=0
  for i in $(seq 1 "${2:-20}"); do
    c=$(curl -s -m 8 -o /dev/null -w '%{http_code}' "$1")
    [ "$c" = "200" ] && ok=$((ok+1)) || ng=$((ng+1))
    sleep 5
  done
  echo "200=$ok other=$ng"
}

3-3. 結果

200 503
変更前の layout(wrangler rollback で復帰) 20 0
素の <script src="/oc.js" defer />(事故版) 6 14
変更前の layout を、自分で再ビルドした対照版 20 0
<Script src="/oc.js" strategy="afterInteractive" />(採用) 20 0

4版それぞれに20リクエストを投げたA/B結果。事故版だけが6/20

事故版だけが 20回中14回 503。他の3版は 20/20 で 200。

3-4. 🔑 3行目の「対照版」がこの表の主役

1行目と2行目だけでも「変更が原因」と言いたくなる。でもそれだと、次の反論を潰せない

  • 「たまたまその時間帯だけ Cloudflare 側が不調だったのでは?」
  • 「ロールバックで戻した版は別のビルド成果物だから、比較になっていないのでは?」
  • 「そもそも <script> とは無関係に、ビルドし直したら直っただけでは?」

3行目は「変更前のソースを、事故版と同じ手順でビルドし直して載せた版」。これが 20/20 で200を返したことで、ビルド行為そのものは無罪だと確定する。残る差分は <script> の1行だけになる。

「直った」を「原因が分かった」と取り違えないための唯一の道具が対照版。
直し方だけ持って帰ると、半年後に同じ地雷を踏む。

3-5. n=20 で足りるのか(正直に)

足りない。事故版が 6/20 なら差は明らかだが、「10回に1回落ちる」を検出するには 20 は少なすぎる
今回は差が極端だったので 20 で判定した、というだけ。小さい差を否定する用途には使えない(後述の第5章がまさにそれ)。


4. 直し方

4-1. next/script に戻す

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        {children}
        {/* 素の <script src> は SSR の CPU を食う。afterInteractive はクライアント挿入 */}
        <Script src="/oc.js" strategy="afterInteractive" />
      </body>
    </html>
  )
}

4-2. 「直った」だけでなく「壊していない」まで確認する

503 が消えても、計測が動かなくなっていたら意味がない。本番のDOMでクリックを1回発火させ、イベントが 1クリックにつき1件(二重送信なし)で飛ぶところまで見た。

実際に踏んで確認した面がこれ:方言ラボのブログ記事 / 沖縄方言の変換ページ(後者が事故で落ちていた面そのもの)。

4-3. 副次で踏んだ落とし穴 2つ

① 全数検査ツール自体が、事故の燃料だった。
キャッシュを完全に外して全URLを取り直すモード(--fresh)は、106面をまるごとSSRさせる。HTMLは no-store なのでエッジキャッシュも効かない。つまり検査を連発するほど CPU 上限を押し上げる。測定器が対象を壊すタイプのやつ。

② ブラウザ自動操作で「イベントが1件も出ない」と誤判定した。
navigate の直後に JS 評価を並べていたので、defer スクリプトの実行前に検証コードが走っていた。壊れているのはサイトではなく測り方だった。navigate と評価の間に待ちを挟む。


5. この記事を書いている今日、落ちているのは別の面だった

ここからが「直しました、めでたし」で終わらない部分。

5-1. 実測(2026-09-03・記事執筆中に取得)

同じ probe を、修正済みの本番に対して回した。

URL 200 503 応答なし(8秒タイムアウト)
/translate/okinawa(事故で落ちていた面) 20 / 20 0 0
/(トップ)1回目 16 / 20 2 2
/(トップ)2回目 14 / 20 2 4
/quiz/tosa(公開直後に追加で測った) 10 / 12 2 0

2026-09-03の実測。トップページが40回中4回200を返さなかった

事故のときと 役割がそっくり入れ替わっている。あのとき「200だから健康」の証拠として使っていたトップページが、いま不安定な側になっている。

しかも最後の1行は、この記事を公開した直後に足したもの。本文中のリンクが生きているか確認しようとして /quiz/tosa を叩いたら、いきなり503が返ってきた。測った面しか分からないという話を書いている記事で、測っていなかった面がもう1つ出てきた。

5-2. わかっていないことを、わかっていないまま書く

  • トップの 503 が 同じ exceededCpu かどうかは未確認wrangler tail を当てていないので、原因は書けない
  • 8秒で応答が返らなかった 6件(000)が、遅いSSRなのか、こちら側のネットワークなのかも切り分けていない
  • したがって「修正が不十分だった」とも「別件だ」とも言えない。言えるのは「今日は トップが40回中4回、/quiz/tosa が12回中2回、200を返さなかった」だけ
  • 母数も足りない。/quiz/tosa は12回しか投げていないので、「17%落ちる」と読むのは早い(3-5 に書いたとおり)

書けるのはここまで。**この記事の主張は「原因はこうだ」ではなく、「トップが200であることを健康の証拠に使うな」**なので、結論としてはむしろ都合が悪いほど正しい。


6. 持ち帰り用チェックリスト

SSRに何かを足す前のチェックリスト7項目

CPU 上限の近くで走っている SSR に何かを足すとき、自分がこれから毎回やること:

  1. トップページの200を、健康の証拠にしない。 一番重い動的ルートを名指しで測る
  2. 1回叩いて200は「200を返すことがある」。 確率的な障害は 同一URLに20回
  3. 原因を言い切る前に対照版を取る。 「変更前のソースを、同じ手順で再ビルドして載せた版」
  4. 推測の前にログ。 Workers なら wrangler tailoutcome を見れば即決する
  5. SSR 経路に素の <script src> を置かない。 React 19 のホイストは SSR 中に走る
  6. 直した後、壊していないかまで確認する。 503が消えても計測が死んでいたら意味がない
  7. 測定器が対象に負荷をかけていないか。 全数スイープは連発しない

7. 参考

同じサイトを並列のAIエージェントで触っていて別の壊し方をした話を、前に書きました → Claude Codeのサブエージェント6体を同じリポジトリで並走させたら、成果物を消し合った

「0」の読み違いを7件並べた回はこちら → 外部リンク73本が全部未計測だった


この記事の数字は、すべて自分のサイトの本番で実測したものです。 図はその実測値から起こしました。
再現条件(Workers Free + Next.js App Router + React 19 + 重めのSSR)が揃っている人は、まず一番重い動的ルートに20回 curl を投げてみてください。 うちはそれで「一部の面だけが確率的に落ちている」ことに、しばらく気づいていませんでした。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?