원격 데몬에서 워크트리별 *.localhost dev 서버가 안 열리는 이유 — 릴레이는 HTTP 를 안 나른다

원격에 띄운 오케스트레이터 데몬이 워크트리마다 dev 서버를 올려도 클라이언트에서 그 화면이 안 열린다. 릴레이가 서비스 HTTP 를 터널링하지 않는 경계와, public 프록시로 여는 검증 방법.

워크트리마다 dev 서버를 하나씩 올려 격리하는 구성은 예전에 정리해뒀다. 그때는 데몬이 로컬이라 브라우저와 서비스가 같은 머신에 있었다. 이번엔 오케스트레이터 데몬을 원격 서버 컨테이너로 옮기고, 데스크톱·모바일 클라이언트는 릴레이로 붙였다. 워크트리별 dev 서버까지는 잘 떴는데, 그 화면을 클라이언트에서 열려고 하니 전부 연결이 거부됐다. 로컬이면 겪지 않는 함정이라 원인을 남긴다.

증상 — *.localhost URL 이 클라이언트에서 죽는다

워크트리 서비스의 프리뷰 URL 을 조회하면 이렇게 생긴 주소가 나온다.

http://preview--feature-42-web-3f9c1a20.localhost:6767

문제는 *.localhost 다. 이 호스트는 브라우저가 도는 머신의 127.0.0.1 로 풀린다. 데몬이 원격이면 이 주소는 클라이언트 자기 자신을 찾아가고, 데스크톱 앱에서는 이렇게 실패한다.

Error invoking remote method 'GUEST_VIEW_MANAGER_CALL':
Error: ERR_CONNECTION_REFUSED (-102) loading 'http://preview--….localhost:6767/'

재현 조건은 단순하다. 데몬이 원격 + 프리뷰 URL 이 *.localhost 이면 무조건 이렇게 된다. 로컬 데몬에서는 127.0.0.1 이 실제로 서비스가 사는 곳이라 그냥 열린다.

실패했던 접근 — 릴레이를 믿은 것

처음엔 릴레이가 붙어 있으니 서비스 화면도 릴레이를 타고 넘어올 거라 생각했다. 아니었다. 릴레이가 나르는 건 데몬 제어 API 와 이벤트뿐이다. 서비스가 뱉는 HTTP 응답은 릴레이 경계를 넘지 않는다.

클라이언트 앱 ⇄ relay ⇄ daemon      # 상태 · API · 에이전트
브라우저 ───────────────→ service    # 이 화살표는 relay 를 안 지난다

근거는 오케스트레이터 쪽 오픈소스 변경 이력에 있다. “기존 View 링크가 direct connection 로직으로 빠져 잘못된 URL 을 열었다”며 public URL 선택 분기를 따로 추가한 커밋이 있다. 릴레이가 서비스 HTTP 를 날랐다면 이 분기 자체가 필요 없다.

근본 원인 — 프리뷰 호스트가 로컬 전용으로 만들어진다

데몬은 서비스 프록시 호스트네임을 두 종류로 만든다. 로컬용과 public 용이다.

function buildLocalServiceHostname(input) {
  return `${buildServiceProxyLabel(input)}.localhost`;
}
function buildPublicServiceHostname({ publicBaseUrl, ...service }) {
  const base = new URL(publicBaseUrl);
  return `${buildServiceProxyLabel(service)}.${base.hostname}`;
}

기본값은 *.localhost 다. 그래서 daemon.serviceProxy.publicBaseUrl 을 설정하지 않으면 클라이언트가 도달할 수 있는 주소가 아예 생성되지 않는다. 원격 데몬에서는 클라이언트가 직접 도달 가능한 public URL 이 반드시 있어야 한다.

여기에 Host 검증이 하나 더 걸린다. 데몬은 기본적으로 localhost / *.localhost / IP 만 허용한다. 도메인을 붙이면 allowlist 에 넣어야 통과한다. 패턴이 . 으로 시작하면 서픽스 매치라, .example.com 하나로 example.com 과 모든 하위 도메인이 열린다.

실제 해결 — public 프록시 경로를 깐다

핵심은 워크트리 서비스 → public 도메인 → 클라이언트로 이어지는 실제 경로를 만드는 것이다. 예시 도메인을 example.com 이라 하자.

1. 서비스는 0.0.0.0 에 바인드한다. 포트는 워크트리마다 주입되는 환경변수에 묶어 하드코딩하지 않는다. 127.0.0.1 에 바인드하면 프록시가 컨테이너 밖에서 못 붙는다.

{
  "scripts": {
    "web": { "type": "service", "command": "npx vite --host 0.0.0.0 --port \"$PASEO_PORT\" --strictPort" }
  }
}

2. 데몬에 public 도메인과 allowlist 를 준다.

{
  "daemon": {
    "hostnames": [".example.com"],
    "serviceProxy": { "publicBaseUrl": "https://example.com" }
  }
}

3. 리버스 프록시로 프리뷰 호스트만 데몬으로 넘긴다. 프리뷰 라벨에는 -- 가 들어가므로, 그 패턴만 잡으면 기존 서비스 라우트와 안 부딪힌다.

@svc header_regexp svc Host ^[a-z0-9][a-z0-9-]*--[a-z0-9-]+\.example\.com(:\d+)?$
handle @svc {
    reverse_proxy daemon-host:6767
}

Caddy 기준 주의 두 가지. host_regexp 는 빌드에 따라 없는 매처라 (module not registered: http.matchers.host_regexp) header_regexp <name> Host <pattern> 을 쓴다. 그리고 admin off 가 있으면 caddy reload 가 admin API(2019) 부재로 실패하므로 컨테이너를 재시작해 반영한다.

4. 와일드카드 DNS 를 판다. *.example.com A → <프록시가 listen 하는 IP>. 개별 레코드가 우선하니 기존 서비스엔 영향이 없다. 사설망 IP 로 두면 그 망에 든 기기만 접근할 수 있다 — 모바일에서도 보려면 그 기기도 같은 망에 있어야 한다.

5. 데몬을 재시작한다. 핫리로드 경로가 없다. config 만 바꾸고 요청하면 계속 403 이다.

검증 방법 — 403 · 502 · 404 의 의미가 다 다르다

라벨은 비기본 브랜치면 <script>--<branch>--<project>, 기본 브랜치면 branch 가 빠진다. /- 로 slug 화되고 결합 라벨은 DNS 63자 제한에 걸려 잘리는데 절단 규칙이 문서화돼 있지 않다. 그래서 호스트네임을 손으로 조립하지 말고 항상 서비스 목록의 publicProxyUrl 을 권위값으로 쓴다.

증상별로 원인이 갈린다.

증상 원인
프리뷰 URL 이 502 서비스 프로세스가 죽음. 상태가 running 이어도 포트가 안 열려 있으면 이 경우 (예: 커맨드의 인터프리터가 PATH 에 없어 즉시 종료)
프리뷰 URL 이 403 요청은 데몬까지 갔고 Host allowlist 에서 거부. hostnames 미설정/미반영
브라우저가 ERR_CONNECTION_REFUSED *.localhost 를 원격 데몬에 쓴 것. 구조적으로 불가
프리뷰 URL 이 404 리버스 프록시가 그 Host 를 라우팅 안 함. 매처 확인

502 가 의심되면 컨테이너 안에서 실제 리스닝을 확인한다.

docker exec daemon-host sh -lc 'cat /proc/net/tcp | awk "NR>1 {print \$2, \$4}"'
# 00000000:1A6F 0A  → 0.0.0.0:6767 LISTEN (1A6F=6767, 0A=LISTEN)

로컬 데몬이면 프록시가 필요 없다

  • 로컬 데몬이면 이 문서 절반은 필요 없다. 도메인·프록시·DNS 없이 *.localhost 그대로 열린다. public 프록시는 릴레이 경계가 생기는 원격 구성 전용이다.
  • dev 서버를 프록시 뒤에 둘 때 HMR 이 안 붙을 수 있다. Vite 라면 allowedHosts 에 도메인을 넣고, HMR 소켓의 clientPort 를 프록시가 종단하는 포트(예: 443)로 맞춰야 한다. 기본값은 vite 내부 포트라 프록시 뒤에서 소켓이 안 붙는다.
  • HTTP 200 만 보고 “된다”고 판정하지 않는다. dev 서버가 빌드 산출물의 정적 파일이나 데이터 엔드포인트를 안 서빙하면, 화면은 뜨는데 데이터가 404 라 빈 화면이 된다. 프록시 규칙에 그 경로들을 같이 넣었는지 확인한다.