CI / CD 需要做成什么样

  • CI:持续集成
  • CD:持续发布

CI / CD 是我们整个 Service Mesh 转型过程中最艰难的一个环节。

为什么最艰难?

CI / CD 本质上要解决的问题是:标准 + 自动化脚本 + 可视化 + 通知。

不同的公司情况不同,标准也会不同,因为在没有经验的情况下,很难制定出合理的标准。

没有标准也就不能有足够抽象好用的自动化脚本,为一个项目写一些脚本不难,写出抽象好用的脚本很难。

可视化和通知优先考虑开源产品,这两块在两年前也没什么可选的。

 

第一版简单粗暴有效

遇到这种情况比较好的解决办法就是先做一个 MVP,让大家用起来,在使用的过程中自然会冒出大量的改进意见。

两年前,我们公司的状况是几乎没有任何现成的 CI / CD,而且开发们用的开发语言很多。

当时也没有什么可选的技术方案。

最后只看到了一个 Jenkins Pipeline + Kubernetes Plugin 可以满足我们的需求。

它的基本用法就是基于 Groovy 语言,配合各种插件,把整个 CI / CD 的过程用脚本写出来。

关键的这份脚本叫Jenkinsfile,大致是这样的:

def registry = "private-docker-repository-address"
def namespace = "default"
def projectName = "user-service"
def imageName = "user-service:latest"

podTemplate(containers: [
  containerTemplate(name: 'maven', image: 'maven', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'helm', image: 'alpine/helm', command: 'cat', ttyEnabled: true)
],
volumes: [
  hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')
]) {
  node(POD_LABEL) {
    checkout scm

    stage('Test & Build Jar') {
      container('maven') {
        sh "mvn clean install"
      }
    }

    stage('Build Docker') {
        container('docker') {
            docker.build(imageName)
            docker.withRegistry(registry) {
                docker.image(imageName).push()
            }
        }
    }

    stage('Deploy') {
        container('helm') {
          sh """helm upgrade ${projectName} ./charts/${projectName} -i --namespace ${namespace} \
                --set image=${registry}/${imageName} \
                --set resources.limits.cpu=2 \
                --set resources.limits.memory=1Gi \
                --set resources.requests.cpu=1 \
                --set resources.requests.memory=1Gi \
                --debug"""
        }
    }
  }
}

它的大致运行过程是 Jenkins 会读取你这份Jenkinsfile文件,根据你声明的容器组装成一个Pod,然后在 Kubernetes 集群里运行。

Jenkinsfile写好后,在 Jenkins 里创建一个 Pipeline 项目就行了。

Jenkins Create

大致的项目结构也很简单:

.
├── Dockerfile
├── Jenkinsfile
├── charts
│   └── user-service/
└── src/

这个方案的最大优点是什么?自由,灵活。

开发可以自由定制自己的 CI / CD 过程,我们不需要任何标准,无论是什么语言,是新项目还是老项目,大家都可以定制化自己的脚本。

有点像那个迪斯尼小路的故事,在地上种上草任由游客踩踏,最后出来的路就是最佳路径。嗯,有点心灵鸡汤的味道。

 

第一版中遇到的问题

第一版简单粗暴,期间也遇到了不少小问题,这里可以说说解决方案。

 

Docker in Docker

我们的 Jenkins 运行在 Kubernetes 中,也就是说我们的 Jenkins 运行在 Docker 中。然后我们的 Jenkins 又要跑docker build,那这里就会遇到很经典的 Docker in Docker 的问题。

查了一些资料后,你会发现实现 Docker in Docker 并不是一个合适的方案,而且我们的问题并不是真正的 Docker in Docker 问题。

使用 Docker-in-Docker 来运行 CI 或集成测试环境?三思!

我们想要做的只是在一个 Docker 容器里去构建一个镜像,我们并不是想要在这个容器里再跑一个容器,这两个有本质的区别。

如果只是想要构建一个镜像,那么能否让宿主机的 Docker 来做呢?答案是可行的!Docker 是 Client/Server 模式,并且通过 Unix Socket 通讯。

看我们上面的Jenkinsfile里有一行配置:

hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')

这行配置的作用就是把宿主机的 Docker Unix Socket 映射到容器内,这样容器内的 Docker 就可以直接和宿主机的 Docker Daemon 通讯了。

 

隔离

因为 Jenkins 里的跑的 Job 可以直接操作宿主机 Docker 了,而且 Jenkins 在构建的时候消耗的资源也不少,所以把 Jenkins 独立部署在一组机器上会更安全。

这里很明显就要用到 Kubernetes 的污点和容忍性Pod节点分配功能了。

首先给 Jenkins 相关的Pod配置节点分配,让它只跑在特定的一组机器上。但这时候别的Pod也会过来运行。

所以还需要给这组机器打上污点(Taints),一般的Pod都有洁癖,无法忍受这个污点,这样就不会在这台机器上跑了。

接下来再在 Jenkins 相关的Pod上配置容忍性,代表着它们可以容忍这个污点。

最终的效果就是只有 Jenkins 相关的 Job 可以跑在这批机器上了。

具体的配置方法是打开 Jenkins 配置界面: /configure

先配置一个 Jenkins Kubernetes 模版,并把它设置成默认模版,默认安装完应该就会有一个模版:

Jenkins Pod Template

然后再给这个模版配置容忍性:

Jenkins Pod Template Yaml

除此以外,这里的默认模版其实还可以做很多事情,例如上面解决 Docker in Docker 的时候手动在Jenkinsfile里写了hostPathVolume,其实这个可以写在模版里,所有任务就可以通用了。

 

Light Merge

一些稍微大一点的项目就会有很多人一起开发,大家都在不同的分支上,但是发布的时候希望临时把这些合并一下。

因为 CI / CD 脚本都是自己把控的,无非就是自己实现一些脚本可以。如果有这种需求的话只需要修改一下脚本就行了。

利用 Jenkins Pipeline 相关插件可以很轻松的实现:

sshagent {
  sh 'git config --global user.email "devops@dozer.cc"'
  sh 'git config --global user.name "devops"'
  sh 'git merge origin/feature/test'
}

 

从实践中总结

迪斯尼的小路走出来了,如果继续放任不管,最终所有路面都会被踩秃,所以及时根据反馈铺上正经的道路很重要。

经过一段时间的实践后和思考后,我们觉得下面还需要做这么几件事情:

  1. 制定标准,包括项目结构,整体流程等
  2. 重写可以通用的自动化脚本,提高开发的开发效率,也便于全局更新自动化脚本
  3. 更强大的发布控制

 

制定标准

我们基于各种实践和探索,在制定标准的过程中主要参考了如下几个原则:

  • 和开源社区接轨
  • 约定约于配置,减少开发的配置工作量
  • 足够灵活,除了默认配置外允许定制化
  • 参考 GitOps 的理念,一切配置和项目放在一起

 

明确 CI 流程

第一步要先明确一下适合我们公司的发布流程,每个公司情况都不一样,要根据自身情况来决定。

我们 CI 目前大致分为这几个步骤:

  1. Git Push 触发
  2. Light Merge
  3. 更新 Git 子模块
  4. 测试,编译,打包等(不同语言步骤不同)
  5. 构建 Docker 镜像
  6. 发布 Docker 镜像
  7. 构建 Helm Charts
  8. 发布 Helm Charts
  9. 通知 CD 系统

 

明确 CD 流程

CD 流程不多,但做起来却比 CI 难做,我们主要分为这几个步骤:

  1. CI 触发
  2. 支持手动修改发布参数
  3. 手动发布
  4. 支持发布过程中自动金丝雀发布
  5. 支持手动回滚

 

统一项目结构

基于上面的 CI / CD 流程,有很多信息需要告诉对应的工具,那怎么做呢?

这时候就要用到上述提到的几个原则了,例如构建 Docker 镜像用的Dockerfile,一般都是直接放在根目录的;Jenkinsfile也是如此;Helm Chart 一般放在./charts/project-name里。

先参考开源社区的最佳实践,然后再制定我们内部的约定:

  • 项目名称就是 Git 代码仓库的名称
  • Light Merge 自动合并所有 Pull Request 对应的分支
  • Docker 镜像名称就是项目名
  • Docker Image Tag 格式:{yyyyMMddhhmm}.{short reversion}
  • Helm Chart 名称就是项目名
  • Helm Chart 版本:{chart version}+{image tag}
  • 部署出来的服务名就是项目名

这些约定大多数没什么可说的,但 Light Merge 值得说一下。

最早之前,我们的 Light Merge 都是写死在各自的脚本里的,如果以后把自动化脚本抽象后,如何才能知道哪些分支要做 Light Merge 呢?

一种方案是根据名字来,因为设计良好的分支模型各个分支的合并方向是固定的。在测试环境,一般就是把所有的feature/*hotfix/*合并到测试分支就行了。

但是有些feature/*分支还在开发中,不想在测试环境发布怎么办?

后来我们想到了是否可以利用 Pull Request 来做。因为 Pull Request 是一个代表想要合并的意图,Pull Request 也是把代码提交到测试分支的必经之路,有了 Pull Request 也可以告诉对应人员需要做代码审查了。

所以 Pull Request 和 Light Merge 的出现时机是完全一致的。

 

统一自动化脚本

有了各种标准,那么下一步要做的就是统一自动化脚本了。 之前我们自动化脚本的最大问题就是大部分逻辑都是可以公用的,但是却在每个项目里保留了一份。

一开始我们提供了一份模板给大家“抄”,但这个模版也在不断更新,很难去推动大家修改自己的模版。

所以如果还是基于 Jenkins 的话如何才能尽量复用代码呢?

后来我们发现,在 Jenkins Pod Template 的配置里,还可以指定默认的容器。上面Jenkinsfile的例子里用了mavendockerhelm三个容器做不同的事情,但这几个都是非常基础的容器,开发还需要额外写不少代码。

那我们是不是可以自己做一个 CI 镜像,然后把所有 CI 的功能集成在里面呢?

答案是肯定的,经过简化后,Jenkinsfile可以做到非常简化,而且又不失灵活性:

podTemplate(
    containers: [
        containerTemplate(name: 'go', image: 'golang:latest', ttyEnabled: true, command: 'cat')
    ]
) {
    node(POD_LABEL) {
        checkout scm
        stage('Before Build') {container('ci') {sh "before_build"}}
        stage('Build') {container('go') {sh "make test && make linux"}}
        stage('After Build') {container('ci') { sh "after_build" }}
    }
}

其中,before_build会完成 CI 流程里的 2~3,after_build会完成 CI 流程里的 5~9。

需要更新 CI 自动化脚本的时候只要更新这个 CI 镜像就行了,开发不需要做任何改动。

 

灵活性

自动化脚本之所以可以这么简单,是因为有了大量的约定而无法大量的配置。但只要配置是可修改的,那么灵活性同样也可以保证。

例如有些项目它同一份代码可能要部署成不同的项目,那么项目名等于 Git 代码仓库名这个约定就失效了。

podTemplate(
    containers: [
        containerTemplate(name: 'go', image: 'golang:latest', ttyEnabled: true, command: 'cat')
    ],
    envVars: [
        envVar(key: 'PROJECT_NAME', value: 'block-service')
    ]
) {
    node(POD_LABEL) {
        checkout scm
        stage('Before Build') {container('ci') {sh "before_build"}}
        stage('Build') {container('go') {sh "make test && make linux"}}
        stage('After Build') {container('ci') { sh "after_build" }}
    }
}

那这里在Jenkinsfile里只需要注入环境变量就行了,非常方便。底层脚本都是根据环境变量来操作的,如果为空就按照默认约定自动生成。

 

另外还有一些项目,它会在同一个项目里构建多个镜像,发布成同一个服务,那after_build这个脚本底层其实也是调用了两外两个封装好的脚本,分别是push_imagepush_cd

那只要把after_build去掉,直接调用底层的脚本就行了:

例如我们在发布一些 Python 服务的时候,就需要额外构建一个 Nginx 镜像:

// Disable default after build
// stage('After Build') {container('ci') { sh "after_build" }}

stage('Build uWsgi Image') {
    container('ci') {
        sh("push_image --name user-service-uwsgi --file docker/uwsgi/Dockerfile")
    }
}

stage('Build Nginx Image') {
    container('ci') {
        sh("push_image --name user-service-nginx --file docker/nginx/Dockerfile")
    }
}

stage('Push to CD') {
    container('ci') {
        cmd = """push_cd \
            --image uwsgiImage=user-service-uwsgi \
            --image nginxImage=user-service-nginx """
        sh($cmd)
    }
}

 

更强大的发布控制

在我们做第一版 CI / CD 的时候,支持 Kubernetes 的 CD 开源工具几乎是空白。

我们也尝试过自研,但因为别的项目屡屡搁置,因为要做一个好用的 CD 工具对前端水平就是一个很大的考验。

“求求你,我不想再学了!”

这是在每个前端框架发布后都会看到的一句话,说多了都是泪,对后端来说真的是没精力去学那么多前端框架了。

还好在调研各种工具后,发现 Argo CD 非常好用。

它可以在界面上控制各种发布参数,Helm Chart 打包出来的包在不同的环境下会有不同的配置,需要一个地方来管理。

它也可以把整个发布过程做可视化,哪个环节出了问题,看日志非常方便。

最实用的就是,它可以手动控制回滚到特定版本。发布的时候出问题了,只要直接手动回滚就行了。

具体的用法可以看它官网的介绍。

然而它也不是万能的,金丝雀发布和蓝绿发布等都没有。所以虽然我们的计划里有这部分,但目前并没有实现

 

总结

目前为止,虽然我们新的 CI / CD 已经上线,但其实这个版本是刚完成没多久的,得到大家的试用反馈后还会有大量的问题和需求。

还好目前我们的新方案中,发布脚本的变动不需要开发的改动了。

可预见的其中一个需求就是金丝雀发布和蓝绿发布。虽然还没有实现,但是我们也已经有了一些方案。

相关的开源框架有 FlaggerArgo Rollouts

这两个我们都试用过,但都还缺点东西,所以我们后续的计划应该是基于其中某个做二次开发,来满足我们自身的需求。

 

另外,整个 CI / CD 的使用中,还有很多东西值得去做成约定,而不是让开发自己去写。

例如Dockerfile等,虽然有多语言,但一个同样是 Golang 的简单服务,它们的Dockerfile几乎是没有任何区别的。

所以这里还有很大的空间可以去进一步简化开发的工作量。

本作品由 Dozer 创作,采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。