데비안 서버에 도커로 OpenClaw를 올려 쓰는 구성이 늘고 있습니다. 그런데 컨테이너에 담는 순간 한 가지 벽에 부딪힙니다. 서버 앞에 앉아 있으면 다 잘 되는데, 휴대폰을 들면 안 열립니다. Control UI의 Portals 목록에는 항목이 보이는데 주소를 눌러도 빈 화면이고, 채팅에 붙인 임베드는 Widget sandbox host is unavailable이라는 문구만 띄웁니다.
이 글은 그 상황을 실측으로 분해한 기록입니다. 원인은 세 가지가 겹쳐 있었고, 그중 하나는 도커의 동작 방식에서 오는 함정이었습니다. 마지막에는 컨테이너를 다시 만들어도 같은 문제가 반복되지 않게 만드는 방법까지 정리합니다.
먼저 포트를 구분합니다
헷갈림의 대부분은 이 셋을 하나로 뭉뚱그릴 때 생깁니다. OpenClaw는 목적이 다른 포트를 여러 개 씁니다.
| 구분 | 기본 포트 | 하는 일 | 휴대폰 접근 |
|---|---|---|---|
| Gateway | 18789 | Control UI, WebSocket, 호스팅 문서(/__openclaw__/canvas/) | 게시하면 가능 |
| 위젯 샌드박스 | 게이트웨이 + 1 (18790) | 채팅 임베드의 iframe 로드 | 게시하면 가능 |
| 브라우저 제어 | 게이트웨이 + 2 (18791) | 브라우저 자동화 제어 서비스 | 게시 불필요 |
| 포털 리스너 | 매번 랜덤 | 개발 서버 프록시 (핫리로드) | 사실상 불가 |
가장 중요한 차이는 이겁니다. Gateway와 위젯 샌드박스는 포트가 고정이지만, 포털 리스너는 열 때마다 새로 배정됩니다. 이 차이가 뒤에서 결론을 갈라놓습니다.
포트 번호는 장식이 아니라 관계로 정해집니다. 위젯 샌드박스는 게이트웨이 + 1, 브라우저 제어는 게이트웨이 + 2 자리로 파생됩니다. 그래서 게이트웨이 포트를 바꾸면 이 번호들도 함께 따라 움직입니다.
원인 1. 포털 포트는 랜덤이라 미리 열어둘 수 없습니다
"포털이 안 열리니 포털 포트를 방화벽에 열자"는 접근은 성립하지 않습니다. 포털 리스너는 운영체제가 빈 포트를 골라주는 방식으로 뜹니다. 소스에서 확인한 값은 이렇습니다.
listenPort: 0, // 0 = OS가 빈 포트 배정
const PORTAL_PORT_ALLOCATION_ATTEMPTS = 10; // 앱 포트와 겹치면 재시도
컨테이너의 임시 포트 대역은 32768~60999였습니다. 즉 포털을 열 때마다 이 넓은 구간 어딘가에서 다른 번호가 잡힙니다. 포트를 고정하는 설정 키도 없습니다.
게다가 Control UI는 호스트 이름만 게이트웨이 주소로 바꿔줄 뿐, 포트는 그 랜덤 값(listenPort)을 그대로 씁니다. 그래서 Control UI 안에서 열든 밖에서 열든 랜덤 포트가 필요합니다.
결론은 명확합니다. 포털 경로는 휴대폰용으로 부적합합니다. 게시하려면 32768~60999를 통째로 열어야 하는데, 보안상 좋지 않고 현실적이지도 않습니다.
원인 2. 포털 URL의 호스트가 127.0.0.1로 나옵니다
두 번째는 더 미묘합니다. 포털을 열면 이런 주소가 돌아옵니다.
https://127.0.0.1:42275/<경로>?openclaw_portal=<토큰>
게이트웨이가 0.0.0.0으로 바인드된 상태에서, 주소를 만들 때 127.0.0.1로 바꿔 넣기 때문입니다. 문제는 다른 기기에서 127.0.0.1은 그 기기 자신이라는 점입니다. 휴대폰에서 이 주소를 열면 휴대폰 자신의 42275번 포트를 찾다가 실패합니다. 포트를 열어줘도 이 주소는 휴대폰에서 영원히 동작하지 않습니다.
원인 3. 임베드는 게이트웨이 포트가 아니라 별도 리스너에서 옵니다
Widget sandbox host is unavailable 문구의 정체입니다. 채팅에 붙인 임베드는 게이트웨이 포트에서 직접 오지 않습니다. 보안 격리를 위해 위젯 샌드박스 리스너(기본값은 게이트웨이 포트 + 1, 즉 18790)에서 iframe으로 로드됩니다.
즉 게이트웨이 포트(18789)만 게시해 둔 상태라면, Control UI 자체는 열려도 임베드 프레임은 못 가져옵니다. 컨테이너 안에서는 리스닝 중인데 밖에서는 닿지 않으니, 화면에는 그 문구만 남습니다.
여기서 중요한 성질이 하나 있습니다. 위젯 샌드박스 포트는 고정입니다. 포털과 달리 게시할 수 있습니다.
도커의 함정: 포트 매핑은 컨테이너 생성 시 고정됩니다
여기서 한 번 크게 헛짚었습니다. docker-compose.yml에 포트를 추가하고 확인해보니 여전히 안 닿았습니다. 이유는 간단했습니다. 도커의 포트 매핑은 컨테이너를 만들 때 정해지고, 그 뒤에는 바뀌지 않습니다. compose 파일만 수정하고 컨테이너를 다시 만들지 않으면 기존 컨테이너에는 아무 영향이 없습니다.
재생성 여부를 판별하는 방법은 두 가지입니다.
# 1) 컨테이너 PID 1의 경과 시간을 본다 (재생성하면 리셋됨)
ps -o pid,etime,cmd -p 1
# 2) 예전에 띄운 백그라운드 프로세스가 아직 살아있는지 본다
# 재생성됐다면 전부 죽어 있어야 한다
ps -eo pid,etime,args | grep -F "내가띄운프로세스"
실제로 판별이 깔끔하게 됐습니다. 기존 포트만 200을 주고 새로 추가한 세 개는 전부 미게시였습니다. 특정 포트나 방화벽 문제가 아니라, 변경 자체가 컨테이너에 전달되지 않은 상태였던 겁니다. 게다가 몇 시간 전에 띄운 개발 서버가 아직 살아있다는 사실이 결정적 증거였습니다.
진단 순서: 서버 로그를 가장 먼저 봅니다
이번 트러블슈팅에서 가장 값싼 교훈입니다. "안 열린다"는 신고를 받으면 포트부터 의심하기 쉬운데, 서버 접근 로그를 먼저 보면 진단이 절반으로 줄어듭니다.
로그를 보니 이런 줄이 있었습니다.
192.0.2.55 - - [15/Sep/2026 21:41:18] "GET / HTTP/1.1" 200 -
휴대폰이 이미 200을 받았습니다. HTML도 전달됐습니다. 즉 문제는 전달 실패가 아니었고, 제가 포트만 파고 있던 시간은 전부 헛수고였습니다. 로그가 "요청이 아예 안 왔다"와 "요청은 왔는데 화면이 안 나온다"를 즉시 갈라줍니다.
그래서 순서를 이렇게 잡는 것이 좋습니다.
- 서버 접근 로그 (요청이 왔는가)
- 같은 파일을 대상 기기 뷰포트로 직접 렌더 (내용이 정상인가)
- 그다음에 네트워크와 포트
해결책 1. 필요한 포트만 게시하고 컨테이너를 재생성합니다
고정 포트인 것만 게시하면 됩니다. 포털은 랜덤이라 대상에서 제외합니다.
services:
openclaw:
ports:
- "18789:18789" # Control UI / Gateway
- "18790:18790" # 위젯 샌드박스 (임베드 프레임)
- "34700:34700" # 로컬 정적 페이지 (선택)
- "6081:6081" # noVNC (선택)
그리고 반드시 컨테이너를 다시 만듭니다.
docker compose up -d
이 시점에 게이트웨이가 재시작되므로, 세션과 백그라운드 프로세스가 정리되는 것을 감안해야 합니다.
해결책 2. 비-loopback 접속에는 allowedOrigins가 필요합니다
포트를 다 열었는데도 Control UI가 붙지 않는 경우가 있습니다. 게이트웨이가 loopback이 아닌 주소로 바인드되면, Control UI는 접속한 브라우저의 출처(origin) 를 검사합니다. 설정 스키마는 이 값을 "비-loopback Control UI 배포에서 필요하다"고 규정합니다.
openclaw config set gateway.controlUi.allowedOrigins '["http://192.0.2.10:18789"]' --strict-json
값은 전체 origin 형식이라 스킴과 포트까지 포함해야 합니다. 와일드카드 "*" 는 어떤 브라우저 출처든 허용하므로, 통제된 로컬 테스트 밖에서는 쓰지 않는 편이 안전합니다. 이 설정은 핫 리로드 대상이라 게이트웨이를 재시작하지 않아도 반영됩니다.
여기에 실무적인 함정이 하나 있습니다. 도커 설치 스크립트는 보통 127.0.0.1 과 localhost 기준으로 이 값을 미리 채워둡니다. 그런데 휴대폰에서 접속할 때 쓰는 주소는 LAN IP입니다. 목록에 LAN IP 출처가 없으면 그 브라우저는 막힙니다. 서버 앞에서 잘 되던 것이 휴대폰에서만 안 되는 이유가 여기에 하나 더 숨어 있을 수 있습니다. 접속에 쓸 주소를 기준으로 origin을 추가해두는 편이 좋습니다.
해결책 3. 정적 페이지를 띄울 때 charset을 확인합니다
휴대폰 확인용으로 정적 페이지를 띄운다면 파이썬 기본 서버의 성질을 알아두는 게 좋습니다. python3 -m http.server는 Content-Type에 charset을 붙이지 않습니다.
Content-type: text/html <- charset 없음
한글이 본문 대부분인 문서라면, 브라우저가 인코딩을 잘못 추측할 여지가 생깁니다. 캐시 헤더도 없어서 이전 응답이 남을 수 있습니다. 아래처럼 최소한을 직접 챙기는 편이 안전합니다.
class Handler(http.server.SimpleHTTPRequestHandler):
def guess_type(self, path):
ctype = super().guess_type(path)
if ctype == "text/html":
return "text/html; charset=utf-8"
return ctype
def end_headers(self):
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
User-Agent를 로그에 남기면, 나중에 어떤 기기와 브라우저가 들어오는지 바로 확인할 수 있어 진단이 훨씬 빨라집니다.
컨테이너 재생성 후 로컬 서버를 자동으로 되살립니다
포트 매핑이 고정인 것과 별개로, 컨테이너 안에서 돌던 프로세스는 재생성 때 전부 죽습니다. noVNC나 정적 서버를 매번 손으로 살리는 일은 금방 지칩니다. 멱등 스크립트를 하나 만들어 두면 해결됩니다.
#!/usr/bin/env bash
# 이미 떠 있으면 아무것도 하지 않는다 (멱등). 반복 실행 안전.
set -uo pipefail
port_up() { (exec 3<>"/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1; }
# 1) noVNC (로그인용 화면)
if ! port_up 6081; then
bash "$HOME/.openclaw/novnc/start-novnc.sh"
fi
# 2) 정적 페이지
if ! port_up 34700; then
( cd "$HOME/static-site" && nohup setsid python3 serve_phone.py & )
fi
이 스크립트를 OpenClaw automation에 5분 간격으로 걸어둡니다.
openclaw automations add \
--name "ensure-local-servers" \
--every 5m \
--command "bash $HOME/scripts/ensure-local-servers.sh" \
--session isolated
핵심은 멱등성입니다. 이미 떠 있으면 아무 일도 하지 않으므로, 5분마다 돌아도 중복 프로세스가 쌓이지 않습니다. 검증은 일부러 서버를 죽이고 실행해보는 방식으로 했고, 양쪽 분기 모두 복구되는 것을 확인했습니다.
automation을 다룰 때 걸린 함정 두 가지도 적어둡니다.
openclaw automations run은 job id를 받습니다. 이름으로는Automation not found가 납니다.--wait를 남용하지 않는 게 좋습니다. 중간에 중단되면 진행 상태가 남아 이후 실행이already-running으로 건너뛰어집니다. 이때는disable후enable로 풀립니다.
검증에 쓴 명령어 모음
| 확인 대상 | 명령 |
|---|---|
| 휴대폰 도달성 | curl -sk --noproxy '*' -o /dev/null -w "%{http_code}\n" https://<호스트>:18789/ |
| 컨테이너 재생성 여부 | ps -o pid,etime,cmd -p 1 |
| 실제 리스닝 포트 | ss -tlnp 또는 /proc/net/tcp 확인 |
| 임베드 리스너 | curl -sk -o /dev/null -w "%{http_code}\n" https://<호스트>:18790/ |
| 정적 페이지 내용 | curl -s -D- -o /dev/null http://<호스트>:34700/ (charset 확인) |
포트 확인에서 주의할 점이 하나 있습니다. --noproxy '*'를 붙이지 않으면 컨테이너의 이그레스 프록시가 요청을 가로채서, 정상 서버도 502나 401로 보입니다. 로컬과 LAN 주소를 확인할 때는 프록시를 반드시 우회합니다.
게시하지 말아야 할 포트
포트를 여는 김에 보이는 번호를 다 열고 싶어지는데, 열어도 의미가 없는 포트가 있습니다.
- 브라우저 제어 서비스 (기본 게이트웨이 + 2, 예: 18791): 게이트웨이 포트에서 파생되어 loopback에만 바인드됩니다. 컨테이너 안에서 브라우저 자동화를 제어하는 내부 용도라, 게시해도 외부에서 쓸 일이 없고 노출만 늘어납니다.
- 포털 리스너: 앞서 본 대로 랜덤이라 게시 자체가 성립하지 않습니다.
반대로 게시할 가치가 있는 것은 게이트웨이(18789), 위젯 샌드박스(18790), 그리고 직접 띄운 정적 서버 정도입니다.
정리합니다
겹쳐 있던 원인을 다시 짚으면 이렇습니다.
- 포털 리스너 포트는 랜덤이라 게시할 수 없습니다. 휴대폰용으로는 쓰지 않는 게 맞습니다.
- 포털 URL의 호스트가
127.0.0.1로 나오면 다른 기기에서는 무의미합니다. - 임베드는 위젯 샌드박스(게이트웨이 + 1) 에서 오므로, 그 포트를 게시해야 보입니다.
- 비-loopback으로 접속하면 allowedOrigins 검사를 받습니다. LAN IP 출처가 목록에 없으면 막힙니다.
- 브라우저 제어(게이트웨이 + 2)는 loopback 전용이라 게시해도 소용없습니다.
- 도커 포트 매핑은 컨테이너 생성 시 고정이므로, compose 수정 후 반드시 재생성합니다.
- 진단은 서버 접근 로그부터 시작합니다.
데비안이든 다른 리눅스든, 도커로 OpenClaw를 돌리면서 휴대폰이나 다른 기기에서 접속하려는 구성이라면 같은 지점에서 막히게 됩니다. 이 글이 그 왕복을 줄여주면 좋겠습니다.
0 댓글