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