OVS datapath actions执行流程详解:从内核源码到性能调优 先说一个我前阵子实际遇到的问题。某次排查一个bond口上的流量分流不均流表里明明配了group但实际转发效果和预期差了很多。一开始怀疑是hash算法的问题翻来覆去看用户态流表后来直接把内核datapath路径上的action执行流程从头到尾捋了一遍才发现问题出在clone里的output和clone外层的output语义理解错了。这之后我就想OVS datapath的actions执行流程看着是内核模块里一块不起眼的代码其实决定了整个OVS转发行为的正确性和性能表现。今天就把这部分内容系统梳理一遍结合源码和实测经验讲清楚actions从下发到执行、再到异常排查的完整链路。1. datapath路径里的actions到底是什么Open vSwitch的整体架构分用户态和内核态两层。用户态的ovs-vswitchd负责管理面包括OpenFlow协议处理、流表管理、各种学习逻辑而真正的数据包转发路径在内核态的openvswitch模块里也就是我们常说的datapath。这个datapath做的事情可以简化为两件事查表、执行。查表是datapath classifier的工作。数据包到达某个vport后先提取出flow key然后用这个key去匹配流表。匹配到对应的flow之后就会得到一组actions。这一组actions就是数据包后续要执行的动作序列可能是简单地从一个端口发出去也可能是修改报文头、封装隧道、丢进连接跟踪模块之类的组合操作。这块代码路径的核心逻辑在内核源码的datapath/actions.c和datapath/datapath.c中。ovs_vport_receive接收到报文后调用ovs_flow_key_extract提取key再通过ovs_flow_lookup查找到匹配的flow拿到flow-sf_acts指向的动作数组最终进入do_execute_actions逐条执行。那么为什么值得专门去分析这个执行流程第一OVS很多看似诡异的行为根源都在action的执行顺序和嵌套逻辑上。比如VLAN的push和pop先后顺序不对结果就是报文带着错误的tag出去recirc和ct配合不好就会出现连接跟踪状态丢失的问题。第二性能优化离不开这部分的理解。datapath路径是每包都要走的热路径action的组织方式和执行效率直接决定了转发性能recirc用多了会显著拉低吞吐这个在后续调优中会反复提到。第三排查问题的时候ovs-appctl ofproto/trace只能看到用户态解析后的结果真正在内核里发生了什么还是得靠对datapath执行逻辑的理解。简单归个类常见action包括output从指定端口发出这是最基础的动作。push_vlan/pop_vlanVLAN标签的压入和弹出。set_eth/set_ipv4/set_ipv6修改报文头字段。recirc重新进入datapath执行流程用于group、学习、连接跟踪等需要二次查表的场景。ct连接跟踪。clone快照当前状态执行一段子操作序列后再恢复用于镜像、事务性操作。sample采样用于sFlow/IPFIX。mpls_push/mpls_pop、set_tunnel等隧道和MPLS操作。理解这些action的执行语义基本就理解了大半个OVS转发面。2. actions数据结构的组织与生命周期2.1 action在内核态是怎么存的用户态和内核态之间通过netlink通信。下发流表时用户态把action列表编码成一组netlink属性每个action是一个OVS_ACTION_ATTR_*类型的属性属性内部是具体的参数。内核收到后经过ovs_flow_cmd_new进入ovs_flow_alloc和action的复制逻辑。action在内核里最终存放在struct sw_flow的sf_acts字段中类型是struct sw_flow_actions *。这个结构体里面是一个紧凑排列的action数组格式是通用的netlink属性格式struct sw_flow_actions { struct rcu_head rcu; u32 actions_len; struct nlattr *actions; };这里有个关键点actions是作为一个整体连续存放在内存里的而不是一个链表或者指针数组。每条action的格式是struct nlattr包含nla_len和nla_type后面跟着action参数。之所以用这种紧凑排列是因为执行时需要遍历CPU缓存友好而且下发时可以直接把netlink消息里的属性块整体拷贝过来省去逐条解析的开销。actions_len记录的是整个actions缓冲区占用的字节数。遍历的时候就是从头开始根据每条action的nla_len不断往后跳a actions; end actions actions_len; while (a end) { /* 处理a指向的action */ a nla_next(a, remaining); }从代码习惯上看内核开发者在这里默认了用户态下发的action列表是合法且校验过的。所以内核态执行时不会再做太多的合法性检查直接相信nla_len和nla_type。这也意味着如果用户态有bug或者有恶意程序直接操作netlink下发异常数据内核态执行时会出问题。不过实际场景中用户态OVS在parse_actions阶段已经做了严格校验正常情况下不会有非法action进来。2.2 流表actions的下发与更新机制当用户态下发或者修改一条流时actions的更新过程需要细致处理。假设一条流已经存在内核里有多个CPU同时在执行这条流的action此时用户态下发了一个修改命令要更新actions怎么办直接改那块内存肯定不行因为正在执行的CPU可能刚读到一半的数据。OVS的做法是整体替换。新的struct sw_flow_actions被分配、填充然后通过rcu_assign_pointer原子地替换掉sw_flow里的sf_acts指针。旧的在RCU grace period之后释放。这个机制保证了执行中的action序列是始终一致的不会出现半新半旧的状态。对应到源码就是ovs_flow_actions_alloc分配新内存ovs_flow_actions_copy或者直接的nla_memdup把netlink属性块拷贝进去最后在ovs_flow_actions_set中完成替换。老版本内核里这个替换过程叫ovs_flow_actions_realloc或类似命名逻辑大同小异。这里有一个实操中值得注意的点flow的actions更新触发的是整条流更新而不是局部热更新。频繁更新actions会带来额外的分配和RCU等待开销。在控制平面上如果有大量流需要频繁修改action性能会受一定影响。所以在设计控制逻辑时要尽量避免让同一个flow的actions频繁变动能复用就复用不能用就干脆删掉重新下发。2.3 action执行时的包上下文保存action执行不是光看一个action数组就够的执行过程中往往要动态修改数据包本身。比如push_vlan会往skb里塞一个VLAN头set_tunnel会设置skb的metadata。这些修改是有状态的后续的action可能会依赖前一个action修改后的结果。因此在进入do_execute_actions之前内核会先在ovs_execute_actions里做一些上下文准备。其中一个关键点是OVS_CB(skb)的初始化。OVS_CB是struct ovs_skb_cb存储在skb的cb区域一个专门留给协议栈使用的私有缓冲区里面保存了数据包当前所处的datapath实例。输入端口信息。隧道相关metadata的指针。一些执行过程中需要的临时状态。ovs_execute_actions在做完必要初始化后会调用do_execute_actions开始逐条处理。如果中途出现recirc这类会重新进入查找流程的action就需要在重新进入前把当前包的上下文保留好。还有一个常被忽略的细节ovs_execute_actions返回后调用方ovs_dp_process_packet会根据执行结果决定是继续向上层传递还是直接丢弃。output到某个vport之后包并不会回到ovs_execute_actions继续执行除非这个vport是internal类型重新进入了网络栈。所以从某个端口发出去这个操作对datapath路径来说是终态后续的一切都属于其他子系统了。3. do_execute_actions 的主循环执行流程3.1 主循环的基本框架do_execute_actions是datapath执行action的核心函数。它的主体是一个循环从头到尾遍历action数组逐个执行。代码逻辑大概长这样static int do_execute_actions(struct datapath *dp, struct sk_buff *skb, struct sw_flow_key *key, const struct nlattr *actions, int len) { const struct nlattr *a; int rem; for (a actions, rem len; rem 0; a nla_next(a, rem)) { int err 0; u32 orig_key_flags key-mac_proto; switch (nla_type(a)) { case OVS_ACTION_ATTR_OUTPUT: /* 处理output */ break; case OVS_ACTION_ATTR_RECIRC: /* 处理recirc */ break; case OVS_ACTION_ATTR_CLONE: /* 处理clone */ break; /* ... 其他case ... */ } if (err) return err; } return 0; }nla_next宏就是跳到下一条action的核心机制#define nla_next(nla, remaining) \ ((remaining) NLA_ALIGN((nla)-nla_len) ? \ (struct nlattr *) ((char *) (nla) NLA_ALIGN((nla)-nla_len)) : \ NULL)每个action都按4字节对齐nla_len字段表示这条action实际占用的字节数所以跳到下一条就是地址加上对齐后的长度。这个设计保证了action数组可以快速遍历不需要额外的索引表。在遍历顺序上action的执行顺序严格遵循下发的先后顺序。举个例子如果actions是[push_vlan(100), output(1)]那么内核会先执行push_vlan再执行output。如果反过来了报文从端口1出去的时候还没带VLAN标签。这个顺序的控制权在用户态内核态只是忠实执行。3.2 output动作与发送路径的细节output对应的宏是OVS_ACTION_ATTR_OUTPUT它的参数是一个32位的端口号。执行时首先根据这个端口号查找对应的vportcase OVS_ACTION_ATTR_OUTPUT: { int output_port nla_get_u32(a); struct vport *vport; if (unlikely(!key-eth.type)) { /* 非以太网帧特殊处理 */ } vport ovs_vport_rcu_deref(dp, output_port); if (unlikely(!vport)) { /* 端口不存在丢包 */ break; } err ovs_vport_send(vport, skb); break; }这里有个重要的行为ovs_vport_send把skb交给vport发送后do_execute_actions里的循环还在继续吗答案是看skb是否还被消费掉。如果vport发送失败或者丢弃了skb后续action依然会继续执行因为这种异常并不是致命错误。这在排查问题时容易造成困惑表现为output到一个不存在的端口但后面的action还是在跑。另外要理解output的一个微妙之处它不只是简单地调用dev_queue_xmit或类似接口发报文。对于不同类型的vport发送逻辑不一样vport类型是internal比如ovs-system、br-int这种时报文会重新进入宿主机的网络协议栈。类型是netdev物理网卡或veth等时走的是dev_queue_xmit路径。类型是gre/vxlan/geneve等隧道端口时会走隧道封装逻辑用skb上的metadata信息填充外层头。尤其需要注意隧道端口场景。隧道信息不是直接由output参数决定的而是保存在skb的metadata里通常由之前的set_tunnelaction或隧道的入口vport解析时填充。很多人在配置OVS隧道时会在流表里写set_tunnel再output到隧道端口或者直接output到隧道端口这两种方式在datapath层面的执行差别很大后面再详细说。3.3 clone与recirc的控制流语义这是整个actions执行流程里最绕、也最容易被误解的两个action。先说clone。OVS_ACTION_ATTR_CLONE的作用是把当前的数据包状态做一份快照然后执行一段嵌套的action序列执行完这段序列后把数据包状态恢复成执行clone之前的样子再继续执行外层后续的action。这个语义很适合做流量镜像不管镜像路径里做了什么修改原始报文不受影响。case OVS_ACTION_ATTR_CLONE: { const struct nlattr *clone; int clone_len; clone nla_data(a); clone_len nla_len(a); err clone_actions(dp, skb, key, clone, clone_len, NULL); break; }clone_actions里先用skb_clone克隆出一个skb在克隆skb上执行嵌套actions执行完后原skb没动继续外层循环。这里有个性能代价非常大的点每次执行clone不管里面有多少action都要做一次skb克隆。skb克隆不是浅拷贝那么简单它涉及到skb_shared_info的引用计数管理、各header指针的复制等。所以在datapath热路径上频繁使用clone吞吐会明显下降。实测下来同一套流表把sampleclone的镜像逻辑去掉PPS能提升接近一倍。再看recirc。OVS_ACTION_ATTR_RECIRC做的事情更重它会把当前数据包重新丢回datapath的处理流程重新提取flow key此时metadata可能已经被前面的action修改过重新查表再匹配一组新的actions。也就是说recirc是datapath层面的二次转发。case OVS_ACTION_ATTR_RECIRC: { struct sw_flow_key recirc_key; int err; err ovs_flow_key_update(skb, key, recirc_key); if (err) break; err ovs_dp_process_packet(dp, skb, recirc_key); break; }注意ovs_flow_key_update重新计算了flow key包括可能变化的MAC地址、IP、端口、隧道ID等等。然后ovs_dp_process_packet重新进入主处理入口查一次新的流表。所以recirc之后原来的actions数组里剩下的action还会继续执行吗不会。因为recirc已经把控制流交出去了执行的是新流的actions。如果想要recirc之后还能回到当前流继续执行后面的action就只能把后续action放到recirc后新匹配到的流里。这也是为什么很多场景会用recirc后接一个临时流表再输出的做法。recirc最常见的使用场景是与ct搭配。连接跟踪需要把报文先交给netfilter处理然后重新查表决定后续动作。所以ct action后面几乎总会跟着一个recirc。但这里有一个容易踩的坑recirc的dp_hash和flow key是重新计算的如果用户在ct之前修改过L4端口之类的字段recirc后要确保key里的端口信息已经更新否则后续流表匹配会失败或者匹配到错误的流。clone与recirc的核心区别可以这样理解clone是局部快照子序列执行执行完回到原来的位置继续往下走recirc是整体重启datapath流程当前action序列直接终止控制权交给新的流表匹配结果。在设计流表时想清楚自己需要的是哪一种控制流能省很多排查时间。4. 常见action的解析与实现要点4.1 以太网与VLAN操作VLAN操作在datapath层面非常基础但也非常容易出细节问题。push_vlan和pop_vlan对应的内核实现分别在push_vlan_action和pop_vlan_action。static int push_vlan_action(struct sk_buff *skb, struct sw_flow_key *key, const struct ovs_action_push_vlan *vlan) { /* 校验skb是否已有vlan头 */ if (skb_vlan_tag_present(skb)) { /* 已有vlan tag需要处理多层tag情况 */ } /* 在以太网头之后插入vlan头 */ __skb_push(skb, VLAN_HLEN); /* 填充tag信息 */ ... }看实现之前先理解一个前置概念内核skb里的VLAN信息有两种表达方式。一种是把VLAN头直接放在数据区的以太网头之后另一种是放在skb的vlan_tci字段里不占实际数据长度。skb_vlan_tag_present就是判断现在走的是哪种模式。OVS在push_vlan时如果skb已经有无标签的vlan_tci需要先把vlan头实际压进数据区再处理新的push这就涉及到两层VLANQinQ的情况。实操中特别容易踩坑的是QinQ场景。OVS流表里如果一次性push两个vlan tag要确保顺序是先push外层再push内层。因为push_vlan是往以太网头后面硬塞一个VLAN头后push的会出现在更外层。很多人用push_vlan(100)、push_vlan(200)想构造VLAN 200内层、VLAN 100外层结果做反了抓包一看两个tag顺序不对。pop_vlan则相反从以太网头后面摘掉一个VLAN头更新key里的VLAN信息。如果skb的vlan信息还在vlan_tci里则直接清空如果在数据区则skb_pull掉一个VLAN头并重新计算校验和。还有一个相关但容易被忽略的action是OVS_ACTION_ATTR_SET它用来修改以太网地址、IP头、端口号等。set_eth_addr修改的是skb数据区里的MAC同时会触发skb-csum相关的更新。如果是修改IP地址还要更新IP校验和。这些校验和计算都是增量更新的调用skb_make_writable先确保skb可写处理COW然后直接修改对应字段。4.2 MPLS与隧道动作MPLS动作在datapath里的实现是mpls_push_action和mpls_pop_action。mpls_push会在以太网头和L3头之间插入一个MPLS shim头并设置label、tc、ttl等字段。mpls_pop则相反去掉shim头同时要处理一个比较tricky的问题MPLS pop之后L3头的类型怎么判断所以mpls_pop的参数里会带上一个ethertype告诉内核pop之后L3层的协议类型比如是IPv4还是IPv6。static int mpls_pop_action(struct sk_buff *skb, struct sw_flow_key *key, const struct ovs_action_pop_mpls *mpls) { /* 去掉MPLS头 */ skb_pull(skb, MPLS_HLEN); /* 更新网络头指针 */ skb_reset_network_header(skb); /* 更新key的eth.type为mpls-ethertype */ key-eth.type mpls-ethertype; ... }隧道相关action里最常见的是set_tunnel。它的参数是一个隧道ID对于VXLAN是VNIGRE是key。执行时把隧道信息保存到skb的metadata区后面的output动作在发送到隧道vport时读取这段metadata封装外层头。这里要展开说一下output到隧道端口和set_tunneloutput到隧道端口的区别。直接output到一个隧道vport比如vxlan端口OVS会看skb metadata里有没有隧道信息。如果没有就用vport本身的默认配置比如创建vxlan时指定的VNI。如果有就用metadata里的。先set_tunnel设置VNI再output到隧道vport则使用的就是新设置的VNI。这也是用流表动态控制隧道封装的主要方式。实现层面ovs_vport_send里对隧道vport做了特殊判断。如果skb上带了tunnel metadata就会走隧道封装协议的处理流程否则按普通包发送。所以在使用隧道vport时一定要确认skb的tunnel metadata是期望的状态否则会出现明明output到了vxlan端口抓包却发现外层VNI不对的问题。4.3 连接跟踪与元数据操作ctaction在datapath里是一个重操作。它不是OVS自己实现的而是依赖内核的netfilter连接跟踪框架nf_conntrack。执行ct action时内核会把这个skb交给nf_conntrack_in处理进行连接状态的查询或建立然后设置skb的nfct指针。关键点在于ct action执行后skb的flow key里的L4信息可能已经变了比如NAT修改了端口或者地址。因此ct action后面几乎总是跟随着recirc重新提取key、重新查表让后续的流表能够基于连接跟踪的结果和NAT后的五元组来做匹配。常见的使用模板是actionsct(commit),recirc(,2)这里recirc(,2)里的table参数表示recirc后使用哪个OpenFlow table去匹配后续流程。在实际datapath层落到代码上recirc本身可能携带的table id信息用来在用户态做OpenFlow级别的跳转但内核态的flow lookup还是按当前datapath的flow table查控制权在用户态ovs-vswitchd里如何处理recirc结果上。这里容易遇到的问题ct和recirc的顺序搞反了。必须先ct再recirc如果先recirc了新流表匹配完执行ct后面如果没有再接recirc连接跟踪的结果不会驱动后续行为状态机就断了。多个ctrecirc嵌套时要防止同一个报文被连接跟踪处理两次。比如clone里面嵌了ct(commit),recircclone外头又接着ct(commit),recirc那报文会被提交两次连接状态引起nf_conntrack的冲突。setaction处理元数据时还有一个容易被忽略的OVS_ACTION_ATTR_SET不仅可以改L2/L3/L4字段还可以改skb的priority对应skb-priority用于qos调度和skb的mark。mark在OpenFlow里对应set_field:reg或者pkt_mark。pkt_mark会直接写skb的mark字段这个字段在整个内核协议栈里通用可以用来和iptables/nftables联动。4.4 sample与镜像类action的实现代价sample action的实现基于clone它的目的就是采样一部分报文做镜像处理。实现方式是以一定概率nla_get_u32(nla_data(a))里的概率字段决定是否走clone分支。实际代码里sample的底层是sample_action内部通过skb_clone来复制报文对克隆出的skb执行镜像action序列原skb不受影响。这也就是为什么sample和clone在性能上代价很高——每采样一个包就要克隆一次skb。如果采样率设置得很高比如50%那整体转发吞吐会掉得很明显。一个性能优化的小经验如果只是想统计流量特征而不是做完整镜像建议优先考虑使用per-port的统计计数器或者sFlow/IPFIX的agent侧采样逻辑让采样在用户态做完统计之后丢弃克隆包而不是在datapath路径上对每个包都走一遍clonesample。5. 调试与实测经验5.1 怎么看datapath里实际执行了哪些action排查datapath问题最直接的手段是用ovs-appctl ofproto/trace。但这个命令揭示的是用户态视角的流表查找结果和OpenFlow action列表真正内核datapath里做了什么、每步修改对包的形态有什么影响它只能间接反映。要看内核datapath的实际行为有两个方向。第一是看/proc/net/openvswitch/下的各端口统计和flow统计。ovs-dpctl dump-flows可以列出内核datapath的实际flow和actions注意这里的actions是已经经过用户态翻译后的datapath action格式和OpenFlow action不完全一样。第二是使用perf或者bpftrace在do_execute_actions上做动态追踪打印每次进入时处理的是哪条action、报文的关键字段是什么。比如用bpftrace简单追踪bpftrace -e kprobe:do_execute_actions { printf(actions_len: %d\n, (int)arg3); }或者追踪到具体action类型bpftrace -e kprobe:do_execute_actions /arg3 0/ { $a (struct nlattr *)arg2; printf(action type: %u len: %u\n, $a-nla_type 0xffff, $a-nla_len); }注意netlink attribute的type字段实际包含flag位所以需要掩码处理低16位。这类动态追踪在生产环境上有一定风险建议在测试环境或者低峰期操作先确认内核符号没有被优化掉/proc/kallsyms里能看到do_execute_actions。5.2 常见问题速查与踩坑记录实际排查中最常遇到的动作执行问题整理成速查表现象可能原因排查方向报文从隧道口出去封装不对set_tunnel没有在output之前执行或者隧道metadata被后续action覆盖用trace确认action顺序抓包确认外层头VLAN标签顺序反了push_vlan的先后顺序不符合预期多层push时先压入的在后明确是外层先push还是内层先push按照封装层次反向操作recirc后流量走错流表recirc重新计算flow key后新key与预期不匹配dump内核flow查看recirc后的实际key值同一报文连接跟踪记录异常clone里和外层重复执行ct检查是否在clone内嵌了ct(commit)后外层又接ct(commit)clone里修改报文没生效clone执行的是克隆skb原skb不受影响确认是否真的需要clone需要修改原报文就不该用clonesample概率很高时吞吐骤降sample底层是skb clone代价高降低采样率或者改用其他统计方式output到一个端口后后续action还在跑output后如果skb未被消费循环继续若vport异常可能静默丢包先确认vport存在再确认发送成功还有个我在实际中踩过的坑在流表里写了actionsoutput(1),output(2)以为两个端口都会收到报文。实际上在datapath层第一个output执行完后如果skb被成功发送skb的引用计数可能会归零第二个output操作的对象就已经不再有效。类似这种多个output的需求应该用group或者clone来实现而不是简单地叠加output。5.3 datapath actions执行的性能心得说几个调优方向上比较实用的点。减少recirc。recirc每执行一次等于把数据包的整个datapath流程重跑了一遍flow key要重新提取哈希要重算流表要重新查。在高PPS场景下recirc的开销比普通action大一个量级。能不recirc就不recirc能用一次查表解决的不要把流程拆成两段。合理组织action顺序。修改报文的action尽量放在output之前避免在报文发出后再做无效修改。同时多个修改类action尽量合并比如连续修改IP和端口时共用一个skb_make_writable减少COW触发次数。避免不必要的clone和sample。前面已经说了clone和sample每用一次就是一次skb克隆。在镜像需求不是必须的时候不要滥用这两类action。注意OVS_ACTION_ATTR_SET的COW机制。skb可能被多个地方引用比如广播、镜像修改前要先确保独占。skb_make_writable会检查引用计数如果大于1就做拷贝。所以数据包如果经历了clone或者被多播后续的set操作代价会成倍增加。从内核分配器角度actions分配走的是kmalloc流表项本身是ovs_flow_alloc里一次性分配的。大量短生命周期流会频繁触发actions的分配和释放RCU的延迟回收机制会堆积一些待释放内存如果调优时看到内存增长明显优先查流表项数量而不是actions本身。结尾从最开始那个bond口分流不均的问题出发我最终定位到的问题是流表里用了groupclone的组合clone内部的output把包发出去之后原skb又被group的后续bucket再次处理结果在负载均衡的场景下同一个包被复制了两次。想通datapath actions的执行语义之后这种问题就变得清晰了很多。个人的习惯是遇到转发异常先不急着改流表而是用ovs-appctl ofproto/trace把用户态流程过一遍再对照ovs-dpctl dump-flows看内核datapath里的实际actions两者差异往往就是问题所在。datapath路径的actions处理流程本质上就是一个包在内核里按照一串指令逐步变形和移动的过程把每个指令的副作用记清楚排查OVS问题就不容易迷糊了。