Cloudflare VPC Network connect()가 로컬 dev에서만 실패하는 이유 — workers-sdk 기여기
remote: true VPC Network 바인딩의 connect()가 로컬 dev에서 실패하는 원인은 miniflare 프록시 워커에 inbound CONNECT 핸들러가 없기 때문이다. workerd 소스까지 추적해 workers-sdk에 수정 PR을 머지시킨 과정을 기록한다.
ㅂㄹㄱ
2026-07-28
들어가며
배포하면 되는데 로컬에서만 안 되는 버그는 개발 환경 에뮬레이션 레이어를 의심해야 한다. SvelteKit 앱에서 Cloudflare Tunnel 뒤에 있는 사설망 MySQL에 붙는 작업을 하고 있었다. VPC Networks의 TCP connect()가 2026-06-16 GA되면서 Worker에서 사설망 DB로 raw TCP 소켓을 여는 게 공식 지원되기 시작했다. 배포한 Worker에서는 잘 됐다. Drizzle 쿼리까지 정상으로 돌았다.
문제는 로컬이었다. wrangler dev에서 같은 코드가 즉시 죽었다.
TypeError: Incoming CONNECT on a worker not supported
같은 바인딩의 fetch()는 로컬에서도 잘 됐다. connect()만 죽었다. 이 글은 이 에러를 workerd C++ 소스까지 추적해서 cloudflare/workers-sdk에 수정 PR을 머지시킨 기록이다.
같은 버그, 다른 두 얼굴
이 버그는 진입 경로에 따라 완전히 다른 에러로 보이기 때문에 원인 파악이 어렵다. 당시 환경은 wrangler 4.110.0, miniflare 4.20260708.1, workerd 1.20260708.1이었다.
| 진입 경로 | 표면 에러 |
|---|---|
wrangler dev | TypeError: Incoming CONNECT on a worker not supported |
getPlatformProxy() (vite dev) | TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body. |
wrangler dev 쪽 에러는 그나마 정직하다. 문제는 vite 경로였다. SvelteKit의 @sveltejs/adapter-cloudflare는 내부에서 getPlatformProxy()를 쓰는데 여기서는 스트림 에러로 다시 포장되어 나온다. 처음에는 내 코드가 ReadableStream을 두 번 읽는 줄 알고 한참 엉뚱한 곳을 팠다.
workerd 로그를 직접 열어보고 나서야 두 에러가 같은 뿌리라는 걸 알았다.
workerd/api/sockets.c++:449: error: Socket proxy disconnected abruptly;
e = workerd/io/worker-entrypoint.c++:648: failed:
jsg.TypeError: Incoming CONNECT on a worker not supported
표면 에러가 아니라 런타임 로그를 봐야 한다는 것, 이게 이 버그의 첫 번째 교훈이었다.
remote bindings는 로컬에서 어떻게 동작하나
miniflare는 remote: true 바인딩을 로컬에서 JS 프록시 워커로 대신 서비스한다. 내 Worker가 보기에 env.VPC_NETWORK는 진짜 바인딩이지만 실제로는 miniflare가 등록한 remote-proxy-client.worker.ts라는 위장 워커다. 이 워커가 요청을 받아서 edge에 떠 있는 원격 프록시 세션(ProxyServerWorker)으로 넘기고 거기서 진짜 바인딩이 실행된다.
sequenceDiagram
participant W as 내 Worker (로컬)
participant P as remote-proxy-client<br/>(miniflare 위장 워커)
participant E as ProxyServerWorker<br/>(edge preview 세션)
participant DB as 사설망 MySQL<br/>(Tunnel 뒤)
W->>P: env.VPC_NETWORK.fetch() — 릴레이 OK
P->>E: HTTP/JSRPC 릴레이
E->>DB: 진짜 바인딩 실행
W--xP: env.VPC_NETWORK.connect() — 여기서 즉사
Note over P: connect 핸들러가 없다
핵심은 이 위장 워커에 fetch와 capnweb JSRPC 릴레이만 있었다는 점이다. fetch()가 로컬에서 잘 됐던 이유이자 connect()만 죽었던 이유다. HTTP 쪽 릴레이는 다 갖춰져 있었는데 raw TCP가 지나갈 길만 없었다.
workerd 소스까지 내려간 root cause
에러의 최종 원인은 workerd가 compat flag 없는 워커로 들어오는 inbound CONNECT를 거부하는 데 있다. 추적 경로를 따라가면 이렇다.
env.BINDING.connect(addr)는 JS 함수 호출처럼 보이지만 native(C++) 경로다. workerd가 이걸 HTTP CONNECT로 바꿔서 대상 워커의 entrypoint에 배달한다.- 대상은 miniflare의 위장 워커(
remote-proxy-client.worker.ts)다. 앞서 봤듯 여기에는 connect 핸들러가 없다. - workerd의
WorkerEntrypoint::connect는 받는 쪽 워커에connect_pass_through또는experimentalcompat flag가 없으면 inbound CONNECT를 거부한다. 에러 메시지의 출처가 정확히worker-entrypoint.c++:648이다. - edge 쪽 ProxyServerWorker에도 소켓 릴레이 경로가 없다. 즉 로컬에서 살아남았더라도 건너갈 다리가 없었다.
요청이 원격 프록시 세션에 도달하기도 전에 로컬에서 죽는 구조였다. 참고로 기존 VPC Networks remote-bindings e2e 테스트는 .fetch()만 검증했고 그마저 skip 상태여서 이 공백을 잡아줄 커버리지도 없었다.
이슈를 열기 전에 spike부터 돌렸다
원인 분석만 담긴 이슈보다 수정이 가능하다는 증거까지 담긴 이슈가 훨씬 빨리 움직인다. 이 건은 계정에 묶인 Cloudflare Tunnel과 사설 DB가 있어야 재현되기 때문에 재현 리포지토리를 만들 수 없었다. 대신 workers-sdk main 위에서 spike 3개를 돌려 수정 경로의 각 구간이 뚫리는지 확인했다.
- 로컬 인그레스 검증.
compatibilityFlags: ["experimental"]을 준 워커에export default { connect(socket) }핸들러를 달면 service binding 경유connect()가 정상 수신된다. workerd#6059로 머지된 inbound connect 핸들러다. 중요한 발견은 miniflare가 이미 넘기는 전역--experimentalCLI 플래그로는 부족하고 per-worker compat flag여야 한다는 점이었다. - 주소 복원 검증. 호출자가
connect("host:port")에 넘긴 주소는 핸들러 쪽에서(await socket.opened).localAddress로 그대로 복원된다. 별도의 주소 협상 프로토콜이 필요 없다. - edge 구간 검증. ProxyServerWorker에 임시 진단 코드를 넣고 실제 Tunnel + 사설망 MySQL로 테스트했다. 원격 프록시 세션 안에서
env[binding].connect(addr)가 성공했고 진짜 MySQL handshake 78바이트가 돌아왔다.
세 구간이 다 뚫린다는 건 workerd 수정 없이 workers-sdk만으로 고칠 수 있다는 뜻이었다. 이 증거와 함께 이슈 #14710을 열었다. 메인테이너가 설계 질문 2개에 바로 답을 줬다.
WebSocket으로 TCP를 릴레이하는 설계
수정의 뼈대는 로컬 위장 워커와 edge 프록시 서버 사이에 WebSocket 터널을 놓고 TCP 바이트를 양방향 릴레이하는 것이다. PR #14712의 주요 설계 결정은 다음과 같다.
| 결정 | 선택 | 이유 |
|---|---|---|
| inbound CONNECT 수신 | per-worker experimental compat flag | 전역 CLI 플래그로는 안 됨. 로컬 전용이고 miniflare가 workerd 버전을 직접 통제하므로 메인테이너가 승인 |
| 주소 전달 | socket.opened.localAddress → MF-Connect-Address 헤더 | 주소가 그대로 도착하므로 협상 프로토콜 불필요 |
| WebSocket 토폴로지 | connect()마다 전용 WebSocket | 기존 capnweb JSRPC 세션과 프레이밍 충돌 없음. 메인테이너도 충돌 방지 차원에서 이쪽 선호 |
| 적용 범위 | rawTcp 옵트인 (VPC networks만) | 기존 HTTP/JSRPC 프록시 콜사이트는 experimental flag가 켜지지 않게 격리 |
한계도 명시했다. WebSocket에는 backpressure 신호가 없어서 소켓을 생산 속도대로 드레인한다. TCP half-close도 지원하지 않아서 한쪽이 끝나면 터널 전체를 닫는다. 이런 제약은 코드 주석과 PR 본문에 남겨서 리뷰어가 판단할 수 있게 했다.
테스트는 end-to-end 터널 검증, close/error 매트릭스, 릴레이 함수 단위 테스트, 옵트인 플래그 검증까지 13개를 새로 넣었다. 실제 Tunnel 뒤 MySQL로 Drizzle 쿼리가 로컬 dev에서 도는 것도 수동으로 확인했다.
리뷰에서 머지까지
이슈 오픈부터 머지까지 8일 걸렸다. 2026-07-16에 이슈와 PR을 열었고 2026-07-24에 머지됐다. 그 사이 과정이 오픈소스 기여의 실제 모습에 가깝다.
- 메인테이너(petebacondarwin)가 "remote-bindings 시스템의 non-trivial한 변경이라 리뷰에 시간이 필요하다"며 Hyperdrive 팀과도 논의를 시작했다.
- 리뷰 도중 대규모 코드 이동(#14720)으로 ProxyServerWorker가
@cloudflare/remote-bindings패키지로 옮겨지면서 conflict가 났다. rebase하면서 옮겨진 위치를 따라갔고 그 패키지의 더 엄격한noUncheckedIndexedAccesstsconfig에 맞춰 가드 하나를 추가했다. - Hyperdrive 팀 엔지니어가 "이 접근이 Hyperdrive
remote: true에도 통할 것 같다"는 의견을 남겼다. 파보니 릴레이 프리미티브 자체는 재사용할 수 있었다. 다만 Hyperdrive 바인딩은 Fetcher가 아니고 miniflare 플러그인도 local-only라 config 레이어부터 손대야 한다. 별도 후속 PR 감이라고 정리해서 답했다.
이 수정은 이 글을 쓰는 시점(2026-07-28) 기준 main에 머지된 상태다. wrangler 4.114.0과 miniflare 4.20260722.0 다음 릴리스부터 포함된다.
정리
이번 기여의 핵심을 표 하나로 남긴다.
| 항목 | 내용 |
|---|---|
| 증상 | remote: true VPC Network 바인딩의 connect()가 로컬 dev에서만 실패 |
| 표면 에러 | Incoming CONNECT on a worker not supported / vite에서는 ReadableStream is disturbed로 위장 |
| 원인 | miniflare 위장 워커에 connect 핸들러 부재 + workerd의 inbound CONNECT compat flag 거부 |
| 수정 | inbound connect 핸들러 + connect()당 전용 WebSocket 릴레이 (workers-sdk#14712) |
| 포함 버전 | wrangler 4.114.0 / miniflare 4.20260722.0 다음 릴리스 |
교훈을 일반화하면 세 가지다. 첫째, 로컬에서만 죽는 버그는 에뮬레이션 레이어(여기서는 miniflare의 위장 워커)를 의심한다. 둘째, 표면 에러가 아니라 런타임 로그를 본다. vite의 스트림 에러만 봤다면 영영 못 찾았을 것이다. 셋째, 재현 환경을 못 주는 버그일수록 소스 추적과 spike 증거로 대신한다. 이슈에 "고칠 수 있다는 증거"까지 담기면 메인테이너의 의사결정이 빨라진다. 이 건은 그 덕에 8일 만에 머지됐다.
참고자료
- cloudflare/workers-sdk#14710 — 이슈: connect() on remote VPC Network bindings fails in local dev
- cloudflare/workers-sdk#14712 — PR: Support connect() on remote VPC Network bindings in local dev
- cloudflare/workerd#6059 — inbound connect handler
- Cloudflare Docs — Workers VPC
- Cloudflare Changelog — TCP connect() over VPC Networks GA (2026-06-16)
- Cloudflare Docs — Remote bindings (local development)
- Cloudflare Docs — TCP sockets (connect API)