Skip to content

WSL2 環境での crawl 不安定(ハング・CPU 張り付き・失敗ページ増加)の切り分け #294

Description

@YusukeHirao

症状

Windows (WSL2) 上で crawl 実行中に以下が発生する。なお macOS では同条件(下記のストレステスト)を試していないため「WSL2 でのみ発生する」とは未確定 — macOS でも不安定化する可能性はある。ただし下記仮説の一部(A・C・D)は WSL2 固有の機構に由来するため、WSL2 で発生確率・深刻度が上がる方向には働く。

  • 途中でハングし進捗が出なくなる
  • CPU 張り付き・処理低速化
  • エラーで失敗するページの増加

再現時の条件: 2 アーカイブの crawl を同時実行(ストレステスト。各セッション並列 10 = Chromium 相当 20 プロセスが同時起動している状態)。

これまでに判明していること(コード側の調査済み事項)

  • crawl はシングル Node プロセス構成: @d-zero/dealer は in-process の promise プール、@d-zero/beholder はメインスレッド直呼び。プロセスとして分離しているのは URL ごとに使い捨てる Chromium だけ(1 URL = 1 puppeteer.launch()crawler/src/crawler/crawler.ts_launchBrowserAndScrape
  • メインスレッドの同期 CPU 負荷要因だった computeBodyHash の二重計算は fix(crawler): compute page body hash once per page instead of twice #293 で解消済み(適用後の Windows 環境での再計測は未実施)
  • イベントリスナーのリーク経路は調査済みで見つからず(CrawlerOrchestrator.crawling() のリスナー登録はセッション毎に 1 回、Scraper / Page / browser はページ毎に使い捨て)
  • 書き込みキャッシュ WriteRefCaches はセッション生涯保持だが、大半は distinct URL 数に比例(24 万 URL の実績あり)。例外は consoleLogIds で、これは「ページが吐く distinct なコンソール内容の多様性」に比例する(タイムスタンプ入りエラーを吐き続けるサイトでは URL 数と無関係に成長する)
  • puppeteer.launch({ headless: true })サンドボックス関連の引数を一切渡していないcrawler.ts)。かつ CLI から Chromium 起動引数や executablePath を渡す手段が現状ないCrawlerOrchestrator のコア API にのみ executablePath がある)

仮説(優先度順)

A. Chromium Linux サンドボックスと WSL2 カーネルの相互作用

Chromium のサンドボックスは Linux の user namespaces / seccomp に依存し、WSL2/Ubuntu 環境では AppArmor ポリシーや root 実行との組み合わせで launch 失敗・ハングが既知(Puppeteer 公式 Troubleshootingmodelcontextprotocol/servers#819)。20 プロセス同時起動でサンドボックスのセットアップが競合すると、クラッシュではなく「起動待ちのまま返らない」ハング様の症状になり得る。macOS はこのコードパスを通らない(別のサンドボックス機構)ため、この仮説が主因なら macOS では同条件でも再現しないはず — 逆に macOS でも同条件で再現するならこの仮説の優先度は下がる(切り分けに使える)。

B. WSL2 VM のメモリ上限(vmmem)と Linux OOM killer / スラッシング

WSL2 は軽量 VM で、.wslconfig 未設定時はホスト RAM の一部しか割り当てられない。Chromium 20 台(ヘッドレスでも内部的に各数プロセス)+ Node ×2 で上限に達すると、OOM killer がプロセスを黙って kill するか、swap スラッシングで見かけ上ハングする。

C. WSL2 NAT の ephemeral port 枯渇

WSL2 の NAT ネットワークには大量同時接続でポートが枯渇し再起動まで回復しない既知問題がある(microsoft/WSL#3311microsoft/WSL#2913)。さらに Ubuntu 24.04 では ephemeral port range が 44620–48715 の 4,096 個しかないという報告もある(microsoft/WSL#13696)。ブラウザ 20 台のサブリソース同時フェッチはこの経路を強く叩く。「失敗ページの増加」と「途中から進まなくなる」の両方を説明できる。

D. 作業ディレクトリが /mnt/c(drvfs/9p)にある場合の I/O 劣化

/mnt/* は 9p プロトコル経由で、メタデータ操作が Linux ネイティブ(ext4)比で最大 2 桁遅い(microsoft/WSL#4197)。.nitpicker(SQLite)の書き込み先が /mnt/c 配下だと、WriteQueue の消化がスクレイプ速度に追いつかず滞留し、ハングに見える。

現状のデバッグ手段とギャップ(計装を先に足す選択肢)

現状 crawl の診断手段は次の 2 つしかない:

  • DEBUG=Nitpicker:* のテキストログ(Nitpicker:Archive:DB / Nitpicker:Utils:Crawler 等の名前空間。イベント発生時に流れるだけ)
  • 事後の query error-kinds / crawl_errors(アーカイブに書けたものだけ)

どちらも「ハングしたその瞬間に、プロセス内部で何が詰まっているか」を出せない。手順 2 の OS 側データで絞り込めない場合——あるいは再現試行の往復コストを考えると最初から——先に以下の計装を追加してから再現テストに入るほうが早い可能性が高い。各項目は仮説 A–D と直接対応する:

計装 内容 対応仮説
定期メトリクスライン 30 秒毎に stderr へ 1 行: RSS / heapUsed、イベントループ遅延(perf_hooks.monitorEventLoopDelay の p95)、in-flight URL 数、WriteQueue.pending(getter は実装済み・未露出)、LinkList サイズ、WriteRefCaches 各 Map のサイズ B・D・メモリ肥大仮説全般
ブラウザ起動時間の記録 Launching browserCreating page の所要時間を per-URL で記録。サンドボックス起因のストールはここに直接現れる A
ストールウォッチドッグ 「N 分間 1 ページも完了していない」を検知したら、in-flight 全 URL の現在フェーズ(beholder の changePhase は既にイベントとして飛んでいる)と経過時間、上記メトリクスを自動ダンプ A–D 共通
オンデマンドダンプ SIGUSR2 受信で同じダンプを即時出力(ハングしてから kill -USR2 <pid> で回収できる) A–D 共通
エラー種別のリアルタイム集計 classify-error-kind の結果を種別ごとに定期ラインへ含める(timeout / reset / dns の増加傾向がその場で見える) C

実装の当たり: 定期ライン+ウォッチドッグは CrawlerOrchestrator にオプトイン(--diagnostics フラグまたは NITPICKER_DIAGNOSTICS=1)で足すのが自然。in-flight フェーズの追跡は changePhase イベントの最終値を URL キーで持つだけで済む。

切り分け手順(Windows 環境での作業)

0. 環境情報の採取(再現前に 1 回)

wsl.exe --version          # (PowerShell 側)
uname -a                   # WSL 内
cat /etc/os-release
cat /proc/sys/net/ipv4/ip_local_port_range   # C 判定の事前値
ls %UserProfile%\.wslconfig                  # (PowerShell 側) 有無と内容
pwd                        # 作業ディレクトリが ~/ (ext4) か /mnt/c か → D 判定

1. 再現実行(ログを必ず保存する)

DEBUG=Nitpicker:* npx @nitpicker/cli crawl <URL> 2>&1 | tee crawl-debug-1.log
DEBUG=Nitpicker:* npx @nitpicker/cli crawl <URL2> 2>&1 | tee crawl-debug-2.log   # 別ターミナルで同時

2. ハング発生時に取るデータ(この順で)

# プロセス状態: chrome の数と STAT 列(D = I/O 待ちで固まっている / Z = ゾンビ)
ps aux | grep -c chrome
ps -eo pid,stat,rss,etime,comm | grep -E "chrome|node"

# メモリ: OOM killer が発動していないか(最重要)
sudo dmesg -T | grep -iE "oom|killed process"
free -h

# ネットワーク: ポート枯渇していないか
ss -s
cat /proc/sys/net/ipv4/ip_local_port_range

# リソース圧迫の内訳(CPU / メモリ / I/O のどれが詰まっているか)
cat /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io

Windows 側ではタスクマネージャで vmmem(または Vmmem WSL)のメモリ・CPU を確認。

3. 判定基準

観測 濃厚な仮説
dmesg に Out of memory: Killed process B(.wslconfigmemory= を増やして再検証)
ss -s の TCP 数がポートレンジ幅に張り付く / 新規接続だけ失敗 C(レンジ拡張 sysctl net.ipv4.ip_local_port_range で再検証)
chrome プロセスが STAT=D で多数滞留、PSI の io が高い D(作業ディレクトリを ~/ 配下へ移して再検証)
chrome が起動直後のまま増えも減りもしない、ログが Launching browser で止まる A(下記の暫定パッチで --no-sandbox を試す)

4. 仮説 A の検証には一時的なコード変更が必要

CLI から Chromium 引数を渡す手段がないため、検証時は crawler/src/crawler/crawler.tspuppeteer.launch({ headless: true, ... }) に一時的に args: ['--no-sandbox'] を足してビルドして試す(診断専用。恒久化する場合はセキュリティ上の是非を別途議論)。切り分けの結果 A が主因なら、環境変数または CLI フラグでの opt-in を検討する。

5. 事後分析(ハングに至らなかった run も)

生成済みアーカイブに対して:

npx @nitpicker/cli query <file> error-kinds   # エラー種別の分布(dns / timeout / reset の偏り)

network-related 系エラーが特定時間帯に集中していれば C/B、ランダムに分散していればサイト側要因の可能性が高い。

期待する成果

  1. (必要と判断したら)診断計装の追加 — 上記表の全部でなくてよい。定期メトリクスライン+ウォッチドッグだけでも仮説の大半が観測可能になる
  2. 上記 A–D のどれが(あるいは複数が)主因かの特定
  3. 判明した仮説に応じた対処の提案(.wslconfig 推奨値のドキュメント化 / --no-sandbox opt-in フラグ / 作業ディレクトリ警告 など)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions