Jenkinsfile 14.2 KB
#!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 部署》文档'
        }
    }
}