背景介绍
2017年,Docker公司把原来的开源项目Docker改名为Moby,同时把Docker软件分为社区版的docker-ce和商业版的docker-ee。
Moby是由社区维护的开源项目,源码托管在github上:https://github.com/moby/moby
经过多年的发展,Moby已经是一个十分成熟的项目,拥有健全的持续集成(CI)系统和数量庞大的测试集。
Moby项目的自动化测试分为三种:单元测试(unit test)、集成测试(integration test)和端到端测试(end-to-end test)。
目前,集成测试又可以分为两类:一类是所谓的legacy integration test,即“老”测试集。这一类测试的原理是构造并运行完整的docker命令,通过检查命令的输出结果是否符合预期来判定测试成功或者失败。另一类是所谓的“新”测试集。这一类测试的原理是通过调用Docker API来运行docker,效果和直接运行docker命令是一样的。基于API的新测试框架是社区的主推方向。目前所有新加入的测试用例都使用API方式,社区已经不再接受使用老框架的测试用例。
Moby社区有志于把所有老的测试用例转到新的测试框架下面,不过现在的进展并不快,因为老测试集里面用例数量庞大。目前,老测试集中的用例数量还远远大于新测试集。
问题
大量的集成测试用例为性能研究提供了方便。
笔者通过比较同一测试集在arm64和x86服务器上运行的耗时来发现Docker在arm64架构下的性能问题。
本文描述的性能测试是分别在一台arm64和一台x86服务器上进行的,两个服务器的CPU单核性能相近。都安装有ubuntu 18.04 server系统,并且都预先安装了docker-ce软件,因为Moby的测试是在container里面进行的。
首先clone源码到本地服务器:
git clone https://github.com/moby/moby.git
运行老的集成测试集:
make test-integration
这一命令会完成3件事:
1. Moby会首先自动从根目录下的Dockerfile构建一个容器,这一步耗时可能比较长,具体时间视网络带宽而定;
2. 在容器中从源码构建docker程序并安装;
3. 运行集成测试集。
测试的log会打印到文件bundles/test-integration/test.log。在test.log文件中你可以找到每个测试例子的名字、结果,还有最重要的耗时。通过处理test.log文件,可以把所有测试用例和运行时间整理成表格。通过比较相同测试用例在arm64和x86机器上的运行时间,可以发现潜在的性能问题。
下图即是数据表格的一部分。“Test case”竖列是测试用例所在文件和名字,“Qualcomm”列是在arm64机器上的运行时间(单位是秒),“Dell”是在x86机器上的运行时间。(这里直接以机器的生产商命名。)"Diff(s)"列是在arm64和x86机器上运行时间之差,最后一列“Diff(times)”是两个运行的比值,即"在arm64机器上的运行时间/在x86机器上的运行时间"。最后一列是主要的比较目标,为方便检查,笔者给这一列加了颜色(excel功能):数值越大颜色越红,数值越小颜色越绿。两台用于测试的服务器性能比较接近,所以多数"Diff(times)"列的数据在1.0左右,呈黄颜色。而那些颜色偏红的数据表明,这个测试在arm64上运行的时间比在x86上运行的时间多出很大的比例,可能存在性能问题。
注意,单个测试用例的耗时长短可能存在偶然性,需要反复运行,看平均情况。
经过多次比较,发现Network类的集成测试普遍性能较差。在arm64机器上的运行时间往往是在x86上的2倍以上。看来在arm64架构下,docker的network子命令很可能存在性能问题。
分析
接下来,先要确定性能的瓶颈在哪。通过在network子命令的源代码中打印更多的时间戳,最终把问题定位在GO语言提供的API "exec.Command(<command>, <args...>).CombinedOutput()"上。Docker network子命令通过"exec"来调用外部的“iptables”程序来为容器配置网络数据包处理规则。在调用这个接口的时候,arm64机器总是比x86机器花费更多的时间。在x86机器上,“exec.Command().CombinedOutput()”一般只需要2~4毫秒就完成,但是在arm64机器上则需要7~10毫秒。
单独测试iptables命令没有问题,arm64和x86机器上直接在命令行里运行iptables程序的用时相近。所以最终问题指向了GO语言的"exec"API。
在GO的"exec"包里面没有办法直接加打印信息来查看时间,因这它已经是比较底层的包了,而用于打印和获取时间的包所在层次比它略高,如果import的话就会出现"循环引用"的问题。
幸好Go的工具箱里有"tool trace",具体用法请参考:https://making.pusher.com/go-...
“tool trace”提供了可视化的工具,可以查看所有go-routine上的细节信息,包括时间戳。通过分析“exec.Command().CombinedOutput()”所在的go-routine的时间就可以发现多余的时间花在了哪里。
下图所示是一次“exec.Command().CombinedOutput()”外部命令调用的go-routine分析,可以看到GO语言在从当前进程fork新的进程(用于执行iptables)的时候,会有一个明显的时延,即图中的4ms。
而这个明显的等待时间在x86机器上是不存在的,见下图。
这就是在arm64机器上调用外部命令比在x86机器上慢的原因。
解决
那么造成这个4毫秒时延的原因又是什么呢?
查看GO语言syscall部分源码发现,在做fork系统调用的时候,x86架构存在一些优化,特别是使用"CLONE_VFORK"和 "CLONE_VM" 选项可以明确缩短fork的时间。这个优化在arm64架构下也是支持了,但是还没有加到GO的源码里。
要在GO语言的arm64版本上实现这一优化,既需要修改上图所示代码来针对arm64架构配置"CLONE_VFORK"和 "CLONE_VM" 选项,也需要在RawSyscall6函数的汇编代码中做相应的修改。
加入"CLONE_VFORK"和 "CLONE_VM"优化之后,前述的4ms左右延时消失,整个network类集成测试的时间缩短15%左右。
最终,在验证了优化效果之后,我们向GO语言社区提交了patch: https://go-review.googlesourc...。目前,这一优化已经进入GO语言master branch。