Jenkinsfile
14.2 KB
1
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
#!groovy
// yudao-server 的 Jenkins 部署流水线:Maven 打包 Jar,再通过 SSH 部署到一台 Linux 服务器
//
// 使用说明见《Jenkins 部署》文档:https://doc.iocoder.cn/deployment-jenkins/
// 1. Jenkins 新建「Pipeline(流水线)」类型的 Job,选择「Pipeline script from SCM」,Script Path 填写 script/jenkins/Jenkinsfile
// 2. Jenkins 中配置 Tools(jdk17、maven3),以及两个凭据(yudao-deploy-ssh、yudao-deploy-known-hosts)
// 3. 服务器上提前创建 <DEPLOY_BASE_DIR>/yudao-server 目录,并放入运行时配置 app.env(数据库、Redis 的地址与密码)
// 在目标服务器上执行的脚本:Jenkins 通过 ssh 传入,用法为 bash -s -- <check|deploy> <部署根目录> <服务名> <默认端口>
def REMOTE_SCRIPT = '''
set -euo pipefail
action="$1"
app_name="$3"
app_dir="$2/$3"
app_jar="$app_dir/$app_name.jar"
new_jar="$app_dir/upload/$app_name.jar"
env_file="$app_dir/app.env"
default_port="$4"
stop_timeout=60 # 优雅停止的最长等待秒数,超时后 kill -9
start_timeout=180 # 启动检查的最长等待秒数
backup_keep=5 # backup 目录保留最近几个 Jar
fail() {
echo "[deploy] 失败:$*" >&2
exit 1
}
# 加载运行时配置 app.env:数据库、Redis 等地址和密码只保存在服务器上,不进入 Git 与 Jenkins
load_env() {
[ -r "$env_file" ] || fail "缺少运行时配置 $env_file,首次部署请参考文档创建"
set -a
. "$env_file"
set +a
[ -n "${SPRING_PROFILES_ACTIVE:-}" ] || fail "$env_file 中没有设置 SPRING_PROFILES_ACTIVE"
port="${SERVER_PORT:-$default_port}"
case "$port" in
'' | *[!0-9]*) fail "SERVER_PORT 必须是数字" ;;
esac
java_cmd=java
if [ -n "${JAVA_HOME:-}" ]; then
java_cmd="$JAVA_HOME/bin/java"
fi
command -v "$java_cmd" > /dev/null || fail "找不到 Java:$java_cmd"
}
# 部署前检查:目录、app.env、Java、curl、pgrep、ss,以及端口是否被其它进程占用
check() {
[ -d "$app_dir" ] || fail "目录 $app_dir 不存在,首次部署请先创建,并放入 app.env"
[ -w "$app_dir" ] || fail "用户 $(id -un) 没有 $app_dir 的写权限"
load_env
command -v curl > /dev/null || fail "服务器缺少 curl 命令"
command -v pgrep > /dev/null || fail "服务器缺少 pgrep 命令(procps 软件包)"
command -v ss > /dev/null || fail "服务器缺少 ss 命令(iproute 或 iproute2 软件包)"
mkdir -p "$app_dir/upload" "$app_dir/backup"
echo "[check] Java:$("$java_cmd" -version 2>&1 | head -n 1)"
echo "[check] Profile:$SPRING_PROFILES_ACTIVE,端口:$port"
case "$(stat -c %a "$env_file" 2> /dev/null || echo unknown)" in
600 | 400) ;;
*) echo "[check] 警告:建议执行 chmod 600 $env_file,避免其它用户读取密码" ;;
esac
if [ -f "$app_jar" ]; then
echo "[check] 已存在 $app_jar,部署前会先备份"
else
echo "[check] 首次部署:$app_jar 不存在,无需备份"
fi
if [ -z "$(find_pids)" ] && port_in_use; then
fail "端口 $port 已被其它进程占用,请先执行 ss -ltnp 排查"
fi
}
# 查找本服务的进程:java 进程中,-jar 后面的参数与 $app_jar 完全相等(逐个比较 /proc/<pid>/cmdline 的参数,不使用正则)
find_pids() {
local pid prev arg
for pid in $(pgrep -x java || true); do
prev=""
while IFS= read -r -d '' arg; do
if [ "$prev" = "-jar" ] && [ "$arg" = "$app_jar" ]; then
echo "$pid"
break
fi
prev="$arg"
done 2> /dev/null < "/proc/$pid/cmdline" || true
done
}
# 等待指定的进程全部退出,超时返回 1
wait_exit() {
local timeout="$1" deadline pid alive
shift
deadline=$((SECONDS + timeout))
while true; do
alive=""
for pid in "$@"; do
if [ -e "/proc/$pid" ]; then
alive="$pid"
fi
done
[ -z "$alive" ] && return 0
[ "$SECONDS" -ge "$deadline" ] && return 1
sleep 1
done
}
# 端口是否已被监听:使用 ss 列出本机所有 IPv4、IPv6 地址(0.0.0.0、[::]、127.0.0.x、内网 IP 等)上监听该端口的 TCP 套接字
# 先把完整输出保存到变量再判断,不使用管道,避免 grep -q 提前退出导致的误判;ss 执行失败时直接中止部署
port_in_use() {
local out
out=$(ss -ltn "sport = :$port") || fail "执行 ss 命令失败,无法检查端口 $port 是否被占用"
case "$out" in
*LISTEN*) return 0 ;;
esac
return 1
}
# 停止:先 kill -15 优雅关闭,超时再 kill -9。确认进程退出后才返回,否则中止部署
stop_app() {
local pids
pids=$(find_pids)
if [ -z "$pids" ]; then
echo "[stop] $app_name 未运行,无需停止"
return 0
fi
pids=$(echo $pids)
echo "[stop] 停止进程:$pids"
kill -15 $pids 2> /dev/null || true
if ! wait_exit "$stop_timeout" $pids; then
echo "[stop] 等待 $stop_timeout 秒仍未停止,执行 kill -9"
kill -9 $pids 2> /dev/null || true
wait_exit 10 $pids || fail "进程 $pids 无法停止(可能属于其它用户),已中止部署,Jar 包未替换"
fi
echo "[stop] 已停止"
}
# 启动:环境变量来自 app.env,Spring Boot 会用它们覆盖 Jar 包中的配置
start_app() {
echo "[start] 启动 $app_jar,Profile:$SPRING_PROFILES_ACTIVE"
# JAVA_OPTS 需要拆分成多个 JVM 参数,所以这里不加引号
nohup "$java_cmd" ${JAVA_OPTS:--Xms512m -Xmx512m} -jar "$app_jar" > "$app_dir/nohup.out" 2>&1 < /dev/null &
app_pid=$!
echo "[start] 进程号:$app_pid"
}
# 启动检查:新进程存活,并且端口返回 HTTP 状态码(小于 500)。启动前已确认端口空闲,所以响应来自新进程
# 它只说明服务已启动、端口可访问,不代表业务健康。项目默认不包含 actuator,所以不使用 /actuator/health
wait_started() {
local url="http://127.0.0.1:$port/" code
local deadline=$((SECONDS + start_timeout))
while [ "$SECONDS" -lt "$deadline" ]; do
if [ ! -e "/proc/$app_pid" ]; then
echo "[start] 进程已退出"
return 1
fi
code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' "$url" || true)
if [ "$code" -ge 200 ] 2> /dev/null && [ "$code" -lt 500 ] && [ -e "/proc/$app_pid" ]; then
echo "[start] $url 已响应,启动完成"
return 0
fi
sleep 3
done
echo "[start] 等待 $start_timeout 秒后,$url 仍未响应"
return 1
}
# 只保留最近 backup_keep 个备份:按文件名中的时间戳排序(C 语言环境),而不是按修改时间(cp -p 保留的是原 Jar 的修改时间)
# 删除失败时(例如说没有权限),输出警告后继续删除其它备份,不影响已经成功的部署
clean_backups() {
local LC_ALL=C
local backups=("$app_dir/backup/$app_name"-[0-9]*.jar)
local i
[ -e "${backups[0]}" ] || return 0
for ((i = 0; i < ${#backups[@]} - backup_keep; i++)); do
rm -f -- "${backups[i]}" || echo "[backup] 警告:无法删除旧备份 ${backups[i]},请检查权限后手动清理" >&2
done
}
# 部署:备份 => 停止 => 替换 => 启动 => 启动检查。失败时不自动回滚,打印日志与备份路径,由人工确认后回滚
deploy() {
[ -s "$new_jar" ] || fail "没有找到上传的 $new_jar"
load_env
cd "$app_dir"
backup_jar=""
if [ -f "$app_jar" ]; then
backup_jar="$app_dir/backup/$app_name-$(date +%Y%m%d%H%M%S).jar"
cp -p "$app_jar" "$backup_jar"
echo "[backup] 已备份到 $backup_jar"
else
echo "[backup] 首次部署,无需备份"
fi
stop_app
if port_in_use; then
fail "停止 $app_name 后,端口 $port 仍被其它进程占用,已中止部署:新 Jar 包未替换,但已停止的 $app_name 不会自动恢复。请执行 ss -ltnp 排查并处理端口后重新构建,或参考文档手动启动、回滚"
fi
mv -f "$new_jar" "$app_jar"
start_app
if wait_started; then
clean_backups
echo "[deploy] 部署成功:$app_name"
else
echo "[deploy] 启动检查失败,$app_dir/nohup.out 的最后 100 行:"
tail -n 100 "$app_dir/nohup.out" || true
if [ -n "$backup_jar" ]; then
echo "[deploy] 可以手动回滚到 $backup_jar,步骤见《Jenkins 部署》文档"
fi
exit 1
fi
}
case "$action" in
check) check ;;
deploy) deploy ;;
*) fail "未知操作:$action" ;;
esac
'''
pipeline {
agent any
options {
skipDefaultCheckout()
disableConcurrentBuilds()
timeout(time: 60, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '5'))
}
parameters {
string(name: 'DEPLOY_HOST', defaultValue: '', trim: true, description: '目标服务器的 IP 或域名(必填)')
string(name: 'DEPLOY_USER', defaultValue: 'root', trim: true, description: 'SSH 登录用户')
string(name: 'DEPLOY_SSH_PORT', defaultValue: '22', trim: true, description: 'SSH 端口')
string(name: 'DEPLOY_BASE_DIR', defaultValue: '/work/projects', trim: true, description: '部署根目录,实际部署到 <DEPLOY_BASE_DIR>/yudao-server')
}
// 构建使用的 JDK、Maven,需要在 Jenkins 的 Tools 中配置同名工具。与 Jenkins 自身运行的 Java 版本无关
tools {
jdk 'jdk17'
maven 'maven3'
}
environment {
// 应用名称,同时是服务器上的目录名、Jar 包名
APP_NAME = 'yudao-server'
// Maven 模块与 Jar 包路径
APP_MODULE = 'yudao-server'
APP_JAR = 'yudao-server/target/yudao-server.jar'
// 服务端口,用于启动检查。app.env 中设置了 SERVER_PORT 时,以 SERVER_PORT 为准
APP_PORT = '48080'
// Jenkins 凭据 ID:SSH 私钥(SSH Username with private key)、known_hosts 文件(Secret file)
SSH_CREDENTIAL_ID = 'yudao-deploy-ssh'
KNOWN_HOSTS_CREDENTIAL_ID = 'yudao-deploy-known-hosts'
}
stages {
stage('拉取代码') {
steps {
checkout scm
}
}
stage('检查参数') {
steps {
script {
// 参数会拼接到 ssh、scp 命令中,必须严格校验,避免命令注入
def host = params.DEPLOY_HOST ?: ''
if (!host.matches('[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?')) {
error('DEPLOY_HOST 必填,只能是 IPv4 地址或域名')
}
def user = params.DEPLOY_USER ?: ''
if (!user.matches('[a-z_][a-z0-9_-]{0,31}')) {
error('DEPLOY_USER 不合法,只能包含小写字母、数字、下划线、中划线')
}
def port = params.DEPLOY_SSH_PORT ?: ''
if (!port.matches('[0-9]{1,5}') || port.toInteger() < 1 || port.toInteger() > 65535) {
error('DEPLOY_SSH_PORT 必须是 1 ~ 65535 的数字')
}
def baseDir = params.DEPLOY_BASE_DIR ?: ''
if (!baseDir.matches('/[A-Za-z0-9._/-]*[A-Za-z0-9_-]') || baseDir.contains('..') || baseDir.contains('//')) {
error('DEPLOY_BASE_DIR 必须是绝对路径,只能包含字母、数字和 . _ - /,不能以 / 结尾')
}
echo "部署 ${env.APP_NAME} => ${user}@${host}:${baseDir}/${env.APP_NAME}"
}
}
}
stage('编译') {
steps {
sh 'java -version && mvn -v'
sh 'mvn -B -ntp clean package -pl "$APP_MODULE" -am -Dmaven.test.skip=true'
sh 'test -f "$APP_JAR"'
}
}
stage('归档') {
steps {
archiveArtifacts artifacts: env.APP_JAR, fingerprint: true
}
}
stage('部署') {
steps {
writeFile file: '.jenkins-remote-deploy.sh', text: REMOTE_SCRIPT
withCredentials([file(credentialsId: env.KNOWN_HOSTS_CREDENTIAL_ID, variable: 'KNOWN_HOSTS_FILE')]) {
sshagent(credentials: [env.SSH_CREDENTIAL_ID]) {
// 只信任 known_hosts 凭据中的主机公钥:StrictHostKeyChecking=yes,并且不读取系统的 known_hosts
sh '''
set -eu
case "$KNOWN_HOSTS_FILE" in
*[[:space:]]*) echo "known_hosts 文件路径不能包含空白字符,请修改 Job 名称" >&2; exit 1 ;;
esac
remote() {
ssh -p "$DEPLOY_SSH_PORT" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE" "$DEPLOY_USER@$DEPLOY_HOST" "$@"
}
# ① 检查服务器
remote bash -s -- check "$DEPLOY_BASE_DIR" "$APP_NAME" "$APP_PORT" < .jenkins-remote-deploy.sh
# ② 上传 Jar 到 upload 目录
scp -P "$DEPLOY_SSH_PORT" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE" "$APP_JAR" "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_BASE_DIR/$APP_NAME/upload/$APP_NAME.jar"
# ③ 备份、停止、替换、启动,并检查启动结果
remote bash -s -- deploy "$DEPLOY_BASE_DIR" "$APP_NAME" "$APP_PORT" < .jenkins-remote-deploy.sh
'''
}
}
}
}
}
post {
success {
echo "部署成功:${env.APP_NAME}"
}
failure {
echo '部署失败:请查看上方 [check]、[stop]、[start]、[deploy] 开头的日志,回滚步骤见《Jenkins 部署》文档'
}
}
}