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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user