VS Code Dev Container wysokie IO: szczegółowa konfiguracja executeInWSL i analiza przyczyn

Kompletny proces diagnozowania problemu wysokiego IO wywołanego przez rozszerzenie VS Code Dev Container w systemie Windows, od identyfikacji objawów po analizę przyczyn, z rozwiązaniem opartym na dev.containers.executeInWSL, które eliminuje wąskie gardło komunikacji Docker CLI między granicami.

W systemie Windows, podczas korzystania z rozszerzenia Dev Container w VS Code do programowania w kontenerach, niektórzy użytkownicy napotykają wyraźne zacięcia systemu. Menedżer zadań pokazuje, że proces Extension Host ma stale podwyższone zużycie CPU i dyskowego IO, które nie spada nawet przy braku aktywności. Ten artykuł opisuje pełny proces diagnozowania, począwszy od objawów, poprzez identyfikację przyczyn, aż po kluczowe rozwiązanie.

Objawy problemu

Zacięcia systemu pojawiają się po połączeniu rozszerzenia Dev Container z kontenerem. W Menedżerze zadań widać, że proces Extension Host ma ciągle podwyższone odczyty dysku i zużycie CPU, nawet gdy użytkownik nie edytuje ani nie używa terminala. W skrajnych przypadkach cała responsywność pulpitu Windows spada, a kursor myszy zaczyna przerywanie się poruszać.

Proces diagnozowania

Użycie Process Monitor do zlokalizowania źródła IO

Sysinternals Process Monitor jest pierwszym narzędziem w tego typu analizie. Po uruchomieniu procmon, ustaw filtr Process Name is Code.exe lub Process Name is Extension Host, aby obserwować wszystkie operacje ReadFile/WriteFile, ich ścieżki i częstotliwość. W wynikach filtracji operacje na ścieżkach zaczynających się od \\pipe\ (named pipe) pojawiają się niezwykle często – dziesiątki razy na sekundę. Te operacje odpowiadają komunikacji między Docker CLI a Docker daemon, co wskazuje, że Extension Host intensywnie wywołuje Docker CLI.

Potwierdzenie przy użyciu wbudowanych narzędzi VS Code

Poprzez Help > Toggle Developer Tools otwieramy Chromium DevTools i w zakładce Performance nagrywamy profil CPU. Widać, że Extension Host spędza dużo czasu na tworzeniu podprocesów i odczycie ich strumieni stdout. Ustawiając poziom logowania „Dev Containers” na „trace”, można zobaczyć pełną sekwencję wywołań: docker inspect --type container, docker version --format, docker exec, docker ps i inne, które są powtarzane. Z danych z issue #9194 wynika, że pojedyncze wywołanie docker inspect --type container trwa około 1800 ms, a docker version około 620 ms.

Analiza systemowego IO przy użyciu Windows Performance Analyzer

Dla głębszej analizy używamy wpr.exe -start GeneralProfile -filemode, odtwarzamy problem, a następnie wpr.exe -stop capture.etl. Ładujemy wynik w Windows Performance Analyzer. Widok Disk I/O potwierdza, że Extension Host jest głównym źródłem odczytów dysku, a widok Process Life Cycle pokazuje liczne krótkotrwałe podprocesy, które są stale tworzone i niszczone – są to wywołania Docker CLI.

Analiza przyczyn

Architektura komunikacji procesów Dev Container

Wyniki wskazują na architekturę wieloprocesową Dev Container. Gdy użytkownik otwiera przestrzeń roboczą w kontenerze z Windows, uruchamiana jest łańcuchowa komunikacja wieloprocesowa przekraczająca granicę Windows‑Linux.

flowchart LR
    subgraph Windows["Maszyna hosta Windows"]
        A["Klient VS Code<br/>(Electron)"]
        B["Extension Host<br/>(Node.js)"]
        C["Docker CLI<br/>(podproces)"]
    end

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

    subgraph Container["Wewnątrz kontenera"]
        E["VS Code Server"]
        F["Remote Extension Host"]
        G["Proces przekierowania portów<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;

Lokalny klient VS Code (Electron) komunikuje się z lokalnym Extension Host (Node.js) przez IPC. Extension Host musi połączyć się z VS Code Server wewnątrz kontenera, a to połączenie zależy od Docker CLI jako pośrednika. Po uruchomieniu VS Code Server, Remote Extension Host przejmuje wykonywanie rozszerzeń, a proces przekierowania portów zapewnia tunel TCP dla przeglądarki do usług kontenera. Każdy z tych elementów może stać się źródłem zwiększonego IO.

Mechanizm pollingu Docker CLI

Rozszerzenie Dev Containers często wywołuje polecenia Docker CLI, aby uzyskać i utrzymać stan kontenera. Podczas uruchamiania kontenera rozszerzenie kolejno wykonuje docker inspect --type container, docker version --format, docker exec (do wykrywania środowiska) oraz docker ps. Te polecenia nie są wywoływane tylko raz, lecz powtarzane w regularnych odstępach przez cały cykl życia kontenera.

Każde wywołanie Docker CLI uruchamia nowy podproces, co generuje koszty tworzenia procesu, odczytu strumienia stdout i parsowania JSON. W trybie domyślnym wszystkie te operacje muszą przekraczać granicę Windows/WSL, a protokół 9P używany przez WSL2 do udostępniania plików ma słabą wydajność przy dużej liczbie małych operacji IO i krótkich połączeń. Zgodnie z zaleceniami Microsoft Docs, operacje między systemami plików powinny być ograniczane, ale architektura Dev Container utrudnia ich całkowite wyeliminowanie.

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

    EH->>CLI: spawn docker inspect
    CLI->>DD: żądanie named pipe
    DD-->>CLI: odpowiedź JSON
    CLI-->>EH: stdout
    EH->>CLI: spawn docker version
    CLI->>DD: żądanie named pipe
    DD-->>CLI: informacje o wersji
    CLI-->>EH: stdout
    EH->>CLI: spawn docker exec
    CLI->>DD: żądanie named pipe
    DD-->>CLI: wynik wykonania
    CLI-->>EH: stdout

Wycieki procesów przekierowania portów

Mechanizm przekierowywania portów w VS Code tworzy osobny podproces Node.js dla każdego przekierowanego portu. Procesy te łączą się z docelowym portem w kontenerze przy pomocy net.createConnection i dwukierunkowo przesyłają dane. Problem pojawia się, gdy przeglądarka lub inny klient zamyka połączenie, a logika czyszczenia nie usuwa procesu – pozostaje on aktywny.

Z analizy microsoft/vscode-remote-release#5767 wynika, że każdy wyciekający proces zajmuje ok. 26 MiB pamięci. W środowisku z wieloma przekierowaniami i częstym dostępem liczba procesów może wzrosnąć z 2 do kilkudziesięciu w krótkim czasie. Poniższy fragment kodu pokazuje kluczowy wzorzec:

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));
});

Gdy podproces docker exec zakończy się nieprawidłowo, a proces Node.js w kontenerze nadal działa, pozostają „osierocone” procesy, które nie są zwalniane, co prowadzi do stałego wzrostu pamięci. Problem został częściowo naprawiony w VS Code 1.62, ale w specyficznych warunkach sieciowych może się powtórzyć. Nie jest to bezpośrednio powiązane z executeInWSL.

Pętla ponownego łączenia Extension Host

Z microsoft/vscode-remote-release#6178 wynika, że po utracie połączenia z kontenerem (np. przerwanie sieci) logika ponownego łączenia w Extension Host zawiera błąd: asynchroniczna funkcja wywołuje samą siebie w bloku catch, co prowadzi do niekończącej się pętli CPU. Stos wywołań w processTicksAndRejections rośnie bez warunku zakończenia.

flowchart TD
    A["Utrata połączenia"] e1@--> B["asynchroniczna funkcja ponownego łączenia"]
    B e2@--> C{"Połączenie udane?"}
    C e3@-->|Tak| D["Powrót do normalności"]
    C e4@-->|Nie| E["blok 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;

Jednocześnie pamięć Extension Host rośnie o ok. 1 MB/minutę, ponieważ każde rekurencyjne wywołanie pozostawia niezwolnione konteksty na stosie. Problem został naprawiony w wersji Remote‑Containers 0.221.0‑pre‑release, ale użytkownicy nieaktualizujący rozszerzenia mogą go odtworzyć, np. wyłączając Wi‑Fi. Nie jest to związane z executeInWSL, ale pogłębia odczuwalne zacięcia.

Wzrost IO przy przekraczaniu systemów plików w WSL2

Przy użyciu Docker Desktop z backendem WSL2 istnieje wrodzona degradacja wydajności przy operacjach IO między systemami plików. Rozszerzenie VS Code w Windows komunikuje się z demonem Docker w WSL2 przez named pipe; gdy operacje w kontenerze odwołują się do ścieżek /mnt/c (dysk Windows), muszą przejść przez protokół 9P. W scenariuszach z wieloma małymi plikami 9P jest 3‑5 krotnie wolniejszy niż natywne systemy plików. Dodatkowo, z microsoft/vscode-remote-release#9372 wynika, że na ARM Mac przy uruchamianiu x86 kontenerów przez Rosettę, proces VS Code Server ma maskę affinity ustawioną na jeden rdzeń, co ogranicza wydajność. Choć problem dotyczy głównie macOS, pokazuje niejednolitość kontroli parametrów planowania w Dev Container, co może objawiać się także w WSL2. Ustawienie executeInWSL: true przenosi wykonywanie Docker CLI do WSL, zmniejszając częstotliwość operacji przez 9P, ale nie eliminuje ich całkowicie przy dostępie do /mnt/c.

Rozwiązania

Ograniczenie konfiguracji przekierowywania portów

W devcontainer.json usuń niepotrzebne pozycje forwardPorts, pozostawiając tylko niezbędne. Zmniejszy to liczbę procesów przekierowywania i ryzyko wycieków opisanych w issue #5767. Zamknięcie nieużywanych okien Dev Container natychmiast zwalnia zasoby Extension Host i procesy przekierowań.

Optymalizacja Docker Desktop

W ustawieniach Docker Desktop → Resources dostosuj przydział CPU i pamięci, aby uniknąć częstego garbage collection lub swapu w demonie Docker. Korzystaj z najnowszej wersji Docker Desktop – każda aktualizacja poprawia wydajność backendu WSL2. Dla bardzo wymagających scenariuszy rozważ instalację Docker Engine bezpośrednio w WSL2, omijając warstwę wirtualizacji Docker Desktop i dodatkowy narzut komunikacji Windows/WSL.

Optymalizacja konfiguracji VS Code

Wyłącz niepotrzebne rozszerzenia, szczególnie te działające w kontenerze i monitorujące pliki (np. TypeScript language service, ESLint). W settings.json dodaj files.watcherExclude dla node_modules, .git, dist itp., aby ograniczyć IO generowane przez monitorowanie systemu plików. Ustawienie extensions.autoUpdate: false zapobiega automatycznym aktualizacjom rozszerzeń, które mogą wywoływać dodatkowy ruch sieciowy i dyskowy w kontenerze.

Alternatywne podejścia

Jeśli powyższe środki nie wystarczą, rozważ użycie rozszerzenia VS Code Remote‑SSH do połączenia bezpośrednio z WSL2 i zarządzania kontenerami przy pomocy Docker CLI wewnątrz WSL. Dzięki temu wywołania Docker CLI nie muszą przechodzić przez granicę Windows/WSL. Inna opcja to użycie Docker Compose do zarządzania cyklem życia kontenerów (docker compose up -d), a następnie połączenie Remote‑SSH do kontenera – całkowicie omija to mechanizm pollingu Dev Container.

Włączenie executeInWSL (kluczowe rozwiązanie)

Pozostałe metody jedynie częściowo łagodzą wysokie IO; nie rozwiązują podstawowej przyczyny: kosztowna komunikacja named pipe między Docker CLI a Docker daemon przy przekraczaniu granicy Windows/WSL. Ustawienie dev.containers.executeInWSL przenosi wywołania Docker CLI do WSL, eliminując ten narzut.

Jak wyjaśnia członek zespołu VS Code, chrmarti, w microsoft/vscode-remote-release#9194, to ustawienie decyduje, czy polecenie docker jest uruchamiane po stronie Windows, czy wewnątrz WSL. Po ustawieniu na true wszystkie wywołania (docker inspect, docker version, docker exec, docker ps itp.) są wykonywane w WSL i komunikują się z demonem Docker przez Unix socket, omijając named pipe i konwersję 9P.

Aby włączyć, dodaj do settings.json:

{
  "dev.containers.executeInWSL": true
}

Dlaczego to działa? Porównajmy dwie ścieżki komunikacji. W trybie domyślnym (executeInWSL: false lub nieustawione) Extension Host działa w Windows, a każde wywołanie Docker wymaga uruchomienia docker.exe jako podprocesu Windows, który komunikuje się z demonem Docker w WSL2 przez named pipe (\\pipe\). Trasa obejmuje tworzenie procesu Windows, IO przez named pipe oraz odczyt stdout – każdy etap wprowadza opóźnienia. Gdy executeInWSL: true, Extension Host wywołuje wsl -d <distro> -e docker, co uruchamia Docker CLI bezpośrednio w WSL; CLI łączy się z demonem przez Unix socket (lokalne IPC), co eliminuje kosztowną warstwę named pipe.

flowchart TB
    subgraph Default["Tryb domyślny (executeInWSL: false)"]
        direction LR
        A1["Extension Host<br/>(Windows)"]
        A2["docker.exe<br/>(podproces Windows)"]
        A3["named pipe<br/>(\\\\pipe\\\\)"]
        A4["Docker Daemon<br/>(WSL2)"]
        A1 e1@--> A2
        A2 e2@--> A3
        A3 e3@--> A4
    end

    subgraph Optimized["Tryb zoptymalizowany (executeInWSL: true)"]
        direction LR
        B1["Extension Host<br/>(Windows)"]
        B2["wsl -e docker<br/>(wewnątrz WSL)"]
        B3["Unix socket<br/>(lokalne 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;

Dane z fazy diagnozy potwierdzają poprawę: w trybie domyślnym pojedyncze wywołanie docker inspect --type container trwa ~1800 ms, a docker version ~620 ms – opóźnienia pochodzą głównie z komunikacji named pipe i kosztów tworzenia procesów. Po włączeniu executeInWSL: true wywołania skracają się do kilku milisekund, a skumulowany efekt jest znaczący.

Znane problemy i uwagi

  • Automatyczne uruchamianie Docker Desktop – w issue #9695 opisano, że przy executeInWSL: true Docker Desktop nie uruchamia się automatycznie. Problem został naprawiony w Dev Containers 0.353.0‑pre‑release; nowsze wersje nie powinny go wykazywać.
  • Przekazywanie usług WSL – w issue #9194 zauważono, że nawet przy executeInWSL: false rozszerzenie próbuje połączyć się z WSL (dla przekazywania display/ssh‑agent/gpg‑agent). Od wersji 0.337.0‑pre‑release można to wyłączyć przy pomocy nowego ustawienia dev.containers.wslServiceForwarding.
  • Kompatybilność z Rancher Desktop – w issue #10722 zgłoszono, że przy użyciu Rancher Desktop zamiast Docker Desktop executeInWSL: true wywołuje błąd WSL1. Problem jest nadal otwarty; użytkownicy Rancher Desktop mogą potrzebować wyłączyć to ustawienie.
  • Nieoczekiwane uruchomienie – w issue #11005 opisano, że executeInWSL: true może niechcący uruchamiać proces inicjalizacji Dev Container w lokalnym repozytorium Windows. Rozwiązaniem jest ograniczenie tego ustawienia do konkretnego workspace zamiast globalnej konfiguracji.

Podsumowanie

Diagnozowanie wysokiego IO w VS Code Dev Container na Windows wymaga od objawów, przez identyfikację przyczyn, po wdrożenie kluczowego rozwiązania. Process Monitor wskazuje, że głównym źródłem jest komunikacja named pipe Docker CLI, a narzędzia VS Code oraz Windows Performance Analyzer pomagają zmierzyć częstotliwość i opóźnienia wywołań. Analiza ujawnia cztery skumulowane czynniki: intensywny polling Docker CLI, wycieki procesów przekierowywania portów, pętlę ponownego łączenia Extension Host oraz ogólny narzut IO w WSL2.

Rozwiązanie dev.containers.executeInWSL: true eliminuje najważniejszy wąskie gardło – kosztowną komunikację między Windows a WSL – przenosząc wywołania Docker CLI do WSL i używając Unix socket. Dodatkowe środki (ograniczenie portów, optymalizacja Docker Desktop, wyłączenie niepotrzebnych rozszerzeń) wspomagają ogólną wydajność. Użytkownikom zmagającym się z tym problemem zaleca się najpierw zastosować executeInWSL: true, a następnie dobrać pozostałe optymalizacje w zależności od środowiska.