開発eurekapu-nuxt4

Eurekapuのデプロイが9.3分に伸びた話

朝、いつも通り pnpm deploy:cloudflare を流して別の作業に戻ろうとしたら、ターミナルが9分以上沈黙していた。前日まで4〜5分で終わっていたデプロイが、ほぼ倍に伸びている。

原因に心当たりはあった。ここ数日で財務諸表教科書の章ページを6本(3,857行)と Practice* の figure を18個(7,276行)まとめて足している。コンテンツが増えればビルドが伸びるのは当然として、それにしても倍は重い。

「とりあえず計測してから動く」と決めて、dist/ の中身を一度全部数えた。

まず物量を測った

dist/ 直下を du -sh で見ると 248MB、ファイル数は 5,195。そのうち画像(dist/images/boki3 配下)が 155MB、動画が 14MB。コードや HTML よりも、画像のほうが圧倒的に重い。

Cloudflare Pages は1デプロイあたりのアセット数とサイズに上限があるわけではないが、wrangler pages deploy は変更検知のために全アセットをハッシュ計算して比較するので、ファイル数が増えるほど upload phase が伸びる。9.3分のうち何分が upload に消えているのか、ログをスクロールして秒数を拾った。

upload phase だけで 3 分強。残りは Nitro build と SSG prerender。

「画像をどこかに逃がせばだいぶ縮むな」と思ったところで、すでに別プロジェクト用に立てておいた R2 バケット(assets.info-accounting.com にカスタムドメインを当ててある)を思い出した。これに画像を全部寄せて、本体のデプロイからは画像を外せばいい。

最初の計画書:R2移行を最優先に置いた

memo/2026-05-13/deploy-optimization-plan.md に最初の計画を書いた。要点はこうだった。

  1. dist/images/boki3 配下を R2 に上げる(容量 155MB を本体から剥がす)
  2. nuxt.config.tsapp.baseURL 的な仕組みで https://assets.info-accounting.com/images/... に書き換える
  3. dynamic import で重いコンポーネントを別 chunk に分けて Worker バンドルを軽くする

書きながら「これで upload が 21 秒くらい縮むはず」と見積もりを添えた。ここまでで30分。

書き終わってすぐ codex exec -m gpt-5.5 でレビューを投げた。「瑣末な点へのクソリプはしないで。致命的な点だけ指摘して」と添えて。

返ってきた3点が、全部痛いところを突いていた。

Codex の3つの致命傷

1点目:順序が逆。

「R2画像移行の見積もりが 21 秒で、Nitroチャンクの外出しが 333 秒。なぜ 21 秒の方を先にやるのか」。

ログを見直したら、確かに upload phase の中で時間を食っていたのは画像ではなく、Nitro が吐く巨大なJSチャンクだった。slide-artboard-master-map が 1.04MB、client.precomputed.mjs が 694KB。これが prerender のたびに Worker バンドルに巻き込まれていて、bundling と upload の両方で時間を食っていた。

体感「画像が重そう」というだけで優先順位を決めていたのを指摘されて、グッと黙った。

2点目:dynamic import の効果を勘違いしている。

「dynamic import は別 chunk に分けるだけで、Worker バンドルから消えるわけではない」。

これは完全に思い違いだった。dynamic import すれば Worker の初期ロードからは外れるが、Cloudflare Pages の Worker は全 chunk をバンドルとして抱え込むので、デプロイサイズは縮まない。「コードスプリットすれば軽くなる」という Vue/Vite の感覚を、そのまま Worker に持ち込んでいた。

3点目:$fetch('/content/...') は SSR で 404 になる。

これも痛い。計画の中で「コンテンツを _payload から外して $fetch で取りに行く」と書いていたが、既存実装はちゃんと /api/content/... の Nitro route 経由で取っていて、直接 /content/... は public に出ていなかった。SSR時に存在しないパスを叩く設計を、レビューなしで書いていた。

3点とも「実装に入る前に気づいていれば最高だが、入ってからだと半日溶ける」種類の指摘だった。

計画を全面改訂した

codex exec resume --last で文脈を引き継いだまま、計画書を一度白紙に戻して書き直した。新しい順序はこうなった。

  1. Nitroチャンクの外出しを最優先(333秒短縮見込み)
    • slide-artboard-master-map 1.04MB と client.precomputed.mjs 694KB を Worker バンドルから外す
    • 大型データは静的 JSON として public/ に置き、/api/... 経由で読む
  2. R2画像移行を次点(21秒短縮見込み)
    • 画像配信は本体デプロイから完全に切り離す
  3. dynamic import は今回見送り(Worker サイズに効かないため)

書き直して再度レビューに投げた。今度は「順序は正しい。R2移行は _redirects で済むので nuxt.config いじらないほうが堅い」とだけ返ってきた。これで実装に入れる、と判断した。

R2画像移行:_redirects 方式で詰めた

R2バケットへの画像同期は別途スクリプトを書いていたので、本体側でやることは「/images/... へのリクエストを R2 のカスタムドメインに 302 で逃がす」だけ。

Cloudflare Pages の _redirects ファイルに

/images/* https://assets.info-accounting.com/images/:splat 302

の一行を足せば終わる。問題は、_redirectsscripts/generate-cloudflare-redirects.ts で自動生成されていたこと。ここに手で1行足しても、次のビルドで上書きされて消える。

スクリプトを開いて、コンテンツから生成するリダイレクト行の配列に、ハードコードで images の行を append する処理を足した。

// 既存のコンテンツ起点リダイレクトを生成したあと
lines.push('/images/* https://assets.info-accounting.com/images/:splat 302')

これでビルドのたびに自動で _redirects に含まれる形に揃った。

deploy.ps1 のほうも、以前は「R2同期 → ビルド → wrangler pages deploy」と3ステップだったのを、画像同期は別CIに分離して、デプロイ時は wrangler pages deploy だけで完結する形に縮めた。

検証で302チェーンを目視した

デプロイが通ったあと、curl -I https://eurekapu-nuxt4.pages.dev/images/boki3/chapter01/figure_01.png で確認した。

HTTP/2 302
location: https://assets.info-accounting.com/images/boki3/chapter01/figure_01.png

-L を付けて追跡すると、ちゃんと R2 から 200 で画像が返ってきた。本体の dist/ から dist/images/boki3.gitignore 的に除外する仕込みも入れて、次回ビルドからは upload が155MB分軽くなる見込み。

最後にやらかしたこと:ビルド中の編集でハッシュ不整合

検証が一通り終わって「次は Nitroチャンクの外出しだな」と画面を切り替えた直後、再ビルドを流しながら別ファイルを編集してしまった。

ビルド完了後、ローカルで開くとブラウザが app-styles.B3Vj9TBQ.css を 404 で取りに行く。dist/_nuxt/ を ls すると、そのハッシュのファイルは存在せず、別のハッシュのCSSだけが置かれている。

最初は Cloudflare のキャッシュを疑ったが、ローカルの dist/ でも同じ症状が出ているので、もっと根が浅い。grep -r "B3Vj9TBQ" dist/ で当たりをつけると、HTML 側だけ古いハッシュを参照していて、CSS実体は新しいハッシュで吐かれていた。

ビルド途中で apps/web/app/assets/ の中身を1行書き換えたせいで、CSSの再ハッシュは走ったが、HTMLの埋め込みは最初のスナップショットを参照したまま固まっていた。Nitro/Vite のビルドは並列で走るので、入力ファイルが途中で変わると、出力同士の参照が食い違う。

クリーンビルドを一回叩いたら直った。次から「ビルド中はファイルに触らない」を物理的に守るために、pnpm build を流すターミナルとエディタを別ワークスペースに分けた。

振り返り

  • 「重そう」という体感で順序を決めようとしたら、Codex に 1分でひっくり返された
  • dynamic import が Worker サイズに効かないことを今日初めて手で確かめた
  • _redirects は思っていたよりずっと頼れる。アプリのコードに R2 のURLを散らかさずに済む
  • ビルド中の編集は、エラーメッセージがハッシュ不整合という遠回しな形で返ってくる。物理的に作業ワークスペースを分けるしかない

明日やること:

  • Nitroチャンクの外出し(slide-artboard-master-mapclient.precomputed.mjs を public 静的JSON化)
  • 画像同期スクリプトを CI 化(手元で pnpm sync:r2-images を叩く運用から外す)
  • デプロイ後の upload phase 秒数を Cloudflare API から拾って Slack 通知にする(次回 9分超えたら気づける)