#!/bin/bash # ============================================ # 芋道 (Yudao) 生产部署脚本 - 运维级 # ============================================ # 特性: # - 优雅启停 (SIGTERM → 等待 → SIGKILL 兜底) # - 健康检查 + 自动回滚 # - 版本备份 + 保留最近 5 个版本 # - 并发锁,防止同时执行 # - 前置检查 (磁盘/内存/端口/编译产物) # - 结构化日志 # ============================================ set -o pipefail # ==================== 配置 ==================== readonly PROJECT_DIR="/www/wwwroot/sionrui" readonly BACKEND_DIR="/www/wwwroot/yudao-server" readonly FRONTEND_DIR="/www/wwwroot/muyetools.cn" readonly ADMIN_DIR="/www/wwwroot/8.155.172.147" readonly BACKEND_JAR="sion-rui.jar" readonly BACKEND_PORT=9900 readonly BACKUP_KEEP=5 # 保留最近 N 个版本 readonly SHUTDOWN_GRACE_SEC=30 # 优雅关闭等待上限 readonly HEALTH_CHECK_RETRIES=30 # 健康检查重试次数 (30 × 2s = 60s) readonly HEALTH_CHECK_INTERVAL=2 # 健康检查间隔(秒) readonly DEPLOY_LOCK="/tmp/yudao_deploy.lock" readonly BUILD_LOG="/tmp/yudao_build_$(date +%Y%m%d_%H%M%S).log" readonly DEPLOY_LOG="/tmp/yudao_deploy.log" readonly BACKUP_DIR="$BACKEND_DIR/backups" # Java 环境 export JAVA_HOME="/www/server/java/jdk-17.0.8" export PATH="$JAVA_HOME/bin:$PATH" export MAVEN_OPTS="-Xms512m -Xmx1024m" export NODE_OPTIONS="--max-old-space-size=2048" # Maven MVN_EXEC=$(which mvn 2>/dev/null || find /www/server -name "mvn" -type f 2>/dev/null | head -1 || echo "mvn") MAVEN_REPO="$PROJECT_DIR/.m2_repo" # ==================== 工具函数 ==================== log() { echo "[$(date '+%H:%M:%S')] $1" | tee -a "$DEPLOY_LOG"; } success() { echo "[$(date '+%H:%M:%S')] ✅ $1" | tee -a "$DEPLOY_LOG"; } warn() { echo "[$(date '+%H:%M:%S')] ⚠️ $1" | tee -a "$DEPLOY_LOG"; } error() { echo "[$(date '+%H:%M:%S')] ❌ $1" | tee -a "$DEPLOY_LOG"; } die() { error "$1"; release_lock; exit 1; } # ==================== 并发锁 ==================== acquire_lock() { if [ -f "$DEPLOY_LOCK" ]; then local pid pid=$(cat "$DEPLOY_LOCK" 2>/dev/null) if ps -p "$pid" > /dev/null 2>&1; then die "部署脚本已在运行中 (PID=$pid),请等待完成或手动删除 $DEPLOY_LOCK" fi warn "发现残留锁文件(进程 $pid 已不存在),清理后继续" rm -f "$DEPLOY_LOCK" fi echo $$ > "$DEPLOY_LOCK" } release_lock() { rm -f "$DEPLOY_LOCK" } # 确保退出时释放锁 trap release_lock EXIT INT TERM # ==================== 前置检查 ==================== pre_flight() { log "=== 前置检查 ===" # 磁盘空间 (至少 2GB) local avail avail=$(df -m "$BACKEND_DIR" | awk 'NR==2 {print $4}') if [ "${avail:-0}" -lt 2048 ]; then die "磁盘空间不足:${BACKEND_DIR} 可用 ${avail}MB,需要至少 2048MB" fi log " 磁盘可用: ${avail}MB ✓" # 内存 (至少 512MB 空闲) local mem_free mem_free=$(free -m | awk '/Mem:/ {print $7}') if [ "${mem_free:-0}" -lt 512 ]; then warn "可用内存偏低:${mem_free}MB,构建可能失败" else log " 可用内存: ${mem_free}MB ✓" fi # JDK if [ ! -x "$JAVA_HOME/bin/java" ]; then die "JDK 未找到:$JAVA_HOME/bin/java" fi log " JDK: $($JAVA_HOME/bin/java -version 2>&1 | head -1) ✓" # 项目目录 if [ ! -d "$PROJECT_DIR/.git" ]; then die "项目目录不存在或不是 Git 仓库:$PROJECT_DIR" fi log " 项目目录: $PROJECT_DIR ✓" # 端口 (是否被非本应用的进程占用) local port_pid port_pid=$(ss -tlnp 2>/dev/null | grep ":$BACKEND_PORT " | grep -oP 'pid=\K\d+' || true) if [ -n "$port_pid" ]; then local port_cmd port_cmd=$(ps -p "$port_pid" -o comm= 2>/dev/null || echo "unknown") if ! echo "$port_cmd" | grep -q "java"; then die "端口 $BACKEND_PORT 被非 Java 进程占用 (PID=$port_pid, cmd=$port_cmd)" fi log " 端口 $BACKEND_PORT: 被当前后端占用 (PID=$port_pid),将在停止阶段处理" else log " 端口 $BACKEND_PORT: 空闲 ✓" fi success "前置检查通过" } # ==================== 停止旧服务 ==================== stop_service() { log "=== 停止旧服务 ===" local old_pid old_pid=$(ps -ef | grep "$BACKEND_JAR" | grep -v grep | awk '{print $2}') if [ -z "$old_pid" ]; then log " 未发现运行中的旧进程" return 0 fi # 记录旧进程信息用于回滚 local old_start old_start=$(ps -p "$old_pid" -o lstart= 2>/dev/null || echo "unknown") log " 旧进程: PID=$old_pid, 启动时间=$old_start" # Step 1: SIGTERM 优雅关闭 log " 发送 SIGTERM ..." kill -15 "$old_pid" 2>/dev/null || true local waited=0 while [ $waited -lt $SHUTDOWN_GRACE_SEC ]; do if ! ps -p "$old_pid" > /dev/null 2>&1; then success "旧进程优雅退出 (PID=$old_pid, 耗时 ${waited}s)" break fi sleep 1 waited=$((waited + 1)) # 每 10 秒汇报一次状态 if [ $((waited % 10)) -eq 0 ] && [ $waited -gt 0 ]; then log " 等待中... (${waited}s/${SHUTDOWN_GRACE_SEC}s)" fi done # Step 2: 仍未退出 → SIGKILL if ps -p "$old_pid" > /dev/null 2>&1; then warn "优雅关闭超时 (${SHUTDOWN_GRACE_SEC}s),执行 kill -9" kill -9 "$old_pid" 2>/dev/null || true sleep 3 if ps -p "$old_pid" > /dev/null 2>&1; then die "无法杀死进程 PID=$old_pid,请手动处理" fi log " 进程已强制终止" fi # Step 3: 确认端口释放 local retries=10 while [ $retries -gt 0 ]; do if ! ss -tlnp 2>/dev/null | grep -q ":$BACKEND_PORT "; then log " 端口 $BACKEND_PORT 已释放 ✓" return 0 fi sleep 2 retries=$((retries - 1)) done # 最后手段:fuser 释放端口 warn "端口 $BACKEND_PORT 未自动释放,使用 fuser 强制释放" fuser -k "${BACKEND_PORT}/tcp" 2>/dev/null || true sleep 2 } # ==================== 版本备份 ==================== backup_current() { if [ -f "$BACKEND_DIR/$BACKEND_JAR" ]; then mkdir -p "$BACKUP_DIR" local backup_name="${BACKEND_JAR%.jar}_$(date +%Y%m%d_%H%M%S).jar" cp "$BACKEND_DIR/$BACKEND_JAR" "$BACKUP_DIR/$backpack_name" log " 已备份: $backup_name" # 清理旧备份,只保留最近 N 个 local count count=$(ls -1t "$BACKUP_DIR"/*.jar 2>/dev/null | wc -l) if [ "$count" -gt "$BACKUP_KEEP" ]; then ls -1t "$BACKUP_DIR"/*.jar | tail -n +$((BACKUP_KEEP + 1)) | xargs rm -f log " 清理旧备份,保留最近 $BACKUP_KEEP 个" fi fi } # ==================== 回滚 ==================== rollback() { error "=== 健康检查失败,开始回滚 ===" # 停止新进程 local new_pid="$1" if [ -n "$new_pid" ] && ps -p "$new_pid" > /dev/null 2>&1; then log " 停止新进程 PID=$new_pid" kill -9 "$new_pid" 2>/dev/null || true sleep 2 fi # 恢复最近的备份 local latest_backup latest_backup=$(ls -1t "$BACKUP_DIR"/*.jar 2>/dev/null | head -1) if [ -n "$latest_backup" ]; then log " 恢复备份: $latest_backup" cp -f "$latest_backup" "$BACKEND_DIR/$BACKEND_JAR" else error "没有可用的备份文件!" return 1 fi # 启动旧版本 log " 启动旧版本..." cd "$BACKEND_DIR" nohup "$JAVA_HOME/bin/java" -Xms512m -Xmx1024m \ -jar "$BACKEND_JAR" --server.port="$BACKEND_PORT" \ > ./logs/console.log 2>&1 & local rollback_pid=$! log " 回滚进程 PID=$rollback_pid" # 等待旧版本启动 for i in $(seq 1 $HEALTH_CHECK_RETRIES); do if curl -s -o /dev/null -w "%{http_code}" \ "http://127.0.0.1:${BACKEND_PORT}/actuator/health" 2>/dev/null | grep -q "200"; then success "回滚成功 (PID=$rollback_pid)" return 0 fi if ! ps -p "$rollback_pid" > /dev/null 2>&1; then break fi sleep $HEALTH_CHECK_INTERVAL done error "回滚失败!请手动检查!" return 1 } # ==================== 启动新服务 ==================== start_service() { log "=== 启动新服务 ===" cd "$BACKEND_DIR" # 确保日志目录 mkdir -p "$BACKEND_DIR/logs" nohup "$JAVA_HOME/bin/java" \ -Xms512m -Xmx1024m \ -jar "$BACKEND_JAR" --server.port="$BACKEND_PORT" \ > ./logs/console.log 2>&1 & local new_pid=$! log " 新进程 PID=$new_pid" # 健康检查 log " 等待健康检查..." local started=false for i in $(seq 1 $HEALTH_CHECK_RETRIES); do if ! ps -p "$new_pid" > /dev/null 2>&1; then error "新进程已退出!最近日志:" tail -30 "$BACKEND_DIR/logs/console.log" | while read -r line; do error " $line" done rollback "$new_pid" die "启动失败,已回滚到上一版本" fi if curl -s -o /dev/null -w "%{http_code}" \ "http://127.0.0.1:${BACKEND_PORT}/actuator/health" 2>/dev/null | grep -q "200"; then success "后端启动成功 (PID=$new_pid, 耗时 $((i * HEALTH_CHECK_INTERVAL))s)" started=true break fi # 每 5 次汇报进度 if [ $((i % 5)) -eq 0 ]; then log " 等待中... ($((i * HEALTH_CHECK_INTERVAL))s / $((HEALTH_CHECK_RETRIES * HEALTH_CHECK_INTERVAL))s)" fi sleep $HEALTH_CHECK_INTERVAL done if [ "$started" != "true" ]; then error "健康检查超时" rollback "$new_pid" die "启动超时,已回滚到上一版本" fi } # ==================== 后端部署 ==================== deploy_backend() { log "" log "╔══════════════════════════════════════════╗" log "║ 后端部署 ║" log "╚══════════════════════════════════════════╝" # 0. 前置检查 pre_flight # 1. 拉取代码 & 构建 log "=== 构建 ===" cd "$PROJECT_DIR" log " 拉取最新代码..." git fetch origin 2>&1 | tee -a "$BUILD_LOG" local old_commit new_commit old_commit=$(git rev-parse --short HEAD) git reset --hard origin/main 2>&1 | tee -a "$BUILD_LOG" new_commit=$(git rev-parse --short HEAD) if [ "$old_commit" != "$new_commit" ]; then log " 代码更新: ${old_commit} → ${new_commit}" else log " 代码无变化 (${old_commit})" fi log " Maven 构建..." mkdir -p "$MAVEN_REPO" && chmod -R 777 "$MAVEN_REPO" 2>/dev/null || true if ! $MVN_EXEC clean package -Dmaven.test.skip=true -pl yudao-server -am -T 1C \ -Dmaven.repo.local="$MAVEN_REPO" >> "$BUILD_LOG" 2>&1; then error "Maven 构建失败!查看:tail -100 $BUILD_LOG" die "构建失败" fi success "Maven 构建完成" # 2. 检查产物 local source_jar="$PROJECT_DIR/yudao-server/target/$BACKEND_JAR" if [ ! -f "$source_jar" ]; then die "编译产物不存在:$source_jar" fi log " 产物大小: $(du -h "$source_jar" | cut -f1)" # 3. 备份当前版本 backup_current # 4. 停止旧服务 stop_service # 5. 部署新 JAR log "=== 部署 ===" rm -f "$BACKEND_DIR/$BACKEND_JAR" cp -f "$source_jar" "$BACKEND_DIR/" log " JAR 已复制到 $BACKEND_DIR/" # 6. 启动 start_service log "" success "后端部署完成 ($old_commit → $new_commit)" } # ==================== 前端部署 ==================== deploy_frontend() { log "" log "╔══════════════════════════════════════════╗" log "║ 用户端前端部署 ║" log "╚══════════════════════════════════════════╝" log "=== 构建 ===" cd "$PROJECT_DIR/frontend" log " pnpm install..." pnpm install >> "$BUILD_LOG" 2>&1 || die "pnpm install 失败" log " pnpm build:gold..." pnpm build:gold >> "$BUILD_LOG" 2>&1 || die "pnpm build:gold 失败" log "=== 部署 ===" rm -rf "${FRONTEND_DIR:?}"/* cp -r "$PROJECT_DIR/frontend/app/web-gold/dist/"* "$FRONTEND_DIR/" success "用户端前端部署完成" } deploy_admin() { log "" log "╔══════════════════════════════════════════╗" log "║ 管理后台前端部署 ║" log "╚══════════════════════════════════════════╝" log "=== 构建 ===" cd "$PROJECT_DIR/yudao-ui-admin-vue3" log " pnpm install..." pnpm install >> "$BUILD_LOG" 2>&1 || die "pnpm install 失败" log " pnpm build:prod..." pnpm build:prod >> "$BUILD_LOG" 2>&1 || die "pnpm build:prod 失败" log "=== 部署 ===" rm -rf "${ADMIN_DIR:?}"/* cp -r "$PROJECT_DIR/yudao-ui-admin-vue3/dist-prod/"* "$ADMIN_DIR/" success "管理后台部署完成" } # ==================== 部署后摘要 ==================== print_summary() { log "" log "╔══════════════════════════════════════════╗" log "║ 部署摘要 ║" log "╚══════════════════════════════════════════╝" log " 构建日志: $BUILD_LOG" log " 部署日志: $DEPLOY_LOG" log " 版本备份: $BACKUP_DIR/ ($(ls -1 "$BACKUP_DIR"/*.jar 2>/dev/null | wc -l) 个)" log " 控制台日志: $BACKEND_DIR/logs/console.log" # 快速验证 if curl -s -o /dev/null -w "%{http_code}" \ "http://127.0.0.1:${BACKEND_PORT}/actuator/health" 2>/dev/null | grep -q "200"; then success "健康检查: UP" else warn "健康检查: DOWN (可能仍在启动中)" fi # 磁盘 local disk_use disk_use=$(df -h "$BACKEND_DIR" | awk 'NR==2 {print $5}') log " 磁盘使用: ${disk_use}" log "" } # ==================== 主流程 ==================== main() { acquire_lock # 清理残留 Maven 进程 pkill -f "maven" 2>/dev/null || true echo "" >> "$DEPLOY_LOG" log "═══════════════════════════════════════════" log " 部署开始 (模式: ${1:-full})" log "═══════════════════════════════════════════" case "${1:-full}" in backend-only) deploy_backend ;; frontend-only) deploy_frontend ;; admin-only) deploy_admin ;; backend|full|"") deploy_backend deploy_frontend deploy_admin ;; *) echo "用法: $0 {backend-only|frontend-only|admin-only|full}" release_lock exit 1 ;; esac print_summary log "🏁 部署流水线全部执行完毕 ($(date '+%H:%M:%S'))" } main "$@"