在Datadog代理的二进制文件大小在5年时间里从428 MiB增长到了1.22 GiB之后,Datadog的工程师们开始着手缩小它的二进制文件大小。他们发现:Go语言生成的二进制文件之所以会变得如此庞大,主要原因是隐藏的依赖关系、被禁用的链接器优化功能,以及Go编译器和链接器中存在的一些特殊行为。
这种二进制文件大小的增加对我们和我们的用户都产生了影响:网络传输成本和资源消耗量增加了,人们对Datadog代理的使用体验也变差了,在资源有限的平台上使用该代理也变得更加困难。
为了解决这个问题,Datadog的软件工程师Pierre Gimalac提出了一种解决方案:他们通过仔细检查代码中的导入语句,将可选代码分离出来,并消除那些会导致二进制文件大小增加的反射机制或插件功能,从而尽可能地缩小二进制文件的大小。
事实上,在分析了代理程序二进制文件大小增长的原因之后,Datadog的工程师们发现,这种增长主要是由于新功能的添加、额外的集成模块的引入,以及大量的第三方依赖关系的存在。尤其是Go语言的依赖模型允许存在传递性导入关系,因此即使是一个小的修改也可能会导致数百个包被包含进来。
为了去除这些不必要的依赖关系,Datadog的工程师们采用了两种方法:一是使用构建标签(如//go:build feature_x)来排除那些可选的代码;二是将某些代码分离到单独的包中,这样非必需的包就能保持尽可能小的大小。这两种方法都需要系统地检查代码中的导入语句,才能确定哪些文件或包可以在构建过程中被忽略掉。例如,仅仅是将一个函数移到它自己的包中,就可以使生成的二进制文件的大小减少大约570个包和36 MB的代码量。
虽然检查依赖关系是一项并不容易的任务,但Go生态系统中提供了三种非常有用的工具来帮助开发者完成这项工作:go list可以列出构建过程中所使用的所有包;goda可以帮助人们直观地了解依赖关系图和导入链的结构,从而理解为什么某个依赖关系是必需的;go-size-analyzer则可以显示每个依赖关系占二进制文件大小的百分比。
除了优化依赖关系之外,Datadog的工程师们还通过尽量减少反射机制的使用,进一步使二进制文件的大小减少了20%。因为反射机制有时会悄悄地禁用一些链接器优化功能,比如删除无用的代码。
如果使用的是非常量方法名,那么在构建阶段链接器就无法知道运行时到底会使用哪些方法。因此,它就会保留所有可导出的方法以及这些方法所依赖的所有符号,这样就会导致最终生成的二进制文件的大小大幅增加。
为了解决这个问题,他们在自己的代码库以及依赖的第三方库中尽可能地避免了动态反射机制的使用。为了实现这一目标,他们向kubernetes/kubernetes、uber-go/dig、google/go-cmp等项目提交了几份拉取请求。
另外,Go插件也是导致无法删除无用代码的一个原因。因为Go插件允许程序在运行时动态加载额外的代码。实际上,仅仅是因为导入了plugin包,链接器就会将生成的二进制文件视为动态链接的文件,这样一来,删除无用代码的功能就会被禁用,甚至链接器还会保留所有没有被导出的方法。这一改动使得在某些构建过程中,二进制文件的大小又减少了大约20%。<最后需要说明的是,吉马拉克强调这些改进是在六个月内完成的,而且最重要的是,这些改进并没有导致任何功能被删除。他的描述中包含了比这里所能介绍的更多的细节,因此建议大家一定要阅读他的完整说明,才能了解整个事情的来龙去脉。