在银河麒麟容器里落地 Selkies WebRTC:从 VNC 的孤岛,驶向低时延的海

在已构建好的 kylin:ukui-vnc-slim 基础镜像上,植入 Selkies(portable 形态),通过 WebRTC 把容器里的桌面“零厚度”地投递到浏览器端,获得更低时延与更稳定画质的交互体验。
VNC 是一条老路,Selkies 则像一座新桥——以 GStreamer 为梁、以 TURN 为墩,在网络的风雨里仍然稳固地把人和系统连接起来。

下文不仅完整保留你的全部教程内容与代码(不删一字),更补上“为什么这样做”与“如何在生产中站稳”的分析与排障要点,让这套实践既能跑起来,也能扛得住。


一、整体思路与系统形态

你在做的事:

  1. kylin:ukui-vnc-slim 为底座,维持已打通的 UKUI 桌面与 TigerVNC 图形会话(Xvnc 充当 X 服务器);
  2. 将 Selkies 以 portable 方式解包进镜像,使用它的 selkies-gstreamer-run 把 X 会话编码、打包为 WebRTC;
  3. 用自签证书为 HTTPS 提供“最低限度”的安全前提(测试环境够用);
  4. 自建 coturn,解决 NAT/防火墙后的打洞失败媒体中继问题;
  5. 通过一段 entrypoint.sh 同时编排 VNC/Xvnc 与 Selkies 的运行序列、环境变量与 TURN/ICE 参数;
  6. 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-corexvfb 的组合确保最小 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_ENCODERx264enc 为稳妥选择;如要继续压榨带宽/画质,可探索 GStreamer 的管线参数(注意浏览器兼容性与 CPU 开销);
  • TURN_HOST/PORT/PROTOCOL/TLS:与 coturn 匹配;UDP 优先,TCP/TLS 兜底;
  • TURN_USERNAME/TURN_PASSWORDTURN_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),以及权限问题(XAUTHORITYxhost 已在脚本处理)。

2. HTTPS 报证书错误或 Selkies 日志里见 ssl.SSLError: PEM lib

  • 常见原因:cert.pem/key.pem 不配套、私钥带 passphrase、PEM 格式错误、权限不可读;
  • 你的 Dockerfile 已 chmod 600chown 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 的流水在浏览器端汇成一面清澈的湖,
人与系统的距离,被压缩到“延迟”的两三个音节里。

这篇博文既保留了你全部的操作细节,也补齐了背后的工程逻辑与生产尺度。
愿它不仅能“跑通”,更能“跑久”。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注