知乎容器化构建系统:从0到1支撑日近万次构建部署
知乎应用平台团队基于Jenkins Pipeline和Docker打造了一套持续集成系统。Jenkins Master和Slave基于Docker部署,每次构建也是在容器中进行。目前有三千个Jenkins Job,支撑着整个团队每日近万次的构建和部署量。
整个系统的设计目标是具备以下的能力:
较低的应用接入成本,较高的定制能力:写一个构建系统配置文件成本要尽可能简单方便,或者可以通过模板一键创建,但又要能满足应用的各种定制化的需求。
具备语言开放性和部署多样性:平台需要能支撑业务技术选型上的多语言,同时,要能满足应用不同的部署类型,如单纯的打包发布,或者进一步部署到物理机、容器、离线任务平台等。
构建快和稳定,复现问题成本低:每次构建都在干净的容器中,减少非应用本身问题带来的构建异常。同时,如果构建出现问题,在权限控制的前提下,要能方便开发者自己调试和排查。
推动业界标准以及最佳实践,同时在代码合并之前就能更好把控住质量。
整个集群高可用,可扩展,以及具备较低的运维成本。
一、背景
知乎选用Jenkins作为构建方案,因其强大和灵活,且有非常丰富的插件可供使用和扩展。
早期,应用数量较少时,每个开发者都手动创建并维护着几个Job,各自编写Jenkins Job的配置,以及手动触发构建。随着服务化以及业务类型,开发者以及Jenkins Job数量的增加,我们面临了以下的问题:
每个开发者都需要去理解Jenkins的基本配置和触发逻辑,使得配置创建和维护成本高。
构建在物理机上进行,每个应用可能有着不同的版本依赖,构建时会遇到版本冲突,甚至上线之后发现行为不一致导致故障等。
构建一旦失败,需要开发者能登录Jenkins Slave所在的物理机进行调试,权限控制成为了一个问题。
于是,一个能方便应用接入构建部署的系统,成为了必须。
二、完整的生命周期
知乎的构建工作流主要是以下两种场景:
只有Master分支的代码可以用于线上部署,但支持指定任意的分支进行构建。
所有对Master分支的修改必须通过Merge Request来进行。为了避免潜在代码冲突导致测试结果不准的情况,对Merge Request上的代码进行构建前,会模拟跟Master分支的代码做一次合并。
一个Commit从提交到最后部署,会经历以下的环节:
开发者提交代码到GitLab。
GitLab通过Webhook通知到ZAE(Zhihu App Engine,知乎的私有云平台)。
ZAE将构建的上下文信息,如GitLab仓库ID,ZAE应用信息给到构建系统Lavie。目前只处理用户提交MR以及合并到Master分支的事件。
构建系统Lavie读取应用仓库中的配置文件后生成配置,触发一个构建。在构建过程中获取动态生成的Jenkinsfile,生成Dockerfile构建出应用的镜像,并跑起容器,在容器中执行构建,测试等应用指定的步骤。
测试成功之后,分别往物理机部署平台,容器部署平台,离线任务平台上传Artifact,注册待发布版本的信息,并Slack通知用户结果。
构建结束,用户在ZAE上可以进行后续操作,如选择一个候选版本进行部署。
每个应用的拉取代码,准备数据库,处理测试覆盖率,发送消息,候选版本的注册等通用的部分,都会由构建系统统一处理,而接入构建系统的应用,只需要在代码仓库中包含一个约定格式的配置文件。
三、达到的目标以及中间遇到的问题
构建系统去理解应用要做的事情靠的是约定格式的yaml配置文件,而我们希望这个配置文件能足够简单,声明上必要的部分,如环境、构建、测试步骤就能开始构建。
同时,也要有能力提供更多的定制功能让应用可以使用,如选择系统依赖和版本,缓存的路径,是否需要构建系统提供MySQL以及需要的MySQL版本等,以及可以根据应用的类别自动生成配置文件。
一个最简单的应用场景:
base_image: python2/jessie
build:
- buildout
test:
unittest:
- bin/test --cover-package=pin --with-xunit --with-coverage --cover-xml
一个更多定制化的场景:
base_image: py_node/jessie
deps:
- libffi-dev
build:
- buildout
- cd admin && npm install && gulp
test:
deps:
- mysql:5.7
unittest:
- bin/test --cover-package=lived,liveweb --with-xunit --with-coverage
coverage_test:
report_fpath: coverage.xml
post_build:
scripts:
- /bin/bash scripts/release_sentry.sh
artifacts:
targets:
- docker
- tarball
cache:
directories:
- admin/static/components
- admin/node_modules
为了尽可能满足多样化的业务场景,我们主要将配置文件分为三部分:声明环境和依赖、构建相关核心环节、声明Artifact类型。
声明环境和依赖:
image,基础镜像,需要指明已提前准备好的语言镜像。
deps,dependencies的简写,声明使用的系统依赖以及对应的版本。
构建相关核心环节:
build,构建的步骤,如buildout、npm install、或者执行一个脚本。
test,测试环节,应用需要声明构建的步骤,也可以在这里定制使用的MySQL以及对应的版本。构建系统会每次为其创建新的数据库,将关键信息export为环境变量。
post build,最后一个环节,如发包、发Slack、邮件通知、或发布一个Sentry release等。
声明Artifact类型:
artifact,用于选择部署的类型,目前支持的有:
tarball:构建系统会将整个应用Workspace打包上传到HDFS用于后续的物理机部署。
docker:镜像会被push到私有的Docker Registry用于容器部署。
static:应用指定的路径打包后会被上传到HDFS,用于后续的静态资源部署。
offline:应用指定的文件会被上传到离线平台,用于离线任务的执行。
早期所有的构建都在物理机上进行,构建之前需要提前在物理机上安装好对应的系统依赖,而如果遇到所需要的版本不同时,调度和维护的成本就高了很多。
随着团队业务数量和种类的增加,技术选型的演进,这样的挑战越来越大。于是构建系统整体的优化方向由物理机向Docker容器化前进。
如今,所有构建都容器中进行,基础的语言镜像由应用自己选择。目前镜像管理的方式是:
我们会事先准备好系统的基础镜像。
在系统镜像的基础上,会构建出不同的语言镜像供应用使用,如Python,Golang,Java,Node,Rust的各种版本以及混合语言的镜像。
在应用指定的image语言镜像之上,会安装上deps指定的系统依赖,再构建出应用的镜像,应用会在这个环境里面进行构建测试等。
语言这一层的Dockerfile会被严格review,通过的镜像才能被使用,以更好了解和支持业务技术选型和使用场景。
缓存的设计
最开始构建的缓存是落在对应的Jenkins Slave上的,随着Slave数量的增多,应用构建被分配到不同Slave带来的代价也越来越大。
为了让Slave的管理更加灵活以及构建速度和Slave无关,我们最后将缓存按照应用使用的镜像和系统依赖作为缓存的标识,上传到HDFS。在每次构建前拉取,构建之后再上传更新。
针对镜像涉及到的语言,我们会对常见的依赖进行缓存,如eggs、node_modules,.ivy2/cache、.ivy2/repository。应用如果有其他的文件想要缓存,也支持在配置文件中指定。
依赖获取稳定性
在对整个构建时间的开销和不稳定因素的观察中,我们发现拉取外部依赖是个非常耗时且失败率较高的环节。
为了让这个过程更加稳定,我们做了以下的事情:
完善内部不同语言的源。
在不同语言的基础镜像中放入优先使用内部源的配置。
搭建HTTP Proxy,提供给以上覆盖不到的场景。
更低的排查错误的成本
本地开发和构建环境存在明显的差异,可能会出现本地构建成功但是在构建系统失败的情况。
为了让用户能够快速重现,我们在项目docker-ssh的基础上做了二次开发,支持直接ssh到容器进行调试。由于容器环境与其他人的构建相隔离,我们不必担心ssh权限导致的各种安全问题。构建失败的容器会多保留一天,之后便被回收。
我们希望能给接入到构建系统的提高效率的同时,也希望能推动一些标准或者好的实践,比如完善测试。
围绕着测试和测试覆盖率,我们做了以下的事情:
配置文件中强制要有测试环节。
应用测试结束之后,取到代码覆盖率的报告并打点。在提交的Merge Request评论中会给出现在的值和主分支的值的比较,以及最近主分支代码覆盖率的变化趋势。
在知乎有应用重要性的分级,对于重要的应用,构建系统会对其要求有测试覆盖率报告,以及更高的测试覆盖率。
对于团队内或者业界的基础库,如果发现有更稳定版本或者发现有严重问题,构建系统会按照应用的重要性,从低到高提示应用去升级或者去掉对应依赖。
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的构建环节推动更多的质量保证标准实施,如更多的接口自动化测试,减少有问题的代码被合并到主分支。
Jenkinsfile 相关文档 :
https://jenkins.io/doc/book/pipeline/jenkinsfile/
<Jenkins Logo>:
https://jenkins.io/
<Docker Logo>:Brand Guidelines
作者:Amyyyyy
来源:https://zhuanlan.zhihu.com/p/45694823
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
近期热文