RouteScope 这个名字最初只是我电脑里一个不起眼的工具脚本名,意思是“把路由路径放进观测视野里”。后来它慢慢变成了我处理网络故障时最先打开的东西:一条命令,把从本机到目标 IP 之间每一跳的设备、延迟、丢包和 AS 归属全部拉出来,再按时间轴回放对比。这篇文章就围绕 RouteScope 这个项目,聊聊我为什么做它、路径探测的原理是什么、怎么用 Python 搭一个能用的原型,以及落地过程中踩过的那些坑。如果你想排查“ping 通但业务卡”“延迟忽高忽低”“路径莫名绕路”这类问题,或者想给自己的监控体系补上“路径可视化”这块拼图,这篇内容应该能帮你省不少时间。
1. 做 RouteScope 之前,先想清楚它要解决什么问题
1.1 ping 通不等于链路健康
很多朋友排查网络问题有个习惯:先 ping。ping 通就觉得链路没问题,然后开始查服务器负载、查数据库慢查询、查应用日志,折腾半天没结论,最后才发现问题出在中间链路上。
我遇到过最典型的一个案例:某地到云上业务的 TCP 连接频繁超时,业务方坚持说网络没问题,因为他们源头 ping 目标机房的 IP 一直是通的,延迟在 10ms 以内。但实际抓包发现 TCP 握手的 SYN 包发出去之后,ACK 回得非常慢,而且丢包集中在特定时段。这种情况 ping 根本看不出来,因为 ping 用的是 ICMP,走的转发优先级和实际业务流量不一定一样,而且 ping 只告诉你“目标通不通”,根本不告诉你“路径上到底哪一段出了问题”。
1.2 RouteScope 的核心定位:把 trace 从“命令”变成“视图”
传统的 traceroute 能列出每一跳 IP,但输出是纯文本,信息太碎。你要自己盯着一堆 IP 判断哪一跳异常,还要手动跑好几次才能确认路径是否漂移。如果目标路径跨多个运营商、多个地域,一次 trace 的输出根本不足以支撑判断。
RouteScope 的定位就是把这些原始输出变成结构化的、可对比的、可告警的视图。它做三件事:
- 把每一跳的 IP、RTT、丢包率、AS 归属整理成统一的结构化数据;
- 多次探测结果按时间存储,能回放“路径是否变了”“延迟是否在恶化”;
- 当路径变化、丢包率超过阈值时产生告警,而不是等你肉眼去发现。
简单说,它解决的是“从 A 到 B 的网络路径,到底走得好不好”这个问题的可观测性。
1.3 现有工具和我想要的差异
市面上不是没有类似能力,比如 mtr、Grafana 的 Blackbox Exporter、各种商业网络监控产品。但我实际用下来都差点意思,列个对比表更直观:
| 工具/方案 | 能看路径 | 能历史回放 | 能路径变化告警 | 部署成本 | 我的痛点 |
|---|---|---|---|---|---|
| traceroute | 是 | 否 | 否 | 极低 | 纯文本,难对比 |
| mtr | 是 | 否(实时持续) | 否 | 极低 | 数据没落地,无法回看 |
| Blackbox Exporter | 部分 | 是(配合 Prometheus) | 是 | 中 | 默认按探针拿 RTT,路径细节不足 |
| 商业监控产品 | 是 | 是 | 是 | 高 | 贵,且闭环难定制 |
RouteScope 走的是“轻量、自助、能落库”的路线:核心探测逻辑很简单,存储用 SQLite 或 InfluxDB 都行,告警直接对接现有的 Alertmanager 或者钉钉/邮件。它不追求替代商业产品,而是补上“我自己可掌控的路径观测能力”这块短板。
1.4 为什么这个名字要带 Scope
名字里带 Scope 有两个原因。一是本意,我要把路径的“范围”看清楚;二是在 IPv6 的世界里 scope 本身就是一个技术术语,链路本地地址fe80::/10是有 scope 的,处理多网卡主机时要区分报文从哪个接口进来。这个细节后文会专门讲,算是一个隐藏双关。
2. 路径探测的原理:读懂每一跳的应答
2.1 TTL 耗尽机制
路径探测最底层的原理还是 TTL(Time To Live)。IP 报文每经过一个路由器,TTL 减 1;减到 0 时,路由器丢弃报文,同时给源地址回一个 ICMP Time Exceeded 报文。我们只要从 TTL=1 开始逐跳发包,就能让路径上每一台路由器都“被迫”向我们报一次到。
这里有个生活化的类比:TTL 就像游戏里的体力值,每过一个关卡扣一格血,血扣完了,关卡守卫会喊一句“你出局了”,并且告诉你“我是谁”。我们从第一关开始,每一关都派人去送死,就能把整条路线上的守卫全部问出来。
要注意,这个机制依赖中间设备“配合”回 ICMP。如果设备禁用了 ICMP Time Exceeded 的发送,那这一跳就会显示为*,但不代表设备不存在。后面我会讲怎么区分“设备不回”和“设备真的挂了”。
2.2 ICMP、UDP、TCP 三种探测方式
实际写探测逻辑时,不可能只发 ICMP Echo Request,那样目标往往直接回 Echo Reply,中间跳的 TTL 超时信息也能拿到,但很多网络设备对 ICMP 的限速最狠,丢包率看起来很高,容易误判。所以一般有三种探测方式:
| 方式 | 发送的包 | 期待的回包 | 优点 | 缺点 |
|---|---|---|---|---|
| ICMP Echo | ICMP Echo Request | Time Exceeded / Echo Reply | 目标容易识别 | 中间设备限速严重,容易误报丢包 |
| UDP | UDP 到高位端口 | Time Exceeded / Port Unreachable | 传统 traceroute 方式,较“友好” | 某些防火墙直接静默丢弃 UDP |
| TCP SYN | TCP SYN 到指定端口 | Time Exceeded / SYN-ACK | 能探测特定服务的可达性 | 需要 root 权限构造 TCP 包 |
我实际用得最多的是 UDP,因为它最接近传统 traceroute 的行为,而且不容易被中间设备针对。但如果目标主机的防火墙把高位 UDP 端口全封了,最后一跳会一直显示*,这时就得切到 TCP SYN 去确认目标本身是否可达。
2.3 路径漂移与多路径
还有一个容易忽略的问题:同一时刻、同一对源和目标,路径不一定是唯一的。很多骨干网会用 ECMP(等价多路径)做负载均衡,同一个 TTL 的多个探测包可能走到不同的下一跳。如果只发一个包,你看到的只是“某一条路径”,下次再发可能就是另一条。
这会导致一个非常误导人的现象:两次 trace 的结果不一样,中间多了或少了一跳,看起来像“路由绕路了”,其实只是负载均衡把流量分摊到了不同链路上。
RouteScope 的应对策略是:同一个 TTL 连续发多个探测包,统计这一跳返回的所有不同源 IP。如果多个结果不一致,就把这个 TTL 标记为“多路径节点”,而不是简单地覆盖上一次的结果。这个设计非常重要,也是我早期踩坑踩得最狠的地方。
2.4 AS 归属与地理位置
把每一跳的 IP 打上 AS 编号,是 RouteScope 比普通 traceroute 好用很多的地方。AS 全称 Autonomous System,自治系统,你可以把它理解成一个“网络机构的世界语编号”。看到路径从AS13335跳到AS4134,你能立刻知道流量从一个运营商网络切到了另一个或另一个机构网络,路径是否在跨网绕路一目了然。
地理位置信息反而要看场景。IP 地理定位库的准确度参差不齐,我一般只把 AS 和几个粗粒度标签(比如“骨干网内”“国际出口”“云厂商接入点”)作为参考,不拿它当精确判断依据。
3. 用 Python + Scapy 写一个最小可用的 RouteScope 原型
3.1 为什么先选 Python 和 Scapy
做原型阶段我选了 Python + Scapy,原因很实际:Scapy 构造和解析网络包非常方便,不用手动拼 IP 头和 ICMP 头,十几行代码就能实现一次探测,适合快速验证思路。等逻辑稳定了,我再考虑用 Go 重写一遍核心探测器,因为 Scapy 在并发大流量下的解析性能和 GIL 限制确实是瓶颈,但那是后话。
先安装依赖:
pip install scapy然后在 Linux 机器上跑,因为我需要 root 权限来构造原始套接字。Windows 上也能跑,但需要装 Npcap,而且某些防火墙行为会导致结果不如 Linux 直观。macOS 需要给 Python 进程额外授权,稍微麻烦一点。
3.2 单条路径探测代码实现
我直接贴一个最简版,只做一件事:从 TTL=1 到 TTL=30,每个 TTL 发 3 个 UDP 探测包,把每一跳的 IP 和 RTT 收集起来。
#!/usr/bin/env python3 import time from scapy.all import IP, ICMP, UDP, sr1 TARGET = "1.1.1.1" MAX_TTL = 30 PROBES = 3 TIMEOUT = 2.0 def single_probe(target, ttl, probe_id): # 传统 traceroute 会从 33434 开始递增目标端口,避免探测包之间相互复用 dport = 33434 + probe_id pkt = IP(dst=target, ttl=ttl) / UDP(dport=dport) start = time.time() reply = sr1(pkt, timeout=TIMEOUT, verbose=False) rtt = (time.time() - start) * 1000 if reply is None: return {"ip": None, "rtt": None, "type": "timeout"} if reply.haslayer(ICMP): # ICMP type 11 是 Time Exceeded,type 3 是 Port Unreachable return {"ip": reply.src, "rtt": rtt, "type": reply[ICMP].type} return {"ip": reply.src, "rtt": rtt, "type": "reply"} def trace(target): for ttl in range(1, MAX_TTL + 1): hops = [] for probe_id in range(PROBES): result = single_probe(target, ttl, probe_id) hops.append(result) ips = list({h["ip"] for h in hops if h["ip"] is not None}) status = "multi" if len(ips) > 1 else "single" print(f"TTL {ttl:2d} | {status:6s} | {[h['ip'] for h in hops]} | RTT {[round(h['rtt'], 1) if h['rtt'] else None for h in hops]}") if "reply" in [h["type"] for h in hops]: print("Reached target, stopping.") break if __name__ == "__main__": trace(TARGET)这段代码有几个关键点值得展开:
- 目标端口为什么要递增?如果每次都发同一个 UDP 端口,某些目标主机会对同一个目的端口的行为进行缓存,后续包可能被直接丢弃或做特殊处理;按 probe_id 递增可以在一定程度上规避。
sr1的 timeout 设 2 秒合理吗?对国内跨网路径,2 秒基本够用;如果路径非常拥堵或目标很远,可以提高到 3 秒。但 timeout 越久,整个 trace 时间越长,30 跳 × 3 次 × 2 秒最坏情况要 3 分钟。实际工程里我会用并发发送的方式压缩时间。- 判断“到达目标”不能只看 ICMP Port Unreachable,因为可能目标根本不开对应 UDP 端口;要结合 type 3 和 type 11 一起看,必要时用 TCP SYN 确认。
3.3 多路径发现与数据聚合
单跳探测只是基础,真正有价值的是把多次探测结果汇总。我在原型里加了一个聚合层:对同一个 TTL,把多包返回的 IP 集合、RTT 最小值/平均值/最大值、丢包数都记录下来。
def aggregate_hops(results): aggregate = [] for ttl_block in results: rtts = [h["rtt"] for h in ttl_block if h["rtt"] is not None] ips = list({h["ip"] for h in ttl_block if h["ip"] is not None}) loss = sum(1 for h in ttl_block if h["ip"] is None) aggregate.append({ "ttl": ttl_block[0]["ttl"], "ips": ips, "rtt_min": min(rtts) if rtts else None, "rtt_avg": sum(rtts) / len(rtts) if rtts else None, "rtt_max": max(rtts) if rtts else None, "loss": loss, "multi": len(ips) > 1 }) return aggregate这里要注意:loss 不能简单等于“丢包数除以发包数”,因为中间设备可能只是限速 ICMP,而不是真的丢业务包。所以我在界面上会把“探测包丢包率”和“业务实际丢包”分开展示,避免误导。
3.4 结果输出与简单可视化
聚合后的数据我习惯转成 JSON 落盘,这样后面不管接 Grafana 还是自己画页面都方便。
{ "target": "1.1.1.1", "time": "2025-01-15T10:30:00Z", "path": [ {"ttl": 1, "ips": ["192.168.1.1"], "rtt_avg": 1.2, "loss": 0}, {"ttl": 2, "ips": ["203.0.113.1"], "rtt_avg": 8.9, "loss": 0} ] }可视化我用的是 ECharts 的关系图,把每一跳当成一个节点,相邻 TTL 的节点之间连一条线。如果某个 TTL 存在多个 IP,就画出多分支,一眼就能看出路径是否在负载均衡。节点大小按平均 RTT 映射,颜色按丢包率渐变,这样“哪一跳在抖”非常直观。
4. 从原型到可落地工具的四个细节
4.1 探测频率与并发控制
原型跑起来之后,不能直接每秒钟跑一次。对公网目标高频发包,轻则被目标安全策略封禁,重则影响正常业务,这一点必须克制。
我的建议是:
- 默认每 60 秒一轮完整路径探测;
- 一条路径一轮最多 30 跳 × 5 个探测包,每包间隔 200ms 以上;
- 如果要缩短一轮时间,用并发发送而非缩短间隔。
并发发送可以用 Scapy 的sr()一次发多个包,但要注意回调解析。实际跑下来,30 跳并发一轮大约 5 到 8 秒能完成,相比串行的 2 到 3 分钟快太多了。不过并发时系统会瞬时产生一批原始套接字报文,对本地网卡和 CPU 有一点压力,单机同时跑几十个路径没问题,不要贪多。
4.2 IPv6、链路本地地址和 Scope 处理
做 IPv6 探测时,最容易被忽略的就是链路本地地址。如果用fe80::开头的地址作为探测源或目标,Linux 内核要求你同时指定scope,也就是出接口,比如fe80::1%eth0。Scapy 里构造 IPv6 包时,如果目标字段带%后缀,需要先把接口名解析出来。
from scapy.all import IPv6, UDP, sr1 import socket def build_ipv6_target(addr_with_scope): if "%" in addr_with_scope: addr, iface = addr_with_scope.split("%") # 构造报文时通过 iface 参数指定出接口 return addr, iface return addr_with_scope, None这个细节和 RouteScope 的名字意外契合:scope 在 IPv6 世界里就是一个真实存在的概念。如果处理多网卡主机,不处理 scope,探测包可能从错误的接口发出去,导致路径完全不对。我在一台双网卡服务器上踩过这个坑,排查了半天才发现是源地址选择问题。
4.3 数据存储与趋势告警
原型阶段数据存在 SQLite 就够,表结构很简单,核心字段就是target、timestamp、ttl、ips、rtt_min、rtt_avg、rtt_max、loss。如果要长期存储多个监测点,再迁移到 InfluxDB,我自己的经验是按target + timestamp + ttl作为 tag 和 field 的组合,查询性能最好。
告警逻辑我拆成两个规则:
- 路径变化告警:当任意一跳的 IP 集合与上一个时间窗口完全不同,且不是由于多路径正常轮换时,说明路径发生了切换;
- 质量劣化告警:连续 3 轮探测中,同一跳丢包率超过 10% 或平均 RTT 超过历史基线的 1.5 倍。
第一个规则特别有用。很多时候业务卡顿不是因为带宽不够,而是路径被切到了一条绕远的链路上,RTT 从 20ms 变成 80ms。路径变化告警能第一时间告诉你“网络路由可能变了”,这时候再去看 BGP 或运营商侧的信息,方向就对了。
4.4 安全边界:只读探测也有合规红线
这点必须单独说。路径探测是只读操作,但它不是无副作用的:过多的探测流量会对中间设备和目标产生负载。我不建议对非授权目标做高频长时间探测,尤其不要用 RouteScope 去“巡检”别人的公网服务器。如果要在公司内部部署,先确认探测目标属于自己或合作方,在合理范围内使用。
另外,构造原始 IP 包需要较高的系统权限,这本身就是一把双刃剑。工具本身是网络诊断用途,但一定要控制部署面,别让脚本落到不相关的人手里。我自己的原则是:生产环境的探测 Agent 只跑在公司监控网段,目标列表白名单化,不做任意 IP 探测。
5. 实操过程里踩过的坑和排查速查表
5.1 常见异常现象速查表
工具做到后面,真正值钱的是“遇到问题知道怎么排查”。我把踩过的坑整理成了一张表,每次新环境出问题先对着它查:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
从某跳开始全是* | 中间设备限速 ICMP,或不回 Time Exceeded | 增大 timeout,换成 TCP SYN 探测,对比多轮结果 |
| 两次 trace 路径差一跳 | ECMP 负载均衡导致路径漂移 | 增加同 TTL 探测包数量,标记为 multi,不要当故障 |
| 目标可达但 traceroute 不完结 | 目标防火墙丢弃 UDP 高位端口 | 用 TCP SYN 到 80/443 端口确认 |
| 脚本报 PermissionError | 原始套接字需要 root 权限 | sudo运行,或给进程加CAP_NET_RAW |
| 探测延迟很高但业务正常 | ICMP 被 QoS 降级,不代表业务路径差 | 用业务端口做 TCP SYN 探测交叉验证 |
| IPv6 路径探测不通 | 链路本地地址缺 scope 或源地址选择错误 | 显式指定出接口,检查路由表 |
| 虚拟机上探测结果异常 | 虚拟交换机/安全组过滤 ICMP | 换物理机或调整安全组规则 |
5.2 一次“第二跳丢包 80%”的排障实录
举一个实际例子。有段时间监测数据显示,从办公网到某个云厂商接入点的路径上,第二跳丢包率高达 80%,但是第三跳以后丢包率却接近 0。第一反应是第二跳设备出了严重问题,但是结合业务实际访问又似乎没有明显故障。
后来我同时跑了三条探测:一条 ICMP、一条 UDP、一条 TCP SYN,结果 ICMP 路径显示第二跳丢包严重,UDP 路径相对正常,TCP SYN 路径几乎不丢。这就说明第二跳设备大概率只是对 ICMP 限速比较狠,而不是转发有问题。再配合设备侧 SNMP 接口计数确认,物理链路没有 CRC 错误,最终判定这是“假丢包”。
这个案例给我的教训是:任何单协议的单次探测结果都不能直接当结论。RouteScope 的联动多协议探测能力,就是我对比之后专门加进去的。现在遇到异常,我会先看“是不是所有探测方式都丢包”,如果只有一种协议丢,基本可以判定是设备策略导致的探测噪音。
5.3 探测时间窗和基线问题
另一个容易忽略的问题是“用什么时候的数据做基线”。很多告警系统第一次接入时会立刻建立基线,但如果路径一开始就是劣化的,基线本身就不健康,后续永远不告警。我在初始化 RouteScope 时会先跑 24 小时“观察期”,把这段时间的数据作为基线,之后如果某跳 RTT 超过观察期的 P95 一定比例,才触发告警。
还有时间窗口粒度的问题。按 60 秒一轮的频率,单轮数据本身噪声不小;我计算告警用的是 5 分钟滑动窗口,窗口内有 5 轮数据,去除最大值和最小值后再取平均,这样能过滤掉瞬时抖动带来的误报。
5.4 存储膨胀控制
路径探测数据增长很快,如果一分钟一轮、一轮 30 跳,一台机器监测 20 条路径,一天就是 86 万条记录。虽然 SQLite 也能扛,但查询速度会变慢。我在实际使用中做两级压缩:
- 原始逐轮数据只保留 24 小时;
- 超过 24 小时后聚合为 5 分钟一条的摘要;
- 超过 30 天后只保留每日的极值、均值路径指纹。
路径指纹是我自己定义的一个字符串,比如192.168.1.1|203.0.113.1|...|1.1.1.1,专门用来快速判断路径是否发生变化。这样历史回放时不用查每一跳明细,直接对比指纹就能知道哪天路径改变了。
6. 一点后续可以继续扩展的空间
工具做到现在这个程度,对我日常工作已经够用了,但还有几个方向可以继续做深。一个是把多个监测点数据放一起做横向对比,比如从不同城市分别探测同一个目标,能更准确定位“问题出在哪个区域、哪段链路上”。另一个是接入 BGP 数据,当路径变化告警触发时,自动拉取路由表看看有没有异常的前缀通告,把“网络路径变化”和“路由源头变化”关联起来。这些进阶内容我还在逐步完善,等跑一段时间再整理出来分享。在你自己实际用的时候,建议先小范围跑一条核心业务路径,跑通再扩,不要一上来就全公司铺开。