背景
最近需要在 ARM64 服务器上部署一个远程浏览器,通过 Web UI 访问。选了一圈发现 linuxserver/webtop 对 ARM64 原生支持,底层用的是 Selkies 流媒体引擎(不是老的 KasmVNC),整体体验不错。
但问题来了——WebRTC 要求浏览器必须在 安全上下文(Secure Context) 下运行,也就是说必须通过 HTTPS 访问。而 webtop 容器内部的 nginx 只监听 HTTP(端口 3000),这就需要外部 nginx 做 SSL 终端并反向代理。
这个反向代理的配置比想象中复杂得多,踩了不少坑,记录下来供参考。
架构概览
最终架构如下:
浏览器 (HTTPS)
↓
外部 Nginx (443/SSL终端)
↓ /browser/* → 容器 Nginx (3080→3000)
↓ /browser/webrtc/* → Selkies 信令服务器 (3082→8082)
↓
webtop 容器
├── Nginx (3000) — 静态资源 + 代理
└── Selkies Python 信令服务器 (8082) — WebSocket + WebRTC 信令
关键点:Selkies 的信令服务器是独立的 Python 进程,容器内的 nginx 只是做了一层代理。所以我们需要把信令流量直接送到 8082 端口,而不是经过容器 nginx 中转。
踩坑过程
坑一:直接暴露端口行不通
最初的想法最简单——不做反向代理,直接通过 http://域名:3080 访问。但浏览器要求 HTTPS(Secure Context),在 HTTP 下 WebRTC 会被拒绝,WebRTC DataChannel 无法建立。直接暴露端口意味着你得自己配容器内的 SSL 证书,或者用自签证书(浏览器会弹警告)。
结论:必须通过 nginx 做 SSL 终端。
坑二:子路径反代的路径构造问题
最初配置了简单的反向代理:
location /browser/ {
proxy_pass http://127.0.0.1:3080/; # 注意末尾的 /,会剥离 /browser/ 前缀
}
这样 webtop 收到的是根路径 / 的请求,静态资源没问题。但 Selkies 的 JavaScript 客户端会根据 window.location.pathname 动态构造 WebSocket URL:
// Selkies JS 核心逻辑(简化版)
var pathname = window.location.pathname;
// 当 pathname 以 / 结尾时,取第二段作为信令路径名
var signaling_name = pathname.endsWith("/") && pathname.split("/")[1] || "webrtc";
// 取 pathname 的目录前缀
var prefix = pathname.slice(0, pathname.lastIndexOf("/") + 1);
// 构造 WebSocket URL
var ws_url = "wss://" + host + prefix + signaling_name + "/signaling/";
当浏览器访问 /browser/ 时,nginx 剥离前缀后,容器内的页面在 / 下加载,JS 构造出的 URL 变成:
wss://host/webrtc/signaling/ ← 正确路径,但容器 nginx 没有 location 来处理!
当访问根路径 / 时,JS 构造:
wss://host/webrtc/signaling/ ← 同样的路径
但容器内的 nginx 配置只有 /websocket 代理到 8082,没有 /webrtc/signaling/ 这个 location!
坑三:容器 nginx 配置每次启动都会被覆盖
webtop 使用 s6-overlay 管理初始化,容器 nginx 配置在 /etc/nginx/sites-available/default,每次启动都会从 /defaults/default.conf 模板重新生成。我试过用 docker cp 直接修改容器内的 nginx 配置来添加信令代理,但容器一重启就全没了。
又尝试通过 s6-overlay 的 init 脚本自动注入,但 sed/awk 插入多行 nginx 配置很容易破坏格式,导致 nginx 启动失败。
坑四:/config/nginx/default.conf 是个目录不是文件
webtop 的持久化卷 /config 挂载后,/config/nginx/ 目录会被创建,但 default.conf 变成了目录而不是文件。直接挂载配置文件的方案也行不通。
最终解决方案
折腾了一圈后,发现 webtop 自带 SUBFOLDER 环境变量支持。设置后所有路径都会自动加前缀,包括容器内 nginx 的 location 规则和 Selkies JS 的路径构造。
第一步:设置 SUBFOLDER 环境变量
docker-compose.yml:
services:
webtop:
image: lscr.io/linuxserver/webtop:ubuntu-xfce
container_name: webtop
restart: unless-stopped
shm_size: "2gb"
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
- CUSTOM_USER=mason
- PASSWORD=your_password
- SUBFOLDER=/browser/ # ← 关键!
volumes:
- /opt/webtop/config:/config
ports:
- "3080:3000" # Web 界面
- "3082:8082" # Selkies 信令服务器(必须暴露)
SUBFOLDER=/browser/ 的效果:
- 容器 nginx 的 location 规则自动变为
/browser/、/browser/websocket、/browser/files等 - Selkies JS 在
/browser/页面下会构造/browser/webrtc/signaling/的 WebSocket URL - 不再有路径不匹配的问题
启动后查看容器内 nginx 配置,确认 location 已正确替换:
$ docker exec webtop grep "location" /etc/nginx/sites-available/default
location /browser/ {
location /devmode {
location /browser/websocket {
location /browser/files {
第二步:暴露 Selkies 信令端口
这是最关键的发现。容器内有两个进程处理请求:
| 端口 | 进程 | 作用 |
|---|---|---|
| 3000 | nginx | 静态资源 + 代理到 8082 的 /websocket 路径 |
| 8082 | Selkies Python | WebSocket 信令(/webrtc/signaling/)、TURN 凭证(/turn/) |
容器 nginx 只代理了 /websocket 到 8082,但 /webrtc/signaling/ 和 /turn/ 完全没有 location 规则!这些请求会被当作静态文件处理,返回 404。
所以必须把 8082 端口也暴露出来,让外部 nginx 直接将信令请求代理到 Selkies 服务器。
第三步:配置外部 Nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# ====== 主界面:代理到容器 nginx ======
location /browser/ {
proxy_pass http://127.0.0.1:3080/browser/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
# ====== WebRTC 信令 WebSocket ======
location /browser/webrtc/signaling/ {
proxy_pass http://127.0.0.1:3082/browser/webrtc/signaling/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
# ====== TURN 凭证 ======
location /browser/turn/ {
proxy_pass http://127.0.0.1:3082/browser/turn/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ====== WebSocket (websockets 模式回退路径) ======
location /browser/websocket {
proxy_pass http://127.0.0.1:3082/browser/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
}
关键设计决策:
/browser/主界面代理到 3080(容器 nginx)——处理静态资源和页面加载/browser/webrtc/signaling/、/browser/turn/、/browser/websocket直接代理到 3082(Selkies 信令服务器)——绕过容器 nginx,避免 404- 所有
proxy_pass保留完整路径——因为容器设置了SUBFOLDER=/browser/,信令服务器也会处理带前缀的路径
验证
# 主界面 - 应返回 200(需要认证则 401)
$ curl -sI -u user:pass https://your-domain.com/browser/
HTTP/2 200
# WebSocket 端点 - 应返回 426 (Upgrade Required)
$ curl -s -o /dev/null -w "%{http_code}" https://your-domain.com/browser/websocket
426
# WebRTC 信令 - 应返回 426
$ curl -s -o /dev/null -w "%{http_code}" https://your-domain.com/browser/webrtc/signaling/
426
# TURN 凭证 - 应返回 426
$ curl -s -o /dev/null -w "%{http_code}" https://your-domain.com/browser/turn/
426
426 状态码表示 “Upgrade Required”,是 WebSocket/信令 端点的正常响应——它们期望连接升级为 WebSocket,普通 HTTP 请求返回 426 是正确的。
为什么不用 Neko?
部署过程中也试过 Neko,它的 WebRTC 支持更成熟。但 Neko 的官方 Docker 镜像只支持 amd64 架构。服务器是 ARM64,通过 QEMU 模拟运行 amd64 二进制时会因 SIGSEGV 崩溃。所以最终选择了原生支持 ARM64 的 webtop。
总结
| 问题 | 解决方案 |
|---|---|
| WebRTC 需要 HTTPS | 外部 nginx 做 SSL 终端 |
| 子路径反代路径错乱 | 使用 SUBFOLDER=/browser/ 环境变量 |
| 容器 nginx 缺少信令 location | 暴露 8082 端口,外部 nginx 直接代理到信令服务器 |
| 容器 nginx 配置重启被覆盖 | 不修改容器内部配置,用 SUBFOLDER 机制解决 |
| Selkies JS 动态构造路径 | SUBFOLDER 模式下路径自然对齐 |
核心经验:遇到容器内部的反向代理问题时,优先看有没有官方的路径前缀支持,而不是试图修改容器内部配置。 webtop 的 SUBFOLDER 环境变量完美解决了子路径反代的问题。唯一需要额外处理的是把 Selkies 信令服务器的端口也暴露出来,因为容器 nginx 没有代理所有信令路径。