287 lines
10 KiB
Bash
287 lines
10 KiB
Bash
|
|
#!/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 <commit-hash>
|
|||
|
|
# 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
|