XDP_TX on veth:为什么 peer 也要挂一个 XDP_PASS
1 | |
这篇文章整理一个很容易误判的 veth + XDP 现象:XDP 程序确实收到了包,也确实执行到了 return XDP_TX;,但包没有被发回去,ethtool -S 里还能看到 xdp_tx_errors 增长。
这个问题来自我之前在 Stack Overflow 上写的一个问答:XDP_TX don’t work for veth for the simplest L2 forwarding。最后的解法看起来很怪:在 veth 的另一端也挂一个什么都不做、只返回 XDP_PASS 的 XDP 程序。
它怪,是因为这不是转发逻辑需要;它是 veth driver 的 XDP 数据路径需要。
现象
测试拓扑大概是这样:
1 | |
在 veth2_3 上挂一个最小 XDP 程序:收到 IPv4 包后交换二层源/目的 MAC,然后返回 XDP_TX,期望包从同一个 veth 口打回去。
简化后的程序类似这样:
1 | |
调试时会看到几个互相矛盾的信号:
bpf_printk()能证明程序收到了包。- 代码确实走到了
return XDP_TX;。 - L2 地址看起来也是对的。
- tcpdump 能看到包进入
veth2_3。 - 但返回方向看不到包。
ethtool -S veth2_3里rx_queue_0_xdp_tx_errors增长。
如果只从 XDP action 的语义理解,很容易以为问题在 MAC、checksum、bridge、route 或 namespace 配置上。实际上根因在 veth 的 XDP 发送路径。
误区:XDP_TX 不是普通 dev_queue_xmit
对物理网卡来说,XDP_TX 可以粗略理解成“把这个刚收到的 packet buffer 从同一块网卡发回去”。所以很多人会把这个心智模型直接搬到 veth 上:
1 | |
方向没错,但机制不对。veth 没有真实硬件 TX ring。veth pair 的“发出去”本质上是把帧交给 peer device 的 receive side。
也就是说,在 veth 上执行 XDP_TX 时,driver 不是走普通内核网络栈重新发一个 skb,而是把 xdp_frame 放进 peer 侧为 XDP 准备的接收队列。
关键点是:那个接收队列并不是永远存在。
veth 里的真正路径
当前 Linux veth driver 的核心路径在 drivers/net/veth.c 里。veth_xdp_xmit() 会拿到 peer device,然后选择 peer 的 receive queue。
关键判断是这个意思:
1 | |
后面才会把 frame 放进 peer 侧的 xdp_ring:
1 | |
如果 peer 侧没有启用对应的 XDP/NAPI receive path,rq->napi 不存在,veth_xdp_xmit() 直接失败。于是你会看到程序执行到了 XDP_TX,但 driver 统计里出现 xdp_tx_err。
这就解释了为什么这个 bug 看起来很反直觉:不是你的 XDP 程序没有 return XDP_TX,而是 XDP_TX 后面的 veth peer-side ring 没准备好。
为什么 Linux 要这样设计
这个行为不是因为 XDP 语义要求“对侧也必须运行 BPF 程序”。真正原因是:veth 的 XDP_TX 要在 XDP fast path 里完成跨 peer 交付,而这个 fast path 的基本单位不是 skb,而是 xdp_frame。
普通 veth 发送路径是 skb 世界:
1 | |
XDP 路径则刻意绕开了这件事。XDP 的设计目标是在尽量早的位置处理包,最好在进入完整网络栈、分配和加工 skb 之前就完成 drop、redirect 或 tx。到了 XDP_TX 这里,veth 手里拿到的是从 xdp_buff 转出来的 xdp_frame,它代表的是一块由 XDP 内存模型管理的 packet buffer。
所以 veth 有两个选择:
1 | |
当前实现选择的是 A。也就是:XDP_TX 不负责临时造一个 skb 再走普通 veth receive,而是把 xdp_frame 放进 peer 的 xdp_ring,让 peer side 的 NAPI poller 去消费。
这个选择有几个好处:
- 避免从 XDP buffer 退回 skb 路径,少一次对象转换和更多 skb 相关开销。
- 可以批量处理 frame,符合 XDP/driver fast path 的设计方式。
- peer 收到 frame 后仍然可以继续运行 peer 侧 XDP 程序,而不是直接进入普通协议栈。
- 内存所有权更清楚:frame 进入 XDP ring,由 XDP-aware 的 receive path 负责继续处理或释放。
代价就是:peer 必须真的有一个能消费 xdp_ring 的 receive side。这个 consumer 通常由 peer 侧启用 XDP 后初始化出来,包括 rq->napi、xdp_ring、xdp_rxq 等状态。没有这个 consumer,发送侧就算已经执行到了 XDP_TX,也没有一个合法的地方可以把 xdp_frame 交出去。
可以把它理解成这样:
1 | |
为什么不在 peer 没开 XDP 时自动走 choice B?从接口体验看,这样确实更友好;但从 driver 设计看,这会把一个 fast-path action 变成“有时走 XDP ring,有时临时转 skb”的混合路径。这个 fallback 不只是多几行转换代码,它还要处理 headroom、frags、metadata、GRO、引用计数、错误释放和统计口径。旧实现/旧讨论里确实出现过 fallback 思路,但当前主线 veth 的代码选择更明确:没有 peer-side XDP/NAPI consumer,就让 XDP_TX 失败并计入错误。
所以,“对侧挂一个 XDP_PASS”不是业务需求,而是在声明 peer 侧也支持 XDP receive path:
1 | |
这里很容易想到另一个解释:是不是 Linux 故意要求 peer 开 XDP,是为了防止 veth pair 在内核里互相 XDP_TX,形成死循环?
我觉得这个直觉有道理,但它不是这个现象的主要原因。两边都写成 XDP_TX 的确可能制造 packet bounce:
1 | |
这种情况更像是一个 packet-level livelock 或 packet storm,而不是同一个内核调用栈里的递归死循环。veth 的 XDP 路径通过 ring 和 NAPI poller 交付 frame,不是 A() 直接同步调用 B() 再同步调用 A() 的函数递归。
也就是说,peer 没开 XDP 就失败 不是一个“防止循环”的语义规则。如果内核想防止循环,单纯要求 peer 挂 XDP 也挡不住,因为 peer 挂的程序完全可以继续返回 XDP_TX。真正能避免 bounce loop 的,是你在 BPF 程序里写清楚 TTL、mark、方向位、metadata 或协议判断,让包只在预期方向上被 tx。
因此这个问题更准确的说法是:
1 | |
为什么挂 XDP_PASS 能修好
最小修复是在 peer 侧挂一个空 XDP 程序:
1 | |
编译:
1 | |
然后挂到 veth 的另一端。比如你的主程序在 ns2/veth2_3 上返回 XDP_TX,那就要确保 peer ns3/veth3_2 也有 XDP:
1 | |
这个程序不负责处理业务包。它只是在 peer 侧打开 veth XDP receive machinery,让 xdp_ring、NAPI 和相关队列状态变成可用。
可以把它理解成这样:
flowchart LR
A["veth2_3 receives packet"] --> B["run real XDP program"]
B --> C["return XDP_TX"]
C --> D["veth_xdp_xmit()"]
D --> E{"peer veth3_2 has XDP/NAPI?"}
E -- "no" --> F["xdp_tx_err++"]
E -- "yes" --> G["enqueue xdp_frame into peer xdp_ring"]
G --> H["peer side drains ring"]
所以这个 XDP_PASS 程序的意义不是“让包 pass”,而是“让 peer 具备接收 XDP frame 的基础设施”。
该怎么记
我现在会用这条规则记 veth 的 XDP 行为:
1 | |
工程上可以直接写成 checklist:
1 | |
如果你用的是容器、Kubernetes、UPF/N3/N6 这类 veth-heavy 的环境,这个规则尤其重要。很多时候你以为自己只是在一个接口上做“回包”,但对 veth driver 来说,回包动作会落到 peer 的 XDP receive queue 上。
调试顺序
遇到 XDP_TX 没效果时,我会按这个顺序查:
1 | |
这能避免一开始就陷进 L2/L3 配置里。对这个问题来说,最关键的信号其实不是 tcpdump,而是 xdp_tx_errors。
小结
这个现象的本质是:veth 的 XDP_TX 不是一个独立的“原路发回”动作,它依赖 peer side 的 XDP receive infrastructure。当前 veth driver 会通过 peer 的 xdp_ring 交付 xdp_frame;如果 peer 没有启用 XDP/NAPI,ring 不存在或不可用,XDP_TX 就会失败并计入错误统计。
所以最小修复很简单:
1 | |
这行看起来多余的 XDP_PASS,实际上是在告诉 veth:这个 peer 也要走 XDP 数据路径。