完善数据库备份恢复链路

This commit is contained in:
yml2213
2026-08-23 14:39:30 +08:00
parent 0dfc1ef0df
commit d56189fe59
10 changed files with 1535 additions and 5 deletions
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 数据库备份:postgres 容器内 pg_dump(custom 格式,自带压缩,MVCC 快照无需停机)
# → 本地暂存 → 上传阿里云 OSS;同时打包 apps/backend/data 下的平台 JSON 配置(排除日志与缓存)。
#
# 用法:
# bash deploy/backup-db.sh # 手动或 cron 调用
# bash deploy/backup-db.sh --keep 14 # 本地保留最近 14 份(默认读 .env 的 DB_BACKUP_KEEP,再默认 7)
#
# 说明:
# - 上传由 backend 镜像内的 dist/db-backup.js 完成;OSS 凭证优先读 .env 的 DB_BACKUP_OSS_*,
# 未配置时复用 STORAGE_OSS_*。备份 key 形如 backups/postgres/order_site-<时间戳>.dump。
# - OSS 端的过期删除请配置桶生命周期规则,见 docs/数据库备份与恢复.md。
# - ubuntu-deploy.sh 在执行数据库迁移前会自动调用本脚本(--skip-backup 可跳过)。
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${PROJECT_ROOT}/.env"
BACKUP_ROOT="${PROJECT_ROOT}/backups"
DB_DIR="${BACKUP_ROOT}/postgres"
CONFIG_DIR="${BACKUP_ROOT}/configs"
KEEP=""
BACKUP_PREFIX=""
log() { printf '\033[1;34m[backup]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[backup]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[backup]\033[0m %s\n' "$*" >&2; exit 1; }
parse_args() {
while (($# > 0)); do
case "$1" in
--keep)
[[ $# -ge 2 ]] || fail "--keep 需要一个正整数参数。"
KEEP="$2"
shift 2
;;
-h|--help)
sed -n '4,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
fail "未知参数:$1。执行 bash deploy/backup-db.sh --help 查看用法。"
;;
esac
done
if [[ -z "${KEEP}" ]]; then
KEEP="${DB_BACKUP_KEEP:-$(read_env_value DB_BACKUP_KEEP || true)}"
fi
KEEP="${KEEP:-7}"
[[ "${KEEP}" =~ ^[1-9][0-9]*$ ]] || fail "本地保留份数必须是正整数,当前值:${KEEP}"
BACKUP_PREFIX="${DB_BACKUP_OSS_PREFIX:-$(read_env_value DB_BACKUP_OSS_PREFIX || true)}"
BACKUP_PREFIX="${BACKUP_PREFIX%/}"
BACKUP_PREFIX="${BACKUP_PREFIX:-backups/postgres}"
[[ "${BACKUP_PREFIX}" != /* && "${BACKUP_PREFIX}" != *'..'* ]] || fail "DB_BACKUP_OSS_PREFIX 不能是绝对路径或包含 ..。"
}
read_env_value() {
local key="$1"
[[ -f "${ENV_FILE}" ]] || return 0
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}"
}
run_backup_tool() {
# 用一次性容器而不是 exec,部署过程中 backend 未运行时也能上传。
docker compose run --rm --no-deps -T backend node dist/db-backup.js "$@"
}
run_pg_dump() {
local database_url
database_url="$(read_env_value DATABASE_URL)"
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
docker compose exec -T postgres sh -c 'pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" -Fc'
else
log "DATABASE_URL 指向外部数据库,从 postgres 容器经网络直连导出"
docker compose exec -T -e DB_DUMP_URL="${database_url}" postgres sh -c 'pg_dump "$DB_DUMP_URL" -Fc'
fi
}
backup_database() {
local stamp dump_file
stamp="$(date +%Y%m%d-%H%M%S)"
dump_file="${DB_DIR}/order_site-${stamp}.dump"
log "导出数据库(pg_dump custom 格式,无需停机)"
if ! run_pg_dump > "${dump_file}"; then
rm -f "${dump_file}"
fail "pg_dump 失败。可先用 docker compose logs postgres 排查数据库状态。"
fi
[[ -s "${dump_file}" ]] || fail "pg_dump 输出为空:${dump_file}"
log "上传数据库备份到 OSS:${BACKUP_PREFIX}/$(basename "${dump_file}")"
local dump_size
dump_size="$(wc -c < "${dump_file}" | tr -d ' ')"
if ! run_backup_tool upload --key "${BACKUP_PREFIX}/$(basename "${dump_file}")" --size "${dump_size}" < "${dump_file}"; then
fail "备份上传 OSS 失败。数据库未受影响,本地已保留:${dump_file}"
fi
log "数据库备份完成:${dump_size} 字节"
}
backup_configs() {
local stamp tar_file sample
sample="$(
find "${PROJECT_ROOT}/apps/backend/data" -type f \
! -path '*/logs/*' ! -path '*/cache/*' \
! -path '*/browser-sessions/*' ! -path '*/redeem-screenshots/*' 2>/dev/null | head -n 1
)"
if [[ -z "${sample}" ]]; then
warn "apps/backend/data 下没有需要备份的配置文件,跳过配置打包"
return
fi
stamp="$(date +%Y%m%d-%H%M%S)"
tar_file="${CONFIG_DIR}/order_site-${stamp}.tar.gz"
if ! tar -czf "${tar_file}" \
--exclude='./logs' --exclude='./cache' \
--exclude='./browser-sessions' --exclude='./redeem-screenshots' \
-C "${PROJECT_ROOT}/apps/backend/data" .; then
rm -f "${tar_file}"
fail "打包 apps/backend/data 失败。"
fi
local config_prefix
config_prefix="${BACKUP_PREFIX%/postgres}/configs"
log "上传配置备份到 OSS:${config_prefix}/$(basename "${tar_file}")"
local config_size
config_size="$(wc -c < "${tar_file}" | tr -d ' ')"
if ! run_backup_tool upload --key "${config_prefix}/$(basename "${tar_file}")" --kind app-configs --size "${config_size}" < "${tar_file}"; then
fail "配置备份上传 OSS 失败。数据库备份已上传,本地配置备份已保留:${tar_file}"
fi
log "配置备份完成:${config_size} 字节"
}
prune_local() {
local dir="$1" removed
removed="$(cd "${dir}" && ls -1t order_site-*.dump order_site-*.tar.gz 2>/dev/null | tail -n +$((KEEP + 1)) || true)"
if [[ -z "${removed}" ]]; then
return
fi
while IFS= read -r file; do
rm -f -- "${file}"
log "清理本地旧备份:${file}(本地保留 ${KEEP} 份,OSS 仍由生命周期规则管理)"
done <<<"${removed}"
}
main() {
parse_args "$@"
cd "${PROJECT_ROOT}"
command -v docker >/dev/null 2>&1 || fail "未找到 docker。"
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件。"
if [[ -z "$(docker compose images backend -q 2>/dev/null)" ]]; then
fail "backend 镜像不存在,请先完成部署:bash deploy/ubuntu-deploy.sh"
fi
mkdir -p "${DB_DIR}" "${CONFIG_DIR}"
backup_database
backup_configs
prune_local "${DB_DIR}"
prune_local "${CONFIG_DIR}"
log "备份完成"
}
main "$@"
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 数据库恢复与备份验证。
#
# 用法:
# bash deploy/restore-db.sh --list # 列出 OSS 中的数据库备份(倒序)
# bash deploy/restore-db.sh --verify # 用最新备份做恢复演练(临时容器,不影响线上库)
# bash deploy/restore-db.sh --verify --key backups/postgres/order_site-xxx.dump
# bash deploy/restore-db.sh # 交互确认后,恢复最新备份到线上库
# bash deploy/restore-db.sh --key backups/postgres/order_site-xxx.dump --yes
# bash deploy/restore-db.sh --restore-configs # 同时恢复最新 data 配置备份
# bash deploy/restore-db.sh --allow-unverified # 仅兼容历史无 manifest 备份
#
# 恢复流程:下载备份(校验 sha256)→ 停 backend/web → pg_restore --clean → 执行迁移 → 重启并等待健康检查。
# 详见 docs/数据库备份与恢复.md。
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${PROJECT_ROOT}/.env"
TMP_DIR="${PROJECT_ROOT}/backups/tmp"
MODE="restore"
KEY=""
CONFIG_KEY=""
RESTORE_CONFIGS=false
ASSUME_YES="${RESTORE_CONFIRM:-}"
ALLOW_UNVERIFIED=false
BACKUP_PREFIX=""
CONFIG_PREFIX=""
RESTORE_ACTIVE=false
RESTORE_SUCCEEDED=false
BACKEND_WAS_RUNNING=false
WEB_WAS_RUNNING=false
RESTORE_TARGET=""
CONFIG_TARGET=""
VERIFY_CONTAINER=""
log() { printf '\033[1;34m[restore]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[restore]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[restore]\033[0m %s\n' "$*" >&2; exit 1; }
parse_args() {
while (($# > 0)); do
case "$1" in
--list|--verify)
[[ "${MODE}" == "restore" ]] || fail "--list 与 --verify 互斥。"
MODE="${1#--}"
shift
;;
--key)
[[ $# -ge 2 ]] || fail "--key 需要一个备份 key 参数。"
KEY="$2"
shift 2
;;
--config-key)
[[ $# -ge 2 ]] || fail "--config-key 需要一个配置备份 key 参数。"
CONFIG_KEY="$2"
RESTORE_CONFIGS=true
shift 2
;;
--restore-configs)
RESTORE_CONFIGS=true
shift
;;
--allow-unverified)
ALLOW_UNVERIFIED=true
shift
;;
--yes)
ASSUME_YES=1
shift
;;
-h|--help)
sed -n '4,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
fail "未知参数:$1。执行 bash deploy/restore-db.sh --help 查看用法。"
;;
esac
done
}
read_env_value() {
local key="$1"
[[ -f "${ENV_FILE}" ]] || return 0
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}"
}
run_backup_tool() {
docker compose run --rm --no-deps -T backend node dist/db-backup.js "$@"
}
restore_cleanup() {
local exit_code=$?
if [[ "${RESTORE_ACTIVE}" == "true" && "${RESTORE_SUCCEEDED}" != "true" && "${exit_code}" -ne 0 ]]; then
warn "恢复失败,尝试恢复恢复前正在运行的业务服务。数据库可能处于部分恢复状态,请立即检查。"
if [[ "${BACKEND_WAS_RUNNING}" == "true" ]]; then
docker compose up -d --no-deps backend >/dev/null 2>&1 || warn "backend 自动重启失败。"
fi
if [[ "${WEB_WAS_RUNNING}" == "true" ]]; then
docker compose up -d --no-deps web >/dev/null 2>&1 || warn "web 自动重启失败。"
fi
fi
[[ -n "${RESTORE_TARGET}" ]] && rm -f -- "${RESTORE_TARGET}"
[[ -n "${CONFIG_TARGET}" ]] && rm -f -- "${CONFIG_TARGET}"
[[ -n "${VERIFY_CONTAINER}" ]] && docker rm -f -v "${VERIFY_CONTAINER}" >/dev/null 2>&1 || true
trap - EXIT
exit "${exit_code}"
}
is_external_database() {
local database_url
database_url="$(read_env_value DATABASE_URL)"
[[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]
}
resolve_key() {
if [[ -n "${KEY}" ]]; then
printf '%s\n' "${KEY}"
return
fi
local latest
local latest_args=(latest --prefix "${BACKUP_PREFIX}")
[[ "${ALLOW_UNVERIFIED}" == "true" ]] && latest_args+=(--allow-unverified)
if ! latest="$(run_backup_tool "${latest_args[@]}")"; then
fail "未能获取最新备份 key。可先用 bash deploy/restore-db.sh --list 查看已有备份。"
fi
printf '%s\n' "${latest}"
}
resolve_config_key() {
if [[ -n "${CONFIG_KEY}" ]]; then
printf '%s\n' "${CONFIG_KEY}"
return
fi
local latest
latest="$(run_backup_tool list --prefix "${CONFIG_PREFIX}" | awk -F '\t' '$1 ~ /\.tar\.gz$/ { print $1; exit }' || true)"
[[ -n "${latest}" ]] || fail "未找到配置备份。可使用 --config-key 指定配置备份,或不启用 --restore-configs。"
printf '%s\n' "${latest}"
}
download_backup() {
local key="$1" target="$2" kind="${3:-postgres-dump}"
log "下载备份并校验 sha256:${key}"
local verification_args=(--key "${key}" --kind "${kind}")
[[ "${ALLOW_UNVERIFIED}" == "true" ]] && verification_args+=(--allow-unverified)
if ! run_backup_tool download "${verification_args[@]}" > "${target}"; then
rm -f "${target}"
fail "下载或校验失败:${key}"
fi
[[ -s "${target}" ]] || fail "下载内容为空:${key}"
}
run_pg_restore_stdin() {
local database_url
database_url="$(read_env_value DATABASE_URL)"
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
docker compose exec -T postgres sh -c 'pg_restore -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" --clean --if-exists --no-owner'
else
log "DATABASE_URL 指向外部数据库,从 postgres 容器经网络直连恢复"
docker compose exec -T -e DB_RESTORE_URL="${database_url}" postgres sh -c 'pg_restore --dbname="$DB_RESTORE_URL" --clean --if-exists --no-owner'
fi
}
service_is_running() {
local service="$1" container_id status
container_id="$(docker compose ps -q "${service}" 2>/dev/null || true)"
[[ -n "${container_id}" ]] || return 1
status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)"
[[ "${status}" == "running" ]]
}
restore_configs() {
local source="$1" staging="${TMP_DIR}/config-extract-$$"
log "恢复配置备份到 apps/backend/data"
while IFS= read -r entry; do
case "${entry}" in
/*|../*|*/../*|..)
fail "配置备份包含不安全路径:${entry}"
;;
esac
done < <(tar -tzf "${source}")
rm -rf -- "${staging}"
mkdir -p "${staging}"
if ! tar -xzf "${source}" --no-same-owner -C "${staging}"; then
rm -rf -- "${staging}"
fail "配置备份解包失败,未修改现有配置。"
fi
cp -a "${staging}/." "${PROJECT_ROOT}/apps/backend/data/"
rm -rf -- "${staging}"
}
restore_live_database() {
local key="$1" target="$2" config_target="${3:-}"
warn "即将用备份覆盖当前数据库:${key}"
warn "当前库中的订单、后台用户、审计日志等数据都会被备份内容替换。"
if [[ "${ASSUME_YES}" != "1" ]]; then
if [[ ! -t 0 ]]; then
fail "当前不是交互式终端。确认恢复请执行:RESTORE_CONFIRM=1 bash deploy/restore-db.sh --key ${key}"
fi
local answer
printf '请输入 RESTORE 确认恢复数据库:'
read -r answer
[[ "${answer}" == "RESTORE" ]] || fail "未确认恢复,已停止。"
fi
BACKEND_WAS_RUNNING=false
WEB_WAS_RUNNING=false
service_is_running backend && BACKEND_WAS_RUNNING=true
service_is_running web && WEB_WAS_RUNNING=true
RESTORE_ACTIVE=true
log "停止 backend/web,避免恢复期间产生写入"
docker compose stop backend web >/dev/null 2>&1 || true
if service_is_running backend || service_is_running web; then
fail "无法确认 backend/web 已停止,为避免恢复期间写入已中止。"
fi
log "pg_restore 恢复中(--clean --if-exists)"
if ! run_pg_restore_stdin < "${target}"; then
fail "pg_restore 失败。数据库处于中间状态,请根据日志处理;必要时用 --list 选择更早的备份重试。"
fi
log "执行数据库迁移(补齐备份之后新增的迁移)"
if ! docker compose run --rm --no-deps backend node dist/db/migrate.js; then
fail "迁移失败。请查看日志;数据已恢复到备份时点。"
fi
if [[ -n "${config_target}" ]]; then
restore_configs "${config_target}"
fi
log "重启 backend/web"
docker compose up -d --no-deps backend >/dev/null || fail "backend 启动失败。"
wait_backend_healthy
docker compose up -d --no-deps web >/dev/null || fail "web 启动失败。"
local health_url site_addr
site_addr="$(read_env_value CADDY_SITE_ADDR)"
health_url="${site_addr%/}/health/ready"
if curl -fsS "${health_url}" >/dev/null 2>&1; then
log "健康检查通过:${health_url}"
else
fail "健康检查未通过:${health_url},请检查 docker compose logs backend。"
fi
RESTORE_SUCCEEDED=true
log "恢复完成。建议抽查近期订单与后台登录确认数据正确。"
}
wait_backend_healthy() {
local attempt container_id status
for ((attempt = 1; attempt <= 60; attempt++)); do
container_id="$(docker compose ps -q backend 2>/dev/null || true)"
if [[ -n "${container_id}" ]]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)"
if [[ "${status}" == "healthy" ]]; then
return
fi
fi
sleep 2
done
fail "等待 backend 健康超时(120 秒),请检查 docker compose logs backend。"
}
resolve_postgres_image() {
local container_id image
container_id="$(docker compose ps -q postgres 2>/dev/null || true)"
if [[ -n "${container_id}" ]]; then
image="$(docker inspect -f '{{.Config.Image}}' "${container_id}" 2>/dev/null || true)"
if [[ -n "${image}" ]]; then
printf '%s\n' "${image}"
return
fi
fi
image="$(docker compose images postgres -q 2>/dev/null | head -n 1 || true)"
[[ -n "${image}" ]] || fail "无法确定 postgres 镜像(线上 postgres 未运行)。"
printf '%s\n' "${image}"
}
verify_backup() {
local key="$1" target="$2" image cid attempt
image="$(resolve_postgres_image)"
log "使用镜像 ${image} 启动临时验证容器(不接入任何业务网络)"
docker rm -f order-site-backup-verify >/dev/null 2>&1 || true
cid="$(docker run -d --name order-site-backup-verify -e POSTGRES_PASSWORD=verify-only "${image}")"
VERIFY_CONTAINER="order-site-backup-verify"
for ((attempt = 1; attempt <= 30; attempt++)); do
if docker exec "${cid}" pg_isready -U postgres >/dev/null 2>&1; then
break
fi
sleep 1
done
docker exec "${cid}" pg_isready -U postgres >/dev/null 2>&1 || fail "临时验证容器启动超时。"
log "在临时容器中恢复备份"
docker cp "${target}" "${cid}:/tmp/verify.dump" >/dev/null
docker exec "${cid}" createdb -U postgres order_site >/dev/null
if ! docker exec "${cid}" pg_restore -U postgres -d order_site --no-owner /tmp/verify.dump; then
fail "备份无法恢复:pg_restore 报错,该备份不可用。"
fi
log "备份可恢复。已应用迁移:"
docker exec "${cid}" psql -U postgres -d order_site -At -c 'SELECT filename FROM schema_migrations ORDER BY filename'
log "各表行数:"
docker exec "${cid}" psql -U postgres -d order_site -At -F '=' \
-c "SELECT format('SELECT %L, count(*) FROM %I;', tablename) FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename \\gexec"
log "验证通过,已清理临时容器。建议每月执行一次。"
docker rm -f -v "${VERIFY_CONTAINER}" >/dev/null 2>&1 || true
VERIFY_CONTAINER=""
}
list_backups() {
log "OSS 中的数据库备份(key/字节/时间):"
run_backup_tool list --prefix "${BACKUP_PREFIX}"
}
main() {
parse_args "$@"
cd "${PROJECT_ROOT}"
command -v docker >/dev/null 2>&1 || fail "未找到 docker。"
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件。"
BACKUP_PREFIX="${DB_BACKUP_OSS_PREFIX:-$(read_env_value DB_BACKUP_OSS_PREFIX || true)}"
BACKUP_PREFIX="${BACKUP_PREFIX%/}"
BACKUP_PREFIX="${BACKUP_PREFIX:-backups/postgres}"
[[ "${BACKUP_PREFIX}" != /* && "${BACKUP_PREFIX}" != *'..'* ]] || fail "DB_BACKUP_OSS_PREFIX 不能是绝对路径或包含 ..。"
CONFIG_PREFIX="${BACKUP_PREFIX%/postgres}/configs"
if [[ "${MODE}" == "list" ]]; then
list_backups
return
fi
mkdir -p "${TMP_DIR}"
local key target config_key
key="$(resolve_key)"
target="${TMP_DIR}/$(basename "${key}")"
RESTORE_TARGET="${target}"
download_backup "${key}" "${target}" postgres-dump
if [[ "${RESTORE_CONFIGS}" == "true" && "${MODE}" == "restore" ]]; then
config_key="$(resolve_config_key)"
CONFIG_TARGET="${TMP_DIR}/$(basename "${config_key}")"
download_backup "${config_key}" "${CONFIG_TARGET}" app-configs
fi
if [[ "${MODE}" == "verify" ]]; then
verify_backup "${key}" "${target}"
rm -f -- "${target}"
return
fi
if is_external_database; then
warn "DATABASE_URL 指向外部数据库:pg_restore 将经网络直连外部库并覆盖其数据,请确认目标正确。"
fi
restore_live_database "${key}" "${target}" "${CONFIG_TARGET:-}"
}
trap restore_cleanup EXIT
main "$@"
+64
View File
@@ -16,6 +16,8 @@ HEALTH_URL=""
READY_TIMEOUT="${READY_TIMEOUT:-120}"
RESET_DB=false
RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}"
SKIP_BACKUP="${SKIP_BACKUP:-false}"
RESET_PRE_BACKUP_DONE=false
log() {
printf '\033[1;34m[deploy]\033[0m %s\n' "$*"
@@ -38,6 +40,7 @@ print_usage() {
参数:
--reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,再按 migrations 目录全量重建(001 基线 + 全部增量)。
--skip-backup 跳过部署过程中的自动数据库备份(备份失败时确认为可接受后可用它紧急部署)。
--yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。
-h, --help 显示帮助。
@@ -47,6 +50,8 @@ print_usage() {
它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。
图片存储使用阿里云 OSS.env 的 STORAGE_OSS_*),生产编排不含 MinIO。
每次部署在执行数据库迁移前会自动备份到 OSSdeploy/backup-db.sh);
--reset-db 在删除数据卷前也会先抓一份最后备份。备份与恢复见 docs/数据库备份与恢复.md。
EOF
}
@@ -56,6 +61,9 @@ parse_args() {
--reset-db)
RESET_DB=true
;;
--skip-backup)
SKIP_BACKUP=true
;;
--yes)
RESET_DB_CONFIRM=1
;;
@@ -340,12 +348,68 @@ restart_web() {
fail "Web 启动失败,请根据上面的日志排查。"
}
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
run_scheduled_backup "执行迁移"
}
deploy_stack() {
check_compose_config
pull_base_images
build_images
backup_before_reset
reset_database
start_postgres
backup_before_migrate
run_database_migrations
restart_backend
restart_web