
我在香港葵涌数据中心的机柜前一边看着笔记本上跳动的 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 和健康检查的落地细节再补一版。