这样用Jenkins ,是不是格局就上来了?

1,730 阅读5分钟

早期使用Jenkins发布,往往每个代码仓库都需要放置一份Jenkfiles,里面定义了大量重复的CI/CD逻辑,这些重复的逻辑抽离出来,就可以形成一个公共模块,就是所谓的Jenkins Shared Library

Jenkins Shared Library 仅仅从字面意思解释,难免让人一头雾水;便于理解,可以简单的认为是一种公共模块,类似于二方包,实现流水线的逻辑复用。

Shared library的结构

在我看来,Shared library不仅仅是一个公共模块,同时也是一个开发框架,按照约定俗成的规范去编写逻辑,让我们看下官方给出的shared library的结构

(root)
+- src                     # Groovy source files
|   +- org
|       +- foo
|           +- Bar.groovy  # for org.foo.Bar class
+- vars
|   +- foo.groovy          # for global 'foo' variable
|   +- foo.txt             # help for 'foo' variable
+- resources               # resource files (external libraries only)
|   +- org
|       +- foo
|           +- bar.json    # static helper data for org.foo.Bar
  • var 放置公用的函数或者逻辑,一般用来定义pipeline的主要逻辑的实现代码
  • resources 放置资源文件,作用等同于java项目中的resources目录
  • src 放置公用的工具函数,例如告警、消息通知功能

仅仅停留在概念上,难免让人不得其法,让我们举例说明下Shared library该如何使用

Jenkinsfile的"退化"

为了支持应用发布,早期普遍的做法是将在项目根目录创建Jenkinsfile,并在其中定义pipeline的实现逻辑

在有了Shared Library功能后,我们就可以顺理成章的将Jenkinsfile中可复用的逻辑定义为全局变量移到var目录中,而原本的Jenkinsfile只需要定义发布参数即可

如下所示,我们在var目录中定义global variable——javaDeploy.groovy,并重写call()方法,实现pipeline的发布逻辑:

cat var/JavaDeploy.groovy

def call(Object WorkflowScript) {
    // 接收Jenkinsfile中配置的参数
    def application = WorkflowScript.application ?: ''
    def port = WorkflowScript.port ?: ''
    def project = WorkflowScript.project ?: ''
    def receivers = WorkflowScript.dingTalkRecivers ?: ['defaultReceviers']
    def jvmOptions = WorkflowScript.jvmOptions ?: '-server -Xms512m -Xmx1g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m'
    
    pipeline {
      agent any
      stages {
        stage('Git Clone') {
            steps {
                checkout scm
            }
        }
        stage('编译') {
            steps {
              ...
            }
        }
        stage('推送镜像') {
            steps {
              ...
            }
        }
        stage('部署'){
            steps {
             ...
            }
        }
      }
    }

而原本的Jenkinsfile只需要配置相关发布参数,并在最后调用javaDeploy并传递参数:

cat Jenkinsfile

import groovy.transform.Field

// 应用名
@Field
String application = 'demo'

// 应用端口
@Field
String port = '9903'

// jvm堆栈空间参数默认值
@Field
String jvmOptions = '-server -Xms512m -Xmx1g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m'

// 钉钉姓名或者昵称列表,多个用户逗号分隔 用于发布结果通知
@Field
List dingTalkRecivers = ['a','b']

// 调用全局变量
javaDeploy(this)

Shared Libray的逻辑也如业务代码一样保存在repo中,当jenkins的job真正运行的时候就会加载该repo中的代码

如此一来,Jenkfiles的内容也就化繁为简,变成了仅仅是一份配置文件;开发人员只需配置寥寥几行参数,而运维人员也只需维护share library的逻辑即可

更近一步,我们甚至可以将项目或者应用的配置保存在统一平台中,例如应用平台,再由Jenkins去调用应用平台的API获取配置参数

k8s插件的引入

公司原本的Jenkins系统是一主三从的结构,但是面对日均上百次的发布量却力不从心,经常因OOM而宕机,经过几次硬件升级,仍然无法从根本上解决问题。于是,从成本和性能考虑,我们决定引入K8S插件,借助k8s的弹性伸缩,实现agent快速水平扩容的功能,有build需求就创建agent pod执行任务,执行完则立即销毁agent pod

对于如何使用 kubernetes-plugin 官方给出了demo,以Declarative Pipeline为例:

pipeline {
  agent {
    kubernetes {
      yaml '''
        apiVersion: v1
        kind: Pod
        metadata:
          labels:
            some-label: some-label-value
        spec:
          containers:
          - name: maven
            image: maven:alpine
            command:
            - cat
            tty: true
          - name: busybox
            image: busybox
            command:
            - cat
            tty: true
        '''
    }
  }

可以看到,其中有一大段跟主逻辑无关的agent pod的定义,又臭又长。。导致整体代码有些臃肿; 从简洁性考虑,我们可以把这段pod的定义抽离出来放置在单独的文件中,然后再引入到pipeline的主逻辑中

那么如何做?

首先定义agent pod yaml: jenkinsAgent.yaml

apiVersion: v1
    kind: Pod
    metadata:
      labels:
        some-label: some-label-value
    spec:
      containers:
      - name: maven
        image: maven:alpine
        command:
        - cat
        tty: true
      - name: busybox
        image: busybox
        command:
        - cat
        tty: true

然后,在Jenkinsfile中引入Agentyaml:通过pipeline内置的libraryResource方法加载文件并转换为string变量

 @Field
 def jenkinsAgentYaml = libraryResource 'com/xxxx/java/jenkinsAgent.yaml'

 pipeline {
     agent {
       // enable kubernetes plugin
       kubernetes{
         yaml jenkinsAgentYaml
       }
     }
   ...
}

这样代码看起来似乎就更加简洁和干练了

引入Dockerfile

关于Dockerfile,约定俗成的做法将其与业务代码放置在同一repo中;但是,考虑到公司大部分项目用到的Dockerfile基本雷同,因此也将这些共通的逻辑其抽离出来保存在Jenkins Shared Library中,统一放在resource资源目录中,并在pipeline的主逻辑中调用处理

Java项目的Dockerfile

FROM BASE_IMAGE
MAINTAINER beiguo <beiguo@heaven.com>

ARG module_name

RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" >/etc/timezone

ADD ${module_name}.jar /
ADD entrypoint.sh /
ENV module=${module_name}
ENTRYPOINT ["sh","/entrypoint.sh"]

其中使用的启动脚本entrypoint.sh也一并放入resource目录中

然后在Jenkinsfile中调用

// 加载java应用Dockerfile
@Field 
def dockerFile = libraryResource 'com/xxx/java/Dockerfile'

pipeline {
      ...
        stage('Build') {
            steps {
                    ...
                    writeFile file: "Dockerfile",text: dockerFile
                    sh "docker build --build-arg module_name=${params.module_name} -t xxx.com/java/demo"
                    ...
                }
            }
        }

JVM参数的动态生成

线上、测试和预发等不同环境使用的JVM参数也不尽相同,因此,在pipeline中也需要动态生成jvm参数,我们可以借助groovy定义一个方法来实现。诸如此类的工具函数建议统一放在src目录中

src/com/xxx/Utils.groovy

package com.xxx

class Utils implements Serializable{

     /**
     * 根据部署环境生成jvm参数
     * @param deployEnv 部署环境
     * @param module java应用模块名 针对多模块布局
     * @param jvmOpts jvm堆栈空间参数 p.s. -server -Xms512m -Xmx512m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512ms
     * @return
     */
    def generateJavaOpts(String deployEnv,String module,String jvmOpts,String naocsConfigServer) {
        
        def logDir="/log/${module}"
        def sentinelService="sentinel:8280"
        def skywalkingAgent="/usr/skywalking/agent/skywalking-agent.jar"
        def skywalkingBackend="oap.skywalking:11800"
        def skywalkingServiceName="${module}"

        // JVM GC options
        def gcOpts=("-verbose:gc"
                       +" -XX:+PrintGCDetails"
                       +" -XX:+PrintGCDateStamps"
                       +" -XX:+PrintGCTimeStamps"
                       +" -XX:+UseGCLogFileRotation"
                       +" -XX:NumberOfGCLogFiles=100"
                       +" -XX:GCLogFileSize=100K"
                       +" -Xloggc:${logDir}/gc.log"
                       +" -DLOG_PATH=${logDir}/log.log")
        // HeapDump options
        def dumpOpts="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${logDir}"
        // sentinel options
        def sentinelOpts="-Dcsp.sentinel.api.port=8719 -Dcsp.sentinel.dashboard.server=${sentinelService}"
        // nacos options
        def nacosOpts="-DNACOS_CONFIG_SERVER=${naocsConfigServer}"
        // skywalking options
        def skywalkingOpts=("-Dskywalking.agent.service_name=${skywalkingServiceName}"
                          + " -Dskywalking.collector.backend_service=${skywalkingBackend}"
                          + " -javaagent:${skywalkingAgent}")
        def javaOpts = ''
        switch(deployEnv){
            case "test":
               javaOpts = "${jvmOpts} ${gcOpts} ${dumpOpts} ${nacosOpts} -Dproject.name=${projectName}"
               break
            case "production":
               javaOpts = "${jvmOpts} ${gcOpts} ${dumpOpts} ${sentinelOpts} ${nacosOpts} ${skywalkingOpts} -Dproject.name=${projectName}"
               break
            ...
        }
        return javaOpts
    }
}

总结

除了以上功能,借助Jenkins Shared Library,还可以实现其他灵活多变的需求,诸如动态生成k8s yaml清单文件等等