ZLMediaKit + WebRTC:本地低延迟直播链路实战部署指南
先说结论:如果你只是想在自己电脑上把“推流端 -> 媒体服务器 -> 播放端”这整条 WebRTC 直播链路跑通,ZLMediaKit 是目前最省事的选择之一。它本身是一个开源媒体服务器,天然支持 RTSP、RTMP、HTTP-FLV、HLS、WebRTC 这些主流协议,而且内置了 WebRTC 信令处理,不像 Janus、mediasoup 那样要你额外再搭一套独立服务。我最近在本地用 Docker 部署了一个 ZLMediaKit,推流用 OBS 和 FFmpeg,播放端用浏览器原生 WebRTC,完整测了一遍推拉流流程,这篇文章就是这次实战的记录。
文章面向两类人:一是刚接触音视频、想快速看到 WebRTC 低延迟效果的开发者;二是已经在用 ZLMediaKit 做直播但还没接 WebRTC 这条链路的同学。我会从协议选型讲起,然后给出一套可以直接抄的本地部署命令,再分别解决“怎么推流”和“怎么播放”,最后把我在实际调试中踩过的坑整理成一份排查清单。你不用把整篇背下来,跟着操作就能把流跑起来。
1. 为什么要选 ZLMediaKit:WebRTC 直播的选型逻辑
1.1 直播协议对比:延迟差异到底在哪
传统直播链路里,最常见的是 RTMP 推流加 HLS 播放。RTMP 本身是 TCP 长连接,适合推流但生态老化,浏览器端不能原生播放。HLS 是切成一个个小文件让播放器去轮询,切片和播放器缓存都会把延迟拉高。我之前在同一台机器上测试,RTMP 推流、HLS 播放,整体延迟经常在 5 秒到 10 秒之间。这个延迟在追剧场景下无所谓,但在应急指挥、视频通话、在线白板这类需要实时反馈的场景里根本没法用。
WebRTC 走的是 UDP 优先的 ICE 协商,经过 DTLS 握手和 SRTP 加密之后,媒体数据直接点对点传输,没有切片等待。它在设计之初就是为了浏览器实时通信,所以“浏览器端播放”这件事不需要装任何插件。实测在局域网环境下,WebRTC 从推流到播放的端到端延迟通常能压到 500 毫秒以内,体感上几乎是实时的。
1.2 ZLMediaKit 凭什么能接 WebRTC
很多人以为 WebRTC 就是“浏览器上的直播协议”,其实它不是一个像 RTMP 那样的简单流协议。WebRTC 连接建立之前要做 SDP 交换,协商音视频编解码器、ICE candidate;建立之后还要做 DTLS 握手、SRTP 加解密。这套东西如果全部自己写,工作量很大。ZLMediaKit 的价值在于把媒体面的这些脏活累活都做了,你只需要把它当成一个媒体服务器,按它的 HTTP API 发起推拉流请求,剩下的协商、加密、端口管理都由它处理。
它还有一个很实用的点:因为 ZLMediaKit 同时支持 RTMP、RTSP、HTTP-FLV 和 WebRTC,你可以在同一个服务里完成协议互转。比如推流端继续用 OBS 推 RTMP,播放端却用 WebRTC 拉流,延迟比直接用 FLV 还低。这正是我这次实战里采用的组合:推流用最成熟的 RTMP,播放走 WebRTC,两条链路互不冲突。
1.3 这套方案适合谁
本地调试是 ZLMediaKit 最有价值的场景之一。你不用花钱买云服务器、不用公网 IP,甚至不用连外网,只要一台可以跑 Docker 的电脑就能完整体验 WebRTC 直播链路。它适合测试自己的前端播放器、验证摄像头延迟、做小规模视频会议演示,也适合当成一个“直播链路试验台”,先跑通再往生产环境迁移。
当然,它不代表无限扩展。单机部署的 ZLMediaKit 在带宽和并发上有上限,海量用户同时观看时需要结合 CDN 或横向扩容方案。对于“先跑通、再优化”的目标,它已经足够。
2. 环境准备:5分钟先跑起一个 ZLMediaKit
2.1 用 Docker 部署官方镜像
我推荐用 Docker 部署 ZLMediaKit,省去编译依赖的麻烦。官方镜像做了精简,拿到手直接映射端口就能用。我的部署方式如下:
mkdir -p ~/zlm/config ~/zlm/data
docker run -d --name zlm \
-p 1935:1935 \
-p 554:554 \
-p 8080:80 \
-p 10000:10000/tcp \
-p 10000:10000/udp \
-p 8000:8000/udp \
-v ~/zlm/config:/opt/media/conf \
-v ~/zlm/data:/opt/media/bin/data \
--restart unless-stopped \
registry.cn-hangzhou.aliyuncs.com/zlmediakit/zlmediakit
端口说明:1935 是 RTMP 推流端口,554 是 RTSP 端口,8080 映射的是容器内的 HTTP 服务端口,10000 是 ZLMediaKit 默认的 RTP 接收端口,8000 是 WebRTC 媒体端口。第一条命令创建目录是为了挂载配置和数据,后续改配置不用进容器操作。
镜像拉取可能需要一点时间,启动后建议先看一眼日志:
docker logs -f zlm
日志正常会显示 HTTP 服务、RTMP 服务、RTSP 服务等监听端口。如果某一行显示“bind port failed”,说明宿主机端口被占用了,调整
-p
参数里的宿主机端口即可。
2.2 关键配置项:config.ini 里最需要盯住的三个点
ZLMediaKit 启动后,配置目录下会生成
config.ini
。本地调试时不用改太多,但有三个点必须理解:
第一,
[rtc]
段下的
externIP
。这表示告诉 WebRTC 的 ICE 模块,媒体流要走哪个 IP。本地测试直接用
127.0.0.1
或者本机局域网 IP 就行,不需要填公网。如果你之后部署到有公网 IP 的服务器上,一定要改成公网 IP,否则浏览器连接不上。
第二,
[rtc]
段下的
port
。ZLMediaKit 默认用 UDP 8000 接收 WebRTC 媒体流,这个端口要和 Docker 映射保持一致。如果映射的是
-p 8000:8000/udp
,配置里就不用动。
第三,
[general]
段下的
mediaServerId
。它本身是标识媒体服务器实例的字符串,出问题排查时会用到,一般不用改。
改完配置文件要重启容器:
docker restart zlm
为了确认服务正常,可以请求一下 HTTP API:
curl http://127.0.0.1:8080/index/api/getServerConfig
能返回 JSON 就说明服务活着。这一步做完,ZLM 就已经跑起来了。
3. 推流实战:把本机画面送进 ZLMediaKit
3.1 用 FFmpeg 推一条测试流
没有摄像头也不影响测试,FFmpeg 可以生成测试画面和测试音轨。我用的是
testsrc
加正弦波音源,推流命令如下:
ffmpeg -re \
-f lavfi -i testsrc=size=640x480:rate=25 \
-f lavfi -i sine=frequency=1000:sample_rate=44100 \
-c:v libx264 -preset ultrafast -tune zerolatency \
-c:a aac -b:a 128k \
-f flv rtmp://127.0.0.1:1935/live/test
这里解释几个关键参数。
-re
表示按原始帧率实时读取源,不然 FFmpeg 会瞬间把整段视频推完。
testsrc
是 FFmpeg 自带的彩色测试画面源,
sine
是自带的音频源。
-preset ultrafast
和
-tune zerolatency
是为了降低编码延迟,直播调试场景非常推荐。最后的 URL 分成三段理解:
rtmp://127.0.0.1:1935
是服务器地址,
live
是应用名,
test
是流名。ZLMediaKit 里流名是自由命名的,我用
test
方便记忆。
推流启动后,FFmpeg 会持续输出日志,看到
speed=1x
说明正常。此时可以拿 FFplay 先验证 RTMP 流是否通了:
ffplay rtmp://127.0.0.1:1935/live/test
能弹出窗口播放,说明推流这一步没问题。如果你想更贴近真实场景,可以换成摄像头设备和麦克风设备,FFmpeg 的
-f v4l2
、
-f avfoundation
等参数按平台选择就行。
3.2 用 OBS 推真实画面:最常见的生产推流方式
FFmpeg 适合自动化和测试,但日常直播大家更常用 OBS。OBS 设置很简单:打开“设置 -> 直播”,服务选“自定义”,服务器填
rtmp://127.0.0.1:1935/live
,串流密钥填
obs_test
。注意这里没有密码的概念,“串流密钥”本质上是流名的一部分,ZLMediaKit 会把推流后的完整地址识别为
live/obs_test
。
设置完点击“开始直播”,OBS 右下角状态会变成“直播中”。此时用 FFplay 或者 ZLMediaKit 的 HTTP-FLV 地址也能看到画面,但我这次的重点是 WebRTC 播放,所以流名只是用来定位媒体流。OBS 推流的优势是画面来源丰富:窗口、显示器、摄像头、浏览器源都可以直接加,比命令行方便得多。
3.3 进阶:用 WHIP 直接在浏览器端 WebRTC 推流
如果你不想经过 RTMP 转一手,想直接从浏览器 WebRTC 推流,ZLMediaKit 也支持 WHIP 协议。WHIP 的流程是:浏览器通过
getUserMedia
获取麦克风摄像头,创建
RTCPeerConnection
,把本地 SDP 通过 HTTP POST 发给 ZLMediaKit 的 WHIP 接口,服务器返回 answer SDP 后连接建立。
简化后的核心代码如下:
const pc = new RTCPeerConnection({ iceServers: [] });
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
await pc.setLocalDescription(await pc.createOffer());
await waitGatheringComplete(pc);
const res = await fetch(
'http://127.0.0.1:8080/index/api/whip?app=live&stream=test',
{
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
}
);
const answer = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
其中
waitGatheringComplete
是为了等 ICE candidate 收集完成后再发送 SDP,避免第一个 offer 里什么都没带。这个示例只保留直播演示最核心的逻辑,实际生产使用的 WHIP 客户端还要处理 candidate 的增量上报和资源生命周期管理,但作为本地跑通链路已经够了。
4. 播放实战:把 WebRTC 延迟压到毫秒级
4.1 用原生 JS 写一个 WHEP 播放器
WebRTC 播放端对应的是 WHEP 协议。播放时浏览器创建
RTCPeerConnection
,把本地 offer 的 SDP 发给 ZLMediaKit 的 WHEP 接口,服务器返回 answer 后,媒体轨道会通过
ontrack
回调交给播放器。
一个可以直接放到 HTML 里的最小示例:
<video id="player" muted autoplay playsinline></video>
<script>
async function waitGatheringComplete(pc) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') return resolve();
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') resolve();
});
});
}
async function startPlay() {
const pc = new RTCPeerConnection({ iceServers: [] });
pc.ontrack = (ev) => {
const video = document.getElementById('player');
video.srcObject = ev.streams[0];
video.play();
};
await pc.setLocalDescription(await pc.createOffer());
await waitGatheringComplete(pc);
const res = await fetch(
'http://127.0.0.1:8080/index/api/whep?app=live&stream=test',
{
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
}
);
const answer = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
}
startPlay();
</script>
提前说说几个关键点。
muted autoplay
是必要的,浏览器自动播放策略要求无声音媒体才能自动播放;如果你真的需要声音,得让用户主动点击页面后再调用播放。
playsinline
是针对移动端 Safari 的常见写法,可以防止真机浏览器弹全屏播放器。
iceServers: []
在本地环回测试里是够用的,因为候选地址都在本机;如果跨网络,需要配置 STUN 或 TURN 服务,但这是另一个话题。
4.2 给 Vue 项目封装一个可复用组件
很多前端项目都在用 Vue 做页面,WebRTC 播放器封装成组件后,后面接任何直播流都很方便。我先从实战角度直接把组件代码放出来:
<template>
<video ref="videoEl" muted controls autoplay playsinline></video>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const videoEl = ref(null);
let pc = null;
function waitGathering(pc) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') return resolve();
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') resolve();
});
});
}
async function play() {
const streamUrl =
'http://127.0.0.1:8080/index/api/whep?app=live&stream=test';
pc = new RTCPeerConnection({ iceServers: [] });
pc.ontrack = (ev) => {
if (videoEl.value) {
videoEl.value.srcObject = ev.streams[0];
videoEl.value.play && videoEl.value.play();
}
};
await pc.setLocalDescription(await pc.createOffer());
await waitGathering(pc);
const resp = await fetch(streamUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!resp.ok) {
throw new Error('WHEP request failed: ' + resp.status);
}
const answer = await resp.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
}
onMounted(play);
onBeforeUnmount(() => {
if (pc) pc.close();
});
</script>
组件里唯一的副作用就是
pc
,所以
onBeforeUnmount
里关闭连接是必须的。如果页面里同时有很多路画面,记得给每个组件都维护独立的
RTCPeerConnection
。这个组件的按钮交互和错误提示我没写,你们可以按自己的项目风格补。
4.3 播放效果验证与延迟测量
播放成功后,先不要急着相信眼睛,用一个可重复的方法量一下延迟。最简单的是“秒表法”:推流端用 OBS 抓手机屏幕上的秒表画面,播放端用另一个手机摄像头拍电脑显示器上的播放画面,对比两边秒表读数差。对本地 WebRTC 链路来说,这个差值应该在一秒以内,网络正常时通常只有几百毫秒。
想更精确,可以打开 Chrome 的
chrome://webrtc-internals
,在播放页面建立连接以后,这个页面会显示每个
RTCPeerConnection
的收发字节、丢包率、RTT 等指标。我实测丢包率接近 0 时,浏览器端播放和推流端画面基本同步。如果丢包率一直在上升,优先检查 Wi-Fi 信号和局域网是否有人占用大量带宽。
5. 常见问题与排查技巧实录
5.1 播放页面黑屏,但推送端日志正常
这种问题我遇到得最多。黑屏说明媒体流可能已经推上去了,但 WebRTC 连接没有真正把媒体数据交给浏览器。先做三件事:第一,用 FFplay 播放
rtmp://127.0.0.1:1935/live/test
,确认源流本身没问题;第二,打开浏览器控制台看是否有跨域、SDP 解析、ICE 相关报错;第三,确认 ZLMediaKit 的 WebRTC UDP 端口没有被防火墙拦截。
如果是本机测试,最常见的原因是浏览器默认对
http://127.0.0.1
下的页面放行安全权限,但如果你换成局域网 IP 访问页面,
getUserMedia
和 WebRTC 相关能力会被浏览器限制。此时要么用
localhost
访问,要么给页面配上 HTTPS。开发环境可以先用
openssl
生成本地证书,生产环境直接上合法的 HTTPS 证书。
5.2 WHIP 或 WHEP 请求返回 404 或 405
如果接口返回 404,先确认你用的 ZLMediaKit 版本支持 WHIP/WHEP。这个特性不是所有老版本都有。返看
docker logs zlm
里有没有打印 HTTP 路由,或者直接访问官方文档对照当前版本的 API。如果返回 405,看看自己是否用了
GET
请求,WHIP 推流和 WHEP 拉流一般都用
POST
提交 SDP。
另外要注意 URL 里的参数。
app
和
stream
必须和推流方一致。比如 FFmpeg 推的是
rtmp://127.0.0.1:1935/live/test
,那 WHEP 地址里就是
app=live&stream=test
。如果你用了 OBS 串流密钥
obs_test
,那就要改成
stream=obs_test
。
5.3 画面有声音但特别卡顿
人眼对延迟和卡顿是分开感知的。如果音频正常但视频卡,多半是编码参数没有针对直播调优。FFmpeg 推流时建议加上
-tune zerolatency
,这个参数会让编码器更偏向低延迟而不是压缩率。OBS 里对应的是“输出模式”切换到“高级”,然后把“关键帧间隔”设置为 1 或 2,“CPU 编码预设”改成“ultrafast”或“fast”。
如果是 WiFi 环境,信号波动也会造成 WebRTC 丢包重传。WebRTC 用 NACK 和 RTX 做丢包恢复,但代价是延迟升高。本地测试最好直接用网线连接,避免无线干扰造成误判。
5.4 换了公网环境就连不上
本地跑通之后,很多同学会想放到云服务器上测。这时最容易被坑的是
externIP
。ZLMediaKit 的 WebRTC 模块生成的 SDP 里带的公网地址来自
externIP
配置。如果你不填,它可能生成一个内网 IP 或者空地址,浏览器就找不到服务器。云服务器上部署时,把
[rtc] externIP
填成服务器的公网 IP,同时在安全组里放行 8000/udp 以及你配置的其他 UDP 端口。
另外,不要以为映射了 TCP 端口就万事大吉。WebRTC 的音视频数据默认走 UDP,云厂商的安全组如果只开放了 TCP,客户端会一直处于连接建立状态但收不到媒体数据。这个问题在云服务器上非常典型,排查优先级很高。
5.5 一个小技巧:用 getMediaList 确认流状态
在排查任何推拉流问题时,我喜欢先看 ZLMediaKit 认为当前有多少条流。请求
http://127.0.0.1:8080/index/api/getMediaList
,返回 JSON 里会列出所有存在于服务器上的媒体流。如果推流端已经发送数据,但这里看不到对应的
live/test
,说明推流根本没进服务器;如果能看到,说明问题大概率在播放端或 WebRTC 协商环节。这个接口是我日常调试中最常用的“体检工具”,比看一堆日志直观得多。
我在实际使用中还有一个习惯:不要把第一版播放器封装得太复杂。先用原生
RTCPeerConnection
跑通最小链路,再封装成 Vue 组件或 React 组件。这样一旦出问题,能清晰区分是协议层的问题还是 UI 层的问题。等真正跑通以后,再逐步加入自动重连、多路监控、动态切换清晰度这些功能。WebRTC 直播并没有想象中那么神秘,把推流、服务、播放三端拆开调试,每一条链路都能像 RTMP 一样被快速定位。
更多推荐
所有评论(0)