持续集成 (Continuous integration)是一种软件开发实践,即团队开发成员经常集成它们的工作,通过每个成员每天至少集成一次,也就意味着每天可能会发生多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早地发现集成错误。
持续部署(continuous deployment)是通过自动化的构建、测试和部署循环来快速交付高质量的产品。某种程度上代表了一个开发团队工程化的程度,毕竟快速运转的互联网公司人力成本会高于机器,投资机器优化开发流程化相对也提高了人的效率,让 engineering productivity 最大化。
pipeline,即流水线,是jenkins2.X的新特性,是jenkins官方推荐使用的持续集成方案。与传统的自由风格项目不同,它是通过jenkins DSL 编写代码来实现。相比于之前用户只能通过Web界面进行配置的方式来定义Jenkins任务,现在通过使用jenkins DSL 和 Groovy 语言编写程序,用户可以定义流水线并执行各种任务。
那么重点来了,流水线代码写在哪里呢?
答案就是Jenkinsfile。
在jenkins 2 中,流水线配置可以从jenkins中剥离出来。在自由风格等项目中,任务的配置都是以配置文件的形式保存在jenkins的服务器上的,这意味这所有的配置的变更都依赖于jenkins的web界面。当配置中使用了许多插件时,去维护他人构建的配置就十分繁琐。在流水线项目中,可以在Web界面中编写流水线脚本,也可以将脚本以文本形式保存在外部的版本控制系统中,这个文本就是Jenkinsfile。
对于git项目,推荐使用多分支流水线。将任务配置和流水线信息保存在Jenkinsfile中,把jenkinsfile保存在项目的根目录下。不同的项目和分支都会有自己的Jenkinsfile,其内容各不相同。这样一个多分支流水线 project 可以对不同的分支代码进行持续集成和持续部署。
使用Jenkinsfile可以像管理项目中代码一样通过文件的形式来管理jenkins任务,支持历史追溯和差异对比等功能。
流水线语法有两种:一种是脚本式流水线,另一种是声明式流水线。下面对两种语法进行简单介绍。更详细的内容请查阅官方文档。
流水线代码就是 Groovy 脚本,其中插入了部分针对jenkins的DSL步骤。这种方式几乎没有结构上的约束,程序流程也基于Groovy语法结构实现。这种方式更加灵活,但是需要会使用Groovy。
Jenkinsfile (Scripted Pipeline)
node {
stage('Example') {
if (env.BRANCH_NAME == 'master') {
echo 'I only execute on the master branch'
} else {
echo 'I execute elsewhere'
}
}
}
相对于脚本式流水线的灵活,声明式流水线比较严谨。它的结构更加清晰,更加接近自由风格类型的项目。同时,清晰的结构有助于错误检查,上手简单,对Blue Ocean(下面会讲到)的支持也好。官方推荐使用这种语法格式。
通过下面的示例,可以看到一个stage就是一个阶段,每个阶段内是步骤和相关配置。agent 标识了阶段在哪个节点上卖弄执行。相关语法会在下面结合实际项目进行讲解。
如果声明式流水线不能满足我们的需求,那么我们可以在声明式脚本中使用脚本式流水线,具体方法在下一章中。
Jenkinsfile (Declarative Pipeline)
pipeline {
agent none
stages {
stage('Example Build') {
agent { docker 'maven:3-alpine' }
steps {
echo 'Hello, Maven'
sh 'mvn --version'
}
}
stage('Example Test') {
agent { docker 'openjdk:8-jre' }
steps {
echo 'Hello, JDK'
sh 'java -version'
}
}
}
}
在本文的实践环节中,会使用声明式语法来演示一个多分支流水线的project。
Blue Ocean 是 jenkins2 中全新的可视化界面(需要安装Blue Ocean插件)。它为流水线的每一个阶段都添加了图形化展示,可以查看每一个阶段的状态和进展,对每个阶段、每个任务都有点选式日志查看的功能,十分清晰。
已经安装pipeline相关插件。实践的对象是一个git项目。一共三个分支:maste、test、script。每个分支下都有一个Jenkinsfile。
三个分支的配置基本相同,主要步骤如下:
拉取代码—>maven打包—>构建镜像—>测试—>推送harbor仓库—>部署发布
上述基本完成了一个项目的持续集成和持续部署
新建一个多分支流水线project,源码管理选择git,然后填上项目的git地址。如果权限正常的话,在保存后Jenkins会扫描项目的所有分支下的Jenkinsfile,自动创建流水线并执行。
查看新增的多分支流水线(我这里每个分支都已经构建过多次了)。
点击左侧的打开Blue Ocean进入可视化界面。
持续集成的源头就是获取最新的代码。让我们看下在pipeline 脚本中是如何做的。
stage('Git Pull') {
steps {
git(url: 'https://gitee.com/GJXing/luckymoney.git', branch: 'master',credentialsId: '6cde17d1-5480-47df-9e08-e0880762b496')
echo 'pull seccess'
}
}
echo 是输出信息,提示拉取成功,此处可有可无。
项目是一个SpringBoot的Java项目,通过maven构建jar包。
stage('Maven Build') {
steps {
sh 'mvn clean install'
}
}
sh 就是执行shell 命令
注意:在test分支中做了testNG测试,此时执行 ‘mvn clean install’ 会在打包时进行测试。如果test分支此阶段不想做测试,则打包时忽略测试
stage('Maven Build') {
steps {
sh 'mvn clean install -Dmaven.test.skip=true'
}
}
将jar包保存为“制品”
假设我们想保存script分支构建得到的jar包,那么可以通过
archive 'target/luckymoney-0.0.1-SNAPSHOT.jar'
在进行下一阶段前,先解决一个比较重要的问题,在流水线中如何配置环境变量。
environment {
IMAGE_NAME = 'harbor.guojiaxing.red/public/springbootdemo'
CONTAINER_NAME = 'luckymoney'
}
声明式流水线的环境变量配置是声明在environment块中。这里我声明了两个环境变量,一个是镜像名称,一个是容器名称。这两个变量在后面的阶段中会反复使用。
environment 可以声明在pipeline块下作用于整个配置,也可以声明在一个stage下只作用于一个阶段。
环境变量的调用方式与shell一样,如:${CONTAINER_NAME}
pipeline 针对docker做了分装,其自己定义了一套语法规则来进行容器的操作。
例如:
build(image[,args])
使用当前目录的Dockerfile,运行docker build来创建一个镜像并打上标签。
Image.run([args,command])
使用docker run来运行一个镜像,同时返回一个容器。
Image.pull()
运行 docker pull
除了以上列出的几个,还有其他的许多方法。但是我并不愿意使用这些方法。原因有两个,一个是docker 命令本身就不复杂,使用起来就比较方便。另一个原因是使用docker命令比二次分装的方法更加灵活。
下面正式开始进行镜像的构建
stage('Build Image') {
steps {
sh '''VERSION=$(date +%Y%m%d%H%M%S)
echo "$(date +%Y%m%d%H%M%S)" > ${WORKSPACE}/VERSION
echo "building image: ${IMAGE_NAME}_${BRANCH_NAME}:${VERSION}"
docker build -t ${IMAGE_NAME}_${BRANCH_NAME}:${VERSION} .'''
}
}
镜像由镜像名称和镜像TAG构成。
对于镜像名称,由于是多分支流水线,这里采用基础镜像名称+分支名称组合的形式。
tag的作用就是表明版本,此处采用构建时间,精确到秒。将构建时间保存在文件中,之后所有的阶段都读取该文件来获取版本。当然我们还可以选择构建号或自定义等变量作为tag(如下),但是这种方式在唯一性方面不如时间方式。此处可根据实际业务进行选择。
sh ' docker build -t ${IMAGE_NAME}_${BRANCH_NAME}:${BUILD_NUMBER} .'
在每次构建后,我们都应该对当前版本的代码做自动化测试,通过冒烟等测试来评估当前版本的质量。保证我们要发布版本的质量是过关的,如果任何一个测试步骤失败则后续阶段不会继续,应用也不会发布。这也是持续集成的重要思想。
严格的说,服务的启动不应该和测试放在一个阶段,应该在一个单独的阶段中执行,此处为了方便进行了简写。
了解docker就会知道,同名的容器没有被删除,那么容器是无法启动的。如果由于上次构建失败或其他原因导致目标容器名称的容器存在,那么在容器启动前进行环境清理就很有必要。
steps {
script {
try {
sh '''environmental_clean(){
docker_ps=`docker ps | grep ${CONTAINER_NAME}_${BRANCH_NAME}`
docker_psa=`docker ps -a | grep ${CONTAINER_NAME}_${BRANCH_NAME}`
if [[ 0 -eq $docker_ps ]];
then
#容器未启动
echo "容器${CONTAINER_NAME}_${BRANCH_NAME}未启动"
else
echo "停止容器"
docker stop ${CONTAINER_NAME}_${BRANCH_NAME}
fi
if [[ 0 -eq $docker_psa ]];
then
echo "容器${CONTAINER_NAME}_${BRANCH_NAME}不存在"
else
echo "删除容器"
docker rm ${CONTAINER_NAME}_${BRANCH_NAME}
fi
}
#docker 环境清理
environmental_clean'''
}
catch (exc) {
echo '环境不需要清理'
}
}
脚本很简单,通过判断docker进程是否存在来停止或删除容器。
重点是通过script块在声明式代码中引用了脚本式的代码。为什么要这样做呢?
前面已经讲过了这是一个容错处理,如果环境不干净就清理,但如果环境中无目标容器名称的容器从在,在‘docker_ps=docker ps | grep ${CONTAINER_NAME}_${BRANCH_NAME}
’执行完后会返回-1,与shell脚本不同,pipeline规定当返回结果不是表示成功时,流水线就会终止,此处为了流水线可以继续走下去,用try catch做异常处理。
如果不借助脚本式语法,声明式流水线可以在sh步骤开始加入 ‘set +e’,则在出现错误后不会停止。此处主要是为了演示如何在声明式代码中引用脚本式的代码。
启动容器
sh '''VERSION=$(cat ${WORKSPACE}/VERSION)
docker run -dit --name=${CONTAINER_NAME}_${BRANCH_NAME} ${IMAGE_NAME}_${BRANCH_NAME}:${VERSION}'''
流水线不仅可以串行也可以并行。在测试阶段我们就可以进行并行操作。
如图所示,在test分支的Test阶段,ui自动化、接口自动化、性能测试、单元测试等都可以并行执行。
在pipeline脚本中 parallel 块内的 stage 块会并行执行。
parallel {
stage('接口自动化测试') {
steps {
sh '''echo "进行接口自动化测试"
mvn clean test
echo "自动化测试完成" '''
}
}
stage('UI自动化测试') {
steps {
echo 'ui test'
}
}
}
将html报告进行发布。
在testNG执行完后会在项目test-output目录下生成report.html,将报告发布。安装 HTML Publisher plugin 插件。
post {
always {
publishHTML([
allowMissing: false,
alwaysLinkToLastBuild: false,
keepAll: true,
reportDir: "test-output",
reportFiles: "report.html",
reportName:"testNg report"
])
}
}
post块的作用是在所有步骤最后执行,可以作用于整个pipeline或一个阶段内。always表示不管是成功还是失败都执行。
代码测试完成后就可以将镜像推送到远程仓库了。
仓库是我自己搭建的harbor。
stage('Push to Harbor') {
steps {
withCredentials(bindings: [usernamePassword(credentialsId: 'e8dc0fa7-3547-4b08-8b7b-d9e68ff6c18f', passwordVariable: 'password', usernameVariable: 'username')]) {
sh 'docker login -u $username -p $password harbor.guojiaxing.red'
}
sh '''export VERSION=$(cat ${WORKSPACE}/VERSION)
echo "docker push ${IMAGE_NAME}_${BRANCH_NAME}:${VERSION}"
docker push ${IMAGE_NAME}_${BRANCH_NAME}:${VERSION}'''
}
pipeline中的鉴权方式是withCredentials(),这需要事先在jenkins配置中保存凭据。
登录harbor查看,发现镜像已经被push成功。
安装 SSH Pipeline Steps 插件。配置username and password 凭据。
stage('ssh deploy') {
steps {
script {
def remote = [:]
remote.name = 'gjx_server'
remote.host = 'www.guojiaxing.red'
remote.allowAnyHosts = true
withCredentials([usernamePassword(credentialsId: 'gjx-server', passwordVariable: 'password', usernameVariable: 'username')]) {
remote.user = "${username}"
remote.password = "${password}"
}
sshCommand remote: remote, command: "docker pull ${IMAGE_NAME}_${BRANCH_NAME}:${BUILD_NUMBER}"
}
}
ssh pipeline 更多的操作请查阅官方文档
在作用于pipeline全局的post中进行环境清理
always {
script {
try{
sh '''environmental_clean(){
docker_ps=`docker ps | grep ${CONTAINER_NAME}_${BRANCH_NAME}`
docker_psa=`docker ps -a | grep ${CONTAINER_NAME}_${BRANCH_NAME}`
if [[ 0 -eq $docker_ps ]];
then
#容器未启动
echo "容器${CONTAINER_NAME}_${BRANCH_NAME}未启动"
else
echo "停止容器"
docker stop ${CONTAINER_NAME}_${BRANCH_NAME}
fi
if [[ 0 -eq $docker_psa ]];
then
echo "容器${CONTAINER_NAME}_${BRANCH_NAME}不存在"
else
echo "删除容器"
docker rm ${CONTAINER_NAME}_${BRANCH_NAME}
fi
}
#docker 环境清理
environmental_clean
export BUILD_NUMBER=$(cat ${WORKSPACE}/BUILD_NUMBER)
docker rmi ${IMAGE_NAME}_${BRANCH_NAME}:${BUILD_NUMBER}'''
}
catch (exc) {
echo '镜像不需要删除'
}
}
安装 Email Extension Plugin 插件并完成邮件配置
在作用于pipeline全局的post中编写邮件配置
failure {
emailext (
subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' 自动化测试结果",
body: '''<body leftmargin="8" marginwidth="0" topmargin="8" marginheight="4" offset="0">
<table width="95%" cellpadding="0" cellspacing="0"
style="font-size: 11pt; font-family: Tahoma, Arial, Helvetica, sans-serif">
<tr>
<td><br />
<b><font color="#0B610B">构建信息</font></b>
<hr size="2" width="100%" align="center" /></td>
</tr>
<tr>
<td>
<ul>
<li>构建名称:${JOB_NAME}</li>
<li>构建结果: <span style="color:red"> ${BUILD_STATUS}</span></li>
<li>构建编号:${BUILD_NUMBER}</li>
<li>构建地址:<a href="${BUILD_URL}">${BUILD_URL}</a></li>
<li>GIT 分支:${BRANCH_NAME}</li>
<li>变更记录: ${CHANGES,showPaths=true,showDependencies=true,format="- 提交ID: %r
- 提交人:%a
- 提交时间:%d
- 提交信息:%m
- 提交文件:
%p
",pathFormat=" %p failure 为构建失败时执行,success 为构建成功时执行。
查看邮件:
options {
disableConcurrentBuilds()
timeout(time: 1, unit: 'HOURS')
}
triggers {
pollSCM('H/15 * * * *')
}
disableConcurrentBuilds() 表示多分支不允许同时构建
timeout(time: 1, unit: ‘HOURS’) 表示超时时间,1小时超时则构建失败
pollSCM(‘H/15 * * * *’) 表示每15分钟 轮询扫描源代码,如果有修改则构建,无修改则不构建。
script分支下Jenkinsfile
通过此次 Jenkins Pipeline 实践,对比于传统的自由风格项目,感受到流水线工程的强大和方便之处,也加深了我对CI/CD的理解。
此次从代码拉取到打包到测试再到部署,使用了maven、docker、testNG和harbor等工具,基本上覆盖了一般业务的部署流程,但对于生产环境来说还是不完善的,后期可以整合K8S进行部署。
对于一门新技术或工具的学习,我认为具体用到什么内容就学什么内容,没有必要把一个工具或技术完完全全掌握,一般也很难全部掌握。正所谓用到的才是有用的。即使如此,此次实践也覆盖了pipeline 80%以上的语法内容。
pipeline很强大、很好用,并且它还在不断的完善中,随着时间推移,未来其在CI/CD领域一定会有更大的作用。