1. 视频AI中台的架构思路与整体拆解
1.1 为什么提“异构算力”这个概念
先说个背景。我之前所在的项目组做的是一个视频AI中台,核心业务是从各个前端摄像头拉取实时视频流,在服务端做AI分析(入侵检测、车牌识别、客流统计这类),然后把结果推给业务方。原本这套系统跑在几台物理机上,用的是一套古老的单体服务,视频流靠ffmpeg进程硬扛,AI推理靠几块GPU卡直接分配。业务量小的时候没问题,但是当接入的摄像头从几十路涨到几百路、甚至上千路的时候,问题就爆发了:
- 有的摄像头是GB28181协议的国标设备,有的是海康/大华私有SDK,还有的直接暴露RTSP地址,来源五花八门;
- 有的点位是1080P,有的是4K,同一路流的码率波动极大,白天晚上差异很大;
- AI推理模型又有不同架构的需求,有些模型只能跑NVIDIA GPU,有些推理服务恰恰更适合在ARM CPU上跑低分辨率检测;
- 单机部署时,某一台机器一旦宕机或GPU故障,整条链路就断了,而且资源无法互相调剂。
这才有了后面整套方案的演进:用Docker的多架构镜像解决“同一套服务在不同芯片上都能跑”的问题,用K8s的弹性调度解决“算力怎么动态分配”的问题,再叠加GB28181/RTSP视频接入的标准化处理,形成一套完整的视频AI中台。这套东西上线之后,给我最大的感受是:视频接入不再是一个一个适配的“手工活”,AI推理资源也不再是一块一块“静态分赃”,而是一个统一的、能伸缩的资源池在按需分配算力。
1.2 视频接入层:GB28181、RTSP与私有SDK的取舍
做视频AI中台,第一道坎就是接入层。市面上摄像头和NVR的主要视频输出协议大概就三类:
| 协议/方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| GB28181 | 国标统一,跨厂商互通,支持信令+媒体分离 | SIP信令复杂,部分厂商实现得比较“野” | 平安城市、雪亮工程、大平台对接 |
| RTSP | 简单直接,推拉流都方便,工具链成熟 | 需摄像头/编码器直接支持,部分设备并发数受限 | 小规模接入、本地调试、私有化部署 |
| 厂商私有SDK | 功能全,能拿设备状态、报警等附加信息 | 强绑定厂商,开发成本高,SDK维护困难 | 深度集成场景,建议在边缘网关做屏蔽 |
我们最终选了“GB28181为主、RTSP为辅”的双通道接入方案。原因不复杂:
- 对客户来讲,GB28181已经是很多项目招标的硬性要求,国标设备无须二次开发就能注册进来;
- 对内部团队来讲,GB28181的SIP信令比厂商SDK更利于抽象统一,我们把GB28181的注册、心跳、Invite、ACK、BYE这些都封装成了标准化的媒体接入接口;
- RTSP则作为“补位通道”存在,遇到不支持GB28181的老摄像头或者第三方流媒体服务时,直接通过
ffmpeg拉RTSP流转成内部统一媒体格式。
这里有一个容易被忽略的点:GB28181的媒体流默认走RTP/RTCP,而直连RTSP时大多走RTSP over TCP或UDP,所以内部媒体网关要支持两种协议的互相转换。我们内部定的统一媒体流标准,就是转成RTSP(或者直接推流给ZLMediaKit),这样下游的AI分析服务拿到的永远是一路“干净的RTSP地址”,不用关心上游到底是国标还是海康SDK。
1.3 整体分层架构设计
整套系统跑起来之后,我把它总结成四个层次,方便团队内部对焦:
- 接入接入层:负责对接GB28181设备端(SIP服务器自建)、RTSP拉流、以及少量厂商SDK。这一层将各路流统一转成内部RTSP流。
- 媒体处理层:负责解码、抽帧、缩放、编码、切片、录制。这里大量使用Docker容器,每个处理单元都是一个可水平扩展的实例。
- AI推理层:负责运行业务模型(检测、分类、跟踪等),按需用CPU/GPU/NPU,通过K8s调度到合适的节点。
- 业务开放层:把AI结果、录像检索、告警事件等通过API/Webhook暴露给上层业务系统。
既然是“中台”,核心目标就是让上层业务系统不需要关心摄像头怎么接入的、AI推理怎么部署的,只关心“给一个设备ID,给我一路流”和“给一个时间段,给我识别结果”。
2. Docker多架构镜像构建的实战经验
2.1 为什么要同时考虑x86和ARM架构
视频AI中台中,算力种类很多:x86服务器上插NVIDIA GPU跑重型模型,ARM架构的边缘盒子(比如瑞芯微RK3588、海思Hi3559)跑轻量级模型,还有一部分纯CPU节点做视频转码。如果每一类硬件都单独写一套部署脚本,维护成本会迅速失控。特别是边缘节点数量上来之后,手工rsync、手工docker build的方式根本没法看。
Docker多架构镜像解决的正是这件事:同一个镜像tag,在不同CPU架构的机器上docker pull时,会自动拉取对应架构的镜像层。用户无感知,但底层是manifest list(也叫镜像索引)在做“按需分发”。
示意一下镜像索引的逻辑:
registry.cn-hangzhou.aliyuncs.com/your-project/video-ai:latest ├── linux/amd64 → amd64平台的镜像层 ├── linux/arm64 → arm64平台的镜像层 └── linux/arm/v7 → 32位ARM平台的镜像层(视需要决定要不要)用docker manifest inspect可以查看一个多架构镜像的详细信息。上线部署时,K8s节点上的kubelet会根据节点自身的Architecture字段自动选择正确的镜像变体来拉取。
2.2 用buildx做一次构建、多平台输出
多架构镜像的构建,我推荐直接用Docker官方提供的buildx插件。这个工具底层使用了BuildKit,支持在一个构建节点上同时产生多个平台的目标镜像。
一个典型的构建命令大概是这样的:
# 先创建支持多平台的构建器(builder) docker buildx create --name multiarch --use --platform linux/amd64,linux/arm64 # 然后构建并推送 docker buildx build \ --platform linux/amd64,linux/arm64 \ -t your-registry.com/video-ai-mediagate:2026.01 \ --push \ -f docker/Dockerfile.media_gate .实际项目中,我为“媒体网关”这个组件写了单独的Dockerfile.media_gate,核心步骤包括:
- 基于
ubuntu:22.04基础镜像,安装FFmpeg、ZLMediaKit运行时依赖; - 拷贝编译好的二进制(媒体网关本体);
- 处理时区、CA证书、非root用户启动等细节;
- 通过
buildx同时产出amd64和arm64版本。
这里有几个非常值得注意的坑:
- 如果不加
--platform参数,docker build只会构建当前机器的架构。这是一个很常见的误区,以为buildx装上就等于多架构了。 - ARM版本的二进制要么交叉编译,要么在ARM节点上构建。如果是纯解释型语言(比如Python、Node.js),直接做成多架构镜像问题不大;但像C++写的媒体网关,建议走交叉编译链,或者用QEMU模拟。我自己的经验是:复杂二进制不建议依赖buildx的模拟构建,否则构建时间可能翻5倍,而且有些依赖库在模拟环境下编译会报“Illegal instruction”。
- 给镜像打多个tag时,注意保留架构后缀tag,方便排障。我会在CI里同时推一个
latest-arm64、latest-amd64的tag,出问题时候排查更快。
2.3 视频AI镜像里最容易踩的三个坑
视频处理相关的镜像,跟普通Web服务镜像不太一样,下面这几个坑比较有代表性:
第一个坑是底层依赖库对CPU指令集的要求。比如FFmpeg在编译时如果开启了--cpu=native,那编出来的二进制就绑定在特定CPU型号上了,换一台新机器可能会直接段错误。建议生产镜像构建FFmpeg时用通用指令集,尽量不开启native优化,宁可损失一点点性能换兼容性。尤其是ARM平台,同是arm64,不同核心的指令集差异更大。
第二个坑是字体、CA证书、时区这类“小东西”。AI分析的结果往往需要在画面上画框,绘制中文标签就需要中文字体;拉取HTTPS的RTSP流时需要根证书;视频录像的时间戳必须使用Asia/Shanghai时区。这些在镜像里不配好,运行起来就是各种“不可描述”的乱码和错误。我的习惯是在Dockerfile里显式安装并设置时区,字体也都打进去。
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN apt-get install -y fonts-noto-cjk ca-certificates第三个坑是容器内以root运行时留下的隐患。安全扫描经常报这个,而且K8s强制runAsNonRoot时根本起不来。我的做法是:在镜像里创建一个低权限用户,并给需要写入的目录显式授权。
RUN useradd -r -s /sbin/nologin videoai \ && mkdir -p /data/records /data/cache \ && chown -R videoai:videoai /data USER videoai3. K8s弹性调度:算力怎么按需分配
3.1 调度与亲和性:让容器跑到最合适的机器上
K8s集群里如果只有一种规格的节点,调度策略相对简单;但视频AI中台的节点往往是“混血”的:有GPU节点、有纯CPU计算节点、有ARM边缘节点,甚至可能还有存储型节点挂着大容量磁盘。这时候调度策略必须显式声明。
我梳理了几类必须做的调度约束:
| 需求 | 实现方式 | 说明 |
|---|---|---|
| 推理服务必须跑到GPU节点 | nodeSelector+nvidia.com/gpu资源 | 给GPU节点打标签gpu=true,推理Pod里声明nvidia.com/gpu: 1 |
| 视频转码服务尽量跑到CPU核数多的节点 | nodeAffinity(preferred) | 设置preferredDuringSchedulingIgnoredDuringExecution,优先调度到高CPU节点 |
| 边缘盒子必须跑ARM镜像 | nodeSelector+ 镜像自动选择 | 边缘节点打标签arch=arm64,调度时绑定到对应节点 |
| 避免同一路视频的主备分析实例放在同一节点 | podAntiAffinity | 同一设备的两个分析实例不要调度到同一台机器 |
有一点需要特别说明:K8s原生调度器默认不会识别GPU资源,需要安装NVIDIA Device Plugin。装好插件后,Pod声明nvidia.com/gpu: 1,K8s才会把GPU当作一种可调度的资源来处理。如果没装插件,就算把Pod调度到GPU节点上,容器里也看不到GPU设备。
3.2 HPA弹性伸缩:视频负载下的扩容策略
视频AI中台的负载特征非常“非线性”:白天车流量大、晚上几乎没有事件,某些节假日前一天晚上这种峰值压力特别明显。如果按峰值时刻采购常备资源,成本不合理;所以必须让K8s的HPA(HorizontalPodAutoscaler)来接这个“削峰填谷”的活儿。
标准的HPA基于Pod的CPU/内存利用率扩容,但对视频AI服务来说,CPU利用率其实不够“直觉”——视频解码和AI推理都会吃CPU/GPU,但它们的波动非常快,500毫秒内就可能从20%跳到90%。单纯按CPU平均值扩缩容,经常出现“刚扩容完,峰值已经过去了”的尴尬局面。
我们的生产配置里,综合用了两类指标:
- K8s内置资源指标:CPU、内存利用率,适合兜底,防止资源耗尽;
- 自定义指标(Prometheus Adapter):比如“当前正在分析的视频路数”“消息队列积压数”,这类业务指标对扩容决策才是最直接的。
举一个简化版的HPA配置示例:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: ai-analyzer-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: ai-analyzer minReplicas: 2 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Pods pods: metric: name: ai_video_stream_active target: type: AverageValue averageValue: 50这里有两条红线要提醒:
- HPA不要针对GPU利用率直接扩容,因为GPU调度是排他性的,扩容出来的Pod可能因为GPU资源不足而Pending。更稳妥的做法是让HPA扩容后由调度器决定Pod落在哪个GPU节点上,同时给不同优先级的Pod设置调度优先级。
- 缩容策略要加稳定窗口。视频AI业务正在分析中途,如果Pod被杀了,会导致一路视频分析中断,影响体验。建议
behavior.scaleDown.stabilizationWindowSeconds设为600秒以上,确保负载下降后不会立刻把Pod回收掉。
3.3 只读权限、CPU Throttling与集群安全
K8s生产集群里,运维安全绝对不是一个可选项。我在实践中做过两件比较典型的加固:只读根文件系统和只读权限用户。
“只读权限用户”这个需求跟Docker镜像里的非root启动是配套的。在K8s层面,我们通过securityContext强制约束:
spec: securityContext: runAsNonRoot: true runAsUser: 1001 containers: - name: ai-analyzer securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] volumeMounts: - name: tmp mountPath: /tmp - name: cache mountPath: /data/cache视频分析服务经常要写临时文件(抽帧、缓存、日志),所以readOnlyRootFilesystem: true之后,必须把/tmp、/data/cache这些目录以emptyDir或PVC方式挂载进去,否则一启动就报“Read-only file system”。
再来说CPU Throttling。这是K8s里一个非常经典且容易被忽略的问题。当你在Pod的resources.limits.cpu里设置了CPU上限(比如limits.cpu: 2),Linux CFS带宽控制会在100ms的周期内限制Pod的CPU用量。如果一个视频服务在一段时间内试图超过限制,就会触发cpu throttling,表现为:CPU使用率看起来不高,但响应延迟猛增,视频处理出现卡顿。
排查方式很直白:进容器看cpu.stat。
cat /sys/fs/cgroup/cpu.stat nr_periods 100000 nr_throttled 35000 throttled_time 95900000000如果nr_throttled占比很高,说明容器频繁被限流。这时候最直接的解法是提升limits.cpu,或者直接把limits调成跟requests一致(这样虽然预留了资源,但避免被限流到“想用用不了”的尴尬)。另外对于视频转码这类持续消耗CPU的Pod,不建议设置过低的CPU限频,宁可让调度器把它放到空闲节点上。
4. GB28181/RTSP接入与流媒体分发实现
4.1 GB28181信令要点与常见业务流
GB28181本质上是一套基于SIP的信令协议,加上RTP媒体传输的规范。很多初次接触的人会被SIP协议吓到,头韵、sdp、cseq都是一堆抽象名词。用大白话讲清楚就是:
- 设备注册:摄像头作为SIP用户代理(UA),主动向SIP服务器(我们自建在K8s里的信令网关)发送
REGISTER请求,携带设备ID、密码等信息。服务器应答后,设备就进入“在线”状态。 - 实时音视频点播:SIP服务器向设备发送
INVITE请求,里面携带SDP参数,描述我们期望接收的媒体格式(视频编码H.264/H.265、码率、分辨率等)。设备回复200 OK并携带设备侧的SDP后,双方进入“媒体传输”阶段。 - 媒体传输:设备用RTP协议把PS流(Program Stream)推过来,后台需要做“RTP/PS → ES流”的解析,再封装成RTSP流或者直接喂给解码器。
- 语音对讲:方向反过来,服务器向设备发送音频RTP流,用于广播/对讲。
在实际项目中,我见过不少设备在国标对接时出现“注册成功但点播失败”或者“点播成功但画面迟迟不出来”的问题。排查时优先看三个地方:信令日志中的SSRC是否正确、SDP中的媒体IP是否可达、RTP端口是否被防火墙拦截。尤其是某些摄像头虽然注册到了公网IP,但实际媒体流从内网IP发送,服务器回包时直连内网IP就会失败。
4.2 RTSP拉流与转发的坑
RTSP协议本身不复杂,但在大规模拉流场景下,坑全在细节里。最常见的两个问题:
一是拉流地址带鉴权信息时的拼接。海康/大华的RTSP地址格式通常是rtsp://username:password@ip:554/Streaming/Channels/101。这类地址一旦密码里有特殊字符,就需要URL编码处理,否则认证失败。更稳妥的做法是使用ffmpeg时通过-rtsp_transport tcp和显式提供凭据来拉流。
二、并发拉流导致设备/编码器过载。很多网络摄像头的RTSP并发数限制很低(4路、6路),如果中台的解码服务重复去拉同一路流,摄像机就受不了。我们的做法是:再加一层“流聚合网关”,对同一路上游RTSP永远只维持一个会话,下游所有需要该流的业务(AI分析、录像、转发)都从网关内部分发。
样例命令(用ZLMediaKit作为流媒体网关时,可以直接通过API动态发起拉流):
curl -X POST http://127.0.0.1:8080/index/api/addStreamProxy \ -H "Content-Type: application/json" \ -d '{ "vhost": "__defaultVhost__", "app": "live", "stream": "camera_001", "url": "rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101", "rtp_type": 0 }'这个API返回成功后,ZLMediaKit会保持上游RTSP流,并对外提供rtsp://127.0.0.1:554/live/camera_001这样的统一地址给下游消费。
4.3 浏览器播放:从RTSP到Web端的最短路径
有一个高频需求是“在浏览器里看视频”,但浏览器不支持RTSP协议。所以必须做一层流媒体转换。我们内部做过两个方案:老方案用FFmpeg把RTSP转成HLS直播流,新方案用ZLMediaKit/Mediamtx直接转成WebRTC或HTTP-FLV。
HLS的优点是兼容性强,Apple、Android、PC浏览器都能播,但延迟在3~10秒,对于AI告警联动这种对实时性要求高的场景不够友好。WebRTC延迟能压到1秒以内,体验好很多,但部署复杂度更高,需要信令服务和TURN/STUN配置。
我的建议是:对外展示类场景,优先做WebRTC;对录像回放场景,保留HLS即可。中间这套转换逻辑可以沉淀为平台的基础能力,上层业务不需要自己处理。
5. 落地过程中遇到的高频问题与排查实录
5.1 国标接入的“403”与注册失败
GB28181对接时,403 Forbidden是很典型的错误。绝大多数情况是:密码错误、设备ID不在允许列表、或者SIP服务器的认证机制(Digest校验)不兼容。排查方法是同时抓设备端和服务器端的SIP日志,比对Authorization头的摘要算法,有设备只支持MD5,有设备需要SHA-256,后端如果只实现了一种算法就会导致部分设备反复403。
另外有一些设备在初次注册时用的是默认密码,上线时没改,后台又强制要求复杂密码,这种属于“配置问题”而不是“协议问题”。我的习惯是做一个“一键抓包”小工具:在信令网关容器里直接tcpdump -i any -s0 -w /tmp/sip.pcap port 5060,把SIP报文留下来,然后用Wireshark的voip_calls视图分析,很快能定位是注册阶段还是Invite阶段失败。
5.2 RTSP拉流花屏、卡顿与时间戳问题
RTSP拉流时如果出现花屏,大概率是RTP包乱序或者丢包。常见解法是把传输方式从UDP强制改成TCP:
ffmpeg -rtsp_transport tcp -i "rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101" -c:v copy -an -f flv rtmp://127.0.0.1:1935/live/camera_001改用TCP后乱序问题基本消失,但TCP拥塞控制也可能导致延迟变大。所以真正可靠的做法是:保持UDP传输,但在接收侧做大缓冲区并做RTP排序,ZLMediaKit对这块处理得比较成熟,我建议优先使用开源的ZLMediaKit,而不是自己造轮子。
时间戳问题则更隐蔽。有些摄像头在RTP包头里的时间戳单位不一致,导致转封之后播放时快进/慢放或音画不同步。排查技巧是看FFmpeg的-debug输出或ZLMediaKit的毫秒级统计,如果发现时间戳跳变异常,可以在拉流时加-use_wallclock_as_timestamps 1这种参数,强制使用系统时钟统一时间戳。
5.3 容器网络模式与防火墙导致的“拉流不通”
在K8s里跑媒体网关,网络模型不像docker run --network=host那么简单直接。默认情况下Pod网络是Overlay(Calico/Flannel等),从Pod内发起的RTP/RTCP包走的是VXLAN或IPIP隧道。部分网络插件对组播或大量UDP包支持差,容易导致视频卡顿。
视频媒体处理类服务,我强烈建议使用hostNetwork模式,让Pod直接复用宿主机网络栈:
spec: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet原因很简单:媒体网关需要大量并发UDP收发,Overlay网络会有性能损耗,而且排查问题时多一层网络封装会让人非常痛苦。hostNetwork模式下,RTSP的554端口、RTP的10000-20000端口、SIP的5060端口都直接暴露在宿主机的网络上,配合防火墙规则反而更容易管理。
5.4 监控告警全家桶:Prometheus + Node Exporter + Grafana
视频AI中台不带监控就跑生产,等于裸奔。我的做法是标准三件套:Prometheus采集+存储、Node Exporter做节点指标、Grafana做展示和告警。
Node Exporter采集的指标很多,但磁盘告警规则是最值得先配的。视频录像和AI缓存会疯狂消耗磁盘空间,如果不做告警,等磁盘满了才发现,整个集群的Pod可能都处于Evicted状态。
一条经典的磁盘告警规则长这样:
groups: - name: disk-alerts rules: - alert: DiskUsageHigh expr: (1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"})) * 100 > 85 for: 5m labels: severity: warning annotations: summary: "磁盘使用率超过85%" description: "节点 {{ $labels.instance }} 磁盘使用率已超过85%,请立即清理或扩容。"除了磁盘,CPU Throttling的监控也很关键,可以通过容器指标container_cpu_cfs_throttled_periods_total直接计算限流占比,超过阈值就发出告警。有了这套监控,很多“明明跑着但感觉不对”的问题都能提前暴露。
6. 演进过程中的实践经验与一点个人总结
整个视频AI中台从最初“几台物理机 + 手工脚本”演进到现在“Docker多架构镜像 + K8s弹性调度 + GB28181/RTSP标准接入”,最大的收获并不是“用了K8s”这个事实,而是团队处理问题的方式变了:原来接一个摄像头要改代码、拉分支、发版;现在只需要在平台里录入一条设备配置,媒体网关自动完成注册、拉流、转流,AI分析服务自动通过K8s调度获得算力。新接入一个点位的时间从按天算缩短到按分钟算。
有几点经验想单独说:
- 多架构镜像一定要在项目早期就推下去,而不是先只做amd64、等ARM设备进场再补。补历史镜像的坑,比一开始双架构构建的维护成本高得多。
- K8s的弹性能力要在业务压力真正出现之前就验证过,否则压力峰值来了,HPA扩出来的Pod全是Pending,等于没有弹性。我建议每季度做一次“压测日”,把视频路数人为打上去,看调度、扩缩容、录像是怎么表现的。
- 视频媒体的网关服务尽量保持“有状态”的感知,但要设计成无状态部署。有状态指的是它需要知道某路流的会话状态;无状态指的是多个网关实例之间不互相依赖,任何一台挂了,另外一台可以接管。我们的做法是把会话状态存储放到Redis里,网关实例只保持本地内存态,重建时从Redis恢复。
- GB28181这类国标协议,看着很复杂,但有标准文档可查。遇到问题先抓包,比看日志猜问题和问厂商客服要高效得多。
最后再分享一个小技巧:在和摄像头厂商联调时,不要只给对方看结果,把信令包、报文时间线、媒体传输的码率统计一起发过去,两边对齐效率会高很多——这个习惯帮我们解决过好几个悬而未决的“玄学问题”。视频AI中台这条路还很长,但这套“异构算力 + 统一接入 + 弹性调度”的骨架,确实帮我们节省了大量重复劳动,也让我们在面对新项目时,能把精力聚焦在AI算法本身,而不是被底层接入和运维问题反复摩擦。