先说说我为什么写这篇东西。前阵子给一个做中小型园区安防的朋友搭监控平台,设备是几十路国标GB28181的摄像头,需要统一接入、实时预览、录像回放。我一开始在他那台Ubuntu 22.04服务器上手动装WVP-PRO和ZLMediaKit,装到一半发现依赖版本互相打架,MySQL、Redis、JDK、Maven全都得伺候,折腾了两个晚上才把服务跑起来,结果朋友一个docker compose ps都看不明白,后续维护更是灾难。
后来我推倒重来,老老实实改用Docker Compose一套编排,把WVP-PRO、ZLM、录像服务、Nginx反代全部容器化,整个部署过程压缩到一小时内。这篇教程就是那次完整重建过程的记录,属于那种“我要是早点看到就能少掉两根头发”的保姆级内容。你如果手里正好有国标摄像头要接入、要录像、要回放,这个方案可以整套抄走。文章比较长,但每一步都是实测过的,跟着做基本不会卡壳。
1. 先搞清楚WVP-PRO和ZLM到底在系统里各自干什么
很多人一上来就急着复制docker-compose.yml,结果跑起来发现摄像头上线了但不出流,或者录像目录空的,然后开始怀疑人生。要避免这种局面,第一步不是敲命令,而是搞清楚这堆容器里每个角色的职责边界。
1.1 国标接入的那套信令与媒体分离逻辑
GB28181是国标视频监控的通信协议,它的核心设计是信令和媒体分离。翻译成白话就是:摄像头和控制平台之间跑着两套完全不同的数据流。
信令通道走的是SIP协议(Session Initiation Protocol,会话初始协议),负责设备的注册、心跳、邀请、查询这些控制操作。摄像头开机后会把自己注册到某个SIP服务器上,告诉服务器“我在线、我在这、我支持什么编码”。这层通信的特点是数据量小、频率低,但对可靠性要求高。
媒体通道走的才是真正的视频流,摄像头被平台“邀请”之后,通过RTP/RTCP协议把H.264或H.265的视频流推出来。这层数据量大、实时性要求高,对网络端口的要求也很苛刻。
在这个架构里:
- WVP-PRO(WVP视频平台)就是那个SIP信令服务器,它负责跟摄像头喊话、做设备管理、用户认证、录像计划编排,以及把信令和媒体层粘合起来。
- ZLMediaKit(简称ZLM)就是那个媒体流转发引擎,负责接收摄像头推上来的RTP流,转封装成RTMP、RTSP、HLS、WebRTC等不同协议,再分发给播放端。同时它还有一个很实诚的录像功能——直接按计划把流录制为MP4文件。
你从Web界面看到的设备列表、通道列表、播放画面、录像回放,上层是WVP在做业务控制,底层全部落到ZLM在搬砖。
1.2 这套组合的典型架构和容器分工
用Docker Compose部署的时候,实际上一共跑这么几个容器:
| 容器服务 | 镜像/角色 | 核心职责 |
|---|---|---|
| mysql | MySQL 8.x | 存储WVP的设备信息、用户信息、录像计划、操作日志 |
| redis | Redis 7.x | 缓存会话、临时状态、异步任务队列 |
| wvp-pro | WVP-PRO服务 | SIP信令处理、RESTful API、Web管理界面 |
| zlm | ZLMediaKit | 流媒体接收、转封装、分发、MP4录像落盘 |
| nginx | Nginx | 反向代理Web界面和流媒体接口、SSL终结、统一入口 |
这里要特别强调:WVP和ZLM是强耦合的。WVP在信令层面把摄像头“邀请”上来之后,会在内部给ZLM派活——告诉它去哪个端口收流、收完流怎么转协议。ZLM收到流之后,又会通过HTTP hook回调告诉WVP“流已经上线了”。如果这两个服务之间通信不畅,整条链路就断了。
1.3 为什么非要用容器编排而不是裸机部署
我自己在裸机上踩过大坑,对照下来容器编排有几个压倒性优势:
第一,依赖隔离。WVP-PRO需要JDK 8以上,ZLM需要特定版本的编译环境和FFmpeg依赖库,MySQL和Redis又有自己的版本要求。裸机装的时候,一个库的版本冲突能让整个系统罢工。容器把每份依赖锁在自己的镜像里,互不干扰。
第二,环境可复现。同一份docker-compose.yml,在开发机、测试机、生产机上跑出来的结果完全一致。不会出现“我本机明明是好的,部署到服务器就废了”的玄学问题。
第三,维护成本断崖式下降。升级镜像就是改一行版本号再docker compose pull && docker compose up -d,回滚也是改回来就行。裸机部署想升级一次JDK或者ZLM版本,怕是要折腾半天。
第四,资源占用可控。这几个容器加起来内存占用大概在1GB到2GB之间,CPU在空闲状态基本可以忽略。对一台4核8G的入门级服务器来说,跑这套完全没有压力。
2. 部署前的准备:端口规划、目录划分与镜像版本选择
部署之前,先把规划做细,后面能省一大半排错时间。我那次就是因为端口规划没做好,摄像头注册上来了,RTP流却怎么都收不到,排查了半天发现是UDP端口的防火墙规则没放行。
2.1 一张端口清单把所有通信链路理清
这套系统涉及到的端口比较多,我一般建议拿张小本子先把端口写清楚,再开始部署。下面这张表是我实际使用的端口规划,你可以直接照抄:
| 端口 | 归属服务 | 协议 | 用途 |
|---|---|---|---|
| 3306 | MySQL | TCP | 数据库访问,仅容器内网通信,不对外 |
| 6379 | Redis | TCP | 缓存访问,仅容器内网通信,不对外 |
| 18080 | WVP-PRO | TCP | Web管理界面HTTP端口 |
| 18081 | WVP-PRO | TCP | Web管理界面HTTPS端口(反代后可不对外开放) |
| 5060 | WVP-PRO | TCP/UDP | SIP信令端口,必须对摄像头开放 |
| 1935 | ZLM | TCP | RTMP流媒体端口 |
| 554 | ZLM | TCP | RTSP流媒体端口 |
| 80 | ZLM | TCP | ZLM内置HTTP API端口(hook回调用) |
| 332 | ZLM | TCP/UDP | RTP收流端口(接收国标推流) |
| 10000~10010 | ZLM | UDP | RTP收流端口区间(每路流占一个端口) |
| 30000~30050 | ZLM | UDP | RTP收流扩展端口区间(路数多时使用) |
| 16200 | ZLM | TCP | 用于HTTP-TS等流媒体拉流端口 |
这里重点提醒两件事。
第一,SIP信令端口5060需要同时支持TCP和UDP。很多调试工具默认只开TCP,结果摄像头注册总是超时。国标协议里,注册和心跳这些信令消息可以走TCP也可以走UDP,但实际设备实现五花八门,有些老设备只支持UDP,你只放行TCP就会注册不上。
第二,ZLM的RTP收流端口是UDP区间端口。这是一整块UDP范围而不是单个端口。原因在于,ZLM每接收一路国标摄像头的推流,就会在这个区间里动态分配一个UDP端口。如果端口区间开得太小,并发推流超过端口数量就会随机失败。具体的区间大小,跟你的摄像头路数直接相关,建议至少开放10个端口起步,规模大就按“路数+50%余量”来规划。
2.2 目录与数据卷划分:数据库、配置、录像必须分开
我见过有人在部署时图省事,所有数据都放到容器里头,结果容器一删,设备配置、录像文件全部消失。这个坑必须绕开。
强烈建议在宿主机上建立独立的目录结构:
/opt/wvp-docker/ ├── docker-compose.yml ├── mysql/ │ └── data/ # MySQL数据目录 ├── redis/ │ └── data/ # Redis持久化目录 ├── wvp/ │ └── config/ # WVP配置文件容器内挂载 ├── zlmediakit/ │ ├── config/ # ZLM配置文件容器内挂载 │ └── media/ # 录像/媒体文件存储目录 └── nginx/ ├── conf/ # Nginx配置 └── logs/ # Nginx日志我习惯把录像目录单独挂在独立的数据盘上。举个例子,如果你的服务器有块专门的数据盘挂载在/data,那就把目录规划成/data/zlmediakit/media,不要跟系统盘放一起。录像文件的写入频率高、单文件体积大,如果跟系统盘抢IO,系统负载一高,摄像头信令可能都会受影响。
2.3 镜像版本对应关系与网络模式选择
镜像这里我吃过亏,版本对应关系一定要看清。
WVP-PRO的官方Docker镜像和库在64854coding这个腾讯工蜂仓库里,你直接docker pull可能拉不到,建议先拉下来再重新打标签。具体版本对应关系如下:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| MySQL | mysql:8.0 | 8.0以上版本对JSON类型的支持更好,WVP的某些初始化脚本用到了 |
| Redis | redis:7.0 | 7.x性能好且稳定,兼容性也没有问题 |
| wvp-pro | wvp-pro:latest(自定义构建) | 官方仓库拉下来后需要打标签,建议固定sha值不用latest |
| ZLMediaKit | zlmediakit:latest(自定义构建) | 保持与WVP的hook API兼容 |
| Nginx | nginx:1.24-alpine | Alpine版本体积小,跑反代足够 |
网络模式我建议使用Compose默认的bridge网络,这个是最稳的选择。不要图省事直接network_mode: host,虽然省去了端口映射的麻烦,但会让容器间通信变得混乱,尤其在Nginx反代转发场景下,指代IP稍微写错就连不上。
在bridge网络里,容器间可以通过服务名互相访问。比如WVP容器里配置ZLM的API地址,直接写http://zlm:80而不是http://192.168.x.x:80。这个逻辑在配置hook回调的时候尤其重要,后面有专门的章节讲。
3. docker-compose.yml逐段拆解:每一行配置对应的实际作用
这段是整个部署的核心。我先把完整版的docker-compose.yml拿出来,然后逐段拆解为什么这么写。
version: '3.8' services: mysql: image: mysql:8.0 container_name: wvp-mysql restart: always environment: MYSQL_ROOT_PASSWORD: Wvp@123456 MYSQL_DATABASE: wvp TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_general_ci - --lower_case_table_names=1 volumes: - /opt/wvp-docker/mysql/data:/var/lib/mysql - /opt/wvp-docker/mysql/init:/docker-entrypoint-initdb.d healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-pWvp@123456"] interval: 10s timeout: 5s retries: 10 redis: image: redis:7.0 container_name: wvp-redis restart: always command: redis-server --appendonly yes --requirepass Wvp@123456 volumes: - /opt/wvp-docker/redis/data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "Wvp@123456", "ping"] interval: 10s timeout: 5s retries: 10 wvp: image: wvp-pro:latest container_name: wvp-pro restart: always depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: TZ: Asia/Shanghai volumes: - /opt/wvp-docker/wvp/config:/opt/wvp/config - /opt/wvp-docker/wvp/logs:/opt/wvp/logs ports: - "5060:5060/tcp" - "5060:5060/udp" - "18080:18080" - "18081:18081" zlm: image: zlmediakit:latest container_name: zlmediakit restart: always depends_on: - wvp environment: TZ: Asia/Shanghai volumes: - /opt/wvp-docker/zlmediakit/config:/opt/media/conf - /opt/wvp-docker/zlmediakit/media:/opt/media/bin/www network_mode: service:wvp depends_on: - wvp nginx: image: nginx:1.24-alpine container_name: wvp-nginx restart: always depends_on: - wvp ports: - "80:80" - "443:443" volumes: - /opt/wvp-docker/nginx/conf:/etc/nginx/conf.d - /opt/wvp-docker/nginx/logs:/var/log/nginx这份内容的挂载关系是经过验证的,下面逐段拆解。
3.1 MySQL与Redis:为WVP准备的后端服务
MySQL这一段有几个容易被忽略的细节。
--lower_case_table_names=1这个参数是必须加的。WVP的数据库脚本里表名既有大写又有小写,而MySQL 8.0在Linux系统上默认是区分大小写的,不加这个参数初始化脚本会报一堆表不存在的错。
初始化SQL脚本的挂载位置在/opt/wvp-docker/mysql/init目录,MySQL容器首次启动时会自动执行该目录下的.sql文件,帮你建好库表结构。这个初始化动作只在数据目录为空时执行一次,如果数据目录已经存在内容,后续重复挂载不会重新执行。
Redis这里用了--appendonly yes开启AOF持久化,并设置了密码。有人可能会问,WVP的内部服务通信,Redis有必要加密码吗?我个人建议加上。因为docker-compose.yml文件很容易被分享出去或者截图传阅,如果Redis无密码且端口映射到了公网,攻击者直接连上来扫缓存数据,里面可能有设备的鉴权信息。加一层密码不会增加多少运维负担,但安全性提升一个档次。
3.2 WVP-PRO:数据库初始化与环境变量
WVP容器首次启动时,会自动尝试连接MySQL初始化数据库。等MySQL容器是healthy状态再启动WVP,这个依赖关系由depends_on加condition: service_healthy控制,能保证数据库已经准备就绪。
环境变量这里,我只设置了TZ=Asia/Shanghai。WVP真正的配置项在application.yml里,通过挂载目录的方式读入。有人会问,为什么不用环境变量去传MySQL密码、Redis密码这些?WVP的官方镜像本身支持环境变量覆盖配置,但实际测试下来,部分老版本的镜像对环境变量的读取逻辑比较简单粗暴,直接改application.yml是最稳的方式。
WVP的配置目录挂载到/opt/wvp/config,首次启动时如果该目录为空,容器会把默认配置模板复制一份出来。所以正确的操作顺序是:先把容器跑一遍生成默认配置,然后停掉容器,修改配置,再启动容器。不要提前往配置目录里塞空文件,那会让容器无法生成默认配置。
3.3 ZLM:config.ini挂载方式与secret的口子
ZLM这段我踩过最大的坑,就是直接挂载宿主机空目录到/opt/media/conf,结果容器起不来。因为ZLM启动时会往conf目录写入很多默认配置和配置文件,你给它一个空目录,它自己会生成一份,看起来没问题,但如果这个空目录的属主不是容器内运行用户,后续写入过程就会报权限错误。
我的做法是:先让ZLM容器挂载一个已经存在的目录并把目录权限设为chmod -R 777,容器启动时会在里面生成config.ini。生成之后,停掉容器,再修改config.ini里的关键项,再启动。这个顺序能避开绝大多数的权限坑。
ZLM的config.ini里有两个关键配置必须检查:
[api]下的secret:这是ZLM的API鉴权密钥。WVP调用ZLM的API时,需要携带这个secret。WVP配置里的media.secret必须和ZLM的config.ini里的secret一致,否则会出现“流媒体服务器连接失败”的报错。[hook]下的enable和on_flow_report等回调地址:ZLM发布流成功后会回调WVP的接口,通知业务层“流上线了”。这个回调地址如果写错,最直接的现象就是——摄像头已经在ZLM里能看到流了,但WVP的Web页面上依然显示离线或看不到画面。
3.4 健康检查、重启策略与依赖启动顺序
这段是Compose编排里最容易被忽视、但恰恰是稳定性的关键。
restart: always让容器异常退出后自动重启,这个直观易懂。难点在于启动顺序。WVP依赖MySQL和Redis,如果数据库还没就绪WVP就先启动了,WVP会报连接超时然后直接退出。加了depends_on加condition: service_healthy之后,Compose会等MySQL的healthcheck通过再启动WVP。
MySQL的healthcheck我用的命令是mysqladmin ping -h localhost -p密码。这里有个小坑:MySQL容器刚启动时,mysqladmin ping是能成功的,但此时可能还没完成初始化。保险做法是把retries调大一点,比如10次,每次间隔10秒,给足MySQL初始化的时间。
ZLM这里用了network_mode: service:wvp,把ZLM和WVP放进同一个网络命名空间。这么做的好处是ZLM的hook回调地址直接可以用http://127.0.0.1:18080访问WVP,不需要依赖Docker的服务名解析。这个写法的缺点是ZLM不能单独映射端口,但因为我们只需要通过WVP同一个网络栈对外服务,完全够用,还省掉了一批端口映射的维护项。
4. 打通WVP与ZLM的鉴权和钩子回调
这一章是整个部署过程中最容易让人半路放弃的环节。我把每个互联网上模模糊糊的细节都摊开讲透。
4.1 secret配置不一致会出现的典型症状
WVP和ZLM之间通过HTTP API通信,ZLM的API接口都要求携带secret参数做鉴权。WVP的application.yml配置和ZLM的config.ini里都有这个secret字段。
常见的部署报错长这样:
2024-xx-xx 10:23:45.123 ERROR - [MediaServer] 连接流媒体服务器(ZLM)失败: 401 Unauthorized, secret error问题的本质很简单:两边的密钥对不上。我在排查这个问题的时候发现,很多人喜欢把WVP里的media.secret留空,觉得不填最省事。可ZLM默认的secret也不是空的,它有个默认值,两边一对比就报了鉴权失败。
正确做法是:在ZLM的config.ini里设置一个强一点的secret,比如your-strong-secret-2024,然后在WVP的application.yml里把media.secret配置成同一个值,重启两个容器让配置生效。
4.2 hook回调地址在容器网络里的正确写法
ZLM的hook是这样一个机制:每当流上线、流关闭、录像开始、录像结束等事件发生时,ZLM向配置好的HTTP地址发起POST请求,把事件信息推给WVP。这个回调地址如果不对,事件通知就到不了WVP。
这里有两种场景,地址写法完全不同:
场景一:ZLM和WVP在同一网络命名空间(就像我们这种Compose方案)
这时ZLM容器内访问WVP,直接写http://127.0.0.1:18080/index/hook就好,因为network_mode: service:wvp让两者共享了网络栈。127.0.0.1在容器里既指代WVP又指代ZLM。
场景二:ZLM和WVP在不同容器、使用普通bridge网络
这时要用服务名来访问,写成http://wvp:18080/index/hook。注意这里不能用公网IP,因为容器间的流量只走Docker内部网络,公网IP不一定能回环到达容器内。
我有个朋友反代想外界访问流畅,把hook回调地址写成了http://公网IP:18080/index/hook,实际跑起来画面一直加载不出来。原因就是容器流量走Nginx再绕回来,链路多了一条,还容易出现跨域问题。
4.3 录像相关hook:on_record_mp4从ZLM到WVP的链路
如果你只是想要实时预览,不配置录像相关hook也能用。但录像开始、录像结束这些事件要联动到WVP的业务层,就必须把on_record_mp4这个hook配置好。
它的流程是:
- WVP在数据库里创建了一条录像计划,到期后通过API通知ZLM开始对某条流录制MP4。
- ZLM录制完成一段MP4文件后,根据
on_record_mp4配置的地址,向WVP发一个POST请求,把录像的路径、文件名、文件大小、时间戳等推给WVP。 - WVP收到通知后,在数据库里写入一条录像索引记录。前端回放页面才能列出来这段录像。
如果on_record_mp4没配置或者地址不对,录像文件依然会在ZLM的磁盘上生成,但WVP的web回放页面里会看不到录像,或者点击回放时提示没有录像数据。
在config.ini里,这些hook回调地址一般长这样:
[hook] enable=1 on_flow_report=http://127.0.0.1:18080/index/hook/on_flow_report on_stream_changed=http://127.0.0.1:18080/index/hook/on_stream_changed on_play=http://127.0.0.1:18080/index/hook/on_play on_record_mp4=http://127.0.0.1:18080/index/hook/on_record_mp4每一行都对应一种事件,如果你不确定当前WVP版本支持哪些hook,去WVP的application.yml里找hook相关的配置块,官方默认模板里列得明明白白。
5. 录像服务:从数据库初始化到录像文件落盘
录像功能不是配置一个开关就完事的,它涉及数据库表结构、ZLM录制模块、磁盘目录、回放接口四个层面的协作。
5.1 WVP首次登录后的数据库初始化
WVP容器启动之后,浏览器访问http://服务器IP:18080,默认账号admin,默认密码admin289。登录进去之后,你会发现页面是能打开的,但很多功能模块是空的。
这时候要检查MySQL的wvp数据库里,是否已经创建好了所有表。如果没有,那就要手动执行WVP项目里附带的初始化SQL脚本。
这些脚本一般在WVP项目的sql/目录下,文件名类似wvp_2024xxxx.sql。如果你使用我上面那套Compose方案,在MySQL容器首次启动时,它就自动从这个目录执行了初始化脚本。但如果你的/opt/wvp-docker/mysql/init目录在首次启动时是空的,那一开始就没建表,后面想补就得多走一步手动执行:
docker exec -i wvp-mysql mysql -uroot -pWvp@123456 wvp < wvp_20240401.sql执行完之后,刷新一下WVP后台页面,你就能看到“设备管理”“录像计划”这些菜单都正常了。这个初始化动作不能漏,漏了后面摄像头注册上来了,你看不到设备列表,还以为是摄像头的问题,其实是库表没建好。
5.2 录像计划的配置入口和ZLM录制的对应关系
WVP后台“录像计划”功能里,你可以按通道设置录像计划。比如某个通道只在工作日9点到18点录像,另一个通道全天候录像。
这个计划的生效链路是这样的:WVP把计划持久化到MySQL,然后按照计划的时间点,通过ZLM的API发起录制请求。ZLM收到请求后,对流数据进行MP4封装并写入本地磁盘。
配置录像计划时,有几个细节要留意:
- 通道必须在线。如果通道离线,WVP不会为离线的通道发起录制请求。等通道恢复上线后,需要检查录像计划是否还在生效,有些版本在通道离线再恢复后,老的计划会失效。
- 录制时间片。ZLM的录制默认是分片存储的,我建议把分片大小设为
60分钟或者120分钟。分片太短会产生大量小文件,对磁盘inode消耗很大;分片太长,某一段录像损坏了,这个时间段的内容就全丢了。 - 录像存储位置。ZLM的
config.ini里,录像目录默认在/opt/media/bin/www下,我挂载到宿主机的/opt/wvp-docker/zlmediakit/media。录像文件按record/日期/应用名/流ID/这样的层级组织。
如果录像目录里出现了文件但WVP回放页面没有记录,优先检查on_record_mp4hook的回调地址是否正确。
5.3 录像文件的目录映射和回放验证
部署完之后,怎么确认录像真的在录?最快的办法是直接在宿主机上看目录:
find /opt/wvp-docker/zlmediakit/media/record -type f | tail -20如果文件在持续生成,说明ZLM录制正常。再用ffprobe验证一下文件能不能正常解码:
ffprobe -v error -show_format -show_streams /opt/wvp-docker/zlmediakit/media/record/2024-04-01/live/stream_id/xxx.mp4 | grep -E "codec_name|duration"正常输出里应该有h264或h265的编码信息,以及不为零的时长。
回放阶段的完整链路是:用户在WVP页面点击某条通道的“回放”,WVP先查MySQL里有没有该时间段的录像索引,有则向ZLM请求一个回放流地址,播放器拉流播放。
我习惯再做一步外部播放验证,用VLC直接打开ZLM的RTSP回放地址rtsp://IP:554/record/live/stream_id/2024-04-01.mp4,能正常出画就说明流媒体链路通,问题只可能在WVP到前端播放器的环节。
6. Nginx反代部分:HTTP与流媒体端口的统一入口
反代这部分,一开始我是拒绝的——反正服务都能通过IP加端口访问,为什么还要多套一层Nginx?后来发现,当你要给客户交付系统、要在页面上隐藏WebSocket端口、要做HTTPS证书终结的时候,没有反代,配置会散落得到处都是。
6.1 为什么需要反代以及Http块配置
归纳下来,反代至少解决四个问题:
- 端口统一。对外只暴露80和443,内部WVP的18080端口、ZLM的API端口都不用暴露到公网,减少被扫描攻击的面。
- HTTPS证书终结。让你能用
https://wvp.example.com访问后台,证书只需要在Nginx上配一份,后端服务不用改。 - 路径路由。同一个Nginx服务上,可以把
/路由到WVP,把/live/路由到ZLM的流媒体服务。 - WebSocket代理支持。WVP前端页面与后端通信会用到WebSocket,Nginx需要配置对应的Upgrade头才能正确透传。
下面是我实际使用的Nginx配置,注意WebSocket的请求头设置:
server { listen 80; server_name wvp.example.com; location / { proxy_pass http://wvp:18080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_connect_timeout 60s; proxy_read_timeout 3600s; } }proxy_read_timeout 3600s这个设置很关键。播放器通过WVP拉流时,WebSocket连接可能会长时间保持,默认的60秒超时会让播放一会儿就断开。我一开始没加,导致10分钟左右的监控画面就闪断一次,排查了很久才发现是Nginx的超时在作怪。
如果你需要把ZLM的流媒体播放地址也暴露出来,可以加一个单独的location:
location /live/ { proxy_pass http://zlm:80/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }注意这里proxy_pass后面的URI和location的匹配关系。http://zlm:80/结尾带斜杠,会把/live/后面的路径直接拼上去,这样ZLM收到的请求路径就跟它原生一致了,否则会因为路径多了一层前缀导致404。
6.2 流协议端口用stream模块转发
HTTP协议用Nginx的http模块反代,但RTSP、RTMP这类流协议不是标准HTTP,用location块处理不了。这时候要开Nginx的stream模块。
在nginx.conf里加上:
stream { upstream zlm_rtsp { server zlm:554; } upstream zlm_rtmp { server zlm:1935; } upstream zlm_rtp { server zlm:332; } server { listen 554; proxy_pass zlm_rtsp; proxy_timeout 3600s; proxy_connect_timeout 10s; } server { listen 1935; proxy_pass zlm_rtmp; proxy_timeout 3600s; proxy_connect_timeout 10s; } server { listen 332 udp; proxy_pass zlm_rtp; proxy_timeout 3600s; proxy_connect_timeout 10s; } }这里有个容易踩的坑:Nginx的stream模块和http模块不能写在同一个server块里,它们是不同上下文。stream块必须放在nginx.conf的顶层,和http块平级,而不是放在某个server里面。
UDP的RTP流如果端口数量多,也可以在stream块里用一个listen区间来做转发:
server { listen 10000-10010 udp; proxy_pass zlm_rtp; }这样就能把ZLM的RTP收流端口区间也统一包到Nginx后面,外网摄像头推流地址只需要指向Nginx的IP和端口。
6.3 回调地址在反代场景下最容易搞混的关系
反代一上,就出现了一个新的地址关系:外部请求和内部回调请求,走的是完全不同的路径。
外部请求:浏览器 -> Nginx 80/443 -> WVP 18080
这是用户访问Web页面、拉流的路径。
内部回调:ZLM -> WVP 18080(不走Nginx)
这是ZLM向WVP推送事件通知的路径。
判断这个链路是否出问题的经验是:如果页面能打开、设备也能注册上来,但录像记录不生成,那多半是内部回调路径断了;如果页面打不开,那是外部入口的问题。
在反代场景下,我建议ZLM的hook回调仍然写http://127.0.0.1:18080/index/hook(因为两者共享网络命名空间),不要改成https://wvp.example.com/index/hook。因为Nginx的HTTPS终结之后,要把请求再转发给WVP内部的HTTP端口,又多一层可以出错的地方。而且如果Nginx配置的client_max_body_size比较小,大体积的hook请求还可能被Nginx拒收。
7. 上线后的核心技术自查清单与排障链路
部署完成不是终点,能稳定跑起来才是。这章是我实战中最常被人咨询的排障合集,我把排查思路按链路顺序写出来,遇到问题直接对照步骤走。
7.1 摄像头上线但不出流的七步排查链路
这是最高频的问题:摄像头注册上来了,设备列表里能看到在线状态,但点开播放一直转圈不出画面。
我固定按下面这七个步骤排查,基本能定位90%以上问题:
| 步骤 | 检查对象 | 操作/命令 | 期望结果 |
|---|---|---|---|
| 1 | 摄像头侧状态 | 在WVP后台看通道在线状态 | 在线且通道ID正确 |
| 2 | WVP信令日志 | docker logs -f wvp-pro --tail 200 | 看到SIP invite发送记录 |
| 3 | ZLM收流状态 | docker logs -f zlmediakit --tail 200 | 看到on_publish回调记录 |
| 4 | ZLM端流注册 | 访问ZLM APIhttp://IP:80/index/api/getMediaList?secret=xxx | 有对应stream_id的流信息 |
| 5 | ZLM到WVP的hook | 检查WVP日志是否有on_stream_changed记录 | 有记录说明hook通了 |
| 6 | 播放器拉流链路 | 用VLC直接拉rtsp://IP:554/live/xxx | 能出画面说明流媒体链路正常 |
| 7 | Nginx转发链路 | 检查Nginx错误日志,外网拉rtsp://域名:554/live/xxx | 能出画面说明反代正常 |
这个链路的核心逻辑是:从摄像头端一路往播放端推移,每一步检查都验证前一步的下游是否正常。
举个例子,如果第2步WVP日志里压根没有SIP invite记录,那问题其实出在WVP没有收到摄像头的推流请求,或者信令端口不通。这时候不需要检查ZLM,因为ZLM根本没有被WVP通知去收流。
如果第4步ZLM端有流,但第5步WVP日志里没有on_stream_changed,那就是hook回调地址配置的问题,按前面第4.2节的方法改回调地址。
7.2 UDP端口未开放这个最大的隐形坑
我专门把一个坑单独拎出来讲,因为这个坑的表现太有迷惑性了。
现象是:设备在线、WVP日志显示invite已发送、ZLM日志显示收到RTP流了,但画面就是不显示。查了很久,最后发现是云服务器安全组和宿主机防火墙只开了TCP端口,UDP端口被默认丢弃。
国标推流走的是UDP的RTP,如果UDP端口不通,ZLM收不到流,显示出来的就是连接超时。但有意思的是,设备在线状态是靠SIP注册心跳维持的,而SIP信令刚才讲了也走5060端口。如果5060只开放了TCP,且设备恰好走TCP注册,那设备列表照样显示“在线”,让你误以为网络全部通畅。
排查时用下面这条命令直接测试UDP端口通不通:
nc -uvz 服务器IP 332或者用tcpdump抓包看是否有UDP包到达ZLM容器:
docker exec -it zlmediakit sh -c "cat /proc/net/udp" tcpdump -i any udp port 332 -n -c 50如果看到大量的UDP包被丢弃,基本可以确定是防火墙或安全组把UDP端口挡了。放行规则要同时覆盖:
- UDP 5060(SIP信令)
- UDP 332(RTP基础收流)
- UDP 10000-10010(RTP动态端口区间)
7.3 我踩过的几个坑和对应的规避方案
最后把我自己部署和维护过程中印象最深的四个坑分享出来,每一个都对应一条实际的规避经验。
坑一:镜像拉不下来导致部署中断
WVP-PRO和ZLMediaKit的镜像在部分网络环境下可能不太好拉取。我的应对方案是:如果官方仓库比较吃力,考虑走代理镜像源,或者从内网镜像仓库拉取后手动打标签。另外docker pull的时候注意指定完整仓库地址,不要用简写,避免拉错镜像。
坑二:录像文件越来越大,磁盘被写满
录像是一头吞磁盘的空间怪兽。按一台200万像素摄像头、H.264编码、码率4Mbps来算,一天的录像量大约是40GB出头。二三十路通道跑一个月就是几十TB。我的建议是:
- 录像目录一定要独立挂载到大容量数据盘
- 写一个定时任务,按保留天数清理旧录像文件
- 提前规划好存储容量,别等磁盘满了才发现
坑三:容器重启后服务起不来,因为端口被占用
常见场景是服务器开机后某些进程提前占用了端口,WVP或ZLM容器无法绑定。排查方式:
ss -lntup | grep -E '5060|332|18080'如果发现端口被其他进程占用,要么停掉冲突进程,要么改容器端口映射。这个坑多发生在你用同一台服务器部署过别的业务的情况下。
坑四:WVP升级后数据库表结构对不上
WVP版本更新频繁,数据库表结构可能随之变化。我建议升级前先备份数据库:
docker exec wvp-mysql mysqldump -uroot -pWvp@123456 wvp > wvp_backup_$(date +%F).sql升级后再跑增量SQL脚本。不要图省事直接覆盖库表,生产环境的设备数据丢了很难恢复。
8. 我个人在实际操作中的一些体会
整套系统跑到现在,我对这套方案的评价是:稳定、省心、扩展性好。但要说“一键部署真的一点坑都没有”,那是骗人的。真实体验是,Compose编排把基础设施的复杂度收敛了,但WVP和ZLM之间那层对接逻辑,以及对于流媒体协议的理解,才是真正决定你能不能顺利上线的关键。
如果让我给后来者三条最实在的建议,我会说:
第一,先单机单流跑通,再加路数。别一上来就把几十路摄像头全接入。先用一支摄像头做完“注册->直播->录像->回放”全链路闭环,验证没有问题了,再慢慢把剩余通道接进来。这样即使出问题,范围也小、好排查。
第二,配置文件的每一次修改都要有记录。我习惯在修改application.yml和config.ini的地方写注释,说明改了什么、为什么改。很多次排查问题时,翻注释能迅速回忆起当初的意图,省去大量的反向推理时间。
第三,注意版本冻结。WVP、ZLM这两个组件迭代速度很快,今天能用的配置,下周升级后可能就不兼容了。生产环境建议固定镜像的sha256值,不要用latest标签。升级时先在测试环境验证一遍,再应用到生产。
这套环境现在还在稳定的运行,每天录着几十路的视频,摄像头掉线也能自动实名上线,回放检索速度也很快。视频监控这种系统,稳定性比花哨更重要,用Docker Compose把每个组件管好、把数据目录规划好、把备份脚本写好,剩下的交给时间就好。