RouteScope:网络路径探测与可视化实战
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 耗尽机制路径探测最底层的原理还是 TTLTime To Live。IP 报文每经过一个路由器TTL 减 1减到 0 时路由器丢弃报文同时给源地址回一个 ICMP Time Exceeded 报文。我们只要从 TTL1 开始逐跳发包就能让路径上每一台路由器都“被迫”向我们报一次到。这里有个生活化的类比TTL 就像游戏里的体力值每过一个关卡扣一格血血扣完了关卡守卫会喊一句“你出局了”并且告诉你“我是谁”。我们从第一关开始每一关都派人去送死就能把整条路线上的守卫全部问出来。要注意这个机制依赖中间设备“配合”回 ICMP。如果设备禁用了 ICMP Time Exceeded 的发送那这一跳就会显示为*但不代表设备不存在。后面我会讲怎么区分“设备不回”和“设备真的挂了”。2.2 ICMP、UDP、TCP 三种探测方式实际写探测逻辑时不可能只发 ICMP Echo Request那样目标往往直接回 Echo Reply中间跳的 TTL 超时信息也能拿到但很多网络设备对 ICMP 的限速最狠丢包率看起来很高容易误判。所以一般有三种探测方式方式发送的包期待的回包优点缺点ICMP EchoICMP Echo RequestTime Exceeded / Echo Reply目标容易识别中间设备限速严重容易误报丢包UDPUDP 到高位端口Time Exceeded / Port Unreachable传统 traceroute 方式较“友好”某些防火墙直接静默丢弃 UDPTCP SYNTCP 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 单条路径探测代码实现我直接贴一个最简版只做一件事从 TTL1 到 TTL30每个 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(dsttarget, ttlttl) / UDP(dportdport) start time.time() reply sr1(pkt, timeoutTIMEOUT, verboseFalse) rtt (time.time() - start) * 1000 if reply is None: return {ip: None, rtt: None, type: timeout} if reply.haslayer(ICMP): # ICMP type 11 是 Time Exceededtype 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(fTTL {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 数据当路径变化告警触发时自动拉取路由表看看有没有异常的前缀通告把“网络路径变化”和“路由源头变化”关联起来。这些进阶内容我还在逐步完善等跑一段时间再整理出来分享。在你自己实际用的时候建议先小范围跑一条核心业务路径跑通再扩不要一上来就全公司铺开。