前回は Pagefind で作るサイト内検索の話をしましたが、今回はトラブルシュート系の番外編です。
このブログには「下書き機能」があります。記事の frontmatter に draft: true と書いておくと、開発中(pnpm dev)では一覧に表示されるのに、本番ビルドからは除外される、という仕組みです。書きかけの記事を安心して置いておけるので重宝していました。
ところがある日、本番ビルドの出力を眺めていて気づきました。まだ公開するつもりのない下書きが、しれっと本番に混ざっていたのです。
下書きを除外する仕組み
まず、当時の仕組みをおさらいします。記事の取得処理では、ビルド時かどうかを判定して下書きをフィルタしていました。判定の入り口はこんな感じです。
export function filterDrafts( entries: BlogEntry[], isBuildLikeEnv: boolean,): BlogEntry[] { return isBuildLikeEnv ? entries.filter((e) => !isDraftEntry(e)) : entries;}
export async function getBlogEntries(): Promise<BlogEntry[]> { const entries = await getCollection("blog"); return filterDrafts(entries, isBuildLike());}ロジック自体はシンプルで、「ビルド系の環境なら下書きを捨てる、そうでなければ全部返す」だけです。問題は、その「ビルド系かどうか」をどう判定していたか、でした。当初の判定はこうなっていました。
export function isBuildLike(): boolean { return import.meta.env.PROD;}import.meta.env.PROD を見ているだけです。普通に考えれば、astro build のときは true になってくれるはずです。
問題発生:build しても下書きが消えない
実際に確認してみると、pnpm build で生成された dist/ の中に、draft: true のはずの記事ページが普通に出力されていました。pnpm preview でアクセスしても表示されてしまいます。
最初は「draft の値を読み間違えているのでは」と思い、isDraftEntry を疑いました。でも frontmatter は確かに draft: true。フィルタのロジックも、目で追う限り間違っていません。
そこで getBlogEntries の中に一時的なログを仕込んで、判定値そのものを出力してみました。
console.log("PROD:", import.meta.env.PROD);console.log("DEV :", import.meta.env.DEV);console.log("MODE:", import.meta.env.MODE);pnpm build で出てきた結果がこれです。
PROD: falseDEV : trueMODE: productionastro build を回しているのに PROD が false。一方で MODE はちゃんと production になっています。ここで「あ、これは環境変数の話だ」とピンときました。
原因:シェルに NODE_ENV=development が固定されていた
僕の開発環境は Docker コンテナ内に構築しているのですが、そのコンテナに NODE_ENV=development が固定で入っていました。これが効いていたのです。
Vite の import.meta.env.PROD と MODE は、似ているようで根っこが違います。
import.meta.env.MODE… 実行モード。astro buildはデフォルトでproductionモードになる。コマンド側で決まる値。import.meta.env.PROD… 本番稼働かどうかの真偽値。こちらはNODE_ENV === "production"を基準にしている。
つまり NODE_ENV=development が居座っていると、たとえ astro build(= MODE は production)を実行しても、PROD は NODE_ENV に引っ張られて false のままになります(Vite の環境変数の仕様は公式ドキュメントの Env Variables and Modes が詳しいです)。PROD だけを信じていた isBuildLike は、ビルド中なのに「これは開発環境だ」と判断し、下書きを素通しさせていた、というわけでした。
NODE_ENV はその気がなくても紛れ込みがちな変数です。コンテナのベースイメージ、Dev Container の設定、シェルの初期化スクリプト、CI のジョブ定義など、心当たりが複数あって特定に時間がかかりました。「環境によって挙動が変わる」系のバグは、まず変数の実値をそのまま出力するのが結局いちばん速いです。
解決策:MODE も併せて見る
原因がわかれば対処はシンプルです。NODE_ENV に依存する PROD だけに頼らず、コマンドで決まる MODE も併せて評価するようにしました。
export function isBuildLike(): boolean { return import.meta.env.PROD || import.meta.env.MODE === "production";}これで「PROD が true」でも「MODE が production」でも本番系と判定されます。NODE_ENV がどう設定されていようと、astro build であれば確実に下書きが除外されるようになりました。
判定を env.ts の isBuildLike という一箇所に切り出しておいたのも、地味に効きました。下書き除外のロジックは記事一覧・タグページ・個別ページなど複数の場所から呼ばれているのですが、判定が一箇所にまとまっていたおかげで、直すのはこの関数だけで済んでいます。
テストで二度と踏まないように
同じ罠を未来の自分が踏まないよう、isBuildLike にはテストを足しました。特に「PROD が false でも MODE が production なら true」という、まさに今回ハマったケースを明示的に固定しています。
8 collapsed lines
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("isBuildLike", () => { beforeEach(() => { vi.resetModules(); });
it("PROD=true のときtrueを返す", async () => { vi.stubEnv("PROD", true); const { isBuildLike } = await import("./env"); expect(isBuildLike()).toBe(true); vi.unstubAllEnvs(); });
it("PROD=false かつ MODE='development' のときfalseを返す", async () => { vi.stubEnv("PROD", false); vi.stubEnv("MODE", "development"); const { isBuildLike } = await import("./env"); expect(isBuildLike()).toBe(false); vi.unstubAllEnvs(); });
it("PROD=false でも MODE='production' のときtrueを返す(NODE_ENV=development 下の astro build)", async () => { vi.stubEnv("PROD", false); vi.stubEnv("MODE", "production"); const { isBuildLike } = await import("./env"); expect(isBuildLike()).toBe(true); vi.unstubAllEnvs(); });});vi.stubEnv を使うと import.meta.env の値を差し替えられるので、環境変数まわりのロジックも素直にテストできます。バグそのものをテストケースとして残しておくと、原因も再発防止も一目で伝わるのが気に入っています。
おまけ:あえて PROD だけを見ている場所もある
実はこのブログには、import.meta.env.PROD だけで判定している箇所がもう一つあります。Google Analytics の読み込みです。こちらは「ローカルの preview では計測タグを出したくない、実際にデプロイされた環境でだけ有効にしたい」という意図があり、NODE_ENV 依存の PROD がちょうど都合よく働いてくれます。同じ PROD でも、使う場所によって正解が変わるのが面白いところです。
「ちょっと下書きを置いておくだけ」のつもりの機能が、環境変数ひとつで本番に漏れる。判定の根っこにある値が何で決まっているかを知らないと、こういう取りこぼしは平気で起きるのだと痛感しました。同じところでつまずく人が一人でも減れば幸いです。