From a86b269ed71e2e3fc648b4a94525039ca8cf0248 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 25 Oct 2026 02:44:51 +0800 Subject: [PATCH] feat(deploy): 完善 Jenkinsfile 与 Jar 部署流程 --- script/jenkins/Jenkinsfile | 340 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- 1 file changed, 307 insertions(+), 33 deletions(-) diff --git a/script/jenkins/Jenkinsfile b/script/jenkins/Jenkinsfile index 91842b3..925a934 100644 --- a/script/jenkins/Jenkinsfile +++ b/script/jenkins/Jenkinsfile @@ -1,60 +1,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. 服务器上提前创建 /yudao-server 目录,并放入运行时配置 app.env(数据库、Redis 的地址与密码) + +// 在目标服务器上执行的脚本:Jenkins 通过 ssh 传入,用法为 bash -s -- <部署根目录> <服务名> <默认端口> +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//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: 'TAG_NAME', defaultValue: '', description: '') + 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: '部署根目录,实际部署到 /yudao-server') + } + + // 构建使用的 JDK、Maven,需要在 Jenkins 的 Tools 中配置同名工具。与 Jenkins 自身运行的 Java 版本无关 + tools { + jdk 'jdk17' + maven 'maven3' } environment { - // DockerHub 凭证 ID(登录您的 DockerHub) - DOCKER_CREDENTIAL_ID = 'dockerhub-id' - // GitHub 凭证 ID (推送 tag 到 GitHub 仓库) - GITHUB_CREDENTIAL_ID = 'github-id' - // kubeconfig 凭证 ID (访问接入正在运行的 Kubernetes 集群) - KUBECONFIG_CREDENTIAL_ID = 'demo-kubeconfig' - // 镜像的推送 - REGISTRY = 'docker.io' - // DockerHub 账号名 - DOCKERHUB_NAMESPACE = 'docker_username' - // GitHub 账号名 - GITHUB_ACCOUNT = 'https://gitee.com/zhijiantianya/ruoyi-vue-pro' - // 应用名称 + // 应用名称,同时是服务器上的目录名、Jar 包名 APP_NAME = 'yudao-server' - // 应用部署路径 - APP_DEPLOY_BASE_DIR = '/media/pi/KINGTON/data/work/projects/' + // 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('检出') { + stage('拉取代码') { steps { - git url: "https://gitee.com/will-we/ruoyi-vue-pro.git", - branch: "devops" + checkout scm } } - stage('构建') { + stage('检查参数') { steps { - // TODO 解决多环境链接、密码不同配置临时方案 - sh 'if [ ! -d "' + "${env.HOME}" + '/resources" ];then\n' + - ' echo "配置文件不存在无需修改"\n' + - 'else\n' + - ' cp -rf ' + "${env.HOME}" + '/resources/*.yaml ' + "${env.APP_NAME}" + '/src/main/resources\n' + - ' echo "配置文件替换"\n' + - 'fi' - sh 'mvn clean package -Dmaven.test.skip=true' + 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 { - sh 'cp -f ' + ' bin/deploy.sh ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" - sh 'cp -f ' + "${env.APP_NAME}" + '/target/*.jar ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" +'/build/' - archiveArtifacts "${env.APP_NAME}" + '/target/*.jar' - sh 'chmod +x ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" + '/deploy.sh' - sh 'bash ' + "${env.APP_DEPLOY_BASE_DIR}" + "${env.APP_NAME}" + '/deploy.sh' + 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 部署》文档' + } + } } -- libgit2 0.21.4