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 | 网络质量、丢包 | 10s |
| TCP握手耗时 | eBPF kprobe | 连接建立延迟 | 实时 |
| 各状态连接数 | /proc/net/tcp或ss | 连接泄漏、端口耗尽 | 15s |
| 接收/发送队列积压 | ss -lnt的Send-Q/Recv-Q | 应用处理慢 | 10s |
| 零窗口探测次数 | /proc/net/netstat | 接收端缓冲区不足 | 30s |
| 连接重置数 | /proc/net/netstat | 异常断开 | 10s |
2.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负责发送SYN,tcp_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是ESTABLISHED,06是TIME_WAIT,0A是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 -lnt | Recv-Q > 80% Send-Q | 应用accept慢 |
| 握手P99 | eBPF | > 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 namespace |
| eBPF事件丢失 | 数据不完整 | 增大ring buffer + 采样 |
| 标签基数爆炸 | Prometheus内存高 | 聚合网段 + Top N |
7.5 一个反直觉的发现
最后分享一个反直觉的发现:重传率高不一定代表网络差。有一次某服务重传率持续在3%左右,但延迟和错误率都正常。深入排查后发现,是因为该服务的发送缓冲区设置过小,导致大量小包发送,而小包在拥塞控制下更容易触发重传。把发送缓冲区调大后,重传率降到了0.5%以下。所以看到重传率高,先别急着找网络团队,检查一下应用的socket参数可能更快解决问题。
这个经历让我明白,自定义网络指标的价值不在于指标本身,而在于它能引导你找到问题的真正根因。默认指标告诉你"有问题",自定义指标告诉你"问题在哪一层"。把网络层的可观测性做扎实,很多以前需要抓包才能定位的问题,现在看面板就能判断个八九不离十。