QUIC已经成为HTTP/3的底层传输协议,它把可靠性、拥塞控制全部搬到了用户态实现,底层只依赖UDP。这种设计的代价是:内核不再帮你管理重传和流控,UDP丢包的排查难度反而上升了。当业务方反馈接口偶发超时,而你的TCP监控大盘一片绿色时,问题很可能就藏在UDP的丢包路径上。本文介绍如何用eBPF在内核中精确捕捉每一次UDP丢包,并结合QUIC的传输特性做网络质量归因。

UDP丢包在内核中到底发生在哪里
要监控丢包,先得知道包是怎么丢的。UDP是无连接协议,内核对UDP数据的处理路径远比TCP简单,但丢包点却不少。接收方向上,数据包从网卡驱动进入协议栈后,会经过IP层处理,然后查找对应的UDP套接字,把数据放入接收队列。这中间任何一步失败,包都会被静默丢弃,不会有任何ACK或重传机制通知对端。
常见的丢包原因可以归为几类:一是套接字接收缓冲区满,sk_rcvbuf达到上限后新到的报文直接被丢,这是UDP应用最典型的丢包形态,因为内核不做排队降速,发送方发多快它就收多快;二是校验和错误,通常是网卡或链路问题;三是没有找到对应的套接字,比如目标端口上没有监听进程;四是softnet处理不过来导致的背压丢包,这类丢包发生在更早的路径上,连UDP层都到不了。
下面用一个简单的方式在内核里直接观察丢包统计,不需要任何额外工具:
cat /proc/net/snmp | grep -i udp # 输出示例: # Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors # Udp: 1234567 89 234 987654 210 0
其中RcvbufErrors就是接收缓冲区满导致的丢包数,InErrors包含校验和错误等。但这种方式只能看到全局聚合数字,无法回答三个关键问题:丢的是哪个端口的包、丢给了哪个进程、丢包发生在什么时间点。这恰好是eBPF擅长的地方。
用kprobe和tracepoint监控内核丢包路径
eBPF程序可以挂载在内核函数入口或静态追踪点上。对于UDP丢包,推荐优先使用tracepoint,因为它是内核开发者承诺稳定的接口;对于没有合适tracepoint的路径,再退回用kprobe。Linux内核在udp_queue_rcv_skb被调用前后是丢包判断的关键位置,当这个函数返回非零值时,报文就被丢弃了。
下面是一个基于BCC的kretprobe示例,捕获udp_queue_rcv_skb返回失败的事件:
#!/usr/bin/env python3
from bcc import BPF
bpf_text = "#include <uapi/linux/ptrace.h>
#include <net/sock.h>
BPF_HASH(drop_count, u32, u64);
int trace_udp_drop(struct pt_regs *ctx) {
int ret = PT_REGS_RC(ctx);
if (ret != 0) {
u32 key = 0;
u64 *val, zero = 0;
val = drop_count.lookup_or_try_init(&key, &zero);
if (val) {
(*val)++;
}
}
return 0;
}"
b = BPF(text=bpf_text)
b.attach_kretprobe(event="udp_queue_rcv_skb", fn_name="trace_udp_drop")
print("tracing udp drops...")
b.trace_print()".strip().replace("""", "")实际生产环境建议改用libbpf加CO-RE的方式编写,这样不依赖目标机器的内核头文件,一次编译到处运行。如果想拿到更详细的上下文,比如丢包时刻的五元组信息,可以在kprobe挂载点读取struct sock,从中取出本地端口和远端地址,再通过bpf_get_current_pid_tgid关联到进程。注意在容器场景下,PID要经过namespace转换,直接用宿主机视角的PID去查cgroup归属更可靠。
除了kprobe,内核还提供了skb:kfree_skb这个tracepoint,它会在每个sk_buff被释放时触发,附带丢弃原因参数。配合解析drop_reason枚举,可以把丢包归类为缓冲区满、校验失败、无套接字等具体类别,监控维度更细。不过这个tracepoint在流量大的机器上触发极其频繁,务必在eBPF内先做过滤,比如只跟踪UDP协议的skb,再上报事件,否则会显著影响性能。
结合QUIC特性做网络质量归因分析
监控到丢包只是第一步,对QUIC业务来说,更重要的是区分丢包类型并评估它对传输质量的真实影响。QUIC自带可靠性机制,单次丢包会触发快速重传,用户未必能感知;但如果丢包率超过一定阈值,拥塞窗口持续收缩,吞吐就会明显下滑。因此在设计监控指标时,不建议只看内核丢包计数,而应该把内核丢包率和QUIC连接级的RTT、丢包恢复事件放在一起看。
一个实用的做法是让eBPF程序把丢包事件按五元组聚合,输出为按端口分组的计数,再和业务侧QUIC库(如quiche或lsquic)暴露的连接指标做关联。比如某端口的内核丢包率突增,同时该端口上QUIC连接的PTO超时次数同步上升,基本可以断定是网络侧问题;如果内核丢包平稳但QUIC层重传增加,则更可能是接收端用户态处理慢导致缓冲区堆积。
最后把这些指标接入Prometheus,可以基于eBPF导出器暴露如下指标:udp_drop_total按端口和原因标签分维度统计,配合rate()函数计算丢包速率,再设置告警规则,比如五分钟内RcvbufErrors增速超过阈值就告警。这样一套体系搭建完成后,QUIC业务的网络质量问题就从玄学排查变成了看图说话。
性能与部署注意事项
eBPF程序跑在内核态,写不好确实会拖累整机网络性能。实践中有几条经验值得遵守:第一,探针内部逻辑保持极简,只做过滤和计数,不要在内核态做字符串格式化或大块内存拷贝;第二,事件上报使用ringbuf而不是perf event buffer,高吞吐场景下ringbuf的内存管理和唤醒机制开销更低;第三,对丢包计数做聚合后再上报,避免每个丢包事件都唤醒用户态进程。
部署方面要注意内核版本兼容性。kfree_skb的drop_reason参数需要内核5.17以上才可用,低版本只能拿到丢包事实拿不到原因。另外部分云厂商的定制内核会裁剪BTF信息,CO-RE程序可能无法加载,建议在发布流程里加上内核能力探测,失败时降级到只读/proc/net/snmp的兜底方案。对于运行在Kubernetes中的QUIC服务,可以用DaemonSet方式部署eBPF导出器,通过cgroup映射把丢包指标打上Pod标签,这样告警就能直接定位到具体业务方,排查路径缩短到分钟级。
总结一下,eBPF把UDP丢包从一个只能看聚合数字的黑盒,变成了可按进程、按端口、按原因下钻的可观测信号。对QUIC这类用户态协议来说,这套内核态的观测能力正好补上了TCP监控工具失效后留下的空白,值得每个做网络质量保障的团队投入建设。