当前位置:网站首页>Jenkins中pipeline对接CMDB接口获取主机列表的发布实践原创

Jenkins中pipeline对接CMDB接口获取主机列表的发布实践原创

2022-06-12 19:11:00 二丫讲梵

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)
}

单说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)
            }
        }
    }
}

那么,现如今想要对接CMDB发布服务,则只需要解决两个事情即可:

  1. 参数化构建处通过CMDB接口展示主机列表。
  2. 定义部署主机列表步骤改造成通过接口获取的方式渲染。

2,参数

二丫 (opens new window)有了明确的目标以及大概的思路之后,凭借着以往网上冲浪时大概看到过有人通过groovy实现通过接口渲染参数的模糊印象,开始了漫长地研究之路。

本文如下代码可能需要你根据报错情况添加插件,具体情况具体分析,这里不多赘述。目前简单记得至少需要如下插件:

找到以往模糊印象中的文章,确定了这里需要使用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"]}';
}

二丫 (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}"""
                }
            }
        }
    }
}

关于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

接着是借助URL模块儿,传递唯一参数应用名,拿到数据,return出来,当然这里如果用于生产,还应该再好好设计一下,这里暂时按下不表,在后文再详述。 最后困难的是如果在这个阶段拿到项目名,成了困住二丫 (opens new window)的一个地方,二丫 (opens new window)找遍了网上的资料,试了又试,败了又败,人家说:文章不厌百回改,二丫 (opens new window)说,code不厌百回试。虽然在active choice官方文档处有说明提供了两个变量获取Jenkins构建的系统信息:

也见到有人使用这两个变量来获取的,如下:

import hudson.model.*
def jobName = this.binding.jenkinsProject.name

但经自己的测试,却始终都没拿到。最后还是在Stack Overflow中看到一个答案:

def jobName = (Thread.currentThread().toString() =~ /job\/(.*?)\//)[0][1]

如果你所有项目都是单层目录的场景,则用上边的方案没有问题,只不过需要注意的是转义符会报语法错误,可多加一层转义处理:

def jobName = (Thread.currentThread().toString() =~ /job\\/(.*?)\\//)[0][1]

不过因为二丫 (opens new window)测试场景是多目录的情况,发现如上方法拿多层级job的时候会有问题,因此个人改造此方法如下:

def jobName = Thread.currentThread().toString().split('job')[-1].replace('/','')

现在来到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")
    }
}

这段代码在本地直接通过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"
}

说明:这里不一一介绍代码了,捡一些干货聊聊,有话则长,无话则短。

  • 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  主机列表,其中主机列表应该通过请求参数进行区分

通过一些处理从Jenkins项目名中拿到环境以及项目名的参数,实现一套代码,发布多套环境的需求。

保留冗余字段

除了上边提到的三种环境字段,还应该结合自己的实际保留一定的冗余字段,比如因为经常搞活动而需要扩缩容,这个时候就应该再多个场景expand-user-api,自动扩容过程中,通过运维平台购买下来的主机通过这个字段返回,然后复制一个项目将代码同步到扩容的主机上去。

提高CMDB稳定性

当CMDB的应用接口暴漏给发布接入之后,同时对CMDB所在服务的稳定性提高了要求,原来通过单台提供服务的,现在就需要配置成两台高可用起来。

以CMDB为中心

这一举措彻底将发布中心从原来的手动维护转到以CMDB为中心的方案中来,那么如果保障CMDB中的数据可持续性稳定,准确,就是需要从购买机器,扩缩容流程,等逐个变动的场景中来细细规划。

封装一层

如上策略全局推广之后,所有应用的信息全部维护在CMDB中,那么发布就只是一个透明的管道,只需要将应用的参数传递给管道即可触发构建,这个时候,就是适合在Jenkins上封装一层,完全集成到运维平台的时候了。有很多公司一上来就要给Jenkins封装,最后发现要么无法封装成熟,要么就封装出了个新的Jenkins。

本文从想法的萌发,到思路的梳理,到逐步的实现,都做了详细介绍以及过程中的心理描写,运维标准化规范化建设之路非常漫长,需要不断地深耕建设,更需要无数这样的点汇聚起来,从而才能形成一片堂堂汪洋。

本文研究完毕之后,我曾在朋友圈发表感慨一段,也摘录与此:

《技术的乐趣》

你在冲破一个新知识点的过程中,难道不充满曲折,困厄,迷惘,反复么!

你知道自己将要达到何方

你知道自己将要面对多少凶险

你几乎靠近最终答案

你经常与正确答案擦肩

你在被自己的怯弱劝退

你在被自己的勇气鼓励

你终于,会收获那最终的正确答案

而这个答案,足够你开心灿烂好久好久

同时摘录了南宋诗人杨万里的一首诗表意:

《桂源铺》- 杨万里

万山不许一溪奔,

拦得溪声日夜喧。

到得前头山脚尽,

堂堂溪水出前村。

5,参考

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('-')
}
原网站

版权声明
本文为[二丫讲梵]所创,转载请带上原文链接,感谢
https://cloud.tencent.com/developer/article/2020995