Files
backend/deploy/deploy.sh
T
34047007@qq.com c55d096769
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
feat: deploy/deploy.sh 自动化部署体系 — 双tag镜像/快照/迁移门控/回滚,退役根deploy.sh
- compose 四服务补 image: ${BACKEND_TAG}/${FRONTEND_TAG} 插值(换 tag 回滚前提)
- deploy/deploy.sh:工作区校验→PREV_SHA→pull→predeploy 快照(-Fc)→destructive 判定→
  build+双tag→.pending-deploy→迁移退出码门控→up -d --no-deps→健康轮询→versions.log→
  镜像治理;支持 --dry-run / flock / 基础设施健康门 / 多head预检
- deploy/rollback.sh:双入口(手动读 versions.log 跳 ROLLBACK 行 / --auto 读
  .pending-deploy);destructive 走 pg_restore --clean;双 tag 原子切换
- deploy/hotfix.sh:紧急单文件 cp + 强制收口提醒
- .gitignore 补 .pending-deploy/versions.log(运行时状态)
- 退役根 deploy.sh + deploy.env.example(SSH 旧模型,被 deploy/deploy.sh 取代)
2026-08-10 16:30:43 +08:00

305 lines
12 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# deploy.sh — SciLit 生产一键部署(docker-compose.prod.yml
#
# 用法:
# bash deploy/deploy.sh # 正式部署(在服务器 /root/scilit 下执行)
# bash deploy/deploy.sh --dry-run # 只打印将执行的命令,不执行任何变更
# bash deploy/deploy.sh --help
#
# 前置条件:
# - 在 git 仓库根目录的 deploy/ 下执行(自动定位 REPO_ROOT 与 .env
# - 基础设施(postgres/redis/elasticsearch/minio)已在跑(脚本内置健康门)
# - 生产部署期间勿并发(脚本自带 flock 单实例锁)
#
# 流程(对应 docs/16 §2 设计):
# 遗留标记检查 → source .env → flock → 工作区校验 → 基础设施健康门 →
# PREV_SHA → git pull → before alembic head → predeploy 快照 →
# destructive 判定 → build + 双 tag → .pending-deploy → 迁移(退出码门控) →
# after alembic head → up -d --no-deps → 健康轮询 → versions.log → 镜像治理
#
# 失败处理分两段:
# · 迁移前失败(快照/构建阶段)→ 容器仍是旧的,只清理中间态,不触发回滚
# · 迁移已跑/容器已切后失败 → 调 rollback.sh --auto(读本次 .pending-deploy
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$DEPLOY_DIR/.." && pwd)"
cd "$REPO_ROOT"
COMPOSE_FILE="docker-compose.prod.yml"
COMPOSE=(docker compose -f "$COMPOSE_FILE")
BACKUP_DIR="${BACKUP_DIR:-/data/backups}"
KEEP_TAGS=10 # 保留最近 N 个带 tag 版本镜像(§5 count 制)
HEALTH_POLLS=12 # 健康轮询次数 × 5s = 60s 上限
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
error() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
DRY_RUN=0
# 所有变更命令统一走 run():--dry-run 下只打印不执行
run() {
if [ "$DRY_RUN" -eq 1 ]; then
echo -e "${YELLOW}[DRY-RUN]${NC} $*"
return 0
fi
"$@"
}
usage() {
cat <<'EOF'
用法: bash deploy/deploy.sh [--dry-run] [--help]
--dry-run 只打印将执行的命令,不执行任何变更(SHA 显示当前状态)
--help 显示本帮助
EOF
}
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--help) usage; exit 0 ;;
*) usage; exit 1 ;;
esac
done
# ── 1. 遗留 .pending-deploy 检测(I:上次部署中途崩溃的残留,不静默覆盖)──
if [ -f "$DEPLOY_DIR/.pending-deploy" ]; then
error "⚠️ 上一次部署异常退出,残留 ${DEPLOY_DIR}/.pending-deploy。
请先确认当前状态(容器/迁移/versions.log)再继续,然后删除该标记或人工处理后重跑。"
fi
# ── 2. source .envGPG_PASSWORD/REDIS_PASSWORD 供 pg_dump 备选与探活)──
if [ ! -f "$REPO_ROOT/.env" ]; then
error "未找到 ${REPO_ROOT}/.env(含密钥,不进 git)。请先在服务器配置。"
fi
set -a; source "$REPO_ROOT/.env"; set +a
# ── 3. flock 单实例锁(防两人/两终端并发部署)──
exec 9>/tmp/scilit-deploy.lock
flock -n 9 || error "已有部署/回滚在进行中(/tmp/scilit-deploy.lock 被锁)。"
# ── 4. 工作区校验(防 hotfix 残留导致 git pull 冲突)──
# 只拦「已跟踪文件的改动」(这才是 pull 冲突来源);未跟踪文件(如服务器运维
# 工具 backup.sh/tmp_*.pywarn 不拦
STALE_CHANGES="$(git status --porcelain | grep -v '^??' || true)"
if [ -n "$STALE_CHANGES" ]; then
error "工作区有已跟踪文件改动(可能为 hotfix 残留,git pull 会冲突):
$STALE_CHANGES"
fi
UNTRACKED="$(git status --porcelain | grep '^??' || true)"
if [ -n "$UNTRACKED" ]; then
warn "存在未跟踪文件(不影响 pull,仅当新代码含同名文件时需注意):
$UNTRACKED"
fi
# ── 5. 基础设施健康门(P14/N5:有 healthcheck 判 healthy、无则判 running)──
infra_ok() {
if [ "$DRY_RUN" -eq 1 ]; then
echo "[DRY-RUN] 健康门:postgres/redis/elasticsearch/minio 模拟通过"
return 0
fi
local svc cid health status
for svc in postgres redis elasticsearch minio; do
cid=$("${COMPOSE[@]}" ps -q "$svc" 2>/dev/null || true)
if [ -z "$cid" ]; then
error "基础设施 $svc 未运行。请先冷启动:
docker compose -f $COMPOSE_FILE up -d postgres redis elasticsearch minio gitea"
fi
health=$(docker inspect --format '{{.State.Health.Status}}' "$cid" 2>/dev/null || true)
status=$(docker inspect --format '{{.State.Status}}' "$cid" 2>/dev/null || true)
if [ -n "$health" ]; then
[ "$health" = "healthy" ] || error "基础设施 $svc health=$health(期望 healthy)。请先确认状态再部署。"
else
[ "$status" = "running" ] || error "基础设施 $svc status=$status(无 healthcheck,期望 running)。"
fi
done
info "基础设施健康门通过"
}
infra_ok
# ── 6. 采 PREV_SHAFdestructive 判定基线,必须在 git pull 之前)──
PREV_SHA="$(git rev-parse HEAD)"
info "当前生产版本: $PREV_SHA"
# ── 7. git pull ──
run git pull
# ── 取 alembic 当前 revision ──
# 用 psql 直读 alembic_versionpostgres 已健康门保证运行;容器内 socket trust 免密)。
# 不用 `run --rm backend alembic current`before 阶段新镜像还没 buildimage:latest
# 不存在会触发自动 build(打乱"快照在先"顺序)。表不存在(首次部署)返回空,v5 容错。
get_db_rev() {
if [ "$DRY_RUN" -eq 1 ]; then echo "-"; return 0; fi
"${COMPOSE[@]}" exec -T postgres psql -U scilit -d scilit -tAc \
"SELECT version_num FROM alembic_version" 2>/dev/null | tr -d '[:space:]' | cut -c1-12
}
# ── 8. before alembic head(空则记 '-',首次部署容错)──
ALEMBIC_BEFORE="$(get_db_rev || true)"
info "迁移前 alembic head: ${ALEMBIC_BEFORE:-(空/首次)}"
# ── 9. predeploy 全量快照(-Fc custom 格式,pg_restore 专用;含全部表)──
mkdir -p "$BACKUP_DIR"
SNAPSHOT=""
snapshot_take() {
local ts
ts="$(date +%Y%m%d_%H%M%S)"
SNAPSHOT="$BACKUP_DIR/predeploy_$ts.dump"
if [ "$DRY_RUN" -eq 1 ]; then
echo -e "${YELLOW}[DRY-RUN]${NC} pg_dump → $SNAPSHOT"
return 0
fi
# 容器内 pg_dump(不经宿主端口,与生产 backup.sh 同机制)
PGPASSWORD="${PG_PASSWORD}" "${COMPOSE[@]}" exec -T postgres \
pg_dump -Fc --no-owner --no-privileges -U scilit -d scilit > "$SNAPSHOT"
if [ ! -s "$SNAPSHOT" ]; then
rm -f "$SNAPSHOT"
error "predeploy 快照生成失败(空文件),中止部署。"
fi
info "predeploy 快照: $SNAPSHOT"
}
snapshot_take
# predeploy 快照保留最近 N=3E1count 判定 + sort | head -n -3 | xargs -r rm
prune_snapshots() {
if [ "$DRY_RUN" -eq 1 ]; then return 0; fi
local count
count="$(ls "$BACKUP_DIR"/predeploy_*.dump 2>/dev/null | wc -l)"
if [ "$count" -gt 3 ]; then
ls "$BACKUP_DIR"/predeploy_*.dump | sort | head -n -3 | xargs -r rm -f
info "已清理旧 predeploy 快照(保留最近 3 个)"
fi
}
prune_snapshots
# ── 10. destructive 判定(P12 细化规则 + destructive_* 文件名/# DESTRUCTIVE 双保险)──
NEW_SHA="$(git rev-parse --short HEAD)"
detect_destructive() {
local prev="$1" new="$2" files f fn
files="$(git diff --name-only "$prev" "$new" -- alembic/versions/)"
[ -z "$files" ] && { echo "否"; return 0; }
for f in $files; do
fn="$(basename "$f")"
case "$fn" in
destructive_*) echo "是"; return 0 ;;
esac
if grep -q '# DESTRUCTIVE' "$REPO_ROOT/$f" 2>/dev/null; then echo "是"; return 0; fi
done
if git diff "$prev" "$new" -- alembic/versions/ | grep '^+' | grep -qE \
'op\.drop_(table|column|index)|op\.rename_(table|column|index)|op\.alter_column|existing_type|op\.execute[^)]*(DROP|DELETE|UPDATE|TRUNCATE)'; then
echo "是"; return 0
fi
echo "否"
}
DESTRUCTIVE="$(detect_destructive "$PREV_SHA" "$(git rev-parse HEAD)")"
info "本次迁移 destructive: $DESTRUCTIVE"
# ── 11. build + 双 tagbuild 前 exportcompose build 直接打 ${BACKEND_TAG} 标签)──
if [ "$PREV_SHA" = "$(git rev-parse HEAD)" ] && [ -z "$(git diff --name-only "$PREV_SHA" -- docker-compose.prod.yml deploy)" ]; then
warn "代码无更新(HEAD 未变),执行幂等重部署"
fi
export BACKEND_TAG="$NEW_SHA" FRONTEND_TAG="$NEW_SHA"
info "构建并打标签: backend/frontend @ $NEW_SHA"
run "${COMPOSE[@]}" build backend frontend
# ── 12. 写 .pending-deploy(N1:快照后、迁移前;失败自动回滚唯一数据源)──
if [ "$DRY_RUN" -ne 1 ]; then
echo "$NEW_SHA $NEW_SHA $DESTRUCTIVE $SNAPSHOT" > "$DEPLOY_DIR/.pending-deploy"
fi
info "写入部署标记: $DEPLOY_DIR/.pending-deploy"
# ── 13. alembic 多 head 预检(P15+ 迁移(Krun --no-deps --rm,退出码即真值)──
check_single_head() {
if [ "$DRY_RUN" -eq 1 ]; then return 0; fi
local n
n="$("${COMPOSE[@]}" run --no-deps --rm backend alembic -c alembic/alembic.ini heads 2>/dev/null | grep -cE '[0-9a-f]{12}\b' || true)"
[ "$n" -le 1 ] || error "alembic 多 head$n 个),请先合并(alembic merge 或串行 rebase)。"
}
check_single_head
auto_rollback() {
echo -e "${RED}[✗] 部署部分完成(迁移已跑/容器已切),触发自动回滚${NC}"
"$DEPLOY_DIR/rollback.sh" --auto || warn "自动回滚失败,请人工介入(详见 docs/16 §3 回滚 runbook"
exit 1
}
info "执行迁移: alembic upgrade head(后台 frontend/backend 暂不切换)"
if ! run "${COMPOSE[@]}" run --no-deps --rm backend alembic -c alembic/alembic.ini upgrade head; then
auto_rollback
fi
# ── 14. after alembic head ──
ALEMBIC_AFTER="$(get_db_rev || true)"
info "迁移后 alembic head: ${ALEMBIC_AFTER:-(空/首次)}"
# ── 15. up -d --no-deps(显式指定应用容器,不动基础设施)──
info "切换容器到新版本(backend worker frontend"
if ! run "${COMPOSE[@]}" up -d --no-deps backend worker frontend; then
auto_rollback
fi
# ── 16. 健康轮询(V6up 后仍在 start_period,轮询而非单次 curl)──
wait_backend_healthy() {
local i
for i in $(seq 1 "$HEALTH_POLLS"); do
if "${COMPOSE[@]}" exec -T backend curl -sf http://localhost:8000/health >/dev/null 2>&1; then
return 0
fi
sleep 5
done
return 1
}
check_frontend() {
"${COMPOSE[@]}" exec -T frontend wget -q -O- http://localhost/ >/dev/null 2>&1
}
if [ "$DRY_RUN" -eq 1 ]; then
info "[DRY-RUN] 健康轮询:backend /health + frontend 200(模拟通过)"
elif wait_backend_healthy; then
info "backend /health 通过"
if check_frontend; then
info "frontend 200 通过"
else
warn "frontend 健康检查未过,但 backend 已就绪——请人工确认前端状态"
fi
else
auto_rollback
fi
# ── 17. 成功 → 写 versions.log + 删 .pending-deploy ──
if [ "$DRY_RUN" -eq 1 ]; then
echo -e "${YELLOW}[DRY-RUN]${NC} 追加 versions.log + 删除 .pending-deploy"
else
echo "DEPLOY $NEW_SHA $NEW_SHA $DESTRUCTIVE ${ALEMBIC_BEFORE:-} ${ALEMBIC_AFTER:-} $(date '+%Y-%m-%d %H:%M:%S %z')" \
>> "$DEPLOY_DIR/versions.log"
tail -n 200 "$DEPLOY_DIR/versions.log" > "$DEPLOY_DIR/versions.log.tmp"
mv "$DEPLOY_DIR/versions.log.tmp" "$DEPLOY_DIR/versions.log"
rm -f "$DEPLOY_DIR/.pending-deploy"
fi
info "部署成功,已写入 versions.log"
# ── 18. 镜像治理(只按 count 清理带 tag 版本镜像;age-prune 只清 dangling)──
prune_images() {
if [ "$DRY_RUN" -eq 1 ]; then return 0; fi
local repo count to_remove t
for repo in scilit/backend scilit/frontend; do
count="$(docker images "$repo" --format '{{.Tag}}' | grep -v '^latest$' | wc -l)"
if [ "$count" -gt "$KEEP_TAGS" ]; then
to_remove="$(docker images "$repo" --format '{{.Tag}} {{.CreatedAt}}' \
| grep -v '^latest ' | sort -k2 | head -n $((count - KEEP_TAGS)) | awk '{print $1}')"
for t in $to_remove; do
docker rmi "$repo:$t" >/dev/null 2>&1 || true
done
info "已清理 $repo 旧版本镜像(保留最近 $KEEP_TAGS 个)"
fi
done
docker image prune --filter "until=168h" -f >/dev/null 2>&1 || true
}
prune_images
info "全部完成 ✅ 当前版本: $NEW_SHA"