1. 背景
分布式技术的发展深刻地改变了我们编程的模式和思考软件 的 模式。分布式很好 的 解决性能扩展,可靠性,组件可用性等问题,但是单机转变成分布式却加大了系统的复杂性,对于组件的开发,测试,部署,发布都提出更高的要求。那么,针对复杂的分布式系统怎么保证软件质量和系统的稳定性?首先看下,传统软件产品活动的大致流程,简化流程大概是 3 大块:
一般如下图:

流程有个很大的问题,质量全靠 QA 测,对接全靠人力,沟通成本大,遗漏问题多,一般有几个常见的问题:
- QA 很难每次都测试全面,毕竟QA毕竟是人,人的主观因素太大,有时候人为判断觉得简单,不用测的地方很可能有漏了。或者觉得修改点太简单,觉得不至于出问题,就不再全面的测试。以至于可能会有基本功能问题;
- 测试速度慢,效率太低,QA 资源浪费,如果每次 QA 都需要全量的测试,那么重复工作太多,效率太低,效果也不好,对于这些重复工作,本可以更好,更快的解决,不至于 QA 就为了测试这么点东西,而没有精力去做更多的事情;
- 甚至说编译不过的代码都有可能遗漏到 QA;
- 闭环太慢,开发功能如果有问题,等到 QA 测出来, 让后 再反馈到开发这个闭环就太慢了。更不必说,问题漏到线上,再反馈到开发人员,那么 戴江 就更大了;
- 多个开发人员并行开发的时候,工作可能相互影响,小问题越积越多,功能集成的时候可能非常耗时;
我们提出的改进点:
- 核心点之一:问题发现要早,发现越早,代价越小;
- 核心点之一:问题闭环要快,闭环越快,效率越高;
- 重复工作自动化,减少人的无效劳作;
- 多开发人员的时候,功能持续集成,问题 拆小 ,提早发现;
最核心的一点就是:”自动化闭环问题“。
当前的复杂的软件系统对质量和效率提出了更高的要求,所以响应的软件活动必须要高度自动化才能达到要求。自动化触发、自动化测试、自动化闭环、自动化发布、自动化卡点等一系列的保证,一切能够事先预知且可固化的行为都应该自动化,把效率和质量提升,而让人去做更聪明的事情。

我们的思考:近些年来,对于自动化有 Continuous Integration,Continuous Delivery,Continuous Deployment 的一些理论和实践。这三者来说,突出“持续”二字,“持续”是为了达到“快”的目的,“快速迭代”,“快速响应”,“快速闭环”,“快”是核心竞争力。一般大家的共识流程分类如下:

对于开发者来说,接触到更多的是 Continuous Integration(持续集成),CI 把通过自动化,把流程固化下来,保证代码集成的有序、可靠,确保版本可控,问题可追溯,代码的活动中通过自动化,降低了人为主观的出错率,提高速度,提高版本质量和效率。
2. CI 是什么?
CI 即是持续集成(Continuous Integration),是当今软件活动中至关重要的一环。CI 一般由开发人员递交代码改动所触发,CI 在中间环境做自动化验证,CI 验证过后,即经过了基本质量保证,那么就可以允许下一 步 的软件活动。
持续集成说白了就是一种软件开发实践,即团队开发成员尽可能的快的集成,每次集成通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早地发现集成错误。因为问题发现 的 越早,那么问题解决的成本就越少。
一般来说,持续集成需要打通几个环节:
- 代码提交( Git )
- 任务构建( jenkins )
- 部署测试(Ansible,Shell,Puppet)
- 划重点:CI 过程由代码活动触发。
代码活动关注两个时间点:
- Pre-Merge :代码改动合入主干分支前夕触发。集成的对象是代码改动与主干最新代码 Merge 之后的代码,目的是验证代码改动是否能够合入主干;
- Post-Merge :代码改动合入主干分支之后触发。集成对象就是最新的主干分支代码,目的是验证合入改动代码之后主干是否能够正常工作;
Pre-Merge 和 Post-Merge 关注点不同,缺一不可。差异在哪里?如果只有一个开发者,那么 Pre-Merge 和 Post-Merge 的测试对象是相同的。在多个开发者递交代码的时候,Pre-Merge 和 Post-Merge 就会呈现差异,他们的 CI 测试对象不同。

换句话说,Pre-Merge 是并行的,每个开发分支想要合入主干都会触发Pre-Merge CI,CI 的测试对象是 <开发分支+主干分支>, Post-Merge 是串行的,测试对象永远都是最新的主干分支代码。
3. CI 的四个思考

3.1 CI 怎么触发?
代码活动触发:
一般有两个触发点,Pre-Merge,Post-Merge ,分别是代码合入主干之前,主干代码合入之后。
定时触发。
3.2 CI 触发之后做什么?
CI 触发了之后做什么?说白了就是构建任务做了啥,一般有几个流程:
- Checkout,Pre-Merge 代码——校验 MR 合入是否合法;
- 代码编译 —— 校验代码编译是否合法;
- 静态检查 —— 校验静态语法是否合法;
- 单元测试 —— 回归测试函数单元合法;
- 冒烟测试 —— 简单测试系统是否正常;
- 接口测试 —— 测试用户接口是否正常;
- 性能基准测试 —— 测试性能是否符合预期;
3.3 怎么闭环问题?
先思考可能会有什么问题:
- Pre-Merge 代码冲突;
- 代码编译失败;
- 静态检查失败;
- 单元测试回归测试不通过;
- 冒烟测试步通过,接口测试失败……
递交一个代码 MR 递交可能遇到以上问题,那么怎么才能快速闭环这个问题呢?
首先,得有手段通知到开发者。
解决:
- MR 的 comment,CI 活动失败之后,直接以评论的方式自动添加到 MR;
- 邮件,触发的一次 MR ,失败了以邮件的方式发送到相关人;
再者,得有手段让开发人员知道问题。
解决:
- 开发者知道自己的 MR 触发 CI 失败之后,得知道怎么去排查问题 —— 测试报告,比如单元测试失败,要有单元测试报告,接口测试失败要有接口测试报告;
- 每次构建任务保留归档线索,以便排查;
3.4 CI 构建活动输出什么?
- 单元测试报告;
- 接口测试报告;
- 代码覆盖率报告;
- 接口覆盖率报告;
- 构建版本包(持续化部署需要);
4. CI 平台选型
一般对多数的公司来说,不需要自己研发一个 CI 平台,有很多优秀的开源 CI 平台工具,工具之间并没有绝对的差异优势,这里就不进行选型了,我们以一个 Jenkins 完整示例来说明 CI 的使用方法和技巧。代码仓库我们使用 Gitlab ,CI 平台我们使用开源的 Jenkins 作为演示。一步步完成我们需要 得 几大模块功能。
平台选型:代码平台 Gitlab,CI 平台 Jenkins。
5. CI 的流程实践
CI 主要把关的是代码活动,一般有两个触发点:
- 代码合入主干前,触发 CI 测试,目的是校验本次合入是否符合质量预期,如果不符合,那么不准代码合入主干;
- 代码合入主干后,触发 CI 测试,目的是校验最新的主干分支是否符合质量预期。
Pre-Merge 触发过程:
- 开发代码递交 Merge Request ( github 上习惯叫做 PR,gitlab 上习惯叫做 MR )
- MR 自动触发 CI 构建事件
- 运行静态检查,Merge 检查,单元测试,冒烟测试,集成测试,全部通过之后,代码才允许 Merge 合入主干分支;
- 进行下一步软件活动。
Post-Merge 触发过程:
- 管理员审核 Merge Request 通过,代码合入主干,触发 Post-Merge 事件;
- CI 平台收到事件,自动进行 CI 构建;
- 构建完成,进行下一步软件活动;
6. Jenkins 平台构建
6.1 Jenkins 平台搭建
Jenkins 是 Java 程序开发的,安装是非常方便的,去官网上下载一个 war 包,然后后台拉起运行即可。运行命令如下:
启动:
nohup /usr/bin/java -jar jenkins.war --httpPort=8888 >> jenkins.log 2>&1 &
这样 Jenkins 平台就拉起来了,超简单。
6.1.1 初始化平台
初始密钥:
平台第一次搭建需要做一些配置,在日志里找到一个“初始密钥”注意一下提示:
*************************************************************
*************************************************************
*************************************************************
Jenkins initial setup is required. An admin user has been created and a password generated.
Please use the following password to proceed to installation:
5ddce31f4a0d48b4b7d6d71ff41d94a8
This may also be found at: /root/software/jenkins/workhome/secrets/initialAdminPassword
*************************************************************
*************************************************************
*************************************************************
这个是最初始的超级用户密码,待会配置的 Jenkins 时候会用上,所以赶紧拿个小本本记下。
登录网页:
现在打开浏览器,登录 jenkins 的网页,我们下面做 jenkins 的初始化:

点击 “Continue”,进行下一步,下一步就是定制一些插件安装了。
第一次定制插件安装:
这个是可选的,这个根据自己需求选择插件,为了省事,选第一种就好,之后也很方便在平台上下载插件。

安装插件过程(Jenkins 几乎完全由插件组装起功能):

更新成功的就会显示“绿色”。插件安装完之后,下一步就是配置第一个超级用户了。

配置完成之后,点击 “Save and Continue” ,最后一步,配置 url ,点击 finish 即可:


现在基本配置已经完成,可以开始愉快的使用 Jenkins 了。

6.1.2 使用小技巧
中文化配置:
Jenkins 平台搭建好之后,默认的是英文的,在国内的话可能没必要,我们可以安装中文化插件来更友好的展示我们的 Jenkins。分两个步骤:
步骤一:安装插件:”Locale“:
"Manage Jenkins" -> "Manage Plugins" -> "Available"

步骤二:安装完之后,配置 Configure:

6.2 Jenkins 插件安装
6.2.1 插件更新地址
这里推荐国内的插件源地址,因为官网的网络访问不是很稳定。比如以下是清华的镜像源。
6.2.2 必备插件安装
Jenkins 功能都是由插件提供,有些插件是必须配备的,才能提供完备的 CI 功能,比如流水线 Pipeline 。这里列举几大关键插件的使用方法。
Pipeline:
Jenkins 必备插件,流水线插件,能否非常方便的让你定义流程,调度节点,分配资源,处理结果等。
blue ocean:
Pipeline 的可视化插件,Pipeline 还是声明式代码编写,如果要能让人更方便的使用,那么需要一个可视化的工具,blue ocean 就是为此而生。
junit:
测试报告的一个解析插件,这也是一种较为通用的测试报告格式。
Cobertura Plugin:
覆盖率展示的一个插件。单测跑完,需要有手段知道覆盖率的情况,并且需要 能 方便的闭环处理。
- 显示覆盖率的情况;
- 代码的覆盖详情,方便开发人员闭环处理(细化到每一行代码);
GitLab:
我们的演示以 Gitlab 作为例子,需要和 GitLab 进行交互,所以需要安装插件用来接受 GitLab 事件,并反馈 CI 结果。
6.3 Jenkins 任务创建
6.3.1 创建任务(item)
item 就是 CI 的项目,item 由管理员静态创建配置好,触发起来就是 job 了。每触发一次,job 编号都是递增的。点击 New Item 创建一个”流水线“ 的项目。

6.3.2 创建视图(view)
View 是什么概念?View 可以把一些有业务意义的任务归纳起来,在一个列表中显示。可以点击 ”New View“ 进行创建。
视图会展示 item ,你可以选择性 的 勾选。
6.4 Jenkins 流水线
流水线框架是通过 Pipeline 脚本来描述流程,Pipeline 有两种创建方式,两种语法:
- 声明式流水线语法
- 脚本化流水线语法
现在官方推荐的是声明式流水线语法。那么,声明式的语法是什么样子的?声明式语法特点之一:顶层必须以 pipeline {} 开始。
7. Jenkins 和 Gitlab 交互
这一步就是最关键的东西,Jenkins 搭建好了之后,如果只是一个孤岛平台,那么没有任何意义,它必须参与到软件开发流程中去才能发挥效果。交互示意图如下:

我们看到,GitLab 的代码活动需要以事件的形式触发 Jenkins,Jenkins 执行完一系列活动之后,需要把结果反馈到 GitLab,并且能够影响到 GitLab 的下一步活动,所以 GitLab 和 Jenkins 需要相互配置关联。
7.1 Gitlab 配置
为什么需要配置 Gitlab ?因为需要打通 Gitlab 到 Jenkins 的路。Gitlab 作为代码仓库,主要产生项目代码相关的事件,比如 Merge Request,Push Commit 等,当 Gitlab 产生这些事件的时候,需要自动把这个事件推送给 Jenkins ,这样就打通了触发交互。
7.1.1 配置 Web Hook 事件

操作步骤:
- 打开代码仓库
- 点击 setting -> integrations
填入 URL填入 Secret Token勾选 Trigger 事件URL 和 Secret Token 怎么来的?这个是对应到 item 。
在 Jenkins 平台上,打开对应的 item,打开 Configure ,勾选 Build Triggers ,找到 ”Build when a change is pushed to GitLab“ ,就是这个了。

再往下有一个 Secret token ,点击 Generate 。

把这两个正确填写好,那么就能打通第一个环节了:GitLab 到 Jenkins 的触发。填写完之后,可以由 GitLab 发个测试事件测试下。

返回 200 即是成功了。
7.1.2 配置 Pre-Merge 卡点
代码 Pre-Merge CI 没过不让合入主干 这个功能怎么实现?关键是 GitLab 要支持代码 Merge 前夕的 Hook 行为。
- 首先,我们约定一个行为规范:所有合入主干的代码必须递交 MR,MR CI 测试通过才可以合入主干;
- 其次,勾选 Settings -> General -> Merge requests ,把 ”Only allow merge requests to be merged if the pipeline succeeds“ 勾选上。
7.2 Jenkins 配置
Jenkins 主要看 Pipeline 的配置,Pipeline 配置打开 Configure 如下:

看一个完整的 pipeline 架子定义各个阶段(可以直接把这个拷贝,运行看下效果):
pipeline {
agent any
stages {
stage('代码checkout') {
steps {
echo "------------"
}
}
stage ("静态检查") {
steps {
echo "------------"
}
}
stage ("代码编译") {
steps {
echo "------------"
}
}
stage ("单元测试") {
steps {
echo "------------"
}
}
stage ("打包") {
steps {
echo "------------"
}
}
stage ("冒烟测试") {
steps {
echo "------------"
}
}
stage ("集成测试") {
steps {
echo "------------"
}
}
stage ("基准性能测试") {
steps {
echo "------------"
}
}
}
post {
always {
echo "------------"
}
success {
echo "------------"
}
failure {
echo "------------"
}
unstable {
echo "------------"
}
}
}
跑出来的效果:

Blue Ocean 的效果:

接下来,我们拆解几个关键的阶段来分析。
7.2.1 代码 checkout
Checkout 的代码也就是我们的测试对象,这个对于 Pre-merge 和 Post-merge 是不同的,Pre-merge 卡点由 Merge Request 事件触发,我们需要 Checkout 出 ”代码修改“ + ”最新主干分支“ 的代码。Post-merge 相对简单,我们只需要 Checkout 出最新的主干分支即可。怎么做?
Pipeline 直接支持这个使用。
stage('代码checkout') {
steps {
dir(path: "${你想要放置的代码路径}") {
checkout changelog: true, poll: true, scm: [
$class: 'GitSCM',
branches: [[name: "*/${env.gitlabSourceBranch}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'PreBuildMerge', options: [fastForwardMode: 'FF', mergeRemote: 'origin', mergeStrategy: 'DEFAULT', mergeTarget: "${env.gitlabTargetBranch}" ]],
[ $class: 'UserIdentity', email: "${env.gitlabUserEmail}", name: "${env.gitlabUserName}" ]
],
submoduleCfg: [],
userRemoteConfigs: [[
credentialsId: "${env.OCS_GITLAB_CredentialsId}",
url: "${env.gitlabSourceRepoHttpUrl}"
]]
]
}
}
}
这里指明:
- 代码 checkout 下来放置的路径(dir path 配置);
- 指明 checkout 的分支,MR 事件触发 CI 的时候 env.gitlabSourceBranch 是会自动设置上的;
- 指明 checkout 的行为,PreBuildMerge 行为(其实 Post-Merge 触发 PUSH 事件的时候,也适用于上面的写法);
- 指明 GitLab 认证凭证(credentialsId);
上面的语法同时适用于 Pre-merge 和 Post-merge 。
7.2.2 静态检查
静态检查主要是对代码语法做一些静态检查,比如 golang ,可以使用自带的 go vet ,或者 go fmt 等检查,经过这一轮检查就能保证大家的代码消除了最基本的语法和格式错误。
7.2.3 单元测试
对最小的函数做单元测试是必要的,经过单元测试可以拿到项目的一个覆盖率情况。这个阶段我们获得两个东西:
- 单元测试案例报告
- 覆盖率报告
测试报告怎么拿?以 Golang 为例,跑单测的时候,把覆盖率的开关打开,标准输出到一个文件:
go test -cover -coverprofile=cover. output xxx | tee ut.output
这里会产生两个文件:
- ut.output :用来生成单元测试报告的文件;
- cover.output :用来生成覆盖率报告的文件;
单测报告生成:
首先解析这个文件 成 xml 格式的文件,然后用 junit 上报给 jenkins 展示。
sh "go-junit-report < ut.output > ut.xml"
junit 'ut.xml'
go-junit-report 哪里来的?这是个开源的工具,就是专门用来做单元测试解析的。
在 Jenkins 上展示的效果如下:

覆盖率报告生成:
解析覆盖率输出文件,生成一个xml 文件:
gocov convert cover.output | gocov-xml > cover.xml
上报这个 xml 文件,用于 Jenkins 平台展示:
step([
$class: 'CoberturaPublisher',
autoUpdateHealth: false,
autoUpdateStability: false,
coberturaReportFile: '**/cover.xml',
failUnhealthy: false,
failUnstable: false,
maxNumberOfBuilds: 0,
onlyStable: false,
sourceEncoding: 'ASCII',
zoomCoverageChart: false
]
)

点击进文件,可以看到代码覆盖的详情:

7.2.4 接口测试
对完整的系统做一些接口级别的测试,比如模拟用户行为,测试用户调用的接口,这样能保证最基本的功能。报告输出也可以用 junit 格式,可以上报给 Jenkins,解析如图:

7.2.5 邮件发送
测试通过或者失败,需要发送有结果邮件。
configFileProvider([configFile(fileId: '5f1e288d-71ee-4d29-855f-f3b22eee376c', targetLocation: 'email.html', variable: 'content')]) {
script {
template = readFile encoding: 'UTF-8', file: "${content}"
emailext(
subject: "CI构建结果: ${currentBuild.result?: 'Unknow'}",
to: "test@test.com",
from: "push@test.com",
body: """${template}"""
)
}
}
7.2.6 GitLab 状态交互
// 定义 Gitlab 流程
options {
gitLabConnection('test-gitlab')
gitlabBuilds(builds: ['jenkinsCI'])
}
// 触发gitlab pipeline
updateGitlabCommitStatus name: 'jenkinsCI', state: 'success'
addGitLabMRComment comment: """**CI Jenkins自动构建详情**\n
| 条目 | 值 |
| ------ | ------ |
| 结果 | ${currentBuild.result?: 'Unknow'} |
| MR LastCommit | ${env.gitlabMergeRequestLastCommit} |
| MR id | ${env.gitlabMergeRequestIid} |
| Message Title | ${env.gitlabMergeRequestTitle} |
| 构建任务ID | ${env.BUILD_NUMBER} |
| 构建详情链接 | [${env.RUN_DISPLAY_URL}](${env.RUN_DISPLAY_URL})"""
CI 成功或失败,都需要把这个状态给到 GitLab,我们以一个 Comment 展示结果,并且附上 jenkins 任务的跳转链接,这样可以最快的帮助开发人员闭环。

成功才允许合入:

Gitlab CI:

7.2.7 构建归档
打包日志:
// 先打一个 tar 包
sh "tar -czvf log.tar.gz ${SERVICEDIR}/run/*.log"
// jenkins 进行归档
archiveArtifacts allowEmptyArchive: true, artifacts: "log.tar.gz", followSymlinks:
false
8. Jenkins 高级技巧
8.1 资源互斥
有时候多个任务跑的时候,可能会并发使用到某个资源,而如果这个资源有限,那么可能需要用到一些互斥手段来保证。比如,两个任务可能都用到了 mongodb,而 mongodb 如果只有一套,那么就必须让多个任务串行执行才行,不然就会跑错了逻辑。怎么做?
这个可以在 “Configure System”->“Lockable Resources Manager” 定义好锁资源:

然后再 Pipeline 脚本里使用这个锁资源:
stage ("单元测试") {
steps {
lock(resource: "UT_TEST", quantity:1) {
echo "====== 单元测试 ============"
echo "====== 单元测试完成 ============"
}
}
}
并且还可以在界面上( Dashboard -> Lockable Resources )看到哪些资源被哪些任务占用:

通过合理定义锁资源,我们就能做到任务可以并发,但是关键的竞态资源做互斥,这样 CI 构建任务更灵活,更有效率(这个可以类比成代码里面锁粒度的一个影响,如果你不用 Lock Resource 这种方式,那么很可能只能配置成 node 并发度为1才能保护到竞态资源)。
8.2 节点调度
Jenkins 允许你调度指定的任务到合适的节点。当有多个节点的时候,可能会想要任务 A 固定到 node1 上执行,那么可以使用 agent 命令指定。
定义节点的时候每个节点都会赋有一个 label 名称,然后运行的时候,就可以指定节点了:
agent { label "slave_node_1" }
8.3 节点间文件传输
我们使用 stash, unstash 来实现, 下面的例子就是把 build/ 目录在 node1 和 node2 直接做无损传输:
stage ("打包") {
agent { label "slave_node_1" }
steps {
// 节点1上,把 Build 目录下的都打包;
stash (name: "buildPkg", includes: "build/**/*")
}
}
stage ("冒烟部署") {
agent { label "slave_node_2" }
steps {
// 节点2上,解包
unstash ("buildPkg")
}
}
8.4 节点的后置清理
在流水线多节点切换的时候,需要注意下你所在的节点是哪个,千万别晕头了。
pipeline {
agent { label "master" }
stages {
stage ("测试") {
agent { label "slave_node_1" }
steps {
}
// 阶段后置
post {
always {
// 清理 slave_node_1 的构建空间
cleanWs()
}
}
}
}
// 流水线总后置
post {
always {
// 只清理 master 节点的构建空间
cleanWs()
}
}
}
多节点的时候,一定要记得分别清理节点。
9. 总结
通过使用合理的技术平台,把人与事合理的关联,现代软件开发活动中,CI 是必不可少的流程,开发人员身在其中,CI 以代码活动为起点,构建结果能能快速响应到对应人,并提供手段让对应人快速解决,最后提供直观的报告。我们通过 Jenkins(CI平台) + GitLab(仓库)来演示完整搭建流程,展示一个可实践的过程。一切都是为了软件开发效率和版本质量。