#!/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 .env(G:PG_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_*.py)warn 不拦 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_SHA(F:destructive 判定基线,必须在 git pull 之前)── PREV_SHA="$(git rev-parse HEAD)" info "当前生产版本: $PREV_SHA" # ── 7. git pull ── run git pull # ── 取 alembic 当前 revision ── # 用 psql 直读 alembic_version(postgres 已健康门保证运行;容器内 socket trust 免密)。 # 不用 `run --rm backend alembic current`:before 阶段新镜像还没 build,image: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=3(E1:count 判定 + 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 + 双 tag(build 前 export,compose 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)+ 迁移(K:run --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. 健康轮询(V6:up 后仍在 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"