对我负责过半年的某厂内部CI服务的经验总结。

某测试服务的底层就是一个自研的CI引擎,而绝大多数的CI引擎都是:用户给予一些命令以及这些命令执行顺序的编排定义,然后在一台或多台执行机按要求去执行这些命令。

一般来说,CI引擎的实现分为负责执行顺序编排的中心调度服务,和实际执行命令的执行机worker,这里主要分享下worker这个层面,在我接手半年时间里的一些优化思路。

背景

业界对于worker的实现,可以有以下几个维度的区分:

  1. worker所处的位置:
    1. 直接运行在一个vm里
    2. 运行在容器里
  2. worker执行命令的方式
    1. 直接在所处环境里执行命令
    2. 通过拉起一个其他计算资源,在那个计算资源里去执行命令(例如拉起一个容器)
  3. 环境本身的管理
    1. 环境长期不回收,在不同命令之间复用。
    2. 环境在执行命令结束后就回收,不同命令之间看到的环境都是空白的。

当然这些区分都不是绝对的,有不少场景都是混合存在的,例如Github Actions Runner就即可以直接在所处的vm里执行命令,也可以拉起一个容器来执行命令。

某测试服务的worker,由于历wo史bu选zhi型dao的原因,以及某厂容器化的趋势要求,选择的是将worker本身运行在一个容器中来执行命令。在我接手时,选择的是在不同命令之间,环境直接复用,但会以小时为间隔,定期删除再创建容器的方式。

虽然不少使用自建jenkins的用户,实际都是选择了环境长期不回收直接复用的方案,但对于一个提供公共资源池的服务来说,环境直接复用的问题是很多的,前一个用户把系统文件删了几个或者改了hosts,后一个用户的命令就要莫名其妙的挂掉,对于CI的稳定性来说是极为不利的。

某基于docker swarm的一层调度系统上的优化

业界对于环境回收的方式,在容器这个实现方案上,基本都是把容器删掉再创建的路子,但内部用的镜像都比较庞大,最大的镜像达到了9GB; 加上接手时,使用的某二层调度和某基于docker swarm的一层调度已经处于无人维护的状态,扩容的稳定性没有保障,导致如果环境回收了,就很难及时的再创建出来,因此无奈的选择了环境复用定时回收的方案。

但思考下,我们的本质需求其实是重置环境到初始状态,并不是真的需要走一遍完整的扩容流程,而重置容器内的环境主要指以下两点:

  1. 进程全部杀掉
  2. 磁盘上文件的改动全部reset

刚好,某一层调度为发布场景提供的upgrade接口,底层实现就是停止已有容器+创建一个新的容器,如果镜像不变的话,是刚好能满足需求的。

  扩容 upgrade
1.调度器选择宿主机 必须 无需
2.分配网络存储资源 必须 无需
3.拉取镜像 必须 镜像不变时无需
4.启动新容器 必须 必须

从上面这个表格能看出,如果将流程从扩容一个容器,改成将一个容器做一次镜像不变的原地upgrade,由于环节变少,将极大的提高成功率,从实际线上的数据看,原地upgrade的耗时一般在30秒左右,足以支撑每次执行完命令就立刻reset一次的产品设计,让每次执行命令都可以有一个尽可能干净的环境。

k8s上的优化

环境重置

虽然在某docker swarm调度系统上做了一些优化,极大的改善了worker所在机器的环境一致性,但容器目前的发展趋势就是从docker swarm向k8s发展,因此worker集群迁移到基于k8s的集群也是一条必经之路。

鉴于我就在相关k8s团队负责存储和网络方案,扩容链路上存储和网络资源的申请的稳定性,受限于依赖的全链路稳定性,提升空间有限,因此最优选择依然是延续之前方案中用原地升级替代扩容的技术选择。

某厂内部魔改的k8s对于“镜像不变重建容器”这个场景不再需要通过原地升级来实现,相对于社区k8s的实现来说,提供了修改容器环境变量(env)后就重建容器这个更加简单的实现方式,当然社区k8s上也可以通过OpenKruise的Container Restart能力来实现类似的效果。

管控命令执行

同时接入了k8s方案后对于可运维性也有较大提升:原先对于容器环境的一些清理和初始化操作都需要通过某内部的命令通道,在中心调度端下发命令来完成,这个流程极度依赖于某命令通道的稳定性,需要容器内命令通道进程正常工作才能生效,同时如果执行失败了,还需要后台保存状态,不断重试,让整个实现逻辑变得极度复杂。

而接入了k8s后,可以把相关逻辑全部定义在k8s pod的PostStart/PreStop的hook脚本中,这样只要修改了上面提到的特定环境变量进行重建,则会由kubelet自动执行相关命令,执行失败自动重试,不再需要中心调度端去保存状态和重试,极大的降低了整个系统的复杂度,同时也不再依赖于某命令通道的稳定性,对于一些环境被破坏的特别厉害的场景也有了很强的自愈能力。

自定义镜像的支持

由于前面介绍的docker swarm链路的扩容不稳定,因此之前是取消了用户自定义镜像的支持。但随着k8s的接入,这方面的需求可以重新进行考量。

自定义镜像引入的最大挑战,就是拉取镜像这一步无法被忽略,而用户镜像的大小是很难估计的,如果镜像大小很大,有可能直到CI任务启动超时(目前是30分钟),镜像都没有拉取下来。

这里遇到的问题就是容器场景很典型的“虽然镜像很大,但用户命令实际只用到一小部分内容”的场景,通过引入dadi镜像方案,在这个地方取得了突破。

dadi的介绍可以参看这篇文章,简而言之,就是无论什么镜像都可以秒级启动,按需加载镜像内容,极度适合CI场景。

在用户CI任务下发时,主动寻找当前是否有空闲的相同镜像的worker节点,如果有则进行标记已被使用,如果没有则按LRU算法找全局没被使用的最久的worker节点,并修改镜像为新镜像。

由于对dadi方案产生了强依赖,因此需要在k8s pod的调度中指定一定要调度到支持dadi的node上:

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: alibabacloud.com/speedup-image
              operator: In
              values:
                - dadi

通过使用dadi方案,用户指定一个全新的镜像后,无论这个镜像有多大,均可以在一分钟内从零拉起一个使用这个镜像的容器作为worker来使用。这一分钟里不仅包括了镜像拉取的时间(通常在10秒左右),还包括worker节点的其他准备工作。

其他一些小问题的workaround

pouch的特殊逻辑

内部pouch为了支持很早之前富容器场景的用户习惯,例如hosts修改后能被保留,添加个人账号后,即使发布升级,依然能登录到容器,做了一些定制,会在发布前备份这些文件(主要是 /etc/{hosts,passwd,group,shadow,sudoers,ssh/ssh_host_rsa_key,ssh/ssh_host_dsa_key} 这些文件 ),发布后,将备份的文件覆盖掉新发布的容器里。但这样在CI场景就带来了问题,自定义镜像中如果有用户定义了自己的hosts或用户,就会被覆盖掉,因此需要在PreStop脚本中提前把这些文件删掉,防止被备份并覆盖到新容器中。

pouch-containerd清理旧容器失效

内部默认使用的单Pod单盘方案会将一个Pod上所有容器的存储都放在一个独立云盘上,而pouch-containerd的机制是在容器被销毁后,异步回收这些被销毁的容器占用的磁盘。如果因为种种原因,回收不及时,就会出现容器虽然被重建,但使用的独占云盘的空间依然没有释放的问题。这里在业务层上做了一个兼容,在触发重建时,将整个云盘的根目录也作为volumeMount挂载到容器中,在PostStart中找到pouch-containerd使用的目录,进行主动清理,防止磁盘被占用。

如何判断容器健康

由上面自定义镜像的支持逻辑可以发现,如果在调度时,某个上层认为健康的Pod,实际是不健康的,就会造成CI任务下发失败的问题。这里由于CI引擎的实现问题,没有办法简单的通过k8s的livenessProbe来判断容器是否健康,而是需要通过判断容器是否重置成功是否Ready来进行判断。

判断方法,用传统思路当然是触发重建前记录一下容器ID,Pod重新进入Ready状态后再读取一下容器ID,但这样又重新给中心调度端引入了状态存储的复杂度。

这里最后实现用了一个取巧的方案,就是在重建容器使用的环境变量的值,不使用随机值,而是用当前容器ID通过一个hash算法来获取,这样如果重建没成功,则通过容器ID获取到的hash始终和对应环境变量的值是一样的,只有重建成功,容器ID发生变化,hash和环境变量的值就会不一会,这样简单的对比这两个值是否一致,就能判断容器是否重建成功了。