1、封面页(此页面将由下图全覆盖,此为编辑稿中的示意,将在终稿 PDF 版中做更新)(待分享)卷首语 速度与效率与激情 什么是速度?速度就是快,快有很多种。有小李飞刀的快,也有闪电侠的快,当然还有周星星的快:(船家)我是出了名够快。(周星星)“这船好像在下沉?”(船家)“是呀!沉得快嘛”。并不是任何事情越快越好,而是那些有价值有意义的事才越快越好。对于这些越快越好的事来说,快的表现是速度,而实质上是提效。今天我们要讲的 Java 应用的研发效率,即如何加快我们的 Java 研发速度,提高我们的研发效率。提效的方式也有很多种,但可以分成二大类。我们使用一些工具与平台进行应用研发与交付。这些工具与平台
2、的用户很多,而且一般对于大部分应用来说,效率不错,否则也不会有这些工具与平台的存在了。但对于一小部分应用来说,效率低的要命。所以当这小部分应用的用户找工具与平台负责人时,负责人建议提效的方案是:你看看其他应用都这么快,说明我们平台没问题。可能是你们的应用架构的问题,也可能是你们的应用中祖传代码太多了,要自己好好重构下。这是大家最常见的一类提效方式。而今天我们要讲的是第二类,是从工具与平台方面进行升级。即通过基础研发设施与工具的微创新改进,实现研发提效,而用户要做的可能就是换个工具的版本号。有了速度后一定会有激情吗?我们先来看看电影中一个小插曲。有一次故事讲到范老大来到了古巴,与当地的大哥赛车。
3、相比与大哥的豪车,范老大的车就是一辆拖拉机。但他用双手好象对拖拉机的发动机施了一个“魔法”,然后在最后一公里时,发动机着火了,但车的速度却达到火箭一样的速度,冲向了终点。这里没有哈利波特的人设,这世界没有魔法。范老大也没有。但这世界上几乎没有人能比范老大更懂发动机。所以也几乎没有人能比他更能改造发动机。而发动机就是一辆车的灵魂。买了一辆再好的车,带来的只是速度。而自己不断研究与改造发动机,让车子越来越快,在带来不断突破的“速度”的同时还带来了“激情”。因为这是一个不断用自己双手创造奇迹的过程。所以我们今天要讲的不是买一辆好车,而是讲如何改造“发动机”。在阿里集团,有上万多个应用,大部分应用都是
4、 Java 应用,95%应用的构建编译时间是 5 分钟以上,镜像构建时间是 2 分钟以上,启动时间是 8 分钟以上,这样意味着研发同学的一次改动,大部分需要等待 15 分钟左右,才能进行业务验证。而且随着业务迭代和时间的推移,应用的整体编译构建、启动速度也越来越慢,发布、扩容、混部拉起等等一系列动作都被拖慢,极大的影响了研发和运维整体效能,应用提速刻不容缓。我们将阐述通过基础设施与工具的改进,实现从构建到启动全方面大幅提速的实践和理论,相信能帮助大家。目录 一、maven 构建提速.6 二、本地 IDEA 环境提速.20 三、docker 构建提速.25 四、JDK 提速.30 五、Class
5、Loader 提速.37 六、阿里中间件提速.42 七、其他提速.50 卷尾语:持续地.激情.56 作者:阿里巴巴 CTO 技术 联合作者:道延 微波 沈陵 梁希 大熊 断岭 北纬 未宇 岱泽 浮图 一、maven 构建提速 6 一、maven 构建提速 1.现状 maven 其实并不是拖拉机。相对于 ant 时代来说,maven 是一辆大奔。但随着业务越来越复杂,我们为业务提供服务的软件也越来越复杂。虽然我们在提倡要降低软件复杂度,但对于复杂的业务来说,降低了复杂度的软件还是复杂的。在这些年,随着业务竞争越来越激励,业务越来越复杂,软件也越来越复杂。而maven 却还是几年的版本。在 201
6、2 年推出 maven3.0.0 以来,直到现在的 2022年,正好十年,但 maven 最新版本还是 3 系列 3.8.6。所以在十年后的今天,站在复杂软件面前,maven 变成了一辆拖拉机。编码也是一种艺术,对于我们一线研发同学来说,每个人都期望变成一名艺术家,而不是一个码农。但如一次构建大于 3 分钟,会将我们从一名高雅的艺术家沦为一名焦虑的码农,因为项目的 deadline 是放的那么地显眼。我们可能有过这样体验,编码几分钟,代码提交后,CI/CD 中的构建却要 10 多分钟。特别是在项目联调阶段,代码修改会越频率,但一天解决不了几个 BUG,因为时间都花在等待构建阶段上了。我们曾经错
7、过了多少个夏天的晚霞与秋天的朝霞,都是因为等待构建与编译而工作到凌晨。2.解决方案 在这十年,虽然 maven 还是停留在主版本号是 3,但当今业界也不断出现了优秀的构建工具,如 gradle,bazel。但因各工具的生态不同,同时工具间迁移有成本与风险,所以目前在 Java 服务端应用仍是以 maven 构建为主。所以我们在 apache-maven 的基础上,参照 gradle,bazel 等其它工具的思路,进行了优化,并以“amaven”命名。一、maven 构建提速 7 因为 amaven 完全兼容 apache-maven,所支持的命令与参数都兼容,所以对我们研发同学来说,只要修改一
8、个 maven 的版本号。3.效果 从目前试验来看,对于 mvn build 耗时在 3 分钟以上的应用有效果。对于典型应用从 2325 秒降到 188 秒,提升了 10 倍多。我们再来看持续了一个时间段后的总体效果,典型应用使用 amaven 后,构建耗时p95 的时间有较明显下降,对比使用前后二个月的构建耗时降了 50%左右。4.原理 如果说发动机是一辆车的灵魂,那依赖管理就是 maven 的灵魂。因为 maven 就是为了系统化的管理依赖而产生的工具。使用过 maven 的同学都清楚,我们将依赖写在pom.xml中,而这依赖又定义了自己的依赖在自己的pom.xml。通过 pom 文件的层
9、次化来管理依赖的确让我们方便很多。我们平常说的 maven 其实指二样东西,一个是 maven 工具,一个是 maven 仓库。maven 工具主要是 mvn 命令,我们在执行 mvn compile 等命令时,maven 会先不断解析 pom 中的依赖,如某个依赖本地没有则会从 maven 仓库下载到本地,再递归解析与下载“依赖的依赖”,最后生成一个 dependencyGraph,然后再将graph 中的依赖的 jar 列表成 classPath 中的参数,进行 Javac,从而完成一次编译。如再加上一些插件执行,则一次典型的 maven 构建过程,会是这样:一、maven 构建提速 8
10、从上图可以看出,maven 构建主要有二个阶段,而第一阶段是第二阶段的基础,基本上大部分的插件都会使用第一阶段产生的依赖树:解析应用的 pom 及依赖的 pom,生成依赖树;在解析过程中,一般还会从maven 仓库下载新增的依赖或更新了的 snapshot 包。执行各 maven 插件。我们也通过分析实际的构建日志,发现大于 3 分钟的 maven 构建,瓶颈都在“生成依赖树”阶段。而“生成依赖树”阶段慢的根本原因是一个 module 配置的依赖太多太复杂,它表现为:依赖太多,则要从 maven 仓库下载的可能性越大。依赖太复杂,则依赖树解析过程中递归次数越多。既然说发动机是车子的灵魂,那要让
11、车子跑的更快,最核心的就要不断改造发动机的性能;既然说依赖管理是 maven 的灵魂,那要让 maven 执行的快,最核心的就要不断“改造”依赖分析的性能。在amaven中通过优化依赖分析算法,与提升下载依赖速度来提升依赖分析的性能。除此之外,性能优化的经典思想是缓存增量,与分布式并发,我们也遵循这个思想。既然生成依赖树的代价大,那我们就将依赖树缓存起来(直接缓存与复用肯定比重新解析一次快),因为在实际开发过程中,修改自己的 Java 代码的概率远大于修改 一、maven 构建提速 9 应用的 pom。同时,如一个应用,特别是大库应用,当它的 module 可能有几十个,或几百个时,则要使用分
12、布式并发构建的方案,将互不依赖的的 module 启多线程,甚至分配到不同的编译机上去同时构建。因为 maven 自己也是 Java 程序,所以为了尽可能降低字节码在运行时转成机器码的开销,我们也考虑了 daemon 方案。所以总的加速思路如下:而当以上思路在不断落地过程中,amaven 也不断地 C/S 化了,即 amaven 不再是一个 client,而有了 server 端,同时将部分复杂的计算从 client 端移到了 server 端。而当 client 越做越薄,server 端的功能越来越强大时,server 的计算所需要的资源也会越来越多,将这些资源用弹性伸缩来解决,慢慢地 a
13、maven 云化了。从单个 client 到 C/S 化再到云化,这也是一个工具不断进化的趋势所在。1)依赖树 a)依赖树缓存 既然依赖树生成慢,那我们就将这依赖树缓存起来。缓存后,这依赖树可以不用重复生成,而且可以不同人,不同的机器的编译进行共享。使用依赖树缓存后,一次典型的 mvn 构建的过程如下:一、maven 构建提速 10 从上图中可以看到 amaven-server,它主要负责依赖树缓存的读写性能,保障存储可靠性,及保证缓存的正确性等。b)依赖树生成算法优化 虽在日常研发过程中,修改 pom 文件的概率较修改应用 Java 低,但还是有一定概率;同时当 pom 中依赖了较多 SNA
14、PSHOT 且 SNAPSHOT 有更新时,依赖树缓存会失效掉。所以还是会有不少的依赖树重新生成的场景。所以还是有必要来优化依赖树生成算法。在 maven2 及 maven3 版本中,包括最新的 maven3.8.5 中,maven 是以深度优先遍历(DF)来生成依赖树的。(在社区版本中,目前 master 上已经支持 BF,但还未发 release 版本:https:/ debug 与打日志发现有很多相同的 gav 或相同的 ga 会被重复分析很多次,甚至数万次。一、maven 构建提速 11 树的经典遍历算法主要有二种:深度优先算法(DF)及广度优先算法(BF),BF 与DF 的效率其实差
15、不多的。在有些场景,是 DF 更快,在有些场景,是 BF 更快。DF一般用 stack 数据结构,BF 一般用 queue 数据结构。树的二种遍历算法本没有根本的孰好孰坏之分,但当结合 maven 的版本仲裁机制考虑会发现有些差异。我们再来看看 maven 的仲裁机制。无论是maven2还是maven3,最主要的仲裁原则就是depth。相同ga或相同gav,谁更 deeper,谁就 skip。当然仲裁的因素还有 scope、profile 等。考虑根据 depth来仲裁的机制,按层遍历会更优,因为可以比 DF 更容易结合按 depth 仲裁。如下图,如按层来遍历,则红色的二个 D1,D2 就会
16、 skip 掉,不会重复解析。(注意,实际场景是 C 的 D1 还是会被解析,因为它更左)。按层遍历也就是 BF。所以小结下,对于树的遍历算法本身来说,DF 与 BF 效率是差不多的。但对于maven3.5.0 的依赖树生成逻辑来说,是因为在 BF 中可以先加上按 depth 仲裁逻辑,才会比 DF 快。即算法优化的思路是:“提前修枝”。之前 maven3 的逻辑是先生成依赖树再版本仲裁,而优化后是边生成依赖树边仲裁。就好比一个树苗,要边生长边修枝,而如果等它长成了参天大树后则修枝要累死人。一、maven 构建提速 12 c)依赖下载优化 maven 在编译过程中,会解析 pom,然后不断下载
17、直接依赖与间接依赖到本地。一般本地目录是.m2。对一线研发来说,本地的.m2 不太会去删除,所以除非有大的重构,每次编译只有少量的依赖会下载。但对于 CICD 平台来说,因为编译机一般不是独占的,而是多应用间共享的,所以为了应用间不相互影响,每次编译后可能会删除掉.m2 目录。这样,在 CICD 平台要考虑.m2 的隔离,及当.m2 清理后要下载大量依赖包的场景。而依赖包的下载,是需要经过网络,所以当一次编译,如要下载上千个依赖,那构建耗时大部分是在下载包,即瓶颈是下载。增大下载并发数 依赖包是从 maven 仓库下载。maven3.5.0 在编译时默认是启了 5 个线程下载。我们可以通过 a
18、ether.connector.basic.threads 来设置更多的线程如 20 个来下载,但这要求 maven 仓库要能撑得住翻倍的并发流量。所以我们对 maven 仓库进行了架构升级,根据包不同的文件大小区间使用了本地硬盘缓存,redis 缓存等包文件多级存储来加快包的下载。一、maven 构建提速 13 下表是对热点应用 A 用不同的下载线程数来下载 5000 多个依赖得到的下载耗时结果比较:在 amaven 中我们加了对下载耗时的统计报告,包括下载多少个依赖,下载线程是多少,下载耗时是多少,方便大家进行性能分析。如下图:同时为了减少网络开销,我们还采用了在编译机本地建立了 mirr
19、or 机制。本地 mirror 在 CI/CD 平台,构建时,为避免重复下载同一个依赖文件,架构可能是这样的:(架构 1.0:共享.m2)这架构会有依赖包的准确性问题。一、maven 构建提速 14 在一个 node 上,会编译很多应用,而每个应用编译时指定的 maven 仓库可能不一样。如果 volume 同一个.m2 目录,当应用 A 下载从仓库 a 下载了 maven-compiler-plugin:3.8.1 后,应用 B 它指定了从仓库 b 下载依赖,但当它编译时发现.m2 目录已经有 maven-compiler-plugin:3.8.1 了,就不会下载了。当仓库 a 与仓库 b
20、中maven-compiler-plugin:3.8.1 的文件的 checksum 不同时,就会让应用 B 在构建或运行时出现问题。为保证依赖包的准确性,需要将.m2 隔离.即每个 pod 都有一个独立的.m2 来volume。(架构 2.0:按 pod 隔离.m2)虽然这样会浪费一些磁盘,但准确性就能得到保障。但产生了同一个仓库的同一个依赖会在同一个 node 上重复下载的问题,即使 2.0 图示的二个 pod 是同一个应用的并发构建,但在同一个 node 上面下载 maven-compiler-plugin:3.8.1 时,要下载二次。所以我们继续架构演进,来按 app 来隔离。一、ma
21、ven 构建提速 15 (架构 3.0:按 app 隔离.m2)但还是会存在同一个 node 上重复下载同一个仓库的同一个依赖文件,因为 appA与 appB,它们用的是同一个仓库。因为在一个 node 上,是不会知道在其中运行的mvn build 的,是同一个应用?或不同应用但相同仓库?或不同应用不同仓库?所以我们得继续演进,不按应用而按仓库来隔离.m2。一、maven 构建提速 16 (架构 4.0:按 repo 隔离.m2)这架构看上去感觉还可以,但解决不了一个应用依赖多个仓库的问题。有些应用有些复杂,它会在 maven 构建的仓库配置文件 settings.xml(或 pom 文件)中
22、指定下载多个仓库。因为这应用的要下载的依赖的确来自多个仓库。当指定多个仓库时,下载一个依赖包,会依次从这多个仓库查找并下载。虽然 maven 的 settings.xml 语法支持多个仓库,但 localRepository 却只能指定一个。所以要看下 docker 是否支持将多个目录 volume 到同一个容器中的目录,即上图中的红线,但初步看了 docker 官网文档,并不支持。那是否可以用 overlay 的方式?即使可行,但 overlay 时谁在 lower,谁在 upper,这个得根据 settings.xml 中指定的多个仓库的顺序来才行。于是得在启 pod 构建前要解析下这应用
23、的 settings.xml,稍嫌复杂。一、maven 构建提速 17 为解决按仓库隔离.m2,且应用依赖多个仓库时的问题,我们现在通过对 amaven的优化来解决。(架构 5.0:repo_mirror)当 amaven 执行 mvn build 时,当一个依赖包不在本地.m2 目录,而要下载时,会先到 repo_mirror 中对应的仓库中找,如找到,则从 repo_mirror 中对应的仓库中将包直接复制到.m2,否则就只能到远程仓库下载,下载到.m2 后,会同时将包复制到 repo_mirror 中对应的仓库中。通过repo_mirror可以实现同一个node上只会下载一次下载一次同一
24、个仓库的同一个文件。当然如果有合适的共享存储,多个 node 共享一个存储服务,那就能解决多个 node只下载一次同一个仓库的同一个文件了。d)SNAPSHOT 版本号缓存 其实在 amavenServer 的缓存中,除了依赖树,还缓存了 SNAPSHOT 的版本号。我们的应用会依赖一些 snapshot 包,同时当我们在 mvn 构建时加上-U 就会去检测这些 SNAPSHOT 的更新,而在 apache-maven 中检测 SNAPSHOT 需要多次请求maven 仓库,会有一些网络开销。现在我们结合 maven 仓库作了优化,从而让多次请求 maven 仓库,换成了一次cache 服务直
25、接拿到 SNAPSHOT 的最新版本。一、maven 构建提速 18 2)增量 增量是与缓存息息相关的,增量的实现就是用缓存。maven 的开放性是通过插件机制实现的,每个插件实现具体的功能,是一个函数。当输入不变,则输出不变,即复用输出,而将每次每个函数执行后的输出缓存起来。上面讲的依赖树缓存,也是 maven 本身(非插件)的一种增量方式。要实现增量的关键是定义好一个函数的输入与输出,即要保证定义好的输入不变时,定义好的输出肯定不变。每个插件自己是清楚输入与输出是什么的,所以插件的增量不是由 amaven 统一实现,而是 amaven 提供了一个机制。如一个插件按约定定义好了输入与输出,则
26、 amaven 在执行前会检测输入是否变化,如没变化,则直接跳过插件的执行,而从缓存中取到输出结果。但其实一个插件的输入与输出要定义清楚并没那么简单,我们拿 maven-compiler-plugin 来说。maven-compiler-plugin 简单地说,它的输入是 src/java 目录,输出是 classes 目录。但有些场景即使 src/java 没变,classes 也不能复用。如一个 project有二个 module:A 与 B。B 中有一个 Java 文件引用了 A 的一个 Java 文件的常量。A 修改了一个 Java 中的一个常量,A 会重新 compile。但 B 没
27、修改 Java 文件,但 B也要重新 compile。一、maven 构建提速 19 除了常量外,还有一些 ABI 兼容性的问题也要考虑。module B 依赖 A,B 调用了 A中的一个方法 foo(String args),当 A 将方法签名修改成 foo(String.args),B 不用修改对应的调用 foo 方法的类,而需要重新编译,否则运行时会报找不到方法。一个 module 执行一个插件,是否能用增量,不只是考虑自己 module 的变化情况,还要考虑其它 module 及直接依赖或间接依赖的变化情况,这会让增量的实现有一定的挑战性。需要不断的校正输入。但增量的效果是明显的,如依
28、赖树缓存与算法的优化能让 maven 构建从 10 分钟降到 2 分钟,那增量则可以将构建耗时从分钟级降到秒级。而 gradle 与 bazel 能达到修改一个 Java 文件,几秒内完成编译,就是增量效果的体现。当然它们也有定义清晰与准确输入的挑战性。3)daemon 与分布式 daemon 是为了进一步达到 10 秒内构建的实现途径。maven 也是 Java 程序,运行时要将字节码转成机器码,而这转化有时间开销。虽这开销只有几秒时间,但对一个 mvn构建只要15秒的应用来说,所占比例也有10%多。为降低这时间开销,可以用 JIT 直接将 maven 程序编译成机器码,同时 mvn 在构建
29、完成后,不退出,常驻进程,当有新构建任务来时,直接调用 mvn 进程。一般,一个 maven 应用编译不会超过 10 分钟,所以,看上去没必要将构建任务拆成子任务,再调度到不同的上执行分布式构建。因为分布式调度有时间开销,这开销可能比直接在本机上编译耗时更大,即得不偿失。所以分布式构建的使用场景是大库。为了简化版本管理,将二进制依赖转成源码依赖,将依赖较密切的源码放在一个代码仓库中,就是大库。当一个大库有成千上万个 module 时,则非用分布式构建不可了。使用分布式构建,可以将大库几个小时的构建降到几分钟级别。二、本地 IDEA 环境提速 20 二、本地 IDEA 环境提速 1.从盲侠说起
30、曾经有有一位盲人叫座头市,他双目失明,但却是一位顶尖的剑客,江湖上没人能接得了他三招,他行侠于江湖,江湖上称他为“盲侠”。在我们的一线研发同学中,也有不少盲侠。这些同学在本地进行写代码时,是盲写。他们写的代码尽管全都显示红色警示,写的单测尽管在本地没跑过,但还是照写不误。而且慢慢的练就了,本地写了代码后,不用管语法的错误提示,不用管单测是否能跑,代码提交上去后,能一切编译通过,部署正常。但这“练就”其实只是大家自己的期望,每次代码提交后,返工的次数还是挺多的。而且这些同学也不是自己故意装逼要当个“盲侠”,而是逼于无奈。因为他们要研发的应用的代码在本地 IDEA 环境导入后,依赖解析不全,导致众
31、多红叉。我们一般的开发流程是,接到一个需求,从主干拉一个分支,再将本地的代码切到这新分支,再刷新 IDEA。但有些分支在刷新后,尽管等了 30 分钟,尽管自己的 mac的 CPU 沙沙直响,热的冒泡,但 IDEA 的工作区还是有很多红线。这些红线逼我们不少同学走上了“盲侠”之路。一个 maven 工程的 Java 应用,IDEA 的导入也是使用了 maven 的依赖分析。而我们据分析与实际观测,一个需求的开发,即在一个分支上的开发,在本地使用 maven的次数绝对比在 CICD 平台上使用的次数多。所以本地的 maven 的性能更需要提升,更需要改造。因为它能带来更大的人效。二、本地 IDEA
32、 环境提速 21 我们在“maven 构建提速”这一小节中讲了 amaven 在 CICD 平台上的解决方案,及它的效果与原理。在这,我们再讲讲 amaven 如何用在本地,特别是用在本地的IDEA 工具中。2.解决方案 amaven 要结合在本地的 IDEA 中使用也很方便。a)下载 amaven 最新版本 b)在本地解压,如目录/Users/userName/soft/amaven-3.5.0 c)设置 Maven home path:为了充分利用 mac 的内存资源,建议设置大些的内存:-Xms1538m-Xmx2048m-Xmn768m-XX:SurvivorRatio=10 d)在应
33、用目录下新建 amaven.config,并写入:二、本地 IDEA 环境提速 22 aether.collector.impl=bf amaven.write.log.to.file=true#amaven.log.dir=amaven.log.dir 如不设置,默认是用户目录。建议将这 amaven.config 提交到分支上,这样同一应用的其他研发同学就不用重复设置了。amaven.config 在用户目录中,则它对所有应用生效,如在应用目录中,则优先使用应用目录的,且只能此应用生效。e)重启 idea 后,点 import project 最后我们看看效果,对热点应用进行 import
34、 project 测试,用 maven 要 20 分钟左右,而用 amaven3.5.0 在 3 分钟左右,在命中缓存情况下最佳能到 1 分钟内。简单五步后,我们就不用再当“盲侠”了,在本地可以流畅地编码与跑单元测试。除了在 IDEA 中使用 amaven 的依赖分析能力外,在本地通过命令行来运行 mvn compile 或 dependency:tree,也完全兼容 apache-maven 的。二、本地 IDEA 环境提速 23 3.原理 IDEA 是如何调用 maven 的依赖分析方法的?在 IDEA 的源码文件:https:/ 中 979 行,调用了 dependencyResolve
35、r.resolve(resolution)方法:dependencyResolver 就是通过 maven home path 指定的 maven 目录中的DefaultProjectDependenciesResolver.java。而 DefaultProjectDependenciesResolver.resolve()方法就是依赖分析的入口。IDEA 主要用了 maven 的依赖分析的能力,在“maven 构建提速”这一小节中,我们已经讲了一些 amaven 加速的原理,其中依赖算法从 DF 换到 BF,依赖下载优化,整个依赖树缓存,SNAPSHOT 缓存这些特性都是与依赖分析过程相关
36、,所以都能用在 IDEA 提速上,而依赖仓库 mirror 等因为在我们自己的本地一般不会删除.m2,所以不会有所体现。二、本地 IDEA 环境提速 24 amaven 可以在本地结合 IDEA 使用,也可以在 CICD 平台中使用,只是它们调用maven 的方法的方式不同或入口不同而已。但对于 maven 协议来说“灵魂”的还是依赖管理与依赖分析。三、docker 构建提速 25 三、docker 构建提速 1.背景 自从阿里巴巴集团容器化后,把构建镜像做为发布构建的一步后开发人员经常被镜像构建速度困扰,每天要发布很多次的应用体感尤其不好。为了让应用的镜像构建尽量的少,我们几年前已经按最佳实
37、践推荐每个应用要把镜像拆分成两部分,一部分是基础镜像,包含低频修改的部分。另一部分是应用镜像,包含高频修改的部分,比如应用的代码构建产物。但是很多应用按我们提供的最佳实践修改后,高频修改部分的构建速度依然不尽如人意。现在 CICD 平台和集团很多镜像构建场景用的还是 pouch 的前身。它只支持顺序构建,对多阶段并发构建的支持也不完整,我们设想的很多优化方法也因为它的技术过于老旧而无法实施。它中很多对低版本内核和富容器的支持也让一些镜像包含了一些现在运行时用不到的文件。为了跟上主流技术的发展,我们计划把CICD平台的构建工具升级到moby-buildkit,docker 的最新版本也计划把构建
38、切换到 moby-buildkit 了,这个也是业界的趋势。同时在 buildkit 基础上我们作了一些增强。2.增强 1)新语法 SYNC 我们先用增量的思想,相对于 COPY 增加了一个新语法 SYNC。我们分析 Java 应用高频构建部分的镜像构建场景,高频情况下只会执行 Dockerfile中的一个指令:COPY appName.tgz/home/appName/target/appName.tgz 三、docker 构建提速 26 发现大多数情况下 Java 应用每次构建虽然会生成一个新的 app.war 目录,但是里面的大部分 jar 文件都是从 maven 等仓库下载的,它们的创
39、建和修改时间虽然会变化但是内容的都是没有变化的。对于一个 1G 大小的 war,每次发布变化的文件平均也就三十多个,大小加起来 2-3 M,但是由于这个 appName.war 目录是全新生成的,这个 copy 指令每次都需要全新执行,如果全部拷贝,对于稍微大点的应用这一层就占有 1G 大小的空间,镜像的 copy push pull 都需要处理很多重复的内容,消耗无谓的时间和空间。如果我们能做到定制 dockerfile 中的 copy 指令,拷贝时像 Linux 上面的 rsync 一样只做增量 copy 的话,构建速度、上传速度、增量下载速度、镜像的占用的磁盘空间都能得到很好的优化。因为
40、 moby-buildkit 的代码架构分层比较好,我们基于dockerfile 前端定制了内部的 SYNC 指令。我们扫描到 SYNC 语法时,会在前端生成原生的两个指令,一个是从基线镜像中 link拷贝原来那个目录(COPY),另一个是把两个目录做比较(DIFF),把有变化的文件和删除的文件在新的一层上面生效,这样在基线没有变化的情况下,就做到了高频构建每次只拷贝上传下载几十个文件仅几兆内容的这一层。而用户要修改的,只是将原来的 COPY 语法修改成 SYNC 就行了。如将:COPY appName.tgz/home/admin/appName/target/appName.tgz 修改为
41、:SYNC appName.dir/home/admin/appName/target/appName.war 我们再来看看 SYNC 的效果。集团最核心的热点应用 A 切换到 moby-buildkit 以及我们的 sync 指令后 90 分位镜像构建速度已经从 140 秒左右降低到 80 秒左右。三、docker 构建提速 27 2)none-gzip 实现 为了让moby-buildkit能在CICD平台上面用起来,首先要把none-gzip支持起来。这个需求在docker社区也有很多讨论:https:/ gzip 会导致 90%的时间都花在压缩和解压缩上面,构建和下载时间会加倍,发布环
42、境拉镜像的时候主机上一些 CPU 也会被 gzip解压打满,影响同主机其它容器的运行。虽然 none-gzip 后,CPU 不会高,但会让上传下载等传输过程变慢,因为文件不压缩变大了。但相对于 CPU 资源来说,内网情况下带宽资源不是瓶颈。只需要在上传镜像层时按配置跳过 gzip 逻辑去掉,并把镜像层的 MediaType 从:application/vnd.docker.image.rootfs.diff.tar.gzip 改成:application/vnd.docker.image.rootfs.diff.tar 三、docker 构建提速 28 就可以在内网环境下充分提速了。3)单层内
43、并发下载 在 CICD 过程中,即使是同一个应用的构建,也可能会被调度到不同的编译机上。即使构建调度有一定的亲和性。为了让新构建机,或应用换构建机后能快速拉取到基础镜像,由于我们以前的最佳实践是要求用户把镜像分成两个(基础镜像与应用镜像)。换编译机后需要在新的编译机上面把基础镜像拉下来,而基础镜像一般单层就有超过 1G 大小的,多层并发拉取对于单层特别大的镜像已经没有效果。所以我们在层内并发拉取的基础上,还增加了同一层镜像的并发拉取,让拉镜像的速度提升了 4 倍左右。默认每 100M 一个分片,用户也可以通过参数来设置分片大小。当然实现这层内并发下载是有前提的,即镜像的存储需要支持分段下载。因
44、为我们公司是用了阿里云的 OSS 来存储 docker 镜像,它有很好的分段下载或多线程下载的性能。4)无中心 P2P 下载 现在都是用 containerd 中的 content store 来存储镜像原始数据,也就是说每个节点本身就存储了一个镜像的所有原始数据 manifest 和 layers。所以如果多个相邻的节点,都需要拉镜像的话,可以先看到中心目录服务器上查看邻居节点上面是否已经有这个镜像了。如果有的话就可以直接从邻居节点拉这个镜像,而不需要走镜像仓库去取镜像 layer,manifest 数据还必须从仓库获取是为了防止镜像名对应的数据已经发生了变化了,只要取到 manifest
45、后其它的 layer 数据都可以从相邻的节点获取,每个节点可以只在每一层下载后的五分钟内(时间可配置)提供共享服务,这样大概率还能用到本地 page cache,而不用真正读磁盘。三、docker 构建提速 29 中心 OSS 服务总共只能提供最多 20G 的带宽,从历史拉镜像数据能看到每个节点的下载速度都很难超过 30M,但是我们现在每个节点都是 50G 网络,节点相互之间共享镜像层数据可以充分利用到节点本地的 50G 网络带宽,当然为了不影响其它服务,我们把镜像共享的带宽控制在 200M 以下。5)镜像 ONBUILD 支持 社区的 moby-buidkit 已经支持了新的 schema2
46、 格式的镜像的 ONBUILD 了,但是集团内部还有很多应用 FROM 的基础镜像是 schema1 格式的基础镜像,这些基础镜像中很多都很巧妙的用了一些 ONBUILD 指令来减少 FROM 它的 Dockerfile 中的公共构建指令。如果不能解析 schema1 格式的镜像,这部分应用的构建虽然会成功,但是其实很多应该执行的指令并没有执行,对于这个能力缺失,我们在内部补上的同时也把这些修改回馈给了社区(https:/ 提速 30 四、JDK 提速 1.AppCDS 1)现状 CDS(Class Data Sharing)在 Oracle JDK1.5 被首次引入,在 Oracle JDK
47、8u40 中引入了 AppCDS,支持 JDK 以外的类,但是作为商业特性提供。随后 Oracle 将 AppCDS贡献给了社区,在 JDK10 中 CDS 逐渐完善,也支持了用户自定义类加载器(又称AppCDS v2)。目前 CDS 在阿里的落地情况:热点应用 A 使用 CDS 减少了 10 秒启动时间。云产品 SAE 和 FC 在使用 Dragonwell11 时开启 CDS、AOT 等特性加速启动。经过十年的发展,CDS 已经发展为一项成熟的技术。但是很容易令人不解的是 CDS不管在阿里的业务还是业界(即便是 AWS Lambda)都没能被大规模使用。关键原因有两个。a)AppCDS 在
48、实践中效果不明显 jsa 中存储的 InstanceKlass 是对 class 文件解析的产物。对于 boot classloader(加载 jre/lib/rt.jar 下面的类的类加载器)和 system(app)类加载器(加载-classpath下面的类的类加载器),CDS 有内部机制可以跳过对 class 文件的读取,仅仅通过类名在 jsa 文件中匹配对应的数据结构。Java 语言还提供用户自定义类加载器(custom class loader)的机制,用户通过Override 自己的 Classloader.loadClass()查找类,AppCDS 在为 customer cla
49、ss loade 时加载类是需要经过如下步骤:调用用户定义的 Classloader.loadClass(),拿到 class byte stream 计算 class byte stream 的 checksum,与 jsa 中的同类名结构的 checksum 比较 四、JDK 提速 31 如果匹配成功则返回 jsa 中的 InstanceKlass,否则继续使用 slow path 解析class 文件 b)工程实践不友好 使用 AppCDS 需要如下步骤:针对当前版本在生产环境启动应用,收集 profiling 信息 基于 profiling 信息生成 jsa(java sahred a
50、rchive)dump 将 jsa 文件和应用本身打包在一起,发布到生产环境 由于这种 trace-replay 模式的复杂性,在 SAE 和 FC 云产品的落地都是通过发布流程的定制以及开发复杂的命令行工具来解决的。2)解决方案 针对上述的问题 1,在热点应用 A 上 CDS 配合 JarIndex 或者使用编译器团队开发的EagerAppCDS 特性(原理见 5.1.3.1)都能让 CDS 发挥最佳效果。经验证,在热点应用A已经使用JarIndex做优化的前提下进一步使用EagerAppCDS依然可以获得 15 秒左右的启动加速效果。3)原理 面向对象语言将对象(数据)和方法(对象上的操作