本文介绍一种基于码云的持续集成工作流及其实现,读者可以通过阅读本文,从 0 实现一个生产级以 Gitee 仓库为核心的云原生持续集成工作流。
在日常开发过程中,通常会有多个业务或功能模块同时开发,这些在 git 工作流中都以分支的形式共存在一个代码仓库中,当开发完成后向发布分支提交 PR 以合并到发布分支完成发布,这时我们的测试工作就落到了对这些 PR 的部署和测试上,对于多个 PR 的部署我们有什么方便、快捷、自动化的部署方案呢?
于是本文就以开发环境中多 PR 同时部署和测试为例,实现一个包含了 Gitee 源码仓库、 Jenkins 、 Harbor 、 Kubernetes 、 Helm 等功能组件,可以实现从代码托管到发布上线的整套持续集成流程。通过自动化处理构建过程,极大的简化了日常迭代工作的复杂度。
工作流架构图
架构组件说明
- 码云 :国内最大代码托管平台、全球第二大代码托管平台、全球最大中文代码托管平台。提供代码托管、代码质量分析、项目管理、代码演示等一站式企业级公有云服务
- Gitee Jenkins Plugin :码云基于 GitLab Jenkins Plugin 开发的 Jenkins 插件。用于配置 Jenkins 触发器,接受码云平台发送的 WebHook 触发 Jenkins 进行自动化持续集成或持续部署,并可将构建状态反馈回码云平台。
- Harbor :由 VMware 开源的 Docker 镜像仓库,作为私有镜像仓库最好的选择。
- Jenkins :基于 Java 开发的一种持续集成工具
- Kubernetes :是用于自动部署、扩展和管理容器化( containerized )应用程序的开源系统。
- Helm : Kubernetes 的包管理器
工作流程说明
1. 编码并推送到码云
开发者编码完成后,将代码推送到 码云 ,通过触发由项目管理员预设的 Webhook 规则触发 Jenkins 作业。
这里我们使用码云的 Gitee-Jenkins-Plugin 插件完成 Jenkins 端和码云端的配置。 Jenkins 安装及配置过程见 Jenkins with Gitee-Jenkins-Plugin ,有需要的读者可以前往该文档进行参考。
2. 在 Jenkins 中根据项目中编写的 Jenkinsfile 执行完整的构建和发布流程。
Jenkinsfile 是一个文本文件,其中包含 Jenkins Pipeline 的定义,通常和源码一起管理。
Jenkins Pipeline 是对流程的自动表达,用于将软件从版本控制一直传递到用户和客户。开发过程中对软件所做的每项更改(在源代码管理中进行的)都需要经过复杂的过程才能发布。此过程涉及以可靠且可重复的方式构建软件,以及通过多个测试和部署阶段来逐步升级已构建的软件(在 Jenkins 中称为一个 build )。
Jenkins Pipeline 提供了一组可扩展的工具集,用于通过管道特定于域的语言(DSL)语法以代码的形式( pipelines "as code" )对简单到复杂的交付管道进行建模。
下面的流程图是在 Jenkins Pipeline 中轻松建模的一种 CD 方案的示例(图片来自官方文档):
这里我们以一个简约而不简单的 Jenkinsfile 来对整个构建( build )过程进行逐讲解,同时介绍一些常用的 pipeline 语法。
首先上 Jenkinsfile :
pipeline {
agent any
stages {
stage('build images and assets') {
when {
not {
anyOf {
environment name: 'giteePullRequestState', value: 'closed'
environment name: 'giteePullRequestState', value: 'merged'
}
}
}
failFast true
parallel {
stage('add start comment to GiteePR') {
steps {
addGiteeMRComment comment: "+ CI triggered, building... [BUILD](" + env.RUN_DISPLAY_URL + ")"
}
}
stage('build frontend assets') {
steps {
sh '''
set -u
if [[ $(echo $giteePullRequestDescription|grep without_compare|wc -l) -gt 0 ]]; then
echo "skip compare"
rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid/public
mkdir -p $JENKINS_HOME/nfs/$giteePullRequestIid/public
cp -r $JENKINS_HOME/nfs/global/public/assets $JENKINS_HOME/nfs/$giteePullRequestIid/public/
cp -r $JENKINS_HOME/nfs/global/public/webpacks $JENKINS_HOME/nfs/$giteePullRequestIid/public/
else
rm -rf $JENKINS_HOME/atompi_workspace/assets-builder/atompi
rm -rf $JENKINS_HOME/atompi_workspace/assets-builder/out
cp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/assets-builder/atompi
cd $JENKINS_HOME/atompi_workspace/assets-builder && DOCKER_BUILDKIT=1 $JENKINS_HOME/bin/docker -H tcp://docker:2375 build -o out .
rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid/public
mkdir -p $JENKINS_HOME/nfs/$giteePullRequestIid/public
cp -r $JENKINS_HOME/atompi_workspace/assets-builder/out/* $JENKINS_HOME/nfs/$giteePullRequestIid/public/
fi
'''
}
}
stage('build frontend images') {
steps {
sh '''
set -u
rm -rf $JENKINS_HOME/atompi_workspace/frontend/atompi
cp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/frontend/atompi
cd $JENKINS_HOME/atompi_workspace/frontend && $JENKINS_HOME/bin/docker -H tcp://docker:2375 build -t hub.atompi.cc/atompi_ci/frontend:v3.0.0-$giteePullRequestIid .
$JENKINS_HOME/bin/docker -H tcp://docker:2375 push hub.atompi.cc/atompi_ci/frontend:v3.0.0-$giteePullRequestIid
$JENKINS_HOME/bin/docker -H tcp://docker:2375 rmi hub.atompi.cc/atompi_ci/frontend:v3.0.0-$giteePullRequestIid
'''
}
}
stage('build backend image') {
steps {
sh '''
set -u
rm -rf $JENKINS_HOME/atompi_workspace/backend/atompi
cp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/backend/atompi
cd $JENKINS_HOME/atompi_workspace/backend && $JENKINS_HOME/bin/docker -H tcp://docker:2375 build -t hub.atompi.cc/atompi_ci/backend:v3.0.0-$giteePullRequestIid .
$JENKINS_HOME/bin/docker -H tcp://docker:2375 push hub.atompi.cc/atompi_ci/backend:v3.0.0-$giteePullRequestIid
$JENKINS_HOME/bin/docker -H tcp://docker:2375 rmi hub.atompi.cc/atompi_ci/backend:v3.0.0-$giteePullRequestIid
'''
}
}
}
}
stage('deploy') {
when {
not {
anyOf {
environment name: 'giteePullRequestState', value: 'closed'
environment name: 'giteePullRequestState', value: 'merged'
}
}
}
steps {
sh '''
set -u
cd $JENKINS_HOME/atompi_workspace/CI-atompi-helm && sed "s/CPRID/${giteePullRequestIid}/g" values.yaml.template > values.yaml
cp $WORKSPACE/config/atompi.yml.cm ./charts/backend/templates/configmap-atompi-yml.yaml
cp $WORKSPACE/config/environments/production.rb.cm ./charts/backend/templates/configmap-production-rb.yaml
$JENKINS_HOME/bin/helm uninstall -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIid || echo "release does not exists"
sleep 10 && $JENKINS_HOME/bin/kubectl delete ns ci-atompi-$giteePullRequestIid || echo "ns ci-atompi-$giteePullRequestIid does not exists"
sleep 5 && $JENKINS_HOME/bin/kubectl create ns ci-atompi-$giteePullRequestIid || echo "namespace already exists"
cd $JENKINS_HOME/atompi_workspace/CI-atompi-helm && $JENKINS_HOME/bin/helm upgrade -i -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIid ./
'''
addGiteeMRComment comment: '''
<details>
<summary>CI Opened</summary>
部署已完成,正在启动服务。[点我测试](http://''' + env.giteePullRequestIid + '''.atompi.cc)
访问该 url 前 需要本地 dns 设置为192.168.1.1
</details>
'''
}
}
stage('delete') {
when {
anyOf {
environment name: 'giteePullRequestState', value: 'closed'
environment name: 'giteePullRequestState', value: 'merged'
}
}
steps {
sh '''
set -u
echo $giteePullRequestState
$JENKINS_HOME/bin/helm uninstall -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIid
sleep 30
$JENKINS_HOME/bin/kubectl delete ns ci-atompi-$giteePullRequestIid
rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid
curl -X DELETE -H 'Accept: text/plain' "http://hub.atompi.cc/api/repositories/atompi_ci/backend/tags/v3.0.0-$giteePullRequestIid"
curl -X DELETE -H 'Accept: text/plain' "http://hub.atompi.cc/api/repositories/atompi_ci/frontend/tags/v3.0.0-$giteePullRequestIid"
'''
addGiteeMRComment comment: "+ CI Closed"
}
}
}
post {
failure {
addGiteeMRComment comment: "+ CI build failure! [BUILD](" + env.RUN_DISPLAY_URL + ")"
}
aborted {
addGiteeMRComment comment: "+ CI build aborted! [BUILD](" + env.RUN_DISPLAY_URL + ")"
}
}
}
- pipeline {...} :在声明式的 Pipeline 语法中, pipeline 块定义了整个管道中完成的所有工作。
- agent any :用于说明在任何可用 agent 上执行此管道或其任何阶段。 agent 即 Jenkins 集群中的构建节点,我们可以给这些节点指定标签,让某些有特定需求的构建过程在这些带有特定标签的节点上运行。
- stages {...} : 包含管道的构建步骤集
- stage('build images and assets') {...} : 定义其中某个构建过程(括弧中的内容为该 stage 的标题,用于展示在 Jenkins web 界面及日志中),其中 when 块定义了执行本 stage 的判断条件,如果为真则执行,否则跳过; steps 块定义了本 stage 真正执行的操作步骤,如 sh 表示在 agent 上执行 shell 脚本、 addGiteeMRComment 表示通过 Gitee-Jenkins-Plugin 插件调用码云接口,向当前构建的 PR 发送评论信息。
- failFast true 及 parallel {...} : 我们可以看到某些 stage 块中有 parallel {...} 块,同时 parallel {...} 块中又包含多个 stage 块,这样的语法的意思是在 parallel {...} 块中的 stage 为并行执行的,不在 parallel {...} 中的 stage 是按照从上到下的顺序串行执行,只有在上一个 stage 成功执行完后才会进入下一个 stage ,而 parallel {...} 块中定义的 stage 会并行执行, parallel {...} 块前一行的 failFast true 表示当 parallel {...} 块中的某个 stage 出现错误时,整个 parallel {...} 块都退出,并结束 parallel {...} 块中的所有 stage 不管这些 stage 是否执行完成,同时标记上层 stage 为错误退出。
- post {...} : 定义所有 stages 执行完成后的后续操作,不一定需要 post 块,当我们需要根据 stages 结束状态来判断是否执行后续操作时,我们可以定义一个 post 块,如本案例中定义了当 stages 结果为 failure (失败)或 aborted (拒绝,通常时人为的,比如 web 界面上手动停止本次构建)时将构建信息发送到码云的 PR 评论中。
本案例的 pipeline 语法到这里就介绍完了,更详细的语法规则可查看 官方在线文档 。
接下来我们对整个构建流程再进行一遍梳理,详细的说明一下本案例中的 pipeline 干了什么,同时也是对本文介绍的工作流做一个全面的讲解。
- 当 Jenkins 收到码云发送的 Webhook 请求并匹配到触发规则时,我们配置的 Job 进入 build 状态。
- build 开始之后, Jenkins 通过 git 插件从码云拉取指定代码到 workspace 同时读取代码仓库中的 Jenkinsfile ,获取到 Jenkinsfile 后, Jenkins 开始按照 Jenkinsfile 中声明的流程开始执行构建
- 进入 stages 中的第一个 stage : build images and assets , 该 stage 执行如下操作:判断当前 PR 的状态,不为 closed 或 merged 则执行后面的操作(对于已经关闭或合并的 PR 我们不需要再执行构建);如果判断结果为真,则执行加下来的并行执行的 stage ;并行执行:向码云当前 PR 页面发送构建开始的评论信息,并附带本次构建的链接,方便 PR 负责人实时查看构建过程;并行执行:从 PR 描述( Gitee-Jenkins-Plugin 插件可以通过环境变量 $giteePullRequestDescription 获取 PR 的描述文本 ) 判断是否需要编译前端静态资源,再执行后面的编译前端静态资源并推送到前端静态资源共享的 nfs server 、构建前端 docker 镜像并推送到 harbor 、构建后端 docker 镜像并推送到 harbor ,这些构建都是通过 sh 定义一个 shell 脚本,在 agent 上执行这个脚本完成构建;
- 并行操作执行完成后开始执行部署的 stage :values.yaml.template
- 对于已经合并或者关闭的 PR ,在码云上操作 PR 状态修改为“关闭”或者“合并”时,再次触发 Jenkins 流水线,同时进入 delete 的 stage ,该 stage 的工作就是将已部署的服务从 Kubernetes 集群中删除并将不再使用的 docker 镜像和静态资源从 harbor 和 nfs server 中删除,完成整个 CI 系统的清理工作,让系统可以持续运行而不需要认为干预。
至此我们在 Jenkinsfile 中定义的流水线就走完了。
3. 发布完成后用户即可通过 Kubernetes 暴露的 Ingress 请求入口访问我们发布的服务。
发布完成后,我们通过在 Kubernetes 设置集群内服务的访问方式来让用户能够访问到部署在 Kubernetes 集群中的服务。我们可以配置 LB 、端口转发或 DNS 配置等,以访问群集中的应用程序。
这里我们通过在 Kubernetes 集群中安装 Ngins Ingress controller 同时配置 Ingress 规则来让用户访问集群内服务。
Ingress 是一个 API 对象,它定义了允许外部访问群集中服务的规则。更多关于 Ingress 的说明见 官方文档
Ingress 的安装及配置见文档: Kubernetes
架构组件部署
- Harbor
- Kubernetes
- Helm : Helm 从 v3 版本开始不再需要安装 tiller ,对于热衷于使用 Helm 来管理 Kubernetes 应用安装包的用户来说,无疑是史诗级升级。从 v3 版本开始,我们只需要下载 Helm 二进制文件即可直接与 Kubernetes 集群交互,前提是 ~/.kube/ 目录下存在有权限的 config 文件,这对已经安装过 Kubernetes 集群的人来说并不是个问题。 Helm 二进制文件 下载地址 。
- Jenkins with Gitee-Jenkins-Plugin
扩展
本文案例中的 Jenkinsfile 所定义的流水线适用于开发环境多 PR 同时部署测试的使用场景。我们只需要对其中部分 stage 稍作修改,同样可以应用与生产环境的构建与部署。比如,对于 release 环境我们只需要构建和发布 release 分支即可,因此不存在 PR 的概念,也不需要执行 PR 评论的操作。我们可以将 PR 状态的判断、 addGiteeMRComment 去除;将 agent 只向生产环境的构建节点;将 docker 镜像推送至生产环境的 harbor 仓库;使用生产环境的 helm chart 将 docker 镜像发布到生产环境的 Kubernetes 集群。对于不是部署到 Kubernetes 集群的应用,我们可以将构建 docker 镜像的操作替换为构建制品(如:二进制文件、 jar/war 包等)的操作,同时将 docker push 操作替换为将制品推送到制品库(如: nexus 等)的操作,这时,发布将不再使用 helm 工具,而是使用我们自定义的发布流程工具(如: shell 脚本、 ansible-playbook 等)。总之,本文的案例是对基于码云的云原生持续集成工作流的一个实现参考,我们可以在这个工作流的基础上创造更多的最佳实践。
当然,本文实现的案例也存在的些许不足,比如对于多 PR 的部署,总是存在这样的场景:有多个 PR 同时更新了代码,这时候的构建队列是串行的,在有限的节点资源下,我们的构建工作会出现饱和并且需要排队的现象,这样对于一个需要编译前端静态资源的 PR 来说需要等待的时间会非常漫长,从而导致队列中的其他构建一同等待,降低了构建效率。在此,本文给出一种解决方案,同时也是作者再在使用的一种方案:将编译静态资源的工作下放到每一个部署中。 Jenkins 构建工作是瞬时的,而部署在 Kubernetes 集群中的应用是长时间运行的,由此我们通常会为 Kubernetes 集群分配更多的节点而尽量减少 Jenkins 集群的节点,当然我们可以将 Jenkins 部署在 Kubernetes 集群中,共享 Kubernetes 集群资源,但我们为了管理方便,通常将这两个角色分离开来。因此,对于开发环境的多 PR 部署测试的使用场景,我们可以将工作量小的、统一的工作交给 Jenkins 执行,而需要大量资源的编译工作下放到每一个部署中,即 Kubernetes 集群中,使用 Kubernetes 集群资源来并向的编译多个 PR 的静态资源,这样每一个 Jenkins 作业只需要很短的时间完成配置和部署的工作即可结束本次构建,立刻开始下一个构建,这样排队时长就大幅度缩减了。
为了实现这种方案,我们需要提前构建最新的基础代码仓库分支(如某一批 PR 是基于某个分支的最新提交开始的,我们可以把 release 分支或者 master 分支作为基础分支,这些分支通常是最近发布到生产环境的分支)的 docker 镜像作为基础镜像,同时编译一次静态资源作为基础,后续的 PR 编译静态资源时可以基于这个基础进行增量构建,这样可以进一步减少编译时间。
我们在每天发布生产环境时间触发 release 构建的作业,执行一次基础环境构建,并推送到开发环境的 harbor 及 nfs server ,在此基础上后续的 PR 在发布时不再单独构建 docker 镜像,在 helm chart 中我们把 docker image tag 修改为固定的 release 版本。
在 PR 部署完成后,我们增加了一个 modify 的 stage ,这个 stage 的工作就是通过 kubectl 工具在指定的 PR 部署中执行特点的编译脚本,在部署完成( helm chart 发布完成并切所有 pod 均成功启动 )时在指定的 pod 中执行编译工作,这样就实现了使用 Kubernetes 集群中的资源完成工作量较大的编译工作。
实现的 pipeline 如下:
stage('modify') {
when {
not {
anyOf {
environment name: 'giteePullRequestState', value: 'closed'
environment name: 'giteePullRequestState', value: 'merged'
}
}
}
steps {
sh '''#!/bin/bash
set -u
if [[ $(echo $giteePullRequestDescription|grep without_compile|wc -l) -gt 0 ]]; then
min=1
max=10
while [ $min -le $max ]
do
if [[ "Running" == $($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{print $3}') ]]; then
BACKEND_POD_NAME=$($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{print $1}')
MIRACLE_POD_NAME=$($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompife|awk '{print $1}')
$JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $BACKEND_POD_NAME bash /nohup_pull.sh $giteePullRequestIid
$JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $MIRACLE_POD_NAME bash /nohup_pull.sh $giteePullRequestIid
break
else
min=`expr $min + 1`
sleep 60
fi
done
else
min=1
max=10
while [ $min -le $max ]
do
if [[ "Running" == $($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{print $3}') ]]; then
BACKEND_POD_NAME=$($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{print $1}')
MIRACLE_POD_NAME=$($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompife|awk '{print $1}')
$JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $BACKEND_POD_NAME bash /nohup_compile.sh $giteePullRequestIid
$JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $MIRACLE_POD_NAME bash /nohup_pull.sh $giteePullRequestIid
break
else
min=`expr $min + 1`
sleep 60
fi
done
fi
'''
addGiteeMRComment comment: '''
<details>
<summary>CI Opened</summary>
部署已完成,正在启动服务。[点我测试](http://''' + env.giteePullRequestIid + '''.atompi.cc)
访问该 url 前 需要本地 dns 设置为192.168.1.1
</details>
'''
}
当然,对于生产环境而言,我们是不能容忍编译工作占用生产环境资源的,所幸的是,在生产环境中我们不存在多 PR 同时部署的场景,因此就不需要将编译工作下放,我们正常的执行前面介绍的发布流程即可。