Jenkins基于Share Library共享库的最佳实践探索
# 视频
本文内容通过 B 站进行了直播分享,可直接看视频了解本文内容:
# 前言
古代治学总结有人生三境界,在我看来,Jenkins 使用接入亦有三个阶段,这也是每一个运维人员应用 Jenkins 所必须要经历的。
第一阶段:初接触,有很多不熟悉不了解,应用场景也大多是依赖手工点点点维护的 free style 或者 maven 风格的项目,通过配置一些参数,结合脚本进行项目的构建与发布。这种维护方式其实也是 Jenkins 在 1.0 时代的普遍使用姿势。它的最大弊端在于一切参数都要配置化,当维护的项目数量增多的时候,又遇到某些普遍性需要更改的地方,那么维护起来就是一场灾难了。
第二阶段:慢慢往后深入,接触越来越多,逐渐开始了解 pipeline,亦即 Jenkins 在 2.0 时代提出的一个新概念:配置即代码,或者说代码即配置。我们可以不必点点点勾选过多地配置项,只需通过代码定义即可实现相同的效果。只不过据我了解,很多人在这个阶段,因为对流水线基础语法掌握程度不够,因此还有很多人是手工配置参数,然后再结合 Jenkinsfile 来进行构建发布的,其实这种方式,并没有真正体悟到 2.0 的设计精髓,自然也无法吃到这波技术红利了。
第三阶段:你不应该满足于维护大量 Jenkinsfile 的现状中,过多的 Jenkinsfile 就像一个又一个肥大的肿块儿一般,对于后期的二次维护,都是极大的挑战。你可能听过共享库,渐渐你开始了解共享库,尝试共享库,最后将同一个类别的 Jenkinsfile 进行逻辑抽象,每个项目都变成了另外一种变相的参数化构建,从而接入项目只需配置对应的 引导文件
即可,这种方式能够极大地简化 Jenkins 的运维使用难度,对项目交付效率的提升有极大的帮助。
过往的 Jenkins 文章中,我已经写过不少第一阶段,第二阶段的概念或者实践,今天这篇文章,将会针对第三阶段的内容,结合以往的实践,手摸手教会你共享库最佳实践这堂课。
# 准备工作
# 参考内容
# 基础物料
- 基础的环境一笔带过
- Jenkins:2.343
- ansible:2.10.8
- Jenkins 插件,参考这篇文章 (opens new window),所有都安装一下,否则可能本文进行不会顺利。
- 基础代码库
- Jenkinsfile:存放项目的引导文件(我创想的一个词汇,指一个项目基于共享库的模板制定的引导信息。)。
- deploy-playbook:之前提过,宿主机构建流程交给 ansible 来完成。
- share-library:共享库存放的代码仓库。
# 主流程
# 配置秘钥凭据
因为所有步骤流程都要与 gitlab 进行交互,所以首先第一步我们确保配置上与 gitlab 通信的认证。
在 Jenkins 的 系统管理
--->Manage Credentials
中添加凭据:
添加固定的用户名密码如下:
注意:
- 这里强烈建议添加一个相对固定的账号和密码,以免因为某个人离职账号禁用,从而影响整个全局的构建认证。
- ID 可以自定义,后边与 gitlab 交互的时候会用到这个 ID。
# 配置共享库
此时你可以先不必纠结共享库是什么或者里边有哪些内容,你只需要知道:我们在 gitlab 已经创建了一个共享库的仓库,仓库地址为:https://jihulab.com/eryajf-jenkins/share-library.git。
警告
注意,因为极狐对公策略调整,因此本文对应的极狐仓库,现已全部放置在 github,对应关系如下:
接下来我们先把共享库配置到 Jenkins 平台上。
打开 系统管理
---> 系统配置
--->Global Pipeline Libraries
各个参数的含义这里不深入扩展,大家可以自行点问号进行理解。
主要内容简要说明:
- Name:将作为引导文件调用的标志。
- 上边的 Default version:此处可填可不填,如果不填,则默认是 master,也可以在引导文件调用的时候再定义。
- Retrieval method:提供了两种配置模式,可以选择默认现代 SCM。
- 下边就是一些常规配置,不多赘述。
# 引导文件内容
引导文件同样也通过 gitlab 仓库进行代码存储,而不是放置在 Jenkins 中,我们要有这种解耦的思想,不该耦合在一块儿的,务必拆开。通过 git 维护还有一个好处就是,对批量维护非常友好,比如我们之前曾有过一个构建 stage 废除,那么变量自然也不需要了,我就直接在 vscode 批量将变量删除即可,否则一个项目一个项目去点,就太难受了。
引导文件的目录结构如下:
$ tree -N Jenkinsfile
Jenkinsfile
└── ops
└── test-eryajf-blog.jenkins
1 directory, 1 file
2
3
4
5
6
注意:
- ops 目录在 Jenkins 当中也应该对应的是一个目录层面的(目录相当于 k8s 中的 namespace 层面的概念,而视图则相当于统一 namespace 下的不同 label 分组。)隔离。
- 在目录下存放不同项目不同环境的引导文件,建议一个目录下的文件直接放在一起,便于 web 端创建项目,这块儿随后会用实践例子展示。
分析引导文件内容:
@Library('global-shared-library@main') _
def map = [:]
// 定义项目构建运行的 NODE ,根据实际情况进行调整
map.put('RUN_NODE','master')
// 需要修改此处,定义项目名称
map.put('SERVICE_NAME','t-eryajf-blog.eryajf.net')
// 定义webroot目录,一般建议/data/www/${SERVICE_NAME}下
map.put('WEBROOT_DIR','/data/www/${SERVICE_NAME}')
// 定义项目默认的分支,根据实际情况调整
map.put('DEFAULT_BRANCH','main')
// 定义项目git地址
map.put('GIT_URL','https://jihulab.com/eryajf-jenkins/eryajf-blog.git')
// 定义主机选项参数,多台用\n分割
map.put('HOSTS','ALL\n192.168.64.6')
// 定义项目编译命令
map.put('BUILD_COMMAND','echo a')
// 定义项目部署之后执行的脚本,注意此脚本执行位置为 ${WEBROOT_DIR}
map.put('FREE_COMMAND','chown -R www.www /data/www/${SERVICE_NAME}/')
// 定义忽略文件或目录,多个用 \n 分割
map.put('EXCLUDE_FILE','ansible_tmp\nansible_tmp@tmp\n.git\nnode_modules')
// 用于打包编译的基础镜像
map.put('BUILD_BASE_IMAGE','eryajf/node:10.6')
// 指定将要部署到远程的目录,如果部署根目录,用 . 表示
map.put('PROJECT_FILE_PATH','.')
// 指定机器人key
map.put('ROBOT_KEY','6a781aaf-0cda-41ab-9bd2-ed81ee7fc7d2')
// 指定版本的路径以及key,一般不需要更改如下两项
map.put('VERSION_KEY', "$JOB_BASE_NAME" )
map.put('VERSION_FILE', "/jenkins_sync/version/$JOB_BASE_NAME" )
deploy_front_base(map)
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
说明:
- 开头
@Library('global-shared-library@main')_
表示调用上边配置共享库步骤配置的名字,这里还可以通过跟上不同后缀标识选择不同分支。 默认是 master 分支,这里因为我们用的是 main 分支,所以需要显式指定。 - 下边就是声明一个 map,然后将共享库中预留的参数进行完善补充,这里拿我在 gitlab 创建的一个静态内容做例子。其中的
BUILD_COMMAND
原本应该是填写前端项目的编译命令,这里只写一个测试。 - 最后的
deploy_front_base(map)
表示调用共享库中,在 vars 目录下,文件名为deploy_front_base.groovy
的模板文件,并将 map 传递其中。
# 共享库内容
官方文档对于共享库的介绍已经非常详细,只不过很多人仍旧可能会用的很懵,网上不同人用法也是各不一样,导致冲浪之后反而更加蒙圈,很多人也把共享库以一种编程的思维来看待,我认为这样未必有利于共享库的健康发展,毕竟日常使用以及维护它的,都是编程思维相对贫乏的运维同学来做的。
所以,我建议将共享库简化成如下目录结构:
$ tree -N
.
├── README.md
├── src
│ └── org
│ └── devops
│ └── otherTools.groovy
└── vars
└── deploy_front_base.groovy
3 directories, 3 files
2
3
4
5
6
7
8
9
10
11
- src:目录结构类似 Java 源目录结构,此处通常存放一些公共函数方法,供其他地方调用。
- vars:此处定义 Jenkins 服务端能够调用的变量脚本,通常放置的是构建流程主逻辑。
我在 Jenkins 管理维护运维规范 (opens new window) 一文中提到过两个规范:
- 流水线尽量使用一种语法,比如声明式,这样以后大家各自研究的成果可以直接共享,便于一起迭代前进。
- 尽可能将单个流水线的主逻辑放在更少的地方,这样对于后期的维护以及变更绝对是更加高效且省力的,不要一个流水线七零八落调用了五六个地方,非常不利于快速预览与定位。当然,一些非重要的公共逻辑,可以放在约定好的固定地方统一调用,比如通知脚本,回滚用的库,共享库等。
第一个不多赘述,玩到共享库的层面,大家需要理解脚本式与声明式 pipeline 的区别,我将规范统一约定成声明式,就是为了更加贴近运维思维,不仅为了当下,也为了后人维护简洁。
第二个,因为吃过一个项目的构建逻辑要调用五六个地方的脚本这种苦,所以我要求大家尽可能有一个完整的思维来对待共享库,让一个单线构建主逻辑尽可能完整。
所以我一般会给单个语言栈做一个共享库文件,将个性化配置通过参数外置,从而形成一个针对该语言栈通用的构建模板。这句话可能暂时你还不懂,不用急,下边会通过实际例子进行讲解。
内容详细分析:
#!groovy
import org.devops.otherTools
def call(Map map) {
pipeline {
agent {
label map.RUN_NODE
}
environment {
SERVICE_NAME = "${map.SERVICE_NAME}" // 需要修改此处,定义部署到远程的项目名称
GIT_URL = "${map.GIT_URL}"// 主项目地址
HOSTS="${map.HOSTS}" // 定义要部署的主机列表,多台用 \n 分隔
BUILD_COMMAND="${map.BUILD_COMMAND}" // 定义项目编译命令
FREE_COMMAND="${map.FREE_COMMAND}" // 定义项目部署之后执行的脚本
EXCLUDE_FILE="${map.EXCLUDE_FILE}" // 定义忽略文件或目录,多个用 \n 分割
BUILD_BASE_IMAGE="${map.BUILD_BASE_IMAGE}" // 用于打包编译的基础镜像
PROJECT_FILE_PATH="${map.PROJECT_FILE_PATH}" // 指定将要部署到远程的目录
ROBOT_KEY = "${map.ROBOT_KEY}" // 企业微信机器人key
VERSION_KEY="${map.VERSION_KEY}" // 指定版本文件的key
VERSION_FILE="${map.VERSION_FILE}" // 指定版本文件的路径
WEBROOT_DIR="${map.WEBROOT_DIR}" // 定义项目的webroot目录
GITLAB_AUTH_TOKEN="auth-gitlab" // 与gitlab认证的token,不需要更改
GIT_TOKEN="git-token" // 预留对接gitlab中webhook的的token,不需要更改
// 定义项目的临时压缩目录,一般不需要更改
BUILD_TMP="/data/build"
// 定义ansible-base目录
ANSIBLE_BASE="${WORKSPACE}/ansible_tmp/deployfrontbase"
// 定义构建镜像执行的参数
BUILD_ARGS="-v /data/.cache/node/node_cache:/data/.cache/node/node_cache -v /etc/hosts:/etc/hosts"
// 定义主机hosts文件,一般不用更改
ANSIBLE_HOSTS="${ANSIBLE_BASE}/deploy_hosts/${env.JOB_BASE_NAME}_hosts"
// ansible 剧本地址,一般不用更改
GIT_URL_ANSIBLE = "https://jihulab.com/eryajf-jenkins/deploy-playbook.git"
}
options {
timestamps()
disableConcurrentBuilds()
timeout(time: 10, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '12'))
}
triggers{
gitlab(triggerOnPush: true, triggerOnMergeRequest: true, branchFilterType: 'All', secretToken: "${env.GIT_TOKEN}") // 预留Gitlab提交自动构建
}
parameters {
string(name: 'BRANCH', defaultValue: map.DEFAULT_BRANCH, description: '请输入将要构建的代码分支')
choice(name: 'REMOTE_HOST', choices: map.HOSTS, description: '选择要发布的主机,默认为ALL') // 定义项目对应的主机列表
choice(name: 'MODE', choices: ['DEPLOY','ROLLBACK'], description: '请选择发布或者回滚?')
extendedChoice(description: '回滚版本选择,倒序排序,只保留最近十次版本;如果选择发布则忽略此项', multiSelectDelimiter: ',', name: 'ROLLBACK_VERSION', propertyFile: map.VERSION_FILE, propertyKey: map.VERSION_KEY, quoteValue: false, saveJSONParameterToFile: false, type: 'PT_SINGLE_SELECT', visibleItemCount: 10)
}
stages {
stage('拉取代码') {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
script {
try {
checkout(
[$class: 'GitSCM', doGenerateSubmoduleConfigurations: false, submoduleCfg: [], extensions: [[$class: 'CloneOption', depth: 1, noTags: false, reference: '', shallow: true]],
branches: [[name: "$BRANCH"]],userRemoteConfigs: [[url: "${env.GIT_URL}", credentialsId: "${env.GITLAB_AUTH_TOKEN}"]]]
)
// 定义全局变量
env.PULL_TIME = sh(script: "echo `date +'%Y-%m-%d %H:%M:%S'`", returnStdout: true).trim() // 获取时间
env.COMMIT_ID = sh(script: 'git log --pretty=format:%h', returnStdout: true).trim() // 提交ID
env.TRACE_ID = sh(script: "echo `head -c 32 /dev/random | base64`", returnStdout: true).trim() // 随机生成TRACE_ID
env.COMMIT_USER = sh(script: 'git log --pretty=format:%an', returnStdout: true).trim() // 提交者
env.COMMIT_TIME = sh(script: 'git log --pretty=format:%ai', returnStdout: true).trim() // 提交时间
env.COMMIT_INFO = sh(script: 'git log --pretty=format:%s', returnStdout: true).trim() // 提交信息
env._VERSION = sh(script: "echo `date '+%Y%m%d%H%M%S'`" + "_${COMMIT_ID}" + "_${env.BUILD_ID}", returnStdout: true).trim() // 对应构建的版本 时间+commitID+buildID
}catch(exc) {
// 添加变量占位,以避免构建异常
env.PULL_TIME = "无法获取"
env.COMMIT_ID = "无法获取"
env.TRACE_ID = "无法获取"
env.COMMIT_USER = "无法获取"
env.COMMIT_TIME = "无法获取"
env.COMMIT_INFO = "无法获取"
env.IMAGE_NAME = "无法获取"
env.REASON = "构建分支不存在或认证失败"
throw(exc)
}
}
}
}
stage('拉取ansible剧本') {
steps {
dir("${WORKSPACE}/ansible_tmp"){
script {
try {
checkout(
[$class: 'GitSCM', doGenerateSubmoduleConfigurations: false, submoduleCfg: [], extensions: [[$class: 'CloneOption', depth: 1, noTags: false, reference: '', shallow: true]],
branches: [[name: "master"]],userRemoteConfigs: [[url: "${env.GIT_URL_ANSIBLE}", credentialsId: "${env.GITLAB_AUTH_TOKEN}"]]]
)
}catch(exc) {
env.REASON = "拉取ansible剧本出错"
throw(exc)
}
}
}
}
}
stage('编译项目') {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
script {
try {
ansiColor('xterm') {
docker.image("${BUILD_BASE_IMAGE}").inside("${BUILD_ARGS}") {
sh "$BUILD_COMMAND"
}
}
}catch(exc) {
env.REASON = "编译项目出错"
throw(exc)
}
}
}
}
stage ('并行如下任务'){
parallel {
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=34222" >> ${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)
}
}
}
}
stage('定义忽略文件'){
steps{
script{
try{
sh "echo -e \"${EXCLUDE_FILE}\" > ${WORKSPACE}/exclude_file.txt"
}catch(exc) {
env.Reason = "定义忽略文件出错"
throw(exc)
}
}
}
}
}
}
stage('压缩制品') {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
dir("${WORKSPACE}/${PROJECT_FILE_PATH}"){
script {
try {
sh "touch ${BUILD_TMP}/${_VERSION}.tar.bz2 && tar -zc -X \"${WORKSPACE}/exclude_file.txt\" -f ${BUILD_TMP}/${_VERSION}.tar.bz2 ./*"
}catch(exc) {
env.REASON = "压缩制品出错"
throw(exc)
}
}
}
}
}
stage('向左<->向右') {
stages {
stage('部署<向左') {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
dir("${ANSIBLE_BASE}"){
script {
try {
ansiColor('xterm') {
sh "echo \"${FREE_COMMAND}\" > ${ANSIBLE_BASE}/roles/deploy/files/free.sh"
sh """
ansible-playbook -vv -i ./deploy_hosts/${env.JOB_BASE_NAME}_hosts --tags "deploy" site.yml -e "SERVICE_NAME=${SERVICE_NAME} BUILD_TMP=${BUILD_TMP} _VERSION=${_VERSION} WEBROOT_DIR=${WEBROOT_DIR} WORKSPACE=${WORKSPACE}"
"""
}
}catch(exc) {
env.Reason = "项目部署步骤出错"
throw(exc)
}
}
}
}
}
stage('向右>回滚') {
when {
environment name: 'MODE',value: 'ROLLBACK'
}
steps {
dir("${ANSIBLE_BASE}"){
script {
try{
ansiColor('xterm') {
sh "echo \"${FREE_COMMAND}\" > ${ANSIBLE_BASE}/roles/rollback/files/free.sh"
sh """
ansible-playbook -vv -i ./deploy_hosts/${env.JOB_BASE_NAME}_hosts --tags="rollback" site.yml -e "SERVICE_NAME=${SERVICE_NAME} WEBROOT_DIR=${WEBROOT_DIR} _VERSION=${ROLLBACK_VERSION}"
"""
}
}catch(exc) {
env.Reason = "项目回滚步骤出错"
throw(exc)
}
}
}
}
}
}
}
stage("版本号写入") {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
script {
try {
env.FILE=sh (script:"ls ${VERSION_FILE}",returnStatus: true)
if("${env.FILE}" != "0") {
sh "echo \"${VERSION_KEY}=${_VERSION}\" > ${VERSION_FILE}"
}else {
sh 'sed -i "s#=#&${_VERSION},#" ${VERSION_FILE}'
}
env.NUMBER=sh (script: 'grep -o , ${VERSION_FILE} | wc -l', returnStdout: true).trim()
// 判断版本号是否为10个
if("${NUMBER}" == "10") {
sh '''
sed -i "s#,`cut -d, -f11 ${VERSION_FILE}`##" ${VERSION_FILE}
'''
}
}catch(exc) {
env.REASON = "版本号写入出错"
throw(exc)
}
}
}
}
}
post {
always {
wrap([$class: 'BuildUser']){
script{
if ("${MODE}" == "DEPLOY") {
buildName "#${BUILD_ID}-${BRANCH}-${BUILD_USER}" // 更改构建名称
currentBuild.description = "提交者: ${COMMIT_USER}" // 添加说明信息
currentBuild.description += "\n构建主机: ${REMOTE_HOST}" // 添加说明信息
currentBuild.description += "\n提交ID: ${COMMIT_ID}" // 添加说明信息
currentBuild.description += "\n提交时间: ${COMMIT_TIME}" // 添加说明信息
currentBuild.description += "\n提交内容: ${COMMIT_INFO}" // 添加说明信息
sh "rm -f ${BUILD_TMP}/${_VERSION}.tar.bz2"
}else{
buildName "#${BUILD_ID}-${BRANCH}-${BUILD_USER}" // 更改构建名称
currentBuild.description = "回滚版本号为: ${ROLLBACK_VERSION}" // 添加说明信息
}
sh "printenv"
}
}
}
success {
wrap([$class: 'BuildUser']){
script{
sh """
echo "构建成功🥳🥳🥳"
"""
}
}
}
failure {
wrap([$class: 'BuildUser']){
script{
sh """
echo "构建失败😤😤😤"
"""
}
}
}
}
}
}
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
大部分内容都添加了详细的备注,不再赘述,只从架构层面做一下解析。
开头引入的一个 otherTools 包,其实是提供了一个参数为空不报错的方法,这里展示一下,以供大家参考使用:
package org.devops
// 此处的逻辑是为了解决那些流水线中调用没有的变量而不致使构建报错
// 用法:DefaultIfInexistent({COMMIT_USER}, "")
// 如果 COMMIT_USER 不存在,则返回空
static def DefaultIfInexistent(varNameExpr, defaultValue) {
try {
varNameExpr().replace("'","").replace('"','')
} catch (exc) {
defaultValue
}
}
2
3
4
5
6
7
8
9
10
11
12
接下来是模板总框架:
def call(Map map) {
}
2
这个定义是承接 Jenkinsfile 仓库引导文件传递的 map,也就是此处完成了引导文件声明的变量传递给构建的主逻辑。具体抽象哪些,如何抽象,参考如上文件基本上都能弄出来。
再往内层走,其实就是一个单独完整的 Jenkinsfile 的简单抽象,把原来的变量通过 map 中带的内容覆盖到当次构建中。不少地方都值得单独说一说,这里暂时不展开,以后有机会单独开篇进行详述。
# 剧本内容
因为 ansible 剧本内容也不复杂,所以这里就把文件内容罗列一下,以供大家参考。
剧本通常也是与共享库对应的,目录结构如下:
$ tree -N deploy-playbook
deploy-playbook
└── deployfrontbase
├── deploy_hosts
│ └── test-jianghu-fuwu_hosts
├── roles
│ ├── deploy
│ │ ├── files
│ │ │ ├── free.sh
│ │ │ └── keepfive.sh
│ │ └── tasks
│ │ └── main.yml
│ └── rollback
│ ├── files
│ │ └── free.sh
│ └── tasks
│ └── main.yml
└── site.yml
9 directories, 7 files
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
deployfrontbase
对应上边 deploy_front_base 的共享库,其中 deploy_hosts
目录只是一个占位,内容为空,两个 files 下的 free.sh 也是一个占位,内容来自引导文件中的自定义,因此这些文件就不再一一呈现内容。
keepfive.sh:
while true;do
A=`ls . | wc -l`
B=`ls -lrX . | tail -n 1 | awk '{print $9}'`
if [ $A -gt 10 ];then rm -rf ./$B;else break;fi;done
2
3
4
roles/deploy/tasks/main.yml:
---
- name: "创建远程主机上的版本目录"
file:
path: "{{item}}"
state: directory
with_items:
- /data/www
- /data/releases/{{SERVICE_NAME}}/{{_VERSION}}
tags: deploy
- name: "将代码同步到远程主机版本目录"
unarchive:
src: /{{BUILD_TMP}}/{{_VERSION}}.tar.bz2
dest: /data/releases/{{SERVICE_NAME}}/{{_VERSION}}
tags: deploy
- name: "将项目部署到生产目录"
file:
state: link
path: "{{WEBROOT_DIR}}"
src: /data/releases/{{SERVICE_NAME}}/{{_VERSION}}
tags: deploy
- name: "执行自由脚本"
script: chdir="{{WEBROOT_DIR}}" free.sh
tags: deploy
- name: "保留10个版本在版本目录"
script: chdir=/data/releases/{{SERVICE_NAME}} keepfive.sh
tags: deploy
- name: "获取远程目录下内容"
shell: "ls -lrt {{WEBROOT_DIR}}/ | grep -v total"
register: info
run_once: true
tags: deploy
- name: "列出远程目录下的文件"
debug:
msg: "{{ info.stdout_lines }}"
run_once: true
tags: deploy
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
roles/rollback/tasks/main.yml:
---
- name: "检查将要回滚的旧版本是否存在"
shell: 'if [ -d /data/releases/{{ SERVICE_NAME }}/{{ _VERSION }} ]; then echo "true"; else echo "false";fi'
register: versionexists
tags: rollback
- name: "将旧版本回滚到当前项目根目录"
file:
state: link
src: /data/releases/{{ SERVICE_NAME }}/{{ _VERSION }}
path: "{{WEBROOT_DIR}}"
when: versionexists.stdout == "true"
tags: rollback
- name: "执行自由脚本"
script: chdir="{{WEBROOT_DIR}}" free.sh
tags: rollback
- name: "列出项目根目录下的文件"
shell: "ls -lrt {{WEBROOT_DIR}}/ | grep -v total"
register: info
ignore_errors: True
run_once: true
tags: rollback
- name: "打印"
debug:
msg: "{{ info.stdout_lines }}"
run_once: true
tags: rollback
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
site.yml:
---
- hosts: "remote"
serial: 5
max_fail_percentage: 0
name: "开始部署服务上线"
remote_user: "root"
roles:
- deploy
- rollback
2
3
4
5
6
7
8
9
基本上都是 ansible 剧本方面的基础内容,不过多展开,有需要的同学可以直接参考,觉得不符合需求的可自行调配。
# 实践开始
# 创建项目
如上准备工作完成之后,我们就来创建一个项目试试效果。
几个标注的点做一下说明:
- 注意要在 ops 目录下创建该项目。
- 注意项目名与仓库中的文件名保持一致,这是全局的项目标识。规范为:环境+项目名。
- 所有 git 相关的链接,统一使用 HTTP 风格,不允许使用 git 风格(http 风格的优势在于,无论是在构建日志中,还是构建通知中,都能直接通过鼠标点击链接跳转到项目仓库位置,而不需要二次转化,运维优雅化的细节,正在于此!)。
- 注意现在 gitlab 新建项目默认分支为 main,需要调整这里,当然也可以修改 gitlab 默认分支为 master,因为 Jenkins 默认还是拉的 master。
- 注意脚本路径,除去目录之外,使用 Jenkins 的系统变量,这样以后在 ops 目录下新建的项目,都可以使用这一套模板了,那么如果想要在 Jenkins 基础上平台化,其实这个内容就能固化下来了。
# 运行项目
通常第一次运行项目是不会有正式的构建的,我把这次构建称为 配置落位
(上边创建项目时只添加了该项目的配置仓库以及所在位置,所有的参数化配置及其他配置信息都没有任何手动添加,第一次运行则是 Jenkins 将这些在共享库中声明的配置落位的过程。)。
当我们看到构建按钮变成参数化构建按钮时,说明配置已经正常落位,就能进行正常的构建了。
构建似乎没有什么太多可说,不过这里摘录一些构建日志,进行一些说明。
# 加载流程
先看开头的一段日志。
Started by user admin
Obtained ops/test-eryajf-blog.jenkins from git https://jihulab.com/eryajf-jenkins/Jenkinsfile.git
Loading library global-shared-library@main
Attempting to resolve main from remote references...
> git --version # timeout=10
> git --version # 'git version 2.30.2'
using GIT_ASKPASS to set credentials gitlab认证
> git ls-remote -h -- https://jihulab.com/eryajf-jenkins/share-library.git # timeout=10
Found match: refs/heads/main revision a11b1e90801cd56a54a8c94716fb53c51aa02797
The recommended git tool is: NONE
using credential auth-gitlab
> git rev-parse --resolve-git-dir /var/jenkins_home/workspace/ops/test-eryajf-blog@libs/19e2c3b02b6e0d75ca1ec4c1f0f4f244d2feaed5d393d409eaac05133f2b1c63/.git # timeout=10
Fetching changes from the remote Git repository
> git config remote.origin.url https://jihulab.com/eryajf-jenkins/share-library.git # timeout=10
Fetching without tags
Fetching upstream changes from https://jihulab.com/eryajf-jenkins/share-library.git
> git --version # timeout=10
> git --version # 'git version 2.30.2'
using GIT_ASKPASS to set credentials gitlab认证
> git fetch --no-tags --force --progress -- https://jihulab.com/eryajf-jenkins/share-library.git +refs/heads/*:refs/remotes/origin/* # timeout=10
Checking out Revision a11b1e90801cd56a54a8c94716fb53c51aa02797 (main)
> git config core.sparsecheckout # timeout=10
> git checkout -f a11b1e90801cd56a54a8c94716fb53c51aa02797 # timeout=10
Commit message: "fix ansible"
> git rev-list --no-walk a11b1e90801cd56a54a8c94716fb53c51aa02797 # timeout=10
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- 第一行是说由 admin 用户触发构建。
- 第二行很重要:是说从刚刚创建项目时的配置信息,即仓库 https://jihulab.com/eryajf-jenkins/Jenkinsfile.git 的
ops/test-eryajf-blog.jenkins
文件,进行配置读取加载。 - 接着第三行,就加载了引导文件的第一行内容:加载
global-shared-library@main
这个共享库,再往下就是拉取共享库内容到本地。
再往下就是拉取代码,拉取 ansible 剧本的代码,基本上都是常规操作,这里不多赘述。
# 编译的设计
到编译项目这里,值得单独拿出来说下:
[Pipeline] { (编译项目)
[Pipeline] script
[Pipeline] {
[Pipeline] ansiColor
[Pipeline] {
[2022-05-21T14:35:47.625Z]
[Pipeline] isUnix
[Pipeline] withEnv
[Pipeline] {
[Pipeline] sh
[2022-05-21T14:35:47.998Z] + docker inspect -f . eryajf/node:10.6
[2022-05-21T14:35:47.998Z] .
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] withDockerContainer
[2022-05-21T14:35:48.098Z] Jenkins seems to be running inside container e8a3a178e62be79e3fb69612ede25be78b7b781abed4ba812bffc4e5da6961b4
[2022-05-21T14:35:48.149Z] $ docker run -t -d -u 0:0 -v /data/.cache/node/node_cache:/data/.cache/node/node_cache -v /etc/hosts:/etc/hosts -w /var/jenkins_home/workspace/ops/test-eryajf-blog --volumes-from e8a3a178e62be79e3fb69612ede25be78b7b781abed4ba812bffc4e5da6961b4 -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** eryajf/node:10.6 cat
[2022-05-21T14:35:48.506Z] $ docker top 4d36892888ccd2c62eb61068a44360ddcc961346e6749bb22cfb946c2eb42c1f -eo pid,comm
[Pipeline] {
[Pipeline] sh
[2022-05-21T14:35:48.873Z] + echo test
[2022-05-21T14:35:48.873Z] test
[Pipeline] }
[2022-05-21T14:35:48.890Z] $ docker stop --time=1 4d36892888ccd2c62eb61068a44360ddcc961346e6749bb22cfb946c2eb42c1f
[2022-05-21T14:35:50.068Z] $ docker rm -f 4d36892888ccd2c62eb61068a44360ddcc961346e6749bb22cfb946c2eb42c1f
[Pipeline] // withDockerContainer
[Pipeline] }
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
如上输入通过如下这段配置实现:
stage('编译项目') {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
script {
try {
ansiColor('xterm') {
docker.image("${BUILD_BASE_IMAGE}").inside("${BUILD_ARGS}") {
sh "$BUILD_COMMAND"
}
}
}catch(exc) {
env.REASON = "编译项目出错"
throw(exc)
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这是 Jenkins 官方提供的能力,我们可以直接通过这种方式启动一个临时容器,用于执行那些需要环境依赖的命令。
强烈推荐生产环境使用这种方式对宿主机的 Jenkins 环境进行保护,这样无论是多版本的 node,还是编译资源依赖的问题,都可以非常优雅的解决掉,同样,这也是我们在 Jenkins 生产规范中很重要的一条约定。
docker.images:
此处指定的是要拉起的镜像,通过参数外置在引导文件中。.inside:
指定拉起镜像时的参数,同样,参数做了一层提取,不在这里冗杂呈现。- 注意日志的打印,运行容器的时候,会首先将参数跟在 run 命令后边,然后有一个非常重要的挂载就是 Jenkins 默认会将当次构建项目的
$WORKSPACE
挂载进拉起的容器中,以便于直接执行相关命令。 - 另外注意:Jenkins 官方通过执行一个
cat
命令将容器挂起,如果你的基础镜像中没有这个命令,则可能会报错。 - 然后在这种环境基础之下,执行对应的自定义编译命令,我们这里执行的命令是对应引导文件中定义的 echo test,执行完毕之后,Jenkins 会自动将该容器销毁。可谓神龙见首不见尾,春梦了无痕!
- 另外提一个小经验:有时候我们配置的依赖环境,可能运行起来会有问题,想要调试,那么可以在 sh 前边加一个
sleep 300
然后就可以让容器夯住,从而手工进到容器内进行当次环境的调试。
# 主机列表的设计
主机列表的构建逻辑,我这里经过两次迭代的设计,结合选项参数,现在基本上达到完美的地步。
构建日志如下:
[2022-05-21T14:35:50.649Z] + OLD='
[2022-05-21T14:35:50.649Z] '
[2022-05-21T14:35:50.649Z] + IFS='
[2022-05-21T14:35:50.649Z] '
[2022-05-21T14:35:50.649Z] + '[' ALL == ALL ']'
[2022-05-21T14:35:50.649Z] + echo '[remote]'
[2022-05-21T14:35:50.649Z] + for i in ${HOSTS}
[2022-05-21T14:35:50.649Z] + echo 'ALL ansible_port=22'
[2022-05-21T14:35:50.649Z] + for i in ${HOSTS}
[2022-05-21T14:35:50.649Z] + echo '172.19.192.132 ansible_port=22'
[2022-05-21T14:35:50.649Z] + sed -i /ALL/d /var/jenkins_home/workspace/ops/test-eryajf-blog/ansible_tmp/deployfrontbase/deploy_hosts/test-eryajf-blog_hosts
[2022-05-21T14:35:50.649Z] + IFS='
[2022-05-21T14:35:50.649Z] '
2
3
4
5
6
7
8
9
10
11
12
13
当下配置内容如下:
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=22" >> ${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
内容基本上也都是比较简单的 shell 脚本,这里不对脚本进行过多介绍,主要说一下思路历程。
首先注意:供给判断使用的 ${REMOTE_HOST}
参数来自 parameters 区域的选项参数:
parameters {
choice(name: 'REMOTE_HOST', choices: map.HOSTS, description: '选择要发布的主机,默认为ALL') // 定义项目对应的主机列表
}
2
3
这里的选项列表来源是 map.HOSTS
这个变量,那么这个变量其实又来源于引导文件,再看引导文件中的定义:
// 定义主机选项参数,多台用\n分割
map.put('HOSTS','ALL\n192.168.64.6')
2
到这里让我们逆向思维简单总结下:
- 引导文件中通过声明变量 HOSTS 来定义项目对应的主机列表,这是共享库模板底层功能外置到引导文件的集中体现。
- 这个 HOSTS 变量,首先是作为选项参数的选项列表,事实上,之所以使用
\n
分割变量的内容,就是因为这是选项参数配置要求的规范。 - 最后再来到生成主机列表的 stage,这里因为想要复用选项参数的主机列表,所以使用了 shell 中的 IFS 变量,默认情况下,shell 中循环的分隔符为空格,通过重新定义 IFS 我们可以获得自定义的分割符。
以上是主机列表动态生成的当下功能解析。接下来讲下设计的迭代历程,以及为什么这么设计。
迭代历程
事实上在第一个版本中,我还没了解到 IFS 这个系统变量,于是当时选项参数使用的主机列表变量与最后遍历生成主机列表的变量,是通过两个变量来完成的,彼时引导文件中声明主机列表时是这样的:
// 定义主机选项参数,多台用\n分割
map.put('HOSTS','ALL\n192.168.64.6\n192.168.64.7')
// 定义部署主机列表,多台用空格分割
map.put('BUILD_HOSTS','ALL 192.168.64.6 192.168.64.7')
2
3
4
看过上面详细解析的同学,想必应该能理解这里的含义,没错,当时新建一个项目,配置引导文件的主机列表时就是需要配置两遍,首先在接入优雅度上不够好,其次也增加了配置遗漏或配置错误的几率。
所以后来在做 Jenkins 统一项目的时候,我就再深入研究了一下这块儿,将两个变量糅合为一了。
为什么要动态生成?
我知道有很多公司在通过 Jenkins 与 ansible 结合构建的时候,对应项目的主机列表通常是维护在主机上的 /etc/ansible/hosts
中,我们之前也有一些实践是用的这种方式,这里我说下为什么没有采用这种方式。
- 运维铁律:鸡蛋不要放在一个篮子里。
- 我们配置之后的 Jenkins 引导文件有八九百个,如果全部维护在一个 hosts 文件中,那工作是不可想象的。
- 如果这么做,同样违反了单项目构建过程中的物料信息,不要到处堆放的原则。
- 不便于业务方针对单台发布场景的支持,动态生成这里已经设计成,默认 ALL 将会部署所有主机,如果触发构建的同学希望部署到单台,则可以选择单台主机 IP 进行构建。
所以我设计了结合项目的动态生成方案,化整为零,每个项目只需要维护好自己在引导文件中的 HOSTS 变量即可,这对于我们经常会有主机扩缩容的业务场景来说,是非常重要的,同时在于其他形如 CMDB 平台结合的时候,这种设计的优势也将会体现出来。
# 版本号如何维护
版本号是满足 Jenkins 对项目发布的清晰认知,以及回滚能力支撑的基础。版本号功能同样经历过多次迭代,最终我们借用了 extended-choice-parameter
提供的能力,将构建的版本号存到文件中,Jenkins 会自动读取给出可用的版本号列表,以便于研发同学进行回滚的操作。
当前版本代码如下:
stage("版本号写入") {
when {
environment name: 'MODE',value: 'DEPLOY'
}
steps {
script {
try {
env.FILE=sh (script:"ls ${VERSION_FILE}",returnStatus: true)
if("${env.FILE}" != "0") {
sh "echo \"${VERSION_KEY}=${_VERSION}\" > ${VERSION_FILE}"
}else {
sh 'sed -i "s#=#&${_VERSION},#" ${VERSION_FILE}'
}
env.NUMBER=sh (script: 'grep -o , ${VERSION_FILE} | wc -l', returnStdout: true).trim()
// 判断版本号是否为10个
if("${NUMBER}" == "10") {
sh '''
sed -i "s#,`cut -d, -f11 ${VERSION_FILE}`##" ${VERSION_FILE}
'''
}
}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
25
26
27
具体 shell 方面的逻辑这里也不展开了,大家可以自行调试验证。
里边用到了两个变量,我把这两个变量外置到引导文件中了,不过其实通常也是固定的,引导文件中这两个变量内容如下:
// 指定版本的路径以及key,一般不需要更改如下两项
map.put('VERSION_KEY', "$JOB_BASE_NAME" )
map.put('VERSION_FILE', "/jenkins_sync/version/$JOB_BASE_NAME" )
2
3
最后实现效果如下:
详细的回滚逻辑,这里也不展开,之前已经做过不少的分享。
# 辅助信息外置
注意在 post 阶段,我们给所有的共享库流水线都添加了一个 always
步骤,内容如下:
always {
script{
wrap([$class: 'BuildUser']){
buildName "#${BUILD_ID}-${BRANCH}-${BUILD_USER}" // 更改构建名称
currentBuild.description = "提交者: ${COMMIT_USER}" // 添加说明信息
currentBuild.description += "\n提交ID: ${COMMIT_ID}" // 添加说明信息
currentBuild.description += "\n提交时间: ${COMMIT_TIME}" // 添加说明信息
currentBuild.description += "\n提交内容: ${COMMIT_INFO}" // 添加说明信息
}
sh "printenv"
}
}
2
3
4
5
6
7
8
9
10
11
12
- 上边
wrap
包裹的内容,是通过修改构建名称,以及构建描述信息将当次构建的一些信息外置到左侧构建详情中。效果如下:
- 下边
sh "printenv"
单独打印当次构建的所有变量,此举看似是一个简单的闲笔,事实上给日常运维工作排查问题带来了极大的帮助,不至于在排查的时候还需要再添加调试代码进行打印。
# 构建通知
测试中构建通知只是打印了一下成功与失败,实际生产环境中,这里也需要精心设计一下。
最后的构建通知,可以集成一个脚本,通过 webhook 发送给不同群的机器人。这里提供一个发送内容的模板,经过我们的实践,这是一个涵盖了对应项目当次构建的重要信息的通知内容:
成功状态:
- 构建成功,请相关同学注意!
- 构建项目: test-eryajf-blog (opens new window)
- 构建作者: admin
- 构建分支: main
- 提交作者: eryajf
- 提交时间: 2022-05-21 22:35:44
- 构建内容: Initial commit
- 构建版本: 20220521223546_f81a77a_8
- 构建日志: 点击查看 (opens new window)
失败状态
- 构建失败,请相关同学注意
- 构建项目: test-eryajf-blog (opens new window)
- 构建作者: admin
- 失败原因: 编译步骤出错
- 构建日志: 点击查看 (opens new window)
机器人信息通常支持 Markdown,因此这个脚本输出的内容尽量写好看优雅一些,最后还应该添加一个@构建者的能力,这个根据自己的实际需求以及情况满足,不在此赘述。
# 新建项目
如上种种配置安排到位之后,新建项目将会是一个分钟级别的操作,为了展示这种使用方式的优势,我也模拟实践新建一个项目,看看大概的流程。
# 获得素材
素材亦即引导文件中所需要填充替换的内容,这里的内容建议在企业中做成工单模板,比如这个前端项目,就应该做一个前端类的工单模板,业务方需要上线新的项目,就可以按照工单模板,把我们关心的内容提交过来,由运维转化成 Jenkins 风格的引导文件内容。
当然还可以再往平台化方向进一步,就是直接将文件的生产与工单模板打通,当业务方提交了工单之后,直接生成引导文件的内容。
拿上边的项目举例子,现在需要上线该项目的预发环境,则引导文件路径为 ops/pre-eryajf-blog.jenkins
,内容如下:
@Library('global-shared-library@main') _
def map = [:]
// 定义项目构建运行的 NODE ,根据实际情况进行调整
map.put('RUN_NODE','master')
// 需要修改此处,定义项目名称
map.put('SERVICE_NAME','pre-eryajf-blog.eryajf.net')
// 定义webroot目录,一般建议/data/www/${SERVICE_NAME}下
map.put('WEBROOT_DIR','/data/www/${SERVICE_NAME}')
// 定义项目默认的分支,根据实际情况调整
map.put('DEFAULT_BRANCH','main')
// 定义项目git地址
map.put('GIT_URL','https://jihulab.com/eryajf-jenkins/eryajf-blog.git')
// 定义主机选项参数,多台用\n分割
map.put('HOSTS','ALL\n172.19.192.132')
// 定义项目编译命令
map.put('BUILD_COMMAND','echo test')
// 定义项目部署之后执行的脚本,注意此脚本执行位置为 ${WEBROOT_DIR}
map.put('FREE_COMMAND','chown -R www.www /data/www/${SERVICE_NAME}/')
// 定义忽略文件或目录,多个用 \n 分割
map.put('EXCLUDE_FILE','ansible_tmp\nansible_tmp@tmp\n.git\nnode_modules')
// 用于打包编译的基础镜像
map.put('BUILD_BASE_IMAGE','eryajf/node:10.6')
// 指定将要部署到远程的目录,如果部署根目录,用 . 表示
map.put('PROJECT_FILE_PATH','.')
// 指定机器人key
map.put('ROBOT_KEY','6a781aaf-0cda-41ab-9bd2-ed81ee7fc7d2')
// 指定版本的路径以及key,一般不需要更改如下两项
map.put('VERSION_KEY', "$JOB_BASE_NAME" )
map.put('VERSION_FILE', "/jenkins_sync/version/$JOB_BASE_NAME" )
deploy_front_base(map)
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
# 创建项目
通常来说,如果测试环境已经配置完毕,那么预发环境只需要将文件拷贝过来,更改一下项目名,要发布的主机列表就可以了。
然后在 Jenkins 的 ops 文件夹内,新建项目,复制项目,就完成了一个新项目的创建交付:
这种操作,对于熟练了的运维同学来说,基本上就是五分钟的事情。
# 后置总结
很多人不屑于用 Jenkins,以至于错过了 Jenkins 真正的美妙。
很少人领略过 Jenkins 的美妙,以至于感觉到 Jenkins 不好用。
终于完成本篇内容,希望看完的你,能有所收获,并感受到 Jenkins 的美妙。
另外想说一句:这种构建过程中参数化外置的思想,并非个人首创,而是之前维护过一阵儿 Walle 发布系统,这种思想也是从受其启发而演进出来的,感谢 Walle。