Nuxtで画像が表示されないバグを追ったら、ビルド除外の設計に行き着いた

開発blog-platform

ローカルでだけ確認している記事のページを開くと、本文は読めるのに画像だけが真っ白だった。何度リロードしても変わらない。31枚あるはずの画像のうち、1枚も出てこない。原因を突き止めてほしいと頼んだ。

devサーバーがちょうど落ちていて、再起動しかけたところで止めた。触らずこのまま調べたほうがいい。ファイルは全部揃っている。となると疑うべきは配信の経路のほうだ。

IPが取れていない

content内の画像はミドルウェアが配信していて、ビルド時にpublicへコピーする処理と、リクエスト時にアクセスを絞る処理の2段構えになっている。デバッグ出力を仕込んで一つずつ潰していくと、犯人は後者だった。

getRequestIP(event)undefined を返していた。devサーバーは内部でリクエストをプロキシしていて、生のTCPソケット情報が失われる。実際のクライアントIPは x-forwarded-for: ::1 というヘッダーで渡ってくるのに、ミドルウェア側はそのヘッダーを見ない設定で呼び出していた。

// Before: x-forwarded-forを見ない設定 → devでは常にundefined
getRequestIP(event)
// After: プロキシ経由のヘッダーも見る
getRequestIP(event, { xForwardedFor: true })

IPが取れない、ローカルホスト判定が常にfalseになる、常に404を返す。地味なオプションの見落としひとつが、31枚の画像を丸ごと消していた。

直してリロードすると、200が返ってJPEGが届いた。それでも自分の目で確かめないと終われない。画像が出るところまでページをスクロールし、実際の見た目をスクリーンショットで確認する。

ビューポート外はまだ白いままだった。lazy loadingのせいだろうと当たりをつけて、直接fetchで31枚全部のステータスを確認する。全部200だった。念のため最下部までスクロールしてから、ようやく直ったと言えた。

「配置し直した」わけではなかった

報告すると、「他の記事と同じように画像を配置し直したってことですよね」と聞かれた。違う。画像ファイルの場所も記事内のパスも最初から正しく、一度も動かしていない。直したのはサーバー側のアクセス制御ロジックのバグで、画像の置き場所とは無関係だった。

そう伝えると、話は別の方向に転がった。このミドルウェアらしき仕組みは、今日のうちにCodexが追加したものではないか。だとしたら、いらない実装かもしれない。従来通りマークダウンを置くだけでデプロイできればいい。バグが入ったのは新しいロジックのせいなのか、それとも元からあった不具合なのか。

消していいのか、確かめる

git履歴を辿ると、content.config.tsnuxt.config.ts も未コミットで、_local-unpublished という仕組みそのものが今回の一連の作業でまるごと新規に追加されたものだとわかった。着手前に、消したときの影響を確かめておきたい。この記事には機密情報が写り込んだ画像が含まれていて、扱いを間違えられない話だったからだ。

既存の、コミット済みの verify-unpublished-excluded.mjs を見ると、unpublished: true のフラグが付いた記事は、本番ビルドのHTML・rawマークダウン・サイトマップ・JSバンドルの文字列すべてから排除され、漏れがあればビルド自体を失敗させる検証まで用意されていた。つまり _local-unpublished という特別な仕組みがなくても、この記事を普通に unpublished: true のまま置けば、本番サイトには一切出ない。ここまでわかると、今回の追加実装は本当にいるのか、疑いはむしろ強まった。

/blog/unpublished を開いて、既存の非公開記事の一覧と git ls-files の結果を突き合わせた。既存の unpublished: true 記事は、たくさんある方も含めて全部コミット済みだった。本番には出ないが、リポジトリの中には残っている。一方で今回の記事は機密情報が写り込んでいて、リポジトリの中間データにすら残したくない種類のものだ。既存の仕組みと新しい仕組みは、守っている対象の重さが違う。ここでようやく、追加実装には意味があったと言えた。

_local-unpublished が触っている箇所は全部で5つあった。

  • 画像配信ミドルウェアの、今回バグが出た当のアクセス制御
  • コンテンツのビルド設定側で、このフォルダをNuxt Contentの取り込み対象から外す条件分岐
  • そもそもGit管理から外す .gitignore の1行
  • 本番除外を最終的に保証する検証スクリプト
  • ローカル用の記事を置くための専用フォルダそのもの

.gitignore の1行が最初からこのフォルダを除外していて、記事本文も画像31枚も一度もGit管理に入っていないことが、ここで判明した。その .gitignore 自体が未コミットの変更だったので一瞬肝を冷やしたが、Gitはコミットの有無に関わらずワーキングツリー上の中身をそのまま見る。だから今この瞬間も機能していた。

順番を決めてコミットする

方針を決めた。まず今の状態、バグ修正済みで _local-unpublished が動く状態をチェックポイントとしてコミットし、そのあとでIP判定ロジックの見落とし部分だけを直す。実装を削る前に、既存の公開記事がどう画像を置いているかも見比べておいた。置き方の型自体は今回も変わっていない。

4ファイルをステージして学習ゲート付きでコミットしようとしたら、案の定ゲートが動いた。クイズは今回はパスにして、LEARN_SKIP=1 で通した。

ついでに、他に溜まっていた未コミットの変更も粒度を分けて片付けることにした。公開版記事に使う画像4枚は、コミット前に機密情報が残っていないか自分の目で確認した。巨大なコーパスのファイル群は除外し、スクリプト6本と小さい成果物だけをステージしてコミットする。最終的に意味の単位で7本のコミットに分かれ、git status はクリーンになった。作業環境の設定リポジトリのほうにも溜まっていた履歴があったので、そちらも合わせて片付けてプッシュしておいた。

デート制限、と聞いて

前日分の日記を自動生成するチェーンの中で、決算データ取得バッチが引っかかっていた一件も、この日のうちに片付けることにした。Koyfinから71銘柄をまとめて取りに行ったところ、22銘柄が429で弾かれていた。「デート制限にかかっているやつ」と伝えたつもりで、レート制限のことだと通じるまで一拍あった。

修正は /check-earnings の手順書に埋め込まれたスクリプトのテンプレート自体に手を入れる形にした。71銘柄を一斉に投げるのをやめて、12銘柄ずつのチャンクに分割し、429が返ってきたら1.5秒、3秒とバックオフしながら自動リトライするようにする。

Koyfinのタブがまだログイン状態のまま残っていたので、そのまま修正版で71銘柄を再取得させた。今度は全部200が返ってきた。前回取り込めなかった3銘柄も含めて、71/71が成功した。データを再生成し、ページの表示にエラーが出ていないことを確認してから、この修正一式もコミットした。

その日にわかったこと

画像が表示されない理由を探しているうちに、機密情報を含む記事をどう守るかという話にたどり着いた。バグはIPアドレス判定のオプション一つの見落としで、直すこと自体はすぐ終わった。時間がかかったのは、「この仕組みは本当にいるのか」という疑いを、実際に既存の仕組みと突き合わせて確かめる作業のほうだった。

疑ってすぐ消すのではなく、確かめてから残すか判断する。機密記事がこれからも増えたときに、この確認をその都度手作業でやるのか、仕組み側にもう一段検証を足すのかは、まだ決めていない。