Commit a86b269ed71e2e3fc648b4a94525039ca8cf0248

Authored by YunaiV
1 parent 75b5d305

feat(deploy): 完善 Jenkinsfile 与 Jar 部署流程

- 参数化 Maven 构建、产物归档与 SSH 部署
- 补充运行时配置、备份、精确进程停止及启动检查
- 完善端口冲突处理与旧备份清理提示
Showing 1 changed file with 307 additions and 33 deletions
script/jenkins/Jenkinsfile
1 1 #!groovy
  2 +// yudao-server 的 Jenkins 部署流水线:Maven 打包 Jar,再通过 SSH 部署到一台 Linux 服务器
  3 +//
  4 +// 使用说明见《Jenkins 部署》文档:https://doc.iocoder.cn/deployment-jenkins/
  5 +// 1. Jenkins 新建「Pipeline(流水线)」类型的 Job,选择「Pipeline script from SCM」,Script Path 填写 script/jenkins/Jenkinsfile
  6 +// 2. Jenkins 中配置 Tools(jdk17、maven3),以及两个凭据(yudao-deploy-ssh、yudao-deploy-known-hosts)
  7 +// 3. 服务器上提前创建 <DEPLOY_BASE_DIR>/yudao-server 目录,并放入运行时配置 app.env(数据库、Redis 的地址与密码)
  8 +
  9 +// 在目标服务器上执行的脚本:Jenkins 通过 ssh 传入,用法为 bash -s -- <check|deploy> <部署根目录> <服务名> <默认端口>
  10 +def REMOTE_SCRIPT = '''
  11 +set -euo pipefail
  12 +
  13 +action="$1"
  14 +app_name="$3"
  15 +app_dir="$2/$3"
  16 +app_jar="$app_dir/$app_name.jar"
  17 +new_jar="$app_dir/upload/$app_name.jar"
  18 +env_file="$app_dir/app.env"
  19 +default_port="$4"
  20 +stop_timeout=60 # 优雅停止的最长等待秒数,超时后 kill -9
  21 +start_timeout=180 # 启动检查的最长等待秒数
  22 +backup_keep=5 # backup 目录保留最近几个 Jar
  23 +
  24 +fail() {
  25 + echo "[deploy] 失败:$*" >&2
  26 + exit 1
  27 +}
  28 +
  29 +# 加载运行时配置 app.env:数据库、Redis 等地址和密码只保存在服务器上,不进入 Git 与 Jenkins
  30 +load_env() {
  31 + [ -r "$env_file" ] || fail "缺少运行时配置 $env_file,首次部署请参考文档创建"
  32 + set -a
  33 + . "$env_file"
  34 + set +a
  35 + [ -n "${SPRING_PROFILES_ACTIVE:-}" ] || fail "$env_file 中没有设置 SPRING_PROFILES_ACTIVE"
  36 + port="${SERVER_PORT:-$default_port}"
  37 + case "$port" in
  38 + '' | *[!0-9]*) fail "SERVER_PORT 必须是数字" ;;
  39 + esac
  40 + java_cmd=java
  41 + if [ -n "${JAVA_HOME:-}" ]; then
  42 + java_cmd="$JAVA_HOME/bin/java"
  43 + fi
  44 + command -v "$java_cmd" > /dev/null || fail "找不到 Java:$java_cmd"
  45 +}
  46 +
  47 +# 部署前检查:目录、app.env、Java、curl、pgrep、ss,以及端口是否被其它进程占用
  48 +check() {
  49 + [ -d "$app_dir" ] || fail "目录 $app_dir 不存在,首次部署请先创建,并放入 app.env"
  50 + [ -w "$app_dir" ] || fail "用户 $(id -un) 没有 $app_dir 的写权限"
  51 + load_env
  52 + command -v curl > /dev/null || fail "服务器缺少 curl 命令"
  53 + command -v pgrep > /dev/null || fail "服务器缺少 pgrep 命令(procps 软件包)"
  54 + command -v ss > /dev/null || fail "服务器缺少 ss 命令(iproute 或 iproute2 软件包)"
  55 + mkdir -p "$app_dir/upload" "$app_dir/backup"
  56 + echo "[check] Java:$("$java_cmd" -version 2>&1 | head -n 1)"
  57 + echo "[check] Profile:$SPRING_PROFILES_ACTIVE,端口:$port"
  58 + case "$(stat -c %a "$env_file" 2> /dev/null || echo unknown)" in
  59 + 600 | 400) ;;
  60 + *) echo "[check] 警告:建议执行 chmod 600 $env_file,避免其它用户读取密码" ;;
  61 + esac
  62 + if [ -f "$app_jar" ]; then
  63 + echo "[check] 已存在 $app_jar,部署前会先备份"
  64 + else
  65 + echo "[check] 首次部署:$app_jar 不存在,无需备份"
  66 + fi
  67 + if [ -z "$(find_pids)" ] && port_in_use; then
  68 + fail "端口 $port 已被其它进程占用,请先执行 ss -ltnp 排查"
  69 + fi
  70 +}
  71 +
  72 +# 查找本服务的进程:java 进程中,-jar 后面的参数与 $app_jar 完全相等(逐个比较 /proc/<pid>/cmdline 的参数,不使用正则)
  73 +find_pids() {
  74 + local pid prev arg
  75 + for pid in $(pgrep -x java || true); do
  76 + prev=""
  77 + while IFS= read -r -d '' arg; do
  78 + if [ "$prev" = "-jar" ] && [ "$arg" = "$app_jar" ]; then
  79 + echo "$pid"
  80 + break
  81 + fi
  82 + prev="$arg"
  83 + done 2> /dev/null < "/proc/$pid/cmdline" || true
  84 + done
  85 +}
  86 +
  87 +# 等待指定的进程全部退出,超时返回 1
  88 +wait_exit() {
  89 + local timeout="$1" deadline pid alive
  90 + shift
  91 + deadline=$((SECONDS + timeout))
  92 + while true; do
  93 + alive=""
  94 + for pid in "$@"; do
  95 + if [ -e "/proc/$pid" ]; then
  96 + alive="$pid"
  97 + fi
  98 + done
  99 + [ -z "$alive" ] && return 0
  100 + [ "$SECONDS" -ge "$deadline" ] && return 1
  101 + sleep 1
  102 + done
  103 +}
  104 +
  105 +# 端口是否已被监听:使用 ss 列出本机所有 IPv4、IPv6 地址(0.0.0.0、[::]、127.0.0.x、内网 IP 等)上监听该端口的 TCP 套接字
  106 +# 先把完整输出保存到变量再判断,不使用管道,避免 grep -q 提前退出导致的误判;ss 执行失败时直接中止部署
  107 +port_in_use() {
  108 + local out
  109 + out=$(ss -ltn "sport = :$port") || fail "执行 ss 命令失败,无法检查端口 $port 是否被占用"
  110 + case "$out" in
  111 + *LISTEN*) return 0 ;;
  112 + esac
  113 + return 1
  114 +}
  115 +
  116 +# 停止:先 kill -15 优雅关闭,超时再 kill -9。确认进程退出后才返回,否则中止部署
  117 +stop_app() {
  118 + local pids
  119 + pids=$(find_pids)
  120 + if [ -z "$pids" ]; then
  121 + echo "[stop] $app_name 未运行,无需停止"
  122 + return 0
  123 + fi
  124 + pids=$(echo $pids)
  125 + echo "[stop] 停止进程:$pids"
  126 + kill -15 $pids 2> /dev/null || true
  127 + if ! wait_exit "$stop_timeout" $pids; then
  128 + echo "[stop] 等待 $stop_timeout 秒仍未停止,执行 kill -9"
  129 + kill -9 $pids 2> /dev/null || true
  130 + wait_exit 10 $pids || fail "进程 $pids 无法停止(可能属于其它用户),已中止部署,Jar 包未替换"
  131 + fi
  132 + echo "[stop] 已停止"
  133 +}
  134 +
  135 +# 启动:环境变量来自 app.env,Spring Boot 会用它们覆盖 Jar 包中的配置
  136 +start_app() {
  137 + echo "[start] 启动 $app_jar,Profile:$SPRING_PROFILES_ACTIVE"
  138 + # JAVA_OPTS 需要拆分成多个 JVM 参数,所以这里不加引号
  139 + nohup "$java_cmd" ${JAVA_OPTS:--Xms512m -Xmx512m} -jar "$app_jar" > "$app_dir/nohup.out" 2>&1 < /dev/null &
  140 + app_pid=$!
  141 + echo "[start] 进程号:$app_pid"
  142 +}
  143 +
  144 +# 启动检查:新进程存活,并且端口返回 HTTP 状态码(小于 500)。启动前已确认端口空闲,所以响应来自新进程
  145 +# 它只说明服务已启动、端口可访问,不代表业务健康。项目默认不包含 actuator,所以不使用 /actuator/health
  146 +wait_started() {
  147 + local url="http://127.0.0.1:$port/" code
  148 + local deadline=$((SECONDS + start_timeout))
  149 + while [ "$SECONDS" -lt "$deadline" ]; do
  150 + if [ ! -e "/proc/$app_pid" ]; then
  151 + echo "[start] 进程已退出"
  152 + return 1
  153 + fi
  154 + code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' "$url" || true)
  155 + if [ "$code" -ge 200 ] 2> /dev/null && [ "$code" -lt 500 ] && [ -e "/proc/$app_pid" ]; then
  156 + echo "[start] $url 已响应,启动完成"
  157 + return 0
  158 + fi
  159 + sleep 3
  160 + done
  161 + echo "[start] 等待 $start_timeout 秒后,$url 仍未响应"
  162 + return 1
  163 +}
  164 +
  165 +# 只保留最近 backup_keep 个备份:按文件名中的时间戳排序(C 语言环境),而不是按修改时间(cp -p 保留的是原 Jar 的修改时间)
  166 +# 删除失败时(例如说没有权限),输出警告后继续删除其它备份,不影响已经成功的部署
  167 +clean_backups() {
  168 + local LC_ALL=C
  169 + local backups=("$app_dir/backup/$app_name"-[0-9]*.jar)
  170 + local i
  171 + [ -e "${backups[0]}" ] || return 0
  172 + for ((i = 0; i < ${#backups[@]} - backup_keep; i++)); do
  173 + rm -f -- "${backups[i]}" || echo "[backup] 警告:无法删除旧备份 ${backups[i]},请检查权限后手动清理" >&2
  174 + done
  175 +}
  176 +
  177 +# 部署:备份 => 停止 => 替换 => 启动 => 启动检查。失败时不自动回滚,打印日志与备份路径,由人工确认后回滚
  178 +deploy() {
  179 + [ -s "$new_jar" ] || fail "没有找到上传的 $new_jar"
  180 + load_env
  181 + cd "$app_dir"
  182 + backup_jar=""
  183 + if [ -f "$app_jar" ]; then
  184 + backup_jar="$app_dir/backup/$app_name-$(date +%Y%m%d%H%M%S).jar"
  185 + cp -p "$app_jar" "$backup_jar"
  186 + echo "[backup] 已备份到 $backup_jar"
  187 + else
  188 + echo "[backup] 首次部署,无需备份"
  189 + fi
  190 + stop_app
  191 + if port_in_use; then
  192 + fail "停止 $app_name 后,端口 $port 仍被其它进程占用,已中止部署:新 Jar 包未替换,但已停止的 $app_name 不会自动恢复。请执行 ss -ltnp 排查并处理端口后重新构建,或参考文档手动启动、回滚"
  193 + fi
  194 + mv -f "$new_jar" "$app_jar"
  195 + start_app
  196 + if wait_started; then
  197 + clean_backups
  198 + echo "[deploy] 部署成功:$app_name"
  199 + else
  200 + echo "[deploy] 启动检查失败,$app_dir/nohup.out 的最后 100 行:"
  201 + tail -n 100 "$app_dir/nohup.out" || true
  202 + if [ -n "$backup_jar" ]; then
  203 + echo "[deploy] 可以手动回滚到 $backup_jar,步骤见《Jenkins 部署》文档"
  204 + fi
  205 + exit 1
  206 + fi
  207 +}
  208 +
  209 +case "$action" in
  210 + check) check ;;
  211 + deploy) deploy ;;
  212 + *) fail "未知操作:$action" ;;
  213 +esac
  214 +'''
  215 +
2 216 pipeline {
3 217  
4 218 agent any
5 219  
  220 + options {
  221 + skipDefaultCheckout()
  222 + disableConcurrentBuilds()
  223 + timeout(time: 60, unit: 'MINUTES')
  224 + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '5'))
  225 + }
  226 +
6 227 parameters {
7   - string(name: 'TAG_NAME', defaultValue: '', description: '')
  228 + string(name: 'DEPLOY_HOST', defaultValue: '', trim: true, description: '目标服务器的 IP 或域名(必填)')
  229 + string(name: 'DEPLOY_USER', defaultValue: 'root', trim: true, description: 'SSH 登录用户')
  230 + string(name: 'DEPLOY_SSH_PORT', defaultValue: '22', trim: true, description: 'SSH 端口')
  231 + string(name: 'DEPLOY_BASE_DIR', defaultValue: '/work/projects', trim: true, description: '部署根目录,实际部署到 <DEPLOY_BASE_DIR>/yudao-server')
  232 + }
  233 +
  234 + // 构建使用的 JDK、Maven,需要在 Jenkins 的 Tools 中配置同名工具。与 Jenkins 自身运行的 Java 版本无关
  235 + tools {
  236 + jdk 'jdk17'
  237 + maven 'maven3'
8 238 }
9 239  
10 240 environment {
11   - // DockerHub 凭证 ID(登录您的 DockerHub)
12   - DOCKER_CREDENTIAL_ID = 'dockerhub-id'
13   - // GitHub 凭证 ID (推送 tag 到 GitHub 仓库)
14   - GITHUB_CREDENTIAL_ID = 'github-id'
15   - // kubeconfig 凭证 ID (访问接入正在运行的 Kubernetes 集群)
16   - KUBECONFIG_CREDENTIAL_ID = 'demo-kubeconfig'
17   - // 镜像的推送
18   - REGISTRY = 'docker.io'
19   - // DockerHub 账号名
20   - DOCKERHUB_NAMESPACE = 'docker_username'
21   - // GitHub 账号名
22   - GITHUB_ACCOUNT = 'https://gitee.com/zhijiantianya/ruoyi-vue-pro'
23   - // 应用名称
  241 + // 应用名称,同时是服务器上的目录名、Jar 包名
24 242 APP_NAME = 'yudao-server'
25   - // 应用部署路径
26   - APP_DEPLOY_BASE_DIR = '/media/pi/KINGTON/data/work/projects/'
  243 + // Maven 模块与 Jar 包路径
  244 + APP_MODULE = 'yudao-server'
  245 + APP_JAR = 'yudao-server/target/yudao-server.jar'
  246 + // 服务端口,用于启动检查。app.env 中设置了 SERVER_PORT 时,以 SERVER_PORT 为准
  247 + APP_PORT = '48080'
  248 + // Jenkins 凭据 ID:SSH 私钥(SSH Username with private key)、known_hosts 文件(Secret file)
  249 + SSH_CREDENTIAL_ID = 'yudao-deploy-ssh'
  250 + KNOWN_HOSTS_CREDENTIAL_ID = 'yudao-deploy-known-hosts'
27 251 }
28 252  
29 253 stages {
30   - stage('检出') {
  254 + stage('拉取代码') {
31 255 steps {
32   - git url: "https://gitee.com/will-we/ruoyi-vue-pro.git",
33   - branch: "devops"
  256 + checkout scm
34 257 }
35 258 }
36 259  
37   - stage('构建') {
  260 + stage('检查参数') {
38 261 steps {
39   - // TODO 解决多环境链接、密码不同配置临时方案
40   - sh 'if [ ! -d "' + "${env.HOME}" + '/resources" ];then\n' +
41   - ' echo "配置文件不存在无需修改"\n' +
42   - 'else\n' +
43   - ' cp -rf ' + "${env.HOME}" + '/resources/*.yaml ' + "${env.APP_NAME}" + '/src/main/resources\n' +
44   - ' echo "配置文件替换"\n' +
45   - 'fi'
46   - sh 'mvn clean package -Dmaven.test.skip=true'
  262 + script {
  263 + // 参数会拼接到 ssh、scp 命令中,必须严格校验,避免命令注入
  264 + def host = params.DEPLOY_HOST ?: ''
  265 + if (!host.matches('[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?')) {
  266 + error('DEPLOY_HOST 必填,只能是 IPv4 地址或域名')
  267 + }
  268 + def user = params.DEPLOY_USER ?: ''
  269 + if (!user.matches('[a-z_][a-z0-9_-]{0,31}')) {
  270 + error('DEPLOY_USER 不合法,只能包含小写字母、数字、下划线、中划线')
  271 + }
  272 + def port = params.DEPLOY_SSH_PORT ?: ''
  273 + if (!port.matches('[0-9]{1,5}') || port.toInteger() < 1 || port.toInteger() > 65535) {
  274 + error('DEPLOY_SSH_PORT 必须是 1 ~ 65535 的数字')
  275 + }
  276 + def baseDir = params.DEPLOY_BASE_DIR ?: ''
  277 + if (!baseDir.matches('/[A-Za-z0-9._/-]*[A-Za-z0-9_-]') || baseDir.contains('..') || baseDir.contains('//')) {
  278 + error('DEPLOY_BASE_DIR 必须是绝对路径,只能包含字母、数字和 . _ - /,不能以 / 结尾')
  279 + }
  280 + echo "部署 ${env.APP_NAME} => ${user}@${host}:${baseDir}/${env.APP_NAME}"
  281 + }
  282 + }
  283 + }
  284 +
  285 + stage('编译') {
  286 + steps {
  287 + sh 'java -version && mvn -v'
  288 + sh 'mvn -B -ntp clean package -pl "$APP_MODULE" -am -Dmaven.test.skip=true'
  289 + sh 'test -f "$APP_JAR"'
  290 + }
  291 + }
  292 +
  293 + stage('归档') {
  294 + steps {
  295 + archiveArtifacts artifacts: env.APP_JAR, fingerprint: true
47 296 }
48 297 }
49 298  
50 299 stage('部署') {
51 300 steps {
52   - sh 'cp -f ' + ' bin/deploy.sh ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}"
53   - sh 'cp -f ' + "${env.APP_NAME}" + '/target/*.jar ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" +'/build/'
54   - archiveArtifacts "${env.APP_NAME}" + '/target/*.jar'
55   - sh 'chmod +x ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" + '/deploy.sh'
56   - sh 'bash ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" + '/deploy.sh'
  301 + writeFile file: '.jenkins-remote-deploy.sh', text: REMOTE_SCRIPT
  302 + withCredentials([file(credentialsId: env.KNOWN_HOSTS_CREDENTIAL_ID, variable: 'KNOWN_HOSTS_FILE')]) {
  303 + sshagent(credentials: [env.SSH_CREDENTIAL_ID]) {
  304 + // 只信任 known_hosts 凭据中的主机公钥:StrictHostKeyChecking=yes,并且不读取系统的 known_hosts
  305 + sh '''
  306 + set -eu
  307 + case "$KNOWN_HOSTS_FILE" in
  308 + *[[:space:]]*) echo "known_hosts 文件路径不能包含空白字符,请修改 Job 名称" >&2; exit 1 ;;
  309 + esac
  310 + remote() {
  311 + 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" "$@"
  312 + }
  313 + # ① 检查服务器
  314 + remote bash -s -- check "$DEPLOY_BASE_DIR" "$APP_NAME" "$APP_PORT" < .jenkins-remote-deploy.sh
  315 + # ② 上传 Jar 到 upload 目录
  316 + 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"
  317 + # ③ 备份、停止、替换、启动,并检查启动结果
  318 + remote bash -s -- deploy "$DEPLOY_BASE_DIR" "$APP_NAME" "$APP_PORT" < .jenkins-remote-deploy.sh
  319 + '''
  320 + }
  321 + }
57 322 }
58 323 }
59 324 }
  325 +
  326 + post {
  327 + success {
  328 + echo "部署成功:${env.APP_NAME}"
  329 + }
  330 + failure {
  331 + echo '部署失败:请查看上方 [check]、[stop]、[start]、[deploy] 开头的日志,回滚步骤见《Jenkins 部署》文档'
  332 + }
  333 + }
60 334 }
... ...