在已构建好的 kylin:ukui-vnc-slim
基础镜像上,植入 Selkies(portable 形态),通过 WebRTC 把容器里的桌面“零厚度”地投递到浏览器端,获得更低时延与更稳定画质的交互体验。
VNC 是一条老路,Selkies 则像一座新桥——以 GStreamer 为梁、以 TURN 为墩,在网络的风雨里仍然稳固地把人和系统连接起来。
下文不仅完整保留你的全部教程内容与代码(不删一字),更补上“为什么这样做”与“如何在生产中站稳”的分析与排障要点,让这套实践既能跑起来,也能扛得住。
一、整体思路与系统形态
你在做的事:
- 以
kylin:ukui-vnc-slim
为底座,维持已打通的 UKUI 桌面与 TigerVNC 图形会话(Xvnc 充当 X 服务器); - 将 Selkies 以 portable 方式解包进镜像,使用它的
selkies-gstreamer-run
把 X 会话编码、打包为 WebRTC; - 用自签证书为 HTTPS 提供“最低限度”的安全前提(测试环境够用);
- 自建 coturn,解决 NAT/防火墙后的打洞失败与媒体中继问题;
- 通过一段
entrypoint.sh
同时编排 VNC/Xvnc 与 Selkies 的运行序列、环境变量与 TURN/ICE 参数; - 以
docker run
一次性把 WebRTC 服务挂到宿主端口上,对外提供浏览器访问。
一句话架构(ASCII 示意):
Browser ⟷(HTTPS/WebRTC)⟷ selkies-gstreamer-run ⟷ GStreamer ⟷ Xvnc (:1) ⟷ UKUI/Apps
│
└──(STUN/TURN over 3478/5349 + UDP relay 50000-50100)── coturn
二、证书:为浏览器铺平“加密握手”的地砖
下面保留你教程中的完整命令与片段——用于生成测试证书,并在镜像中使用。
使用 openssl 生成证书
HOST=192.168.2.252
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout key.pem -out cert.pem \
-subj "/CN=$HOST" \
-addext "subjectAltName=DNS:$HOST,IP:$HOST"
证书用于下面 Dockerfile 的:
ADD key.pem /opt/
ADD cert.pem /opt/
RUN chmod 600 /opt/key.pem
RUN chmod 600 /opt/cert.pem
RUN chown dev:dev /opt/key.pem
RUN chown dev:dev /opt/cert.pem
解析:
- 这里的 CN/SAN 直接写了 IP,契合测试环境。生产建议申请有效域名证书(Let’s Encrypt/企业 CA),减少浏览器“不受信任”的交互障碍。
- 不建议在生产镜像直接打包私钥,请改为运行时挂载或使用机密管理(K8s Secret、Docker secrets、Vault)。
三、构建镜像:Selkies portable + 依赖清单 + Web 前端
Dockerfile(原样保留)
FROM kylin:ukui-vnc-slim
ENV DEBIAN_FRONTEND=noninteractive
USER root
# 严格按 start.md(portable)最低依赖;保持你现有清单
RUN apt-get update && apt-get install --no-install-recommends -y \
jq tar gzip ca-certificates curl libpulse0 wayland-protocols libwayland-dev libwayland-egl1 \
x11-utils x11-xkb-utils x11-xserver-utils xserver-xorg-core \
libx11-xcb1 libxcb-dri3-0 libxkbcommon0 libxdamage1 libxfixes3 \
libxv1 libxtst6 libxext6 xvfb \
&& rm -rf /var/lib/apt/lists/*
# 解包 portable 与前端
WORKDIR /opt
ADD selkies-gstreamer-portable-v1.6.2_amd64.tar.gz /opt/
ADD gst-web.tar.gz /opt/
ADD key.pem /opt/
ADD cert.pem /opt/
RUN chmod 600 /opt/key.pem
RUN chmod 600 /opt/cert.pem
RUN chown dev:dev /opt/key.pem
RUN chown dev:dev /opt/cert.pem
# 入口脚本
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& chown -R dev:dev /opt/selkies-gstreamer /opt/gst-web /usr/local/bin/entrypoint.sh
# 运行期可覆盖的“非敏感”默认
ENV SELKIES_ADDR=0.0.0.0 \
SELKIES_PORT=8080 \
SELKIES_ENABLE_HTTPS=false \
SELKIES_ENCODER=x264enc \
SELKIES_ENABLE_RESIZE=false \
SELKIES_WEB_ROOT=/opt/gst-web
EXPOSE 8080/tcp
USER dev
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
解析:
- 你选择了 portable 形态,避免系统级编译链与 GStreamer 插件地狱;
x11-*
与xorg-core
、xvfb
的组合确保最小 X 生态与调试工具到位;SELKIES_ENCODER=x264enc
是对兼容性的稳妥选择(浏览器普适,服务器资源消耗可控);SELKIES_ENABLE_HTTPS=false
只是默认值,实际运行时你在entrypoint.sh
里开启了 HTTPS 并直接指定证书,做法 OK。
四、入口脚本:把 VNC 与 Selkies 绑成一个“可呼吸”的进程树
entrypoint.sh(原样保留)
#!/usr/bin/env bash
set -Eeuo pipefail
# ------------------- VNC 基础参数 -------------------
: "${VNC_PASSWD:=changeme}"
: "${VNC_RESOLUTION:=1920x1080}"
: "${VNC_DEPTH:=24}"
: "${ENABLE_NOVNC:=1}"
echo "动态选择可用 DISPLAY 号,优先 :1"
#pick_display() {
# for n in 1 2 3 4 5; do
# if ! lsof -iTCP:$((5900+n)) -sTCP:LISTEN >/dev/null 2>&1 && \
# [ ! -e "/tmp/.X${n}-lock" ] && [ ! -S "/tmp/.X11-unix/X${n}" ]; then
# echo "${n}"
# return 0
# fi
# done
# echo "1"
#}
#DISPLAY_NUM="$(pick_display)"
#export DISPLAY=":${DISPLAY_NUM}"
export DISPLAY_NUM="1"
export DISPLAY=":1"
echo "DISPLY_NUM:${DISPLAY}"
# ------------------- VNC 启动(清理遗留) -------------------
mkdir -p "${HOME}/.vnc"
# 若没有密码文件则生成(非交互)
if [ ! -f "${HOME}/.vnc/passwd" ]; then
if command -v vncpasswd >/dev/null 2>&1; then
printf "%s\n" "${VNC_PASSWD}" | vncpasswd -f > "${HOME}/.vnc/passwd"
else
echo "WARN: vncpasswd 不存在,跳过生成密码文件;请确保镜像内含 TigerVNC 组件。" >&2
fi
fi
chmod 600 "${HOME}/.vnc/passwd" || true
echo "尝试终止同 DISPLAY 的老进程并清理锁"
vncserver -kill "${DISPLAY}" >/dev/null 2>&1 || true
rm -f "/tmp/.X${DISPLAY_NUM}-lock" || true
rm -f "/tmp/.X11-unix/X${DISPLAY_NUM}" || true
echo "启动 VNC/Xvnc"
VNC_OPTS="-geometry ${VNC_RESOLUTION:-1920x1080} -depth ${VNC_DEPTH:-24} -localhost no -xstartup $HOME/.vnc/xstartup"
vncserver :1 $VNC_OPTS
#vncserver "${DISPLAY}" -geometry "${VNC_RESOLUTION}" -depth "${VNC_DEPTH}" -localhost no &
# 关键的 X 访问环境
export XAUTHORITY="${HOME}/.Xauthority"
command -v xhost >/dev/null 2>&1 && xhost +SI:localuser:"$(whoami)" >/dev/null 2>&1 || true
export DISPLAY="${DISPLAY:-:1}"
# ------------------- Selkies(portable) -------------------
echo "音频 / 运行时目录"
export PIPEWIRE_LATENCY="${PIPEWIRE_LATENCY:-128/48000}"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}"
export PULSE_RUNTIME_PATH="${PULSE_RUNTIME_PATH:-${XDG_RUNTIME_DIR}/pulse}"
export PULSE_SERVER="${PULSE_SERVER:-unix:${PULSE_RUNTIME_PATH}/native}"
export GST_DEBUG="${GST_DEBUG:-*:2}"
echo "日志写到 /tmp(dev 用户有权限)"
SELKIES_LOG="/tmp/selkies.log"
mkdir -p /tmp
# ------------------- TURN / ICE 配置 -------------------
# 兼容两套变量名:优先取 SELKIES_TURN_*,否则取 TURN_*,再否则给默认值
: "${TURN_HOST:=${SELKIES_TURN_HOST:-turn.example.com}}"
: "${TURN_PORT:=${SELKIES_TURN_PORT:-3478}}"
: "${TURN_PROTOCOL:=${SELKIES_TURN_PROTOCOL:-udp}}" # udp | tcp
: "${TURN_TLS:=${SELKIES_TURN_TLS:-false}}" # true | false
# 认证三选一:共享密钥(REST 短凭据)或 用户名/密码(长期凭据)
: "${TURN_SHARED_SECRET:=${SELKIES_TURN_SHARED_SECRET:-}}"
: "${TURN_USERNAME:=${SELKIES_TURN_USERNAME:-}}"
: "${TURN_PASSWORD:=${SELKIES_TURN_PASSWORD:-}}"
echo "启动 selkies-gstreamer-run"
/opt/selkies-gstreamer/selkies-gstreamer-run \
--addr="${SELKIES_ADDR:-0.0.0.0}" \
--port="${SELKIES_PORT:-8080}" \
--enable_https=true \
--https_cert="/opt/cert.pem" \
--https_key="/opt/key.pem" \
${SELKIES_BASIC_AUTH_USER:+--basic_auth_user="${SELKIES_BASIC_AUTH_USER}"} \
${SELKIES_BASIC_AUTH_PASSWORD:+--basic_auth_password="${SELKIES_BASIC_AUTH_PASSWORD}"} \
--encoder="${SELKIES_ENCODER:-x264enc}" \
--enable_resize="${SELKIES_ENABLE_RESIZE:-false}" \
--web_root="${SELKIES_WEB_ROOT:-/opt/gst-web}" \
--turn_host="${TURN_HOST}" \
--turn_port="${TURN_PORT}" \
--turn_protocol="${TURN_PROTOCOL}" \
--turn_tls="${TURN_TLS}" \
${TURN_SHARED_SECRET:+--turn_shared_secret="${TURN_SHARED_SECRET}"} \
${TURN_USERNAME:+--turn_username="${TURN_USERNAME}"} \
${TURN_PASSWORD:+--turn_password="${TURN_PASSWORD}"}
#--enable_https="${SELKIES_ENABLE_HTTPS:-true}" \
#${SELKIES_HTTPS_CERT:+--https_cert="/opt/cert.pem"} \
#${SELKIES_HTTPS_KEY:+--https_key="/opt/key.pem"} \
# 前台常驻;需要看日志:docker exec -it <cid> tail -f /tmp/selkies.log
tail -f /dev/null
解析与经验补充:
- 你把
DISPLAY=:1
显式固定,减少了“同机多容器/多会话”争用;注释的pick_display
逻辑则是后续扩展的伏笔。- 这里存在一个小拼写:
echo "DISPLY_NUM:${DISPLAY}"
(少了 A);不影响运行,但建议后续修正为DISPLAY_NUM
以免困惑。xhost +SI:localuser:$(whoami)
是 X 访问白名单,配合XAUTHORITY
,解决了部分 GStreamer/X 源读取受限的问题。- TURN 变量做了两套命名兼容(
SELKIES_TURN_*
与TURN_*
),很好地兼顾了镜像内部与外部调用习惯。tail -f /dev/null
用于容器保活,真正的日志可docker exec -it <cid> tail -f /tmp/selkies.log
观看。
五、启动容器:将配置注入运行时,完成端口暴露
最后的启动命令(原样保留)
docker build -t kylin:ukui-vnc-selkies .
docker run -itd \
--name kylin-selkies \
-e VNC_PASSWD='changeme' \
-e VNC_RESOLUTION='1920x1080' \
-e SELKIES_ADDR='0.0.0.0' \
-e SELKIES_PORT='8080' \
-e SELKIES_BASIC_AUTH_USER='user' \
-e SELKIES_BASIC_AUTH_PASSWORD='changeme' \
-e TURN_HOST=192.168.2.252 \
-e TURN_PORT=3478 \
-e TURN_USERNAME=selkies \
-e TURN_PASSWORD='STRONG_Secret_Passw0rd' \
-e TURN_PROTOCOL=udp \
-e TURN_TLS=false \
-p 8980:8080 \
--hostname devbox \
kylin:ukui-vnc-selkies
解析:
- 你用 Basic Auth 提供最简访问控制,切合测试;生产应改为上游反向代理(Nginx/Traefik)统一鉴权;
- 暴露 8080 到 8980,浏览器即以
https://宿主:8980
访问;- TURN 参数注入与
entrypoint.sh
的兼容逻辑配合,能做到“容器内外口径一致”。
六、TURN 服务:穿越 NAT 的渡船
这里使用自建 TURN 服务,解决 WebRTC 在 NAT 后面通信失败的问题。
(以下内容按你的教程原样保留)
1) 准备最小配置 turnserver.conf
# 基础端口
listening-port=3478
tls-listening-port=5349
# 收窄中继端口,便于只开一小段防火墙
min-port=50000
max-port=50100
# 认证(长凭据,简单粗暴)
lt-cred-mech
realm=turn.example.com
user=selkies:STRONG_Secret_Passw0rd
# 生产建议启用 TLS(先注释,等你有证书再放开)
# cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
# pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
# 如果这台 TURN 在内网、有公网映射,务必写 external-ip
# external-ip=203.0.113.10/10.0.0.5
fingerprint
no-sslv3
no-tlsv1
no-tlsv1_1
2) 用 Docker 一条命令跑起来
docker run -d --name coturn \
-v $(pwd)/turnserver.conf:/etc/coturn/turnserver.conf:ro \
-p 3478:3478/udp -p 3478:3478 \
-p 5349:5349 \
-p 50000-50100:50000-50100/udp \
--restart unless-stopped \
coturn/coturn:latest
官方仓库在
coturn/coturn
,其 README 也给了最简docker run
示例(映射 3478/5349 以及一段 UDP 中继端口)。GitHub
3)(可选)docker-compose 版本
version: "3.8"
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
restart: unless-stopped
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
# - ./certs:/etc/letsencrypt/live/turn.example.com:ro # 如用 TLS
ports:
- "3478:3478/udp"
- "3478:3478"
- "5349:5349"
- "50000-50100:50000-50100/udp"
解析与要点:
- 端口面:3478(明文 STUN/TURN),5349(TLS),以及一段中继 UDP 端口(50000–50100);云上安全组/本地防火墙务必放通对等;
- external-ip:当 TURN 在内网映射到公网时,必须设置正确的
external-ip=公网/内网
,否则候选地址不正确,P2P/中继都会失败;- 认证机制:教程中使用长期凭据(固定用户名/密码);生产可考虑REST 短期凭据(共享密钥签发临时
user:timestamp:hmac
),更安全;- 传输协议:优先 UDP;当企业网络限制 UDP 时,退回 TCP/TLS(代价是时延增加)。
七、关键参数速查(便于“把准脉搏”)
SELKIES_ADDR/SELKIES_PORT
:Selkies HTTP(S) 服务监听地址/端口;--enable_https
+--https_cert/--https_key
:直接在 Selkies 进程内启用 TLS;SELKIES_ENCODER
:x264enc
为稳妥选择;如要继续压榨带宽/画质,可探索 GStreamer 的管线参数(注意浏览器兼容性与 CPU 开销);TURN_HOST/PORT/PROTOCOL/TLS
:与 coturn 匹配;UDP 优先,TCP/TLS 兜底;TURN_USERNAME/TURN_PASSWORD
或TURN_SHARED_SECRET
:二选一或三选一,与你的 coturn 配置一致;VNC_RESOLUTION/VNC_DEPTH
:决定 Xvnc 的画面尺寸与色深,尺寸越大,编码压力越高;XDG_RUNTIME_DIR/PULSE_*
:音频路径与 PipeWire/PA 运行目录;GST_DEBUG
:把日志开到*:2
,遇问题可临时升到3/4
定位。
八、常见故障与实话实说的解法
1. 浏览器连上了,但黑屏/只有鼠标
- 确认容器里
Xvnc
确实起在:1
,并且ukui-session
/面板在跑; - 在容器里尝试:
DISPLAY=:1 xeyes &
或xterm &
,若仍不可见,说明 Xvnc 侧没画面; - 检查
~/.vnc/xstartup
是否正确启动会话(UKUI/DBus),以及权限问题(XAUTHORITY
、xhost
已在脚本处理)。
2. HTTPS 报证书错误或 Selkies 日志里见 ssl.SSLError: PEM lib
- 常见原因:
cert.pem
/key.pem
不配套、私钥带 passphrase、PEM 格式错误、权限不可读; - 你的 Dockerfile 已
chmod 600
且chown dev:dev
,路径参数在entrypoint.sh
里明确,逐项核对路径与文件内容; - 若以 IP 访问,务必在证书的 SAN 里包含该 IP(你已包含)。
3. 一直卡在 “ICE gathering / Checking”
- 九成是 TURN 不通:
- 放通 3478/5349 及 UDP relay 端口段;
- 内网部署 + 公网端口映射时,填好
external-ip
; - 浏览器端 DevTools →
chrome://webrtc-internals
看候选与连通性。 - 确认客户端也能出站访问 TURN(企业内网经常对 UDP 严管)。
4. 画质/时延不稳定
- 优先保证 TURN 的 UDP 路径;
- 服务器 CPU 忙/超量并发导致编码抖动,监控 GStreamer/系统负载;
- 降分辨率或降低帧率,先稳住体验再回拉参数。
九、生产化建议(让系统“经得起时间吹落枝头的尘埃”)
- 密钥与凭据管理:不要把
key.pem
/cert.pem
打进生产镜像;用 secrets/挂载;Basic Auth 放到反向代理; - 统一入口:上游 Nginx/Traefik 终止 TLS,Selkies 走内网 HTTP,配合 WAF/审计;
- 可观测性:把
selkies.log
收拢到日志系统;对 TURN 端口做健康探测与流量统计; - 资源隔离:每个容器限制 CPU/内存,避免单体过载拖垮整机;
- 升级策略:Selkies/GStreamer/浏览器编解码一直在演进,分支冻结一套“可复现”版本,逐步灰度。
结语
技术是桥,网络是河,容器是岛。
你为它安了一扇窗(VNC),如今又建起了一座桥(Selkies)。
当 TURN 的渡船穿过企业防火墙的折叠海岸,
当 GStreamer 的流水在浏览器端汇成一面清澈的湖,
人与系统的距离,被压缩到“延迟”的两三个音节里。
这篇博文既保留了你全部的操作细节,也补齐了背后的工程逻辑与生产尺度。
愿它不仅能“跑通”,更能“跑久”。