知乎容器化构建系统设计和实践

创建时间:2019/6/11 10:07
来源:https://zhuanlan.zhihu.com/p/45694823


Table of Content:

关于

知乎应用平台团队基于 Jenkins Pipeline 和 Docker 打造了一套持续集成系统。Jenkins Master 和 Slave 基于 Docker 部署,每次构建也是在容器中进行。目前有三千个 Jenkins Job,支撑着整个团队每日近万次的构建和部署量。

整个系统的设计目标是具备以下的能力:

背景

知乎选用 Jenkins 作为构建方案,因其强大和灵活,且有非常丰富的插件可供使用和扩展。

早期,应用数量较少时,每个开发者都手动创建并维护着几个 Job,各自编写 Jenkins Job 的配置,以及手动触发构建。随着服务化以及业务类型,开发者以及 Jenkins Job 数量的增加,我们面临了以下的问题:

于是,一个能方便应用接入构建部署的系统,成为了必须。

完整的生命周期

知乎的构建工作流主要是以下两种场景:

一个 Commit 从提交到最后部署,会经历以下的环节:

  1. 1开发者提交代码到 GitLab。
  2. 2GitLab 通过 Webhook 通知到 ZAE (Zhihu App Engine, 知乎的私有云平台)。
  3. 3ZAE 将构建的上下文信息,如 GitLab 仓库 ID,ZAE 应用信息给到构建系统 Lavie。目前只处理用户提交 MR 以及合并到 Master 分支的事件。
  4. 4构建系统 Lavie 读取应用仓库中的配置文件后生成配置,触发一个构建。在构建过程中获取动态生成的 Jenkinsfile,生成 Dockerfile 构建出应用的镜像,并跑起容器,在容器中执行构建,测试等应用指定的步骤。
  5. 5测试成功之后,分别往物理机部署平台,容器部署平台,离线任务平台上传 Artifact,注册待发布版本的信息,并 Slack 通知用户结果。
  6. 6构建结束,用户在 ZAE 上可以进行后续操作,如选择一个候选版本进行部署。

每个应用的拉取代码,准备数据库,处理测试覆盖率,发送消息,候选版本的注册等通用的部分,都会由构建系统统一处理,而接入构建系统的应用,只需要在代码仓库中包含一个约定格式的配置文件。

达到的目标以及中间遇到的问题

  1. 1较低的接入成本,较高的定制能力

构建系统去理解应用要做的事情靠的是约定格式的 yaml 配置文件,而我们希望这个配置文件能足够简单,声明上必要的部分,如环境、构建、测试步骤就能开始构建。

同时,也要有能力提供更多的定制功能让应用可以使用,如选择系统依赖和版本,缓存的路径,是否需要构建系统提供 MySQL 以及需要的 MySQL 版本等。以及可以根据应用的类别自动生成配置文件。

一个最简单的应用场景:

一个更多定制化的场景:

为了尽可能满足多样化的业务场景,我们主要将配置文件分为三部分:声明环境和依赖、构建相关核心环节、声明 Artifact 类型。

声明环境和依赖

构建相关核心环节

声明 Artifact 类型

artifact,用于选择部署的类型, 目前支持的有:

2. 语言开放性

早期所有的构建都在物理机上进行,构建之前需要提前在物理机上安装好对应的系统依赖,而如果遇到所需要的版本不同时,调度和维护的成本就高了很多。随着团队业务数量和种类的增加,技术选型的演进,这样的挑战越来越大。于是构建系统整体的优化方向由物理机向 Docker 容器化前进,如今,所有构建都容器中进行,基础的语言镜像由应用自己选择。

目前镜像管理的方式是:


语言这一层的 Dockerfile 会被严格 review,通过的镜像才能被使用,以更好了解和支持业务技术选型和使用场景。

3. 减少不稳定构建,降低问题复现成本

缓存的设计

最开始构建的缓存是落在对应的 Jenkins Slave 上的,随着 Slave 数量的增多,应用构建被分配到不同 Slave 带来的代价也越来越大。

为了让 Slave 的管理更加灵活以及构建速度和 Slave 无关,我们最后将缓存按照应用使用的镜像和系统依赖作为缓存的标识,上传到 HDFS。在每次构建前拉取,构建之后再上传更新。

针对镜像涉及到的语言,我们会对常见的依赖进行缓存,如 eggs, node_modules, .ivy2/cache, .ivy2/repository。应用如果有其他的文件想要缓存,也支持在配置文件中指定。

依赖获取稳定性

在对整个构建时间的开销和不稳定因素的观察中,我们发现拉取外部依赖是个非常耗时且失败率较高的环节。

为了让这个过程更加稳定,我们做了以下的事情:

更低的排查错误的成本

本地开发和构建环境存在明显的差异,可能会出现本地构建成功但是在构建系统失败的情况。

为了让用户能够快速重现,我们在项目 docker-ssh 的基础上做了二次开发,支持直接 ssh 到容器进行调试。由于容器环境与其他人的构建相隔离,我们不必担心 ssh 权限导致的各种安全问题。构建失败的容器会多保留一天,之后便被回收。

4. 规范和标准的落地抓手

我们希望能给接入到构建系统的提高效率的同时,也希望能推动一些标准或者好的实践,比如完善测试。

围绕着测试和测试覆盖率,我们做了以下的事情:

  • 配置文件中强制要有测试环节。
  • 应用测试结束之后,取到代码覆盖率的报告并打点。在提交的 Merge Request 评论中会给出现在的值和主分支的值的比较,以及最近主分支代码覆盖率的变化趋势。
  • 在知乎有应用重要性的分级,对于重要的应用,构建系统会对其要求有测试覆盖率报告,以及更高的测试覆盖率。


对于团队内或者业界的基础库,如果发现有更稳定版本或者发现有严重问题,构建系统会按照应用的重要性,从低到高提示应用去升级或者去掉对应依赖。

5. 高可用和可扩展的集群

Job 调度策略

Jenkins Master 只进行任务的调度,而实际执行是在不同的 Jenkins Node 上。

每个 Node 会被赋予一些 label 用于任务调度,比如:mysql:5.6, mysql:5.7, common 等。构建系统会根据应用的类型分配到不同的 label,由 Jenkins Master 去进一步调度任务到对应的 Node 上。


高可用设计

集群的设计如下,一个 Node 对应的是一台物理机,上面跑了 Jenkins Slave (分别连 Master 和 Master Standby),Docker Deamon 和 MySQL(为应用提供测试的 MySQL)。

Slave 连接 Master 等待被调度,而当 Jenkins Slave 出现故障时,只需摘掉这台 Slave 的 label,后续将不会有任务调度调度上来。

而当 Jenkins Master 故障时,如果不能短时间启动起来时,集群可能就处于不可用状态了,从而影响整个构建部署。为了减少这种情况带来的不可用,我们采用了双 Master 模型,一台作为 Standby,如果其中一台出现异常就切换到另一台健康的 Master。

监控和报警

为了更好监控集群的运行状态,及时发现集群故障,我们加了一系列的监控报警,如:

  • 两个 Jenkins Master 是否可用,当前的排队数量情况。
  • 集群里面所有 Jenkins Node 的在线状态,Node 被命中的情况。
  • Jenkins Job 的执行时间,是否有不合理的过长构建或者卡住。
  • 以及集群机器的 CPU,内存,磁盘使用情况。

后续的计划

在未来我们还希望完善以下的方面:

  • Jenkins Slave 能更根据集群的负载情况进行动态扩容。
  • 一个节点故障时能自动下掉并重新分配已经在上面执行的任务。一个 Master down 掉能被主动探测到并发生切换。
  • 在 Merge Request 的构建环节推动更多的质量保证标准实施,如更多的接口自动化测试,减少有问题的代码被合并到主分支。


主要开发者(排名不分先后):

参考资料

编辑于 2018-10-01