목록으로 돌아가기
Cloudflare
7분 읽기

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 devTypeError: 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 핸들러가 없다
Diagram: 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를 거부하는 데 있다. 추적 경로를 따라가면 이렇다.

  1. env.BINDING.connect(addr)는 JS 함수 호출처럼 보이지만 native(C++) 경로다. workerd가 이걸 HTTP CONNECT로 바꿔서 대상 워커의 entrypoint에 배달한다.
  2. 대상은 miniflare의 위장 워커(remote-proxy-client.worker.ts)다. 앞서 봤듯 여기에는 connect 핸들러가 없다.
  3. workerd의 WorkerEntrypoint::connect는 받는 쪽 워커에 connect_pass_through 또는 experimental compat flag가 없으면 inbound CONNECT를 거부한다. 에러 메시지의 출처가 정확히 worker-entrypoint.c++:648이다.
  4. 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가 이미 넘기는 전역 --experimental CLI 플래그로는 부족하고 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.localAddressMF-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하면서 옮겨진 위치를 따라갔고 그 패키지의 더 엄격한 noUncheckedIndexedAccess tsconfig에 맞춰 가드 하나를 추가했다.
  • 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일 만에 머지됐다.


참고자료

자주 묻는 질문

'Incoming CONNECT on a worker not supported' 에러는 왜 발생하나?
miniflare가 remote 바인딩을 로컬에서 JS 프록시 워커로 대신 서비스하는데 이 워커에 inbound CONNECT 핸들러가 없어서 workerd가 요청을 거부하기 때문이다. wrangler 4.114.0까지의 remote-bindings 프록시는 fetch()와 JSRPC만 릴레이했고 raw-TCP connect() 경로는 아예 없었다.
getPlatformProxy()에서 'This ReadableStream is disturbed' 에러가 나는 원인은?
vite 개발 서버 경로에서는 같은 CONNECT 거부 에러가 스트림 에러로 다시 포장돼 나온다. workerd 로그를 직접 보면 'Incoming CONNECT on a worker not supported'라는 동일한 근본 원인이 찍혀 있다. 표면 에러만 보고 스트림 사용 코드를 의심하면 원인을 놓친다.
이 문제는 어느 wrangler 버전부터 해결되나?
수정 PR(cloudflare/workers-sdk#14712)이 2026-07-24 main에 머지됐고 wrangler 4.114.0과 miniflare 4.20260722.0 다음 릴리스부터 포함된다. 배포된 Worker에서는 원래부터 정상 동작했고, 로컬 개발 환경(wrangler dev, getPlatformProxy)만 해당되는 문제다.
VPC Network 바인딩의 connect()는 무엇을 하나?
Cloudflare Tunnel로 연결된 사설망 안의 호스트에 Worker가 raw TCP 소켓을 여는 API다. 2026-06-16 GA된 기능으로, HTTP가 아닌 프로토콜(MySQL, PostgreSQL, Redis 등)로 사설망 데이터베이스에 직접 붙을 때 쓴다.
재현이 어려운 버그는 오픈소스 이슈로 어떻게 보고하나?
재현 환경을 통째로 제공하는 대신 소스 레벨 원인 추적과 실행 가능한 검증 증거(spike)를 첨부하면 된다. 이 건은 계정에 묶인 Tunnel과 사설 DB가 있어야 재현되기 때문에 재현 리포지토리를 만들 수 없었지만 원인 코드 위치와 spike 3개 결과를 제시했고 이슈 오픈부터 PR 머지까지 8일이 걸렸다.