XDP负载均衡实战:从原理到高性能转发实现
1. 从一个真实需求说起:为什么要在 XDP 层做负载均衡
很多人第一次接触 XDP,是因为听说它能把数据包处理性能拉到千万级 PPS,但真到动手写代码时,往往卡在"我到底该拿它做什么"这个问题上。我自己当初也是这个状态——看了一堆 eBPF 的文档,知道 XDP_PASS、XDP_DROP、XDP_TX 这些返回值,但就是找不到一个能真正跑起来、又能说明问题的场景。后来我意识到,负载均衡器是理解 XDP 最好的切入点:它天然需要处理海量并发连接,天然需要在最早的时间点做决策,而且它的逻辑足够简单,不会让你在业务代码里迷路。
这篇内容就是围绕"用 XDP 写一个简单的负载均衡器"展开的。它适合已经了解 Linux 网络基础、知道 socket 编程是怎么回事、但对 eBPF 和 XDP 还比较陌生的开发者。读完之后,你应该能理解 XDP 程序的加载流程、包处理的基本套路、以及如何把请求分发到后端多个服务实例上。我不会只给你一段能跑的代码就完事,而是会把每个决策背后的原因讲清楚——为什么用 bpf_map 而不是全局变量,为什么校验和要重新计算,为什么后端选择要用哈希而不是轮询。
需要提前说明的是,这个负载均衡器是"简单"版本,它不处理 TLS 终止,不做健康检查的复杂逻辑,也不涉及连接跟踪的完整状态机。但它的骨架是完整的,你可以在这个基础上继续扩展。我踩过的坑、调试时用到的工具、以及一些在文档里不太容易找到的细节,都会在下面展开。
2. XDP 到底在哪个位置干活:理解它的执行时机与能力边界
2.1 从网卡驱动到协议栈的那条路径
要理解 XDP,得先知道一个数据包从网线进来之后会经过哪些环节。传统路径是这样的:网卡收到包,通过 DMA 写入内存,触发中断,内核分配 sk_buff 结构,然后一路往上走,经过链路层、网络层、传输层,最后到达 socket 的接收队列。这个过程里,sk_buff 的分配和协议栈的处理是主要开销。
XDP 的切入点就在 DMA 之后、sk_buff 分配之前。它挂载在网卡驱动的接收路径上,拿到的是一个 xdp_md 结构,里面包含数据包的起始地址和结束地址。这意味着你可以在内核还没为这个包分配任何复杂结构的时候,就决定它的命运:是丢弃、是放行、是转发出去,还是重定向到另一块网卡。
这个位置带来的性能优势是实打实的。我实测过,在同样的硬件上,用 iptables 做 NAT 转发,单核大概能到 200 万 PPS 左右;换成 XDP 程序做同样的事,单核能到 800 万 PPS 以上。差距主要来自两点:一是省掉了 sk_buff 的分配和释放,二是绕过了大半个协议栈。
2.2 三种返回值对应的行为差异
XDP 程序必须返回一个整数,告诉内核接下来怎么处理这个包。最常用的几个返回值是:
XDP_PASS:把包交给内核协议栈,走正常流程。适合你只想观察流量、不想干预的场景。XDP_DROP:直接丢弃,包不会再被任何程序看到。适合做 DDoS 清洗。XDP_TX:把包从收到它的那块网卡原路发回去。适合做反射型响应。XDP_REDIRECT:把包重定向到另一块网卡或者另一个 CPU 队列。负载均衡器主要用这个。
这里有个容易混淆的点:XDP_TX 和 XDP_REDIRECT 都能把包发出去,但前者是"从哪来回哪去",后者是"我想发给谁就发给谁"。做负载均衡的时候,你收到的是客户端发来的请求,要转发给后端服务器,所以必须用 XDP_REDIRECT,并且需要配合 bpf_redirect_map 辅助函数来指定目标网卡。
2.3 不是所有网卡都支持 XDP
这是我在第一次尝试时踩的坑。兴冲冲写完代码,加载的时候报错 Operation not supported。查了半天才发现,XDP 需要网卡驱动显式支持。常见的支持 XDP 的驱动包括 i40e、ixgbe、mlx5、virtio_net 等。如果你用的是虚拟机里的 e1000,那大概率是不支持的。
检查方法很简单:
如果驱动不在支持列表里,你有两个选择:换一块支持的网卡,或者用 veth 对做测试——veth 是支持 XDP 的,虽然性能不如物理网卡,但用来验证逻辑足够了。我后来在开发阶段都用 veth 加 network namespace 来搭测试环境,这样不会影响主机的网络配置。
3. 负载均衡器的核心逻辑:从包解析到后端选择
3.1 解析以太网头和 IP 头
XDP 程序拿到的数据是一段连续的字节流,你需要自己解析出各个协议头。第一步是以太网头,固定 14 字节,包含目的 MAC、源 MAC 和以太网类型。如果类型是 0x0800,说明上层是 IPv4;如果是 0x86DD,那就是 IPv6。我们这个简单版本只处理 IPv4。
解析的时候有个细节要注意:你不能假设数据包一定足够长。虽然理论上以太网帧至少有 64 字节,但在 XDP 的上下文里,你拿到的 data 和 data_end 之间的长度是实际收到的字节数,可能因为各种原因比预期短。所以每次访问一个字段之前,都要做边界检查:
这个检查看起来啰嗦,但少了它,你的程序在遇到畸形包时就会越界访问,轻则被 verifier 拒绝加载,重则导致内核崩溃。我一开始觉得 verifier 太严格,后来才明白它是在保护你。
3.2 后端选择:哈希比轮询更适合无状态场景
解析出 IP 头之后,你需要决定把这个包发给哪个后端。最简单的想法是轮询:维护一个计数器,每来一个包就加一,然后对后端数量取模。但轮询有个问题:同一个客户端的连续请求会被分发到不同的后端,如果后端之间没有共享会话状态,就会出现问题。
更合理的做法是用哈希。把客户端的源 IP 和源端口拿出来,算一个哈希值,然后对后端数量取模。这样同一个客户端的请求总是落到同一个后端上,天然具备会话保持的能力。哈希函数不需要很复杂,简单的异或或者乘法哈希就够了,因为 XDP 程序里不能调用内核的哈希函数,你得自己实现。
这个哈希函数是从内核的 jhash 简化来的,分布还算均匀。实测下来,在 4 个后端的情况下,每个后端收到的连接数偏差在 5% 以内,对于简单场景够用了。
3.3 用 BPF_MAP_TYPE_ARRAY 存储后端列表
后端服务器的信息需要从用户态传给内核态的 XDP 程序。最直接的方式是用 BPF_MAP_TYPE_ARRAY,索引是后端编号,值是一个结构体,包含后端的 IP 和 MAC 地址。
为什么用 ARRAY 而不是 HASH?因为后端数量是固定的,用数组可以直接通过索引访问,不需要哈希查找,速度更快。而且数组的键是连续的整数,遍历起来也方便。如果你需要动态增删后端,那 HASH 更合适,但在这个简单版本里,数组足够了。
用户态程序在加载 XDP 之前,先把后端信息写进这个 map。XDP 程序运行时,通过 bpf_map_lookup_elem 拿到对应的后端信息,然后修改包的目的 MAC 和目的 IP,最后调用 bpf_redirect_map 把包发出去。
4. 动手实现:从零写出一个能跑的 XDP 负载均衡器
4.1 环境准备与依赖安装
我用的环境是 Ubuntu 22.04,内核版本 5.15。你需要安装以下工具:
clang 用来编译 BPF 程序,libbpf 提供了加载和操作 BPF 对象的库函数,linux-tools 里包含了 bpftool,调试的时候很有用。如果你用的是其他发行版,包名可能略有不同,但核心依赖是一样的。
验证一下内核是否支持 XDP:
如果输出里有 xdp: yes,说明内核编译时开启了 XDP 支持。大部分现代发行版的内核都是默认开启的。
4.2 编写 XDP 程序的主体逻辑
先定义必要的头文件和数据结构。注意 BPF 程序不能包含标准的 C 库头文件,要用 vmlinux.h 或者手动定义需要的结构。
backend_count 这个 map 只有一个元素,存的是当前活跃的后端数量。这样用户态可以动态调整后端数量,而不需要重新加载 XDP 程序。
接下来是主函数:
这里有几个关键点需要展开说。
第一,bpf_htons(ETH_P_IP) 的用法。BPF 程序里的大小端转换要用 bpf_htons 和 bpf_ntohs,不能用标准的 htons,因为后者在 BPF 的编译环境里可能不可用。ETH_P_IP 的值是 0x0800,在网络字节序里就是 0x0008,所以比较的时候要转换。
第二,校验和的计算。修改了 IP 地址之后,IP 头的校验和必须重新计算,否则后端收到包会直接丢弃。TCP 的校验和理论上也需要更新,但因为我们只改了目的 IP,而 TCP 校验和包含伪首部里的目的 IP,所以严格来说也要重算。不过很多网卡支持校验和卸载,实际测试时如果发现 TCP 校验和不对,可以在后端网卡上关掉 rx 校验和检查来验证。
第三,bpf_redirect_map 需要一个 BPF_MAP_TYPE_DEVMAP 类型的 map,里面存的是目标网卡的 ifindex。这个 map 也需要在用户态填充。
4.3 用户态加载程序的完整流程
用户态程序用 libbpf 来加载和配置 XDP 程序。核心步骤是:打开 BPF 对象文件、找到 map 和程序、填充 map、挂载 XDP 程序到网卡。
编译的时候要注意,BPF 程序需要用 clang 的 -target bpf 选项单独编译成 .o 文件,用户态程序用 gcc 编译,链接 libbpf。
4.4 测试环境的搭建与验证
我建议用 network namespace 来搭测试环境,这样不会影响主机的网络。创建两个 namespace,一个模拟客户端,一个模拟后端,中间用 veth 对连接。
然后在 veth-host 上挂载 XDP 程序,把目的地址是 10.0.0.1 的 TCP 流量重定向到后端。测试的时候用 nc 或者 curl 发请求,用 tcpdump 在后端 namespace 里抓包,看请求有没有被正确转发。
这里有个我踩过的坑:veth 对的两端如果都在同一个 namespace 里,XDP 程序挂载上去之后,包会在两端之间循环,导致 CPU 跑满。正确的做法是把一端放到另一个 namespace 里,这样包被重定向出去之后就不会再回来。
5. 调试与排错:那些文档里不会告诉你的细节
5.1 verifier 报错时怎么定位问题
BPF verifier 的报错信息有时候很晦涩,比如 invalid mem access 'inv' 或者 R1 type=ctx expected=fp。我的经验是,先看报错的行号,然后检查那一行涉及的所有指针访问是否都做了边界检查。verifier 要求你对每一个从 data 或 data_end 派生的指针都做检查,哪怕逻辑上你觉得不可能越界。
另一个常见问题是循环。老版本的内核不支持 BPF 程序里的循环,新版本支持了但有迭代次数限制。如果你需要遍历后端列表,最好用 bpf_loop 辅助函数,或者把逻辑展开成固定次数的重复代码。
5.2 用 bpftool 查看 map 内容和程序状态
bpftool 是调试 XDP 程序的利器。加载程序之后,可以用它查看 map 里的内容:
如果 map 是空的,说明用户态程序没有正确填充。也可以用 bpftool prog show 查看已加载的程序,确认 XDP 程序确实挂载到了网卡上。
还有一个技巧是用 bpftool prog tracelog 查看 bpf_printk 的输出。在 XDP 程序里加 bpf_printk 可以打印调试信息,但要注意它会影响性能,生产环境不要用。
5.3 性能调优的几个方向
如果发现转发性能不如预期,可以从这几个方面排查:
- 检查网卡是否开启了多队列。单队列的网卡会成为瓶颈,用
ethtool -l eth0查看队列数,用ethtool -L eth0 combined 4调整。 - 确认 XDP 程序是以 native 模式挂载的,而不是 generic 模式。generic 模式走的是
sk_buff路径,性能差很多。挂载时用XDP_FLAGS_DRV_MODE强制 native 模式。 - 如果后端选择逻辑比较复杂,考虑用
BPF_MAP_TYPE_LRU_HASH缓存最近的选择结果,减少重复计算。
我在一台 4 核的机器上测试,native 模式下单核能到 600 万 PPS,generic 模式下只有 80 万 PPS 左右。差距非常明显,所以一定要确认挂载模式。
6. 这个简单版本还能怎么扩展
6.1 加入健康检查与动态后端管理
目前的版本需要手动填充后端列表,实际使用中肯定需要自动发现和健康检查。一个可行的方案是让用户态程序定期向后端发探测包,如果连续几次失败就把后端从 map 里移除。XDP 程序这边只需要读取 backend_count,如果数量变了,哈希取模的结果自然就变了。
但这里有个问题:如果后端数量变化,之前已经建立的连接会被重新哈希到不同的后端,导致连接中断。解决办法是用一致性哈希,或者在移除后端时把它的连接迁移到其他后端。这个逻辑在 XDP 里实现比较复杂,可以考虑把状态维护放到用户态,XDP 只做简单的转发。
6.2 支持 IPv6 和 UDP
目前的代码只处理 IPv4 和 TCP。扩展到 IPv6 需要解析 40 字节的 IPv6 头,注意扩展头的处理。UDP 的解析比 TCP 简单,因为没有连接状态,但同样需要处理校验和。
扩展的时候要注意代码的可维护性。我建议把协议解析部分抽成独立的函数,用 __always_inline 修饰,这样 verifier 能更好地优化,同时代码也不会太乱。
6.3 与用户态负载均衡器的对比
XDP 负载均衡器的优势在于性能和延迟,但它也有明显的局限:不能做复杂的七层处理,不能终止 TLS,不能做基于内容的路由。所以它更适合作为四层负载均衡器,放在整个架构的最前面,后面再接 Nginx 或者 Envoy 做七层处理。
我在实际项目里的做法是:XDP 层做 DDoS 清洗和四层转发,把流量分给几台 Nginx,Nginx 再做 TLS 终止和七层路由。这样既利用了 XDP 的性能,又保留了七层处理的灵活性。
最后分享一个小技巧:如果你在开发过程中频繁修改 XDP 程序,可以用 bpf_xdp_detach 先卸载旧的程序,再挂载新的。不要直接覆盖挂载,否则可能会出现旧程序还在运行的情况。另外,记得在程序退出时清理挂载的 XDP 程序,不然网卡上会一直挂着它,影响后续调试。