自定义网络层监控指标实战:从TCP重传率到eBPF握手耗时采集 1. 从一次线上告警说起为什么默认网络指标不够用凌晨两点监控大屏上某个服务的P99延迟突然从80ms飙到2.3s但CPU、内存、磁盘IO全部正常应用层的错误率也没有明显波动。值班的同学盯着屏幕看了十分钟最后靠抓包才发现是某个下游依赖的TCP重传率突然升高导致请求在传输层被反复重发。这件事之后我意识到一个问题大多数团队在搭建监控体系时应用层指标QPS、响应时间、错误率和主机层指标CPU、内存、磁盘都做得比较到位但网络层的可观测性往往停留在网卡流量和连接数这两个粗粒度指标上一旦出问题就只能靠抓包和猜。这就是自定义监控网络层指标这个主题要解决的核心问题。默认的node_exporter或者云厂商自带的基础监控通常只提供带宽、包量、连接数这类聚合数据它们能告诉你网络有流量但没法告诉你流量健不健康。真正有价值的网络层监控需要下沉到TCP状态机、重传行为、连接建立耗时、队列积压这些维度。这篇文章会从指标选型、采集方案、埋点实现、可视化配置到告警阈值设定完整走一遍我在实际项目中落地自定义网络指标的全过程适合有一定监控基础、想让可观测性再上一个台阶的后端和运维同学参考。需要提前说明的是网络层指标的自定义采集涉及内核参数读取和eBPF等技术不同内核版本和发行版的行为有差异文中给出的方案都经过实际验证但你在落地时仍需结合自己的环境做适配。2. 网络层到底该监控哪些指标从TCP状态机倒推2.1 默认指标的盲区在哪里先看一个典型的默认监控面板包含什么网卡入/出带宽bytes/s、包量packets/s、活跃连接数、监听队列长度。这四个指标能覆盖的场景其实很有限。带宽高不代表健康可能是重传导致的无效流量连接数多不代表有问题可能是正常的短连接业务监听队列长度只在瞬间溢出时才有意义平时都是0。我踩过的一个坑是某次服务超时率上升但所有默认网络指标都正常。后来用ss -s看TCP统计发现retrans重传段数在持续增长。重传意味着数据包发出后没收到ACK超时后重发这会直接拉长请求的尾延迟。但重传率这个指标默认监控里根本没有。2.2 从TCP状态机推导关键指标要系统性地选指标最好的方法是回到TCP状态机。一个连接从SYN_SENT到ESTABLISHED再到TIME_WAIT每个状态转换都可能出问题连接建立阶段SYN发出后多久收到SYN-ACK如果这个时间变长说明网络RTT增大或对端处理慢。对应的指标是TCP握手耗时。数据传输阶段发出的段有多少需要重传接收窗口是否经常为0对应的指标是重传率和零窗口计数。连接关闭阶段TIME_WAIT堆积过多会耗尽本地端口。对应的指标是各状态连接数分布。基于这个思路我整理了一份核心指标清单指标名称数据来源反映的问题采集频率建议TCP重传率/proc/net/snmp网络质量、丢包10sTCP握手耗时eBPF kprobe连接建立延迟实时各状态连接数/proc/net/tcp或ss连接泄漏、端口耗尽15s接收/发送队列积压ss -lnt的Send-Q/Recv-Q应用处理慢10s零窗口探测次数/proc/net/netstat接收端缓冲区不足30s连接重置数/proc/net/netstat异常断开10s2.3 应用层指标和网络层指标的关联单独看网络指标容易误判。比如重传率升高可能是网络真的有问题也可能是应用写入速度太慢导致发送缓冲区满、触发超时重传。所以自定义网络指标一定要和应用层指标做关联分析。我的做法是在同一个Dashboard里把重传率和该服务的P99延迟画在上下两个图里时间轴对齐一眼就能看出因果关系。提示不要孤立地看待任何一个网络指标。重传率、握手耗时、队列积压这三个指标要放在一起看才能区分是网络问题还是应用问题。3. 采集方案选型三种技术路线的取舍3.1 方案一读取proc文件系统这是最轻量的方案。Linux内核把大量网络统计信息暴露在/proc/net/目录下比如/proc/net/snmp包含TCP和UDP的汇总统计/proc/net/netstat包含更详细的扩展统计如TCPExt里的重传、零窗口等/proc/net/tcp则列出每个连接的详细状态。读取proc的优势是零依赖、零侵入一个shell脚本或者几十行Go代码就能搞定。缺点是只有聚合数据拿不到单个连接的握手耗时而且部分字段的含义在不同内核版本间有变化。我早期就是用Python定时读/proc/net/snmp解析RetransSegs和OutSegs两个字段算出重传率简单有效。# 解析 /proc/net/snmp 计算TCP重传率 def parse_tcp_retrans(): with open(/proc/net/snmp) as f: lines f.readlines() # 找到Tcp:开头的两行第一行是表头第二行是值 tcp_lines [l for l in lines if l.startswith(Tcp:)] headers tcp_lines[0].split()[1:] values tcp_lines[1].split()[1:] stats dict(zip(headers, values)) retrans int(stats[RetransSegs]) out_segs int(stats[OutSegs]) return retrans / out_segs if out_segs 0 else 03.2 方案二eBPF内核探针如果需要单个连接的握手耗时、或者想按进程维度拆分网络指标eBPF是当前最合适的方案。它可以在内核函数上挂kprobe比如在tcp_connect和tcp_rcv_state_process上埋点记录SYN发出和SYN-ACK收到的时间戳两者相减就是握手耗时。eBPF的优势是精度高、维度细可以按PID、按目标IP聚合。代价是需要较新的内核4.9以上推荐5.x且编写和调试有一定门槛。我一般用BCC工具集里的现成脚本做快速验证比如tcpretrans可以直接打印重传事件确认问题后再用libbpf写生产级的采集程序。3.3 方案三应用层埋点如果网络请求都经过统一的客户端库比如gRPC的拦截器、HTTP的RoundTripper可以在应用层记录每次请求的连接建立耗时、TLS握手耗时、首字节时间等。这个方案的优点是维度最丰富可以带上业务标签比如调用的是哪个下游服务缺点是无法覆盖非应用发起的流量且对应用有侵入。我的实际选择是组合方案用proc文件系统采集全局重传率和连接状态分布覆盖所有流量用eBPF采集关键服务的握手耗时精度要求高的场景用应用层埋点补充下游依赖维度的数据。三者互补成本也可控。方案精度维度侵入性适用场景proc文件系统秒级全局无基础重传率、连接状态eBPF微秒级连接/进程无握手耗时、按进程拆分应用层埋点微秒级请求/下游有业务维度的网络质量4. 用eBPF实现TCP握手耗时采集的完整过程4.1 为什么选握手耗时作为第一个自定义指标在众多网络指标里我建议把TCP握手耗时作为第一个落地的自定义指标。原因有三第一它直接反映用户感知的连接建立延迟比带宽更贴近体验第二它的采集逻辑清晰就是SYN发出到SYN-ACK收到的时间差第三它能区分网络慢和对端服务慢——如果握手就慢说明网络或对端accept队列有问题如果握手快但首字节慢那是应用处理慢。4.2 埋点位置的选择TCP握手在内核里的关键函数有两个客户端侧tcp_connect负责发送SYNtcp_rcv_state_process在收到SYN-ACK后把连接状态从SYN_SENT转为ESTABLISHED。我们在这两个函数上挂kprobe用连接的socket指针作为key记录时间戳。这里有个细节需要注意tcp_connect的入参是struct sock *sk而tcp_rcv_state_process的入参也是struct sock *sk两者是同一个对象所以可以用sk指针作为关联key。但sk指针在连接关闭后可能被复用所以要在连接进入ESTABLISHED后立即删除记录避免内存泄漏。// eBPF内核态代码片段 struct key_t { u32 pid; u32 saddr; u32 daddr; }; BPF_HASH(start, struct sock *, u64); int kprobe__tcp_connect(struct pt_regs *ctx, struct sock *sk) { u64 ts bpf_ktime_get_ns(); start.update(sk, ts); return 0; } int kprobe__tcp_rcv_state_process(struct pt_regs *ctx, struct sock *sk) { u64 *tsp start.lookup(sk); if (tsp 0) return 0; u64 delta bpf_ktime_get_ns() - *tsp; // 只记录握手完成的连接 if (sk-__sk_common.skc_state TCP_ESTABLISHED) { // 输出delta到用户态 start.delete(sk); } return 0; }4.3 用户态聚合与指标暴露内核态采集到原始耗时后用户态程序需要做聚合再暴露给Prometheus。我一般用直方图Histogram来记录因为握手耗时的分布比平均值更有意义——平均值可能被大量快速连接拉低掩盖了长尾问题。分桶可以设为1ms、5ms、10ms、50ms、100ms、500ms、1s、5s。// 用户态聚合示例伪代码 var handshakeDuration prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: tcp_handshake_duration_seconds, Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5}, }, []string{pid, daddr}, ) // 从eBPF perf ring buffer读取事件 for { event : readFromPerfBuffer() handshakeDuration.WithLabelValues( strconv.Itoa(int(event.Pid)), intToIP(event.Daddr), ).Observe(float64(event.DeltaNs) / 1e9) }4.4 实测中的性能开销很多人担心eBPF的性能开销。我在一台4核8G的测试机上做过压测QPS 5000的情况下挂载握手耗时探针后CPU额外占用约1.2%内存增加约15MB。这个开销对于大多数业务是可以接受的。但如果你的QPS在10万以上建议只对关键下游IP做采样采集而不是全量。注意eBPF程序加载需要root权限或CAP_BPF能力在生产环境部署时要考虑权限管理。另外内核版本低于4.9的机器不支持需要提前排查。5. 重传率与连接状态分布的采集细节5.1 重传率的正确计算方式重传率最简单的算法是RetransSegs / OutSegs但这个算法有个陷阱OutSegs包含重传的段所以分母偏大算出来的重传率偏低。更准确的做法是用RetransSegs / (OutSegs - RetransSegs)即重传段占原始发送段的比例。不过在实践中两种算法的差异在重传率低于1%时很小用哪种都可以关键是要保持口径一致否则历史数据没法对比。还有一个细节/proc/net/snmp里的统计是自系统启动以来的累计值需要做差分才能得到每秒的重传率。我用的是Prometheus的rate()函数采集端直接暴露累计值查询时做差分。# 手动查看当前重传统计 cat /proc/net/snmp | grep Tcp # 输出示例 # Tcp: RtoAlgorithm RtoMin RtoMax MaxConn ActiveOpens PassiveOpens AttemptFails EstabResets CurrEstab InSegs OutSegs RetransSegs # Tcp: 1 200 120000 -1 123456 78901 234 56 789 12345678 9876543 123455.2 连接状态分布的采集/proc/net/tcp列出了每个TCP连接的本地地址、远端地址和状态码。状态码是十六进制比如01是ESTABLISHED06是TIME_WAIT0A是LISTEN。统计各状态连接数最直接的方法是用ss -s但它的输出是给人看的不适合程序解析。程序化采集我推荐直接读/proc/net/tcp按状态码分组计数。# 统计各状态TCP连接数 STATE_MAP { 01: ESTABLISHED, 02: SYN_SENT, 03: SYN_RECV, 04: FIN_WAIT1, 05: FIN_WAIT2, 06: TIME_WAIT, 07: CLOSE, 08: CLOSE_WAIT, 09: LAST_ACK, 0A: LISTEN, 0B: CLOSING, } def count_tcp_states(): counts {} with open(/proc/net/tcp) as f: next(f) # 跳过表头 for line in f: fields line.split() state fields[3] name STATE_MAP.get(state, UNKNOWN) counts[name] counts.get(name, 0) 1 return counts5.3 队列积压的监控ss -lnt输出里的Send-Q和Recv-Q对监听套接字有特殊含义Recv-Q表示当前已完成三次握手但还没被应用accept的连接数Send-Q表示监听队列的最大长度。如果Recv-Q持续接近Send-Q说明应用accept速度跟不上新连接会被丢弃或超时。这个指标我建议单独做告警阈值设为Recv-Q Send-Q * 0.8持续30秒。因为一旦队列满客户端会收到RST或超时用户体验直接受损。指标采集命令告警阈值建议说明重传率/proc/net/snmp 2% 持续1分钟网络质量下降TIME_WAIT数/proc/net/tcp 30000端口耗尽风险监听队列积压ss -lntRecv-Q 80% Send-Q应用accept慢握手P99eBPF 500ms连接建立慢6. 指标暴露、可视化与告警配置6.1 用Prometheus Exporter统一暴露采集到的指标需要统一暴露给Prometheus。我的做法是写一个自定义Exporter把proc文件系统采集、eBPF采集、应用埋点三路数据汇总到一个HTTP端点。Exporter用Go写因为Go的Prometheus客户端库最成熟而且eBPF的Go绑定cilium/ebpf也比较好用。Exporter的指标命名要遵循Prometheus规范用下划线分隔单位放在末尾比如tcp_retrans_rate、tcp_handshake_duration_seconds、tcp_connection_state_count。标签不要太多否则会导致时间序列爆炸。我一般只保留instance、pid如果是进程维度、daddr如果是下游维度这几个标签。// Exporter核心逻辑 http.Handle(/metrics, promhttp.Handler()) go func() { for { // 每10秒采集一次proc指标 collectProcMetrics() time.Sleep(10 * time.Second) } }() // eBPF事件通过perf buffer异步写入 go consumeEBPFEvents()6.2 Grafana面板的布局思路可视化面板的布局直接影响排查效率。我的经验是按总览-下钻-关联三层来组织第一层总览重传率、握手P99、TIME_WAIT数、队列积压四个核心指标用Stat面板显示当前值红色表示异常。第二层下钻点击某个异常指标后展示按下游IP或按进程拆分的明细。比如握手P99高就下钻看是哪个下游IP导致的。第三层关联把网络指标和应用层的P99延迟、错误率画在同一时间轴上方便判断因果关系。这里有个小技巧Grafana的$__rate_interval变量可以自动适配查询时间范围避免手动调整rate()的窗口。另外重传率这种百分比指标Y轴建议固定为0到5%这样异常时曲线变化更明显。6.3 告警阈值的设定经验告警阈值不能拍脑袋定要基于历史数据的分布来定。我的做法是先采集一周的数据算出P50、P95、P99然后把告警阈值设在P99的1.5倍左右。比如握手耗时P99是80ms那告警阈值就设120ms。但有些指标不能只看绝对值要看趋势。比如TIME_WAIT数业务增长期本来就会上升如果设固定阈值会频繁误报。这种情况我用predict_linear函数做趋势预测如果预测1小时后会超过端口上限的80%就提前告警。# Prometheus告警规则示例 groups: - name: network_custom rules: - alert: HighTCPRetransRate expr: rate(tcp_retrans_segs_total[1m]) / rate(tcp_out_segs_total[1m]) 0.02 for: 1m labels: severity: warning annotations: summary: TCP重传率超过2% - alert: HighHandshakeLatency expr: histogram_quantile(0.99, rate(tcp_handshake_duration_seconds_bucket[5m])) 0.5 for: 2m labels: severity: critical annotations: summary: TCP握手P99超过500ms提示告警规则一定要加for字段避免瞬时抖动触发告警。网络指标本身波动就大不加for会导致告警风暴。7. 踩过的坑与实战心得7.1 内核版本差异导致的字段错位第一次用eBPF采集握手耗时时在测试机上跑得好好的上线到生产环境后数据全是0。排查了半天发现是生产环境的内核版本较老tcp_rcv_state_process的函数签名不同kprobe挂载失败但没报错。后来我加了一个启动自检逻辑加载eBPF程序后主动发起一个TCP连接确认能采集到数据再开始正式采集。这个自检逻辑后来帮我避免了好几次类似问题。7.2 容器环境下的proc文件系统在容器里读/proc/net/snmp读到的是宿主机还是容器自己的数据答案是默认情况下是宿主机的网络命名空间数据因为容器共享宿主机的网络栈除非用了独立的network namespace。这意味着如果你在容器里部署Exporter采集到的重传率是整个宿主机的不是单个容器的。要采集容器自己的网络指标需要进入容器的network namespace或者用eBPF按cgroup过滤。7.3 高并发下的eBPF事件丢失eBPF的perf ring buffer在高并发下会丢事件。我遇到过QPS 2万时握手耗时采集丢了约30%的数据。解决办法是增大ring buffer的页数默认64页可以调到1024页并且在用户态用多goroutine并行消费。如果还是丢就降级为采样采集比如每10个连接采集1个用采样率反推统计值。7.4 指标基数爆炸的预防给握手耗时指标加daddr标签时如果下游IP很多会导致时间序列数量爆炸。我的做法是对daddr做聚合只保留网段比如10.0.1.0/24或者只对Top 20的下游IP单独打标签其余归为other。这样既保留了排查能力又控制了基数。坑点现象解决方案内核版本差异eBPF采集无数据启动自检 版本适配容器proc数据采集到宿主机指标进入network namespaceeBPF事件丢失数据不完整增大ring buffer 采样标签基数爆炸Prometheus内存高聚合网段 Top N7.5 一个反直觉的发现最后分享一个反直觉的发现重传率高不一定代表网络差。有一次某服务重传率持续在3%左右但延迟和错误率都正常。深入排查后发现是因为该服务的发送缓冲区设置过小导致大量小包发送而小包在拥塞控制下更容易触发重传。把发送缓冲区调大后重传率降到了0.5%以下。所以看到重传率高先别急着找网络团队检查一下应用的socket参数可能更快解决问题。这个经历让我明白自定义网络指标的价值不在于指标本身而在于它能引导你找到问题的真正根因。默认指标告诉你有问题自定义指标告诉你问题在哪一层。把网络层的可观测性做扎实很多以前需要抓包才能定位的问题现在看面板就能判断个八九不离十。