From 0b4831361744e29d9a7fe3171f6c7ed75201f4ae Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 16:29:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E9=83=A8=E7=BD=B2=E6=94=B9?= =?UTF-8?q?=E6=88=90=E6=8C=89=E9=9C=80=E9=87=8D=E5=BB=BA=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=20schema=20=E5=8F=98=E6=9B=B4=E8=87=AA=E5=8A=A8=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每次部署都无条件重建 backend + web 两个镜像(实测后端 3m35s、前端 2m54s), 并且每次都在迁移前 pg_dump + 传 OSS(生产 492MB dump 那一步约 12 分钟); 只改了后端也要把前端整条链重跑一遍。 改动: - 新增 deploy/lib/classify-changes.sh:把改动文件映射成"要重建哪一端", 规则保守——apps/backend/** → backend、apps/frontend/**|deploy/caddy/**|前端 Dockerfile → web、 文档/部署脚本/测试文件 → 不重建,compose 文件与未知路径 → 两端都重建。 - ubuntu-deploy.sh 用 .deploy-state(上次成功部署的 commit + .env 指纹)算 git diff, 未变化的一端不 build、也不 --force-recreate,容器保持运行;若它没在跑则用现有镜像拉起。 两端都要建时合并为一次 compose build(bake 并行)。 - 备份:改动不含 apps/backend/src/db/migrations/** 时自动跳过(--backup 强制、 --skip-backup 无条件跳过);算不出改动范围(首次使用、force push)时不自动跳过。 - compose pull 改 --policy missing:不再每次重新校验 postgres tag,避免顺手静默升级数据库镜像。 - 新增 --dry-run(打印计划后退出,含基础镜像 digest)、--all、--backend-only/--web-only; 部署结束打印各阶段耗时,回答"这次怎么比上次慢"不用靠推算。 - .deploy-state 进 .gitignore;README 与 docs/部署启动与数据库重建.md 同步。 验证:新增 7 个用例直接执行分类脚本断言"改动→重建范围",并守住"状态只在健康检查后才落盘" 等关键顺序;全量 595 个单测、typecheck、lint、check:sql 全通过。 --- .gitignore | 3 + README.md | 9 +- .../src/deploy-change-detection.test.ts | 99 +++++ deploy/lib/classify-changes.sh | 57 +++ deploy/ubuntu-deploy.sh | 368 +++++++++++++++++- docs/部署启动与数据库重建.md | 39 +- 6 files changed, 555 insertions(+), 20 deletions(-) create mode 100644 apps/backend/src/deploy-change-detection.test.ts create mode 100644 deploy/lib/classify-changes.sh diff --git a/.gitignore b/.gitignore index 75617859..4fd4740d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ send_code/ # 服务器本地备份暂存目录(deploy/backup-db.sh) /backups/ +# 服务器上的"上次成功部署"记录(deploy/ubuntu-deploy.sh 按需重建用) +/.deploy-state + artifacts/ \ No newline at end of file diff --git a/README.md b/README.md index 652aaaa4..7e07eb10 100644 --- a/README.md +++ b/README.md @@ -216,11 +216,18 @@ bash deploy/ubuntu-deploy.sh 首次若无 `.env`,脚本会从示例生成模板并退出;填好后再执行一次即可。脚本会检查 Docker、占位配置、数据目录权限,构建启动并等待健康检查。 +**按需重建**:脚本用 `.deploy-state` 记录上次成功部署的 commit 与 `.env` 指纹,再按 `git diff --name-only 上次..HEAD` 判断这次要重建哪一端 —— 只改 `apps/backend/**` 就只重建后端镜像,只改 `apps/frontend/**`(或 `deploy/caddy/**`)就只重建 Web 镜像,只改文档/部署脚本时两端镜像都不重建、容器保持运行;`.env` 变化、compose 文件变化或路径拿不准时两端都重建兜底。不需要重建的一侧既不跑 Docker 构建、也不重建容器。部署结束会打印各阶段耗时。 + 常用参数: | 参数 | 说明 | | --- | --- | -| `--skip-backup` | 跳过部署前的自动数据库备份(默认每次部署都会先 `pg_dump` 再上传 OSS,数据量较大时耗时明显)。仅代码改动、无 schema 变更时可加;涉及数据库迁移或 `--reset-db` 前建议保留备份 | +| (无) | 按 git 改动自动判断重建哪一端;本次改动不含 `apps/backend/src/db/migrations/**` 时自动跳过迁移前备份 | +| `--dry-run` | 只打印计划(改了什么、重建哪一端、要不要备份、基础镜像 digest)后退出,不做任何改动 | +| `--all` | 强制两端镜像都重建(忽略改动检测) | +| `--backend-only` / `--web-only` | 只重建指定的一端 | +| `--backup` | 强制迁移前备份(即使本次无 schema 变更) | +| `--skip-backup` | 无条件跳过部署前的数据库备份 | | `--reset-db` | 删除 PostgreSQL 数据卷后按 migrations 全量重建(执行前会自动抓取最后一份备份) | | `--yes` | 跳过 `--reset-db` 的交互确认(等价于 `RESET_DB_CONFIRM=1`) | diff --git a/apps/backend/src/deploy-change-detection.test.ts b/apps/backend/src/deploy-change-detection.test.ts new file mode 100644 index 00000000..d4323126 --- /dev/null +++ b/apps/backend/src/deploy-change-detection.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// 这些用例只检查"改动 → 要重建哪一端"的判定与部署脚本的关键分支, +// 不真正跑 docker;分类逻辑本身放在 deploy/lib/classify-changes.sh 里,方便直接 exec。 +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const REPO_ROOT = path.resolve(BACKEND_ROOT, '../..') +const CLASSIFIER = path.join(REPO_ROOT, 'deploy/lib/classify-changes.sh') +const DEPLOY_SCRIPT = path.join(REPO_ROOT, 'deploy/ubuntu-deploy.sh') + +const hasDeployScripts = fs.existsSync(CLASSIFIER) && fs.existsSync(DEPLOY_SCRIPT) + +function classify(files: string[]): string { + return execFileSync('bash', [CLASSIFIER], { + input: files.join('\n'), + encoding: 'utf8', + }).trim() +} + +test( + '改动分类:只改后端源码只重建 backend,只改前端源码只重建 web', + { skip: !hasDeployScripts }, + () => { + assert.equal(classify(['apps/backend/src/services/worker-platform/x.ts']), 'backend') + assert.equal( + classify(['apps/backend/src/db/migrations/092_x.sql', 'apps/backend/src/index.ts']), + 'backend', + ) + assert.equal(classify(['apps/frontend/src/pages/admin/x.tsx']), 'web') + }, +) + +test('改动分类:进得了镜像的部署文件要带上对应一端', { skip: !hasDeployScripts }, () => { + assert.equal(classify(['deploy/docker/backend.Dockerfile']), 'backend') + assert.equal(classify(['deploy/docker/frontend.Dockerfile']), 'web') + // Caddyfile 会被 COPY 进 web 镜像 + assert.equal(classify(['deploy/caddy/Caddyfile']), 'web') +}) + +test('改动分类:进不了镜像的改动不触发重建', { skip: !hasDeployScripts }, () => { + assert.equal(classify([]), '') + assert.equal(classify(['README.md', 'docs/部署启动与数据库重建.md']), '') + assert.equal(classify(['deploy/ubuntu-deploy.sh', 'deploy/lib/classify-changes.sh']), '') + assert.equal(classify(['deploy/docker/backend-dev.Dockerfile']), '') + assert.equal(classify(['scripts/install-git-hooks.sh', '.gitignore']), '') + // 测试文件不进 dist、生产也不跑 npm test,只改测试不该重建镜像 + assert.equal(classify(['apps/backend/src/services/x.test.ts']), '') + assert.equal(classify(['apps/frontend/src/pages/x.test.tsx']), '') +}) + +test('改动分类:拿不准的路径一律两端重建(保守兜底)', { skip: !hasDeployScripts }, () => { + assert.equal(classify(['docker-compose.yml']), 'backend web') + assert.equal(classify(['apps/backend/src/index.ts', 'apps/frontend/src/main.tsx']), 'backend web') + assert.equal(classify(['some/unknown/path.txt']), 'backend web') +}) + +test('部署脚本:按改动重建,不再无脑两端全量构建', { skip: !hasDeployScripts }, () => { + const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8') + assert.ok(source.includes('detect_changed_services'), '缺少改动检测') + assert.ok(source.includes('deploy/lib/classify-changes.sh'), '缺少改动分类脚本调用') + assert.ok(source.includes('两端镜像都不需要重建'), '缺少"无需重建"的短路') + assert.ok(source.includes('ensure_service_running'), '缺少未变化一侧的保活逻辑') + // 旧的写死双端构建必须消失,否则按需重建会被绕过 + assert.ok(!source.includes('log "构建后端镜像"'), '旧的写死后端构建仍在') + assert.ok(!source.includes('log "构建 Web 镜像"'), '旧的写死 Web 构建仍在') +}) + +test('部署脚本:基础镜像只补缺、部署状态在健康检查之后才落盘', { skip: !hasDeployScripts }, () => { + const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8') + assert.ok(source.includes('--policy missing'), 'compose pull 应使用 --policy missing') + assert.ok(source.includes('write_deploy_state'), '缺少部署状态落盘') + assert.ok(source.includes('print_timing_summary'), '缺少阶段耗时输出') + + const healthIndex = source.indexOf('wait_for_health\n') + const stateIndex = source.indexOf('write_deploy_state\n') + assert.ok(healthIndex > 0 && stateIndex > 0, '找不到健康检查/状态落盘调用') + assert.ok( + healthIndex < stateIndex, + '部署状态必须在健康检查通过后才写入,否则失败的部署会被误判为已上线', + ) +}) + +test( + '部署脚本:无 migrations 改动时自动跳过备份,但算不出改动范围时仍然备份', + { + skip: !hasDeployScripts, + }, + () => { + const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8') + assert.ok(source.includes('自动跳过迁移前备份'), '缺少无迁移改动的自动跳过') + assert.ok(source.includes('DIFF_KNOWN'), '缺少"改动范围是否已知"的判定') + assert.ok(source.includes('migrations_changed'), '缺少 migrations 改动判定') + assert.ok(source.includes('--backup 可强制'), '缺少强制备份的提示') + }, +) diff --git a/deploy/lib/classify-changes.sh b/deploy/lib/classify-changes.sh new file mode 100644 index 00000000..f98a0d62 --- /dev/null +++ b/deploy/lib/classify-changes.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# 把「本次改动涉及哪些文件」映射成「需要重建哪些镜像」。 +# +# 用法: +# printf '%s\n' "apps/backend/src/x.ts" | bash deploy/lib/classify-changes.sh +# bash deploy/lib/classify-changes.sh --paths a.txt b.txt +# +# 输出:空格分隔的服务名(backend / web),两端都不用重建时输出空行。 +# +# 规则刻意保守——只有"明确属于单端、且进不了另一侧镜像"的改动才允许只重建一端, +# 其余一律两端都重建,宁可多建一次也不允许"改了却没生效"。 +# 判断依据是各 Dockerfile 的 COPY 范围: +# backend 镜像 = apps/backend/**(排除 .dockerignore 里的 node_modules/dist/data) +# web 镜像 = apps/frontend/** + deploy/caddy/Caddyfile + +classify_changed_paths() { + local backend=0 web=0 path + local -a out=() + + # `|| [[ -n "${path}" ]]` 兜住"最后一行没有换行符"的情况(read 在 EOF 会返回非零) + while IFS= read -r path || [[ -n "${path}" ]]; do + # 去掉 git 输出里可能带的空白 + path="${path#"${path%%[![:space:]]*}"}" + path="${path%"${path##*[![:space:]]}"}" + [[ -n "${path}" ]] || continue + + case "${path}" in + # 只改测试:生产镜像里不会执行任何 *.test.ts(tsconfig.build.json 把它们排除在 dist 之外, + # 且生产不会跑 npm test),不需要为了一个测试文件重建镜像。 + apps/backend/*.test.ts | apps/backend/*.test.js) ;; + apps/frontend/*.test.ts | apps/frontend/*.test.tsx | apps/frontend/*.test.js) ;; + + # 明确的单端改动 + apps/backend/*) backend=1 ;; + apps/frontend/*) web=1 ;; + deploy/docker/*dev*.Dockerfile) ;; # 开发用镜像,与生产镜像无关 + deploy/docker/backend*.Dockerfile) backend=1 ;; + deploy/docker/frontend*.Dockerfile) web=1 ;; + deploy/caddy/*) web=1 ;; # Caddyfile 会被 COPY 进 web 镜像 + + # 进不了任何生产镜像的改动:文档、本地脚本、部署脚手架 + docs/* | *.md | LICENSE) ;; + .gitignore | .editorconfig | .prettierignore | .prettierrc.json) ;; + .vscode/* | scripts/* | .githooks/*) ;; + deploy/lib/* | deploy/*.sh) ;; + + # 其余(compose 文件、根目录杂项、未知路径)一律两端重建 + *) backend=1; web=1 ;; + esac + done + + (( backend == 1 )) && out+=("backend") + (( web == 1 )) && out+=("web") + printf '%s\n' "${out[*]:-}" +} + +classify_changed_paths diff --git a/deploy/ubuntu-deploy.sh b/deploy/ubuntu-deploy.sh index fa15c044..56a4b7af 100755 --- a/deploy/ubuntu-deploy.sh +++ b/deploy/ubuntu-deploy.sh @@ -3,7 +3,9 @@ set -Eeuo pipefail # Ubuntu 服务器一键部署脚本。 # 用法: -# bash deploy/ubuntu-deploy.sh +# bash deploy/ubuntu-deploy.sh # 按 git 改动自动判断要重建哪一端 +# bash deploy/ubuntu-deploy.sh --all # 强制两端全量重建 +# bash deploy/ubuntu-deploy.sh --dry-run # 只打印计划,不做任何改动 # bash deploy/ubuntu-deploy.sh --reset-db PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -17,7 +19,22 @@ READY_TIMEOUT="${READY_TIMEOUT:-120}" RESET_DB=false RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}" SKIP_BACKUP="${SKIP_BACKUP:-false}" +FORCE_BACKUP=false +FORCE_ALL=false +FORCE_BACKEND=false +FORCE_WEB=false +DRY_RUN=false RESET_PRE_BACKUP_DONE=false +# 上次成功部署的 commit / .env 指纹;用来算"这次到底改了什么"。 +DEPLOY_STATE_FILE="${DEPLOY_STATE_FILE:-${PROJECT_ROOT}/.deploy-state}" +BUILD_BACKEND=false +BUILD_WEB=false +CHANGED_FILES="" +CHANGE_REASON="" +DIFF_KNOWN=false +DEPLOY_STARTED_AT=${SECONDS} +TIMING_LABELS=() +TIMING_SECONDS=() log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*" @@ -36,22 +53,41 @@ print_usage() { cat <<'EOF' 用法: bash deploy/ubuntu-deploy.sh + bash deploy/ubuntu-deploy.sh --dry-run bash deploy/ubuntu-deploy.sh --reset-db 参数: + --all 强制两端镜像都重建(忽略改动检测)。 + --backend-only / --web-only + 只重建指定的一端(镜像与容器),适用于明确知道改在哪一侧时。 + --dry-run 打印本次部署计划(改了什么、重建哪一端、要不要备份)后退出,不做任何改动。 --reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,再按 migrations 目录全量重建(001 基线 + 全部增量)。 - --skip-backup 跳过部署过程中的自动数据库备份(备份失败时确认为可接受后可用它紧急部署)。 + --skip-backup 跳过本次部署的自动数据库备份。 + --backup 强制备份(即使本次改动不含数据库迁移)。 --yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。 -h, --help 显示帮助。 +按需重建: + 脚本用 .deploy-state 记录"上次成功部署的 commit + .env 指纹",再按 + `git diff --name-only 上次commit..HEAD` 决定重建哪一端: + apps/backend/**(含 migrations) → 只重建 backend + apps/frontend/** / deploy/caddy/** / 前端 Dockerfile → 只重建 web + 文档、README、部署脚本本身 → 不重建任何镜像 + compose 文件、.env 变化、未知路径 → 两端都重建(保守兜底) + 不需要重建的一端既不跑构建、也不重建容器,直接保持运行。 + +备份策略: + 默认在"执行迁移"前备份到 OSS;本次改动不含 apps/backend/src/db/migrations/** 时 + 自动跳过备份并提示(--backup 强制,--skip-backup 无条件跳过)。 + 算不出改动范围时(首次使用本脚本、force push 等)不做自动跳过,仍会备份。 + 注意: --reset-db 会删除所有 PostgreSQL 数据,包括订单、任务、后台用户、审计日志和迁移记录。 日常结构变更请新增 002_xxx.sql 等渐进迁移,不要改已应用的历史迁移文件。 它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。 图片存储使用阿里云 OSS(.env 的 STORAGE_OSS_*),生产编排不含 MinIO。 - 每次部署在执行数据库迁移前会自动备份到 OSS(deploy/backup-db.sh); - --reset-db 在删除数据卷前也会先抓一份最后备份。备份与恢复见 docs/数据库备份与恢复.md。 + 备份与恢复见 docs/数据库备份与恢复.md。 EOF } @@ -64,6 +100,21 @@ parse_args() { --skip-backup) SKIP_BACKUP=true ;; + --backup) + FORCE_BACKUP=true + ;; + --all) + FORCE_ALL=true + ;; + --backend-only) + FORCE_BACKEND=true + ;; + --web-only) + FORCE_WEB=true + ;; + --dry-run) + DRY_RUN=true + ;; --yes) RESET_DB_CONFIRM=1 ;; @@ -99,6 +150,209 @@ ensure_docker() { command -v curl >/dev/null 2>&1 || fail "未找到 curl,请先安装:sudo apt-get update && sudo apt-get install -y curl" } +# 阶段计时:每次部署结束打印各阶段耗时,回答"这次怎么比上次慢"不用靠推算。 +time_step() { + local label="$1" + shift + local started=${SECONDS} + "$@" + local elapsed=$((SECONDS - started)) + TIMING_LABELS+=("${label}") + TIMING_SECONDS+=("${elapsed}") + log "⏱ ${label}:${elapsed}s" +} + +print_timing_summary() { + local i total=0 + if [[ ${#TIMING_LABELS[@]} -eq 0 ]]; then + return + fi + log "本次部署各阶段耗时(总计 $((SECONDS - DEPLOY_STARTED_AT))s):" + for i in "${!TIMING_LABELS[@]}"; do + printf ' - %s:%ss\n' "${TIMING_LABELS[$i]}" "${TIMING_SECONDS[$i]}" + total=$((total + TIMING_SECONDS[$i])) + done + printf ' - %s:%ss\n' "已计时阶段合计" "${total}" +} + +# 生成文件指纹(.env 变了要全量重建,否则改了 DATABASE_URL 之类的部署不会生效)。 +file_sha256() { + local file="$1" + [[ -f "${file}" ]] || return 0 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${file}" | awk '{print $1}' + return + fi + shasum -a 256 "${file}" 2>/dev/null | awk '{print $1}' +} + +read_state_value() { + local key="$1" + [[ -f "${DEPLOY_STATE_FILE}" ]] || return 0 + awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' \ + "${DEPLOY_STATE_FILE}" 2>/dev/null || true +} + +write_deploy_state() { + local head_sha env_hash + head_sha="$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)" + env_hash="$(file_sha256 "${ENV_FILE}")" + { + printf 'commit=%s\n' "${head_sha}" + printf 'env_sha256=%s\n' "${env_hash}" + printf 'deployed_at=%s\n' "$(date -Iseconds)" + } >"${DEPLOY_STATE_FILE}" || warn "写入部署状态失败:${DEPLOY_STATE_FILE}" + log "已记录部署状态:${DEPLOY_STATE_FILE}(commit=${head_sha:0:12})" +} + +# 决定这次要重建哪一端:--all/--backend-only/--web-only > .deploy-state + git diff > 保守兜底。 +detect_changed_services() { + local head_sha last_sha env_hash last_env_hash services + + BUILD_BACKEND=false + BUILD_WEB=false + CHANGED_FILES="" + CHANGE_REASON="" + DIFF_KNOWN=false + + if [[ "${FORCE_BACKEND}" == "true" || "${FORCE_WEB}" == "true" ]]; then + [[ "${FORCE_BACKEND}" == "true" ]] && BUILD_BACKEND=true + [[ "${FORCE_WEB}" == "true" ]] && BUILD_WEB=true + CHANGE_REASON="命令行指定了 --backend-only/--web-only" + return + fi + + if [[ "${FORCE_ALL}" == "true" ]]; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON="命令行指定了 --all(强制全量重建)" + return + fi + + if [[ "${RESET_DB}" == "true" ]]; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON="--reset-db 需要整套服务重建" + return + fi + + head_sha="$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)" + last_sha="$(read_state_value commit)" + + if [[ -z "${head_sha}" ]]; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON="项目不是 git 仓库或读不到 HEAD,无法判断改动" + return + fi + + if [[ -z "${last_sha}" ]]; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON="没有 ${DEPLOY_STATE_FILE##*/} 记录(首次使用按需重建),先全量重建一次" + return + fi + + if ! git -C "${PROJECT_ROOT}" cat-file -e "${last_sha}^{commit}" 2>/dev/null; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON="上次部署的提交 ${last_sha:0:12} 已不在仓库中(force push?),全量重建" + return + fi + + if ! git -C "${PROJECT_ROOT}" merge-base --is-ancestor "${last_sha}" "${head_sha}" 2>/dev/null; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON="上次部署的提交 ${last_sha:0:12} 不是 HEAD 的祖先(回滚/分支切换?),全量重建" + return + fi + + CHANGED_FILES="$(git -C "${PROJECT_ROOT}" diff --name-only "${last_sha}" "${head_sha}" 2>/dev/null || true)" + DIFF_KNOWN=true + + env_hash="$(file_sha256 "${ENV_FILE}")" + last_env_hash="$(read_state_value env_sha256)" + if [[ -n "${env_hash}" && -n "${last_env_hash}" && "${env_hash}" != "${last_env_hash}" ]]; then + BUILD_BACKEND=true + BUILD_WEB=true + CHANGE_REASON=".env 与上次部署时不一致(运行时环境/构建参数可能变化),两端全量重建" + return + fi + + services="$(printf '%s\n' "${CHANGED_FILES}" | bash "${PROJECT_ROOT}/deploy/lib/classify-changes.sh")" + if [[ " ${services} " == *" backend "* ]]; then + BUILD_BACKEND=true + fi + if [[ " ${services} " == *" web "* ]]; then + BUILD_WEB=true + fi + + if [[ -z "${CHANGED_FILES}" ]]; then + CHANGE_REASON="自 ${last_sha:0:12} 以来没有代码改动" + else + CHANGE_REASON="自 ${last_sha:0:12} 以来改动 $(printf '%s\n' "${CHANGED_FILES}" | grep -c . ) 个文件" + fi +} + +migrations_changed() { + [[ -n "${CHANGED_FILES}" ]] || return 1 + printf '%s\n' "${CHANGED_FILES}" | grep -q '^apps/backend/src/db/migrations/' +} + +print_plan() { + local head_sha last_sha targets="(无)" skipped + head_sha="$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)" + last_sha="$(read_state_value commit)" + + log "部署计划:" + printf ' 上次成功部署:%s\n' "${last_sha:-(无记录)}" + printf ' 本次 HEAD :%s\n' "${head_sha:-(非 git 仓库)}" + printf ' 改动依据 :%s\n' "${CHANGE_REASON:-未判断}" + + if [[ -n "${CHANGED_FILES}" ]]; then + printf ' 改动文件(最多列 15 个):\n' + printf '%s\n' "${CHANGED_FILES}" | head -n 15 | sed 's/^/ - /' + fi + + if [[ "${BUILD_BACKEND}" == "true" || "${BUILD_WEB}" == "true" ]]; then + targets="" + [[ "${BUILD_BACKEND}" == "true" ]] && targets="${targets} backend" + [[ "${BUILD_WEB}" == "true" ]] && targets="${targets} web" + printf ' 重建镜像 :%s\n' "${targets# }" + else + printf ' 重建镜像 :无(改动进不了镜像,容器保持运行)\n' + fi + + skipped="" + [[ "${BUILD_BACKEND}" != "true" ]] && skipped="${skipped} backend" + [[ "${BUILD_WEB}" != "true" ]] && skipped="${skipped} web" + [[ -n "${skipped}" ]] && printf ' 跳过重建 :%s\n' "${skipped# }" + + if [[ "${SKIP_BACKUP}" == "true" ]]; then + printf ' 数据库备份 :跳过(--skip-backup)\n' + elif [[ "${FORCE_BACKUP}" == "true" ]]; then + printf ' 数据库备份 :执行(--backup)\n' + elif [[ "${DIFF_KNOWN}" == "true" ]] && ! migrations_changed; then + printf ' 数据库备份 :自动跳过(本次无 migrations 改动;--backup 可强制)\n' + else + printf ' 数据库备份 :执行\n' + fi +} + +# 只读地打印基础镜像 digest:下次"为什么又全量重下"时,一看就能区分是上游 tag 漂了还是缓存被清了。 +print_base_image_digests() { + local image digest + command -v docker >/dev/null 2>&1 || return 0 + for image in "$@"; do + if command -v timeout >/dev/null 2>&1; then + digest="$(timeout 20 docker buildx imagetools inspect "${image}" --format '{{.Manifest.Digest}}' 2>/dev/null || true)" + else + digest="$(docker buildx imagetools inspect "${image}" --format '{{.Manifest.Digest}}' 2>/dev/null || true)" + fi + printf ' %-46s %s\n' "${image}" "${digest:-(查询失败,可能无外网)}" + done +} + ensure_env_file() { if [[ -f "${ENV_FILE}" ]]; then return @@ -224,16 +478,29 @@ ensure_disk_space() { } pull_base_images() { - log "拉取基础镜像" + # --policy missing:只在本地没有时拉取。默认的 always 语义会在每次部署时重新校验 + # postgres 这类 tag,既多一次联网,也可能让数据库镜像在"随手一部署"时被静默升级。 + log "检查基础镜像(仅缺失时拉取)" + if docker compose pull --ignore-buildable --policy missing; then + return + fi + warn "compose pull 不支持 --policy missing,回退为默认拉取策略。" docker compose pull --ignore-buildable } build_images() { - log "构建后端镜像" - docker compose build backend || fail "后端镜像构建失败。" + local targets=() + [[ "${BUILD_BACKEND}" == "true" ]] && targets+=("backend") + [[ "${BUILD_WEB}" == "true" ]] && targets+=("web") - log "构建 Web 镜像" - docker compose build web || fail "Web 镜像构建失败。" + if [[ ${#targets[@]} -eq 0 ]]; then + log "两端镜像都不需要重建(本次改动进不了镜像)" + return + fi + + log "构建镜像:${targets[*]}(未变化的一端跳过)" + # 一次调用交给 compose bake:两端同时构建,共享的基础镜像层只解析一次。 + docker compose build "${targets[@]}" || fail "镜像构建失败:${targets[*]}" } resolve_compose_project_name() { @@ -353,6 +620,11 @@ run_database_migrations() { } restart_backend() { + if [[ "${BUILD_BACKEND}" != "true" ]]; then + ensure_service_running backend "后端" + return + fi + log "重建并启动后端" if docker compose up -d --no-deps --force-recreate backend; then wait_for_service backend "后端" @@ -364,6 +636,11 @@ restart_backend() { } restart_web() { + if [[ "${BUILD_WEB}" != "true" ]]; then + ensure_service_running web "Web" + return + fi + log "重建并启动 Web 入口" if docker compose up -d --no-deps --force-recreate --remove-orphans web; then wait_for_service web "Web" @@ -374,6 +651,31 @@ restart_web() { fail "Web 启动失败,请根据上面的日志排查。" } +# 未变化的一端:不重建容器,但如果它没在跑(服务器重启过、被手工停过)就拉起来。 +ensure_service_running() { + local service="$1" label="$2" container_id status + + container_id="$(docker compose ps -q "${service}" 2>/dev/null | head -n 1 || true)" + status="" + if [[ -n "${container_id}" ]]; then + status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)" + fi + + if [[ "${status}" == "running" ]]; then + log "${label} 本次无改动,保持运行(10s 内不重建、不中断)" + return + fi + + warn "${label} 本次无改动但当前未在运行(状态:${status:-不存在}),用现有镜像拉起" + if docker compose up -d --no-deps "${service}"; then + wait_for_service "${service}" "${label}" + return + fi + + print_diagnostics + fail "${label} 启动失败,请根据上面的日志排查。" +} + run_scheduled_backup() { local label="$1" @@ -425,20 +727,34 @@ backup_before_migrate() { log "已在重置数据库前完成最后备份,跳过重置后的空库备份。" return fi + + if [[ "${FORCE_BACKUP}" == "true" ]]; then + run_scheduled_backup "执行迁移" + return + fi + + # 无 schema 变更的部署没必要每次 pg_dump + 传 OSS(生产上这一步接近 12 分钟)。 + # 只有"确认算得出改动范围、且改动里没有 migrations"时才自动跳过; + # 首次部署、force push 这类算不出范围的情况照旧备份。 + if [[ "${DIFF_KNOWN}" == "true" ]] && ! migrations_changed; then + log "本次改动不含 apps/backend/src/db/migrations/**,自动跳过迁移前备份(--backup 可强制)" + return + fi + run_scheduled_backup "执行迁移" } deploy_stack() { - check_compose_config - pull_base_images - build_images + time_step "检查 Compose 配置" check_compose_config + time_step "检查基础镜像" pull_base_images + time_step "构建镜像" build_images backup_before_reset reset_database - start_postgres - backup_before_migrate - run_database_migrations - restart_backend - restart_web + time_step "启动 PostgreSQL" start_postgres + time_step "迁移前备份" backup_before_migrate + time_step "执行数据库迁移" run_database_migrations + time_step "启动后端" restart_backend + time_step "启动 Web 入口" restart_web } wait_for_health() { @@ -509,11 +825,29 @@ main() { confirm_reset_database ensure_data_dir ensure_disk_space + + detect_changed_services + print_plan + + if [[ "${DRY_RUN}" == "true" ]]; then + if [[ "${BUILD_BACKEND}" == "true" || "${BUILD_WEB}" == "true" ]]; then + log "基础镜像当前 digest(用于事后判断是否上游 tag 漂移导致全量重建):" + print_base_image_digests \ + "docker.m.daocloud.io/library/node:22-bookworm" \ + "docker.m.daocloud.io/library/node:22-bookworm-slim" \ + "docker.m.daocloud.io/library/caddy:2-alpine" + fi + log "(--dry-run)以上是计划,未做任何改动。" + return 0 + fi + deploy_stack wait_for_health print_applied_migrations + write_deploy_state log "部署完成" + print_timing_summary docker compose ps } diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index 2313c9d8..83ac9b80 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -178,7 +178,41 @@ DATABASE_URL=postgres://postgres:你的数据库密码@postgres:5432/order_site bash deploy/ubuntu-deploy.sh ``` -脚本会:检查 Docker、检查 `.env` 占位值、检查 `DATABASE_URL`、修正 `apps/backend/data` 权限、构建启动、等待健康检查、打印已应用迁移。 +脚本会:检查 Docker、检查 `.env` 占位值、检查 `DATABASE_URL`、修正 `apps/backend/data` 权限、按改动决定重建哪一端、启动、等待健康检查、打印已应用迁移与各阶段耗时。 + +### 按需重建(默认行为) + +脚本把「上次成功部署的 commit + `.env` 指纹」记在项目根的 `.deploy-state`(已 gitignore), +再按 `git diff --name-only 上次commit..HEAD` 判断本次要重建哪一端: + +| 改动 | 结果 | +| --- | --- | +| `apps/backend/**`(含 migrations) | 只重建 backend 镜像与容器 | +| `apps/frontend/**`、`deploy/caddy/**`、前端 Dockerfile | 只重建 web 镜像与容器 | +| `README.md`、`docs/**`、`deploy/*.sh`、`deploy/lib/**`、`scripts/**` | 不重建任何镜像,容器保持运行(只剩迁移 + 健康检查) | +| `docker-compose.yml`、`.env` 变化、未知路径、上次 commit 不在仓库里(force push/回滚) | 两端都重建(保守兜底) | + +不需要重建的一侧既不跑 Docker 构建、也不 `--force-recreate`;如果它恰好没在运行(服务器重启过), +脚本用现有镜像把它拉起来。首次在有 `--skip-backup` 之前用本脚本时没有 `.deploy-state`, +会按"全量重建一次"处理,之后即进入增量。 + +备份策略同理:本次改动不含 `apps/backend/src/db/migrations/**` 时自动跳过迁移前备份 +(生产上 `pg_dump` + 传 OSS 接近 12 分钟);`--backup` 可强制备份,`--skip-backup` 可无条件跳过, +只有"能算出改动范围"时才会自动跳过。 + +常用参数: + +| 参数 | 说明 | +| --- | --- | +| `--dry-run` | 只打印计划后退出:上次部署的 commit、改动文件、要重建哪一端、是否备份、基础镜像 digest | +| `--all` | 强制两端都重建 | +| `--backend-only` / `--web-only` | 只重建指定一端 | +| `--backup` | 强制迁移前备份 | +| `--skip-backup` | 跳过迁移前备份 | +| `--reset-db` / `--yes` | 见下文「服务器重建数据库」 | + +基础镜像也只补缺:`docker compose pull --ignore-buildable --policy missing` +(不再每次部署重新校验 postgres tag,避免顺手把数据库镜像静默升级)。 ```bash docker compose ps @@ -188,7 +222,8 @@ docker compose logs -f backend ## 服务器日常更新 ```bash -bash deploy/ubuntu-deploy.sh +cd /home/order_site && git pull +bash deploy/ubuntu-deploy.sh # 想看这次会做什么:加 --dry-run ``` 后端启动时自动执行未应用的增量迁移,一般不必单独跑迁移。