Windows でしか再現しないバグは CI では見つけにくい、というのは開発者あるあるだと思います。特に「Windows のファイルシステム由来の何かが Linux コンテナに bind mount されたときに壊れる」タイプのバグは、ubuntu-latest の runner ではそもそも土俵に立てません。
先日 MulmoClaude で踏んだ #1946 / #1982 がまさにこれで、Windows 上の yarn workspace が node_modules/@mulmoclaude/* に絶対パスの NTFS junction を作り、それを Linux Docker コンテナに bind mount すると Windows パス (C:\Users\...) を指す dangling symlink になる という現象でした。ユーザから 2 度目のバグレポートが来て、そろそろ CI で捕まえたい、となったのが今回の話です。
結論から言うと、runs-on: windows-latest の runner で WSL2 + native docker.io (Docker Engine) を立てるのが実用解でした。以下、その過程で他の手段がなぜダメだったかも含めて共有します。
windows-latest は Linux コンテナを動かせない (デフォルトは)
GitHub-hosted windows-latest runner には docker CLI が入っているので、素朴に docker run ubuntu:24.04 と書きたくなります。が、動きません:
docker: Error response from daemon: no matching manifest for windows/amd64
runner 上の Docker Engine は Windows コンテナ専用モード で、Linux イメージは pull できない、というのが理由です。
Docker Desktop を choco install しても headless ではダメ
次に試したのが Chocolatey で Docker Desktop を入れる作戦。install 自体は成功しますが、daemon 起動でつまづきます:
failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine
Docker Desktop は 初回起動時にライセンス同意ダイアログや welcome 画面が出る GUI アプリで、CI の headless 実行ではダイアログを閉じる術がないため daemon が Listen するまで進みません。--accept-license みたいな CLI flag も 2026-07 時点で提供されていないので、この線は諦めることに。
答え: WSL2 + native docker.io
windows-latest runner には WSL2 がプリインストール済み で、Ubuntu ディストロだけ入れれば apt install docker.io で Docker Engine を素で入れられます。GUI もライセンス同意ダイアログもなし。以下が実際に workflow に書いた load-bearing なステップです:
- name: Set up WSL2 (Ubuntu-22.04)
shell: pwsh
run: |
wsl --version
$distros = @()
try { $distros = wsl --list --quiet 2>$null } catch { }
if (-not ($distros | Where-Object { $_.Trim() -eq "Ubuntu-22.04" })) {
wsl --install -d Ubuntu-22.04 --no-launch
Start-Sleep -Seconds 15
}
wsl -d Ubuntu-22.04 -u root -- uname -a
- name: Install docker.io inside WSL2
shell: pwsh
run: |
wsl -d Ubuntu-22.04 -u root -- bash -c \
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io"
- name: Start dockerd in the background inside WSL2
shell: pwsh
run: |
# nohup + disown so dockerd survives the wsl -e exit.
# --iptables=false because WSL2's kernel lacks iptables_nat.
wsl -d Ubuntu-22.04 -u root -- bash -c \
"nohup dockerd --iptables=false --bridge=none > /var/log/dockerd.log 2>&1 &"
for ($i = 0; $i -lt 30; $i++) {
$null = wsl -d Ubuntu-22.04 -u root -- docker info 2>&1
if ($LASTEXITCODE -eq 0) { break }
Start-Sleep -Seconds 2
}
コンテナを立てるときは Windows FS のパス (C:\a\<repo>\<repo>) を WSL2 の /mnt/c/a/<repo>/<repo> に読み替えて bind mount します:
- name: Run docker with Windows FS bind mounts
shell: pwsh
run: |
$wsWin = "${{ github.workspace }}"
$drive = $wsWin.Substring(0, 1).ToLower()
$rest = $wsWin.Substring(3) -replace '\\', '/'
$wsLinux = "/mnt/$drive/$rest"
wsl -d Ubuntu-22.04 -u root -- docker run --rm `
-v "${wsLinux}/node_modules:/app/node_modules:ro" `
# ... more mounts ... `
-e NODE_PATH=/app/node_modules:/app/pkg_modules `
node:22-slim node /repro/probe.mjs
この構成のポイントは、Windows FS が WSL2 経由で Linux コンテナに渡ると、NTFS junction は「Windows スタイルのパスを指す dangling symlink」として現れる ところ。まさに本番のバグ環境と同じ状態が再現できます。
つまづきポイント
いくつか設定次第で刺さる場所があったのでメモ:
dockerd --iptables=false --bridge=none: WSL2 のカーネルにはiptables_natモジュールが無く、この flag を付けないとdockerdは起動直後に “Failed to Setup IP tables” で落ちます。単発の probe を回すだけならネットワーク機能は不要なので両方 off で問題なし。nohupで背景実行:wsl -e bash -c "cmd &"だとwslの session 終了時に子プロセスが SIGHUP されてdockerdが死にます。nohup+ stdout/stderr のリダイレクトで生かしておく必要あり。- WSL2 のディストロ名は厳密に: 後段で
wsl -d Ubuntu-22.04を hardcode するなら、存在チェックも-eq "Ubuntu-22.04"の完全一致にしないと、Ubuntu(無印) だけがある runner で install がスキップされて後続が全滅する事故が起きます。 - junction の sanity check:
yarnv1 が junction を作る挙動に依存しているので、workflow の最初でGet-Item node_modules/@mulmoclaude/x-plugin | Select-Object LinkTypeを叩いてLinkType == "Junction"を確認。yarn 未来版が hardlink や copy に変えた瞬間バグが「再現しなくなる」ので、そのときに noisy に失敗するようにしておきます。 - timing: 一連 (yarn install + WSL2 install + apt + dockerd + probe) で ~8 分。
scheduleの daily canary +workflow_dispatch手動 trigger だけに絞って、通常 PR には走らせない運用にしています。resolver ロジック自体は普段の POSIX CI で unit test 済みなので、Windows CI は「上流 (yarn / WSL2 / Docker) の挙動変化」を拾う canary の位置づけ。
使いどころ
Windows 特有の FS 挙動 (NTFS junction / hardlink / long path / case-insensitive name) が Linux コンテナに bind mount された時に何かが起きる、というタイプのバグには効きます。逆に POSIX 上で symlinkSync すれば同じロジックで再現できる類のバグに対しては、素直に ubuntu-latest で unit test を書いた方が速いです。今回もあくまで「本番相当の environmental drift」を捕まえるための canary として、通常 CI とは別レイヤに残しました。
Windows + Docker + CI という組み合わせで詰まっている人の一助になれば。詳細は docs/windows-docker-ci.md と .github/workflows/docker_sandbox_windows.yaml に置いてあります。