#!/usr/bin/env bash # ═══════════════════════════════════════════════════════════════════ # B7: 事务性部署脚本模板 (Transactional Deploy) # 用途: 在 ECS 上执行零停机部署,支持原子回滚 # # 安全设计: # - 蓝绿部署: 新版本部署到 staging 目录,切换符号链接激活 # - Gold Release: 维护已知良好版本标记,回滚始终回到 gold # - Migration 检测: 含 DB migration 时禁止自动回滚 # - nohup 安全: SSH 断开不中断部署 # - 全程审计日志 # # 用法 (由 deploy-wrapper.sh 白名单调用): # bash deploy-transactional.sh deploy # bash deploy-transactional.sh rollback # bash deploy-transactional.sh status # bash deploy-transactional.sh health # ═══════════════════════════════════════════════════════════════════ set -euo pipefail # ─── 配置 ───────────────────────────────────────────────────── APP_NAME="${APP_NAME:-bookworm-web}" APP_BASE="/var/www/${APP_NAME}" APP_CURRENT="${APP_BASE}/current" # 符号链接 → 当前活跃版本 APP_RELEASES="${APP_BASE}/releases" # 版本目录 APP_SHARED="${APP_BASE}/shared" # 共享资源 (.env, uploads, logs) APP_REPO="${APP_BASE}/repo" # Git 仓库 GOLD_FILE="${APP_BASE}/gold-release.txt" DEPLOY_LOG="/var/log/${APP_NAME}-deploy.log" MAX_RELEASES=5 # 保留最近 N 个版本 HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}" HEALTH_TIMEOUT=30 # 健康检查超时秒数 # ─── 工具函数 ───────────────────────────────────────────────── ts() { date '+%Y-%m-%d %H:%M:%S'; } log() { echo "[$(ts)] $*" | tee -a "${DEPLOY_LOG}"; } die() { log "FATAL: $*"; exit 1; } # ─── 部署命令 ───────────────────────────────────────────────── cmd_deploy() { log "═══ 开始部署 ${APP_NAME} ═══" # 1. 拉取最新代码 log "Step 1/7: 拉取代码..." cd "${APP_REPO}" git fetch origin main --prune git reset --hard origin/main local COMMIT COMMIT=$(git rev-parse HEAD) local SHORT_COMMIT="${COMMIT:0:8}" log "目标 commit: ${SHORT_COMMIT}" # 2. 检测是否包含 migration local HAS_MIGRATION=false if git diff HEAD~1 --name-only 2>/dev/null | grep -qi 'migration\|migrate\|schema'; then HAS_MIGRATION=true log "WARNING: 检测到数据库 migration,回滚将受限" fi # 3. 创建新版本目录 (蓝绿) local RELEASE_DIR="${APP_RELEASES}/$(date +%Y%m%d_%H%M%S)_${SHORT_COMMIT}" log "Step 2/7: 创建版本目录 ${RELEASE_DIR}..." mkdir -p "${RELEASE_DIR}" rsync -a --exclude=node_modules --exclude=.git "${APP_REPO}/" "${RELEASE_DIR}/" # 4. 安装依赖 + 构建 log "Step 3/7: 安装依赖..." cd "${RELEASE_DIR}" if [ -f "pnpm-lock.yaml" ]; then pnpm install --frozen-lockfile --prod 2>&1 | tail -5 elif [ -f "package-lock.json" ]; then npm ci --production 2>&1 | tail -5 fi log "Step 4/7: 构建..." if grep -q '"build"' package.json 2>/dev/null; then pnpm build 2>&1 | tail -10 || npm run build 2>&1 | tail -10 fi # 5. 链接共享资源 log "Step 5/7: 链接共享资源..." [ -f "${APP_SHARED}/.env" ] && ln -sf "${APP_SHARED}/.env" "${RELEASE_DIR}/.env" [ -d "${APP_SHARED}/uploads" ] && ln -sf "${APP_SHARED}/uploads" "${RELEASE_DIR}/uploads" # 6. 数据库 migration (如果有) if [ "${HAS_MIGRATION}" = true ]; then log "Step 5.5/7: 执行数据库 migration..." if [ -f "${RELEASE_DIR}/prisma/schema.prisma" ]; then cd "${RELEASE_DIR}" && npx prisma migrate deploy 2>&1 | tail -5 fi # migration 成功后标记,用于判断是否可回滚 echo "MIGRATION_APPLIED=${COMMIT}" >> "${RELEASE_DIR}/.deploy-meta" fi # 7. 原子切换符号链接 log "Step 6/7: 切换活跃版本..." local TMP_LINK="${APP_CURRENT}.new" ln -sfn "${RELEASE_DIR}" "${TMP_LINK}" mv -Tf "${TMP_LINK}" "${APP_CURRENT}" log "符号链接已切换: current → ${RELEASE_DIR}" # 8. 重启应用 log "Step 7/7: 重启应用..." if command -v pm2 &>/dev/null; then cd "${APP_CURRENT}" && pm2 reload ecosystem.config.js --update-env 2>&1 || \ pm2 reload all 2>&1 fi # 9. 健康检查 log "等待应用启动..." local healthy=false for i in $(seq 1 ${HEALTH_TIMEOUT}); do if curl -sf --max-time 5 "${HEALTH_URL}" >/dev/null 2>&1; then healthy=true break fi sleep 1 done if [ "${healthy}" = true ]; then log "健康检查通过!" # 标记为 gold release echo "${COMMIT}" > "${GOLD_FILE}" log "已标记 gold release: ${SHORT_COMMIT}" # 清理旧版本 (保留最近 N 个) cleanup_old_releases log "═══ 部署成功 (${SHORT_COMMIT}) ═══" echo "DEPLOY_SUCCESS" else log "ERROR: 健康检查超时 (${HEALTH_TIMEOUT}s)" # 回滚 (仅非 migration 场景) if [ "${HAS_MIGRATION}" = true ]; then log "ERROR: 含 migration,无法自动回滚。请手动处理。" echo "DEPLOY_FAIL_MIGRATION_NO_ROLLBACK" exit 1 fi # 自动回滚到前一个版本 log "正在自动回滚..." cmd_rollback_to_previous echo "DEPLOY_FAIL_ROLLED_BACK" exit 1 fi } # ─── 回滚命令 ───────────────────────────────────────────────── cmd_rollback() { local target_commit="${1:-}" if [ -z "${target_commit}" ]; then # 无指定 commit → 回滚到 gold release if [ -f "${GOLD_FILE}" ]; then target_commit=$(cat "${GOLD_FILE}") log "回滚到 gold release: ${target_commit:0:8}" else die "无 gold release 记录,无法回滚" fi fi # 校验 commit hash 格式 if [[ ! "${target_commit}" =~ ^[0-9a-f]{40}$ ]]; then die "无效的 commit hash: ${target_commit}" fi # 查找对应的版本目录 local target_dir="" for dir in "${APP_RELEASES}"/*/; do if [[ "${dir}" == *"${target_commit:0:8}"* ]]; then target_dir="${dir%/}" break fi done if [ -n "${target_dir}" ] && [ -d "${target_dir}" ]; then # 检查目标版本是否含 migration if [ -f "${target_dir}/.deploy-meta" ] && grep -q "MIGRATION_APPLIED" "${target_dir}/.deploy-meta"; then log "WARNING: 目标版本含已执行的 migration,回滚可能导致 schema 不一致" fi # 切换符号链接 local TMP_LINK="${APP_CURRENT}.new" ln -sfn "${target_dir}" "${TMP_LINK}" mv -Tf "${TMP_LINK}" "${APP_CURRENT}" pm2 reload all 2>&1 || true log "已回滚到: ${target_dir}" echo "ROLLBACK_SUCCESS" else # 版本目录不存在,从 git 恢复 log "版本目录不存在,从 git checkout 恢复..." cd "${APP_REPO}" && git checkout "${target_commit}" -- . cmd_deploy fi } cmd_rollback_to_previous() { # 获取当前版本之前的最新版本目录 local current_target current_target=$(readlink -f "${APP_CURRENT}" 2>/dev/null || echo "") local prev_dir="" for dir in $(ls -dt "${APP_RELEASES}"/*/); do dir="${dir%/}" if [ "${dir}" != "${current_target}" ]; then prev_dir="${dir}" break fi done if [ -n "${prev_dir}" ]; then log "回滚到前一版本: ${prev_dir}" local TMP_LINK="${APP_CURRENT}.new" ln -sfn "${prev_dir}" "${TMP_LINK}" mv -Tf "${TMP_LINK}" "${APP_CURRENT}" pm2 reload all 2>&1 || true echo "ROLLBACK_SUCCESS" elif [ -f "${GOLD_FILE}" ]; then log "无前一版本,回滚到 gold release" cmd_rollback "$(cat "${GOLD_FILE}")" else die "无可用的回滚目标" fi } # ─── 状态/健康命令 ──────────────────────────────────────────── cmd_status() { echo "=== ${APP_NAME} 部署状态 ===" echo "当前版本: $(readlink -f "${APP_CURRENT}" 2>/dev/null || echo 'N/A')" echo "Gold release: $(cat "${GOLD_FILE}" 2>/dev/null | head -c 8 || echo 'N/A')" echo "可用版本:" ls -dt "${APP_RELEASES}"/*/ 2>/dev/null | head -${MAX_RELEASES} | while read -r d; do echo " - $(basename "${d}")" done echo "PM2 状态:" pm2 status 2>&1 || echo " pm2 不可用" } cmd_health() { local code code=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "000") if [ "${code}" = "200" ]; then echo "HEALTH_OK (${HEALTH_URL} → ${code})" else echo "HEALTH_FAIL (${HEALTH_URL} → ${code})" exit 1 fi } # ─── 清理旧版本 ────────────────────────────────────────────── cleanup_old_releases() { local count count=$(ls -d "${APP_RELEASES}"/*/ 2>/dev/null | wc -l) if [ "${count}" -gt "${MAX_RELEASES}" ]; then local to_remove=$((count - MAX_RELEASES)) log "清理 ${to_remove} 个旧版本..." ls -dt "${APP_RELEASES}"/*/ | tail -${to_remove} | while read -r d; do # 不删除 gold release 对应的版本 local gold_commit gold_commit=$(cat "${GOLD_FILE}" 2>/dev/null || echo "") if [ -n "${gold_commit}" ] && [[ "${d}" == *"${gold_commit:0:8}"* ]]; then log "保留 gold release 版本: $(basename "${d}")" continue fi log "删除旧版本: $(basename "${d}")" rm -rf "${d}" done fi } # ─── 入口 ───────────────────────────────────────────────────── case "${1:-}" in deploy) cmd_deploy ;; rollback) cmd_rollback "${2:-}" ;; status) cmd_status ;; health) cmd_health ;; *) echo "用法: $0 {deploy|rollback [commit]|status|health}" exit 1 ;; esac