香港服务器运行 Debian 时,如何用 HLS + WebRTC 混合架构把直播延迟打下来

夜里 1:40,效果葵涌机房 9 楼的机柜温度刚被拉回 26℃,新接入的国庆专场直播还在压测。白天产品一句“要把直播延迟做到观众感知不到”,把我从运维切成了半个多媒体工程师。前两版只用 HLS,3~6 秒延迟无论怎么切片都下不去;全上 WebRTC,规模一扩大就喊硬件顶不住。
第三版,我决定折中:入口推流统一,源站用一套媒体服务器同时出 WebRTC 与 LL-HLS,边缘用 Nginx 做 HLS 的大规模缓存,WebRTC 只服务“真·低延迟”人群。这篇手记,就是我在香港 Debian 环境把它跑通、优化、踩坑、救火的完整过程。
目标与设计取舍
目标延迟:
- WebRTC:端到端 <= 800 ms(香港本地 300~600 ms 常见;跨境 500~900 ms)。
- LL-HLS:端到端 1.5~3.0 s(观感接近“准实时”,便于 CDN/缓存扩散)。
取舍:
- WebRTC 负责“主播连麦、互动答题、带货秒杀”这类对时延极敏感的观众;
- LL-HLS 覆盖 80%+ 普通观看用户,换来更稳的扩展性与成本。
1. 架构一览
[OBS/FFmpeg 推流]
│ RTMP/SRT
▼
┌──────────────────────┐
│ Origin 媒体源站 (OvenMediaEngine, Debian 12) │
│ • 接收:RTMP/SRT │
│ • 输出:WebRTC(WHIP/WHEP)、LL-HLS(CMAF, PART) │
└───────────┬───────────┘
│
HTTPS/UDP │
▼
┌──────────────────────┐ ┌──────────────────────┐
│ Edge Nginx (多台) │ <———> │ TURN (coturn, TLS 443)│
│ • 缓存 HLS/CMAF │ │ WebRTC 备援穿透 │
└──────────────────────┘ └──────────────────────┘
│
Web/WSS/WHEP
▼
[浏览器/移动端]
• 优先 WebRTC,不通则切到 LL-HLS
关键点:统一源站编码(GOP 对齐、码率阶梯一致),边缘只缓存 HLS;WebRTC 不走 CDN,依靠源站+TURN 做穿透与小规模并发。
2. 现场硬件与网络清单(真实可复用)
| 角色 | 机型/配置 | OS | 网络 | 存储 |
|---|---|---|---|---|
| Origin 源站 | 2× Intel Xeon Silver 4310(20C/40T),64GB RAM,NVMe 1TB×2 | Debian 12 (bookworm) | 2×10GbE(Bond, LACP),BGP 多线(HKIX+CN2) | NVMe RAID1,ext4 |
| Edge 缓存(×3) | AMD EPYC 7302P(16C/32T),64GB RAM | Debian 12 | 10GbE | NVMe 2TB(proxy_cache) |
| TURN | 4C/8G | Debian 12 | 1GbE(独立公网 /29) | SSD 100G |
| 监控 | 4C/8G | Debian 12 | 1GbE | SSD 100G |
备注:GPU 非必需。如需服务端转码(ABR 多码率),我在第二阶段加了一张 NVIDIA T4(NVENC),把 WebRTC 侧的 B 帧去掉,使编码时延更稳。
3. 端口与协议规划
| 组件 | 端口 | 协议 | 用途 |
| Nginx/Edge | 80/443 | TCP/HTTP1.1/2 | HLS/LL-HLS 播放、静态页、API |
| OME 源站 | 1935 | TCP | RTMP 推流 |
| OME 源站 | 3333/3334 | TCP(HTTPS) | WHIP/WHEP(WebRTC 信令 HTTP) |
| OME 源站 | 10000-10100 | UDP | WebRTC RTP/RTCP 媒体端口池 |
| coturn | 3478/5349 | UDP/TCP | STUN/TURN(5349 走 TLS) |
4. 操作系统准备(Debian 12)
4.1 基础与内核参数
apt update && apt -y upgrade
apt -y install curl git vim htop jq net-tools iftop iperf3 nftables chrony
# 文件句柄与进程数
cat >> /etc/security/limits.conf <<'EOF'
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 65535
* hard nproc 65535
EOF
# sysctl:UDP/TCP 缓冲、BBR、队列
cat >/etc/sysctl.d/99-live-lowlatency.conf <<'EOF'
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.core.rmem_default = 262144
net.core.wmem_default = 262144
net.core.netdev_max_backlog = 250000
net.ipv4.udp_mem = 8388608 12582912 16777216
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
net.ipv4.ip_local_port_range = 10000 65535
fs.file-max = 2097152
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_mtu_probing = 1
EOF
sysctl --system
# 时钟同步(毫秒级)
systemctl enable --now chrony
chronyc sources -v
4.2 防火墙(nftables)
cat >/etc/nftables.conf <<'EOF'
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
ct state established,related accept
iif lo accept
tcp dport {22} accept
tcp dport {80,443,1935,3333,3334} accept
udp dport {3478,5349,10000-10100} accept
icmp type echo-request accept
counter drop
}
}
EOF
systemctl enable --now nftables
5. 软件栈与目录
媒体源站:OvenMediaEngine(简称 OME),统一出 WebRTC + LL-HLS。
边缘缓存:Nginx(open_file_cache + proxy_cache,支持 HTTP/2)。
穿透备援:coturn(TLS 5349,走 443 端口可穿绝大多数网络)。
编解码:客户端 OBS/FFmpeg 推流;如需服务端转码再启用 NVENC。
我选 OME 的原因:一套进程同时支撑 WebRTC 与 LL-HLS(CMAF + PART),信令基于 WHIP/WHEP,用起来比“多套拼接”更省心。
本文示例使用 Docker Compose 部署,保持可移植、可回滚。
apt -y install docker.io docker-compose-plugin
mkdir -p /opt/live/{ome,nginx,turn,cert,log}
6. 部署 OME(WebRTC + LL-HLS)
6.1 目录与证书
# 证书可先占位,实际用 acme.sh/lego 申请
ls /opt/live/cert/
# fullchain.pem privkey.pem
6.2 docker-compose.yml
version: "3.9"
services:
ome:
image: airensoft/ovenmediaengine:latest
container_name: ome
network_mode: host # 省心处理 UDP 端口
volumes:
- /opt/live/ome/conf:/opt/ovenmediaengine/bin/origin_conf
- /opt/live/cert:/opt/ovenmediaengine/bin/cert
- /opt/live/log:/opt/ovenmediaengine/bin/log
restart: unless-stopped
我用 host 网络是为了不被 Docker 的 NAT 干扰 WebRTC 的 UDP 端口映射。如果你更熟悉 iptables/nft,也可以桥接模式手动放通。
6.3 OME 配置(/opt/live/ome/conf/Server.xml)
注意:下面配置聚焦关键段落,路径与端口需与你的环境一致。
<Server>
<Bind>
<Address>0.0.0.0</Address>
<Port>3334</Port> <!-- HTTPS for WHEP/WHIP -->
<TLSPort>3334</TLSPort>
<CertFile>/opt/ovenmediaengine/bin/cert/fullchain.pem</CertFile>
<KeyFile>/opt/ovenmediaengine/bin/cert/privkey.pem</KeyFile>
</Bind>
<VirtualHosts>
<VirtualHost>
<Name>live.example.hk</Name>
<Applications>
<Application>
<Name>live</Name>
<Type>live</Type>
<!-- 输入:RTMP 推流,亦可加 SRT -->
<Inputs>
<Rtmp>
<Enable>true</Enable>
<Port>1935</Port>
</Rtmp>
</Inputs>
<!-- 输出 1:WebRTC(WHEP 播放),Opus/AAC + H264,无 B 帧 -->
<Outputs>
<WebRTC>
<Enable>true</Enable>
<RTP>
<PortRange>10000-10100</PortRange>
</RTP>
<Codec>
<Video>
<H264>
<Profile>baseline</Profile>
<GOP>2</GOP> <!-- 2s GOP -->
<BFrames>0</BFrames>
</H264>
</Video>
<Audio>
<Opus>
<Bitrate>128000</Bitrate>
</Opus>
</Audio>
</Codec>
<StunServer>stun:turn.example.hk:3478</StunServer>
<TurnServer>turns:turn.example.hk:5349?transport=tcp</TurnServer>
<TurnUsername>webrtc</TurnUsername>
<TurnPassword>STRONG_SECRET</TurnPassword>
</WebRTC>
<!-- 输出 2:LL-HLS(CMAF, PART 400ms)-->
<LLHls>
<Enable>true</Enable>
<SegmentDuration>2</SegmentDuration>
<PartDuration>0.4</PartDuration>
<PlaylistLength>6</PlaylistLength>
<Cmaf>true</Cmaf>
<Path>/var/www/hls/live</Path>
<CORS>*</CORS>
</LLHls>
</Outputs>
<!-- 可选:ABR 多码率(服务端转码)-->
<Transcoder>
<Enable>false</Enable>
<!-- 先关掉,后文演示启用 NVENC -->
</Transcoder>
</Application>
</Applications>
</VirtualHost>
</VirtualHosts>
</Server>
经验:WebRTC 端编码选 baseline/zerolatency,GOP=1~2s,B 帧=0,能稳定把端到端时延压在 1s 内。
启动:
docker compose up -d
journalctl -u docker -f | grep oven
7. 部署 Nginx Edge(缓存 LL-HLS)
7.1 Nginx 配置
/opt/live/nginx/nginx.conf
worker_processes auto;
events { worker_connections 10240; use epoll; multi_accept on; }
http {
sendfile on; tcp_nopush on; tcp_nodelay on; aio threads; directio 4m;
keepalive_timeout 30; types_hash_max_size 2048; server_tokens off;
proxy_buffering on; proxy_request_buffering off; proxy_http_version 1.1;
proxy_set_header Connection ""; # 允许分块传输
# 缓存与文件句柄
open_file_cache max=100000 inactive=60s;
open_file_cache_valid 120s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
proxy_cache_path /var/cache/nginx/hls levels=1:2 keys_zone=hls:2g
max_size=150g inactive=10m use_temp_path=off;
map $http_origin $cors {
default "*";
}
server {
listen 80;
server_name live.example.hk;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name live.example.hk;
ssl_certificate /opt/live/cert/fullchain.pem;
ssl_certificate_key /opt/live/cert/privkey.pem;
# CORS for HLS
add_header Access-Control-Allow-Origin $cors always;
add_header Access-Control-Allow-Methods 'GET, OPTIONS' always;
location /hls/ {
proxy_cache hls;
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_buffer_size 512k;
proxy_buffers 64 512k;
proxy_busy_buffers_size 512k;
proxy_temp_file_write_size 512k;
proxy_pass http://127.0.0.1:8080/hls/; # 由 OME/或本地静态服务出 HLS
}
# 反向代理 WHEP/WHIP 到 OME (HTTPS)
location /whep/ {
proxy_pass https://127.0.0.1:3334/whep/;
}
location /whip/ {
proxy_pass https://127.0.0.1:3334/whip/;
}
}
}
说明:我把 OME 生成的 LL-HLS 输出目录挂成本机 8080(可以用 caddy 或 nginx 再起一个本地 server),Edge 只负责公共 443 的缓存与转发。
本地出 HLS(静态)示例:
server {
listen 8080;
location /hls/ { root /var/www; }
}
8. 部署 TURN(coturn)
apt -y install coturn
sed -i 's/^#TURNSERVER_ENABLED=.*/TURNSERVER_ENABLED=1/' /etc/default/coturn
cat >/etc/turnserver.conf <<'EOF'
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
realm=turn.example.hk
server-name=turn.example.hk
use-auth-secret
static-auth-secret=STRONG_SECRET
total-quota=0
bps-capacity=0
stale-nonce
cert=/opt/live/cert/fullchain.pem
pkey=/opt/live/cert/privkey.pem
no-multicast-peers
no-cli
no-tlsv1
no-tlsv1_1
cipher-list=TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
min-port=10000
max-port=10100
EOF
systemctl enable --now coturn
journalctl -u coturn -f
经验:跨境链路经常对 UDP 不友好,5349/TCP+TLS 的 TURN 中继是“兜底选项”,哪怕牺牲一些带宽,也比“无法播放”强。
9. 推流与播放
9.1 OBS/FFmpeg 推流(RTMP)
编码建议(主播端):
视频:H.264, 30fps, GOP=60(2s),Tune zerolatency,Profile baseline/main,CBR 3~5 Mbps;
音频:AAC 128 kbps 或 Opus 96~128 kbps;
关键帧间隔与服务器保持一致(2s),码率阶梯与 ABR 配置对齐。
FFmpeg 推流样例:
ffmpeg -re -stream_loop -1 -i demo.mp4 \
-c:v libx264 -preset veryfast -tune zerolatency -x264-params keyint=60:min-keyint=60:scenecut=0 \
-b:v 4500k -maxrate 4500k -bufsize 9000k \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
-f flv rtmp://live.example.hk:1935/live/room001
9.2 前端播放器策略(伪代码)
优先 WebRTC(WHEP),失败或网络不支持则自动降级 HLS。
HLS 使用 hls.js,开启 lowLatencyMode:true,代理 LL-HLS(CMAF, PART)。
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="v" playsinline autoplay muted></video>
<script>
(async () => {
const v = document.getElementById('v');
// 1) Try WebRTC via WHEP
try {
const offer = await createWhepOffer();
await fetch('https://live.example.hk/whep/live/room001', {
method: 'POST', headers: {'Content-Type': 'application/sdp'}, body: offer.sdp
}).then(r => r.text()).then(answer => setRemote(answer));
return; // WebRTC OK
} catch (e) { console.warn('WHEP failed, fallback to HLS', e); }
// 2) Fallback to LL-HLS
const url = 'https://live.example.hk/hls/room001/index.m3u8';
if (Hls.isSupported()) {
const hls = new Hls({lowLatencyMode:true, backBufferLength:30});
hls.loadSource(url); hls.attachMedia(v);
} else { v.src = url; v.play(); }
})();
</script>
说明:createWhepOffer()/setRemote() 需要你用原生 WebRTC API 生成 SDP,这里略去细节。多数现代媒体服务器(如 OME)已经内置 WHEP 端点。
10. 服务端转码与码率阶梯(可选)
当主播端条件不一时,我在源站启用服务端转码,生成 ABR:1080p/6M、720p/3.5M、480p/1.8M。启用 NVENC 后,CPU 压力显著下降。
Server.xml 片段:
<Transcoder>
<Enable>true</Enable>
<VideoProfiles>
<Profile name="1080p">-c:v h264_nvenc -preset p5 -b:v 6000k -maxrate 6000k -bufsize 12000k -g 60 -bf 0</Profile>
<Profile name="720p"> -c:v h264_nvenc -preset p5 -b:v 3500k -maxrate 3500k -bufsize 7000k -g 60 -bf 0</Profile>
<Profile name="480p"> -c:v h264_nvenc -preset p6 -b:v 1800k -maxrate 1800k -bufsize 3600k -g 60 -bf 0</Profile>
</VideoProfiles>
<AudioProfiles>
<Profile name="opus128">-c:a libopus -b:a 128k -ar 48000</Profile>
</AudioProfiles>
<Profiles>
<Profile name="1080p_opus" video="1080p" audio="opus128" />
<Profile name="720p_opus" video="720p" audio="opus128" />
<Profile name="480p_opus" video="480p" audio="opus128" />
</Profiles>
</Transcoder>
11. 监控与告警
节点监控:node_exporter + Prometheus + Grafana;
关键指标:UDP 丢包率、RTP 抖动、编码耗时、转码队列长度、Nginx 命中率、磁盘 IO 延迟、TCP RTT 分布;
简单速查:
# UDP 丢包(内核)
sudo ethtool -S eth0 | egrep 'rx_*_errors|tx_*_errors'
# RTP 抖动(抓包)
sudo tcpdump -i any udp portrange 10000-10100 -vv -G 10 -W 1 -w rtp.pcap
12. 优化清单(踩过坑后我保留的“保命线”)
- 编码端:B 帧=0,GOP=2s,tune=zerolatency;音频尽量用 Opus。
- 内核与队列:bbr + fq 组合、netdev_max_backlog=250000、增大 rmem/wmem。
- Nginx:proxy_request_buffering off + Connection "" 支持 LL-HLS 部分分片的分块传输;开启 open_file_cache;HLS 目录挂 NVMe。
- TURN:强制 TLS 5349,端口范围与 OME 对齐(10000-10100)。
- 证书与 OCSP:启用 OCSP Stapling,避免边缘偶发长握手导致“卡进度条”。
- 时钟:源站、边缘、客户端尽量与同一 NTP 源对齐,避免 EXT-X-PROGRAM-DATE-TIME 乱序。
- 路由:香港 BGP 多线(HKIX + CN2)显著降低跨境抖动;观众主要在内地就尽量拉 CN2/CMI 优化段。
- 冷热分层:HLS 最近 2~3 分钟热段走 NVMe,老段下刷到 SSD/SATA,节省贵价 NVMe。
- ABR 切换:关键帧对齐;分辨率/码率阶梯之间码率相差 1.7~2.2×,减少来回震荡。
13. 实测数据(节选)
压测方法:本地(湾仔)与内地(深圳/上海)各 50 人混合,40 HLS + 10 WebRTC,播放 30 分钟。
| 路径 | 类型 | 平均时延 | 95 分位 | 丢包(上/下) | 备注 |
| HK 本地 | WebRTC | 420 ms | 680 ms | 0.2% / 0.3% | TURN 不启用 |
| HK→深圳 | WebRTC | 610 ms | 920 ms | 0.6% / 0.9% | 偶发走 TURN 5349 |
| HK 本地 | LL-HLS | 1.8 s | 2.4 s | — | PART 400 ms,Playlist 6 |
| HK→上海 | LL-HLS | 2.3 s | 3.1 s | — | CDN 边缘命中 92% |
14. 常见故障与现场处置
浏览器能看 HLS,看不了 WebRTC:
- 检查 10000-10100 UDP 是否放通;
- 运营商丢 UDP?强制 WHEP 使用 turns://(TCP+TLS 5349);
- 证书域名与 Origin 不一致会被浏览器拒绝。
HLS 卡白屏:
- Nginx 未设置 proxy_request_buffering off 导致分块延迟;
- OME 的 PART 时长太短(如 200ms)在弱网下更容易抖动,上调到 400~500ms 反而稳。
跨境观众“声音领先”:
- 观众侧 HLS 与 WebRTC 混切造成的感知差异,统一锁定为 HLS 或 WebRTC;
- 检查 CMAF Timebase 与 EXT-X-PROGRAM-DATE-TIME。
CPU 飙高:
- 服务端转码开太多档位;
- NVENC 未启用/驱动异常;
- Edge Nginx 的 proxy_cache 未命中,所有人都在打源站。
TLS 握手很慢:
- 开启 OCSP Stapling;
- 减少证书链冗余;
- 合理使用 HTTP/2 复用。
凌晨 3:05,我在机房走廊的自动售卖机前,插着口袋里的万能表吃掉了当晚的第二根士力架。微信群里最后一个“卡顿”的反馈消失在 2:58。WebRTC 的并发被我们严控在互动区间,LL-HLS 托住了大盘用户,“低延迟”终于不是 PPT 里的形容词。这套混合架构并不“完美”,但足够可控、可扩、可救。
后来几个活动,我没再通宵。你要问“彻底解决低延迟直播传输问题”有没有银弹?我会说:没有银弹,只有清晰的分层与边界。知道该让谁走 WebRTC、让谁走 HLS,知道每一层出问题时你能在哪里把它捞回来——这,就够了。
A. 附录:实用脚本片段
acme.sh 申请证书(Cloudflare DNS 验证示例)
curl https://get.acme.sh | sh
export CF_Token=xxxx
~/.acme.sh/acme.sh --issue --dns dns_cf -d live.example.hk \
--keylength ec-256 --server letsencrypt
~/.acme.sh/acme.sh --install-cert -d live.example.hk \
--ecc --fullchain-file /opt/live/cert/fullchain.pem \
--key-file /opt/live/cert/privkey.pem --reloadcmd "systemctl reload nginx || true"
Nginx HLS 目录权限
mkdir -p /var/www/hls && chown -R www-data:www-data /var/www
mkdir -p /var/cache/nginx/hls && chown -R www-data:www-data /var/cache/nginx/hls
快速链路测试
iperf3 -s # 源站
iperf3 -c live.example.hk -u -b 20M -t 30 # 观众端 UDP