最近我们团队对DevOps流水线做了一次全面的性能优化,把一次完整的构建部署时间,从原来的40多分钟,优化到了8分钟,提升了5倍多的效率,效果非常明显。

今天这篇文章,就来记录一下我们这次流水线性能优化的实战过程,聊聊我们遇到了哪些问题,做了哪些优化,每个优化的效果如何,以及一些经验总结。希望能给正在做DevOps流水线优化的朋友一些参考和帮助。

一、优化前的状况

先说说优化前的状况。

我们团队有十几个微服务,用的是Jenkins做持续集成和持续部署,流水线的流程大概是这样的:代码提交之后,触发Jenkins构建,先拉取代码,然后编译打包,跑单元测试,跑集成测试,构建Docker镜像,推送镜像到镜像仓库,然后部署到测试环境,跑自动化测试,最后人工确认部署到生产环境。

这个流水线,功能上是没问题的,能满足我们的需求,但是速度太慢了,一次完整的构建部署,要40多分钟,有时候甚至要一个小时。速度慢带来了很多问题:

  1. 开发效率低,开发人员提交代码之后,要等很久才能看到构建结果,才能部署测试,影响开发节奏。
  2. 反馈慢,如果构建或者测试失败了,要等很久才能知道,这时候开发人员可能已经在做别的事情了,上下文切换成本高。
  3. 部署慢,紧急修复bug的时候,要等很久才能部署上线,影响问题修复的速度。
  4. 资源浪费,流水线运行时间长,占用Jenkins agent的时间长,资源利用率低,而且经常出现排队的情况,多个构建要等资源。

所以,我们决定对流水线做一次全面的性能优化,目标是把一次完整的构建部署时间降到10分钟以内。

二、第一步:瓶颈分析

优化的第一步,不是上来就改,而是先做瓶颈分析,找到流水线慢在哪里,然后针对性地优化。

我们用Jenkins的Pipeline Stage View,看了一下每个阶段的耗时,发现时间主要花在这几个地方:

  1. 代码拉取,每次都重新拉取完整的代码,耗时2到3分钟。
  2. 依赖下载,每次构建都重新下载所有的Maven依赖,耗时5到8分钟,这是最耗时的阶段之一。
  3. 编译打包,每次都全量编译,耗时3到5分钟。
  4. 单元测试和集成测试,每次都跑全部的测试用例,耗时8到10分钟,这也是最耗时的阶段之一。
  5. Docker镜像构建,每次都重新构建,而且没有用好缓存,耗时5到8分钟。
  6. 镜像推送,镜像很大,推送慢,耗时2到3分钟。
  7. 部署,部署到测试环境,然后等服务启动,跑自动化测试,耗时10到15分钟,这也是最耗时的阶段。

找到了瓶颈之后,我们就针对性地做优化,下面一一说说我们做了哪些优化,以及每个优化的效果。

三、优化一:代码拉取优化

代码拉取,原来我们每次构建都用的是git clone,重新拉取完整的代码仓库,我们的代码仓库比较大,有几个G的历史,所以每次拉取都要2到3分钟,很慢。

优化方法:

  1. 改用浅克隆(shallow clone),只拉取最近的一次提交,不拉取完整的历史,用git clone --depth 1,这样拉取的代码量小很多,速度快很多。
  2. 或者用Jenkins的wipe out workspace关闭,保留工作区,每次构建用git fetch增量更新,而不是重新克隆,这样只有第一次构建慢,后面的构建都是增量更新,速度很快。
  3. 对于大的二进制文件,不要放在Git仓库里,用Git LFS或者专门的对象存储,不然Git仓库会越来越大,拉取越来越慢。

我们采用的是第二种方法,关闭了wipe out workspace,保留工作区,每次构建增量更新代码,这样代码拉取的时间从2到3分钟,降到了十几秒,效果非常明显。

不过要注意,保留工作区的时候,要注意清理工作区里的临时文件和构建产物,避免上一次构建的产物影响这一次构建,导致构建结果不可靠。可以在构建开始的时候,加一个清理的步骤,清理掉target、build、node_modules等目录,然后再拉取代码编译。

四、优化二:依赖下载优化

依赖下载,是我们原来最耗时的阶段之一,每次构建都重新下载所有的Maven依赖,我们的项目依赖比较多,有几百个jar包,每次下载都要5到8分钟,很慢,而且如果网络不好,还经常下载失败,导致构建失败。

优化方法:

  1. 搭建私有Maven仓库,比如Nexus或者Artifactory,把依赖缓存到私有仓库里,构建的时候从私有仓库下载,而不是从中央仓库下载,这样速度快很多,而且更稳定,不会因为外网的问题导致下载失败。
  2. 利用Jenkins agent的本地缓存,把Maven的本地仓库(~/.m2/repository)挂载到Jenkins agent上,并且持久化,这样第一次构建下载的依赖,后面的构建都可以复用,不用重新下载。
  3. 对于Docker构建,也可以把Maven本地仓库挂载到构建容器里,或者用Docker的多阶段构建,把依赖下载和编译分开,利用Docker的缓存层,依赖不变的话,就不用重新下载。
  4. 定期清理本地缓存里的旧版本依赖,避免缓存越来越大,占用太多磁盘空间。

我们采用了前三种方法,搭建了私有Nexus仓库,并且把Maven本地仓库持久化到Jenkins agent上,Docker构建的时候也挂载了Maven本地仓库。优化之后,依赖下载的时间从5到8分钟,降到了1分钟以内,大部分时候都是直接用缓存,几乎不用下载,效果非常明显。

这里要注意,依赖缓存虽然能提高速度,但是也要注意缓存的一致性,有时候缓存可能会有问题,比如依赖损坏了,或者版本冲突了,这时候需要清理缓存重新下载。所以,最好保留一个清理缓存的选项,遇到缓存问题的时候,可以手动清理缓存重新构建。

五、优化三:编译打包优化

编译打包,原来我们每次都全量编译,不管代码有没有改动,都重新编译所有的代码,耗时3到5分钟。

优化方法:

  1. 利用编译工具的增量编译功能,比如Maven的增量编译,Gradle的增量编译,只编译改动过的代码,不用全量编译,这样速度快很多。
  2. 把编译和测试分开,编译的时候不跑测试,测试单独一个阶段,这样编译的速度更快,而且如果编译失败了,就不用跑测试了,节省时间。
  3. 对于多模块的项目,可以用并行编译,同时编译多个模块,提高编译速度,Maven和Gradle都支持并行编译。
  4. 优化编译参数,比如增加JVM的内存,调整垃圾回收策略,提高编译的速度。
  5. 对于前端项目,用webpack的缓存,或者用更快的构建工具,比如esbuild、vite,提高前端构建的速度。

我们采用了前三种方法,开启了Maven的增量编译和并行编译,把编译和测试分开。优化之后,编译打包的时间从3到5分钟,降到了1分钟左右,大部分时候都是增量编译,只编译改动过的模块,速度很快。

这里要注意,增量编译虽然快,但是有时候可能会有问题,比如增量编译不彻底,导致旧的class文件没有被更新,出现一些奇怪的问题。所以,重要的构建,比如发布到生产环境的构建,最好还是全量编译,保证构建结果的可靠性,日常开发的构建可以用增量编译,提高速度。

六、优化四:测试速度优化

测试,是我们原来最耗时的阶段之一,每次构建都跑全部的单元测试和集成测试,我们的项目测试用例比较多,有几千个,每次跑都要8到10分钟,很慢。

优化方法:

  1. 把测试分层,分为单元测试、集成测试、端到端测试,单元测试在每次构建的时候都跑,集成测试和端到端测试可以在夜间构建或者发布前跑,不用每次构建都跑,这样日常构建的速度就快很多。
  2. 并行跑测试,JUnit和TestNG都支持并行测试,可以同时跑多个测试用例,提高测试的速度。
  3. 利用测试的增量执行,只跑改动过的代码相关的测试用例,不用跑全部的测试用例,很多测试框架和CI工具都支持这个功能。
  4. 优化测试用例,减少不必要的等待和休眠,用mock代替真实的依赖,比如数据库、第三方接口,提高测试的速度,也让测试更稳定。
  5. 把耗时的测试,比如性能测试、压力测试,放到专门的流水线,不用每次构建都跑。
  6. 定期清理无用的、过时的测试用例,避免测试用例越来越多,跑的时间越来越长。

我们采用了前四种方法,把测试分层,日常构建只跑单元测试,集成测试和端到端测试在夜间构建跑,开启了并行测试,并且用mock代替了真实的数据库和第三方接口。优化之后,日常构建的测试时间从8到10分钟,降到了2分钟左右,效果非常明显。

这里要注意,测试分层虽然能提高速度,但是也要保证测试的覆盖率和质量,不能为了速度就减少测试,不然会导致bug漏到线上。单元测试要保证核心逻辑的覆盖率,集成测试和端到端测试虽然不用每次都跑,但是也要定期跑,比如每天晚上跑一次,或者发布前跑一次,保证系统的质量。

七、优化五:Docker镜像构建优化

Docker镜像构建,原来也是很耗时的阶段,每次都重新构建,而且没有用好缓存,耗时5到8分钟。

优化方法:

  1. 用好Docker的缓存,把变化少的层放在前面,变化多的层放在后面,比如把依赖下载放在前面,把代码编译放在后面,这样依赖不变的话,前面的层就可以用缓存,不用重新构建。
  2. 用多阶段构建(multi-stage build),把构建环境和运行环境分开,构建环境用完整的镜像,包含编译工具和依赖,运行环境用精简的镜像,只包含运行需要的文件,这样最终的镜像很小,推送和拉取都快,也更安全。
  3. 精简基础镜像,用alpine或者distroless作为基础镜像,不要用完整的ubuntu或者centos,这样镜像小很多,构建、推送、拉取都快。
  4. 合并RUN指令,减少镜像的层数,层数越少,构建越快,镜像越小。
  5. 用.dockerignore文件,排除不需要的文件和目录,比如target、build、node_modules、.git等,减少构建上下文的大小,提高构建速度。
  6. 利用构建缓存,比如用BuildKit的缓存挂载,或者用远程缓存,把构建缓存持久化,跨构建复用缓存。
  7. 对于大的依赖,比如JDK、Node.js,可以预先打到基础镜像里,不用每次构建都下载。

我们采用了前五种方法,优化了Dockerfile,用好缓存,用多阶段构建,用alpine基础镜像,合并RUN指令,加了.dockerignore。优化之后,Docker镜像构建的时间从5到8分钟,降到了2分钟左右,大部分时候都是用缓存,只有代码变化的层需要重新构建,速度很快。而且镜像的大小也从原来的1G多,降到了200多M,推送和拉取都快了很多。

这里要注意,Docker缓存虽然能提高速度,但是也要注意缓存的一致性,有时候缓存可能会导致问题,比如依赖更新了,但是缓存没有失效,用了旧的依赖。所以,重要的构建,比如发布到生产环境的构建,可以加--no-cache参数,强制重新构建,保证构建结果的可靠性,日常开发的构建可以用缓存,提高速度。

八、优化六:镜像推送和拉取优化

镜像推送,原来我们的镜像很大,1G多,推送到镜像仓库要2到3分钟,拉取也要2到3分钟,很慢。

优化方法:

  1. 精简镜像大小,用多阶段构建和精简的基础镜像,把镜像做小,这样推送和拉取都快,我们的镜像从1G多降到了200多M,推送和拉取时间都降到了几十秒。
  2. 搭建私有镜像仓库,比如Harbor,放在内网,推送和拉取的速度比公网快很多,而且更稳定,更安全。
  3. 利用镜像层的缓存,镜像推送和拉取都是分层的,已经存在的层不用重复推送和拉取,所以如果镜像变化不大,只有少数层变化,推送和拉取都很快。
  4. 用镜像加速器,拉取公共镜像的时候,用国内的镜像加速器,比如阿里云、网易云的镜像加速器,提高拉取速度。
  5. 定期清理镜像仓库里的旧镜像、无用镜像,避免镜像仓库越来越大,影响性能。

我们采用了前三种方法,精简了镜像大小,搭建了内网的Harbor镜像仓库,利用了镜像层的缓存。优化之后,镜像推送和拉取的时间都从2到3分钟,降到了几十秒,效果很明显。

九、优化七:部署和自动化测试优化

部署和自动化测试,是我们原来最耗时的阶段,部署到测试环境,然后等服务启动,跑自动化测试,要10到15分钟。

优化方法:

  1. 优化部署策略,用滚动更新或者蓝绿部署,不用等所有实例都启动完成,只要有一个实例启动成功,就可以开始测试,不用等全部启动。
  2. 优化服务启动速度,减少服务的启动时间,比如减少初始化的工作,懒加载一些组件,优化JVM的启动参数,用AppCDS或者GraalVM Native Image提高启动速度,这样服务启动快了,等待的时间就少了。
  3. 把健康检查和就绪检查分开,健康检查通过了就说明服务启动了,就绪检查通过了就说明服务可以接收流量了,合理设置这两个检查的参数,不要等太久。
  4. 优化自动化测试,只跑核心的冒烟测试,不用跑全部的自动化测试,全部的自动化测试可以在夜间构建跑,这样部署后的测试时间就短很多。
  5. 并行部署和测试,多个服务可以同时部署,同时测试,不用一个一个来,提高效率。
  6. 用容器的预热,把常用的镜像预先拉取到部署的节点上,不用部署的时候再拉取,节省拉取镜像的时间。

我们采用了前四种方法,优化了部署策略,优化了服务启动速度,合理设置了健康检查和就绪检查,部署后只跑核心的冒烟测试。优化之后,部署和自动化测试的时间从10到15分钟,降到了2分钟左右,效果非常明显。

这里要注意,部署后的测试虽然可以只跑冒烟测试,但是也要保证核心功能的覆盖,不能为了速度就不测试,不然bug会漏到测试环境甚至生产环境。冒烟测试要覆盖核心的业务流程,保证基本功能正常,更全面的测试可以在夜间构建跑,这样既保证了速度,又保证了质量。

十、优化八:并行化和流水线组织优化

除了上面这些具体阶段的优化,我们还对整个流水线的组织做了优化,用并行化提高效率。

优化方法:

  1. 把可以并行的阶段并行执行,比如单元测试和代码检查可以并行,前端构建和后端构建可以并行,不用一个一个串行执行。
  2. 多模块项目,每个模块可以并行构建和测试,不用串行。
  3. 把流水线分成多条,比如日常开发的构建流水线,只跑编译和单元测试,速度快;发布的构建流水线,跑完整的测试和部署,更全面;夜间构建流水线,跑全量的测试、代码检查、安全扫描等。这样不同的场景用不同的流水线,不用每次都跑完整的流水线,提高效率。
  4. 优化Jenkins agent的配置,增加agent的数量,提高并行度,避免构建排队;给agent分配足够的CPU和内存,提高构建速度。
  5. 用Jenkins的pipeline插件,把流水线写成代码,版本控制,方便优化和维护。

我们采用了这些方法,把可以并行的阶段并行执行,把流水线分成了日常构建、发布构建、夜间构建三条,增加了Jenkins agent的数量和配置。优化之后,整个流水线的并行度提高了,排队的情况少了,整体的效率又提升了不少。

十一、优化效果

经过上面这些优化,我们的流水线速度有了质的提升,一次完整的日常构建部署时间,从原来的40多分钟,降到了8分钟左右,提升了5倍多的效率。

具体每个阶段的优化前后对比:

  • 代码拉取:2-3分钟 → 十几秒
  • 依赖下载:5-8分钟 → 1分钟以内
  • 编译打包:3-5分钟 → 1分钟左右
  • 单元测试:8-10分钟 → 2分钟左右
  • Docker镜像构建:5-8分钟 → 2分钟左右
  • 镜像推送:2-3分钟 → 几十秒
  • 部署和测试:10-15分钟 → 2分钟左右

总的时间,从40多分钟降到了8分钟左右,效果非常明显。

优化之后,带来的好处也很多:

  1. 开发效率提高了,开发人员提交代码之后,很快就能看到构建结果,很快就能部署测试,开发节奏更快了。
  2. 反馈更快了,如果构建或者测试失败了,很快就能知道,开发人员还在上下文中,修复问题的速度更快了。
  3. 部署更快了,紧急修复bug的时候,很快就能部署上线,问题修复的速度更快了。
  4. 资源利用率提高了,流水线运行时间短了,占用agent的时间短了,同样的agent能处理更多的构建,排队的情况少了。
  5. 团队的满意度提高了,不用再漫长地等待构建了,大家都很开心。

十二、一些经验总结

最后,总结一下这次流水线性能优化的一些经验,希望能给大家一些参考。

1. 先做瓶颈分析,再优化

优化的第一步,一定是先做瓶颈分析,找到慢在哪里,然后针对性地优化,不要上来就盲目优化,不然可能花了很多时间,优化的不是瓶颈,效果不明显。用工具看一下每个阶段的耗时,找到最耗时的几个阶段,优先优化这些阶段,投入产出比最高。

2. 缓存是提高速度的利器

这次优化,最大的功臣就是缓存,代码缓存、依赖缓存、编译缓存、Docker镜像缓存、测试缓存,各种缓存,能极大地提高构建速度。因为大部分构建,变化的只是少数代码,大部分东西都是不变的,用缓存就能避免重复工作,大大提高速度。

但是,缓存也是一把双刃剑,用不好会导致构建结果不可靠,所以要注意缓存的一致性,重要的构建可以禁用缓存,保证可靠性,日常构建用缓存提高速度。

3. 分层和并行是提高效率的关键

把测试分层,把流水线分层,不同的场景用不同的流水线,不用每次都跑完整的流程,能大大提高日常构建的速度。把可以并行的阶段并行执行,提高并行度,也能大大提高效率。

4. 不要为了速度牺牲质量

优化速度的同时,一定要保证质量,不能为了速度就减少测试,就跳过检查,不然bug会漏到线上,得不偿失。要在速度和质量之间找到平衡,日常构建可以只跑核心的测试和检查,保证速度,发布构建和夜间构建跑全量的测试和检查,保证质量。

5. 优化是一个持续的过程

流水线的优化不是一次就能完成的,是一个持续的过程,随着项目的发展,代码越来越多,依赖越来越多,测试越来越多,流水线可能又会变慢,所以要定期回顾流水线的性能,持续优化,保持流水线的高效。

6. 工具很重要,但是思想更重要

好的工具能提高效率,比如好的CI工具、好的镜像仓库、好的依赖仓库,但是更重要的是思想,比如持续集成、持续交付、自动化、缓存、分层、并行这些思想,有了这些思想,用什么工具都能把流水线做好,没有这些思想,再好的工具也用不好。

十三、写在最后

这次DevOps流水线的性能优化,从40多分钟降到8分钟,效果非常明显,团队的开发效率和满意度都有了很大的提升。这次优化也让我深刻认识到,DevOps不是搭一套流水线就完事了,而是要持续优化,不断提高效率,让开发人员从繁琐的构建部署中解放出来,把更多的时间花在创造价值上。

如果你也在做DevOps流水线,也觉得流水线太慢了,希望这篇文章能给你一些参考和启发。流水线的优化没有那么难,只要找到瓶颈,针对性地优化,用好缓存、分层、并行这些方法,就能取得很好的效果。

当然,每个团队的情况不一样,技术栈不一样,流水线也不一样,具体的优化方法可能也不一样,但是优化的思路是相通的,先分析瓶颈,再针对性优化,持续改进,总能把流水线优化好。

最后,希望大家都能拥有一条高效、稳定、可靠的DevOps流水线,让开发更轻松,让交付更快速,让软件质量更高。如果这篇文章对你有帮助,欢迎分享给更多的人。