流程

上一篇我们了解了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:

  • 分支策略
  • Git Commit 风格
  • 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;
}
Comments
Write a Comment