Windows ホスト特有のバグを GitHub Actions で再現する — WSL2 + native dockerd で Linux コンテナを headless に動かす

Windows ホスト特有のバグを GitHub Actions で再現する — WSL2 + native dockerd で Linux コンテナを headless に動かす

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」として現れる ところ。まさに本番のバグ環境と同じ状態が再現できます。

つまづきポイント

いくつか設定次第で刺さる場所があったのでメモ:

使いどころ

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 に置いてあります。

この記事をシェア

関連記事

記事一覧に戻る