在不久前举办的OpenInfra峰会上,Kube-OVN作者刘梦馨带来了题为“如何优化容器网络性能”的分享。分享围绕着打造Kube-OVN的过程中的网络性能问题展开,详细阐述了Kube-OVN怎样进行性能瓶颈分析、怎样设置性能优化测试思路,测试过程,以及令人较为满意的测试结果。在性能“自救”的同时,Kube-OVN团队还总结出了一些可以适用于其他网路性能优化的方法论,本篇文章希望对大家的容器网络优化带来有一些启示。
Kube-OVN优化之初
Kube-OVN简单来说就是把OVS和OVN的能力和K8s进行结合,我们选了开源SDN领域最成熟的OVN作为网络底座,借助它的功能来丰富K8s的网络能力,并结合灵雀云这些年的经验,把一些企业级的功能不断往里面融合,希望能够把K8s的网络做强做大。
怎么想到要做性能优化?
去年一个国外IT媒体把一些k8s上常见的容器网络插件全搭出来做性能横评,这几张图是最终得到的性能结果,我把Kube-OVN标了红框。上面两个是带宽的评测,TCP和UDP两种场景下Kube-OVN的性能基本上排在倒数第二。在这个评价体系里,网络插件的性能、资源消耗、功能都显示出我们的比较差,资源消耗比较高。当时很多社区用户也来问我们怎么看这个事情,所以我们痛下决心把Kube-OVN的能力提上去。
容器网络性能的自我救赎
不同测试方法测出来的性能差距会比较大,所以我们先寻找固定的测试方法和基准。然后跑性能测试,根据跑出的数据结果去Profile,分析瓶颈,然后寻找优化点进行优化,再回到第一步,不断进行性能测试、找瓶颈、优化,这是基本的方法论。
因为OVS提供了很多功能,性能上会有一定的损耗,开销也可能比Flannel、Cilium大一些。所以当时的优化目标是希望不要和别的网络插件比差太多,尽可能接近宿主机的网络。但经过不断优化,最终测试结果是:相比其他网络插件来说,Kube-OVN基本达到了同水平甚至更好,在某些场景下甚至比宿主机的网络性能还要好。
我们总结了一些网络瓶颈的优化方法,不只针对Kube-OVN有效,对其他的网络插件像Calico,Cilium,Flannel,也会有比较大的帮助。
测试方法和测试基准
我们发现在虚拟机场景下,性能干扰比较大,并且很难复现比较稳定的性能,所以选择物理机进行性能测试。选择了两台物理机通过同一个vlan进行打通,尽可能避免在网络链路上的开销。
在这两台机器上运行了k8s,只运行测试容器性能相关的组件,包括Kube-OVN,还有iperf 、qperf这些测试工具的容器,避免其他容器对性能产生干扰。
需要注意的是,我们测试中发现,如果是物理机,CPU一定要运行在性能模式。节能模式在延迟测试下浮动特别大,很难测试到一个稳定的结果。节能模式CPU会自动降频,而延迟测试一般是一个数据包一个数据包单个测试,所以CPU的频率有可能会很低,如果后台有什么程序,CPU性能会变化,导致主频不断的在变化,latency的信息就会不准确。所以需要把CPU设置成performance的模式,来避免波动对测试结果产生影响。
我们测试数据收集采用qperf,主要测了4个指标:TCP的带宽、UDP的带宽、TCP的延迟和UDP的延迟。
在我们最早优化的迭代过程中,比较关心延迟数据,主要测试1 byte大小数据包,因为是要测性能消耗,可以优化的空间主要是在OVS,OVN,包括流表,参数,如果数据包很大的话,profile的结果可能是大部分CPU花在内核处理数据包,而不是OVS或者其他组件处理数据包。如果我们把数据包的尺寸设到最小,就可以保证内核正常的数据链路处理的时间最短,这样我们自身组件对数据报的处理时间就会比较明显,所以我们最开始选择的测试基准,就是只测 1 byte大小的情况下,看性能是怎么样的。
CPU的profile信息通过火焰图进行收集,下面是收集火焰图的命令,频率收到999是希望尽可能多采一些样。然后pid接qperf的client,一开始收集到client,发现最主要的瓶颈出现在发包,收包很少情况下会出现瓶颈,所以我们主要收集发包一侧CPU的火焰图。
下面是测试了一个最基准的性能,一个是host网络。我们认为host是一个天花板,是不可能超越的,只能尽可能去逼近这个结果。TCP的延迟,UDP的延迟,TCP的带宽,UDP的带宽,然后测container在Kube-OVN没有任何优化情况下的结果。
我们容器的延迟大概是主机的1.8~1.9倍这样的情况,整体延迟比较大,因为如果在小包延迟提升了一倍,很可能吞吐量在大包会更差,吞吐量在TCP的情况下差距不是特别大,可能和TCP自身的一些机制有关。
但UDP的差距比较明显了。我们只有宿主机网络的1/4多一些的这样的一个情况,相当于主机网络的包转发率大概在600万包每秒,但是到容器就只剩了160万。这是Kube-OVN在没有优化情况的基础表现。
第一轮profile
这是第一轮profile得到的火焰图,在这里发现了一个奇怪的现象,跟OVN的实现相关。就是在OVS的流表编排里,可以看到这里OVS执行动作后,执行了clone操作,然后又执行了一次OVS的动作。我们根据OVN的流表进行匹配,发现这里其实是OVN自身lb的实现,lb实现其实涉及到OVS流表的clone,把这个数据包重新发回到OVS,再走一遍流表匹配的过程。这个过程其实就消耗了大概30%的CPU,这是我们第一轮profile得到的结果。
所以我们优化的方式就是删除了在Kube-OVN里面使用OVN的lb的逻辑,改回使用Kube-proxy 来做进行服务,如果大家想手动操作的话,其实执行一个对应的命令就可以了。
第一轮优化
经过这轮修改之后,可以看到TCP和UDP的延迟基本有6微秒左右的下降,平均延迟大概降低了30%,和火焰图看到的30%的一个CPU消耗基本上是能够匹配,并且UDP的带宽也得到比较明显的增加。
第二轮profile
第一轮优化之后,再进行 CPU profile得到的火焰图。可以看到之前很长的OVS动作执行由原来的两个变成一个,最长的那一段已经没了,后面小的这块相对来说变长了一些。在分析堆栈的过程中,可以看到有一个process backlog,涉及到了收发,软中断的过程。这个过程跟内核的代码进行比对,发现是veth处理的损耗,大概是在5%的时间,会占用一些软中断的处理。
我们翻了一些资料,因为现在大部分容器网络是用veth对,一端放到容器里面,另一端放到主机上来实现的,Kube-OVN也是类似的实现,但是会造成一些CPU的消耗。所以我们选择的方式是用OVS的internal- Port来代替veth。因为veth涉及到包的复制,相当于两个网卡完成了一个工作,涉及到两次收发包。但internal Port因为OVS自身的机制,一次收发包就可以完成。
第二轮优化
我们把网口的实现用internal Port替代,结果并没有有很大优化。TCP延迟降低了一些,但是UDP的延迟反而上升了一点。在延迟方面带来的影响比较小,但UDP的带宽有一些上升,对带宽大概有10%的影响。
第三轮profile
第三轮优化结果之后profile,我们发现中间一段veth的处理,5%已经没了。但是使用internal Port后,新增了一块neigh_resolve_output的消耗大概占了6%。veth带来的的5%性能提高相当于被抵消了,在延迟方面没有明显提升。
在这个过程中发现有很多的nf hook slow,总共4段占了27%的CPU消耗。了解netfilter的同学可能会知道,linux系统会有netfilter的framework,包括像iptable、IPVS、Conntrack,这些功能都是依赖于netfilter实现的。但是在我们容器网络的场景里,很多的netfilter都是不用的。首先外面这两段,因为我们容器是一个单独的netns,所以在netfilter的框架下,每个netns也是有独有的netfilter,即使这个net filter里面没有自己手动的去挂任何规则,但是系统里面像iptable、IPVS 、Conntrack,都会去netfilter里面注册函数。所以即使你没有任何的功能,在容器内部大概有很长时间处理netfilter函数,它在里面过了20个函数,尽管1个动作都没有,但是CPU都被消耗掉了。
然后从netns出来之后再往上走,到了宿主机的网络。宿主机网络用Geneve进行了封装,可以理解为我们最终是从OVS的网口出来进到速录机的网络,再从速录机的物理网卡出去。在这还要再进行宿主机的netfilter,把iptable、IPVS 、Conntrack在宿主机又走一遍,相当于有这4块的netfilter完全和跟我们的容器网络没有关系,但占据了将近30% CPU消耗。在我们优化之后,netfilter的处理其实占了很大的性能比重。
第三轮优化
我们自己编写了netfilter的模块,把容器网络的流量,通过隧道的流量,借助net filter里面一个叫nf_stop的 action, 这个action的结果就是如果匹配到对应的跟容器网络相关的流量,就用这个nf_stop提前结束netfilter的过程。可以理解为我们在filter里面一个goto,就到netfilter外面了,这样就可以跳过后面像iptable、IPVS 、Conntrack这些处理。这个模块的代码已经开源出来,大家如果有感兴趣的话可以看一下。
地址:https://github.com/kubeovn/kube-ovn/tree/master/fastpath
这轮性能的优化结果,最下面就是通过这个模块把netfilter提前绕过之后的性能的表现,可以看到性能下降还是比较明显的,从18降了三微秒到了15,UDP降了3微秒到了13,然后UDP带宽是提升了将近30%多。这时和宿主机相比,在延迟方面只相差一点几,接近10%损耗的水平。对比了其他的网络插件的延迟,到这一步Kube-OVN已经做到了一个比较好的水平。
第四轮profile
可以看到新的profile里面很长的nf hook slow,netfilter的处理都没了,说明函数是生效的。剩下两块看起来比较明显的,一个是因为interport引入带来的neigh_resolve_output,还有就是OVS自身OVS flow,table lookup的函数,就是OVS内核模块进行流量流表匹配的操作。这个操作其实涉及到了很多哈希的操作,因为是根据每个数据包的五元组,Contract的信息,和bucket里面的ID进行匹配。因为不是一个列表就完成,要进行好多次哈希的消耗。
OVS官方文档有一个利用X86的指令机,专门针对哈希进行优化的指令,主要是popcnt还有msse4.2,这个x86的扩展指令集能很大提高哈希的计算速度,因为我们的内核在编译的候是没有针对x86指定优化的选项,所以我们把它的内核模块重新编译了然后又重新加载进去。
第四轮优化
带上优化的性能模块之后我们重新测试,Kube-OVN和主机的延迟基本降到了一微秒以内。可以看到TCP差0.8,UDP差0.5,很接近宿主机的延迟水平。UDP的带宽也有了相应的提升,从4.8上升到了5.5。
整体优化总结
蓝色是优化前的情况,灰色是宿主机的网络,橙色的是经过优化后的结果。可以看到Kube-OVN在延迟方面做的已经不错了,相比原来延迟降了40%,比较接近宿主机的延迟水平。TCP整体在小包的吞吐量问题不是特别大,本来优化前就和宿主机差的不是特别多,所以优化数据不是很明显。
UDP的吞吐量的优化是比较明显的,从原来宿主机速度25%左右的水平升到了接近宿主机80%到90%的水平。
和其他网络插件对比
Kube-OVN其实支持两种网络模式,一种是overlay,一种是underlay,我们之前的那组测试境没法测underlay,所以专门准备了另外一套机器的环境,主要想测和其他一些网络插件的横向对比。这里选择了Calico的两种模式,一种是ipip带封装的,一种是不带封装直接路由的形式,和宿主机进行一个在一字节下TCP的延迟和UDP延迟的对比。
这组的测试环境下,右边的是宿主机,蓝色是Kube-OVN,橙色 是Kube-OVN的underlay。TCP的延迟是20微秒,可以看到Kube-OVN的容器网络性能会比宿主机在延迟方面稍微好一点。
这里解释下原因,Kube-OVN之前有20%-30%左右的优化是把netfilter完全跳过,这样就省出20%的CPU,但宿主机网络本身是比容器网络好的,但是由于宿主机网络有netfiltert,有iptable、IPVS 、Conntrack,这个HOOK函数没办法跳过去,所以宿主机网络相对我们的容器网络要多处理一部分netfilter的功能。所以宿主机可能用15微秒就可以完成的功能,但是有了netfilter又加了5微秒,加到了20微秒。所以整体表现来说,如果在同一个环境,一个跑host网络,一个跑容器网络,理论上容器网络性能在延迟可能会比速录机好,实际上我们也做到在某些场景,CPU如果比较好的话可以测出容器网络的延迟性能比速录机的性能要好。
中间的是Calico的两种模式, ipip和没有封装的情况下,ipip的延迟会更高,到了25、26。非封装的延迟会稍微好一点,但是整体都会比宿主机要高,也比Kube-OVN要高。实际上Calico可以用和我们类似的优化方案,把容器内部的net filter都跳过去,性能还会有提升。
1k数据包吞吐量优化
在进行完这轮优化后,不仅达到了我们之前定的目标,数据显示甚至比Calico要好,其实是很意外的突破。但是现实很无情的给我们上了一课,把我们脸打到啪啪响。因为这轮只是在小包延迟做到了最好,但是在别的情况下其实还是有新的性能问题。
这个就是我们在1k的包的情况下的性能表现,发现吞吐量有很明显的衰减,可以看到在1k的情况下,TCP和UDP的延迟情况。可以看到延迟和宿主机基本上保持同一个水平,因为在小包情况下做到和宿主机接近的话,那么大包理论上不会有太大的差距。
UDP的带宽相差大概在15%到16%也还好,但TCP的吞吐量有了很明显的下降,基本上只有宿主机50%左右水平,其实是一个很难接受的收据。
我们进行了profile之后,发现很奇怪的是,之前看到的所有的性能瓶颈都出现在client端,发包的一端CPU是先打满的,把包放大到1k的时候发现收包的一端CPU先打满了,发端CPU反而还有些富裕,单核没有打到100%。最终看到网卡的信息,发现recieve端有大量的丢包,丢包的数据随着压测数字呼呼的往上涨。当时的一个解决方式是把网卡的recieve queue给调大了,原来的queue大概有256左右,把它调到了最大的长度4096再进行测试,带宽打到了8.39,跟主机差1g大概10%的水平,延迟还是保持相当的情况。
UDP的其实有一些上升。但是因为整体来说,UDP没有触发到丢包,所以也在正常的波动范围内,主要是TCP的带宽在改了queue长度之后会有一个比较明显的上升。
我们分析认为是可能是在recieve端处理不过来,就触发了丢包,我们在iperf看到了client端很多的重传,所以认为可能是recieve端一些能力不足导致的。
虚拟机吞吐量优化
我们觉得10%的性能差距还是可以接受,可以出去炫耀一番了,但是打脸的事情又来了。我们的测试都是在物理机上做的,因为最初假设的是虚拟机的性能是不太稳定的,没有办法测得很准,但是有用户在虚拟机上跑Kube-OVN,显示结果实是十分惨烈。
我们用客户的方式拿iperf在一个同样的环境去测。看到上面是在宿主机网络进行测试,拿iperf打到了16.5G。有可能两个虚拟机是在一个物理机上,所以带宽是非常高的。但是进到容器后,带宽就降得很厉害,相当于原来的10%。
然后我们尝试按照物理机的方式调recieve的queue,但是很无奈这个虚拟机是用vhost虚拟出来的网卡,不支持recieve queue的调整,所以之前调queue的方式没法做。其实调queue的方式相对来说有些作弊,但是虚拟机调不了queue的情况下,问题就又出现了。然后我们profile了一下,这次在client端就出现瓶颈了,把client端容器的火焰图和宿主机的火焰图进行对比,发现容器端出现大量的TCP Push,就是在宿主机网络TCP Push几乎是0,但是在容器网络里TCP- push占到50% 60%的CPU消耗。
我们的猜想是因为用跨主机之间的Geneve封装的UDP,但是UDP的网卡很多卸载用不了,比如TSO,还有发端和收端的TCP 相关的offload都用不了。这样就会导致分包,TCP的切片,组包,都要用虚拟机的CPU来做。
如果物理机的CPU比较好的话就不会很明显,比如TCP的切片,checksum的计算,可能耗1G 的主频,发10g的包也需要1G主频,但是当时物理机的CPU都是2.4或3.6的主频,即使有1g的开销也能应付过去。但是虚拟机比较差的情况下,留发包的就会很少了。所以性能就会特别明显,只有原来的5%,10%都是有可能的。
核心原因其实就是UDP的封包没有办法用到一些卸载的能力,所以我们尝试了使用STT封装代替Geneve封装。改封装的命令方式列在这里,感兴趣的话可以试一下。STT的封装比Geneve封装资料更少,我们发现它是用TCP的格式实现了无连接的操作。这样的好处就是它用了TCP格式header之后,网卡的所有tso, tx offload这些都能用了,不会再用虚拟机里面的CPU进行TCP的计算。
我们实测了一下只改了封装,带宽就从1.6G到了11.7G这个水平,翻了大概6倍,很明显看到TCP Push降下去了,因为大部分的操作都是通过 tso 的,由虚拟网卡去做,即使是现在大部分的虚拟网卡,其实也支持TCP的卸载,所以带宽基本上一下提上去了。
提上去和宿主机还是差距比较大,只到了宿主机的60%左右的带宽。然后发现STT是依赖于netfilter来实现的,一方面是我们之前的hook在使用STT封装之后就失效,因为我们当时是针对Geneva的端口进行的netfilte的绕行,而不是针对STT这个端口进行绕行。
然后我们把Geneve的绕行改成STT的,匹配STT TCP的端口进行一个绕行,然后希望能够把netfilter的这些功能给绕过,但是发现上了netfilter的hook 之后,STT就不通了。STT的实现原理相当于利用了netfliter的框架,直接进行数据包转发,而不是像我们常规的iptable,或者IPVS,做过滤或者做nat,他直接把这个数据包从netfliter 直接发到了网络设备上,相当于STT解封包的逻辑是在netfliter里做的,但是我们的hook也挂载到了STT之前,相当于STT绕过去了,所以就失效了。
但是20%的开销还是很大的,所以我们又尝试着对这种情况下进行一个优化。首先我们把STT的模块改了一下。他的netfilter hook是挂在所有hook最后面,我们把hook的挂载点提前了。相当于如果命中了STT的规则,那么net fliter处理完后面的就不再处理。然后我们在out put链进行了另外一个处理,因为STT在output上是没有功能的,所以我们在output上匹配了7471端口,匹配到这个端口的流量之后,跳过剩余的netfliter。把优化执行完了之后,带宽从11.7上升到了14,基本上和原来16.6的带宽比,达到了原来85%的水平,基本效果是可以接受的一个水平。
经验总结
经过优化,Kube-OVN已经可以达到接近宿主机性能的水平。尤其延迟方面某些情况下能达到比宿主机更好的水平。并且推荐给其他的网络和应用,也可以使用这些方法进行优化。
优化建议
1.如果是OVS或者OVN体系的网络系统,使用OVN的LB相对慎重一些
有可能会凭空带来30%的延迟的增加。Kube-OVN这轮优化跟netfilter并列的一个大头,就是OVN的LB,只要删掉就会有20%~30%的延迟下降,我们后期可能会考虑使用ebpf或其他性能损耗相对低一些的功能来做LB和ACL。
2.可以使用STT封装代替Geneve/Vxlan
因为它能够用到 TCP 的 offload 能力,所以它的吞吐量理论上来说会更好的表现。但是需要注意,它并不像TCP那样真正的进行连接,它只是模拟TCP的包头的格式去发包。所以如果你底下是个虚拟网络,这个虚拟网络有些防火墙的话,那么这种包可能会认为是无效的包。如果没有这方面的要求的话,那么在大包的吞吐量性能提升会很明显。
3.性能的关键路径,尽可能的去避开 netfilter
因为netfilter可能凭空增加了另外30%的延迟和CPU消耗。网络这边像calico、cilium、flannel也都可以考虑用类似的方式,至少把容器里面的那一段给放过去,然后从容器网络到你的宿主机,网络再放过去,这样就会有比较明显的提升。
而且其他应用比如说网关、存储的应用,也可以考虑用类似的方式,就是针对某一个端口或某一条链路,把netfilter全部绕过去,如果是一个延迟敏感性的应用,也会有比较明显的提升。
4.利用指令集一些相关优化
如果CPU是X86,有扩展指令集的话,那么针对某些计算密集型的任务,会有一些相关优化的,而且CPU带来的就不需要代码层面上的变更,只要重新编译就好了。
5.还有硬件 rx queue 长度调整
6.优化思路就是先做profile,后做优化
Kube-OVN最早做优化绕了很多弯路,不知道性能瓶颈在哪儿,试了很多网络上关于性能优化的方法,都不管用。直到用了先profile后优化的方法,通过火焰图找到哪块真正消耗CPU,再去进行针对优化是比较有效的。
7.最后比较重要的是,不要畏惧修改内核
我们只是加载了一些模块绕行就能带来20%的性能提升,甚至颠覆性的从比较差的网络水平,变到最好的水平。所以当你profile发现20%~30%的性能瓶颈都在内核,修改动力就会很足,可以考虑针对内核进行一些操作。