基于阿里云容器的 CI/CD 落地实践

作者个人介绍
刘晨 Lorraine

坐标Fintech,精通持续集成与发布,曾具有全平台100+应用持续部署持续发布实战经验,现在立志于成为K8S玩家。

基于阿里云容器的 CI/CD 落地实践

背景

大家好,笔者所在的团队当前面临落地公司业务数字化转型的重大任务。我们面临的主要研发挑战是如何快速得迭代出不断新增的开发需求,由于没有太多历史包袱,团队选择的技术栈也是相对成熟与流行的,比如我负责的Devops主要是基于阿里云ACK容器以及Jenkins2.0+进行搭建实施。

阿里云ACK容器也是基于K8S1.16.9封装的云服务,我们选择的是托管版本,即master节点托管于阿里云,我们只负责worker节点集群的运维与管理。这样做的好处是使团队力量尽可能得集中在业务层面,基础设施层面的运维工作尽量服务化。选择Jenkins2.0有诸多好处,比如Jenkins是经典的CI实施工具平台,开发测试多数熟悉这种使用方式,无需再次学习。2.0版本以后,引入了声明式的Jenkinsfile语法,这种流水线编排格式与基于声明式API的K8S能够更天然地集成,并且Jenkins生态圈的plugin十分丰富,基本可以通过配置方式满足团队构建多语言多项目CICD任务的使用场景。



核心问题

目前实施CICD的业务,基本都要解决以下核心问题。

交付物是什么?

部署环境有哪些?
环境配置信息是什么?
如何自动化地执行构建,部署任务?
如果构建,部署任务执行失败,或者部署环境的应用运行失败,如何感知故障的发生?

交付物定义与结构

交付物对象是在整个CICD流程中需要最先确定的实体,是开发跟devops之间交互的对接物。开发与devops一同参与交付物实体的定义,再由devops针对定义提供交付物的通用模版。



交付物定义

团队对于交付物的定义是基于dockerfile生成的微服务镜像。

devops提供了基于阿里云容器镜像服务的私有仓库来存放,如图:
基于阿里云容器的 CI/CD 落地实践
开发基于dockerfile提供镜像打包定义,我们以java应用为例,镜像内容主要包括基于openjdk基础镜像的工作目录定以及指定启动cmd,示例代码如下:

FROM openjdk:11.0.8
WORKDIR /home/demo
COPY target/index-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]

交付物结构

每个应用部署交付物都归档在一个独立的镜像仓库,镜像仓库命名规范为项目名称.应用服务名称,镜像tag的命名规范里应包含每次构建物里所包含的代码变更内容,以latest commit id来表示,以及构建发生的时间信息,以yyyymmddd-hhmm格式表示时间戳,示例如图:

基于阿里云容器的 CI/CD 落地实践
交付物镜像应是包含了除了环境配置信息以外的应用部署的全部信息的实体。以java应用为例,该交付物是基于openjdk的jar包的镜像实体。环境配置信息可以由环境变量传递并改写。



部署环境与配置

部署环境

CICD理念解决的核心问题之一是在如何在多环境下部署同一交付物。典型的部署环境主要有4种:

dev开发环境

交付物生成的环境。在开发环境内,交付物第一次生成,需要经过必要的单元测试通过率以及代码安全性检查等步骤,该环境的交付物是可提测的部署物。

test测试环境

交付物开始集成测试以及回归测试的环境。该环境的交付物是可上生产的部署物。

stage预发布环境

预发布环境的使用场景会依据需求而有所不同。有些公司会将预发布环境用作demo环境作为poc功能展示;有些公司会在预发布环境中进行流量压力测试,确保上生产之前的服务负载与资源配置相匹配;有些公司做蓝绿发布或者A/B测试时,则会利用stage环境来做流量切换。无论stage环境如何被使用,stage部署环境都需与生产部署环境配置保持一致。

prod生产环境

生产发布环境是交付物最终运行环境,对用户提供服务。

环境配置

环境配置信息是CICD过程中,独立与交付物,但依赖于部署环境的一系列环境变量信息。基于K8S集群部署时,实现的主要方式是ConfigMap以及Secret资源对象。

构建自动化

我们有了Dockerfile来描述交付物的定义,简单来说只要docker build 然后docker push到私有镜像仓库就完成了构建。基于Jenkins搭建自动化的构建任务就是将这个过程自动化。后文实施部分会详细讲述。



构建任务自动化主要考虑的问题有:

当main/master branch有代码提交commit生成时,如何自动触发构建任务执行?

不同语言应用,比如java应用或者前端应用的构建流程是怎样的?包含哪些步骤?

生成的交付物镜像如何成功放至到阿里云容器镜像服务的私有仓库里?

交付物传输过程如何确保网络传输的安全性与可靠性?

部署自动化

我们的部署环境是在阿里云容器集群中,当前每个部署环境通过命名空间来进行资源隔离,后续还会做资源隔离升级。目前对于在K8S集群中的应用部署,主流的解决方案有Helm和Kustomize,我们最后选择了HelmV3来实施。Helm与Kustomize之间的选择是另一个有意思的话题,不在本章展开。基于Helm的部署自动化简单来说就是构建一个能够运行helm Install指令的jenkins任务。后文实施部分会详细讲述。



部署自动化主要考虑的问题有:

如何获取到交付物?

部署的是哪个交付物?要部署的目标环境是哪个?

如何定义部署成功?

部署失败了,如何感知到故障?如何快速定位到问题?

如何实现回滚操作?

事件监控与告警机制

CICD的目标是尽可能的自动化全流程,降低人为参与的程度。当自动化程度越高,对于自动任务的故障发现与告警就越有必要。我们的服务部署在阿里云容器集群中,基于Jenkins2.0来搭建自动化CI/CD的任务,构建部署依赖与Jenkins任务执行是否成功,以及集群中的资源按照Helm的定义是否如期更新运行。CI/CD相关的事件监控围绕着集群资源事件监控以及Jenkins任务监控两方面来进行。

阿里云容器集群事件监控与告警

阿里云服务提供了事件监控与告警服务,可以直接配置使用,如图所示,该示例为阿里云集群服务/运维管理/事件列表提供的事件监控仪表盘服务。我们可以轻松得到整个集群资源运行的情况。

基于阿里云容器的 CI/CD 落地实践
进入“告警配置”服务就能配置基本事件的告警,集群初始化了很多基本事件的告警配置,比如Pod/Node OOM;Pod启动失败,或者资源不足,无法调度等。可以依据业务需求,定制化告警事项。笔者目前采用的是使用默认配置的告警模版,对发生频次做了一定容忍,通过邮件方式进行告警。
基于阿里云容器的 CI/CD 落地实践
Jenkins任务失败自动发送邮件告警

E-mail Notification Plugin可以帮助实现Jenkins服务对于任务完成自动发送邮件的功能。

首先在Jenkins/configuration/Extended E-mail Notification配置SMTP邮件服务信息,我们使用的是阿里云邮件推送服务, 确保填写了正确的smtp server, smtp port,如果有smtp username/passwrod,也需要正确填写。
基于阿里云容器的 CI/CD 落地实践
其次在Jenkinsfile里声明使用email extension plugin,对于构建任务失败自动发送邮件的代码块如下:

emailext (

            to: 'XXX@example.com',
            subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
            body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
            <p>Check console output at &QUOT;<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>&QUOT;</p>""",
            recipientProviders: [[$class: 'DevelopersRecipientProvider']]
        )

###实施

基于Dockerfile/Jenkinsfile构建CI流水线

交付物由Dockerfile定义,每个应用服务的根目录下都应该有一个Dockerfile文件,定义了该服务的构建过程。本节里我们举java微服务来说明,这也是我们后端微服务的构建流水线。

Java微服务

Java应用的构建逻辑定义在Jenkinsfile里,基于maven实现。Dockerfile模版只定义了如何调用构建好的jar包,即部署指令。这样做的主要目的是尽可能缩小镜像,降低网络数据传输的负载。

声明式Jenkinsfile的好处是可以通过代码方式定义与管理流水线逻辑。基于代码就拥有了版本管理的能力。

构建Pipeline主要包含了以下步骤:

scm 获取项目源代码

基于maven的构建,生成可部署的jar包

基于maven的单元测试

基于SonarQube的代码检查

基于kaniko的云原生方式,生成image,并推送至阿里云私有镜像仓库

构建Pipeline主流程之外的后置步骤是构建任务执行完成后的downStream任务,链接的是通用部署任务,这种结构解耦了构建与部署逻辑,可以使不同的构建任务复用同一个部署任务。

def branch_name

def revision
def registryIp = "registry-vpc.cn-shanghai-finance-1.aliyuncs.com"
def app = "XXX"
pipeline {

agent{
    node{
        label 'slave-java' // Jenkins Slave Pod Template
    }
}
stages {
    stage ('Checkout') {
        steps {
            script {
                def repo = checkout scm
                revision = sh(script: 'git log -1 --format=\'%h.%ad\' --date=format:%Y%m%d-%H%M | cat', returnStdout: true).trim()
                branch_name = repo.GIT_BRANCH.take(20).replaceAll('/', '_')
                if (branch_name != 'master') {
                    revision += "-${branch_name}"
                }
                sh "echo 'Building revision: ${revision}'" // 获取代码并生成镜像tag(latest-commit-id+timestanp+branch)
            }
        }
    }
    stage('Compile') {
        steps {
            container("maven") {
                sh 'mvn -B -DskipTests clean package' // 基于maven的构建步骤
            }
        }
    }
    //unit test 测试部署
    stage('Unit Test') {
       steps {
           container("maven") {
               sh 'mvn test org.jacoco:jacoco-maven-plugin:0.7.3.201502191951:prepare-agent install -Dmaven.test.failure.ignore=true'
           }
       }
    }
    // 上传Jacoco检测结果
    stage('JacocoPublisher') {
        steps {
            jacoco()
        }
    }
    stage('Build Artifact') {
        steps {
             container("maven") {
                sh 'chmod +x ./jenkins/scripts/deliver.sh'
                sh './jenkins/scripts/deliver.sh'
             }
        }
    }
    stage('SonarQube Analysis'){
        environment {
            scannerHome = tool 'SonarQubeScanner'
        }
        steps {
            withSonarQubeEnv('sonar_server') {
                sh "${scannerHome}/bin/sonar-scanner"
            }
        }
    }
     // 添加stage, 运行容器镜像构建和推送命令
    stage('Image Build and Publish for Dev Branch'){
      when { not { branch 'master' } }
      steps{
          container("kaniko") {
              sh "kaniko -f `pwd`/Dockerfile -c `pwd` --destination=${registryIp}/xxxx/xxx.${app}:${revision} --skip-tls-verify"
          }
      }
    }
     // 添加stage, 运行容器镜像构建和推送命令
    stage('Image Build and Publish for Master Branch'){
      when { branch 'master' }
      steps{
          container("kaniko") {
              sh "kaniko -f `pwd`/Dockerfile -c `pwd` --destination=${registryIp}/xxxx/xxx.${app} --destination=${registryIp}/xxxx/xxx.${app}:${revision} --skip-tls-verify"
          }
      }
    }
}
post {
    always {
        echo 'This will always run'
    }
    success {
        script {
            build job: '../xxx.app.deploy/master', parameters: [string(name: 'App', value: String.valueOf(app)), string(name: 'Env', value: 'dev-show'), string(name: 'Tag', value: String.valueOf(revision))]
        }
    }
    failure {
        emailext (
            to: 'XXX@example.com',
            subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
            body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
            <p>Check console output at &QUOT;<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>&QUOT;</p>""",
            recipientProviders: [[$class: 'DevelopersRecipientProvider']]
        )
    }
    unstable {
        echo 'This will run only if the run was marked as unstable'
    }
    changed {
        echo 'This will run only if the state of the Pipeline has changed'
        echo 'For example, if the Pipeline was previously failing but is now successful'
    }
}

}

构建任务一般是根据主分支提交的代码自动触发的任务。为了实现autoTrigger,我们需要使用webHook连接SCM服务和Jenkins服务,我们使用的SCM服务是Bitbucket,通过使用hook插件实现了autoTrigger。

基于阿里云容器的 CI/CD 落地实践

基于阿里云容器服务的部署环境属于VPC内,即我们使用的容器镜像服务是VPC内的私有镜像服务,VPC外部无法访问。K8S集群也属于该VPC内的资源,故集群内部的CI Jenkins Slave Pod以及目标部署Pod服务都在该VPC的内网内,与私有化的容器镜像服务直接局域网连接,确保了数据传输的安全性与可靠性。

基于Helm/Jenkinsfile构建CD流水线

在K8S上部署一个应用,传统方式一般利用kubectl创建一系列的资源对象(deployment,configMap,secret,serviceAccount, service,ingress 等)。在CICD部署流程中,涉及同一部署物部署到多个环境内,即部署发生多次。举个例子,部署在开发和测试环境的两个Deployment对象,结构基本一致,只有少数属性的赋值依据环境而有所不同。CICD软件实践里有一个重要理念是部署可以重复,确保在各个环境的部署的动作是一致的,以避免在发布流水线中引入差异。Helm是一种适应上述需求的模版式解决方案。Helm的介绍不在本章展开,有兴趣学习的读者可以参考。https://whmzsu.github.io/helm-doc-zh-cn/

helm提供了chart包(一种可以封装K8S资源对象为模版文件的集合)和values.yaml(属性参数的集合)结构,基于Go Template语法,解耦了manifest里属性值与K8S资源对象模版结构。使得一组对应用部署所需资源对象的创建可以通过模版定义加赋值的方式,复用到多个环境,重复部署,解决了K8S资源对象管理的问题。

下来以Java微服务的helm/Chart包举例来说明我们是如何生成应用部署物的

helm包初始化可以使用helm create Name [flags]

基于阿里云容器的 CI/CD 落地实践
部署物helm包的目录结构如下:
基于阿里云容器的 CI/CD 落地实践
根目录下的values.yaml是所有环境公共的属性集合,比如关于创建serviceAccount,rbac相关的属性信息,dev/test/stage/production.yaml是与部署环境有关的属性集合。

执行helm install 命令来部署当前Helm包模版,例如


helm upgrade index ./helm/ -i -nbss-dev -f ./helm/values.yaml -f ./helm/dev.yaml --set 'image.tag=latest' --set 'image.repo=bss.index' --set 'ingress.hosts.paths={/index}'

前文提到了我们使用声明式Jenkinsfile语法构建部署流水线逻辑,与传统groovy不一样的是,我们可以通过声明pipeline,agent/node,stages/step等对象,直接将部署流程的定义声明。以部署基于helm的jar包应用为例,部署物为在阿里云私有镜像仓库的镜像文件,上文例子所示。部署流程包括两个步骤:1. 获取helm代码 2. 执行helm install,具体如下:

def branch_name
def revision

def registryIp = "registry-vpc.cn-shanghai-finance-1.aliyuncs.com"
pipeline {

agent{
    node{
        label 'slave-java' // Jenkins Slave Pod Template
    }
}
parameters {
    choice(name: 'App', choices: ['111', '222', '333','444'], description: '选择部署应用')
    choice(name: 'Env', choices: ['dev', 'stage', 'test',], description: '选择部署环境')
    string(name: 'Tag', defaultValue: 'latest', description: '请输入将要部署的构建物镜像Tag')
}
stages {
     stage ('CheckoutHelm') {
        steps {
            script {
                def repo = checkout scm
                revision = sh(script: 'git log -1 --format=\'%h.%ad\' --date=format:%Y%m%d-%H%M | cat', returnStdout: true).trim()
                branch_name = repo.GIT_BRANCH.take(20).replaceAll('/', '_')
                if (branch_name != 'master') {
                    revision += "-${branch_name}"
                }
            }
        }
    }

stage ('Deploy') {

         steps {
            container('helm-kubectl') {
                 sh "chmod +x ./helm/setRevision.sh"
                 sh "./helm/setRevision.sh ${revision}"
                 sh "helm upgrade -i ${params.App} ./helm/ -nxxx-${params.Env} -f ./helm/${params.Env}.yaml --set-file appConfig=./appConfig/${params.Env}/${params.App}.yml --set image.tag=${params.Tag} --set image.repo=bss.${params.App} --set ingress.hosts.paths={/${params.App}}"
            }
        }
    }
}

post {

    always {
        echo 'This will always run'
    }
    success {
        echo 'This will run only if successful'
    }
    failure {
        emailext (
            to: 'XXX@example.com',
            subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
            body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
            <p>Check console output at &QUOT;<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>&QUOT;</p>""",
            recipientProviders: [[$class: 'DevelopersRecipientProvider']]
        )
    }
    unstable {
        echo 'This will run only if the run was marked as unstable'
    }
    changed {
        echo 'This will run only if the state of the Pipeline has changed'
        echo 'For example, if the Pipeline was previously failing but is now successful'
    }
}

}

经验总结与展望

流水线自助化改进

笔者团队面对的业务架构是不断增长的微服务集群。基于此,对于CICD的自动化要求以及发布频率不断提出了新的挑战。原先我们的做法是每个微服务拥有独立的helm部署包,但是由于快速增加的微服务数量,对于快速注册新服务到现有CICD流水线中,产生了开发快速构建部署服务的需求。

面临的问题主要是helm部署模版是以应用部署所需的资源对象构建为单元,在不同部署环境做配置。每个应用都会创建一套helm部署包以及的独立的构建和部署任务。当越来越多的微服务部署需求不断增加时,构建helm包以及相应的构建部署任务的配置工作量就会成为CICD流程的效率瓶颈。

解决方案是抽象一个公共helm部署包模版,将各个应用部署时定制化的属性信息集合从原有helm包结构中再抽象出来,以AppConfig File方式独立成一层配置信息,例如:
基于阿里云容器的 CI/CD 落地实践
这里的模版抽象实现主要是依赖Helm命令里-f 传入values.yaml 时,可以同时-f 多个属性集合文件,并且位于后面的-f 的文件可以覆盖前面的属性参数值。如果定制化配置信息文件不是Go template可以直接使用的格式,可以考虑使用flag --set-file 直接将Config文件里的内容写入某个上层的属性参数。

安全问题

将开发,测试环境都搬到公有云上,访问安全是一个不可忽略的问题。我们的交付物,部署流水线以及目标部署集群都基于一个VPC下,从网络传输角度,数据交互在一个局域网内,是相对安全的公有云网络环境。除此之外,为了保证开发环境,测试环境完全的私有化,在微服务应用部署ingress资源对应的slb上我们也设置了相应的访问控制,只能对公司内部的用户开放网络访问。后续对于公网访问的服务以及外部部署环境和CICD服务之间的数据传输会考虑使用阿里云提供的KMS等数据传输保密服务来保证。

上一篇:系统变量与自定义变量


下一篇:服务网格 ASM +容器服务 ACK 助力画雕科技构建 IoT 服务管理能力