先说结论:如果你只是想在自己电脑上把“推流端 -> 媒体服务器 -> 播放端”这整条 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 一样被快速定位。

Logo

邀请您加入社区

更多推荐