[{"data":1,"prerenderedAt":509},["ShallowReactive",2],{"content-/deploy-optimization-r2-redirects":3,"all-pages-for-dir":507,"og-image-/deploy-optimization-r2-redirects":508},{"id":4,"title":5,"body":6,"category":486,"description":487,"extension":488,"meta":489,"navigation":447,"ogImage":490,"path":491,"project_name":492,"published":493,"publishedAt":494,"seo":495,"stem":496,"tags":497,"todo":490,"unpublished":493,"updatedAt":490,"__hash__":506},"pages/2026-05/2026-05-13/deploy-optimization-r2-redirects.md","Eurekapuのデプロイが9.3分に伸びた話：R2リダイレクト移行と、Codexに3回ダメ出しされた計画書",{"type":7,"value":8,"toc":476},"minimark",[9,14,23,26,33,37,50,57,60,63,67,73,99,102,109,112,116,122,125,136,139,144,147,150,159,178,181,184,190,234,241,245,252,258,268,278,281,328,331,340,343,350,356,372,376,379,390,400,407,414,417,433,436,472],[10,11,13],"h1",{"id":12},"eurekapuのデプロイが93分に伸びた話","Eurekapuのデプロイが9.3分に伸びた話",[15,16,17,18,22],"p",{},"朝、いつも通り ",[19,20,21],"code",{},"pnpm deploy:cloudflare"," を流して別の作業に戻ろうとしたら、ターミナルが9分以上沈黙していた。前日まで4〜5分で終わっていたデプロイが、ほぼ倍に伸びている。",[15,24,25],{},"原因に心当たりはあった。ここ数日で財務諸表教科書の章ページを6本（3,857行）と Practice* の figure を18個（7,276行）まとめて足している。コンテンツが増えればビルドが伸びるのは当然として、それにしても倍は重い。",[15,27,28,29,32],{},"「とりあえず計測してから動く」と決めて、",[19,30,31],{},"dist/"," の中身を一度全部数えた。",[34,35,36],"h2",{"id":36},"まず物量を測った",[15,38,39,41,42,45,46,49],{},[19,40,31],{}," 直下を ",[19,43,44],{},"du -sh"," で見ると 248MB、ファイル数は 5,195。そのうち画像（",[19,47,48],{},"dist/images/boki3"," 配下）が 155MB、動画が 14MB。コードや HTML よりも、画像のほうが圧倒的に重い。",[15,51,52,53,56],{},"Cloudflare Pages は1デプロイあたりのアセット数とサイズに上限があるわけではないが、",[19,54,55],{},"wrangler pages deploy"," は変更検知のために全アセットをハッシュ計算して比較するので、ファイル数が増えるほど upload phase が伸びる。9.3分のうち何分が upload に消えているのか、ログをスクロールして秒数を拾った。",[15,58,59],{},"upload phase だけで 3 分強。残りは Nitro build と SSG prerender。",[15,61,62],{},"「画像をどこかに逃がせばだいぶ縮むな」と思ったところで、すでに別プロジェクト用に立てておいた R2 バケット（assets.info-accounting.com にカスタムドメインを当ててある）を思い出した。これに画像を全部寄せて、本体のデプロイからは画像を外せばいい。",[34,64,66],{"id":65},"最初の計画書r2移行を最優先に置いた","最初の計画書：R2移行を最優先に置いた",[15,68,69,72],{},[19,70,71],{},"memo/2026-05-13/deploy-optimization-plan.md"," に最初の計画を書いた。要点はこうだった。",[74,75,76,82,96],"ol",{},[77,78,79,81],"li",{},[19,80,48],{}," 配下を R2 に上げる（容量 155MB を本体から剥がす）",[77,83,84,87,88,91,92,95],{},[19,85,86],{},"nuxt.config.ts"," の ",[19,89,90],{},"app.baseURL"," 的な仕組みで ",[19,93,94],{},"https://assets.info-accounting.com/images/..."," に書き換える",[77,97,98],{},"dynamic import で重いコンポーネントを別 chunk に分けて Worker バンドルを軽くする",[15,100,101],{},"書きながら「これで upload が 21 秒くらい縮むはず」と見積もりを添えた。ここまでで30分。",[15,103,104,105,108],{},"書き終わってすぐ ",[19,106,107],{},"codex exec -m gpt-5.5"," でレビューを投げた。「瑣末な点へのクソリプはしないで。致命的な点だけ指摘して」と添えて。",[15,110,111],{},"返ってきた3点が、全部痛いところを突いていた。",[34,113,115],{"id":114},"codex-の3つの致命傷","Codex の3つの致命傷",[15,117,118],{},[119,120,121],"strong",{},"1点目：順序が逆。",[15,123,124],{},"「R2画像移行の見積もりが 21 秒で、Nitroチャンクの外出しが 333 秒。なぜ 21 秒の方を先にやるのか」。",[15,126,127,128,131,132,135],{},"ログを見直したら、確かに upload phase の中で時間を食っていたのは画像ではなく、Nitro が吐く巨大なJSチャンクだった。",[19,129,130],{},"slide-artboard-master-map"," が 1.04MB、",[19,133,134],{},"client.precomputed.mjs"," が 694KB。これが prerender のたびに Worker バンドルに巻き込まれていて、bundling と upload の両方で時間を食っていた。",[15,137,138],{},"体感「画像が重そう」というだけで優先順位を決めていたのを指摘されて、グッと黙った。",[15,140,141],{},[119,142,143],{},"2点目：dynamic import の効果を勘違いしている。",[15,145,146],{},"「dynamic import は別 chunk に分けるだけで、Worker バンドルから消えるわけではない」。",[15,148,149],{},"これは完全に思い違いだった。dynamic import すれば Worker の初期ロードからは外れるが、Cloudflare Pages の Worker は全 chunk をバンドルとして抱え込むので、デプロイサイズは縮まない。「コードスプリットすれば軽くなる」という Vue/Vite の感覚を、そのまま Worker に持ち込んでいた。",[15,151,152],{},[119,153,154,155,158],{},"3点目：",[19,156,157],{},"$fetch('/content/...')"," は SSR で 404 になる。",[15,160,161,162,165,166,169,170,173,174,177],{},"これも痛い。計画の中で「コンテンツを ",[19,163,164],{},"_payload"," から外して ",[19,167,168],{},"$fetch"," で取りに行く」と書いていたが、既存実装はちゃんと ",[19,171,172],{},"/api/content/..."," の Nitro route 経由で取っていて、直接 ",[19,175,176],{},"/content/..."," は public に出ていなかった。SSR時に存在しないパスを叩く設計を、レビューなしで書いていた。",[15,179,180],{},"3点とも「実装に入る前に気づいていれば最高だが、入ってからだと半日溶ける」種類の指摘だった。",[34,182,183],{"id":183},"計画を全面改訂した",[15,185,186,189],{},[19,187,188],{},"codex exec resume --last"," で文脈を引き継いだまま、計画書を一度白紙に戻して書き直した。新しい順序はこうなった。",[74,191,192,220,231],{},[77,193,194,197,198],{},[119,195,196],{},"Nitroチャンクの外出しを最優先","（333秒短縮見込み）\n",[199,200,201,209],"ul",{},[77,202,203,205,206,208],{},[19,204,130],{}," 1.04MB と ",[19,207,134],{}," 694KB を Worker バンドルから外す",[77,210,211,212,215,216,219],{},"大型データは静的 JSON として ",[19,213,214],{},"public/"," に置き、",[19,217,218],{},"/api/..."," 経由で読む",[77,221,222,225,226],{},[119,223,224],{},"R2画像移行を次点","（21秒短縮見込み）\n",[199,227,228],{},[77,229,230],{},"画像配信は本体デプロイから完全に切り離す",[77,232,233],{},"dynamic import は今回見送り（Worker サイズに効かないため）",[15,235,236,237,240],{},"書き直して再度レビューに投げた。今度は「順序は正しい。R2移行は ",[19,238,239],{},"_redirects"," で済むので nuxt.config いじらないほうが堅い」とだけ返ってきた。これで実装に入れる、と判断した。",[34,242,244],{"id":243},"r2画像移行_redirects-方式で詰めた","R2画像移行：_redirects 方式で詰めた",[15,246,247,248,251],{},"R2バケットへの画像同期は別途スクリプトを書いていたので、本体側でやることは「",[19,249,250],{},"/images/..."," へのリクエストを R2 のカスタムドメインに 302 で逃がす」だけ。",[15,253,254,255,257],{},"Cloudflare Pages の ",[19,256,239],{}," ファイルに",[259,260,265],"pre",{"className":261,"code":263,"language":264},[262],"language-text","/images/* https://assets.info-accounting.com/images/:splat 302\n","text",[19,266,263],{"__ignoreMap":267},"",[15,269,270,271,273,274,277],{},"の一行を足せば終わる。問題は、",[19,272,239],{}," が ",[19,275,276],{},"scripts/generate-cloudflare-redirects.ts"," で自動生成されていたこと。ここに手で1行足しても、次のビルドで上書きされて消える。",[15,279,280],{},"スクリプトを開いて、コンテンツから生成するリダイレクト行の配列に、ハードコードで images の行を append する処理を足した。",[259,282,286],{"className":283,"code":284,"language":285,"meta":267,"style":267},"language-typescript shiki shiki-themes vitesse-light vitesse-light","// 既存のコンテンツ起点リダイレクトを生成したあと\nlines.push('/images/* https://assets.info-accounting.com/images/:splat 302')\n","typescript",[19,287,288,297],{"__ignoreMap":267},[289,290,293],"span",{"class":291,"line":292},"line",1,[289,294,296],{"class":295},"sxvE3","// 既存のコンテンツ起点リダイレクトを生成したあと\n",[289,298,300,304,308,312,315,319,323,325],{"class":291,"line":299},2,[289,301,303],{"class":302},"s4oTP","lines",[289,305,307],{"class":306},"shFtX",".",[289,309,311],{"class":310},"senZ8","push",[289,313,314],{"class":306},"(",[289,316,318],{"class":317},"sMJiu","'",[289,320,322],{"class":321},"sdGka","/images/* https://assets.info-accounting.com/images/:splat 302",[289,324,318],{"class":317},[289,326,327],{"class":306},")\n",[15,329,330],{},"これでビルドのたびに自動で _redirects に含まれる形に揃った。",[15,332,333,336,337,339],{},[19,334,335],{},"deploy.ps1"," のほうも、以前は「R2同期 → ビルド → wrangler pages deploy」と3ステップだったのを、画像同期は別CIに分離して、デプロイ時は ",[19,338,55],{}," だけで完結する形に縮めた。",[34,341,342],{"id":342},"検証で302チェーンを目視した",[15,344,345,346,349],{},"デプロイが通ったあと、",[19,347,348],{},"curl -I https://eurekapu-nuxt4.pages.dev/images/boki3/chapter01/figure_01.png"," で確認した。",[259,351,354],{"className":352,"code":353,"language":264},[262],"HTTP/2 302\nlocation: https://assets.info-accounting.com/images/boki3/chapter01/figure_01.png\n",[19,355,353],{"__ignoreMap":267},[15,357,358,361,362,364,365,367,368,371],{},[19,359,360],{},"-L"," を付けて追跡すると、ちゃんと R2 から 200 で画像が返ってきた。本体の ",[19,363,31],{}," から ",[19,366,48],{}," を ",[19,369,370],{},".gitignore"," 的に除外する仕込みも入れて、次回ビルドからは upload が155MB分軽くなる見込み。",[34,373,375],{"id":374},"最後にやらかしたことビルド中の編集でハッシュ不整合","最後にやらかしたこと：ビルド中の編集でハッシュ不整合",[15,377,378],{},"検証が一通り終わって「次は Nitroチャンクの外出しだな」と画面を切り替えた直後、再ビルドを流しながら別ファイルを編集してしまった。",[15,380,381,382,385,386,389],{},"ビルド完了後、ローカルで開くとブラウザが ",[19,383,384],{},"app-styles.B3Vj9TBQ.css"," を 404 で取りに行く。",[19,387,388],{},"dist/_nuxt/"," を ls すると、そのハッシュのファイルは存在せず、別のハッシュのCSSだけが置かれている。",[15,391,392,393,395,396,399],{},"最初は Cloudflare のキャッシュを疑ったが、ローカルの ",[19,394,31],{}," でも同じ症状が出ているので、もっと根が浅い。",[19,397,398],{},"grep -r \"B3Vj9TBQ\" dist/"," で当たりをつけると、HTML 側だけ古いハッシュを参照していて、CSS実体は新しいハッシュで吐かれていた。",[15,401,402,403,406],{},"ビルド途中で ",[19,404,405],{},"apps/web/app/assets/"," の中身を1行書き換えたせいで、CSSの再ハッシュは走ったが、HTMLの埋め込みは最初のスナップショットを参照したまま固まっていた。Nitro/Vite のビルドは並列で走るので、入力ファイルが途中で変わると、出力同士の参照が食い違う。",[15,408,409,410,413],{},"クリーンビルドを一回叩いたら直った。次から「ビルド中はファイルに触らない」を物理的に守るために、",[19,411,412],{},"pnpm build"," を流すターミナルとエディタを別ワークスペースに分けた。",[34,415,416],{"id":416},"振り返り",[199,418,419,422,425,430],{},[77,420,421],{},"「重そう」という体感で順序を決めようとしたら、Codex に 1分でひっくり返された",[77,423,424],{},"dynamic import が Worker サイズに効かないことを今日初めて手で確かめた",[77,426,427,429],{},[19,428,239],{}," は思っていたよりずっと頼れる。アプリのコードに R2 のURLを散らかさずに済む",[77,431,432],{},"ビルド中の編集は、エラーメッセージがハッシュ不整合という遠回しな形で返ってくる。物理的に作業ワークスペースを分けるしかない",[15,434,435],{},"明日やること：",[199,437,440,456,466],{"className":438},[439],"contains-task-list",[77,441,444,449,450,452,453,455],{"className":442},[443],"task-list-item",[445,446],"input",{"disabled":447,"type":448},true,"checkbox"," Nitroチャンクの外出し（",[19,451,130],{}," と ",[19,454,134],{}," を public 静的JSON化）",[77,457,459,461,462,465],{"className":458},[443],[445,460],{"disabled":447,"type":448}," 画像同期スクリプトを CI 化（手元で ",[19,463,464],{},"pnpm sync:r2-images"," を叩く運用から外す）",[77,467,469,471],{"className":468},[443],[445,470],{"disabled":447,"type":448}," デプロイ後の upload phase 秒数を Cloudflare API から拾って Slack 通知にする（次回 9分超えたら気づける）",[473,474,475],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":267,"searchDepth":299,"depth":299,"links":477},[478,479,480,481,482,483,484,485],{"id":36,"depth":299,"text":36},{"id":65,"depth":299,"text":66},{"id":114,"depth":299,"text":115},{"id":183,"depth":299,"text":183},{"id":243,"depth":299,"text":244},{"id":342,"depth":299,"text":342},{"id":374,"depth":299,"text":375},{"id":416,"depth":299,"text":416},"dev","Eurekapu-NUXT4のデプロイ時間が9.3分まで伸び、dist/総量248MB・5195ファイルまで肥大化していた。R2画像移行→dynamic importの順で計画を立てたら、Codexレビューに『順序が逆』『dynamic importは Worker バンドルから消えない』『$fetch('/content/...')はSSRで404』と3点まとめて致命傷を指摘され、計画を全面改訂。最終的に _redirects 方式でR2にリダイレクトする形に落として、wrangler pages deployだけで完結する流れに揃えた。最後、ビルド中にファイルを編集してapp-styles.B3Vj9TBQのハッシュ不整合に引っかかった反省も書いておく。","md",{},null,"/deploy-optimization-r2-redirects","eurekapu-nuxt4",false,"2026-05-13T00:00:00.000Z",{"title":5,"description":487},"2026-05/2026-05-13/deploy-optimization-r2-redirects",[498,499,500,501,502,503,504,239,505],"Cloudflare Pages","R2","Nuxt","Nitro","dynamic import","Codex","デプロイ最適化","ビルド最適化","E3ttmtP65Zw2io3z5jLc9TCbA9R-OBQ625cJ8d8IFHs",[],"https://log.eurekapu.com/og/blog/deploy-optimization-r2-redirects.png?v=2026-05-13T00%3A00%3A00.000Z&title=Eurekapu%E3%81%AE%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%8C9.3%E5%88%86%E3%81%AB%E4%BC%B8%E3%81%B3%E3%81%9F%E8%A9%B1%EF%BC%9AR2%E3%83%AA%E3%83%80%E3%82%A4%E3%83%AC%E3%82%AF%E3%83%88%E7%A7%BB%E8%A1%8C%E3%81%A8%E3%80%81Codex%E3%81%AB3%E5%9B%9E%E3%83%80%E3%83%A1%E5%87%BA%E3%81%97%E3%81%95%E3%82%8C%E3%81%9F%E8%A8%88%E7%94%BB%E6%9B%B8&author=Kei%20Komatsu&sig=b82761ffad934352",1782528836176]