早期使用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清单文件等等