首次真机验证按需重建时踩到:`git diff --name-only` 默认 core.quotePath=true, docs/部署说明.md 会输出成 "docs/\351\203\250...",匹配不到 docs/* 规则, 一路掉进"拿不准就两端都重建"的兜底分支——只改文档却重建了 backend + web。 修复: - 新增 classify-changes.sh --git-diff <from> <to> [repo],内部用 `git -c core.quotePath=false diff --name-only`,部署脚本改走它; - 分类时顺带剥掉可能残留的首尾引号; - 补回归用例:临时 git 仓库里提交中文文件名,断言改动列表保留中文原文、 且最终分类为空(不加 --git-diff 时该用例会失败,已实测复现)。
857 lines
28 KiB
Bash
Executable File
857 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
|
||
# Ubuntu 服务器一键部署脚本。
|
||
# 用法:
|
||
# 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)"
|
||
ENV_FILE="${PROJECT_ROOT}/.env"
|
||
ENV_EXAMPLE="${PROJECT_ROOT}/.env.server.example"
|
||
BACKEND_DATA_DIR="${PROJECT_ROOT}/apps/backend/data"
|
||
BACKEND_CONTAINER_UID=1001
|
||
BACKEND_CONTAINER_GID=1001
|
||
HEALTH_URL=""
|
||
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' "$*"
|
||
}
|
||
|
||
warn() {
|
||
printf '\033[1;33m[deploy]\033[0m %s\n' "$*"
|
||
}
|
||
|
||
fail() {
|
||
printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2
|
||
exit 1
|
||
}
|
||
|
||
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 跳过本次部署的自动数据库备份。
|
||
--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。
|
||
备份与恢复见 docs/数据库备份与恢复.md。
|
||
EOF
|
||
}
|
||
|
||
parse_args() {
|
||
while (($# > 0)); do
|
||
case "$1" in
|
||
--reset-db)
|
||
RESET_DB=true
|
||
;;
|
||
--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
|
||
;;
|
||
-h|--help)
|
||
print_usage
|
||
exit 0
|
||
;;
|
||
*)
|
||
fail "未知参数:$1。执行 bash deploy/ubuntu-deploy.sh --help 查看用法。"
|
||
;;
|
||
esac
|
||
shift
|
||
done
|
||
}
|
||
|
||
read_env_value() {
|
||
local key="$1"
|
||
awk -F= -v key="${key}" '
|
||
$0 !~ /^[[:space:]]*#/ && $1 == key {
|
||
value = substr($0, index($0, "=") + 1)
|
||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||
gsub(/^"|"$/, "", value)
|
||
gsub(/^'\''|'\''$/, "", value)
|
||
print value
|
||
exit
|
||
}
|
||
' "${ENV_FILE}"
|
||
}
|
||
|
||
ensure_docker() {
|
||
command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先在 Ubuntu 上安装 Docker Engine。"
|
||
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请安装 Docker Compose plugin。"
|
||
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
|
||
|
||
# 走 classify-changes.sh 的 --git-diff 而不是直接 git diff:
|
||
# 那里关掉了 core.quotePath,中文路径(docs/xxx.md)不会被转义成 "\351..." 而匹配不上规则。
|
||
CHANGED_FILES="$(bash "${PROJECT_ROOT}/deploy/lib/classify-changes.sh" --git-diff "${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
|
||
fi
|
||
|
||
cp "${ENV_EXAMPLE}" "${ENV_FILE}"
|
||
fail "已生成 .env,请先编辑真实域名、数据库密码、后台账号和密钥后再重新执行。"
|
||
}
|
||
|
||
validate_env_file() {
|
||
if grep -nE 'change-me|your-domain|example\.com' "${ENV_FILE}" >/tmp/order-site-env-placeholders.txt; then
|
||
cat /tmp/order-site-env-placeholders.txt >&2
|
||
fail ".env 仍包含示例占位值,请替换后再部署。"
|
||
fi
|
||
|
||
[[ "${READY_TIMEOUT}" =~ ^[1-9][0-9]*$ ]] || fail "READY_TIMEOUT 必须是正整数秒数,当前值:${READY_TIMEOUT}"
|
||
|
||
local site_addr worker_site_addr
|
||
site_addr="$(read_env_value CADDY_SITE_ADDR)"
|
||
[[ -n "${site_addr}" ]] || fail ".env 缺少 CADDY_SITE_ADDR。"
|
||
worker_site_addr="$(read_env_value WORKER_SITE_ADDR)"
|
||
[[ -n "${worker_site_addr}" ]] || fail ".env 缺少 WORKER_SITE_ADDR。"
|
||
HEALTH_URL="${site_addr%/}/health/ready"
|
||
|
||
validate_database_url_for_compose
|
||
}
|
||
|
||
validate_database_url_for_compose() {
|
||
local database_url
|
||
database_url="$(read_env_value DATABASE_URL)"
|
||
|
||
if [[ -z "${database_url}" ]]; then
|
||
return
|
||
fi
|
||
|
||
if [[ "${database_url}" == *"@localhost:"* \
|
||
|| "${database_url}" == *"@127.0.0.1:"* \
|
||
|| "${database_url}" == *"@[::1]:"* ]]; then
|
||
fail ".env 的 DATABASE_URL 当前指向本机地址。Docker 部署时后端容器内的 localhost 不是 postgres 容器,请改为 postgres 服务名或外部数据库地址。"
|
||
fi
|
||
}
|
||
|
||
validate_reset_database_target() {
|
||
local database_url
|
||
database_url="$(read_env_value DATABASE_URL)"
|
||
|
||
if [[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]; then
|
||
fail "--reset-db 只支持重置本脚本管理的 Compose PostgreSQL。当前 DATABASE_URL 未指向 postgres 服务,请手动确认外部数据库后再处理。"
|
||
fi
|
||
}
|
||
|
||
confirm_reset_database() {
|
||
if [[ "${RESET_DB}" != "true" ]]; then
|
||
return
|
||
fi
|
||
|
||
validate_reset_database_target
|
||
|
||
warn "即将重置 PostgreSQL 数据库:会删除订单、任务、后台用户、审计日志和所有迁移记录。"
|
||
warn "不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。"
|
||
|
||
if [[ "${RESET_DB_CONFIRM}" == "1" ]]; then
|
||
warn "检测到 RESET_DB_CONFIRM=1 或 --yes,跳过交互确认。"
|
||
return
|
||
fi
|
||
|
||
if [[ ! -t 0 ]]; then
|
||
fail "当前不是交互式终端。若确认要重置数据库,请执行:RESET_DB_CONFIRM=1 bash deploy/ubuntu-deploy.sh --reset-db"
|
||
fi
|
||
|
||
local answer
|
||
printf '请输入 RESET 确认重置数据库:'
|
||
read -r answer
|
||
[[ "${answer}" == "RESET" ]] || fail "未确认重置数据库,已停止。"
|
||
}
|
||
|
||
ensure_data_dir() {
|
||
mkdir -p "${BACKEND_DATA_DIR}"
|
||
|
||
if [[ "$(id -u)" == "0" ]]; then
|
||
chown -R "${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID}" "${BACKEND_DATA_DIR}"
|
||
return
|
||
fi
|
||
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
sudo chown -R "${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID}" "${BACKEND_DATA_DIR}"
|
||
return
|
||
fi
|
||
|
||
warn "未找到 sudo,跳过目录属主调整。若后端无法写入日志或配置,请执行:"
|
||
warn "sudo chown -R ${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID} ${BACKEND_DATA_DIR}"
|
||
}
|
||
|
||
check_compose_config() {
|
||
log "检查 Docker Compose 配置"
|
||
docker compose config >/dev/null || fail "Docker Compose 配置校验失败。"
|
||
}
|
||
|
||
# 部署前磁盘空间预检:备份目录所在文件系统至少要有首份备份 + 现有备份量 2 倍的余量。
|
||
# 不足时直接失败,避免备份或迁移过程中磁盘写满导致数据库不可用。
|
||
ensure_disk_space() {
|
||
local backup_root="${PROJECT_ROOT}/backups"
|
||
local floor_kib=1048576 # 1GiB
|
||
local existing_kib=0 require_kib free_kib
|
||
|
||
mkdir -p "${backup_root}" 2>/dev/null || true
|
||
|
||
if [[ -d "${backup_root}" ]]; then
|
||
existing_kib="$(du -sk "${backup_root}" 2>/dev/null | awk '{print $1}' || true)"
|
||
existing_kib="${existing_kib:-0}"
|
||
fi
|
||
|
||
require_kib=$((existing_kib * 2))
|
||
[[ ${require_kib} -lt ${floor_kib} ]] && require_kib=${floor_kib}
|
||
|
||
free_kib="$(df -Pk "${PROJECT_ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||
free_kib="${free_kib:-0}"
|
||
|
||
log "磁盘空间预检:可用 ${free_kib} KiB,备份目录已占用 ${existing_kib} KiB,要求至少 ${require_kib} KiB"
|
||
if [[ ${free_kib} -lt ${require_kib} ]]; then
|
||
fail "磁盘可用空间不足(可用 ${free_kib} KiB,需 ≥ ${require_kib} KiB)。请清理磁盘或调小 DB_BACKUP_KEEP 后重试。"
|
||
fi
|
||
}
|
||
|
||
pull_base_images() {
|
||
# --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() {
|
||
local targets=()
|
||
[[ "${BUILD_BACKEND}" == "true" ]] && targets+=("backend")
|
||
[[ "${BUILD_WEB}" == "true" ]] && targets+=("web")
|
||
|
||
if [[ ${#targets[@]} -eq 0 ]]; then
|
||
log "两端镜像都不需要重建(本次改动进不了镜像)"
|
||
return
|
||
fi
|
||
|
||
log "构建镜像:${targets[*]}(未变化的一端跳过)"
|
||
# 一次调用交给 compose bake:两端同时构建,共享的基础镜像层只解析一次。
|
||
docker compose build "${targets[@]}" || fail "镜像构建失败:${targets[*]}"
|
||
}
|
||
|
||
resolve_compose_project_name() {
|
||
local project_name
|
||
project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)"
|
||
|
||
if [[ -z "${project_name}" ]]; then
|
||
project_name="$(basename "${PROJECT_ROOT}")"
|
||
fi
|
||
|
||
printf '%s\n' "${project_name}"
|
||
}
|
||
|
||
resolve_postgres_volume_name() {
|
||
local container_id volume_name project_name
|
||
|
||
container_id="$(docker compose ps -aq postgres 2>/dev/null | head -n 1 || true)"
|
||
if [[ -n "${container_id}" ]]; then
|
||
volume_name="$(
|
||
docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' "${container_id}" 2>/dev/null || true
|
||
)"
|
||
if [[ -n "${volume_name}" ]]; then
|
||
printf '%s\n' "${volume_name}"
|
||
return
|
||
fi
|
||
fi
|
||
|
||
volume_name="$(
|
||
docker compose volumes 2>/dev/null \
|
||
| awk '$2 ~ /(^|_)postgres_data$/ { print $2; exit }' || true
|
||
)"
|
||
if [[ -n "${volume_name}" ]]; then
|
||
printf '%s\n' "${volume_name}"
|
||
return
|
||
fi
|
||
|
||
project_name="$(resolve_compose_project_name)"
|
||
printf '%s_postgres_data\n' "${project_name}"
|
||
}
|
||
|
||
reset_database() {
|
||
if [[ "${RESET_DB}" != "true" ]]; then
|
||
return
|
||
fi
|
||
|
||
local volume_name
|
||
volume_name="$(resolve_postgres_volume_name)"
|
||
|
||
warn "开始重置数据库,将删除 Docker volume:${volume_name}"
|
||
docker compose stop web backend postgres >/dev/null 2>&1 || true
|
||
docker compose rm -f -s postgres >/dev/null 2>&1 || true
|
||
|
||
if docker volume inspect "${volume_name}" >/dev/null 2>&1; then
|
||
docker volume rm "${volume_name}" >/dev/null || fail "删除 PostgreSQL 数据卷失败:${volume_name}"
|
||
log "已删除 PostgreSQL 数据卷:${volume_name}"
|
||
return
|
||
fi
|
||
|
||
warn "未找到 PostgreSQL 数据卷:${volume_name},将按空库继续部署。"
|
||
}
|
||
|
||
wait_for_service() {
|
||
local service="$1"
|
||
local label="$2"
|
||
local attempt container_id status health_status
|
||
|
||
log "等待 ${label} 就绪"
|
||
|
||
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
||
container_id="$(docker compose ps -q "${service}" 2>/dev/null || true)"
|
||
if [[ -n "${container_id}" ]]; then
|
||
status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)"
|
||
health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)"
|
||
|
||
if [[ -n "${health_status}" && "${health_status}" == "healthy" ]]; then
|
||
log "${label} 已就绪"
|
||
return
|
||
fi
|
||
|
||
if [[ -z "${health_status}" && "${status}" == "running" ]]; then
|
||
log "${label} 已启动"
|
||
return
|
||
fi
|
||
|
||
if [[ "${status}" == "exited" || "${status}" == "dead" ]]; then
|
||
break
|
||
fi
|
||
fi
|
||
|
||
sleep 1
|
||
done
|
||
|
||
print_diagnostics
|
||
fail "${label} 启动超时,请根据上面的日志排查。"
|
||
}
|
||
|
||
start_postgres() {
|
||
log "启动 PostgreSQL"
|
||
if docker compose up -d --no-deps postgres; then
|
||
wait_for_service postgres "PostgreSQL"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "PostgreSQL 启动失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
run_database_migrations() {
|
||
log "执行数据库迁移"
|
||
if docker compose run --rm --no-deps backend node dist/db/migrate.js; then
|
||
log "数据库迁移完成"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "数据库迁移失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
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 "后端"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "后端启动失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
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"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
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"
|
||
|
||
if [[ "${SKIP_BACKUP}" == "true" ]]; then
|
||
warn "已指定 --skip-backup,跳过${label}前的数据库备份。"
|
||
return
|
||
fi
|
||
|
||
log "${label}前自动备份数据库到 OSS"
|
||
if bash "${PROJECT_ROOT}/deploy/backup-db.sh"; then
|
||
log "${label}前备份完成"
|
||
return
|
||
fi
|
||
|
||
fail "${label}前备份失败,已停止部署。确认为可接受的失败后可用 --skip-backup 继续。"
|
||
}
|
||
|
||
backup_before_reset() {
|
||
if [[ "${RESET_DB}" != "true" ]]; then
|
||
return
|
||
fi
|
||
if [[ "${SKIP_BACKUP}" == "true" ]]; then
|
||
warn "已指定 --skip-backup,跳过删除数据卷前的最后一份备份。"
|
||
return
|
||
fi
|
||
|
||
local container_id status
|
||
container_id="$(docker compose ps -q postgres 2>/dev/null | head -n 1 || true)"
|
||
status=""
|
||
[[ -n "${container_id}" ]] && status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)"
|
||
if [[ "${status}" != "running" ]]; then
|
||
local volume_name
|
||
volume_name="$(resolve_postgres_volume_name)"
|
||
if docker volume inspect "${volume_name}" >/dev/null 2>&1; then
|
||
warn "检测到已有 PostgreSQL 数据卷但容器未运行,先启动数据库以完成重置前备份。"
|
||
start_postgres
|
||
else
|
||
warn "未检测到已有 PostgreSQL 数据卷,跳过重置前备份(首次部署属正常现象)。"
|
||
return
|
||
fi
|
||
fi
|
||
|
||
run_scheduled_backup "重置数据库"
|
||
RESET_PRE_BACKUP_DONE=true
|
||
}
|
||
|
||
backup_before_migrate() {
|
||
if [[ "${RESET_PRE_BACKUP_DONE}" == "true" ]]; then
|
||
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() {
|
||
time_step "检查 Compose 配置" check_compose_config
|
||
time_step "检查基础镜像" pull_base_images
|
||
time_step "构建镜像" build_images
|
||
backup_before_reset
|
||
reset_database
|
||
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() {
|
||
log "等待服务健康检查:${HEALTH_URL}"
|
||
|
||
for _ in $(seq 1 "${READY_TIMEOUT}"); do
|
||
if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then
|
||
log "服务已就绪"
|
||
return
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
print_diagnostics
|
||
fail "部署已启动但健康检查未通过,请根据日志排查。"
|
||
}
|
||
|
||
print_applied_migrations() {
|
||
log "已应用数据库迁移"
|
||
|
||
local migrations
|
||
if migrations="$(docker compose exec -T backend node --input-type=module -e '
|
||
const { query, closeDb } = await import("./dist/db/client.js");
|
||
try {
|
||
const result = await query("SELECT filename FROM schema_migrations ORDER BY filename");
|
||
console.log(result.rows.map((row) => row.filename).join("\n") || "(无迁移记录)");
|
||
} finally {
|
||
await closeDb();
|
||
}
|
||
' 2>&1)"; then
|
||
printf '%s\n' "${migrations}"
|
||
return
|
||
fi
|
||
|
||
warn "读取迁移记录失败:"
|
||
printf '%s\n' "${migrations}"
|
||
}
|
||
|
||
print_diagnostics() {
|
||
warn "当前容器状态:"
|
||
docker compose ps || true
|
||
|
||
warn "后端最近日志:"
|
||
local backend_logs
|
||
backend_logs="$(docker compose logs --tail=120 backend 2>&1 || true)"
|
||
printf '%s\n' "${backend_logs}"
|
||
|
||
if printf '%s\n' "${backend_logs}" | grep -q 'password authentication failed for user "postgres"'; then
|
||
warn "检测到 PostgreSQL 密码认证失败。通常是 postgres_data 已用旧密码初始化,而 .env 改成了新密码。"
|
||
warn "无生产数据时可执行:RESET_DB_CONFIRM=1 bash deploy/ubuntu-deploy.sh --reset-db"
|
||
warn "需要保留数据时,请进入 postgres 容器用当前 .env 的 POSTGRES_PASSWORD 修改数据库用户密码。"
|
||
fi
|
||
|
||
warn "后端 ready 接口:"
|
||
docker compose exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true
|
||
|
||
print_applied_migrations || true
|
||
}
|
||
|
||
main() {
|
||
parse_args "$@"
|
||
|
||
cd "${PROJECT_ROOT}"
|
||
|
||
ensure_docker
|
||
ensure_env_file
|
||
validate_env_file
|
||
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
|
||
}
|
||
|
||
main "$@"
|