做 OVS 二次开发这么久,我越来越觉得:很多人对ovs-ofctl dump-flows里那一长串 actions 的语义很熟,但一旦把问题压到内核 datapath 这一层,问"这条 flow 的 actions 到底是怎么在内核里一条一条执行的",能答清楚的就不多了。标题里这个"datapath 路径"指的就是包进入内核 fast path 之后,被 flow 表命中、再到ovs_execute_actions逐条消费动作之间的整个过程。这篇文章我把 actions 的存法、校验、执行主循环、会分裂包的动作、以及 OUTPUT 的真实出口全部过一遍。读完之后,你再看到dmesg、ofproto/trace、dpctl/dump-flows里的动作输出,应该能直接脑补出内核里正在发生的事。适合正在做 OVS 内核模块改造、DPDK 对比分析,或者排查"转发不符合预期"这类问题的朋友。
1. 先把坐标系画清楚:datapath 快路径与慢路径的分工
1.1 一次收包经过的四个关键节点
要理解 actions 处理,先得知道它在整个 datapath 路径上处于第几步。内核里net/openvswitch模块的收包入口是ovs_vport_receive(),它做的事情可以压缩成四个节点:
- 从 vport 拿到 skb,用
ovs_flow_key_extract()把包头的关键字段解析成一份struct sw_flow_key; - 用这份 key 去 flow table 做带掩码的哈希查找,命中则拿到
struct sw_flow; - 如果命中,调用
ovs_execute_actions()把 flow 里保存的 action 列表执行掉; - 如果没有命中,则走
ovs_dp_upcall()把包上送给用户态的ovs-vswitchd,等它决策后再下发 flow。
注意第 3 步才是本篇文章的主角。很多人以为 OVS 的"转发决策"是在内核 datapath 做的,其实不是。内核 datapath 本身没有 OpenFlow pipeline 的概念,它只有一张掩码流表,每条 flow 对应一串"匹配条件 + actions 列表"。所有基于 OpenFlow 的逻辑(查表、goto、组表、meter)都在用户态完成,用户态把决策结果翻译成一条 datapath flow 下发进来。所以内核 datapath 就是一个"查表 + 执行动作"的引擎,动作执行的质量直接决定转发性能和行为正确性。
1.2 miss 上送与 flow install:动作列表从哪里来
这一步必须讲清楚,否则后面聊 actions 的校验就会没有着落。当内核查不到匹配的 flow 时,会通过 netlink 把一个 miss upcall(OVS_PACKET_CMD_MISS)连同原始包和解析好的 key 送给ovs-vswitchd。ovs-vswitchd收到后,会按照 OpenFlow pipeline 逐步 match 各个 table,最终得出一个 action 集(比如output:2、mod_dl_dst、push_vlan之类),然后把它翻译成 datapath 层的 action 序列,通过OVS_FLOW_CMD_NEW写回内核。
换句话讲:actions 的"内容"是用户态决定的,actions 的"存储和执行"是内核决定的。这个分工非常关键。你在 OpenFlow 层写的actions=mod_dl_src(...),output:2不会原封不动进内核。它要经过ofproto-dpif-xlate的翻译,变成一组 dpif 层的 action,比如set(eth(src=...)),1,2。所以排查问题时,第一件事永远是区分"OpenFlow 动作"和"datapath 动作"——它们不是一回事,前者是策略,后者是可执行的机器指令。
1.3 命中的开销大头在动作执行
从性能角度看,flow lookup 本身已经被优化得很克制:per-CPU 的表项计数、RCU 读锁、带掩码的哈希表,摊下来每个包的成本是几十纳秒量级。真正让 PPS 上不去的,往往在 actions 执行这一段。因为动作执行涉及 skb 的内存修改、校验和状态维护、可能的 clone/recirc、以及最终的dev_queue_xmit或其他发送路径。一条复杂的 flow 可以在一微秒内做很多事情,也可能因为一个错误动作直接丢包。理解了这一点,你就会明白为什么 OVS 的调优和排障,到最后几乎都会落到"看 actions、改 actions、优化 actions"这三个动作上。
2. 内核里动作的存放形态:从 netlink 属性到 sw_flow_action 数组
2.1 userspace 侧如何序列化动作
用户态下发 flow 时,所有字段包括 actions 都是以 netlink attribute(struct nlattr)的形式放进消息体的。每个 attribute 由"长度 + 类型 + payload"构成,动作属性类型就是OVS_ACTION_ATTR_*。比如OVS_ACTION_ATTR_OUTPUT的 payload 是一个 4 字节端口号;OVS_ACTION_ATTR_PUSH_VLAN的 payload 是 VLAN TCI 和 EtherType;OVS_ACTION_ATTR_SET的 payload 又是一组嵌套的 key attribute,里面有OVS_KEY_ATTR_ETHERNET、OVS_KEY_ATTR_IPV4之类。
这个设计在用户态侧对应的是ofproto-dpif-xlate里的ofpbuf构建过程,它把翻译结果不断 append 到一块线性 buffer 里。为什么是线性 buffer?因为 netlink 消息天然是线性的,而且在执行时,线性数组意味着最差的随机访问,最好的一级缓存命中。你如果打开内核的net/openvswitch/actions.c,会看到struct sw_flow_action是一个type + len + union的结构,union 里直接内联了该动作的参数:端口号、VLAN 头、MPLS 标签、隧道元数据、IPv4 键值、hash 参数、truncate 长度等。
2.2 转换与校验:ovs_flow_actions_from_attr
内核收到OVS_FLOW_ATTR_ACTIONS后,会调用flow_netlink.c里的ovs_flow_actions_from_attr()做两件事:解析和转换。解析就是把一个个nlattr从网络消息里剥离出来,识别类型,填充成sw_flow_action数组;转换则包括把属性参数复制到 union、把字符串形式的隧道选项处理成内部二进制结构等。
这里必须强调"校验"这件事:动作不是装上就能用的。内核在正式安装 flow 前会跑一遍validate_actions(),它会做很多语义层面的检查。比如OUTPUT动作的端口号必须能通过ovs_vport_rcu()找到对应 vport,否则直接返回错误;隧道 push 动作里的选项长度必须合法;ct动作的 zone 和 helper 配置必须可解析。一旦校验失败,整条 flow 不会被安装,包会继续反复走 miss upcall。
我见过很多"为什么我这条 flow 永远不生效"的案子,最后都指向这里:用户态ofproto/trace算出来的动作明明是对的,但validate_actions()拒绝了它,内核返回EINVAL,用户态日志里留下一句安装失败,于是流量永远走慢路径。所以看到 flow 装不上去,先别急着怀疑 OpenFlow 策略,先怀疑动作的合法性。
2.3 为什么用紧凑数组而不是链表
这个设计选择值得单独说一句。动作列表不是链表,而是一个柔性数组struct sw_flow_actions { struct rcu_head rcu; u32 actions_len; struct sw_flow_action actions[]; }。原因有三层:
- 第一,执行路径是每次收包都会走的,线性扫描数组比链表逐节点跳转对 cache 友好得多;
- 第二,flow 安装后 actions 是不可变的,不需要运行时增删,所以不需要链表的灵活性;
- 第三,actions 跟随 flow 一起通过 RCU 释放,执行时只用持
rcu_read_lock,不需要任何锁。
这个"不可变 + RCU + 紧凑数组"的组合,是 OVS 内核 datapath 高性能的基础。你在用户态改 flow 动作,内核侧并不是去修改原数组,而是重新构建一份sw_flow_actions,然后通过 RCU 把 flow 里的指针换掉。执行路径上永远在读一份稳定的快照。
3. 执行主循环 do_execute_actions 的逐动作分派
3.1 入口 ovs_execute_actions 与执行上下文
命中 flow 后,ovs_dp_process_packet()会调用ovs_execute_actions()。这个函数是模块导出的符号,也是我们用 kprobe/ftrace 观测动作执行时最方便的一个挂载点。它内部先准备一个执行上下文(struct ovs_execute_ctx,包含 dp、key 等信息),然后直接把控制权交给do_execute_actions()。注意执行发生的上下文:收包时处于 NAPI/softirq 上下文,因此整个动作执行路径不能睡眠。这也是为什么很多动作处理逻辑里都只用原子操作、GFP_ATOMIC 分配、以及 RCU 保护的数据结构。
很多调试工具之所以能观测ovs_execute_actions,就是因为它的参数里有我们想要的三个关键对象:datapath、skb、动作列表。一个函数同时把"谁来执行、执行什么包、执行什么动作"都暴露出来了。相比之下do_execute_actions是静态函数,在较新的内核里可能被 inline,kprobe 不一定能挂上,所以实践上多数人先挂导出符号。
3.2 主循环如何消费动作数组
do_execute_actions的核心是一个for循环,逐个消费sw_flow_action数组里的元素。下面是我按内核代码逻辑简化的示意:
static int do_execute_actions(struct ovs_execute_ctx *ctx, struct sk_buff *skb, const struct sw_flow_action *acts, int len) { const struct sw_flow_action *a; int err = 0; for (a = acts; len > 0; a++, len--) { switch (a->type) { case OVS_ACTION_ATTR_OUTPUT: err = do_output(ctx->dp, skb, a->output.port); break; case OVS_ACTION_ATTR_USERSPACE: err = output_userspace(ctx->dp, skb, ctx->key, &a->userspace); break; case OVS_ACTION_ATTR_SET: err = execute_set_action(skb, &a->set); break; case OVS_ACTION_ATTR_RECIRC: err = execute_recirc(ctx->dp, skb, ctx->key, a->recirc.id); break; case OVS_ACTION_ATTR_CT: err = execute_ct(ctx, skb, &a->ct); break; default: err = -EINVAL; } if (unlikely(err)) goto out; } out: if (unlikely(err)) kfree_skb(skb); return err; }实际代码还包括对last_action、clone、trunc 等特殊分支的判断,但大框架就是这个样子。两个容易踩的细节:第一,动作列表是一个接一个串行执行的,前面的动作会改变 skb 本身,后面的动作看到的是修改后的包;第二,任何一个动作返回非零错误,会直接跳出循环并释放 skb,也就是说这条 flow 里后面的动作全部作废。这种"全有或全无"的失败模型,决定了动作顺序极度重要。我曾经排查过一个案例:用户把set(eth(src=...))放在output之后,结果出去的包源 MAC 根本没变,原因就是输出的那一份 skb 已经发出去了,后面的 set 改的是同一个 skb 对象里已经没有意义的部分——严格来说内核并没有"改错",是顺序写错了语义。
3.3 常见动作的实现落点
把常见动作和它们的实现函数对应起来,是阅读actions.c最快的路径,我列一个表供你对照:
| datapath 动作 | 内核处理函数 | 作用 | 常见触发来源 |
|---|---|---|---|
| OVS_ACTION_ATTR_OUTPUT | do_output | 从指定端口发出 | output、group bucket |
| OVS_ACTION_ATTR_USERSPACE | output_userspace | 把包副本交给用户态 | controller、sFlow、packet-in |
| OVS_ACTION_ATTR_SET | execute_set_action | 就地修改包字段 | mod_dl_xxx、set_field |
| OVS_ACTION_ATTR_PUSH_VLAN | push_vlan / pop_vlan | 荡入/荡出 VLAN 头 | push_vlan、pop_vlan |
| OVS_ACTION_ATTR_PUSH_MPLS | push_mpls / pop_mpls | 荡入/荡出 MPLS 标签 | push_mpls、pop_mpls |
| OVS_ACTION_ATTR_SAMPLE | execute_sample | 按概率做采样 | 镜像、IPFIX |
| OVS_ACTION_ATTR_CLONE | execute_clone | 在 skb 副本上执行嵌套动作 | sample 翻译、组播场景 |
| OVS_ACTION_ATTR_RECIRC | execute_recirc | 更新 key 后二次入表 | ct、MPLS 重分类 |
| OVS_ACTION_ATTR_CT | execute_ct | 交给 netfilter conntrack | ct(commit,zone=...) |
| OVS_ACTION_ATTR_TRUNC | execute_trunc | 截断包长度 | output(port,trunc=...) |
其中SET动作值得展开:它的 payload 是嵌套的 key attribute,可能是OVS_KEY_ATTR_PRIORITY(改skb->priority)、OVS_KEY_ATTR_SKB_MARK(改skb->mark)、OVS_KEY_ATTR_TUNNEL(设置隧道元数据)、OVS_KEY_ATTR_ETHERNET(改 MAC)、OVS_KEY_ATTR_IPV4/IPV6(改 IP 地址)等。内核每个分支都会先做skb_cow_head()之类的空间保证,再直接操作头部字段。这里有一个隐藏的坑:修改头部前如果不确保 skb 有足够 headroom 并且是唯一引用,会触发一次拷贝;在流量很大的场景里,这个拷贝会成为 CPU 热点。
4. 会"分裂"包的动作:USERSPACE、SAMPLE、CLONE 与 RECIRC
4.1 USERSPACE:把包交给用户态而不停下
OVS_ACTION_ATTR_USERSPACE对应的 OpenFlow 场景非常常见:controller动作、sFlow/IPFIX 采样、以及某些版本的 mirror。它的处理函数是output_userspace(),语义是"把这份包的一个副本上送给ovs-vswitchd,但原始包继续执行后续动作"。
内核的做法是构造一个dp_upcall_info,里面包含上送命令、flow key、可选的 userdata(即用户在动作里附带的不透明 cookie),然后调用ovs_dp_upcall()。ovs_dp_upcall()内部通过queue_userspace_packet()把 skb 复制成一份适合 netlink 发送的报文,塞进用户态监听的 socket 队列。注意:这里用的是 skb 副本,不是原包,所以原始 skb 还能继续走后面的动作。如果 upcall 队列满了或者复制失败,output_userspace()会返回错误,这时按照主循环的失败模型,整个 skb 会被释放。也就是说,USERSPACE 动作一旦失败,不只是丢弃副本,连原始包也会"陪葬"。这个行为在拥塞场景下要格外小心。
另外,GSO 大包在上送前往往需要展开成线性报文,否则 netlink 没法传输。queue_userspace_packet()里对这个场景有专门处理,代价不小。如果你发现 PPS 不高但 CPU 很高,并且 flow 里大量出现userspace动作,先怀疑这里。
4.2 SAMPLE 与 CLONE:概率、复制、独立执行空间
SAMPLE动作是另一个典型"分裂"点。它带一个 32 位概率值,内核执行时用prandom_u32()生成一个随机数,如果随机数小于阈值就执行嵌套动作,否则跳过。注意这个概率的编码方式:它是一个 0 到UINT32_MAX之间的阈值,用户态在翻译时会把自己理解的百分比换算成这个值。看到dump-flows里出现sample(rate=...)或sample(percent=...)时,要意识到底层其实是一次随机数比较。
CLONE动作在较新的内核里承担了更重要的角色:它强制把嵌套动作放在 skb 的副本上执行,执行完丢弃副本,原始包继续。为什么需要 CLONE?因为很多场景下我们希望"试探性地"对包做修改或发送多次,但不能影响原始包的后续处理。例如mirror一个包到多个口,如果直接连续 output,第一个 output 之后 skb 状态就可能被后续动作污染。用 CLONE 包裹后,内部动作无论怎么改、怎么发,都不影响外层。用户态在翻译sample时,经常就把它翻译成clone(user(...))或clone(output(...))的组合。所以你会看到dump-flows输出里出现嵌套的括号,那就是 clone/sample 在 datapath 层的具象化。
4.3 RECIRC:为什么必须二次入表
RECIRC是 OVS datapath 里最容易被误解、也最容易出性能问题的动作之一。它的背景是:某些动作(典型的是 conntrack,还有 MPLS POP 后的重分类)执行完之后,包头的含义变了,或者内核拿到了新的状态信息(比如一条新连接被ct(commit)建好),这些信息必须进入 flow key 才能被后续的流表规则匹配。但当前的 flow 是拿旧 key 匹配的,动作列表也已经在执行过程中了,怎么办?答案是:把包重新放回 datapath 入口,更新 key 里的recirc_id,再用新 key 查一次 flow table。
execute_recirc()做三件事:从当前 skb 重新提取 flow key、把recirc_id换成新值、调用ovs_dp_process_packet()重新走一遍收包流程。主循环看到 recirc 后会认为"这个 skb 已经被接管了",直接终止当前动作列表的继续执行。所以在dpctl/dump-flows里,一个带 ct 的流程经常会出现多个recirc_id阶段:
recirc_id(0),in_port(eth1),... actions:ct(zone=1,commit),recirc(0x1) recirc_id(0x1),ct_state(+new-est),... actions:2理解 recirc 之后,很多"包为什么绕了两圈""为什么 flow 数量暴涨"的问题就都有解释了:每多一次 recirc,就会多吃一次查表和一次 key 提取,而且同一个五元组的包会在流表里占多条不同 recirc_id 的 flow。性能敏感的业务里,无谓的 recirc 是应该被主动规避的。
5. 从动作到发送:OUTPUT 动作的三条出口路径
5.1 普通 netdev vport:do_output 到 dev_queue_xmit
OUTPUT是最常见的动作,但它的实现细节往往被低估。do_output()先根据端口号在 datapath 的 vport 哈希表里找到对应的struct vport,找不到就静默丢包(这里不返回错误,行为上等同于 drop)。找到之后,对于非内部端口,会先做一次校验和相关的修正,然后调用vport->ops->send进入该 vport 类型的发送逻辑。
普通 netdev vport 的 send 最终会走到dev_queue_xmit()。注意我们处在收包的 softirq 上下文里,dev_queue_xmit可能触发 qdisc 排队、锁竞争、甚至唤醒 ksoftirqd 发送;这一段的消耗直接决定单核能跑多少 PPS。所以你会看到很多优化方案里,把多个 OUTPUT 到同一个物理口的动作合并成一次,或者尽量避免在一个 flow 里 output 三次同样的口——三次 output 就是三次独立发送,不会自动合并。
5.2 隧道端口:metadata_dst 与 offload 修正
隧道端口(VxLAN/Geneve/GRE 等)的 OUTPUT 路径比普通端口复杂一个量级。原因是隧道封装需要的元数据——源 IP、目的 IP、VNI、Geneve option——不是 vport 自己固定的,而是由动作里set(tunnel(...))这类 SET 动作在发送前写到 skb 上的。内核用metadata_dst携带这份隧道信息,set_tunnel会为 skb 分配并填充metadata_dst,随后 OUTPUT 到隧道端口时,ovs_vport_output()读取这份 metadata 做封装。
隧道发送路径上还有一层和硬件 offload 相关的修正:ovs_iptunnel_handle_offloads()会根据网卡能力处理校验和与 GSO 标记。如果网卡支持 VxLAN offload,内核会把外层校验和、内层 GSO 交给硬件;如果不支持,就得靠软件做分段与封装。实测里最典型的问题是:某个网卡驱动对 tunnel offload 支持不完整,导致dev_queue_xmit报BUG或丢包,最后只能通过 ethtool 关闭对应 offload 才能恢复。这属于操作系统与网卡配合的问题,但在 actions 路径上暴露得特别明显,排查时别只盯着 OVS 本身。
5.3 内部端口与重入收包侧
第三种出口是 OVS 内部端口(比如br0这个网口本身)。内部端口的 send 路径不会走向外出方向,而是把 skb 重新交回内核协议栈的收包方向(通过netif_rx之类的机制)。这意味着:发往内部端口的流量,会像"从网卡刚收到一个新包"一样进入 host 协议栈,由 IP/TCP 等上层协议继续处理。
这条路径有两个实践意义:第一,你在内部端口上抓包,只能抓到进入 host 协议栈的那一份,抓不到 OVS 在 datapath 内部改包之前的样子,所以排查"从主机访问 VM 为什么不通"时要分清观察位置;第二,内部端口在交付给协议栈前,需要对 skb 的校验和状态做一次修正,因为包从 OVS 的角度是"发送",但对协议栈来说它即将被"接收"。这些细节都在vport-internal-dev.c里,感兴趣的可以直接读源码。
6. 实操观测:如何看见一次 actions 执行
6.1 ofproto/trace 输出里的 Datapath actions
要快速知道一条业务流会被翻译成什么样的 datapath actions,最方便的手段是ofproto/trace。它是在用户态模拟整个 OpenFlow pipeline,并给出最终 dpif 层动作。一个典型的例子:
$ ovs-appctl ofproto/trace br0 'in_port=eth1,ipv4,nw_src=10.0.0.1,nw_dst=10.0.0.2,tp_dst=8080' Flow: ipv4(proto=6,... ... Final flow: tos=0,... Datapath actions: set(eth(src=02:00:00:00:00:11,dst=02:00:00:00:00:22)),1,2最后一行的Datapath actions就是你期望内核安装并执行的内容。但请记住,这是用户态的模拟结果,不是内核里的真实执行结果。它最大的价值在于把 OpenFlow 策略到 datapath 动作的翻译过程透明化,让你在动手改配置前先确认"策略层想干什么"。
6.2 dpctl/dump-flows 验证内核安装内容
模拟结果归模拟结果,真正的执行依据是内核 flow 表里安装的那份动作。查看方式:
$ ovs-appctl dpctl/dump-flows recirc_id(0),in_port(eth1),eth(src=aa:bb:...),eth_type(0x0800),ipv4(proto=6,...), packets:1234, bytes:234567, used:0.5s, actions:set(eth(src=02:00:00:00:00:11,dst=02:00:00:00:00:22)),1,2这里每一行才代表内核真正持有的一条 datapath flow。看两个东西:第一,packets/bytes计数器,它证明了这条 flow 是否真的被命中;第二,actions:字段,它由用户态根据内核中存储的属性打印出来,和实际安装内容一致。如果你发现ofproto/trace说有动作、dump-flows里却找不到对应 flow,或者actions:和预期不一致,那不是"内核执行错",而是"安装环节出了问题"——十有八九是校验失败或翻译不一致。
6.3 用 ftrace/kprobe 直接钉住执行函数
如果想看到动作执行的动态过程,也就是每个包实际走过哪些函数,可以往内核执行路径上挂探针。最省事的方式是利用ovs_execute_actions这个导出符号。先确认符号存在:
$ grep ovs_execute_actions /proc/kallsyms ffffffffc0a31000 t ovs_execute_actions [openvswitch]然后用 perf 探针:
# perf probe --add 'ovs_execute_actions' # perf record -e probe:ovs_execute_actions -a sleep 5 # perf script | head想统计执行次数,也可以用 bpftrace:
# bpftrace -e 'kprobe:ovs_execute_actions { @cnt = count(); }'想进一步看 OUTPUT 动作具体往哪个端口发,就把探针挂到do_output;想看 upcall 频率,挂output_userspace。注意静态函数可能被 inline,挂不上时优先挂导出符号,再配合调用栈去推断内部行为。这已经是内核 profiling 的常规操作了,但放在 OVS 场景里,ovs_execute_actions是最值得先挂的那个点,因为它一次把 dp、skb、acts 三个核心对象都暴露出来了。
6.4 一个典型的联调排查案例
最后分享一个我在实际排障中反复用到的套路。某次业务反馈:某条流不通,但ovs-ofctl dump-flows显示规则存在,ofproto/trace也显示动作应该能转发。我做了一次标准的三步排查。
第一步,看dpctl/dump-flows:发现对应 datapath flow 根本没有出现,packets计数自然也是零。说明这条业务流连匹配都没发生,更谈不上执行。第二步,看ovs-vswitchd日志:发现有反复的 miss upcall 和一次 flow 安装失败的记录,错误码是EINVAL。第三步,检查动作里的 output 端口:发现用户在配置里写死了一个端口号,但那个 port 在这台设备上已经不存在(拓扑变更后没有同步配置)。内核validate_actions()在做ovs_vport_rcu()查找时直接拒绝了整条 flow。
修掉端口号、重新下发后,dump-flows里立刻出现新 flow,计数器开始增长,业务恢复。这个案例非常有代表性:它说明 action 校验不是走过场,而是真正保护内核执行安全的屏障。以后你看到"用户态模拟都正常,内核就是不执行"这个症状,优先查安装错误和端口合法性,效率会高很多。
7. 边界与性能:容易被忽略的坑
7.1 skb 校验和与 GSO 位处理
SET 动作修改了包头的 MAC/IP 字段,PUSH/POP 动作改变了报文结构,这些都会让 skb 之前缓存的校验和状态失效。内核的做法是维护一套校验和状态机:能交给硬件 offload 的就设置CHECKSUM_PARTIAL并把校验和字段改成偏移量方式,不能 offload 的就直接在线计算或标记为需要重新校验。在隧道场景里,内外两层校验和的处理更是叠加在一起。
这里最容易踩的坑是:你在 flow 里改了内层头,但发送的网卡并不支持对应 offload,内核不得不在发送路径上做软件校验和与分段,CPU 消耗会突然升高。排查时如果发现perf top里出现大量校验和计算、skb_checksum_help或者 GSO 分段相关函数,先看看动作是否引入太多头部修改,再检查网卡 offload 配置。
7.2 长动作列表与多次 output 的 CPU 消耗
do_execute_actions是纯线性扫描,动作越多、每个包消耗越大。特别是涉及skb_cow_head()的 SET 和 PUSH,会触发内存拷贝;涉及多次 OUTPUT,会触发多次发送且互不合并。用户态在ofproto-dpif-xlate阶段已经做了一些动作合并优化(比如合并连续 SET、折叠到同一出口的动作),但内核侧不会帮你做任何等价变换。所以你要把性能优化的大部分工作放在用户态翻译之前:尽量缩短动作链、消除重复的 modify 动作、把多出口场景用 clone/group 合理组织,而不是指望内核偷懒。
另外注意 CLONE 本身是有代价的。skb_clone()只拷贝struct sk_buff头,数据区共享,看起来便宜,但它会触发引用计数、可能的内存 realloc,并且 clone 之后如果内部动作要改头,又引发skb_cow_head()的二次拷贝。所以"clone 万能"是个误区,能不用就不用,用了就要清楚它在你的流量模型下会放大多少开销。
7.3 与 ct 动作配合的 recirc 陷阱
ct动作是 recirc 最常见的触发来源。一条典型的ct(commit,zone=1)动作,在执行时内核会调用execute_ct(),把包交给 netfilter 的 conntrack 子系统做状态匹配。第一次见到新连接时,conntrack 会建立 entry,并决定"这条连接的信息需要重新进入 flow key 才能继续被后续规则匹配",于是动作列表里通常紧跟一个recirc。也就是说,业务流量第一次建链时,同一个包在 datapath 里至少要过两遍:一遍是 recirc 之前的表项,一遍是 recirc 之后的表项。
这里常见的坑是:用户把ct动作写了,但忘了配对应的 recirc 后续 flow,结果建链包永远找不到第二段的 flow,不断 miss、不断被上送,转发面表现为"第一个包丢、后面的包通",或者干脆全部不通。另一个坑是:只要 flow 里携带recirc_id,它的 key 就带上了 recirc 维度,flow 数量会成倍膨胀;如果 zone 用得很细、规则很多,流表容量和 revalidator 压力都会上来。所以设计使用 conntrack 的 OVS 转发方案时,一定要把 recirc 的两段 flow 当成一体来设计,而不是只盯着 OpenFlow 侧的ct(commit)动作。
最后再分享一点个人经验。我后来排查 OVS datapath 问题,基本养成一个固定的读码习惯:先用dump-flows拿到内核里的真实动作列表,再用ofproto/trace对一遍策略层预期,差异项才是需要深挖的地方;如果差异发生在安装阶段,就去flow_netlink.c的validate_actions()找原因;如果差异发生在执行阶段,就在actions.c里按类型找到对应处理函数,配上 kprobe 看实际调用。这套流程下来,绝大多数"动作执行不符合预期"的问题都能定位到具体一行逻辑。说到底,datapath 的 actions 并不神秘:它不过是一份由用户态翻译、内核校验并逐条执行的指令序列,把这条链路上的每个环节都看懂,O V S 的转发问题就已经解决了一大半。