现如今,CICD 的理念越来越深入人心,我供职过的公司无一例外的都采用了 CICD 来加强软件质量。不过因为供职过的公司规模大小、行业属性不尽相同。所以落地CICD的方式也不尽相同,特别是在老东家的时候,参与了 Mono repo 的建设与 CICD 系统的开发。所以自觉的对 CICD 有所深入了解可以总结一下。

一个失败的 CICD 成长史

你的失败命中注定

当你的项目刚刚开始时,一切看起来都很美好。编译测试都可以快速的完成,整个项目也可以只有你一个人,这时候 CICD 也可以做的非常简单。于是乎,你觉得部署jenkins/ teamcity这类需要自己部署的CICD,还需要自己的机器资源。于是乎,你不加思索得使用了 github action 这类的免费CICD。一切看上去都非常美好。

随着的项目日趋完善,你写了越来越多的测试。所以你需要在这些测试放入到 CI 里去运行。单元测试可能还行,直接运行就可以了。但是对于集成测试,你可能需要在里面运行一些逻辑,启动一些服务来完成测试工作。于是你就把这些逻辑直接写入到 CI配置中。不过从目前来看,也还满意,起码代码与执行逻辑分离。一切都还顺风顺水。

你的项目发展的很好,越来越多的同学加入其中。你的编译速度也越来越慢,于是乎你开启了 cache 保持机制来加速。(注意以 github action 来说,你只能缓存 10 GB 的缓存,且超过7天没有访问,会被自动删除,其次github action是基于branch去缓存的,所以你有多个branch合并之后,几个branch的缓存是很难给复用的,所以他只是适用于一些中小型项目,对于大型项目而言,提速十分有限。)当时cache 开启之后你的速度得到了一点提升。

随着越来越多的开发任务在展开中,你免费资源的CI 已经越来越不能满足你的需要。开发们花了很长时间来等待 CI 的开始。在大家强烈的要求下,你权衡了一下成本。于是乎,你把 CI 迁移到jenkins,将其部署到自己的 IDC 机房内,你的资源问题初步得到了解决。反正资源不够就采购机器进行 join 到jenkins集群内。而且你为了让 jenkins 更好的利用好集群环境,将其部署到了 kubernetes 上。而且迁移过程中,你把老的执行脚本,迁移到了jenkins groovy 里面。

随着测试任务的增加,也越来越复杂。你的脚本写的越来越复杂。例如你的不同 Release Branch 依赖不同于linter版本,于是乎,你只能在脚本里为不同的 release branch 安装不同版本的 linter 工具。开发的 unstable test 越来越多,资源消耗越来越多,你很苦恼。于是,你想到了可以利用 commitid,让一些通过测试的 task 可以直接 pass,让之后没有通过的 task 继续运行。但是你会使用 Jenkins,但是不会对其进行开发,于是乎,你在脚本里做了一些hack脚本,自动push 任务的源信息到远端的服务上,下次运行任务的时候再检查一下任务是否运行过。你的执行脚本越来越复杂,开发看了你的脚本,表示很难帮你添加一下东西。外部的社区同学就更不知道怎么来帮助你,来完善你的测试效果。

开发对你的抱怨越来越多,你的cache没法像第三方那样托管,于是你研究了一个打包 cache 结果上传到 nfs 或者 s3 等储存里,然后之后再复用。结果你发现这样的花费高,性能提升还提升不了多少。而且 nfs 或者自己搭建的 ceph,还很不稳定,你还要想办法给他们搞 SRE。开发们对于质量的要求也越来越高,各种测试越来越花哨。开发们总希望测试能在merge之前完成,但是有些测试实在太长了,或者资源开销太大,你只能劝说开发把测试移到 merge 之后运行。于是乎,你发现那些 merge 的 PR 里会有一些把merge之后的任务跑跪,于是你又要追着开发,把这些测试修复。

这时你的老板看了有点生气,立了一个flag,说要提升CI效能。所以先让你搞一个CI的 dashboard,然后我们优化一波,来观察任务的耗时情况的变化。结果你发现 Jenkins 的任务太多了,你都没办法给他们一一打点,你选择放弃。而且任务如此之多,你也没什么办法来提升效率。只能建议机器不太行,建议加机器,但是 idc 的机器有限,只能选择上云。结果上云之后你发现,业务对CI的消耗也越来越多,但是你的机器扩容得越来越频繁。而且随着集群规模扩大,故障也越来越多,你对 jenkins的熟悉程度只是使用,解决他的问题让你非常头疼。我们对你的信任也越来越低,最后你只能跑路。

如何做好一个 CI

看完上面的故事,你有没有看到自己的影子呢,希望没有。可以说,他们的失败命中注定。

首先,我们要有一个意识,就是 CI 只是一个执行器。他来执行我们脚本,收集我们的日志、覆盖率数据和一些执行情况的工具而已。所以对于一个执行器而已,我们对于他的脚本要足够简单,理想情况下,我们将环境的定义放入到 Dockerfile 中,将逻辑的定义放入到自己repo的脚本里(这个脚本由你喜欢的语言来写)。最后交付给CI的只是一个环境镜像和运行脚本的启动命令而已。

这样的CI理念会带来极大的好处。开发可以很方便的将新的task加入到CI里。如果之后出现问题,开发也能直接在本地复现问题。CI 与研发同学只要保持一些约定即可。比如CI约定如何切入到开发的目录,以及运行过后的日志与数据如何回收,一般来说日志一般都是通过 stdout 来输出,而对于有其他的日志或数据要收集的,一般会有一个约定的目录,带任务结束后,直接上传到储存中,待之后渲染展示或者下载使用。

所以对于一个 CI/CD 的维护部门,其工作的主要任务应该就是维护好 CI/CD 的平台,让开发可以快速接入,同时能让整个 CI/CD 的高效可靠的运行。而对于开发如何运行 CI,指起到指导的作用,主导权还是在开发手上。CI/CD 部门只起到教育提醒的作用,让开发以正确的姿势来使用CI。

如何面对大型项目,提升CI效率

上面我说的是 CI/CD 设计的核心思想,把握正确的设计思想,才能让部门抽出主要精力,来提升整个 CI 的工作效率。而如今大型开源项目越来越多,如何高效的运行CI,也显得越来越重要。按我的观察,里面最重要的就是细节。

正确设计 pipeline

对于大型项目而言,设计合理的 pipeline 才是之后一切好的开始。

我观察到有一种错误的设计pipeline的思想,就是把 CI 的任务拆的足够的细,然后一起并行,更有甚者,把一个测试一份为两,一起并行。但是这样的后果就是一个push触发大量任务,对资源的消耗十分巨大。但是你细细考虑,如果一个编译、linter 也不能通过的PR,你是否还值得为他运行单元测试、集成测试呢?

好的 pipeline 应该是一个任务并发慢慢提高的过程。刚开始应该,运行一些基础简单的task,例如编译和linter,来过滤掉开发的低级失误。然后再运行一些速度比较快的测试,比如说单元测试,其后在按自己的情况来,逐渐提高并行任务的数量。

利用好 cache

利用好 cache 是一个 CI 是否高效的重中之重。利用好 cache,不但可以加速编译的效率,还能提升测试、linter的效率。换个词来形容的话,就是增量。对于如何构建高效的cache系统,我个人的经验是做好两个cache,一个是 local cache,一个是 remote cache。 local remote 存放在本地,犹如一级缓存,快但是容量并不大。而他的实现如果 Kubernetes 体系的话,就是利用他 pvc 与机器的tag,让你的task 尽可能的使用同一,但是里面就有复杂的调度逻辑需要支持。其实更方便的是 master/slave CI体系中,slave 机器常驻在此,所有运行过的任务,能有cache。(所以这里也看出了,CI 系统不一定是要构建于 K8S 体系之上,符合自己情况的架构,才是好架构)而 remote cache 是 local cache 的补充,我们的CI系统机器数量是有限的,对于大量任务,我们不一定能让其满足每次都能利用上之前的local cache,但是我们可以让他利用好之前的 remote cache。

这里要指出一个坏的设计,就是有人会在任务运行结束后,打包cache文件上传,然后在下次使用的时候,下载解压后复用。这种模式只适用于小型项目,大型项目cache可以达到10GB以后,每次任务前后都要对其进行处理,其会耗费大量时间。所以正确的思路,是运用一个工具,例如bazel 或者 c++ 体系里的 ccache 之类的。他们可以理解编译、测试的环节,可以做到一边编译(尽可能下载cache,如果不能才去编译),一边上传 cache。异步的执行才能达到最高的效率。

对于 remote cache 的存储而已,并不需要 s3、对象存储之类的。一般而已,这类存储都会考虑高可用性,将上传的资源做多副本,和纠错码编码。所以速度并不大。而对于 remote cache 而言,cache 本身就具有很强的时效性,很多 cache 可能上传之后,就被其他PR更新,而失效。所以 remote cache 的存储可以舍弃那些高可用性的设计,单纯追求速度,和扩展能力。我在bazel上 remote cache 的实践,是利用 nginx 做一个一致性 cache,然后底层写一个所有写入直接先写内存,内存写入就算成功,后台慢慢落盘,读先读内存,没有再读磁盘的cache系统,就可以达到很好的性能要求。

对 CI 需要有掌控力

其次我们前期使用CI确实只要考虑使用,但是等到后期,面对大量定制化的需要,你需要对 CI 有一定的掌控力,而不是单纯的使用。你可以还是对为什么要有掌控力还感到困惑。那我来写一个需求,你来看看,你是否能简单实现。

1、在大量PR需要合并的情况下,我们一般上会有一个 merge queue 来一个个跑测试,来最终 merge 我们的 PR ,但是这个效率是低下的。所以我们能不能将一段时间内要merge的PR收集起来,先合并成一个,然后来跑测试,如果通过就一起merge。

2、如果我修改的只是文档,那我还有必要运行编译,测试的种种吗?所以是否可以在task配置里规定,PR修改代码文件,才能运行测试项

3、对于外部的 contributor,我是否可以暂缓执行 CI,待研发来 review 其 PR 合理后,发送 ok-to-test 让其执行。

上述三个需求均来自k8s的 prow里,prow是k8s中高度定制的CI系统,实现了很多通用 CICD 不具备的一些能力。从中可以看到,对 CI 系统具有掌控能力,能带来效率上的提升。当然拥有这种能力后,也能方便对其维护,提高CI可用性。

来个完美 CI 需求单

  • branch merge 能力,对标 prow 中的 tide
  • 指定修改文件后缀,符合要求的才能执行
  • 机器人能力,并且通过机器人命令来,控制pipeline的单个或一组task的执行与否,或者是另外执行特定 pipeline
  • pipeline 控制有特定 api,可以使机器人能力微服务化,方便用户按照自己的要求扩展能力
  • 提醒能力,任务中断或成功,可以对研发发送消息,进行提示
  • 统计打点能力,可以为之后分析出任务执行的时长、时间点,来为之后的资源调度,提供数据支持
  • 调度能力,可以在配置中指定运行集群,可以使任务在任意机房执行。这类似于 prow 配置中的 cluster
  • 对于 merge 之后的任务,我们需要让其能经常性运行,并且提供面板观察运行的情况,对错误可以进行聚合统计。切不能搞出 daliyci,这应该是一个 always CI。这里可以参考k8s社区的 testgridTriage

总结

上文中,我举了大量来自于k8s社区的例子。其实CICD的实现,已经不是什么大的秘密,所以对于我们的日常工作中,应该多多学习,别人好的实践,为我即用,努力提高效率。