#!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: '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 { // 应用名称,同时是服务器上的目录名、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 部署》文档' } } }