Jenkins中pipeline对接CMDB接口获取主机列表的发布实践
# 1,前言
发布平台统一项目中,二丫 (opens new window)将八百多个 job 全部基于 Jenkins Pipeline 实现,极大简化了项目配置与维护工作,自此之后再没有什么发布上面的难题。
当时宿主机发布的实现,全部基于参数化构建的特性处理集群里的 IP 列表(事实上在这之后,二丫 (opens new window)增减项目也就只是针对这个字段的调整了),结合 ansible 进行发布,现在随着各种基建的完善,打算改进一版,直接对接 CMDB 获取应用的接口拿到应用对应的主机列表来完成发布的实践,从而完成发布平台这最后一点工作量的覆盖,实现全通用,全自动,围绕以 CMDB 为中心的运维基建工作。
开始正题之前,先看一眼之前已经运行半年左右的实践:
这几个参数满足了二丫 (opens new window)在宿主机发布应用的需求,二丫 (opens new window)基于共享库抽取出 Jenkinsfile 引导文件,又从 Jenkinsfile 里抽取出这四个变量,几个都是常规的变量,作用也显而易见,因此不做过多介绍。实现它们的伪代码(实际应用中需要将如下代码中的部分变量进行提取)如下:
parameters {
string(name: 'BRANCH', defaultValue: 'master', description: '请输入将要构建的代码分支')
choice(name: 'REMOTE_HOST', choices: 'ALL\n10.3.7.7\n10.3.7.8', description: '请选择发布主机,默认ALL')
choice(name: 'MODE', choices: ['DEPLOY','ROLLBACK'], description: '请选择发布或者回滚?')
extendedChoice(description: '回滚版本选择,倒序排序,只保留最近十次版本;如果选择发布则忽略此项', multiSelectDelimiter: ',', name: 'ROLLBACK_VERSION', propertyFile: env.JOB_BASE_NAME, propertyKey: env.JOB_BASE_NAME, quoteValue: false, saveJSONParameterToFile: false, type: 'PT_SINGLE_SELECT', visibleItemCount: 10)
}
2
3
4
5
6
单说 REMOTE_HOST,当二丫 (opens new window)把choices: 'ALL\n10.3.7.7\n10.3.7.8'
提取成一个参数,那么每个项目的不同主机就可以通过参数传递,然后利用如下代码渲染出 ansible 需要的主机发布列表:
stage('定义部署主机列表'){
steps{
script{
try{
sh '''
OLD=${IFS}
IFS='\n'
if [ $REMOTE_HOST == "ALL" ];then
echo "[remote]" > ${ANSIBLE_HOSTS}
for i in ${HOSTS};do echo "$i ansible_port=22" >> ${ANSIBLE_HOSTS};done
sed -i '/ALL/d' ${ANSIBLE_HOSTS}
else
echo "[remote]" > ${ANSIBLE_HOSTS}
echo "$REMOTE_HOST ansible_port=34222" >> ${ANSIBLE_HOSTS}
fi
IFS=${OLD}
'''
}catch(exc) {
env.Reason = "定义主机列表出错"
throw(exc)
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
那么,现如今想要对接 CMDB 发布服务,则只需要解决两个事情即可:
- 参数化构建处通过 CMDB 接口展示主机列表。
- 定义部署主机列表步骤改造成通过接口获取的方式渲染。
# 2,参数
当二丫 (opens new window)有了明确的目标以及大概的思路之后,凭借着以往网上冲浪时大概看到过有人通过 groovy 实现通过接口渲染参数的模糊印象,开始了漫长地研究之路。
本文如下代码可能需要你根据报错情况添加插件,具体情况具体分析,这里不多赘述。目前简单记得至少需要如下插件:
- Active Choices Plugin (opens new window):支持灵活定义的参数化插件。
- http request plugin (opens new window):支持创建 http 请求的插件。
- Pipeline Utility Steps (opens new window):提供 pipeline 流水线中一些读写文件之类的操作。
找到以往模糊印象中的文章,确定了这里需要使用Active Choices Plugin
,通过一些检索,很快确定了基本的发起 http request 的代码,后边一直困住自己的,是在这个步骤获取项目名字的需求,二丫 (opens new window)需要与 CMDB 一起约定一个东西作为项目的唯一 key,不用说,直接用项目名一定是最好的,这个项目名从 gitlab 仓库中的命名,到 Jenkins 中 job 的命名,到 CMDB 中应用的命名,乃至整个生命周期中,都应该是一致的,唯一的。
接下来直接上干货。
为了模仿 CMDB 接口返回应用对应的数据信息,二丫 (opens new window)这里直接在 Nginx 中添加如下配置:
location /user-api {
default_type application/json;
return 200 '{"name":"user-api","host":["ALL","10.0.0.1","10.0.0.2","10.0.0.3","10.0.0.4"]}';
}
2
3
4
当二丫 (opens new window)请求服务对应的 /user-api
接口时,将会拿到应用对应的集群主机 IP 列表:
然后在 Jenkins 中,创建一个名叫 user-api
的流水线项目,流水线代码如下:
properties([
parameters([
[$class: 'ChoiceParameter', filterLength: 1, filterable: true, randomName: 'choice-parameter-18463792817640626',
name: 'HOST', choiceType: 'PT_SINGLE_SELECT', description: '选择要部署的主机',
script:[
$class: 'GroovyScript',
fallbackScript: [classpath: [], sandbox: false, script: 'return[\'error\']'],
script: [ classpath: [], sandbox: false,
script:'''import hudson.model.*
import groovy.json.JsonSlurperClassic
def getHostList(jobName) {
def url = new URL("http://10.6.6.66:66/${jobName}")
def parsedJSON = parseJSON(url.getText())
return parsedJSON.host
}
def parseJSON(json) {
return new groovy.json.JsonSlurperClassic().parseText(json)
}
return getHostList(Thread.currentThread().toString().split('job')[-1].replace('/',''))
''']]]])])
pipeline{
agent any
stages{
stage('Example'){
steps{
script{
sh """echo ${env.HOST}"""
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
关于 active choice 插件的流水线语法,这里不多介绍,可参考官方文档,也可在你的 Jenkins 流水线语法中自行生成理解。
说明:
因为 Jenkins 官方对流水线支持的参数化定义仅支持:booleanParam,choice,text,password,file 等几种常用参数,并不支持 active choice 这类复杂的选项定义,因此这里借助
properties
的方法来实例化任一需要的对象,同样,该用法可以在 Jenkins 中的流水线语法参考详细了解。示例如下:使用代码定义 active choice 参数时需要注意一个坑是:如果你在一个项目中定义多个 active choice 参数,那么请确保不同参数的
randomName
是唯一的,否则可能会无法正常使用。接着进入到 groovy 脚本区域,在写这里的代码时,参考网络上的写法各种各样,先说引包问题。
同一个方法,有人导包有人不导,后来查了下这块儿:Groovy 提供一些默认的导入。Groovy 默认导入的包有参考 (opens new window):
import java.lang.* import java.util.* import java.io.* import java.net.* import groovy.lang.* import groovy.util.* import java.math.BigInteger import java.math.BigDecimal
1
2
3
4
5
6
7
8但二丫 (opens new window)上边的
import groovy.json.JsonSlurperClassic
也可以省略。接着是借助 URL 模块儿,传递唯一参数应用名,拿到数据,return 出来,当然这里如果用于生产,还应该再好好设计一下,这里暂时按下不表,在后文再详述。
最后困难的是如果在这个阶段拿到项目名,成了困住二丫 (opens new window)的一个地方,二丫 (opens new window)找遍了网上的资料,试了又试,败了又败,人家说:文章不厌百回改,二丫 (opens new window)说,code 不厌百回试。虽然在 active choice 官方文档处有说明提供了两个变量获取 Jenkins 构建的系统信息:
也见到有人使用这两个变量来获取的,如下:
import hudson.model.* def jobName = this.binding.jenkinsProject.name
1
2但经自己的测试,却始终都没拿到。最后还是在 Stack Overflow 中看到一个答案:
def jobName = (Thread.currentThread().toString() =~ /job\/(.*?)\//)[0][1]
1如果你所有项目都是单层目录的场景,则用上边的方案没有问题,只不过需要注意的是转义符会报语法错误,可多加一层转义处理:
def jobName = (Thread.currentThread().toString() =~ /job\\/(.*?)\\//)[0][1]
1不过因为二丫 (opens new window)测试场景是多目录的情况,发现如上方法拿多层级 job 的时候会有问题,因此个人改造此方法如下:
def jobName = Thread.currentThread().toString().split('job')[-1].replace('/','')
1
现在来到 Jenkins 当中,在任意目录中创建个项目,命名为 user-api
,然后将 Jenkinsfile 文件丢进去,运行一下,发现能够满足需求:
到这里,参数基于 CMDB 接口实时拿主机列表的需求已经满足,接下来就是实现通过接口将数据解析下来的功能了。
# 3,渲染列表
渲染列表也是这次改造工作的重中之重,一开始考虑用 shell 实现,但是觉得接口拿到的 json 经由 shell 处理实在不理想,于是打算用 go 做个脚本,但是丢个 go 的二进制放在流水线中,又违背了操作指令应当显式呈现在定义流程的 code 这一理念,于是暂时放弃 go 的实现方案,打算转用与流水线更亲和的 groovy 来实现这个需求,不必觉得难,事实上在实现如下需求之前,二丫 (opens new window)对 groovy 的编程经验也是零,因此如下的代码实现也会以运维视角来实现,而非纯编程方式,对于将发布流水线写的像编程一个项目那样,二丫 (opens new window)并不喜欢。
但在这里先说一个二丫 (opens new window)后来才知道的事情:在声明式的流水线中,一些场景中对原生的 groovy 代码会有各种各样的奇怪限制,比如二丫 (opens new window)在实验的过程中就遇到过不让用 URL 模块儿,不让用 write 模块儿的,大概报错日志如下:
Scripts not permitted to use staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods getText java.net.URL
如下:
Method definition not expected here. Please define the method at an appropriate place or perhaps try using a block/Closure instead
后来才了解到,针对 groovy 原生语法的一些常见用法,事实上 Jenkins pipeline 语法中已经提供了相对应的方法进行支持,比如原生的url
方法,在 pipeline 中,应该改用 httpRequest
方法,原生的json
解析方法,可以改用 readJSON
方法,原生的 writer.writeLine
方法,可以改用 writeFile
方法,凡此种种,不一而足。
所以当二丫 (opens new window)用原生 groovy 在本地调试成功之后,屁颠屁颠跑到 Jenkins 上运行时,发现各个步骤都会卡自己一道,最后不得不用 Jenkins 给定的方法进行了重构改造,不过这里还是放一下 groovy 原生的处理方式,仅当留念自己奋斗的成果:
import groovy.json.JsonSlurperClassic
def getHostList(jobName) {
def url = new URL("http://10.6.6.66:66/${jobName}")
def parsedJSON = parseJSON(url.getText())
return parsedJSON.host
}
def parseJSON(json) {
return new groovy.json.JsonSlurperClassic().parseText(json)
}
def newFile = new File(${ANSIBLE_HOSTS})
if (!newFile.exists()) {
newFile.createNewFile()
} else {
newFile.withWriter('utf-8') { writer ->
writer.writeLine '[remote]'
}
host = getHostList(${env.JOB_BASE_NAME})
host -= "ALL"
host.each{
println(it)
newFile.append(it + " ansible_port=22\n")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这段代码在本地直接通过 groovy 解析是没毛病的,放到 Jenkins 中就不行了。
于是再次借助 Jenkins 中流水线语法参考,对各个功能点逐个击破,最后完成脚本内容如下:
properties([
parameters([
[$class: 'ChoiceParameter', filterLength: 1, filterable: true, randomName: 'choice-parameter-18463792817640626',
name: 'HOST', choiceType: 'PT_SINGLE_SELECT', description: '选择要部署的主机',
script:[
$class: 'GroovyScript',
fallbackScript: [classpath: [], sandbox: false, script: 'return[\'error\']'],
script: [ classpath: [], sandbox: false,
script:'''import hudson.model.*
def getHostList(jobName) {
def url = new URL("http://10.6.6.66:66/${jobName}")
def parsedJSON = parseJSON(url.getText())
return parsedJSON.host
}
def parseJSON(json) {
return new groovy.json.JsonSlurperClassic().parseText(json)
}
return getHostList(Thread.currentThread().toString().split('job')[-1].replace('/',''))
''']]]])])
pipeline{
agent any
environment {
ANSIBLE_PORT="22" // 定义远程主机ssh端口,一般不需要更改
ANSIBLE_USER="root" // 定义远程主机ssh用户,一般不需要更改
// 定义主机hosts文件,一般不用更改
ANSIBLE_HOSTS="${WORKSPACE}/${env.JOB_BASE_NAME}_hosts"
}
stages{
stage('定义主机列表'){
steps{
script{
try {
if (HOST == 'ALL') {
def response = httpRequest \
httpMode: "GET",
ignoreSslErrors: true,
contentType: 'APPLICATION_JSON',
validResponseCodes: '200',
// requestBody: groovy.json.JsonOutput.toJson(["k1":"v1","k2":"v2"]),
url: "http://10.6.6.66:66/${env.JOB_BASE_NAME}"
println response.content
def props = readJSON text: response.content
props.host -= "ALL"
writeFile file: env.ANSIBLE_HOSTS, text: '[remote]\n'
props.host.each{
appendFile(env.ANSIBLE_HOSTS,it + " ansible_port=${env.ANSIBLE_PORT} ansible_user=${env.ANSIBLE_USER}")
}
} else {
writeFile file: env.ANSIBLE_HOSTS, text: '[remote]\n'
appendFile(env.ANSIBLE_HOSTS,HOST + " ansible_port=${env.ANSIBLE_PORT} ansible_user=${env.ANSIBLE_USER}")
}
}catch(exc) {
env.REASON = "定义主机列表出错"
throw(exc)
}
}
}
}
}
}
// 该方法实现了往文件内追加内容的功能
def appendFile(String fileFullPath, String line) {
if (fileExists(fileFullPath)) {
current = readFile fileFullPath
}
writeFile file: fileFullPath, text: current + line + "\n"
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
说明:
这里不一一介绍代码了,捡一些干货聊聊,有话则长,无话则短。
httpRequest
在流水线语法中有非常详细的设置项,感兴趣的同学可以去了解查看,这里特地预留了 requestBody,是为了二丫 (opens new window)通过该参数与 CMDB 交互时,能够提供更加灵活的方式,从而让代码更具普适性。readJSON
的用法非常简单,让二丫 (opens new window)能够直接解析 json 串。-=
是 groovy 语法的一种,能够直接删除掉列表中二丫 (opens new window)不需要的元素。.each
是一个遍历方法,它会返回一个 it 对象表示 value。writeFile
方法默认是覆盖式写文件,通过一个外挂方法,实现追加功能。
# 4,归纳
经过以上思路的实现之后,二丫 (opens new window)可以来做一些归纳,将一些规范化的东西约定出来,以便于通过同样的配置代码,完成不同的项目发布需求。
唯一 key 串联
CMDB 平台建设期间,应用的模型至少应该有如下定义:
# http://10.6.6.66:66/project?project=test-user-api-runner&env=test ProjectName 全局唯一 ProjectEnv 应用要有环境的属性 test pre prod HostList 主机列表,其中主机列表应该通过请求参数进行区分
1
2
3
4
5
6
7
8唯一 key 保障了全局交互的统一,环境标识为应用的不同环境提供不同数据,在 Jenkins 配置发布时,就会有如下三种环境:
test-user-api
,pre-user-api
,prod-user-api
。然后将 job 名字拆分,构造如下请求:A = 'test-user-api-runner' def B = A.tokenize('-') def projectEnv = B[0] println projectEnv B.remove(0) def projectName = B.join('-') println projectName
1
2
3
4
5
6
7通过一些处理从 Jenkins 项目名中拿到环境以及项目名的参数,实现一套代码,发布多套环境的需求。
保留冗余字段
除了上边提到的三种环境字段,还应该结合自己的实际保留一定的冗余字段,比如因为经常搞活动而需要扩缩容,这个时候就应该再多个场景
expand-user-api
,自动扩容过程中,通过运维平台购买下来的主机通过这个字段返回,然后复制一个项目将代码同步到扩容的主机上去。提高 CMDB 稳定性
当 CMDB 的应用接口暴漏给发布接入之后,同时对 CMDB 所在服务的稳定性提高了要求,原来通过单台提供服务的,现在就需要配置成两台高可用起来。
以 CMDB 为中心
这一举措彻底将发布中心从原来的手动维护转到以 CMDB 为中心的方案中来,那么如果保障 CMDB 中的数据可持续性稳定,准确,就是需要从购买机器,扩缩容流程,等逐个变动的场景中来细细规划。
封装一层
如上策略全局推广之后,所有应用的信息全部维护在 CMDB 中,那么发布就只是一个透明的管道,只需要将应用的参数传递给管道即可触发构建,这个时候,就是适合在 Jenkins 上封装一层,完全集成到运维平台的时候了。有很多公司一上来就要给 Jenkins 封装,最后发现要么无法封装成熟,要么就封装出了个新的 Jenkins。
本文从想法的萌发,到思路的梳理,到逐步的实现,都做了详细介绍以及过程中的心理描写,运维标准化规范化建设之路非常漫长,需要不断地深耕建设,更需要无数这样的点汇聚起来,从而才能形成一片堂堂汪洋。
本文研究完毕之后,我曾在朋友圈发表感慨一段,也摘录与此:
《技术的乐趣》
你在冲破一个新知识点的过程中,难道不充满曲折,困厄,迷惘,反复么!
你知道自己将要达到何方
你知道自己将要面对多少凶险
你几乎靠近最终答案
你经常与正确答案擦肩
你在被自己的怯弱劝退
你在被自己的勇气鼓励
你终于,会收获那最终的正确答案
而这个答案,足够你开心灿烂好久好久
同时摘录了南宋诗人杨万里的一首诗表意:
《桂源铺》- 杨万里
万山不许一溪奔,
拦得溪声日夜喧。
到得前头山脚尽,
堂堂溪水出前村。
# 5,参考
- How to get the job name on a groovy dynamic parameter? (opens new window)
- A real example of Jenkins active choices and reactive parameter (opens new window)
- 实战 Groovy: for each 剖析 (opens new window)
- 在 Java / Groovy 中将数组转换为字符串 (opens new window)
- 如何将文本追加到 jenkinsfile 中的文件 (opens new window)
- Jenkins Pipeline 实现 http 请求并解析响应 (opens new window)
- Groovy: Method definition not expected here (opens new window)
- 文件操作 (opens new window)
- Groovy 教程 (opens new window)
# 6,补充
# 1,参数优化
上边举例中,应用返回的主机列表里还有一个 ALL 字段,这是应用对应的主机列表所不存在的值,通常应用应该只返回主机列表,所以下边对此处做了简单的改造,利用列表的plus
参数,将 ALL 字段添加上去。
properties([
parameters([
[$class: 'ChoiceParameter', filterLength: 1, filterable: true, randomName: 'choice-parameter-18463792817640626',
name: 'HOST', choiceType: 'PT_SINGLE_SELECT', description: '选择要部署的主机',
script:[
$class: 'GroovyScript',
fallbackScript: [classpath: [], sandbox: false, script: 'return[\'error\']'],
script: [ classpath: [], sandbox: false,
script:'''import hudson.model.*
def getHostList(jobName,jobEnv) {
def url = new URL("http://10.6.6.66:66/${jobName}/${jobEnv}?token=abcdefg")
def parsedJSON = parseJSON(url.getText())
return parsedJSON.host
}
def parseJSON(json) {
return new groovy.json.JsonSlurperClassic().parseText(json)
}
def getProjetName(name) {
def B = name.tokenize('-')
B.remove(0)
return B.join('-')
}
def jobName = Thread.currentThread().toString().split('job')[-1].replace('/','')
def htmp = ["ALL"]
return htmp.plus(getHostList(getProjetName(jobName),jobName.tokenize('-')[0]))
''']]]])])
pipeline{
agent any
environment {
// 定义主机hosts文件,一般不用更改
ANSIBLE_HOSTS="${WORKSPACE}/${env.JOB_BASE_NAME}_hosts"
}
stages{
stage('初始化主机列表'){
steps{
script{
println env.HOST
}
}
}
}
}
// 该方法实现了往文件内追加内容的功能
def appendFile(String fileFullPath, String line) {
if (fileExists(fileFullPath)) {
current = readFile fileFullPath
}
writeFile file: fileFullPath, text: current + line + "\n"
}
def getProjetName(String name) {
def B = name.tokenize('-')
B.remove(0)
return B.join('-')
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58