Herokuを止められるか、nuxt generate を4回回して確かめた — 旧Nuxt 2アプリ静的化の実測

開発eurekapu

計画書に「静的化してHerokuを止める」と1行だけ書いてあった。読み返していて手が止まった。

前日の続きから

昨日の棚卸しで論点を5つ残したまま終わっていたので、まず仕掛かりを洗い出させた。出てきたのは2件。片方はほぼ終わっていたので、もう片方のサイト整理へ進み、計画書をChromeの前面タブに開かせた。

論点は判断の前提を読みながら答えたい。ターミナルの選択肢では前提が見えないので、計画書のHTMLに3択のボタンを埋め込む形にしてもらった。

読み進めていて、課題の一覧から学習アプリが抜けているのに気づいた。Herokuで動いている旧世代のアプリで、昨日の調査では別枠扱いになっていた。指摘して、課題に加えさせた。ついでにアフター像を図にしてほしいと頼んだら「もう描いてあります」と返ってきて、タブの奥に埋もれていた2枚が出てきた。ビフォーとアフターを並べたSVGで、カードの高さが揃っていない箇所だけ直させた。

決裁フォームの方は少しもたついた。サーバーは動いてHTTP 200を返しているのに、こちら側のバックグラウンド追跡が2回続けて切れた。その状態だと、回答を送ってもこちらが気づけない。3回目は立て直しをやめて、回答ファイルが作られた瞬間を検知する監視に切り替えさせた。11時09分に5問すべての回答が届いた。

学習アプリについて選んだのは「静的化してHerokuを止め、移譲は順次進める」だった。

一行の記述に引っかかった

回答をもとにフェーズ2の実行計画を作らせた。そのステップ2が「静的化してHerokuを止める」である。

読んでいて、順序がおかしい気がした。この計画では、Vue 2で書かれた画面をVue 3へ移す作業が先にあるように読める。だが静的化とは生成済みのHTMLとJSを配信することだ。だとすれば、ビルドさえ通れば書き換えを待つ理由はない。Herokuを解約して、生成物をCloudflareに置いて、DNSを向け直す。今日でも終わる話ではないのか。

そう聞いた。合っているなら、月々の支払いが1つ今日消える。

ただし解約は戻せない。前のセッションの調査結果を鵜呑みにさせず、リポジトリを自分で読んで裏を取るところから始めさせた。

サーバー側は33行しかなかった

コードを読ませた結果は、拍子抜けするほど薄かった。

  • Expressのエントリは33行。Nuxtのレンダラを app.use するだけで、独自ルートもDB接続も認証処理もない
  • process.env の参照は1箇所(NODE_ENV)のみ。他はすべてコメントアウト済み
  • ページは799ファイル、動的ルートは1本だけ
  • 記事サイトからの最多参照先である仕訳問題のページ(68箇所)は asyncData を持たない。データをJSONのimportでバンドルし、クエリはクライアント側で読んでいる

手当てが要るものは3つに絞れた。外部APIを叩く動的ルート1本、ランタイムプロキシを使う1ページ、HTTPS強制のミドルウェア。どれも記事サイトからの導線には乗っていない。

計画書には「関門はNode 18でのビルド」と書いてあった。旧世代のwebpackはNode 18より新しい環境でOpenSSL絡みのエラーを吐く。手元にはNode 22しかない。

関門はOpenSSLではなくメモリだった

--openssl-legacy-provider を付けて走らせた。無害で可逆なので、確認を挟まずそのまま実行させた。

8分後、EXIT=134で落ちた。

ログを見ると、webpackのコンパイルはClient / Serverとも通っていた。OpenSSLは何も起こしていない。落ちたのは799ページのレンダリング段階で、ヒープ4GBを使い切っていた。計画書に書いた関門の想定が、そもそも外れていたことになる。

搭載32GBのうち12GBを割り当てて2回目。

NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=12288" \
  ./node_modules/.bin/nuxt generate

メモリ不足は再発しなかった。通知には「exit code 0で完了」と出た。

その報告は取り消された。0はラッパーコマンド全体の値で、nuxt generate 自体はEXIT=1で終わっていたという。794ルートは生成できていて、失敗が6件あった。誤報が自己申告で訂正されたのはよかったが、通知の数字を信じてはいけないという事実だけが残った。

失敗6件は、静的化のせいではなかった

内訳を見て、少し笑ってしまった。

1件は想定どおりの動的ルートだった。残り5件はすべて、存在しないページを指しているサイト内リンクである。加えて、生成を止めていた致命的なエラーの正体は <NuxtLink>to に残った http://localhost:3000/... の1行だった。クローラーがそれを辿り、Windowsのパスとして無効な : を含むディレクトリを作ろうとして落ちていた。

どれも静的化で生まれた問題ではない。SSRなら404を返して終わるので誰も気づかなかっただけで、本番には開発用のURLへ飛ぶリンクが出ていた。静的生成がそれをエラーとして表に出した。

「一旦リンクを外していいので、3つまとめて完走させて」と指示した。

返ってきたのは、指示と少し違う結果だった。

消す前に、行き先が実在していた

調べたところ、リンク切れ5パターンのうち4つは正しいページが実在していたという。06_ というファイル名のプレフィックスが落ちている、cockpitccockpit になっている、単数形と複数形が食い違っている。要するにタイポである。

消せば本来つながるはずの導線まで失う。だから実在が確認できた7箇所は正しいパスに直し、行き先が自明でない1パターン6箇所だけリンクを外した。テキストは残してある。指示と違う判断をしたという申告が先にあったので、そのまま通した。

外した1パターンは、統合ページが3つに分割された跡のように見える。どこへ向けるべきか、いまの私にも決められない。

外部APIに依存する動的ルートは、生成の対象から外した。

generate: { exclude: [/^\/questions$/] },

3回目のビルドはEXIT=0で完走した。失敗は1件だけ残り、それも同じプレフィックス欠落だった。2回目は途中で止まっていたので、その行まで到達していなかっただけである。同じ要領で直して4回目を回した。

終了コード生成ルート失敗ルート
1回目134(メモリ不足)
2回目1(致命エラー)7946
3回目07941
4回目07940

402箇所を照合したら、25件の欠けは集計バグだった

生成できたことと、リンクが切れないことは別の話だ。記事サイトのソース全体から学習アプリへの参照を機械的に抽出させ、生成物と1件ずつ突き合わせさせた。

最初の集計では25件が「生成物に無い」と出た。抽出の正規表現がMarkdownのリンク記法の ]( を拾って、実在しないパスを作っていたのが原因だった。区切り文字を足して再集計すると、ユニーク39URL・延べ402箇所、欠けは0件になった。

数字が悪い方に振れたときは、まず自分の集計を疑う。今回はそれで正解だった。

生成物をローカルで配信して実物も見た。最多参照先の仕訳問題を ?chapter=1&section=1 付きで開くと、18問が読み込まれ、問題文も借方貸方の選択肢もボタンも描画された。コンソールのエラーと警告は0件だった。ログイン画面も認証フォームまで出た。コードから「クエリはクライアント読み」と判断した内容がそのまま実挙動と一致している。

Cloudflare Pagesの制限も測らせた。総ファイル数4,610に対して上限は20,000、25MBを超える単一ファイルは0件だった。総容量750MBのうち375MBがレッスン、179MBが画像である。画像は重いが、制限には当たらない。

それでも、今日は止めない

技術的にできることは実測で確定した。ここで解約に走りたくなるが、まだ静的化した生成物をデプロイしていない。

止める順番はこうなる。まず静的版をCloudflareに載せてDNSを向ける。そのうえでHeroku側へのアクセスが消えたことを確認する。誰も来ていないと分かってから停止する。この確認を飛ばすと、まだ生きている導線を自分で切ることになる。

検証記録と実行計画をドキュメントに残して、学習ゲートをスキップしてコミットした。停止判断は明日の積み残しとして登録してある。

残ったもの

前提を1つ壊した。計画書に書いた「関門はNodeのバージョン」は外れで、実際の関門はメモリだった。手元のマシンで12GBを渡せば通るものを、旧バージョンのNodeを用意するところから始めようとしていた。

そしてビルドを4回回して見えたのは、静的化の可否ではなく、SSRが隠していた不備の量だった。本番に出ていたlocalhostへのリンクが1本、タイポで404に落ちるリンクが5パターン。SSRは404を返して黙るが、静的生成はビルドを止めてまで教えてくる。うるさいが、正しい。

止めるかどうかは明日決める。決め手はビルドの成否ではなく、アクセスログだ。