feat(deploy): 部署改成按需重建,无 schema 变更自动跳过备份

每次部署都无条件重建 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 全通过。
This commit is contained in:
yml2213
2026-10-03 16:29:05 +08:00
parent 30ae35d3a5
commit 0b48313617
6 changed files with 555 additions and 20 deletions
+57
View File
@@ -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
+351 -17
View File
@@ -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
}