知乎容器化构建系统:从0到1支撑日近万次构建部署

Amyyyyy 2019-02-01 12:04:00
知乎应用平台团队基于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上可以进行后续操作,如选择一个候选版本进行部署。

 

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

 

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

 

1、低接入成本,高定制能力
 

 

构建系统去理解应用要做的事情靠的是约定格式的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:应用指定的文件会被上传到离线平台,用于离线任务的执行。

 

2、语言开放性
 

 

早期所有的构建都在物理机上进行,构建之前需要提前在物理机上安装好对应的系统依赖,而如果遇到所需要的版本不同时,调度和维护的成本就高了很多。

 

随着团队业务数量和种类的增加,技术选型的演进,这样的挑战越来越大。于是构建系统整体的优化方向由物理机向Docker容器化前进。

 

如今,所有构建都容器中进行,基础的语言镜像由应用自己选择。目前镜像管理的方式是:

 

  • 我们会事先准备好系统的基础镜像。

  • 在系统镜像的基础上,会构建出不同的语言镜像供应用使用,如Python,Golang,Java,Node,Rust的各种版本以及混合语言的镜像。

  • 在应用指定的image语言镜像之上,会安装上deps指定的系统依赖,再构建出应用的镜像,应用会在这个环境里面进行构建测试等。

 

 

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

 

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

 

缓存的设计

 

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

 

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

 

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

 

依赖获取稳定性

 

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

 

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

 

  • 完善内部不同语言的源。

  • 在不同语言的基础镜像中放入优先使用内部源的配置。

  • 搭建HTTP Proxy,提供给以上覆盖不到的场景。

 

更低的排查错误的成本

 

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

 

为了让用户能够快速重现,我们在项目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的构建环节推动更多的质量保证标准实施,如更多的接口自动化测试,减少有问题的代码被合并到主分支。

 

参考
 

 

最新评论
访客 2019年04月17日

总结的真cool

访客 2019年04月17日

非常不错 受用。

访客 2019年04月13日

作者根本就没用过pg吧,两个数据库都用过一年以上再来…

访客 2019年04月09日

niubility!!!!! 大赞贺大佬

访客 2019年03月27日

Spark2.x+Python大数据机器学习实战视频教程 学习:p…

活动预告