VS Code Dev Container IO 過高: executeInWSL 設定の詳細と根本原因分析

Windows 上で VS Code Dev Container 拡張が引き起こす IO 過高問題の完全な調査プロセスを記録し、現象の特定から根本原因分析、最終的に dev.containers.executeInWSL を中心とした Docker CLI の境界横断通信ボトルネック解決策を提示します。

Windows 上で VS Code の Dev Container 拡張を使用してコンテナ開発を行う際、一部のユーザーはシステムが著しくカクつく現象に遭遇します。タスクマネージャーでは Extension Host プロセスの CPU とディスク読み取り IO が継続的に高く、操作を行っていなくても低下しません。本稿では、現象の把握から根本原因の特定、そして核心的な解決策に至るまでの完全な調査プロセスを記録します。

問題の現象

Dev Container 拡張でコンテナに接続した後にシステムがカクつきます。タスクマネージャーで Extension Host プロセスのディスク読み取り IO と CPU 使用率が常に高く、ユーザーが編集やターミナル操作を行っていなくても指標がアイドル状態に戻りません。極端な場合、Windows デスクトップ全体の応答速度が低下し、マウスカーソルが断続的に止まります。

調査プロセス

Process Monitor で IO の発生元を特定

Sysinternals Process Monitor はこの種の問題を調査する第一歩です。procmon を起動し、フィルタ条件を Process Name is Code.exe または Process Name is Extension Host に設定すると、すべての ReadFile/WriteFile 操作のパスと頻度をリアルタイムで観察できます。フィルタ結果の中で、\\pipe\ で始まる named pipe の操作が異常に高頻度で発生しており、1 秒あたり数十回に達します。これらの named pipe 操作は Docker CLI と Docker daemon 間の通信に対応しており、Extension Host が頻繁に Docker CLI を呼び出していることが分かります。

VS Code 内蔵ツールで確認

Help > Toggle Developer Tools で Chromium DevTools を開き、Performance パネルで CPU プロファイルを記録すると、Extension Host の時間消費が子プロセスの spawn と stdout パイプの読み取りに集中していることが分かります。Output パネルの “Dev Containers” ログレベルを “trace” に設定すると、完全なコマンド呼び出しシーケンスが表示されます:docker inspect --type containerdocker version --formatdocker execdocker ps などが繰り返し実行されています。issue #9194 のログデータから、単一呼び出しのコストは docker inspect --type container が約 1800 ms、docker version が約 620 ms と測定できます。

Windows Performance Analyzer でシステムレベル IO を分析

さらに深く分析するために wpr.exe -start GeneralProfile -filemode で ETW 録画を開始し、問題を再現した後に wpr.exe -stop capture.etl で停止します。Windows Performance Analyzer で結果を読み込むと、Disk I/O ビューで Extension Host がディスク読み取り IO の主要な貢献者であることが確認でき、Process Life Cycle ビューでは多数の短命子プロセスが繰り返し生成・破棄されていることが分かります。これらの子プロセスは Docker CLI の呼び出しインスタンスです。

根本原因分析

Dev Container のプロセス通信アーキテクチャ

調査結果は Dev Container の多プロセス通信アーキテクチャを指し示しています。Windows 上で Dev Container 拡張がコンテナワークスペースを開くと、実際には Windows と Linux の境界を越える多プロセス通信チェーンが起動します。

flowchart LR
    subgraph Windows["Windows ホスト"]
        A["VS Code Client<br/>(Electron)"]
        B["Extension Host<br/>(Node.js)"]
        C["Docker CLI<br/>(子プロセス)"]
    end

    subgraph WSL2["WSL2 / Docker Desktop"]
        D["Docker Daemon<br/>(dockerd)"]
    end

    subgraph Container["コンテナ内部"]
        E["VS Code Server"]
        F["Remote Extension Host"]
        G["ポート転送プロセス<br/>(Node.js)"]
    end

    A e1@--> B
    B e2@--> C
    C e3@--> D
    B e4@--> E
    E e5@--> F
    F e6@--> G

    classDef win fill:#E3F2FD,stroke:#1565C0,stroke-width:1px,color:#0D47A1;
    classDef wsl fill:#FFF8E1,stroke:#EF6C00,stroke-width:1px,color:#E65100;
    classDef ctr fill:#E8F5E9,stroke:#2E7D32,stroke-width:1px,color:#1B5E20;
    classDef animate stroke:#EF6C00,stroke-width:2px,stroke-dasharray: 9\,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;
    class A,B,C win;
    class D wsl;
    class E,F,G ctr;
    class e1,e2,e3,e4,e5,e6 animate;

ローカルの VS Code クライアント (Electron) は IPC でローカルの Extension Host (Node.js) と通信します。Extension Host はコンテナ内の VS Code Server との接続を確立する必要があり、この接続は Docker CLI を仲介に依存しています。コンテナ内の VS Code Server が起動すると、Remote Extension Host が拡張実行を引き継ぎ、ポート転送プロセスがローカルブラウザからコンテナサービスへの TCP トンネルを提供します。このチェーンの各段階が IO 増幅の潜在的な原因となります。

Docker CLI のポーリングメカニズム

Dev Containers 拡張は Docker CLI コマンドを頻繁に呼び出してコンテナ状態を取得・維持します。コンテナ起動時、拡張は順に docker inspect --type container(メタデータ取得)、docker version --format(daemon の可用性確認)、docker exec(コンテナ内環境検出コマンド実行)、docker ps(実行中コンテナ一覧取得)を実行します。これらのコマンドは起動時だけでなく、コンテナのライフサイクル全体で一定頻度で繰り返し呼び出されます。

各 Docker CLI 呼び出しは新しい子プロセスを生成し、プロセス生成コスト、stdout パイプ読み取り、JSON 結果解析など一連の IO 操作が発生します。デフォルトではこれらのデータは Windows/WSL 境界を越えて転送され、WSL2 の 9P ファイル共有プロトコルは多数の小ファイル IO と高頻度の短接続に対して性能が低下します。Microsoft 公式ドキュメント でも、ファイルシステム間の操作はできるだけ回避すべきと推奨されていますが、Dev Container の設計上完全に回避することは困難です。

sequenceDiagram
    participant EH as Extension Host
    participant CLI as Docker CLI
    participant DD as Docker Daemon

    EH->>CLI: spawn docker inspect
    CLI->>DD: named pipe リクエスト
    DD-->>CLI: JSON 応答
    CLI-->>EH: stdout パイプ出力
    EH->>CLI: spawn docker version
    CLI->>DD: named pipe リクエスト
    DD-->>CLI: バージョン情報
    CLI-->>EH: stdout パイプ出力
    EH->>CLI: spawn docker exec
    CLI->>DD: named pipe リクエスト
    DD-->>CLI: 実行結果
    CLI-->>EH: stdout パイプ出力

ポート転送接続のリーク

VS Code のポート転送機構は各転送ポートごとに独立した Node.js 子プロセスを作成します。これらのプロセスは net.createConnection でコンテナ内の対象ポートに接続し、ローカルポートとコンテナポート間で双方向にデータを転送します。問題は、ブラウザやクライアントが転送ポートにアクセスした後に切断された場合、クリーンアップロジックが遅れると、これらの転送プロセスが終了せずに残り続けることです。

microsoft/vscode-remote-release#5767 の分析によると、リークしたポート転送プロセスは約 26 MiB のメモリを消費します。複数の転送ポートを設定し頻繁にアクセスする開発環境では、プロセス数が数秒で 2 から数十に増加します。以下のコードスニペットはポート転送プロセスの核心パターンを示し、client.on('close') イベント処理がプロセスの正常終了に重要です。

const net = require('net');
process.stdin.pause();
const client = net.createConnection({ port: 36187 }, () => {
    client.pipe(process.stdout);
    process.stdin.pipe(client);
});
client.on('close', function (hadError) {
    console.error(hadError ? 'Remote close with error' : 'Remote close');
    process.exit(hadError ? 1 : 0);
});
client.on('error', function (err) {
    process.stderr.write(err && (err.stack || err.message) || String(err));
});

Docker CLI の docker exec プロセスが異常終了し、コンテナ内の Node.js プロセスが残存すると、これらの孤児プロセスが回収されずメモリが増大します。この問題は VS Code 1.62 以降で部分的に修正されていますが、特定のネットワーク条件下では再現する可能性があります。なお、ポート転送リークは executeInWSL とは直接関係せず、VS Code のポート転送機構自体のバグです。

Extension Host の再接続ループ

microsoft/vscode-remote-release#6178 の記録によると、リモートコンテナへの接続がネットワーク障害等で失われた際、Extension Host の再接続ロジックにバグがあり、catch ブロック内で自身を再帰的に呼び出す async 関数が CPU を空転させます。スタックトレースはこの関数が processTicksAndRejections 内で無限ループしていることを示しています。

flowchart TD
    A["接続喪失"] e1@--> B["async 再接続関数"]
    B e2@--> C{"接続成功?"}
    C e3@-->|はい| D["正常復帰"]
    C e4@-->|いいえ| E["catch ブロック"]
    E e5@--> B

    classDef start fill:#FFEBEE,stroke:#C62828,stroke-width:1px,color:#B71C1C;
    classDef work fill:#E8F5E9,stroke:#2E7D32,stroke-width:1px,color:#1B5E20;
    classDef check fill:#FFF8E1,stroke:#EF6C00,stroke-width:1px,color:#E65100;
    classDef animate stroke:#EF6C00,stroke-width:2px,stroke-dasharray: 9\,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;
    class A start;
    class B,D work;
    class C,E check;
    class e1,e2,e3,e4,e5 animate;

同時に Extension Host のメモリは約 1 MB/分の速度で増加し続けます。これは再帰呼び出しごとに解放されないコンテキストがスタック上に蓄積されるためです。この問題は Remote-Containers 0.221.0‑pre‑release で修正されましたが、拡張を更新していないユーザーはネットワーク切断(例:Wi‑Fi オフ)をシミュレートするだけで再現し、切断時のダイアログが表示されません。これは executeInWSL 設定とは無関係な独立したソフトウェアバグです。

WSL2 のファイルシステム横断 IO 拡大

Windows 上で Docker Desktop の WSL2 バックエンドを使用する場合、固有のファイルシステム横断 IO 性能問題が存在します。Windows 側の VS Code 拡張は pipe で WSL2 の Docker daemon と通信し、コンテナ内で /mnt/c パス(Windows ディスクへのアクセス)を使用すると 9P ファイル共有プロトコルを経由します。9P は多数の小ファイル IO と高頻度短接続に対してネイティブファイルシステムに比べて 3‑5 倍の遅延が発生します。

さらに microsoft/vscode-remote-release#9372 の報告によると、ARM Mac で Rosetta 経由で x86 コンテナを実行すると VS Code Server の CPU アフィニティマスクが単一コアに固定され、nproc が実際の物理コア数ではなく 1 を返します。この問題は macOS に特化していますが、Dev Container がクロスプラットフォームでプロセススケジューリングパラメータを一貫して制御できていないことを示しています。Windows の WSL2 環境でも同様の形で影響が出る可能性があります。executeInWSL: true は Docker CLI の実行位置を WSL 内部に移すことで、ファイルシステム横断 IO の頻度を減らしますが、/mnt/c パスへのアクセス時の 9P コストを完全に排除することはできません。

解決策

ポート転送設定の簡素化

devcontainer.jsonforwardPorts 設定を必要最小限に絞ることで、ポート転送プロセスの数を大幅に減らし、issue #5767 で指摘されたプロセスリークリスクを低減できます。使用していない Dev Container ウィンドウを閉じることでも、対応する Extension Host とポート転送リソースが即座に解放されます。

Docker Desktop の最適化

Docker Desktop の Settings > Resources パネルで CPU とメモリ割り当てを適切に調整し、Docker daemon がリソース不足で頻繁にガベージコレクションやスワップを行わないようにします。最新バージョンの Docker Desktop を使用することも重要です。WSL2 バックエンドの性能はバージョンごとに改善されているため、パフォーマンスが重要なシナリオでは、WSL2 内部に直接 Docker Engine をインストールし、Docker Desktop の仮想化層を迂回することも検討してください。

VS Code の設定最適化

不要な拡張機能を無効化して Extension Host の負荷を軽減します。特にリモートコンテナ内で動作しファイル監視を行う拡張(例:TypeScript 言語サービス、ESLint 等)は注意が必要です。settings.jsonfiles.watcherExcludenode_modules.gitdist など大容量ディレクトリを除外すれば、ファイルシステム監視による IO を削減できます。extensions.autoUpdate: false に設定すれば、バックグラウンドでの拡張自動更新がコンテナ環境で余計なネットワーク・ディスク操作を引き起こすことを防げます。

代替案

上記対策でも性能要件を満たせない場合、VS Code Remote‑SSH 拡張で WSL2 に直接接続し、WSL2 内部で Docker CLI を使用してコンテナを管理する方法があります。この方法では Docker CLI の呼び出しが Windows/WSL の境界横断通信ではなく、WSL2 内部のローカル通信になります。別の選択肢として Docker Compose でコンテナライフサイクルを管理し、docker compose up -d でサービスを起動した後に Remote‑SSH でコンテナに接続して開発することで、Dev Container 拡張のポーリングメカニズムを完全に回避できます。

executeInWSL の有効化(核心策)

これらの対策は IO 過高問題をある程度緩和しますが、根本原因は Docker CLI が Windows/WSL 境界を跨いで named pipe 通信を行うことにあります。dev.containers.executeInWSL はこの根本原因に直接対処する設定です。

microsoft/vscode-remote-release#9194 の VS Code チームメンバー chrmarti の明言によれば、この設定は docker コマンドが Windows 側で実行されるか WSL 内部で実行されるかを決定します。true に設定すると、すべての Docker CLI 呼び出し(docker inspectdocker versiondocker execdocker ps など)が WSL 内部で実行され、Unix socket で Docker daemon と直接通信するため、Windows/WSL の named pipe と 9P 変換のオーバーヘッドを回避できます。

settings.json に以下を追加すれば有効化できます:

{
  "dev.containers.executeInWSL": true
}

この設定が IO 性能を大幅に改善できる理由は、通信パスの違いを比較できるからです。デフォルトモード(executeInWSL: false または未設定)では、Extension Host が Windows 上で動作し、Docker daemon とやり取りするたびに Windows 上の docker.exe 子プロセスを spawn します。子プロセスは named pipe(\\pipe\)を介して WSL2 の Docker daemon と通信し、プロセス生成、named pipe 境界横断 IO、stdout パイプの三段階で遅延と IO コストが発生します。executeInWSL: true にすると、Extension Host は wsl -d <distro> -e docker を使って WSL 内部で Docker CLI を実行します。Docker CLI は WSL 内部でネイティブに動作し、Unix socket(ローカル IPC)で同一 WSL インスタンス内の Docker daemon と通信します。このパスは Linux カーネル空間内で完結し、named pipe の境界横断コストが完全に排除されます。

flowchart TB
    subgraph Default["デフォルトモード (executeInWSL: false)"]
        direction LR
        A1["Extension Host<br/>(Windows)"]
        A2["docker.exe<br/>(Windows 子プロセス)"]
        A3["named pipe<br/>(\\\\pipe\\\\)"]
        A4["Docker Daemon<br/>(WSL2)"]
        A1 e1@--> A2
        A2 e2@--> A3
        A3 e3@--> A4
    end

    subgraph Optimized["最適化モード (executeInWSL: true)"]
        direction LR
        B1["Extension Host<br/>(Windows)"]
        B2["wsl -e docker<br/>(WSL 内部)"]
        B3["Unix socket<br/>(ローカル IPC)"]
        B4["Docker Daemon<br/>(WSL2)"]
        B1 e4@--> B2
        B2 e5@--> B3
        B3 e6@--> B4
    end

    Default ~~~ Optimized

    classDef win fill:#E3F2FD,stroke:#1565C0,stroke-width:1px,color:#0D47A1;
    classDef wsl fill:#FFF8E1,stroke:#EF6C00,stroke-width:1px,color:#E65100;
    classDef fast fill:#E8F5E9,stroke:#2E7D32,stroke-width:1px,color:#1B5E20;
    classDef animate stroke:#EF6C00,stroke-width:2px,stroke-dasharray: 9\,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;

    class A1,A2 win;
    class A3,A4 wsl;
    class B1 win;
    class B2,B3,B4 fast;
    class e1,e2,e3,e4,e5,e6 animate;

調査段階で取得した定量データはこの改善を裏付けています。デフォルトモードでは単一 docker inspect --type container 呼び出しに約 1800 ms、docker version に約 620 ms がかかりますが、executeInWSL: true にすると同呼び出しはミリ秒単位に短縮され、累積効果が顕著です。

既知の問題と注意点

dev.containers.executeInWSL は IO 性能を大幅に改善しますが、使用時には以下の既知問題に留意してください。

  • Docker Desktop の自動起動問題: issue #9695 では executeInWSL: true 時に Docker Desktop が自動起動しないことが報告されました。Dev Containers 0.353.0‑pre‑release で修正されているため、最新バージョンを使用すれば再発しません。
  • WSL サービス転送: issue #9194 では executeInWSLfalse にしても拡張が WSL(display/ssh‑agent/gpg‑agent 転送)に接続しようとする挙動が指摘され、0.337.0‑pre‑release で dev.containers.wslServiceForwarding 設定により制御可能となりました。
  • Rancher Desktop 互換性: issue #10722 では Docker Desktop の代わりに Rancher Desktop を使用した際、executeInWSL: true が WSL1 エラーを引き起こすことが報告されています。現在はオープン状態で、Rancher Desktop ユーザーはこの設定を一時的に無効にする必要があります。
  • 意図しない有効化: issue #11005 では executeInWSL: true がローカル Windows リポジトリで Dev Container 初期化プロセスを誤ってトリガーするケースが報告されています。影響を受けるユーザーはグローバル設定ではなく、対象ワークスペース単位で設定することを検討してください。

まとめ

Windows 上の VS Code Dev Container における IO 過高問題は、現象の把握から根本原因の特定、そして最も効果的な解決策の実装まで段階的にアプローチする必要があります。Process Monitor により IO の主原因が Docker CLI の named pipe 通信であることを確認し、VS Code の組み込みツールと Windows Performance Analyzer で呼び出し頻度と遅延を定量化しました。根本原因は四つの要因が重なり合っていることです:Docker CLI の高頻度跨境ポーリングが最大のボトルネック、ポート転送プロセスのリークと Extension Host の再接続バグがソフトウェア的な欠陥、WSL2 のファイルシステム横断 IO がプラットフォーム固有の制限です。

解決策としては、dev.containers.executeInWSL: true が最も核心的な手段であり、Docker CLI の Windows/WSL 境界を跨ぐ named pipe コミュニケーションコストを完全に排除します。その他の対策(ポート転送の簡素化、Docker Desktop の最適化、VS Code 設定の調整)は補助的に作用し、各要因による影響を緩和します。問題に直面しているユーザーは、本稿の調査フローで根本原因を確認した上で、まず executeInWSL: true を有効化し、必要に応じて補助的な最適化策を組み合わせることを推奨します。