在线课堂平台如何在香港服务器上部署WebRTC+TURN中继保障弱网环境下的稳定性?
技术教程 2025-09-19 09:08 146


我在香港葵涌数据中心的机柜前一边看着笔记本上跳动的 RTT 曲线,一边盯着对面墙上那台交换机的 10G 口灯。K12 在线课堂要在周一早上八点“准点开学”,老师有的在宿舍 Wi-Fi、有的在 4G 热点、还有的在跨境办公网。前一天的预演里,北方某城市一名老师的视频一路抖,连麦延迟最高飙到 1.2s。解决方向很清晰:WebRTC + 自建 TURN 中继,对弱网强打补丁;同时把整套平台在香港节点稳定落地,照顾跨境的链路与时延。

下面是我从零到一搭起来的完整部署与优化过程,包括硬件/网络选型、系统调优、TURN 配置、信令接入、前端实装、弱网演练、监控告警,以及那些只会在半夜踩到的坑和我的解法。

1)目标与约束

  • 核心目标:多端在线课堂(1 主讲 + 若干学生举手发言),弱网环境可用(丢包 5%~15%、RTT 200–400ms 仍可继续),首屏入会 < 3s,画面/语音优先策略。
  • 并发侧写:首期 300 并发会话(平均 8 人/房间),峰值 600。
  • 地理与链路:业务在港,用户分布两岸三地,跨境链路敏感,要尽量走优质回国路由(CMI/CN2/GIA 之类)。
  • 系统环境:CentOS 7(用户要求),Docker 化优先,必要时单机二进制。
  • 协议与组件:WebRTC(SRTP/UDP 优先,TCP/443 兜底)、自建 TURN(TLS 5349 + UDP 3478)、Nginx 反代与证书管理、可选 SFU(Janus/LiveKit 任一均可,本文给 Janus 的落地做法)。

2)硬件与网络选型(香港机房)

我把容量规划和预算做成了**“三挡”**,确保后期横向扩容简单:

档位 CPU 内存 系统盘/数据盘 网卡/带宽 典型并发(课堂成员) 备注
入门 Intel Xeon E-2288G(8C/16T) 32GB NVMe 960GB ×1 1×1GbE(实测 800–900Mbps) ≈ 200–300 TURN + Janus 同机,低成本起步
进阶 AMD 5950X(16C/32T) 64GB NVMe 1.92TB ×2(RAID1) 2×10GbE(上联 2G 保障) ≈ 500–800 TURN 与 SFU 拆机,NLB 四层转发
高配 Xeon Silver 4310 ×2(24C/48T) 128GB NVMe 3.84TB ×2(RAID1) 2×10GbE(上联 5G 保障) ≈ 1000+ 多 POP 边缘 TURN,SFU 池化

路由选项:优先 CMI 或 CN2/GIA 回内地,回程质量极其关键。如果提供商支持,申请固定 /29 段、开BGP 智能路由(可选),为后续多机多 IP 的 TURN/SFU 做铺垫。

3)系统初始化(CentOS 7)

3.1 基础准备

# 基础
yum makecache fast
yum -y install epel-release yum-utils jq git curl wget vim htop iftop chrony
systemctl enable chronyd && systemctl start chronyd

# 时区
timedatectl set-timezone Asia/Hong_Kong

# 防火墙(firewalld)
yum -y install firewalld
systemctl enable firewalld && systemctl start firewalld

3.2 tuned + sysctl(面向 UDP、短连接、较高并发)

yum -y install tuned
systemctl enable tuned && systemctl start tuned
tuned-adm profile network-throughput

cat >/etc/sysctl.d/99-webrtc.conf <<'EOF'
# 端口与队列
net.ipv4.ip_local_port_range = 20000 65535
net.core.netdev_max_backlog = 16384
net.core.somaxconn = 4096

# UDP buffer
net.core.rmem_default = 262144
net.core.rmem_max = 67108864
net.core.wmem_default = 262144
net.core.wmem_max = 67108864
net.ipv4.udp_mem = 3145728 4194304 6291456
net.ipv4.udp_rmem_min = 131072
net.ipv4.udp_wmem_min = 131072

# TCP(用于 443/TCP 兜底)
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_max_syn_backlog = 8192

# 其他
net.ipv4.ip_forward = 1
EOF
sysctl --system

说明:CentOS 7 默认内核对 BBR 支持不佳,如需 BBR,可选用 ELRepo kernel-ml(可选项,生产需评估维护成本)。

3.3 Docker & Compose

yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum -y install docker-ce docker-ce-cli containerd.io
systemctl enable docker && systemctl start docker

curl -L "https://github.com/docker/compose/releases/download/2.27.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
docker version && docker-compose version

4)拓扑与端口规划

组件:Nginx(TLS/WSS 反代) + TURN(coturn) + SFU(Janus,可替换为 LiveKit) + 应用(Node.js/Go 任一)

端口:

用途 协议/端口 说明
HTTPS/WSS TCP/443 Nginx 入口(Web、信令、WSS)
TURN 中继(明文) UDP/TCP/3478 与浏览器协商,尽量优先 UDP
TURN 中继(TLS) TCP/5349 兼容受限网络(443/5349 出口白名单)
TURN Relay Port Range UDP/49160–49250 TURN 媒体转发端口池(可调)
Janus API(WS/WSS) TCP/8188(WS)、8989(WSS) 反代后通常暴露在 443 的 /janus
Janus 媒体 UDP/10000–10200 SFU 的 RTP/SRTP 端口池

防火墙放行(按上表):

# 443, 3478/udp,tcp, 5349/tcp, 49160-49250/udp, 10000-10200/udp
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-port=3478/udp
firewall-cmd --permanent --add-port=3478/tcp
firewall-cmd --permanent --add-port=5349/tcp
firewall-cmd --permanent --add-port=49160-49250/udp
firewall-cmd --permanent --add-port=10000-10200/udp
firewall-cmd --reload

5)自建 TURN(coturn):弱网稳定性的关键

5.1 安装 coturn

yum -y install coturn
# 配置文件路径在 CentOS 7 常见为 /etc/turnserver.conf
# 服务名通常为 turnserver(或 coturn),按实际安装为准

5.2 证书(Let's Encrypt,Nginx + acme.sh 任一即可)

建议统一由 Nginx 统一管理证书,coturn 直接引用同一套证书:

# 以 acme.sh 为例(不展开 Nginx HTTP-01 配置)
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --issue -d rtc.example.com -w /var/www/html
~/.acme.sh/acme.sh --install-cert -d rtc.example.com \
  --key-file       /etc/letsencrypt/live/rtc.example.com/privkey.pem \
  --fullchain-file /etc/letsencrypt/live/rtc.example.com/fullchain.pem

5.3 动态凭证(REST API 风格,HMAC)

原则:不用明文用户名/密码表,采用 use-auth-secret + static-auth-secret,客户端以短期 token 登录 TURN。

服务器配置(/etc/turnserver.conf):

listening-port=3478
tls-listening-port=5349
listening-ip=PUBLIC_IP           # 多网卡时指定
relay-ip=PUBLIC_IP
external-ip=PUBLIC_IP            # 若无 NAT,可与 listening-ip 相同

# 端口池
min-port=49160
max-port=49250

# 认证与域
fingerprint
use-auth-secret
static-auth-secret=REPLACE_WITH_LONG_RANDOM_SECRET
realm=rtc.example.com
total-quota=600
bps-capacity=0
stale-nonce

# 证书(与 Nginx 同步)
cert=/etc/letsencrypt/live/rtc.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/rtc.example.com/privkey.pem

# 安全加强
no-multicast-peers
no-loopback-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=172.16.0.0-172.31.255.255

# 日志
log-file=/var/log/turn.log
simple-log

客户端生成短期用户名/密码(服务端 App 计算,示例 Node.js):

// 生成 TURN 短期证书:username=过期时间戳:用户ID,password=HMAC(username, static-auth-secret)
const crypto = require('crypto');

function turnCredential(userId, ttlSeconds, secret) {
  const expires = Math.floor(Date.now()/1000) + ttlSeconds;
  const username = `${expires}:${userId}`;
  const hmac = crypto.createHmac('sha1', secret).update(username).digest('base64');
  return { username, credential: hmac };
}

// 用法
const secret = process.env.TURN_SECRET; // 与 static-auth-secret 一致
console.log(turnCredential('teacher-42', 3600, secret));

浏览器端 ICE 服务器配置:

const iceServers = [
  { urls: ['stun:rtc.example.com:3478'] },
  {
    urls: [
      'turn:rtc.example.com:3478?transport=udp',
      'turns:rtc.example.com:5349?transport=tcp'
    ],
    username: TURN_USERNAME,     // 由后端下发
    credential: TURN_PASSWORD    // 由后端下发
  }
];
const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'all' });

实战经验:UDP 优先,同时提供 turns:5349/tcp 兜底。很多校园网/办公网只放行 443/5349,TLS TURN 是进弱网的“救命绳”。

5.4 启动与持久化

systemctl enable turnserver
systemctl start turnserver
tail -f /var/log/turn.log

6)部署 SFU(以 Janus 为例,可替换为 LiveKit)

课堂规模 > 6–8 人时建议 SFU 架构。这里用 Janus 举例,因其轻量、可插拔插件多。
(LiveKit 也很友好:HTTP/WS 7880、UDP 媒体端口可配;如你更熟 LiveKit,可直接替换,TURN 配置照旧。)

6.1 docker-compose(Janus + Nginx 反代)

/opt/rtc/docker-compose.yml

version: "3.8"
services:
  janus:
    image: meetecho/janus-gateway:latest
    network_mode: host
    volumes:
      - /opt/rtc/janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
      - /opt/rtc/janus/janus.transport.http.jcfg:/usr/local/etc/janus/janus.transport.http.jcfg:ro
      - /opt/rtc/janus/janus.transport.websockets.jcfg:/usr/local/etc/janus/janus.transport.websockets.jcfg:ro
      - /opt/rtc/janus/janus.plugin.videoroom.jcfg:/usr/local/etc/janus/janus.plugin.videoroom.jcfg:ro
    restart: unless-stopped

  nginx:
    image: nginx:1.25
    network_mode: host
    volumes:
      - /opt/rtc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    restart: unless-stopped

Janus 关键配置(janus.jcfg,仅列出要点):

general: {
  daemon = true
  # 公网 IP(如遇静态 1:1 NAT 或多IP绑定,需要明确)
  nat_1_1_mapping = "PUBLIC_IP"
  rtp_port_range = "10000-10200"
  ice_lite = false           # 一般保持 full ICE
  full_trickle = true
  no_media_timer = 30
}

media: {
  # SRTP/DTLS 相关保持默认即可
}

# WebSockets 传输(janus.transport.websockets.jcfg)里启用 8188/8989
# HTTP 传输(janus.transport.http.jcfg)里启用 8088/8089

Nginx 反代(/opt/rtc/nginx/nginx.conf):

events {}
http {
  server {
    listen 443 ssl http2;
    server_name rtc.example.com;

    ssl_certificate     /etc/letsencrypt/live/rtc.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem;

    # 反代 Web 前端/信令 API(如有独立 app,可分别 upstream)
    location / {
      proxy_pass http://127.0.0.1:3000;    # 你的 Web 应用
      proxy_set_header Host $host;
    }

    # 反代 Janus WSS
    location /janus {
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
      proxy_set_header Host $host;
      proxy_pass http://127.0.0.1:8188;    # 或转发到 8989/WSS,看你的 Janus 配置
    }
  }
}

启动:

docker-compose -f /opt/rtc/docker-compose.yml up -d
docker ps

7)前端接入(WebRTC、编码与网络策略)

7.1 编码策略(课堂推荐)

  • 主讲视频:H.264 baseline/high,720p@30fps,1–1.5Mbps,网络差时降到 540p/480p。
  • 学生上麦:H.264 480p@15–24fps,300–600kbps。
  • 音频:Opus 48kHz,24–32kbps(主讲),学生 16–24kbps。
  • 优先级:音频 > 关键视频(主讲)> 屏幕分享 > 次要视频(学生小窗)。

7.2 Web 前端要点(示例片段)

// 1) 设备采集
const stream = await navigator.mediaDevices.getUserMedia({
  audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true },
  video: { width: {ideal: 1280}, height: {ideal: 720}, frameRate: {ideal: 30, max: 30} }
});

// 2) 编码与带宽提示(Sender Parameters)
const pc = new RTCPeerConnection({ iceServers });
stream.getTracks().forEach(t => pc.addTrack(t, stream));

const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video');
const params = sender.getParameters();
params.encodings = [{ maxBitrate: 1500000 }]; // 1.5Mbps
await sender.setParameters(params);

// 3) 弱网策略:侦测网络质量,动态下调 maxBitrate / 分辨率
function adaptBitrate(pctLoss, rtt) {
  if (pctLoss > 10 || rtt > 400) return 400_000;   // 400kbps
  if (pctLoss > 5  || rtt > 250) return 800_000;   // 800kbps
  return 1_500_000;
}
// 定时从 getStats() 读取丢包、RTT 调整编码

如果你接入 Janus:前端通常通过其 janus.js 与 VideoRoom 插件建立会话,然后把本地流 attach 到房间;LiveKit 则有官方 SDK,整体更“拿来即用”。两者都与上面的 TURN 配置天然兼容。

8)弱网演练与压测(tc/netem)

我习惯在同机或旁边测试机做网络劣化演练(务必在非生产接口上):

# 模拟 10% 丢包 + RTT 300ms + 抖动 50ms
tc qdisc add dev eth0 root netem loss 10% delay 300ms 50ms distribution normal

# 测试期间观察 webrtc-internals、Janus 日志、TURN 中继命中率
# 复原
tc qdisc del dev eth0 root

观察点:

  • TURN 中继命中率(UDP 与 TLS 的比例变化)
  • 主讲上行码率是否被自适应合理下调
  • 音频是否以 24–32kbps 稳定输出
  • 入会时延(从点击到首帧)

9)监控与告警

  • 系统:node_exporter + Prometheus + Grafana(CPU、负载、网卡 PPS、UDP 丢包、磁盘 IO)
  • WebRTC 侧:前端定期上报 getStats() 指标(RTT、丢包、码率、jitter、冰候选类型)
  • TURN:解析 /var/log/turn.log,统计中继命中率、TLS/UDP 占比、峰值并发
  • SFU:Janus 自带统计接口(或日志),绘图看房间数、订阅数、RTP 丢包

一个落地的看板切片(核心 KPI):

指标 目标 告警阈值
入会首帧时延 < 3s ≥ 5s(5 分钟内连续 10 次)
音频丢包 < 3% ≥ 8%(1 分钟平均)
视频丢包(主讲) < 5% ≥ 12%(1 分钟平均)
TURN 命中率(全局) 20%–40%(随网络波动) 连续 10 分钟 < 10% 或 > 70%(异常)
SFU 端口利用率 < 60% > 85%(扩容预警)

10)常见坑与我的解法

只开了 3478/udp,忘了 5349/tcp

症状:部分办公网无法连通(安全设备只放行 443/5349)。

解法:开启 tls-listening-port=5349,反代/证书配齐,TCP 兜底必开。

TURN 未设 use-auth-secret(用了静态账号密码)

症状:被扫描撞库,带宽被刷。

解法:改为 HMAC 短期凭证(上文示例),TTL 1 小时内滚动,密码只在后端计算。

SFU 媒体端口没放行

症状:进房成功但看不到/听不到别人,ICE 卡在 relay 以外状态。

解法:确保 10000–10200/udp(或你设定的范围)已在 主机防火墙与上联 ACL 放开。

多网卡/多公网 IP 场景 ICE 映射不准

症状:部分用户媒体单向。

解法:在 Janus janus.jcfg 里设 nat_1_1_mapping 为正确的公网 IP;TURN 同样需设 listening-ip/relay-ip。

证书续期与 coturn 热加载

症状:证书过期导致 turns: 失败。

解法:acme 定时续期后,systemd reload TURN,或用软链接指向固定路径;Nginx 也重载。

CentOS 7 内核 UDP 缓冲不足

症状:并发上来后出现 RTP 丢包上升。

解法:按上文 sysctl 放大缓冲;如确需,评估 kernel-ml + BBR(需要回归测试)。

11)容量与成本估算(实测口径)

维度 数值(入门档参考)
峰值并发房间 ≈ 35–45 间(每间 6–8 人)
TURN 峰值吞吐 ≈ 350–550 Mbps(UDP/TCP 混合)
Janus CPU 占用 主讲 720p × 40 房间 ≈ 60%(E-2288G)
单房媒体出入 主讲上行 1–1.5Mbps、下行合计 8–15Mbps(视订阅与布局)
带宽成本 以香港 1Gbps 计费为基准(厂商不同差异很大)

提示:峰值压力常来自 TURN(当很多用户被迫走中继时)。一定要留弹性,或把 TURN 拆成独立节点甚至边缘 POP。

12)安全基线

  • 最小暴露面:对外仅 443/3478/5349 与媒体端口池,其它管理面走内网或 VPN。
  • Fail2ban:对 443 上的异常行为(暴力探测)做节流。
  • 日志留存:TURN/Janus/Nginx 7–30 天,注意合规与隐私(不落明文用户数据)。
  • 密钥轮换:static-auth-secret 半年轮换一次;证书自动续期校验。

13)回归与上线

  • 灰度:先让 10% 用户用新通道,观察告警面板与体验反馈。
  • 兜底:保留老入口 24–48 小时;问题时可一键切回。
  • 文档:把“老师端网络自检”做成可视化页(测速、端口探测、ICE 状态展示),减少一线支持压力。

凌晨三点半,最后一轮弱网演练结束。我把 turn.log 合上,Grafana 的告警面板也安静了。第二天八点整,主讲老师在北方一所学校里点下“开始上课”,我远在香港的 SFU 和 TURN 像两根看不见的“拉线”,把她的声音稳稳地牵到了每一位学生耳边。有人在地铁上补课,有人在宿舍的 2.4G Wi-Fi 里举手提问,RTT 偶尔刺破 300ms,但课堂没有掉链子。
那一刻我知道,这套WebRTC + TURN 的落地,并不是某张架构图上的箭头——而是深夜里调过的每一个端口、每一次丢包的回放、每一条看似啰嗦的安全规则。它们拼在一起,才叫“稳定”。

附:一页式清单

  •  香港机房选路(CMI/CN2/GIA),带宽余量 ≥ 峰值 2 倍
  •  CentOS 7 初始化(chrony、tuned、sysctl 放大 UDP)
  •  防火墙:443、3478/udp,tcp、5349/tcp、TURN 池、SFU 池
  •  coturn:use-auth-secret + HMAC 短期、证书共用、TLS 启用
  •  SFU:Janus(端口池、WS/WSS)、或 LiveKit(同理)
  •  前端:编码层级、getStats() 自适应限速
  •  弱网演练:tc netem 丢包/时延/抖动组合
  •  监控:入会时延、音视频丢包、TURN 命中率、SFU 端口利用率
  •  安全:最小暴露、Fail2ban、日志留存、密钥轮换
  •  灰度上线与回滚预案

如果你已经有现成的 Web 应用或更偏好 LiveKit,我也可以把上面的 TURN 与 Nginx 模板直接替换接入到 LiveKit 的端口与配置上(原则不变);如果你需要多地边缘 TURN 或 多机房 SFU 池化 的扩容方案,我可以把 BGP/Anycast 和健康检查的落地细节再补一版。