astro buildが止まるエラーの原因とCPU0%永久フリーズの対処法
astro buildの実行中にエラーメッセージも吐かずCPU使用率0.0%のまま停止する主因は、APFSファイルシステムの単一ディレクトリハードリンク上限(65,535個)に達したdistディレクトリに対し、Node.jsのemptyDir(fs.rmSync)が同期深さ優先探索を行ってI/Oキューがデッドロックするためです。
APFSハードリンク窒息デッドロック・実機障害検証ログ
実機テスト・コード検証済み一次データ
キャッシュを削除し、node_modules を再インストールし、設定ファイルを見直しても解決しない場合、問題はソースコードではなくファイルシステムとNode.jsの境界線に存在します。
本記事では、1000ページ超の大規模Astroメディア運用現場で発生した一次実測ログをもとに、Astroビルドがフリーズする4大エラー原因、計算量O(1)で瞬時に脱出するポインタ付け替えハック、そして二度とビルドを停止させない「自律型ビルド防衛Sentinel」の実装コードまでを網羅して解説します。
astro buildが途中で止まる・フリーズする4大エラー原因
Astroの静的ビルド(astro build)が終了せず、コンソールが停止するトラブルには明確な技術的パターンが存在します。闇雲に設定を弄る前に、以下の4つの原因のいずれに該当するかを特定してください。
| 障害分類 | 停止のタイミング | CPU / メモリ挙動 | 主な原因と発生条件 |
|---|---|---|---|
| ① APFSハードリンク窒息 | Collecting build info... 直後 | CPU 0.0% / メモリ正常 / プロセスS+ | dist 内の再帰リンクが65,535個に達し、fs.rmSync がデッドロック |
| ② Content Layerキャッシュ破損 | コマンド実行直後(0.5秒以内) | CPU 0.0% / ハングアップ | .astro/ や node_modules/.astro/ のシリアライズ破損・重複ゴースト |
| ③ リダイレクト定義の衝突 | ルート解決・ページ生成開始時 | CPU 100% または 無限待機 | astro.config.mjs 内の末尾スラッシュ有無(/foo/ と /foo)の二重定義 |
| ④ 外部APIフェッチの未解決 | 静的エントリポイント生成中 | CPU 0.0% / タイムアウト待ち | getStaticPaths 等でのfetchにTimeout/AbortControllerが未設定 |
特に初見で最も時間を溶かしやすいのが、①の「エラーを一切吐かず、CPU使用率0.0%のまま一生眠り続ける現象」です。次章でそのメカニズムを深層解剖します。
【一次実測】エラーすら出ない「CPU 0.0%」停止の解剖とfs.rmSync
全1035本の大規模MDX記事を抱える本番環境において、npm run build を実行した際、ターミナルは以下の行を出力したまま完全に沈黙しました。
22:31:33 [build] output: "static"
22:31:33 [build] mode: "static"
22:31:33 [build] directory: /.../sites/site-name/dist/
22:31:33 [build] Collecting build info...
22:31:33 [build] ✓ Completed in 616ms.
通常であれば、この直後に Building static entrypoints... というログが出力され、Viteによる静的レンダリングが開始されます。しかし、Completed in 616ms. の行でカーソルの点滅が静止します。
別ターミナルから ps aux で該当プロセスを検査した実測データが以下です。
ps aux | grep "astro.js build"
USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
user 85240 0.0 2.3 471074416 384032 s001 S+ 10:39PM 0:01.24 node /.../astro/astro.js build
実行時間(TIME)は1.24秒で停止しており、CPU使用率は 0.0%。プロセスステータスは「S+(割り込み可能なスリープ状態)」でした。
Astroコア内部の真犯人:emptyDir() と fs.rmSync
Astro 5のビルドパイプライン内部コード(node_modules/astro/dist/core/build/static-build.js)をトレースすると、ログ停止地点の直前で以下の処理が実行されていることが判明しました。
// static-build.js
if (settings.config?.vite?.build?.emptyOutDir !== false) {
emptyDir(settings.config.outDir, new Set(".git"));
}
さらに emptyDir の実装(core/fs/index.js)を確認すると、前回のビルド成果物を一掃するために Node.js 標準の同期削除関数 fs.rmSync が呼ばれています。
// core/fs/index.js
function emptyDir(_dir, skip) {
const dir = fileURLToPath(_dir);
if (!fs.existsSync(dir)) return void 0;
for (const file of fs.readdirSync(dir)) {
if (skip?.has(file)) continue;
const p = path.resolve(dir, file);
const rmOptions = { recursive: true, force: true, maxRetries: 3 };
try {
fs.rmSync(p, rmOptions);
} catch (er) {
// 例外処理
}
}
}
限界値「ハードリンク65,535個」の衝突
このとき、停止していた dist ディレクトリの詳細ステータスを ls -la で検査した結果です。
drwx------ 65535 user staff 2097120 Sep 20 22:24 target-guide
特定のディレクトリのハードリンク数が 65,535個 に達していました。これは16ビット符号なし整数の最大値であり、macOSのファイルシステム(APFS/HFS+)における単一ディレクトリのハードリンク上限値です。
過去のビルドや画像変換の異常によって、内部に自己参照的な循環リンクや数十万件のゴーストフォルダが増殖しており、Node.jsのメインスレッドがその巨大なツリー構造を同期的に走査しようとした結果、OSのI/Oキューが完全にデッドロックし、CPU 0.0%の窒息状態に陥っていたのです。
rm -rfは厳禁!0.001秒で突破する「同一ボリューム内ポインタ付け替え(mv)」
この状態に直面した際、ターミナルから手動で rm -rf dist を実行したり、Finderからゴミ箱へ移動させようとするのは絶対に避けてください。
なぜ手動の rm -rf でもMacごと道連れフリーズするのか?
UNIXの標準コマンド rm -rf は、対象ディレクトリツリーのすべてのinodeを走査し、1件ずつ unlink システムコールを発行します。
ハードリンク上限(65535)に達したモンスターディレクトリに対して rm -rf を実行すると、数十万回のメタデータ変更がディスクへ集中発行されます。その結果、APFSのメタデータ排他ロック(カーネルレベルのロック)が長時間保持され、ターミナルだけでなくFinderや他のアプリもディスクI/O待ちでブロックされ、OS全体が道連れフリーズを起こします。
解決策:同一ボリューム内での名前変更(mv)
このファイルシステムの底なし沼を突破する唯一の解法は、削除するのではなく同一ボリューム内での名前変更(mv)を行うことです。
# distを瞬時に別名へ隔離し、即座に空のdistを配備する
mv dist .dist_trash && mkdir dist
UNIXファイルシステムにおいて、同一ボリューム(同一パーティション)内での mv コマンドは、ファイル実体や配下のツリーを走査しません。親ディレクトリのエントリにおいて「ポインタ(inode番号の参照先)を1箇所書き換えるだけ」で終了します。
配下にファイルが1個あろうが65,535個あろうが、要する計算量は O(1)。0.001秒で処理が完了します。
隔離した .dist_trash ディレクトリは、ビルドが完了した後にバックグラウンドで安全に消去させます。
# シェルをブロックさせず、バックグラウンドで低負荷消去
nohup rm -rf .dist_trash >/dev/null 2>&1 &
【コピペ配備】二度とビルドを止めない「自律型ビルド防衛Sentinel」
トラブルを解決しても、人間の手動運用に頼っていてはいつか再発します。プロジェクトの健全性を担保するためには、「コードによる物理的制約」をビルド前に自動介入させるのが鉄則です。
以下のスクリプトをプロジェクトの監査パイプライン(例: scripts/audit_and_fix_quality.py)の最上流に組み込んでください。
# !/usr/bin/env python3
import os
import re
import shutil
import subprocess
import time
def pre_build_sentinel(base_dir: str):
"""
【自律型ビルド防衛センチネル (Pre-build Health Sentinel)】
1. 古いスタック/ゾンビプロセスの自動検死 & 強制終了
2. dist & .astro の健康診断(ハードリンク過密・破損シリアライズの自動隔離 & 瞬間クリーン配備)
3. astro.config.mjs のリダイレクト重複・衝突の自動修復
"""
site_name = os.path.basename(base_dir)
my_pid = os.getpid()
# 1. ゾンビプロセスの検死 & 一掃
try:
ps_out = subprocess.check_output(["ps", "-eo", "pid,command"], text=True)
for line in ps_out.strip().split("\n"):
if site_name in line and ("astro/astro.js" in line or "esbuild" in line):
parts = line.strip().split(None, 1)
if parts and parts[0].isdigit():
pid = int(parts[0])
if pid != my_pid and pid != os.getppid():
try:
os.kill(pid, 9)
print(f"🛡️ [SENTINEL] スタックしていた古いプロセス (PID: {pid}) を安全に終了させました。")
except ProcessLookupError:
pass
except Exception:
pass
# 2. dist & .astro の健康診断と瞬時自浄
dist_dir = os.path.join(base_dir, "dist")
astro_cache = os.path.join(base_dir, ".astro")
if os.path.exists(dist_dir):
needs_reset = False
try:
for entry in os.scandir(dist_dir):
if entry.is_dir(follow_symlinks=False):
stat = entry.stat(follow_symlinks=False)
# ファイルシステム上限(65535)に近い異常なハードリンク数を検知
if stat.st_nlink >= 30000:
needs_reset = True
print(f"⚠️ [SENTINEL] dist/{entry.name} に異常なハードリンク数 ({stat.st_nlink}) を検知しました。")
break
except Exception:
needs_reset = True
if needs_reset:
print("🛡️ [SENTINEL] dist が破損または異常肥大化しているため、瞬間隔離&初期化します...")
trash_name = f".dist_trash_{int(time.time())}"
trash_path = os.path.join(base_dir, trash_name)
try:
os.rename(dist_dir, trash_path)
os.makedirs(dist_dir, exist_ok=True)
subprocess.Popen(["rm", "-rf", trash_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("✨ [SENTINEL] dist をクリーンな状態に瞬間初期化しました。")
except Exception as e:
print(f"⚠️ [SENTINEL] dist 初期化警告: {e}")
# 3. astro.config.mjs のリダイレクト重複・衝突の自動修復
config_path = os.path.join(base_dir, "astro.config.mjs")
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg_content = f.read()
m = re.search(r'(redirects:\s*\{)(.*?)\n\s*\},', cfg_content, re.DOTALL)
if m:
body = m.group(2)
lines = body.split("\n")
seen_routes = set()
new_lines = []
modified_redirects = False
for l in lines:
r_match = re.search(r"^\s*['\"]([^'\"]+)['\"]\s*:\s*(.+)$", l)
if r_match:
route = r_match.group(1)
norm_route = route.rstrip("/") + "/"
if norm_route in seen_routes:
modified_redirects = True
continue
seen_routes.add(norm_route)
new_lines.append(l)
else:
new_lines.append(l)
if modified_redirects:
new_body = "\n".join(new_lines)
new_cfg = cfg_content[:m.start(2)] + new_body + cfg_content[m.end(2):]
with open(config_path, "w", encoding="utf-8") as f:
f.write(new_cfg)
print("🛡️ [SENTINEL] astro.config.mjs 内のリダイレクト重複を自動修復しました。")
except Exception as e:
print(f"⚠️ [SENTINEL] astro.config.mjs 検査エラー: {e}")
実測検証:1075ページのビルドが50秒で完走
このセンチネルスクリプトを package.json のビルドパイプライン直前に挟んだ結果、全1035記事・1075ページの生成処理が、わずか50.21秒でスタック0秒の完全完走を果たすようになりました。
{
"scripts": {
"prebuild": "python3 scripts/audit_and_fix_quality.py",
"build": "npm run prebuild && astro build"
}
}
astro buildエラー・停止に関するよくある質問(FAQ)
まとめ:インフラとOSカーネルを理解してビルドを最速化する
モダンな静的サイトジェネレーター(SSG)がどれほど進化しようとも、最終的に実行されるのはハードウェアとOSカーネルの上です。
「Astroが遅い」「Viteがバグった」と表層の不満で片付けるのではなく、システムコールの挙動やファイルシステムの特性(inodeとハードリンクの限界値)を理解することこそが、予期せぬトラブルから最速で脱出する唯一の武器になります。
Astroのビルドが謎の停止に見舞われた際は、ぜひ本記事のポインタ付け替えハック(mv)と自動防衛Sentinelを活用してください。
※本検証の泥臭い深夜のデバッグ格闘記・リアルタイムドキュメントは、草壁シトヒ公式noteの解説エッセイ でも公開しています。

