VS Code Dev Container IO 과다: executeInWSL 설정 상세 및 근본 원인 분석
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 작업이 비정상적으로 높은 빈도로 나타나며, 초당 수십 회에 달합니다. 이 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 container, docker version --format, docker exec, docker 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 확대
Docker Desktop의 WSL2 백엔드를 Windows에서 사용할 때, 파일 시스템 간 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 affinity mask가 잘못 설정되어 단일 코어만 사용하게 되고, nproc이 실제 물리 코어 수가 아닌 1을 반환합니다. 이 문제는 macOS에 국한되지만, Windows WSL2 환경에서도 유사한 형태로 나타날 수 있음을 시사합니다. executeInWSL: true는 Docker CLI 실행 위치를 WSL 내부로 이동시켜 파일 시스템 간 IO 발생 빈도를 줄이지만, /mnt/c 경로 접근 시 9P 비용을 완전히 없애지는 못합니다.
해결 방안
포트 포워딩 설정 간소화
devcontainer.json에서 forwardPorts 설정을 최소화하고 실제 필요한 포트만 남기면 포트 포워딩 프로세스 수를 크게 줄여 issue #5767에서 언급된 프로세스 누수 위험을 낮출 수 있습니다. 사용하지 않는 Dev Container 창을 닫는 것만으로도 해당 Extension Host와 포트 포워딩 리소스를 즉시 해제할 수 있습니다.
Docker Desktop 최적화
Docker Desktop의 Settings > Resources 패널에서 CPU와 메모리 할당을 적절히 조정해 Docker daemon이 리소스 부족으로 인해 빈번히 가비지 컬렉션이나 스와핑을 수행하지 않도록 합니다. 최신 버전의 Docker Desktop을 사용하면 WSL2 백엔드 성능이 지속적으로 개선됩니다. 성능이 중요한 경우 WSL2 내부에 Docker Engine을 직접 설치해 Docker Desktop의 가상화 레이어를 우회하면 Windows/WSL 경계에서 발생하는 추가 비용을 더욱 줄일 수 있습니다.
VS Code 설정 최적화
불필요한 확장을 비활성화하면 Extension Host 부하를 감소시킬 수 있습니다. 특히 원격 컨테이너에서 실행되며 파일 감시 기능을 가진 확장(TypeScript 언어 서비스, ESLint 등)은 IO를 증가시킵니다. settings.json에 files.watcherExclude를 설정해 node_modules, .git, dist 등 대용량 디렉터리를 제외하면 파일 시스템 감시로 인한 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 Desktop 최적화) 정도에 머물며 근본 원인을 해결하지 못합니다. 핵심 원인은 Docker CLI 호출이 Windows/WSL 경계를 넘으며 발생하는 named pipe 통신 비용입니다. dev.containers.executeInWSL 설정은 바로 이 근본 원인을 해결하는 직접적인 솔루션입니다.
VS Code 팀 멤버 chrmarti가 microsoft/vscode-remote-release#9194에서 명확히 밝힌 바와 같이, 이 설정은 docker 명령이 Windows 측에서 실행될지 WSL 내부에서 실행될지를 결정합니다. true로 설정하면 모든 Docker CLI 호출(docker inspect, docker version, docker exec, docker 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\\)를 통해 Windows/WSL 경계를 넘으며 Docker daemon과 통신합니다. 이 경로는 Windows 프로세스 생성, 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가 소요되며, 이 지연은 주로 Windows/WSL 경계의 named pipe 통신과 프로세스 생성 비용에서 발생합니다. executeInWSL: true로 전환하면 Docker CLI가 WSL 내부에서 Unix socket으로 daemon과 통신해 호출 지연이 밀리초 수준으로 감소하고, 누적 효과가 크게 나타납니다.
알려진 문제 및 주의 사항
dev.containers.executeInWSL은 IO 성능을 크게 개선하지만, 사용 시 다음과 같은 알려진 문제가 있습니다.
- Docker Desktop 자동 시작 문제: issue #9695에서는
executeInWSL: true일 때 Docker Desktop이 자동으로 시작되지 않는다고 보고했으며, 이 문제는 Dev Containers 0.353.0‑pre‑release에서 수정되었습니다. 최신 버전을 사용하면 더 이상 발생하지 않습니다. - WSL 서비스 포워딩: issue #9194에서는
executeInWSL을false로 설정해도 확장이 WSL(디스플레이/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 경계에서 벗어나 WSL 내부로 옮겨 named pipe와 9P 변환 비용을 완전히 제거합니다. 나머지 방안(포트 포워딩 간소화, Docker Desktop 최적화, VS Code 설정 최적화)은 보조적인 효과를 제공하며, 각각의 상황에 맞게 적용하면 추가적인 성능 향상을 기대할 수 있습니다. IO 과다 문제에 직면한 사용자는 본 문서의 조사 과정을 따라 원인을 확인한 뒤, 우선 executeInWSL: true를 활성화하고, 필요에 따라 보조 최적화 전략을 선택해 적용하시기 바랍니다.