跳到主要内容

cicd

library集成触发任务
多环境的CICD自动化实现
实现目标及效果

目前项目存在developmaster两个分支,Jenkinsfile中配置的都是构建部署到相同的环境,实际的场景中,代码仓库的项目往往不同的分支有不同的作用,我们可以抽象出一个工作流程:

  • 开发人员提交代码到develop分支

  • Jenkins自动使用develop分支做单测、代码扫描、镜像构建(以commit id为镜像tag)、服务部署到开发环境

  • 开发人员使用开发环境自测

  • 测试完成后,在gitlab提交merge request请求,将代码合并至master分支

  • 需要发版时,在gitlab端基于master分支创建tag(v2.3.1)

  • Jenkins自动检测到tag,拉取tag关联的代码做单测、代码扫描、镜像构建(以代码的tag为镜像的tag)、服务部署到测试环境、执行集成测试用例,输出测试报告

  • 测试人员进行手动测试

  • 上线

实现思路

以eladmin-api项目为例,目前已经具备的是develop分支代码提交后,可以自动实现:

  • 单元测试、代码扫描
  • 镜像构建
  • k8s服务部署
  • robot集成用例测试

和上述目标相比,差异点:

  1. eladmin-api应用目前只有一套环境,在luffy命名空间中。我们新建两个命名空间:
    • luffy-dev,用作部署开发环境
    • luffy,用作部署集成测试环境
  2. 需要根据不同的分支来执行不同的任务,有两种方案实现:
    • develop和master分支使用不同的Jenkinsfile
      • 可行性很差,因为代码合并工作很繁琐
      • 维护成本高,多个分支需要维护多个Jenkinsfile
    • 使用同一套Jenkinsfile,配合library和模板来实现一套Jenkinsfile适配多套环境
      • 改造Jenkinsfile,实现根据分支来选择任务
      • 需要将deploy目录中所有和特定环境绑定的内容模板化
      • 在library中实现根据不同的分支,来替换模板中的内容
Jenkinsfile根据分支选择任务

使用when关键字,配合正则表达式,实现分支的过滤选择:

pipeline {
agent any
stages {
stage('Example Build') {
steps {
echo 'Hello World'
}
}
stage('Example Deploy') {
when {
expression { BRANCH_NAME ==~ "develop" }
}
steps {
echo 'Deploying to develop env'
}
}
}
}

分别在develop和master分支进行验证。

针对本例,可以对Jenkinsfile做如下调整:

...
stage('integration test') {
when {
expression { BRANCH_NAME ==~ /v.*/ }
}
steps {
container('tools') {
script{
devops.robotTest(PROJECT)
}
}
}
}
...
模板化k8s的资源清单

因为需要使用同一套模板和Jenkinsfile来部署到不同的环境,因此势必要对资源清单进行模板化,前面的内容中只将deployment.yaml放到了项目的manifests清单目录,此处将部署eladmin-api用到的资源清单均补充进去,包含:

  • deployment.yaml
  • service.yaml
  • ingress.yaml
  • configmap.yaml(不建议)
  • secret.yaml(不建议)

涉及到需要进行模板化的内容包括:

  • 镜像地址

  • 命名空间

  • ingress的域名信息

模板化后的文件:

$ cat deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: eladmin-api
namespace: \{\{NAMESPACE\}\}
spec:
replicas: 1
selector:
matchLabels:
app: eladmin-api
template:
metadata:
creationTimestamp: null
labels:
app: eladmin-api
spec:
containers:
- env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
key: DB_HOST
name: eladmin
- name: REDIS_HOST
valueFrom:
configMapKeyRef:
key: REDIS_HOST
name: eladmin
- name: REDIS_PORT
valueFrom:
configMapKeyRef:
key: REDIS_PORT
name: eladmin
- name: DB_USER
valueFrom:
secretKeyRef:
key: DB_USER
name: eladmin-secret
- name: DB_PWD
valueFrom:
secretKeyRef:
key: DB_PWD
name: eladmin-secret
- name: REDIS_PWD
valueFrom:
secretKeyRef:
key: REDIS_PWD
name: eladmin-secret
image: \{\{IMAGE_URL\}\}
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 3
initialDelaySeconds: 20
periodSeconds: 15
successThreshold: 1
tcpSocket:
port: 8000
timeoutSeconds: 3
name: eladmin-api
ports:
- containerPort: 8000
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /auth/code
port: 8000
scheme: HTTP
initialDelaySeconds: 20
periodSeconds: 15
successThreshold: 1
timeoutSeconds: 3
resources:
limits:
cpu: "2"
memory: 4Gi
requests:
cpu: 50m
memory: 200Mi
imagePullSecrets:
- name: registry-172-21-65-226

$ cat service.yaml
apiVersion: v1
kind: Service
metadata:
name: eladmin-api
namespace: \{\{NAMESPACE\}\}
spec:
ports:
- port: 8000
protocol: TCP
targetPort: 8000
selector:
app: eladmin-api
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}

$ cat ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$1
name: eladmin-api
namespace: \{\{NAMESPACE\}\}
spec:
ingressClassName: nginx
rules:
- host: \{\{INGRESS_ELADMIN_API\}\}
http:
paths:
- backend:
service:
name: eladmin-api
port:
number: 8000
path: /apis/(.*)
pathType: Prefix
status:
loadBalancer: {}
实现library配置替换逻辑

我们需要实现使用相同的模板,做到如下事情:

  • 根据代码分支来部署到不同的命名空间
    • develop分支部署到开发环境,使用命名空间 luffy-dev
    • v.*部署到测试环境,使用命名空间 luffy
  • 不同环境使用不同的ingress地址来访问
    • 开发环境,eladmin-api-dev.luffy.com
    • 测试环境,eladmin.luffy.com

如何实现?sharedlibrary

所有的逻辑都会经过library这一层,我们具有完全可控权。

前面已经替换过镜像地址了,我们只需要实现如下逻辑:

  • 检测当前代码分支,替换命名空间
  • 检测当前代码分支,替换Ingress地址

问题来了,如何检测构建的触发是develop分支还是tag分支?

答案是:env.TAG_NAME,由tag分支触发的构建,环境变量中会带有TAG_NAME,且值为gitlab中的tag名称。

做个演示:

使用如下的Jenkinsfile,查看由master分支触发和由tag分支触发,printenv的值有什么不同

pipeline {
agent any
stages {
stage('Example Build') {
steps {
echo 'Hello World'
sh 'printenv'
}
}
stage('Example Deploy') {
when {
expression { BRANCH_NAME ==~ "develop" }
}
steps {
echo 'Deploying to develop env'
}
}
}
}

我们可以选择和替换image镜像地址一样,来执行替换:

def tplHandler(){
sh "sed -i 's#\{\{IMAGE_URL\}\}#\$\{env.CURRENT_IMAGE\}\#g' \$\{this.resourcePath\}/*"
String namespace = "luffy-dev"
String ingress = "eladmin-api-dev.luffy.com"
if(env.TAG_NAME){
namespace = "luffy"
ingress = "eladmin.luffy.com"
}
sh "sed -i 's#\{\{NAMESPACE\}\}#\$\{namespace\}\#g' \$\{this.resourcePath\}/*"
sh "sed -i 's#\{\{INGRESS_ELADMIN_API\}\}#\$\{ingress\}\#g' \$\{this.resourcePath\}/*"
}

但是我们的library是要为多个项目提供服务的,如果采用上述方式,则每加入一个项目,都需要对library做改动,形成了强依赖。因此需要想一种更优雅的方式来进行替换。

思路:

  1. 开发环境和集成测试环境里准备一个configmap,取名为 devops-config

  2. configmap的内容大致如下:

    • 开发环境

      NAMESPACE=luffy-dev
      INGRESS_ELADMIN_API=eladmin-api-dev.luffy.com
      INGRESS_BUSINESS_A=xxx.luffy.com
    • 测试环境

      NAMESPACE=luffy
      INGRESS_ELADMIN_API=eladmin.luffy.com
  3. 约定:configmap的key值,拼接{{KEY}}则为代码中需要替换的模板部分,configmap的该key对应的value,则为该模板要被替换的值的内容。比如:

    NAMESPACE=luffy-dev
    INGRESS_ELADMIN_API=eladmin-api-dev.luffy.com
    \{\{NAMESPACE\}\} =\\> luffy-dev
    \{\{INGRESS_ELADMIN_API\}\} -\\> eladmin-api-dev.luffy.com

    意思是约定项目的deploy的资源清单中:

    • 所有的\{\{NAMESPACE\}\}被替换为luffy-dev
    • 所有的\{\{INGRESS_ELADMIN_API\}\}被替换为eladmin-api-dev.luffy.com
  4. 在library的逻辑中,实现读取触发当前构建的代码分支所关联的namespace下的devops-config这个configmap,然后遍历里面的值进行模板替换即可。

这样,则以后再有新增的项目,则只需要维护devops-config配置文件即可,shared-library则不需要随着项目的增加而进行修改,通过这种方式实现library和具体的项目解耦。

def tplHandler(){
sh "sed -i 's#\{\{IMAGE_URL\}\}#\$\{env.CURRENT_IMAGE\}\#g' \$\{this.resourcePath\}/*"
String namespace = "luffy-dev"
if(env.TAG_NAME){
namespace = "luffy"
}
try {
def configMapData = this.getResource(namespace, "devops-config", "configmap")["data"]
configMapData.each { k, v -\\>
echo "key is \$\{k\}, val is \$\{v\}"
sh "sed -i 's#\{\{\$\{k\\}\}}#\$\{v\}\#g' \$\{this.resourcePath\}/*"
}
}catch (Exception exc) {
echo "failed to get devops-config data,exception: \$\{exc\}."
throw exc
}
}
准备多环境
  1. 创建开发和测试环境的命名空间

    $ kubectl create namespace luffy-dev
  2. 在开发和测试环境准备mysql、Redis、Configmap、Secret

    • Redis

      apiVersion: apps/v1
      kind: Deployment
      metadata:
      name: redis
      namespace: luffy-dev
      spec:
      replicas: 1
      revisionHistoryLimit: 10
      selector:
      matchLabels:
      app: redis
      strategy:
      rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
      type: RollingUpdate
      template:
      metadata:
      creationTimestamp: null
      labels:
      app: redis
      spec:
      containers:
      - args:
      - --requirepass "Y291cnNld2FyZQo"
      image: redis:3.2
      imagePullPolicy: IfNotPresent
      livenessProbe:
      failureThreshold: 3
      initialDelaySeconds: 15
      periodSeconds: 20
      successThreshold: 1
      tcpSocket:
      port: 6379
      timeoutSeconds: 1
      name: redis
      ports:
      - containerPort: 6379
      protocol: TCP
      readinessProbe:
      failureThreshold: 3
      initialDelaySeconds: 5
      periodSeconds: 10
      successThreshold: 1
      tcpSocket:
      port: 6379
      timeoutSeconds: 1
      resources:
      limits:
      cpu: 500m
      memory: 1Gi
      requests:
      cpu: 50m
      memory: 100Mi
      ---
      apiVersion: v1
      kind: Service
      metadata:
      name: redis
      namespace: luffy-dev
      spec:
      ports:
      - port: 6379
      protocol: TCP
      targetPort: 6379
      selector:
      app: redis
      sessionAffinity: None
      type: ClusterIP
      status:
      loadBalancer: {}
    • Mysql

      # pvc.yaml
      apiVersion: v1
      kind: PersistentVolumeClaim
      metadata:
      name: mysql
      namespace: luffy-dev
      spec:
      accessModes:
      - ReadWriteMany
      resources:
      requests:
      storage: 5Gi
      storageClassName: nfs

      ---
      # deployment.yaml
      apiVersion: apps/v1
      kind: Deployment
      metadata:
      name: mysql
      namespace: luffy-dev
      spec:
      replicas: 1
      selector:
      matchLabels:
      app: mysql
      strategy:
      rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
      type: RollingUpdate
      template:
      metadata:
      creationTimestamp: null
      labels:
      app: mysql
      from: luffy
      spec:
      containers:
      - args:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      env:
      - name: MYSQL_ROOT_PASSWORD
      valueFrom:
      secretKeyRef:
      key: DB_PWD
      name: eladmin-secret
      - name: MYSQL_DATABASE
      value: eladmin
      image: mysql:5.7
      imagePullPolicy: IfNotPresent
      livenessProbe:
      failureThreshold: 3
      initialDelaySeconds: 15
      periodSeconds: 20
      successThreshold: 1
      tcpSocket:
      port: 3306
      timeoutSeconds: 1
      name: mysql
      ports:
      - containerPort: 3306
      protocol: TCP
      readinessProbe:
      failureThreshold: 3
      initialDelaySeconds: 5
      periodSeconds: 10
      successThreshold: 1
      tcpSocket:
      port: 3306
      timeoutSeconds: 1
      resources:
      limits:
      cpu: 500m
      memory: 1Gi
      requests:
      cpu: 50m
      memory: 200Mi
      volumeMounts:
      - mountPath: /var/lib/mysql
      name: mysql-data
      dnsPolicy: ClusterFirst
      volumes:
      - name: mysql-data
      persistentVolumeClaim:
      claimName: mysql
      ---
      apiVersion: v1
      kind: Service
      metadata:
      name: mysql
      namespace: luffy-dev
      spec:
      ports:
      - port: 3306
      protocol: TCP
      targetPort: 3306
      selector:
      app: mysql
      sessionAffinity: None
      type: ClusterIP
      status:
      loadBalancer: {}
    • ConfigMap

      apiVersion: v1
      data:
      DB_HOST: mysql
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      kind: ConfigMap
      metadata:
      name: eladmin
      namespace: luffy-dev
    • Secret

      # eladmin-secret
      apiVersion: v1
      data:
      DB_PWD: bHVmZnlBZG1pbiE=
      DB_USER: cm9vdA==
      REDIS_PWD: WTI5MWNuTmxkMkZ5WlFv
      TOKEN: YWJjZGVmZw==
      kind: Secret
      metadata:
      name: eladmin-secret
      namespace: luffy-dev
      type: Opaque

      ---
      # registry-172-21-65-226.yaml
      apiVersion: v1
      data:
      .dockerconfigjson: eyJhdXRocyI6eyIxNzIuMjEuNjUuMjI2OjUwMDAiOnsidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiVGVhbW9iYWJ5MTk5MDEwNCIsImVtYWlsIjoiYWRtaW5AYWRtaW4uY24iLCJhdXRoIjoiWVdSdGFXNDZWR1ZoYlc5aVlXSjVNVGs1TURFd05BPT0ifX19
      kind: Secret
      metadata:
      name: registry-172-21-65-226
      namespace: luffy-dev
      type: kubernetes.io/dockerconfigjson

    创建上述资源:

    # 创建开发环境的数据库
    kubectl create -f luffy-dev/

    # 初始化sql
    kubectl -n luffy-dev cp eladmin.sql mysql-54f9856dbd-b264c:/tmp/eladmin.sql
    kubectl -n luffy-dev exec -ti mysql-54f9856dbd-b264c -- bash
    # mysql -p
    # source /tmp/eladmin.sql;
  3. 对eladmin-api项目的k8s资源清单模板化改造

    • {{NAMESPACE}}
    • {{INGRESS_eladmin-api}}
    • {{IMAGE_URL}}
  4. 初始化开发环境和测试环境的devops-config

    # 开发环境
    $ cat devops-config-dev.txt
    NAMESPACE=luffy-dev
    INGRESS_ELADMIN_API=eladmin-api-dev.luffy.com

    $ kubectl -n luffy-dev create configmap devops-config --from-env-file=devops-config-dev.txt

    # 测试环境
    $ cat devops-config-test.txt
    NAMESPACE=luffy
    INGRESS_ELADMIN_API=eladmin.luffy.com

    $ kubectl -n luffy create configmap devops-config --from-env-file=devops-config-test.txt
  5. 提交最新的library代码

  6. 提交最新的Jenkinsfile代码

    @Library('luffy-devops') _
    pipeline {
    agent { label 'jnlp-slave'}
    options {
    timeout(time: 20, unit: 'MINUTES')
    gitLabConnection('gitlab')
    }
    environment {
    IMAGE_REPO = "172.21.65.226:5000/eladmin/eladmin-api"
    IMAGE_CREDENTIAL = "credential-registry"
    DINGTALK_CREDS = credentials('dingTalk')
    PROJECT = "eladmin-api"
    }
    stages {
    stage('checkout') {
    steps {
    checkout scm
    updateGitlabCommitStatus(name: env.STAGE_NAME, state: 'success')
    }
    }
    stage('mvn package') {
    steps {
    container('tools') {
    sh 'mvn clean package'
    }
    updateGitlabCommitStatus(name: env.STAGE_NAME, state: 'success')
    }
    }
    stage('CI'){
    failFast true
    parallel {
    stage('Unit Test') {
    steps {
    echo "Unit Test Stage Skip..."
    }
    }
    stage('Code Scan') {
    steps {
    container('tools') {
    script{
    devops.scan().start()
    }
    }
    }
    }
    }
    }
    stage('build-image') {
    steps {
    container('tools') {
    script{
    devops.docker(
    "\$\{IMAGE_REPO\}",
    "\$\{GIT_COMMIT\}",
    IMAGE_CREDENTIAL
    ).build().push()
    }
    }
    }
    }
    stage('deploy') {
    steps {
    container('tools') {
    script{
    devops.deploy("manifests", true, "manifests/deployment.yaml").start()
    }
    }
    }
    }
    stage('integration test') {
    when {
    expression { BRANCH_NAME ==~ /v.*/ }
    }
    steps {
    container('tools') {
    script{
    devops.robotTest(PROJECT)
    }
    }
    }
    }
    }
    post {
    success {
    script{
    devops.notificationSuccess(PROJECT,"dingTalk","dingTalk")
    }
    }
    failure {
    script{
    devops.notificationFailed(PROJECT,"dingTalk","dingTalk")
    }
    }
    }
    }
验证多环境自动部署

模拟如下流程:

  1. 提交代码到develop分支,观察是否部署到luffy-dev的命名空间中

  2. 合并代码至master分支

  3. 在gitlab中创建tag,观察是否自动部署至luffy的命名空间中,且使用eladmin.luffy.com可以访问到最新版本

实现打tag后自动部署

我们发现,打了tag以后,多分支流水线中可以识别到该tag,但是并不会自动部署该tag的代码。因此,我们来使用一个新的插件:Basic Branch Build Strategies

安装并配置多分支流水线,注意Build strategies 设置:

  • Regular branches
  • Tags
    • Ignore tags newer than 可以不用设置,不然会默认不自动构建新打的tag
    • Ignore tags older than
优化镜像部署逻辑

针对部署到测试环境的代码,由于已经打了tag了,因此,我们期望构建出来的镜像地址可以直接使用代码的tag作为镜像的tag。

思路一:直接在Jenkinsfile调用devops.docker时传递tag名称

思路二:在shared-library中,根据env.TAG_NAME来判断当前是否是tag分支的构建,若TAG_NAME不为空,则可以在构建镜像时使用TAG_NAME作为镜像的tag

很明显我们更期望使用思路二的方式来实现,因此,需要调整如下逻辑:

def docker(String repo, String tag, String credentialsId, String dockerfile="Dockerfile", String context="."){
this.repo = repo
this.tag = tag
if(env.TAG_NAME){
this.tag = env.TAG_NAME
}
this.dockerfile = dockerfile
this.credentialsId = credentialsId
this.context = context
this.fullAddress = "\$\{this.repo\}:\$\{this.tag\}"
this.isLoggedIn = false
this.msg = new BuildMessage()
return this
}

提交代码,并进行测试,观察是否使用tag作为镜像标签进行部署。

优化名称空间逻辑

shared-library应该是支持多条业务线的流水线任务,当前为写死了luffy-devluffy,因此加入其他项目组的服务部署在不同的名称空间,无法兼容,因此需要优化。

def tplHandler(){
sh "sed -i 's#\{\{IMAGE_URL\}\}#\$\{env.CURRENT_IMAGE\}\#g' \$\{this.resourcePath\}/*"
String namespace = env.DEV_NAMESPACE ? env.DEV_NAMESPACE : "luffy-dev"
if(env.TAG_NAME){
namespace = env.TEST_NAMESPACE ? env.TEST_NAMESPACE : "luffy"
}
try {
def configMapData = this.getResource(namespace, "devops-config", "configmap")["data"]
configMapData.each { k, v -\\>
echo "key is \$\{k\}, val is \$\{v\}"
sh "sed -i 's#\{\{\$\{k\\}\}}#\$\{v\}\#g' \$\{this.resourcePath\}/*"
}
}catch (Exception exc) {
echo "failed to get devops-config data,exception: \$\{exc\}."
throw exc
}
}