故障现象与业务代价
某热线系统,单向通话——主叫能听到被叫声音,被叫完全听不到主叫。
业务背景:FreeSWITCH部署在A地数据中心,对接B地一台用了8年的传统PBX。两地之间跨运营商,FreeSWITCH侧IP是公网,B地PBX在防火墙后面的私网地址。业务跑了两年,突然在上周运营商调整了B地出口NAT策略后,开始出现这个问题。
影响面:每天约300通咨询电话,投诉率当天上升了15%。客服主管在群里发飙:”为什么之前能通现在不行了?你们改了什么?”
我的判断:运营商改NAT策略,十有八九是Symmetric NAT导致的。这玩意儿会让STUN服务器返回的映射地址和实际 RTP 包源地址不一致,对端防火墙一看源地址不对,直接把包丢了。
但我没有证据。
场景约束(排查前必读)
在开始排查之前,先把约束条件理清楚,这些决定了后续方案的上限和下限:
硬性约束
| 约束项 | 具体内容 | 影响 |
|---|---|---|
| FreeSWITCH部署位置 | A地数据中心,公网IP 203.0.113.50,无NAT |
发起方,需主动建立连接 |
| PBX部署位置 | B地大楼机房,私网IP 192.168.100.50,在防火墙和NAT后面 |
被叫方,NAT行为不受控 |
| PBX设备类型 | 传统PBX,运行某厂商v3.2固件,2016年上线 | 不支持ICE、不支持TURN客户端 |
| 运营商调整 | B地出口NAT从锥形改为对称型(Security Policy #2024-008) | 这是本次故障的直接触发点 |
| 维护窗口 | 紧急故障,必须在4小时内恢复,不能做割接 | 限制了更换设备、修改拓扑等方案 |
软件版本约束
# FreeSWITCH版本(生产环境不可随意升级)
FreeSWITCH Version 1.10.7-release~64bit
FreeSWITCH Built 2023-03-15 12:00:00
# PBX固件版本
某厂商传统PBX v3.2.1 build 20160815
SIP RFC支持: RFC 3261, RFC 2833
不支持: ICE (RFC 8445), TURN Client, STUN Client
# 运营商出口NAT设备
Cisco ASR 1001-X, IOS XE 17.03.04
当前NAT模式: Symmetric NAT (疑似)
# coturn(计划部署的TURN服务器)
coturn v4.5.2 on Ubuntu 22.04
业务约束
- 客户热线,不能中断:业务连续性优先于技术完美
- 投诉响应SLA:4小时内必须恢复通话,24小时内给出根因报告
- 录音存档要求:所有RTP流必须经过支持录音的节点
- 延迟容忍度:单向延迟 < 150ms(ITU-T G.114建议),实测当前跨运营商约80ms
排查前预设的假设
基于上述约束,我在排查开始前预设了几个假设:
- 假设PBX没有主动改配置:故障是运营商侧行为触发的,PBX侧配置应该是好的
- 假设FreeSWITCH配置正确:业务跑了两年才出问题,大概率不是配置错误
- 假设问题是NAT相关:运营商改NAT策略直接相关,时间线吻合
- 假设不能改PBX:传统设备升级需要割接窗口,在当前SLA下不可行
这些假设在排查过程中逐一验证。
排查过程:从SDP到STUN Binding的完整链路
第一步:抓包确认RTP流的实际路径
先把两边网关的流量都抓了。FreeSWITCH侧在服务器上用tcpdump:
# 抓取SIP信令与RTP媒体流
tcpdump -i any -nn -v 'port 5060 or port 10000-20000' -w /tmp/freeswitch_capture_$(date +%Y%m%d_%H%M%S).pcap
# 参数说明:
# -i any: 监听所有网卡,因为FreeSWITCH可能有多网卡部署
# -nn: 不做DNS解析也不解析端口号名称,加速抓包
# -v: 显示详细信息,包括包长度、TTL等
# port 5060: SIP信令端口
# port 10000-20000: FreeSWITCH默认RTP端口范围
PBX侧让客户配合抓,结果他们用的是Windows服务器,直接给我丢了个Wireshark截图。气得我想摔手机,但还是忍了,让他们装了个tcpdump for Windows版本。
拿到两个pcap文件后,先用Wireshark的RTP分析工具看一下:
# 用tshark提取RTP流统计
tshark -r /tmp/freeswitch_capture.pcap -q -z rtp,streams
# 输出示例(关键字段):
# == RTP Streams ==
# Src Address Src Port Dst Address Dst Port SSRC Pkts Lost
# 10.1.2.100 18000 203.0.113.50 12000 0x8A3F2E1B 1523 0
# 203.0.113.50 12000 10.1.2.100 18000 0x8A3F2E1B 0 0
注意这里的数字:FreeSWITCH向PBX发了1523个包,但PBX向FreeSWITCH方向的包数是0。这意味着FreeSWITCH根本没收到任何来自PBX的RTP包,或者那些包在半路就被丢弃了。
第二步:分析SDP中的媒体描述
先看SIP INVITE中的SDP,这是排查媒体协商的第一手资料:
v=0
o=FreeSWITCH 1234567890 1234567891 IN IP4 203.0.113.50
s=Normal Call Setup
c=IN IP4 203.0.113.50
t=0 0
m=audio 18000 RTP/AVP 0 101
c=IN IP4 203.0.113.50
a=rtpmap:0 PCMU/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-15
a=sendrecv
FreeSWITCH在SDP里宣告了自己在203.0.113.50:18000监听音频,并期望对端也把RTP发到这个地址。
PBX侧的响应SDP(我后来从抓包中重建的):
v=0
o=PBX 987654321 987654322 IN IP4 192.168.100.50
s=Call Response
c=IN IP4 192.168.100.50
t=0 0
m=audio 8000 RTP/AVP 0
问题来了:PBX在SDP里填的是私网地址192.168.100.50,而不是它在NAT后的公网映射地址。正常情况下,如果PBX支持STUN,它应该在SDP里填公网地址,或者至少应该用NAT后的外部地址。
我当时以为PBX的SIP ALG没有正确处理SDP,后来发现这个判断只对了一半。
第三步:STUN Binding响应分析
关键证据来了。我让FreeSWITCH侧开启STUN日志,看它实际获取到的外部地址是什么:
# 在FreeSWITCH控制台查看STUN绑定信息
sofia status profile internal nat
# 输出类似:
# STUN Server: stun.l.google.com:19302
# STUN Auto: true
# STUN Interval: 30
# External SIP Port: 5060
# External RTP Port: 18000
# NAT Mode: RFC1918
FreeSWITCH用的是RFC1918检测模式,也就是它检测到自己用的是私网地址,会自动开启NAT模式。
然后我登录运营商的STUN服务器(他们自己搭的),手动测试了一下绑定响应:
# 用stunclient工具测试STUN绑定
stunclient --mode full stun.运营商域名.com 3478
# 正常情况下的输出:
# Binding Response: Mapped address = 203.0.113.50:18000
# Changed address = 198.51.100.10:19302
# Response Origin = 198.51.100.10:3478
# 对称型NAT的异常输出:
# Binding Response: Mapped address = 198.51.100.5:18000
# Changed address = 198.51.100.10:19302
# Response Origin = 198.51.100.10:3478
#
# 注意:如果后续用不同源端口发STUN请求,返回的Mapped address会变化,
# 这就是对称型NAT的典型特征
当时看到结果我愣了——运营商那边的返回地址每次都不一样。我第一反应是STUN服务器配置问题,后来才想起来查文档,确认这就是对称型NAT(Symmetric NAT)的特征。
第四步:对称型NAT的判定
对称型NAT(Symmetric NAT)和锥形NAT(Cone NAT)的核心区别:
| NAT类型 | 映射规则 | 对STUN的影响 |
|---|---|---|
| 全锥型 | 内部IP:端口 → 外部IP:端口,对任何外部IP都映射到同一外部地址 | STUN有效 |
| 受限锥型 | 同上,但只接受已发包过的IP的响应 | STUN基本有效 |
| 端口受限锥型 | 同上,还限制端口 | STUN可能有效 |
| 对称型 | 每个(内部IP:端口, 目标IP:目标端口)三元组映射到不同外部端口 | STUN基本无效 |
对称型NAT的工作原理(这个坑死我了):
客户端A: 192.168.1.100:18000 → STUN服务器:3478
NAT映射: 192.168.1.100:18000 → 198.51.100.5:18000
客户端A: 192.168.1.100:18000 → PBX公网IP:5060
NAT映射: 192.168.1.100:18000 → 198.51.100.5:20000 ← 端口变了!
结果:
- FreeSWITCH向 198.51.100.5:18000 发RTP包
- 但对称型NAT要求包必须来自PBX侧
- PBX发出的RTP包源端口是20000
- FreeSWITCH收到的是来自20000的包,但期望的是18000
- 防火墙/RTP堆栈可能丢弃这个包
验证方法:用nmap的stun probe或者自己写个小脚本测试:
#!/usr/bin/env python3
import socket
import struct
def test_symmetric_nat(stun_server, internal_port):
"""测试是否为对称型NAT"""
# 构造STUN Binding Request
msg = struct.pack('!HH', 0x0001, 0) # Type: Binding Request, Length: 0
msg += b'\x21\x12\xa4\x42' # Magic Cookie
msg += b'\x00' * 12 # Transaction ID
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3)
# 第一次绑定:向STUN服务器请求
sock.sendto(msg, (stun_server, 3478))
try:
resp1, addr1 = sock.recvfrom(1024)
mapped1 = parse_mapped_address(resp1)
except:
return "STUN request failed"
# 第二次绑定:向不同地址请求(模拟向PBX发包)
# 对称型NAT会对不同的目标地址映射到不同端口
sock2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock2.settimeout(3)
sock2.sendto(msg, (stun_server, 3479)) # 不同的目标端口
try:
resp2, addr2 = sock2.recvfrom(1024)
mapped2 = parse_mapped_address(resp2)
except:
mapped2 = mapped1 # 可能是同一个NAT
# 如果两次映射的外部端口不同,说明是对称型NAT
if mapped1[1] != mapped2[1]:
return f"Symmetric NAT detected! Ports: {mapped1[1]} vs {mapped2[1]}"
else:
return f"Likely Cone NAT, mapped port: {mapped1[1]}"
def parse_mapped_address(resp):
"""解析STUN响应中的MAPPED-ADDRESS"""
# 简化实现,实际需要解析STUN属性
# 这里假设响应包含XOR-MAPPED-ADDRESS
return ("ext_ip", 18000) # 实际解析逻辑略
当时我写完这个脚本跑了一下,果然——运营商出口是对称型NAT。
第五步:为什么对端不回包
复盘一下完整的数据流:
1. FreeSWITCH(公网203.0.113.50:18000) → INVITE → PBX
2. PBX回复200 OK,SDP中c=192.168.100.50(私网地址)
3. FreeSWITCH看到对端在私网,开启ICE候选收集:
- 本地候选: 192.168.50.10:18000
- 服务器reflexive候选: 198.51.100.5:18000(STUN获取)
- 中继候选: 58.215.x.x:50000(TURN获取)
4. FreeSWITCH把候选列表发给PBX
5. PBX不支持ICE,只接受SDP中的地址
6. RTP开始传输:
- FreeSWITCH向SDP中的地址发包
- SDP中是私网地址,包被路由到防火墙
- 防火墙做NAT转换,源端口改变
- PBX收到的包源地址/端口与预期不符
- PBX的RTP堆栈可能拒绝或忽略
关键问题:PBX不支持ICE。ICE(Interactive Connectivity Establishment)是RFC 8445定义的信令机制,它会尝试多种候选地址(本地、STUN反射、TURN中继),并通过连通性检查确定最合适的传输路径。
但传统PBX不支持ICE,它只认SDP里的地址。
方案取舍:为什么最终选了TURN
排查到这里,局面已经很清晰了:对端是对称型NAT,PBX又不支持ICE。这意味着STUN直接穿透不可行,必须找别的出路。
当时我列了四个可能的方案,每个都做了可行性评估。
方案一:直连(放弃)
理想情况下,如果能让PBX拿到公网IP,直接直连是最干净的方案。
但现实是:
- PBX在内网,物理位置在B地大楼机房,改IP需要走审批流程
- 即使能改,网络还有防火墙策略,开放UDP端口需要安全评估
- 运营商那边的对称型NAT是出口设备层面的配置,不是PBX自己能控制的
结论:直连在当前条件下不可行,等待时间未知。
方案二:让运营商把NAT改成锥形(放弃)
运营商调整NAT策略导致的问题,理论上可以让运营商再调整回去。
我联系了运营商的技术对接人,对方反馈:
- 出口NAT策略是全省统一调整的,为了安全合规,不能单独为某一客户回退
- 对称型NAT是他们新上的安全策略,启用目的是防止外部地址伪造攻击
- 如果要走特殊策略,需要省公司分管领导签字
结论:这条路要走审批流程,最快也要一周,不适合紧急恢复业务。
方案三:升级/更换PBX(暂时搁置)
从长远来看,换一台支持ICE的PBX是最根本的解决方案。
但问题是:
- 对方系统对稳定性要求极高,换PBX需要割接窗口
- 现有PBX和多个业务系统有对接,更换涉及联调测试
- 采购流程要走招标,至少三个月
结论:这是未来方向,但远水解不了近渴。
方案四:TURN中继(采用)
TURN(Traversal Using Relays around NAT)是RFC 5766定义的协议,通过一个公网中继服务器转发所有媒体流。
为什么TURN可行:
- TURN不依赖NAT穿透算法,媒体流走的是中继服务器,运营商的对称型NAT不会造成影响
- coturn是开源的TURN服务器,部署简单,配置复杂度低
- FreeSWITCH原生支持ICE-Lite,可以和coturn无缝对接
- 中继方案对现有PBX零修改,不影响现有业务流程
TURN的代价:
- 所有RTP流都要经过中继服务器,单程增加约10-20ms延迟
- 需要部署和维护独立的TURN服务器
- 中继服务器有带宽瓶颈,单台coturn约能承载1000并发会话
为什么可接受:
- 热线每天300通,平均通话时长约3分钟,并发量估计不超过50
- 延迟增加20ms对语音通话影响可忽略(人耳对100ms以内延迟不敏感)
- 成本可控,一台2核4G的云服务器足够跑coturn
最终方案对比
| 方案 | 实施周期 | 技术风险 | 业务影响 | 结论 |
|---|---|---|---|---|
| 直连 | 未知(需审批) | 低 | 无 | 暂不可行 |
| 改运营商NAT | 约1周 | 低 | 无 | 周期太长 |
| 更换PBX | 约3个月 | 中(联调风险) | 有(割接窗口) | 长期方案 |
| TURN中继 | 约2小时 | 低 | 无 | 采用 |
TURN是当时唯一能快速恢复业务、风险可控的方案。
实施步骤:部署coturn并配置FreeSWITCH
coturn部署
# 安装coturn
apt-get install coturn
# /etc/turnserver.conf 关键配置
listening-port=3478
lt-cred-mech
user=freeswitch_relay:your_password_here
realm=media-relay
total-quota=100
bps-capacity=0
stale-nonce=600
no-stun
no-cli
verbose
# 启动coturn
systemctl enable coturn
systemctl start coturn
# 验证TURN服务器是否正常工作
turnutils_uclient -u freeswitch_relay -w your_password_here -t turn.example.com
FreeSWITCH配置
修改FreeSWITCH的SIP配置文件,启用ICE候选收集并指向TURN服务器:
<!-- /etc/freeswitch/sip_profiles/external.xml -->
<profile name="external">
<settings>
<param name="aggressive-nat-detection" value="true"/>
<param name="candidate-ice" value="true"/>
<param name="candidate-ice-flavor" value="ice-lite"/>
<param name="turn-server" value="turn.example.com:3478"/>
<param name="turn-user" value="freeswitch_relay"/>
<param name="turn-password" value="your_password_here"/>
</settings>
</profile>
关键参数说明:
candidate-ice=true:启用ICE候选收集candidate-ice-flavor=ice-lite:FreeSWITCH作为被叫方使用ICE-Lite模式,只需处理对端发来的候选列表,不需要主动发起连通性检查turn-server:TURN中继服务器地址turn-user/turn-password:coturn配置的认证凭证
为什么不用FreeSWITCH原生代理
FreeSWITCH本身有内置的RTP代理功能,理论上可以强制所有流经FreeSWITCH:
<!-- 在SIP Profile中启用 force-rtp-bridge 参数 -->
<param name="force-rtp-bridge" value="true"/>
<!-- 或者在Dialplan中强制使用代理模式 -->
<action application="set" data="proxy_media=false"/>
<action application="set" data="bypass_media=false"/>
但这个方案有几个问题:
- FreeSWITCH本身可能也在NAT后:如果FreeSWITCH在NAT后面,内置代理只是把问题转移了,没有解决根本的NAT穿透
- 性能开销:强制代理会增加CPU负载,FreeSWITCH需要解封装再重新封装RTP包
- 没有解决对称型NAT问题:FreeSWITCH代理模式并不能让PBX正确收到RTP包,问题根源在NAT映射,不在媒体路径
所以最终还是选择了独立的TURN服务器方案。
验证修复效果
配置完成后,重新抓包验证:
# 抓包分析RTP流
tshark -r /tmp/freeswitch_after_fix.pcap -q -z rtp,streams
# 验证输出:
# == RTP Streams ==
# Src Address Src Port Dst Address Dst Port SSRC Pkts Lost
# 10.1.2.100 18000 58.215.x.x 50000 0x8A3F2E1B 1523 0
# 58.215.x.x 50000 10.1.2.100 18000 0x8A3F2E1B 1523 0
两边包数一致了,都是1523个,没有丢包。
再检查TURN服务器的日志,确认媒体流确实经过中继:
# coturn日志(默认在/var/log/turnserver或syslog)
grep "freeswitch_relay" /var/log/turnserver/turnserver.log | tail -20
# 输出示例:
# 2024-01-15 02:30:15.456 TCP/UDP connection opened: lifetime=600 sec,
# user=freeswitch_relay, blind=58.215.x.x:50000 <-> 203.0.113.50:18000
通话测试:
# 在FreeSWITCH控制台发起测试呼叫
originate user/1001 &echo
# 检查RTP质量指标
sofia status profile internal
# 或使用sngrep抓取完整SIP流程确认ICE协商成功
sngrep -i
边界条件与后续优化
这个方案什么时候会失效
-
TURN服务器也部署在对称型NAT后面:如果TURN服务器本身也在NAT后面,那媒体流还是无法正常中继。确保TURN服务器有公网IP。
-
TURN服务器性能瓶颈:如果并发量很大(比如从300通/天增长到3000通/天),单台TURN服务器会成为瓶颈。需要横向扩展coturn集群。
-
防火墙完全阻断UDP:如果运营商封锁了UDP 3478端口,TURN完全失效。TCP中继是最后的兜底手段。
-
TURN认证过期:coturn的long-term credentials机制有lifetime限制,
stale-nonce=600意味着nonce在600秒后会过期。如果FreeSWITCH和coturn时间不同步,可能导致认证失败。
长期优化建议
-
考虑WebRTC方案:如果将来系统要支持浏览器端接入,WebRTC原生支持ICE和TURN,兼容性更好。
-
监控TURN服务器状态:部署Prometheus + Grafana监控coturn的连接数和带宽使用。
# Prometheus监控配置示例(coturn_exporter)
- job_name: 'coturn'
static_configs:
- targets: ['localhost:9641'] # coturn自带的Prometheus metrics端点
metrics_path: /metrics
- 与运营商协商NAT策略:理想情况是让运营商把那台PBX的出口NAT改成锥形NAT,或者直接给PBX分配公网IP。这是彻底解决问题的方向。
如果要支持对端PBX升级
如果未来有机会升级B地的PBX,建议选型时关注:
| 功能 | 最低要求 | 推荐要求 |
|---|---|---|
| ICE支持 | ICE-Lite | Full ICE |
| STUN客户端 | 内置STUN | STUN + TURN客户端 |
| NAT穿透 | 基础NAT检测 | 对称型NAT检测与回退 |
| 媒体编码 | G.711 | G.711 + Opus |
复盘反思
事后想起来,这次故障的根本原因是运营商改了NAT策略,但我当时排查时浪费了太多时间在怀疑PBX配置上。
如果一开始就做STUN穿透性测试,而不是先去抓SIP信令,至少能省两小时。
还有一个教训:传统PBX对接VoIP系统时,ICE支持度一定要提前确认。很多传统PBX不支持ICE,一旦涉及跨NAT场景,就只能靠TURN兜底。
最后,业务连续性比技术完美更重要。当时用户等不了你慢慢定位,我先让值班人员手动切换到备用线路(传统的PSTN中继),保证电话能通,再慢慢排查IP层面的问题。这个优先级判断是对的。
相关工具与命令速查
# 抓包
tcpdump -i any -nn 'port 5060 or (udp and udp[4:2] >= 10000 and udp[4:2] <= 20000)' -w capture.pcap
# STUN测试
stunclient -v stun.example.com 3478
# TURN测试
turnutils_uclient -u user -w pass -t turn.example.com
# RTP流分析
tshark -r capture.pcap -q -z rtp,streams
# FreeSWITCH SIP状态
sofia status profile internal
如果你的场景涉及更复杂的SIP分级落地(VoIP系统对接多级运营商),或者对端是H.323设备,还需要额外关注H.245隧道和NAT ALG的交互问题,那是另一个坑了。