流程
上一篇我们了解了CI相关的3个概念,分别是:
- continuous integration
- continuous delivery
- continuous deployment
一般CI/CD的完整流程大致为以下步骤
- 开发人员提交代码到代码仓库
- 代码仓库通过Webhook触发CI
- CI 系统拉取代码,执行构建操作
- 执行构建操作的时候,可能会设计到外部服务依赖等
- 构建完成之后,运行测试流程,生成测试报告
- 测试完毕之后上传最终产物到制品仓库
- 部署构建出来的应用到测试环境
- 测试环境测试没问题,创建版本,触发生产环境部署
- 部署到生产环境
工具
- Github - 代码管理
- Jenkins - CI流程
- Slack - 通知
- Nexus - 制品管理(maven, docker, etc...)
- Docker - 打包部署
实现
Github
Github自从提供免费的私有仓库之后,小公司已经可以直接使用私有仓库作为自己的开发代码仓库了,比起自建Gitlab,成本,效率,三方对接,都会舒服很多,不过如果有条件还是推荐机构付费帐号,毕竟管理起来还是很方便的。
- 开发 1-5人 -> 个人私有仓库
- 开发 5人以上 -> 机构付费仓库
这里人数不是关键,关键是你需要多少协同的功能。
Github的使用一般开发人员很快就能上手,关于管理方面这里提一些Tips:
- 分支策略
- https://open.leancloud.cn/git-branch-guide/ 这里可以参考Leancloud的规范,使用下来感觉还是很不错的,对于线上环境比较友好。
- Git Commit 风格
- 一般可以参考比较知名的开源项目,比如Augular,也可以参考Leancloud的。https://open.leancloud.cn/git-commit-message/
- Projects/Issues/Milestones/Wiki
- Github提供的这几个工具,可以在团队规模小的时候,基本上覆盖90%的需求,包括从项目进度,需求沟通,Bug反馈,版本规划,文档输出等。
- 分支保护策略
- 控制PR的Review人数,必须通过CI等规则。
这里推荐一个三方服务https://dependabot.com/ 用来检查代码仓库中依赖更新的一个机器人,可以设置频率或者日期检查。 之前是独立的,后来被Github收购了,目前免费。
Jenkins
Jenkins推荐使用Blue Ocean https://jenkins.io/projects/blueocean/插件,可视化界面,更现代化的UI风格,Jenkinsfile定义Pipeline。更多可以参考文档。
Jenkins的一些Tips:
- 可以用Github OAuth方式,打通账户体系,直接复用Github 组织的权限控制
- 使用Jenkinsfile定义Pipeline,实现配置代码化。
- 通过Github插件配置Webhook,达成一旦有push或者PR触发构建。
Slack
Slack是一款沟通协作的产品,最吸引人的是和大量的三方可以集成。在构建CI服务的时候,通知提醒是不可或缺的一部分。
Slack的一些Tips:
- 在Jenkins的Pipeline中,定义通知
- 构建开始
- 构建成功/失败
- 构建需要人工确认
- 构建ChangeLog
- 和Github的对接
- 新的Issues/PR通知
- 新的分支/代码提交通知
以上的两个插件都可以直接使用Slack提供的。具体配置参考文档即可。
Sonatype Nexus
针对continuous delivery我们一般都需要一个地方来存放构建出来的产物。Nexus基本上是最完整的产品了,之前在评估docker registry的时候,也看了Harbor,但是相比而言,Harbor还是比较简陋的,很多管理上的功能不够强大,另外也考虑其他语言的制品问题,比如Maven,NPM等,所以综合考虑选择Nexus。
Nexus的一些Tips:
- 关闭匿名访问,毕竟是私有仓库,很多时候公司内部的东西会提交上去。
- 配置不同的权限帐号,安全要做到前面
- 开发人员
- 发布服务器
- 运行服务器
- 根据情况配置存储
- 分离不同制品的存储
- 配置清理策略,尤其是Docker registry,因为构建很多,会飞快膨胀。
Docker
k8s是目前容器编排的标准,不过对于很多团队来说,k8s复杂度不是那么好掌控的。第一步可以先尝试简单的应用容器化,使用Docker和docker-compose来简化应用打包和部署流程。
容器化的一些Tips:
- 梳理应用容器化的关键点
- 状态业务,可以考虑使用外部Redis存储。
- 外部存储,NFS等。
- 配置文件env化,应用打包成镜像之后,镜像应该是环境无关的。
- 一个业务线的多个容器可以使用一个网络,避免网络冲突。
注意
本文只是提供了一个简单的CI/CD流程的思路和示例,实际应用中情况会复杂的多,这里面关于配置的处理,部署的处理并非最佳实践。只是一定程度上的过渡方案。
示例
这里提供一个Jenkinsfile的示例,里面基本涵盖了上面的所有流程。具体可以参考注释。包括:
- 拉取代码
- 发送构建通知
- 构建应用Docker镜像
- 推送到私有制品库
- 如果是生产环境,替换配置(也可以使用外部配置中心)
- 自动化部署测试环境
- 发送最终通知到Slack
pipeline {
agent any
environment {
// 定义制品库地址,服务器地址,docker相关信息
registry = 'dockerhub.xxx.com/demo-api'
dev_server = 'deploy@api-service.dev.localdomain'
prod_server = 'deploy@api-service.prod.localdomain'
project = 'demo-api'
docker_network = 'api-network'
docker_network_range = '172.25.0.0/16'
tag = env.GIT_COMMIT.take(7)
}
stages {
stage('Build') {
steps {
script {
passedBuilds = []
lastSuccessfulBuild(passedBuilds, currentBuild);
}
//Slack通知开始构建
slackSend(color: 'good', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Started <${env.RUN_DISPLAY_URL}|(Open)>")
sh './build.sh'
}
}
stage('Build Docker image') {
when {
// 只有Github的这两个分支,才会触发镜像构建,PR不触发
expression { BRANCH_NAME ==~ /(master|release)/ }
}
steps {
script {
// 推送镜像到特定私有仓库
docker.withRegistry('https://dockerhub.xxx.com', 'docker-xxx-private-id') {
def customImage = docker.build("${env.registry}:${env.tag}")
customImage.push("${env.tag}")
// 只有release分支才推latest tag
if (BRANCH_NAME == ~/(release)/) {
customImage.push("latest")
}
}
}
}
}
stage('Deploy - Test') {
when { branch 'master' }
steps {
switchContainer(env.dev_server, env.project)
}
}
stage('Deploy - Production') {
when { branch 'release' }
steps {
script {
def deployToProduction = true
try {
// 需要人工确认部署到生产环境
slackSend(color: 'warning', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Do you want to approve the deploy in production? <${env.RUN_DISPLAY_URL}|(Open)>")
timeout(time: 10, unit: "MINUTES") {
input message: 'Do you want to approve the deploy in production?', ok: 'Yes'
}
} catch (e) {
deployToProduction = false
}
if (deployToProduction) {
slackSend(color: 'good', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Approved and Deploying to production. <${env.RUN_DISPLAY_URL}|(Open)>")
println "Deploying to production"
//切换配置 处理测试和生产配置不同的问题
sh "cat /config/$env.project/prod.env > .env"
switchContainer(env.prod_server, env.project)
} else {
slackSend(color: 'good', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Ignore Deploy to production. <${env.RUN_DISPLAY_URL}|(Open)>")
println "Ignore Deploy to production"
}
}
}
}
stage('Clean docker image') {
when {
expression { BRANCH_NAME ==~ /(master|release)/ }
}
steps {
// 清理docker镜像
sh "docker rmi ${env.registry}:${env.tag} || true"
sh "docker rmi ${env.registry}:latest || true"
}
}
}
options {
timeout(time: 1, unit: 'HOURS')
}
post {
always {
script {
def changeLog = getChangeLog(passedBuilds)
echo "changeLog ${changeLog}"
slackSend(color: 'good', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Changes: ${changeLog}")
}
cleanWs()
}
aborted {
slackSend(color: 'warning', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Aborted <${env.RUN_DISPLAY_URL}|(Open)>")
}
success {
slackSend(color: 'good', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Success. Take ${currentBuild.durationString.replace(' and counting', '')} <${env.RUN_DISPLAY_URL}|(Open)>")
}
failure {
slackSend(color: 'danger', message: "${env.JOB_NAME} - ${env.BUILD_DISPLAY_NAME} Failed <${env.RUN_DISPLAY_URL}|(Open)>")
}
}
}
// 部署到指定服务器
def switchContainer(String server, String project) {
sh "ssh ${server} mkdir -p /apps/${project}"
sh "scp docker-compose.yml ${server}:/apps/${project}"
//追加tag环境变量
sh "echo TAG=${env.tag} >> .env"
sh "scp .env ${server}:/apps/${project}"
sh "ssh ${server} docker network create --subnet=${env.docker_network_range} ${env.docker_network} || true"
sh "ssh ${server} 'cd /apps/${project} && docker-compose -f /apps/${project}/docker-compose.yml pull'"
sh "ssh ${server} 'cd /apps/${project} && docker-compose -f /apps/${project}/docker-compose.yml down --remove-orphans -v'"
sh "ssh ${server} 'cd /apps/${project} && docker-compose -f /apps/${project}/docker-compose.yml up -d'"
sh "ssh ${server} docker image prune -f"
}
def lastSuccessfulBuild(passedBuilds, build) {
if ((build != null) && (build.result != 'SUCCESS')) {
passedBuilds.add(build)
lastSuccessfulBuild(passedBuilds, build.getPreviousBuild())
}
}
@NonCPS
def getChangeLog(passedBuilds) {
def log = ""
for (int x = 0; x < passedBuilds.size(); x++) {
def currentBuild = passedBuilds[x];
def changeLogSets = currentBuild.rawBuild.changeSets
for (int i = 0; i < changeLogSets.size(); i++) {
def entries = changeLogSets[i].items
for (int j = 0; j < entries.length; j++) {
def entry = entries[j]
log += "\n * ${entry.msg} by ${entry.author} "
}
}
}
return log;
}