先说一个我前阵子实际遇到的问题。某次排查一个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_vlan:VLAN标签的压入和弹出。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位的端口号。执行时首先根据这个端口号查找对应的vport:case 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,吞吐会明显下降。实测下来,同一套流表,把sample+clone的镜像逻辑去掉,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,这就涉及到两层VLAN(QinQ)的情况。实操中特别容易踩坑的是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是VNI,GRE是key)。执行时把隧道信息保存到skb的metadata区,后面的output动作在发送到隧道vport时读取这段metadata,封装外层头。这里要展开说一下output到隧道端口和set_tunnel+output到隧道端口的区别。直接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后的五元组来做匹配。常见的使用模板是:actions=ct(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,连接跟踪的结果不会驱动后续行为,状态机就断了。多个ct+recirc嵌套时,要防止同一个报文被连接跟踪处理两次。比如clone里面嵌了ct(commit),recirc,clone外头又接着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路径上对每个包都走一遍clone+sample。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存在,再确认发送成功还有个我在实际中踩过的坑:在流表里写了actions=output(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口分流不均的问题出发,我最终定位到的问题是:流表里用了group+clone的组合,clone内部的output把包发出去之后,原skb又被group的后续bucket再次处理,结果在负载均衡的场景下同一个包被复制了两次。想通datapath actions的执行语义之后,这种问题就变得清晰了很多。个人的习惯是,遇到转发异常先不急着改流表,而是用ovs-appctl ofproto/trace把用户态流程过一遍,再对照ovs-dpctl dump-flows看内核datapath里的实际actions,两者差异往往就是问题所在。datapath路径的actions处理流程,本质上就是"一个包在内核里按照一串指令逐步变形和移动的过程",把每个指令的副作用记清楚,排查OVS问题就不容易迷糊了。
先说一个我前阵子实际遇到的问题。某次排查一个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_vlan:VLAN标签的压入和弹出。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位的端口号。执行时首先根据这个端口号查找对应的vport:case 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,吞吐会明显下降。实测下来,同一套流表,把sample+clone的镜像逻辑去掉,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,这就涉及到两层VLAN(QinQ)的情况。实操中特别容易踩坑的是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是VNI,GRE是key)。执行时把隧道信息保存到skb的metadata区,后面的output动作在发送到隧道vport时读取这段metadata,封装外层头。这里要展开说一下output到隧道端口和set_tunnel+output到隧道端口的区别。直接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后的五元组来做匹配。常见的使用模板是:actions=ct(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,连接跟踪的结果不会驱动后续行为,状态机就断了。多个ct+recirc嵌套时,要防止同一个报文被连接跟踪处理两次。比如clone里面嵌了ct(commit),recirc,clone外头又接着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路径上对每个包都走一遍clone+sample。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存在,再确认发送成功还有个我在实际中踩过的坑:在流表里写了actions=output(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口分流不均的问题出发,我最终定位到的问题是:流表里用了group+clone的组合,clone内部的output把包发出去之后,原skb又被group的后续bucket再次处理,结果在负载均衡的场景下同一个包被复制了两次。想通datapath actions的执行语义之后,这种问题就变得清晰了很多。个人的习惯是,遇到转发异常先不急着改流表,而是用ovs-appctl ofproto/trace把用户态流程过一遍,再对照ovs-dpctl dump-flows看内核datapath里的实际actions,两者差异往往就是问题所在。datapath路径的actions处理流程,本质上就是"一个包在内核里按照一串指令逐步变形和移动的过程",把每个指令的副作用记清楚,排查OVS问题就不容易迷糊了。