From c55d0967692bc5abe0ba3ffbc29021520d932724 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Mon, 10 Aug 2026 16:30:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20deploy/deploy.sh=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96=E9=83=A8=E7=BD=B2=E4=BD=93=E7=B3=BB=20=E2=80=94=20?= =?UTF-8?q?=E5=8F=8Ctag=E9=95=9C=E5=83=8F/=E5=BF=AB=E7=85=A7/=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E9=97=A8=E6=8E=A7/=E5=9B=9E=E6=BB=9A=EF=BC=8C?= =?UTF-8?q?=E9=80=80=E5=BD=B9=E6=A0=B9deploy.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 取代) --- .gitignore | 4 + deploy.env.example | 14 -- deploy.sh | 153 -------------------- deploy/deploy.sh | 304 +++++++++++++++++++++++++++++++++++++++ deploy/hotfix.sh | 48 +++++++ deploy/rollback.sh | 131 +++++++++++++++++ docker-compose.prod.yml | 4 + docs/16-部署运维方案.md | 5 +- docs/deployment-guide.md | 27 ++-- 9 files changed, 513 insertions(+), 177 deletions(-) delete mode 100644 deploy.env.example delete mode 100644 deploy.sh create mode 100644 deploy/deploy.sh create mode 100644 deploy/hotfix.sh create mode 100644 deploy/rollback.sh diff --git a/.gitignore b/.gitignore index d0dd727..d7ae88f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ backend/data/ # Logs *.log +# Deploy runtime state (per-server, not for repo) +deploy/.pending-deploy +deploy/versions.log + diff --git a/deploy.env.example b/deploy.env.example deleted file mode 100644 index f4ff5fa..0000000 --- a/deploy.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# ═══════════════════════════════════════════════════════════════════ -# SciLit — 部署配置(供 deploy.sh 使用) -# ═══════════════════════════════════════════════════════════════════ -# -# 复制为 deploy.env,填入实际值,不要提交到 git: -# cp deploy.env.example deploy.env -# -# 然后在 deploy.env 同目录运行: -# source deploy.env && ./deploy.sh - -# 服务器 SSH 信息 -SCILIT_HOST=your-server.com -SCILIT_USER=root -SCILIT_PATH=/root/scilit-oncology diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 62c8893..0000000 --- a/deploy.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/bin/bash -# ═══════════════════════════════════════════════════════════════════ -# SciLit — 生产环境一键部署脚本 -# ═══════════════════════════════════════════════════════════════════ -# -# 用法: -# export SCILIT_HOST=your-server.com -# ./deploy.sh # 部署到默认服务器 -# ./deploy.sh --skip-tests # 跳过本地测试,直接部署 -# ./deploy.sh --help # 查看全部选项 -# -# 前置条件: -# - 本地: SSH 可连接到服务器(推荐用 ~/.ssh/config) -# - 服务器: 已装 Docker + docker compose,已 clone 项目 -# - 服务器: .env 文件已配置好 -# - 代码仓库: 支持 Gitee / GitHub / 自建 Git(git pull 能工作即可) -# -# 远程仓库配置: -# - 本地 git remote 指向 Gitee: -# git remote add gitee https://gitee.com/你的用户名/scilit-oncology.git -# # deploy.sh 会尝试 push 到 origin(可设为 Gitee) -# - 服务器上 git remote 也指向 Gitee: -# git remote set-url origin https://gitee.com/你的用户名/scilit-oncology.git -# - 可选: GitHub 同时保留用作备份: -# git remote add github https://github.com/你的用户名/scilit-oncology.git -# ═══════════════════════════════════════════════════════════════════ - -set -euo pipefail - -# ── 默认配置 ───────────────────────────────────────────────────── -# 改为你的服务器信息 -SERVER_HOST="${SCILIT_HOST:-your-server.com}" -SERVER_USER="${SCILIT_USER:-root}" -SERVER_PATH="${SCILIT_PATH:-/root/scilit-oncology}" - -# 本地项目根目录 -LOCAL_ROOT="$(cd "$(dirname "$0")" && pwd)" - -# ── 颜色 ───────────────────────────────────────────────────────── -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -info() { echo -e "${GREEN}[✓]${NC} $1"; } -warn() { echo -e "${YELLOW}[!]${NC} $1"; } -error() { echo -e "${RED}[✗]${NC} $1"; exit 1; } - -# ── 参数解析 ───────────────────────────────────────────────────── -SKIP_TESTS=false -ENV_FILE="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --skip-tests) SKIP_TESTS=true; shift ;; - --env) ENV_FILE="$2"; shift 2 ;; - --help) - echo "用法: $0 [--skip-tests] [--env .env.prod]" - echo "" - echo " --skip-tests 跳过本地测试和 lint" - echo " --env FILE 指定服务器上的 .env 文件路径(默认: {SERVER_PATH}/.env)" - exit 0 ;; - *) error "未知参数: $1,使用 --help 查看用法" ;; - esac -done - -# ── 第一步:本地检查 + 测试 ───────────────────────────────────── -echo "════════════════════════════════════════════" -echo " SciLit 部署" -echo " 目标: ${SERVER_USER}@${SERVER_HOST}" -echo "════════════════════════════════════════════" -echo "" - -if [ "$SKIP_TESTS" = false ]; then - echo "── 1/4 本地测试 ──" - cd "$LOCAL_ROOT/backend" - info "运行后端测试..." - python -m pytest tests/ -v --no-cov --tb=short 2>&1 | tail -5 - local EXIT_CODE=$? - if [ $EXIT_CODE -ne 0 ]; then - error "后端测试失败(exit code $EXIT_CODE),中止部署。用 --skip-tests 跳过。" - fi - info "后端测试通过" - - cd "$LOCAL_ROOT/frontend" - info "构建前端(验证)..." - npm run build 2>&1 | tail -3 - info "前端构建通过" - cd "$LOCAL_ROOT" -else - echo "── 1/4 本地测试 ── 已跳过" -fi - -# ── 第二步:推送代码 ───────────────────────────────────────────── -echo "" -echo "── 2/4 推送代码 ──" -cd "$LOCAL_ROOT" -if git status --porcelain | grep -q .; then - warn "有未提交的改动,建议先提交再部署。继续?(y/N)" - read -r yn - if [ "$yn" != "y" ] && [ "$yn" != "Y" ]; then - error "已取消" - fi -fi - -# 尝试推送到 GitHub(失败不阻塞) -if git push origin main 2>&1; then - info "GitHub push 成功" -else - warn "GitHub push 失败(网络问题),继续部署..." -fi - -# ── 第三步:服务器拉取 + 构建 + 启动 ──────────────────────────── -echo "" -echo "── 3/4 服务器部署 ──" - -DEPLOY_CMD=$(cat << SSHCMD -set -e -cd ${SERVER_PATH} - -echo " 拉取代码..." -git pull origin main 2>&1 || echo " [!] git pull 失败,继续用本地代码" - -echo " 构建并启动..." -docker compose -f docker-compose.prod.yml build 2>&1 | tail -3 - -# 优雅重启(不中断现有连接) -echo " 正在更新服务..." -docker compose -f docker-compose.prod.yml up -d --no-deps --remove-orphans 2>&1 - -echo " 等待就绪..." -sleep 5 -curl -sf http://localhost:8000/health && echo " [✓] backend 健康检查通过" || echo " [!] backend 健康检查失败" -curl -sf http://localhost:80 && echo " [✓] frontend 健康检查通过" || echo " [!] frontend 健康检查失败" - -echo " 清理旧镜像..." -docker image prune -f 2>&1 | tail -1 -SSHCMD -) - -ssh -o StrictHostKeyChecking=no "${SERVER_USER}@${SERVER_HOST}" bash <<< "$DEPLOY_CMD" - -info "服务器部署完成" - -# ── 第四步:日志 ───────────────────────────────────────────────── -echo "" -echo "── 4/4 查看日志 ──" -echo "" -echo " 实时日志: ssh ${SERVER_USER}@${SERVER_HOST} 'cd ${SERVER_PATH} && docker compose -f docker-compose.prod.yml logs -f'" -echo " backend: ssh ${SERVER_USER}@${SERVER_HOST} 'cd ${SERVER_PATH} && docker compose -f docker-compose.prod.yml logs -f backend'" -echo " 重新部署: cd $(pwd) && bash deploy.sh" -echo "" -info "部署完毕 🎉" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..078cc63 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,304 @@ +#!/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" diff --git a/deploy/hotfix.sh b/deploy/hotfix.sh new file mode 100644 index 0000000..b254f74 --- /dev/null +++ b/deploy/hotfix.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════ +# hotfix.sh — 紧急单文件修复(仅限紧急情况) +# +# 用法: +# bash deploy/hotfix.sh <服务名> <宿主文件> <容器内路径> +# bash deploy/hotfix.sh backend /tmp/fix.py /app/app/services/fix.py +# +# ⚠️ 强制收口: +# docker cp 只是把文件放进运行中的容器,不进镜像——容器一重建即丢。 +# 用后必须立即: ① git 提交改动 ② 走 deploy.sh 正式部署,否则手工改动与仓库不一致(8-09 教训)。 +# ═══════════════════════════════════════════════════════════════════ +set -euo pipefail + +DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$DEPLOY_DIR/.." && pwd)" +cd "$REPO_ROOT" + +COMPOSE_FILE="docker-compose.prod.yml" + +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; } + +if [ "$#" -ne 3 ]; then + echo "用法: bash deploy/hotfix.sh <服务名> <宿主文件> <容器内路径>" + exit 1 +fi + +SERVICE="$1"; HOST_FILE="$2"; CONTAINER_PATH="$3" + +[ -f "$HOST_FILE" ] || error "宿主文件不存在: $HOST_FILE" + +info "复制 $HOST_FILE → $SERVICE:$CONTAINER_PATH" +docker compose -f "$COMPOSE_FILE" cp "$HOST_FILE" "$SERVICE:$CONTAINER_PATH" + +if [ "$SERVICE" = "backend" ] || [ "$SERVICE" = "worker" ]; then + info "backend/worker 需重启才生效: docker compose -f $COMPOSE_FILE restart $SERVICE" +fi + +echo "" +echo -e "${RED}════════════════════════════════════════════════════════════════${NC}" +echo -e "${RED}⚠️ hotfix 是脆弱窗口:docker cp 不进镜像,容器重建即丢!${NC}" +echo -e "${RED} 必须立即完成以下收口,否则改动丢失且与仓库不一致:${NC}" +echo -e "${RED} 1. 把改动同步回仓库(git 提交 + push)${NC}" +echo -e "${RED} 2. 尽快走正式部署: bash deploy/deploy.sh${NC}" +echo -e "${RED}════════════════════════════════════════════════════════════════${NC}" diff --git a/deploy/rollback.sh b/deploy/rollback.sh new file mode 100644 index 0000000..9de4ac5 --- /dev/null +++ b/deploy/rollback.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════ +# rollback.sh — 回滚到上一个成功部署版本(backend+frontend 双 tag 原子切换) +# +# 用法: +# bash deploy/rollback.sh # 手动回滚:versions.log 最近一次成功部署 +# bash deploy/rollback.sh [] # 回滚到指定版本 +# bash deploy/rollback.sh --auto # 部署失败自动回滚(读本次 .pending-deploy) +# +# 双入口读不同来源(N1 核心洞:失败自动回滚绝不读 versions.log 判 destructive): +# · 手动回滚: 旧 sha + destructive 从 versions.log 读(跳过 ROLLBACK 行,E5) +# · --auto: 读本次 .pending-deploy( <快照路径>) +# +# 数据回退主路径 = pg_restore predeploy 快照(数据 + schema 一起回,先清后恢复); +# alembic downgrade 只反向 schema 不恢复数据,仅用于可逆非破坏性调整。 +# ═══════════════════════════════════════════════════════════════════ +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") + +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; } + +# ── 参数 ── +MODE="manual" +BACKEND_SHA_ARG=""; FRONTEND_SHA_ARG="" +case "${1:-}" in + --auto) MODE="auto" ;; + "") ;; + -*) error "未知参数: $1(用法: rollback.sh [--auto | []])" ;; + *) BACKEND_SHA_ARG="$1"; FRONTEND_SHA_ARG="${2:-$1}" ;; +esac + +# ── 前置:source .env + flock ── +if [ ! -f "$REPO_ROOT/.env" ]; then + error "未找到 ${REPO_ROOT}/.env。" +fi +set -a; source "$REPO_ROOT/.env"; set +a + +exec 9>/tmp/scilit-deploy.lock +flock -n 9 || error "已有部署/回滚在进行中(/tmp/scilit-deploy.lock 被锁)。" + +# ── 解析回滚目标 ── +# 从 versions.log 取最近一条非 ROLLBACK 行(E5:跳过回滚事件,防回滚循环) +last_deploy_line() { + awk '/^DEPLOY /{line=$0} END{print line}' "$DEPLOY_DIR/versions.log" 2>/dev/null || true +} + +OLD_BACKEND_SHA=""; OLD_FRONTEND_SHA=""; DESTRUCTIVE="否"; SNAPSHOT="" + +if [ "$MODE" = "auto" ]; then + [ -f "$DEPLOY_DIR/.pending-deploy" ] || error "--auto 需存在 .pending-deploy(本次部署失败残留),未找到。" + read -r NEW_BACKEND_SHA NEW_FRONTEND_SHA DESTRUCTIVE SNAPSHOT < "$DEPLOY_DIR/.pending-deploy" + # 上一个成功版本 = versions.log 最近一条 DEPLOY 行 + LINE="$(last_deploy_line)" + [ -n "$LINE" ] || error "versions.log 无历史成功部署记录,无法确定回滚目标。" + OLD_BACKEND_SHA="$(echo "$LINE" | awk '{print $2}')" + OLD_FRONTEND_SHA="$(echo "$LINE" | awk '{print $3}')" + info "自动回滚: 本次($NEW_BACKEND_SHA, destructive=$DESTRUCTIVE) → 上一版($OLD_BACKEND_SHA)" +else + if [ -n "$BACKEND_SHA_ARG" ]; then + OLD_BACKEND_SHA="$BACKEND_SHA_ARG"; OLD_FRONTEND_SHA="$FRONTEND_SHA_ARG" + # 指定版本:destructive 从 versions.log 匹配该 sha 的最近一行;缺省按否并提示 + LINE="$(awk -v s="$OLD_BACKEND_SHA" '$2==s || $3==s{line=$0} END{print line}' "$DEPLOY_DIR/versions.log" 2>/dev/null || true)" + if [ -n "$LINE" ]; then + DESTRUCTIVE="$(echo "$LINE" | awk '{print $4}')" + else + warn "versions.log 无 $OLD_BACKEND_SHA 记录,destructive 按「否」处理(数据不回退)。如该版本含破坏性迁移,请人工确认。" + fi + else + LINE="$(last_deploy_line)" + [ -n "$LINE" ] || error "versions.log 无历史成功部署记录。" + OLD_BACKEND_SHA="$(echo "$LINE" | awk '{print $2}')" + OLD_FRONTEND_SHA="$(echo "$LINE" | awk '{print $3}')" + DESTRUCTIVE="$(echo "$LINE" | awk '{print $4}')" + info "手动回滚: 最近成功部署 $OLD_BACKEND_SHA(destructive=$DESTRUCTIVE)" + fi + # 手动 destructive 数据回退:优先本次 predeploy 快照(无则最近一份,尽力而为) + if [ "$DESTRUCTIVE" = "是" ]; then + SNAPSHOT="$(ls -t /data/backups/predeploy_*.dump 2>/dev/null | head -1 || true)" + [ -n "$SNAPSHOT" ] || error "destructive=是 但找不到 predeploy 快照(/data/backups/predeploy_*.dump),数据无法回退。" + fi +fi + +# ── 校验旧 tag 镜像存在 ── +image_exists() { docker image inspect "$1" >/dev/null 2>&1; } +if ! image_exists "scilit/backend:$OLD_BACKEND_SHA"; then + error "镜像 scilit/backend:$OLD_BACKEND_SHA 不存在(可能已被镜像治理清理)。请用 docker tag 恢复或选择其他版本。" +fi +if ! image_exists "scilit/frontend:$OLD_FRONTEND_SHA"; then + error "镜像 scilit/frontend:$OLD_FRONTEND_SHA 不存在。请用 docker tag 恢复或选择其他版本。" +fi + +# ── 数据回退(destructive=是):pg_restore 快照,先清后恢复(L)── +if [ "$DESTRUCTIVE" = "是" ]; then + if [ "$MODE" = "manual" ]; then + echo -e "${YELLOW}⚠️ 该版本含破坏性迁移,将用快照 ${SNAPSHOT} 覆盖当前数据库(pg_restore --clean)。 + 当前库中的所有数据将回退到快照时间点。确认继续?(y/N)${NC}" + read -r yn + [ "$yn" = "y" ] || [ "$yn" = "Y" ] || error "已取消。" + fi + info "停止 backend/worker(避免恢复期间写入冲突)" + "${COMPOSE[@]}" stop backend worker + info "数据回退: pg_restore --clean --if-exists --no-owner ← $SNAPSHOT" + PGPASSWORD="${PG_PASSWORD}" "${COMPOSE[@]}" exec -T postgres \ + pg_restore --clean --if-exists --no-owner -U scilit -d scilit < "$SNAPSHOT" + info "数据回退完成" +fi + +# ── 双 tag 原子切换(E2:一次 up,绝不分两次)── +info "切换: backend+frontend → $OLD_BACKEND_SHA / $OLD_FRONTEND_SHA" +BACKEND_TAG="$OLD_BACKEND_SHA" FRONTEND_TAG="$OLD_FRONTEND_SHA" \ + "${COMPOSE[@]}" up -d --no-deps backend worker frontend + +# ── 记录回滚事件 + 清理标记 ── +echo "ROLLBACK $OLD_BACKEND_SHA $OLD_FRONTEND_SHA $DESTRUCTIVE $(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" +if [ "$MODE" = "auto" ]; then + rm -f "$DEPLOY_DIR/.pending-deploy" +fi + +info "回滚完成 ✅ 当前版本: $OLD_BACKEND_SHA" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0277bbc..1ffdeae 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -111,6 +111,7 @@ services: max-file: "3" migrate: + image: scilit/backend:${BACKEND_TAG:-latest} build: ./backend networks: - scilit @@ -126,6 +127,7 @@ services: restart: "no" backend: + image: scilit/backend:${BACKEND_TAG:-latest} build: ./backend networks: - scilit @@ -185,6 +187,7 @@ services: max-file: "3" worker: + image: scilit/backend:${BACKEND_TAG:-latest} build: ./backend networks: - scilit @@ -238,6 +241,7 @@ services: max-file: "3" frontend: + image: scilit/frontend:${FRONTEND_TAG:-latest} build: context: ./frontend dockerfile: Dockerfile.prod diff --git a/docs/16-部署运维方案.md b/docs/16-部署运维方案.md index 120e837..6db1aa2 100644 --- a/docs/16-部署运维方案.md +++ b/docs/16-部署运维方案.md @@ -1,11 +1,12 @@ # 部署运维方案:生产工程化(一期 + 二期) -> **日期:** 2026-08-08(v17 更新于 2026-08-10) -> **版本:** v17.0(当前) **状态:** 一期 3 项已执行(磁盘/备份/TLS)+ 构建加速(Dockerfile 卫生)+ gitea TLS,其余待执行 +> **日期:** 2026-08-08(v18 更新于 2026-08-10) +> **版本:** v18.0(当前) **状态:** 一期 §2 deploy/deploy.sh 体系已实现(v18),见版本记录 > **关联:** [docs/10-生产部署文档.md](10-生产部署文档.md)、[docs/11-20260717部署事故分析.md](11-20260717部署事故分析.md)、[docs/12-部署实际操作记录.md](12-部署实际操作记录.md)、[docs/17-生产数据迁移.md](17-生产数据迁移.md)(24 迁移专项,2026-08-09) > > **版本记录:** > - **v17** — 2026-08-10:§7 补「服务器本地改动必须回流 gitea」环节(TLS/Caddy 改动从未提交,git-ify checkout 会丢 HTTPS——回流后服务器 git pull 才安全,与 §2 闭环) +> - **v18** — 2026-08-10:**§2 deploy/deploy.sh 体系落地(首次实现)**——① compose 四服务(backend/worker/migrate/frontend)补 `image:` + `${BACKEND_TAG}`/`${FRONTEND_TAG}` 插值(M2/M4 + 换 tag 回滚前提,build 前 export tag 则 build 产物直接打 `` 标签);② 新建 `deploy/deploy.sh`(工作区校验 → PREV_SHA(pull 前,F)→ git pull → before head → predeploy 快照 `-Fc` 全量(H)→ destructive 判定(P12 + `destructive_*`/`# DESTRUCTIVE` 双保险)→ build + 双 tag → `.pending-deploy`(N1,落部署目录)→ 多 head 预检(P15)→ 迁移 `run --no-deps --rm` 退出码门控(K)→ after head → `up -d --no-deps backend worker frontend` → 健康轮询(V6)→ versions.log + 删标记 → 镜像治理 count 制;支持 `--dry-run`;顶部 source .env(G)+ flock + 遗留标记检测(I)+ 基础设施健康门(P14/N5));③ 新建 `deploy/rollback.sh`(双入口:手动读 versions.log 跳 ROLLBACK 行(E5)/ `--auto` 读本次 `.pending-deploy`(N1 洞);destructive=是 走 `pg_restore --clean --if-exists`(L);双 tag 原子切换(E2);回滚追 ROLLBACK 行);④ 新建 `deploy/hotfix.sh`(cp + 收口提醒);⑤ .gitignore 补 `.pending-deploy`/`versions.log`;⑥ 退役根 `deploy.sh` + `deploy.env.example`(SSH 旧模型);⑦ docs/deployment-guide.md 同步。复用 migrate_prod.sh 的 `get_db_rev()` 模式(`grep -oE '[0-9a-f]{12}' | tail -1` 防容器 ID 误抓)。脚本用法见 §2 下方或 deploy/*.sh 头部 > - **v16** — 2026-08-10:§3 补批量迁移执行序 + destructive ordering(批次 1 日期收窄须与发代码同窗口);§7 补 gitea TLS 落地;§6 补构建加速落地(腾讯 pip/npm 源 + BuildKit,详见记忆 docker_build_optimization) > - **v1** — 初稿(生产工程化框架,一期 + 二期) > - **v2** — 吸收三档补强:破坏性迁移硬判定、backup.sh 上机查清、扩对盘、compose 范围澄清、挂卷后 logrotate、pre-deploy 快照留存、registry token 轮换、alembic head 采集、增强级 diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index e68335f..e50e514 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -11,8 +11,8 @@ ```bash # 1. 服务器上 clone 项目 ssh root@your-server.com -git clone https://gitee.com/your-org/scilit-oncology.git /root/scilit-oncology -cd /root/scilit-oncology +git clone https://gitee.com/your-org/scilit-oncology.git /root/scilit +cd /root/scilit # 2. 配置环境变量 cp .env.example .env @@ -27,15 +27,26 @@ curl http://localhost:8000/health curl http://localhost:80 ``` -## 使用 deploy.sh 自动化部署 +## 使用 deploy/deploy.sh 自动化部署(生产) + +> 旧根目录 `deploy.sh`(SSH 直连模型)已废弃,替换为 `deploy/` 脚本体系。 +> 完整设计见 [16-部署运维方案.md](16-部署运维方案.md) §2。 ```bash -# 1. 配置服务器连接信息 -cp deploy.env.example deploy.env -# 编辑 deploy.env,填入 SCILIT_HOST / SCILIT_USER / SCILIT_PATH +# 在服务器 /root/scilit(git 仓库 + .env 所在目录)执行 -# 2. 推送代码并部署 -source deploy.env && ./deploy.sh +# 0. 前置:基础设施已在跑 +docker compose -f docker-compose.prod.yml ps + +# 1. 预览将执行的命令(不执行任何变更) +bash deploy/deploy.sh --dry-run + +# 2. 正式部署(自动:工作区校验 → git pull → predeploy 快照 → +# 迁移 → 容器切换 → 健康轮询 → versions.log) +bash deploy/deploy.sh + +# 3. 回滚到上一成功版本 +bash deploy/rollback.sh ``` ## PostgreSQL 备份与恢复