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
+3
View File
@@ -37,4 +37,7 @@ send_code/
# 服务器本地备份暂存目录(deploy/backup-db.sh)
/backups/
# 服务器上的"上次成功部署"记录(deploy/ubuntu-deploy.sh 按需重建用)
/.deploy-state
artifacts/
+8 -1
View File
@@ -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`) |
@@ -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 可强制'), '缺少强制备份的提示')
},
)
+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
}
+37 -2
View File
@@ -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
```
后端启动时自动执行未应用的增量迁移,一般不必单独跑迁移。