当SIP遇上WebRTC:自研SIP视频网关架构深度解析 当传统电话网络遇到现代实时音视频平台系统真正的挑战从来不只是协议互通而是如何让呼叫接入、节点调度、音视频桥接与多方会议体验同时成立。前言SIP 会话初始协议与 WebRTC 实时音视频平台长期以来像是运行在两个世界中的两套体系。一边是传统电话、视频会议硬终端、PBX 与运营商网络强调标准协议、稳定接入和终端兼容另一边则是浏览器、App 和云端实时会议系统强调互动性、灵活性以及多媒体协作能力。这个项目中的 SIP 视频网关正是连接这两个世界的桥梁。它解决的问题表面上看似直接• 让电话可以拨入 WebRTC 会议• 让会议可以主动呼出到 SIP 终端• 让 SIP 终端接收视频、屏幕共享甚至参与 BFCP 楼层控制但真正把这件事做成一个能在生产环境长期运行的系统远不只是把 SIP 信令翻译一下这么简单。它至少要同时解决四类核心问题• 呼入请求从哪里进入系统最终落到哪一个 SIP 节点• 呼出请求如何在多个 SIP Pod 之间选中最合适的执行者• 多人会议中的多路音频如何稳定回送给只接收单路音频的 SIP 终端• 多路视频与屏幕共享轨道如何统一汇聚并桥接到 SIP视频链路如果说 SIP 网关是一座桥那么真正撑起这座桥的并不只有 SIP 协议本身而是围绕负载均衡、房间接入、音频合流、视频汇聚与媒体编排形成的一整套分布式设计。本文将围绕这些关键问题系统梳理这套 SIP 视频网关的架构思路与实现要点。一、整体架构K8s编排 多副本SIP节点 Redis RPC总线系统整体采用云原生架构设计基于K8s实现容器化编排和弹性扩展系统架构图在这张图里系统实际上由四条主线共同构成• 呼入主线外部 SIP 请求如何通过 K8s 进入某个 Pod• 呼出主线内部业务请求如何通过 Redis RPC 选中一个 Pod 去执行外呼• 音频主线房间中的多路音频如何混合后回送给 SIP 对端• 视频主线房间中的多路摄像头与屏幕共享轨道如何统一汇聚并桥接到 SIP 视频链路也就是说这个系统的本质并不是单纯处理 SIP而是在做一件更复杂的事情把外部 SIP 接入、内部节点调度以及房间中的音视频媒体桥接组织成一个完整的分布式网关。二、两类负载均衡呼入靠K8s呼出靠Redis RPC很多人在第一次看这类系统时会默认认为 既然跑在 Kubernetes 里那负载均衡就是 K8s 做的。然而这句话只说对了一半。在这个项目里呼入和呼出并不是同一种流量因此它们的负载均衡方式天然不同。2.1 呼入K8s服务/负载均衡器负责负载均衡呼入场景是外部 SIP 终端主动向网关发起 INVITE。请求最先接触到的是系统对外暴露的 SIP 服务地址。这条路径非常直接SIP终端 → K8s服务/负载均衡器 → 某个SIP服务节点因此呼入的第一跳分发由 K8s 完成。哪个 Pod 接到这通电话并不是 SIP 服务先在内部协调决定而是入口层先把流量送到了某个副本。这也意味着呼入的负载均衡本质是入口流量分发。一旦请求落到某个 Pod该 Pod 就要负责后续认证、调度、媒体协商与房间接入等完整流程需要特别注意的是 SIP信令可以由K8s入口进行分发但音视频媒体流是有状态的一旦协商完成后续媒体流必须持续到达同一个节点 - 这也是为什么媒体端口通常要通过固定映射方式暴露而不能简单依赖”无状态”转发2.2 呼出Redis 内部RPC负责负载均衡呼出场景完全不是这个模型。呼出时并不是外部 SIP 终端先打过来而是系统内部某个业务请求先产生了 创建一个 SIP 参与者的需求。它的路径不是 外部流量进入 K8s而是上层服务 → Redis/内部RPC → 选中某个SIP节点 → 发起外呼也就是说呼出请求首先进入内部 RPC 总线而不是外部入口。因此呼出要解决的问题不是 哪个入口 Pod 收到流量而是多个 SIP Pod 里应该让哪一个去执行这次外呼。因此呼出的负载均衡本质上是 Redis 加 PSRPC 完成的内部节点选址。2.3 为什么要拆成两种机制因为呼入与呼出的触发方式本质不同。呼入来自外部网络天然先经过K8s/负载均衡器入口呼出来自系统内部业务调用天然先经过Redis/RPC总线负载均衡机制对比所以如果把两者都简单写成”K8s做负载均衡”或者都写成”内部RPC做负载均衡”都会与实际架构不符。正确的架构表述必须是 呼入通过K8s做入口层负载均衡呼出通过Redis 内部RPC做内部节点选择与负载均衡。三、每个SIP节点内部的七层结构每个SIP服务节点内部采用严格的七层分层架构设计层级清晰各司其职。可以把它理解为七层。这种分层设计使得呼入链路主要由入站服务驱动呼出链路主要由出站客户端驱动两者在同一个节点内共享通话统计、监控与资源约束3.1 CLI 入口层main.goCLI 层是整个程序的启动入口负责应用的初始化和生命周期管理本身不包含任何业务逻辑。其主要职责包括• 解析启动参数及配置文件• 初始化日志、配置中心、监控、链路追踪等基础组件• 初始化 RPC Client、Redis、数据库等公共资源• 创建并启动 Service• 监听系统退出信号SIGTERM、SIGINT优雅关闭整个服务因此CLI 层更像整个应用的 Bootstrap启动器负责将各个模块组装起来而不参与具体业务处理。3.2 服务层service/Service 层负责整个 Pod 的生命周期管理可以理解为整个 SIP 网关的管理中心。主要职责包括• 向控制中心注册服务Service Register• 定时上报 CPU、内存、带宽、房间数量等运行状态• 接收控制中心下发的 RPC 请求• 创建、销毁和管理 Room• 统一管理整个 Pod 的生命周期3.3 SIP 服务层sip/service.goSIP Service 层专门负责 SIP 协议处理它是整个系统与 SIP 终端交互的入口。主要职责包括• SIP REGISTER 注册• INVITE 呼叫建立• ACK、BYE、CANCEL 等呼叫控制• OPTIONS 保活检测• REFER 呼叫转移• INFODTMF 等• SUBSCRIBE / NOTIFY如 Cisco Provision3.4 Room会议核心层Room 是整个系统最核心的一层也是所有会议业务逻辑的集中管理者。如果把整个视频会议比作一场演出那么 Room 就像导演负责决定• 谁加入会议• 谁离开会议• 谁能看到谁• 当前采用什么布局• 是否开始合流3.5 媒体处理层Media PortMedia Port 位于业务层和媒体处理层之间是整个系统的媒体抽象层。它负责屏蔽不同协议、不同编码格式之间的差异为上层提供统一的数据接口。主要职责包括• RTP / RTCP 处理• Jitter Buffer处理网络乱序和抖动• 时钟同步3.6 视频管道层PipelinePipeline 是真正执行音视频处理工作的地方也是整个系统计算量最大的模块。主要负责• 视频解码• 视频编码• 音视频混流• 分辨率缩放• 帧率转换• RTP 封装3.7基础设施层Infrastructure基础设施层为整个系统提供通用能力是所有模块共享的公共组件。包括• Config配置管理• Logger日志• Redis Client• RPC Client• Prometheus Metrics• OpenTelemetry Tracing• Worker Pool• Timer• UUID• Retry• RingBuffer这一层不包含任何业务逻辑只负责提供稳定可靠的基础能力。四、启动流程从配置加载到服务注册系统启动过程可概括为几个关键阶段• 加载配置文件初始化日志与监控能力。• 连接Redis集群建立内部RPC消息总线 。• 初始化SIP服务启动入站和出站处理能力 。• 注册内部RPC服务端点开始接收业务请求。• 等待关闭信号处理优雅关闭流程。在启动阶段为了应对复杂的生产与网络环境系统设计了两个至关重要的技术点1. SIP 服务代理共享同一个 UAUserAgent入站 Server 与出站 Client 强制共享同一个 sipgo.UserAgent。这在真实网络中至关重要SIP 的 BYE、re-INVITE 等后续会话信令必须精确返回到同一物理端口。若出入站随机占用不同端口极易引起 NAT 映射失效与会话事务不一致。这是基于海量生产实践所驱动的高可靠设计。2. 顶层服务在内部 RPCPSRPC总线上的注册顶层服务启动后创建 SIP 内部 RPC 服务端点并调用 RegisterCreateSIPParticipantTopic 将当前节点注册到 Topic。这与“呼出通过 Redis RPC 做负载均衡”深度配合将当前节点作为可用副本接入分布式调度池使其能够接受并执行来自业务层的主动外呼任务。五、呼入流程外部SIP终端如何进入会议呼入流程可以概括为以下几个核心步骤 1. 外部SIP终端发起呼叫请求 2. K8s负载均衡器将请求转发到某个SIP节点 3. 节点进行中继认证验证呼叫合法性 4. 评估调度规则决定呼叫去向 5. 若需要PIN码验证引导用户输入 6. 进行媒体能力协商确定音视频参数 7. 建立媒体端口和会议房间连接 8. 终端成功加入会议开始双向音视频通信。呼入流程5.1 呼入为什么是K8s负载均衡因为在呼入场景里第一个到达系统的是外部SIP呼叫请求请求首先落到K8s暴露出来的入口地址然后由K8s/负载均衡器将其转发到某个SIP节点。此时并不存在”上层先调用某个内部RPC再选节点”的过程因此负载均衡责任天然属于入口层。认证通过后服务还会查询对应的业务调度规则决定该呼叫应该进入哪个房间、是否需要验证 PIN 码、或者是否需要拒绝丢弃。在此必须将“业务调度”与“节点负载均衡”清晰剥离开。节点负载均衡在呼入流量进入系统的那一刻已经由外部的 K8s Service / LB 完成了节点分发与 Pod 选址。业务调度落到具体 Pod 之后由 DispatchCall 执行的业务路由动作。这属于纯业务侧的路由与调度绝不参与多 Pod 之间的物理节点竞争和分发逻辑。理解这一点对排除分布式流量故障至关重要。认证通过后服务还会查询对应的调度规则决定该呼叫应该进入哪个房间、是否需要PIN码验证、是否需要拒绝或丢弃。这里解决的是”业务去向”问题不是”节点选址”问题。节点选址在呼入链路中已经由K8s完成。六、呼出流程内部RPC如何选中一个SIP节点发起外呼呼出场景下外部SIP终端还没有发起请求而是系统内部先发起一个”创建SIP参与者”的命令。流程如下1. 上层业务服务发起外呼请求2. 请求进入Redis承载的内部RPC总线3. 内部RPC机制在多个可用SIP节点间进行选址4. 请求被投递到选中的目标节点5. 目标节点创建外呼任务连接对应的会议房间6. 向外部SIP终端发起呼叫请求7. 协商媒体能力建立媒体流连接8. 通话成功建立返回结果给上层服务。呼出流程6.1 呼出为什么是Redis RPC负载均衡因为这条链路不是从K8s入口过来的而是上层业务服务发起内部RPC调用消息进入Redis承载的内部RPC总线多个SIP节点共同参与处理该类请求 由RPC机制决定最终哪一个节点处理这次外呼。因此呼出的负载均衡属于内部RPC级别的节点选择而不是外部入口流量分发。6.2 呼出时如何理解”负载均衡”呼出的负载均衡可以分成两个层次看 1. 机制层面靠Redis 内部RPC实现多节点请求分发 2. 策略层面应优先让更空闲的节点处理请求呼出的负载均衡可分为两个核心层面• 分发机制由 Redis 承载的 PSRPC 总线实现多节点间的命令广播与分配。• 选址策略总线优先选择当前“总活跃呼叫数Active Calls”最少的 Pod 节点来执行此次外呼。这是一种极其科学的自适应负载选址算法。系统会同时统计节点的出站与入站活跃通话相加得到其总活跃通话数。这一指标完美地契合了 SIP 网关的物理资源模型能确保请求流向负载最轻的节点。七、合流为什么它是 SIP 视频网关真正的技术难点如果只从功能列表看很多人会把这个系统理解成 一个把 SIP 和 WebRTC 接起来的协议转换器。但真正进入多人会议场景后系统面对的核心问题很快就会变化。对于 SIP 终端来说它看到的媒体世界通常是 一路音频、一路视频但对于 SFU中房间来说里面可能同时存在• 多个参与者的音频轨道• 多个摄像头视频轨道• 屏幕共享轨道• 动态加入、离开、静音、取消静音、切换共享等实时变化也就是说网关不能只是 把一条流转给另一条流而必须先把房间里的多路媒体整理成 SIP 终端能够消费的形态。这件事就是本文所说的合流。从架构上看这里的 合流 其实包含两类完全不同的问题1.音频合流把多人会议中的多路声音混成一路稳定输出。2.视频汇聚把多路摄像头与屏幕共享轨道统一接管再桥接到 SIP 视频链路。两者都发生在 Room 这一层附近但处理方式完全不同。音频是混合视频是汇聚与桥接。理解这一点才能真正看懂这个网关为什么不是一个简单的 SIP 适配器而是一个媒体编排系统。7.1 为什么合流是网关而不是终端的问题在纯 WebRTC 会议里终端通常可以各自订阅多路轨道界面也可以自由布局但传统 SIP 终端不具备这样的交互模型。对 SIP 终端而言音频侧通常只能接收一条连续的媒体流视频侧即便支持视频最终也必须被桥接成一个可协商、可持续输出的单一发送链路 它并不理解 房间里当前有多少人发言、多少人开摄像头、谁在共享屏幕 这些会议语义。因此系统必须在网关内部完成一次 会议语义 到 SIP 媒体语义 的转换。换句话说SIP 网关并不是被动转发媒体而是在主动回答两个问题• 房间里这么多路音频最后哪一路或者哪几路应该被送给电话侧• 房间里这么多路视频最后该由哪条视频链路代表会议输出给 SIP 终端这就是为什么合流问题天然落在网关内部而不是交给终端自己解决。7.2 音频合流的核心不是 MediaPort而是 Room很多人会下意识认为音频处理都在 MediaPort 里完成。实际上在这个项目中真正负责 多人音频合成 的核心在 Room()中。在这里系统会创建两个关键对象out : msdk.NewSwitchWriter(RoomSampleRate)mix : mixer.NewMixer(r.out, rtp.DefFrameDur, 1, mixer.WithStats(st.Mixer), mixer.WithOutputChannel())这两个对象共同构成整个音频合流中心mix负责把多路输入混成一路out负责把混合后的输出送往当前下游。这意味着Room在这里并不是一个单纯的房间连接包装器而是承担了会议侧音频汇聚中心的职责。只要有新的房间音轨进入它首先不是直接进入 SIP 编码器而是先进入混音体系。也就是说系统内部实际存在这样一条链多路房间音频 → mixer → 统一输出 writer → SIP 编码链路这个设计的意义非常大因为它把 会议侧多路输入 与 SIP 侧单路输出解耦开了。上游只需要不断把新的房间音轨接入 mixer下游只需要消费稳定的单路 PCM 输出两侧不必直接感知彼此的复杂状态7.3 多路音频到底是怎么被接进 mixer 的Room.NewTrack()返回的是 r.mix.NewInput()。这个细节非常重要因为它暴露了整个音频合流模型的真实结构。它不是 先创建一条固定输出流再把所有人往里写而是采用了 更典型的混音器输入口模型房间中每一路待进入混音器的音频源都会拿到一个独立的 mixer input。每个 input 代表一条独立的上游音频写入。mixer 按统一的 frame duration 和采样率对这些输入做时间对齐与混合混合完成后再把结果写给统一输出从工程视角看这种设计有三个好处• 输入隔离某一路音频的抖动、暂停或结束不会破坏整个输出结构。• 节奏统一所有输入最终都被拉齐到同一混音节拍便于稳定编码输出。• 易于扩展新增或移除一个说话者本质上只是新增或关闭一个 input。这也是为什么这个项目的音频合流可以自然适配多人会议而不是只能处理一对一通话。7.4 混音后的音频最终如何回到 SIP 对端当 Room 产出一路稳定的 PCM 音频后这路音频会继续进入 SIP 侧的媒体发送链路。在 MediaPort.setupOutput() 中系统会建立 RTP 写出链路并通过 rtp.EncodePCM() 把 PCM 编码成当前协商好的 SIP 音频格式再封装成 RTP 包。完整链路可以理解为房间多路音频→ Room.NewTrack()→ mixer.NewInput()→ Room.mix→ Room.out SwitchWriter→ MediaPort audioOut→ rtp.EncodePCM()→ RTP writer→ SIP 对端这条链路里每一层职责都非常明确Room 负责会议侧多路输入管理与合流 mixer 负责时间对齐与音频混合SwitchWriter 负责统一输出与动态切换MediaPort 负责编码、RTP 封装与网络发送。因此如果只把音频链路理解成 MediaPort 在发 RTP会错过真正的核心。真正决定 多人会议音频能否正确回送给 SIP 终端 的是前面那套由 Room 驱动的合流结构。7.5 视频合流这套网关真正复杂的不是视频编码而是多路视频如何被接管、筛选和桥接如果说音频合流解决的是 多路声音如何回到单路电话音频那么视频合流要解决的问题其实更棘手。因为视频不能像音频那样直接做采样叠加。多人会议里的多路 camera、屏幕共享、动态开关摄像头、切换发言者、共享开始与结束这些变化都必须被网关实时接住。但最终发往 SIP 终端的却仍然是一条可协商、可持续发送、可被传统终端理解的视频链路。所以这个项目里的视频合流准确说并不是把多路视频混成一张画布而是把多路视频统一纳入一套 接管、筛选、订阅、桥接 的流程。如果用一句话概括它的本质视频合流的关键不是混而是管。先把房间里的多路视频管起来再把其中合适的一路或一类内容稳定送进 SIP 视频链路。这也是为什么视频部分比音频更像一个控制系统而不只是一个媒体处理链路。7.5.1 视频合流的起点仍然是 Room而不是 GStreamer很多人一看到视频桥接就会立刻把注意力放到 GStreamer 上。但在这个项目里GStreamer 只是后半段的处理引擎真正的合流入口其实仍然在 Room。原因很简单系统必须先决定房间里的哪些视频值得被接入桥接链路然后才轮到媒体管道去处理这些数据。在 Room.Subscribe()中系统会枚举远端参与者及其轨道并按轨道类型做不同处理• 音频轨道会直接订阅。• 屏幕共享轨道在启用时会直接纳入订阅流程。• 摄像头轨道则不会无条件全部订阅而是先收集到 UnmutedCameraTracks再交给后续订阅管理协程处理。 Room 在视频侧承担的不是 简单转发而是第一层策略控制。7.5.2 摄像头与屏幕共享为什么要分开处理系统并没有把所有视频一视同仁。在 Room.onTrackSubscribed()]中摄像头轨道与屏幕共享轨道会被送往不同的 source。 摄像头轨道走r.cameraSource.OnTrackSubscribed()屏幕共享轨道走 r.screenshareSource.OnTrackSubscribed()。摄像头视频和屏幕共享虽然都属于视频但它们在会议里的语义并不相同。摄像头强调人像、发言者、参会者可见性屏幕共享强调内容展示通常更接近当前会议主内容。两者在订阅策略、切换时机、BFCP 联动以及终端体验上都有所不同。也正因为如此系统在房间侧就把两类视频拆成两条逻辑链路而不是先揉成一个抽象的视频池再说。这正是 视频合流 的第一层关键能力先分类管理再决定如何桥接。7.5.3 Room 如何把视频入口真正接到后续桥接链路上无论入站还是出站呼叫系统都会在媒体编排器创建完成后把 orchestrator 暴露出的视频 source 注入到 Room。对应代码分别在room.SetSource(orchestrator.cameraRtcPad.RtcSrc,orchestrator.screenshareRtcPad.RtcSrc)room.SetSource(orchestrator.cameraRtcPad.RtcSrc,orchestrator.screenshareRtcPad.RtcSrc)最终落到 SetSource()。因为在这一步之前Room只知道 房间里有什么视频而在这一步之后Room才真正拥有了把这些视频交给后续媒体系统处理的能力。换句话说Room 负责感知与管理房间里的视频轨orchestrator 提供实际可消费这些轨道的 sourceSetSource()把两者连成一条完整通路。一旦这个连接成立后续房间里的 camera 与 screenshare 订阅事件才不只是会议事件而会真正转化为 SIP 视频桥接链路的输入事件。7.5.4 真正的视频桥接是如何由 VideoManager 接管的当 Room 把视频轨道交给对应 source 之后真正负责把这些内容送进 SIP 视频链路的是 VideoManager。从结构上看VideoManager 内部持有几个关键要素RTP 与 RTCP 连接用于和 SIP 终端建立真实的视频网络通路。 SDP 媒体描述用于记录当前协商后的方向与编解码信息。controller. *pipeline.Controller用于承接具体的视频处理管道。 rtcSrc 与 rtcSink用于连接 WebRTC 侧轨道与 SIP 侧输出。这意味着 VideoManager 的角色不是 单纯发包而是负责把会议侧视频轨道 与 SIP 侧协商后的视频会话 参数绑定在一起。7.5.5 pipeline.Controller 才是视频合流落地成媒体链路的地方如果说 Room 负责收拢与挑选视频轨道VideoManager负责绑定 SIP 会话参数那么真正把视频跑起来的是 pipeline.Controller。在 pipeline.New()中控制器会同时创建两条视频管道down 管道WebRTC → SIPup 管道SIP → WebRTC。这两条管道在 BuildPipeline() 中完成 GStreamer pipeline、source、sink 与 video bin 的构建。对于我们关心的视频合流文章主题而言最关键的是下行链路也就是 WebRTC 房间的视频如何被送往 SIP 终端。这一步的本质是上游 source 接收来自房间订阅后的轨道数据GStreamer 完成 depay、parse、decode、convert、compositor、encode、pay 等处理 sink 把处理好的 RTP 视频输出到 SIP 对端到了这里所谓 视频合流才真正从房间轨道管理走到了 SIP 媒体输出。所以完整的视频合流链路应该理解为房间多路 camera / screenshare tracks→ Room.Subscribe()→ 候选轨道筛选与订阅管理→ Room.onTrackSubscribed()→ cameraSource / screenshareSource→ VideoManager→ pipeline.Controller→ GStreamer video pipeline→ RTP 输出→ SIP 视频终端这个流程里每一层都在完成不同意义上的 合流• Room 做的是会议多路轨道的收拢与筛选• source 层做的是被选中轨道的统一接管• VideoManager 做的是 SIP 会话参数与媒体链路绑定• pipeline.Controller 做的是真正的视频转码合流与桥接输出八、优雅关闭与滚动升级的分布式平滑摘流机制系统的优雅关闭流程是典型的“平滑摘流”设计。其与网关的两层负载均衡机制构成了极其完美的分布式闭环配合• 主动注销 PSRPC 注册工作节点一旦触发优雅下线首先主动向 Redis PSRPC 总线注销外呼 Topic 的注册。这使得业务侧新发起的呼出选址再也不会将指令派发给这个 Pod 节点直接实现了新外呼的主动屏蔽。• 入口流量渐进过渡对于入站流量K8s 会继续把新连接分配给集群中其它健康的 Pod 实例原节点不再承担新呼入。• 5秒周期间隔轮询服务进程每隔 5 秒钟进行一次活跃呼叫Active Calls数量检测。对于已在当前节点上建立并通话中的存量呼入/呼出连接进程不会粗暴切断而是等待它们自然、平滑地挂断结束。• 资源完全清零后正常退出直到节点承载的总活跃通话数精确归零所有媒体端口和房间连接资源被全部优雅释放服务才正式停止 SIP 用户代理并干净利落地关闭进程。这套机制保证了整个集群在滚动升级或缩容时用户侧可达到 100% 的“完全无感知”平滑摘流体验。这与”呼出通过Redis RPC做负载均衡”天然配合。节点一旦开始优雅关闭就不会再被RPC机制选中处理新外呼已经建立的呼入/呼出通话继续自然结束 滚动升级过程中集群可以实现平滑摘流用户无感知。而呼入侧则继续由K8s在仍然健康的节点之间分发新流量。九、结语这个SIP网关的价值不仅在于”把SIP接进WebRTC”更在于它采用了适合生产环境的双层负载均衡思路。面向外部SIP流量依赖K8s提供稳定的入口分发能力面向内部呼出请求依赖Redis 内部RPC在多个SIP节点之间做细粒度节点选择这使系统既能适应传统SIP网络的入口接入模式又能在内部业务调用场景下实现更灵活的横向扩展。最终这个架构的关键不是单纯”有多少节点”而是让呼入流量通过K8s可靠进入系统让呼出请求通过Redis RPC被分配到最合适的SIP节点。