flnxi-Wu/scripts/backup-restore.sh
2026-06-01 15:26:33 +08:00

546 lines
17 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# FLNXI customer backup, restore, and verify helper for NAS/Linux hosts.
set -euo pipefail
ACTION="backup"
PROJECT_DIR="."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env"
BACKUP_DIR="backups"
BACKUP_PATH=""
APP_SERVICE="app"
POSTGRES_SERVICE="postgres"
MINIO_SERVICE="minio"
HELPER_IMAGE="busybox:1.36"
SKIP_STOP_APP_FOR_BACKUP=0
SKIP_DATABASE_BACKUP=0
SKIP_OBJECT_STORAGE_BACKUP=0
SKIP_OBJECT_STORAGE_RESTORE=0
RESTORE_ENV=0
HEALTH_TIMEOUT_SECONDS=120
CONFIRM_RESTORE=""
RESTORE_CONFIRM_PHRASE="RESTORE_FLNXI_DATA"
info() {
printf '[INFO] %s\n' "$1"
}
ok() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1" >&2
}
fail() {
printf '[ERROR] %s\n' "$1" >&2
exit 1
}
usage() {
cat <<'EOF'
FLNXI NAS/Linux 备份、恢复和验收脚本
用法:
bash scripts/backup-restore.sh --action backup|restore|verify [选项]
常用选项:
--project-dir <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--backup-dir <dir> 备份目录名,默认 backups
--backup-path <path> 恢复时指定备份目录
--helper-image <image> 备份/恢复 MinIO 卷用的小型镜像,默认 busybox:1.36
--skip-stop-app-for-backup 备份时不暂停 app 写入
--skip-database-backup 跳过数据库备份
--skip-object-storage-backup 跳过对象存储备份
--skip-object-storage-restore 恢复时跳过对象存储
--restore-env 恢复备份中的 .env
--confirm-restore RESTORE_FLNXI_DATA 恢复确认短语
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
-h, --help 显示帮助
说明:
备份包包含 .env、数据库和客户素材,必须限权或加密保存,不能提交到 Git。
如果当前账号没有 Docker 权限,请用 sudo 运行本脚本。
EOF
}
normalize_action() {
case "$1" in
backup|Backup) printf 'backup' ;;
restore|Restore) printf 'restore' ;;
verify|Verify) printf 'verify' ;;
*) fail "未知动作:$1" ;;
esac
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="$(normalize_action "${2:?--action requires a value}")"
shift 2
;;
--project-dir)
PROJECT_DIR="${2:?--project-dir requires a value}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:?--compose-file requires a value}"
shift 2
;;
--env-file)
ENV_FILE="${2:?--env-file requires a value}"
shift 2
;;
--backup-dir)
BACKUP_DIR="${2:?--backup-dir requires a value}"
shift 2
;;
--backup-path)
BACKUP_PATH="${2:?--backup-path requires a value}"
shift 2
;;
--app-service)
APP_SERVICE="${2:?--app-service requires a value}"
shift 2
;;
--postgres-service)
POSTGRES_SERVICE="${2:?--postgres-service requires a value}"
shift 2
;;
--minio-service)
MINIO_SERVICE="${2:?--minio-service requires a value}"
shift 2
;;
--helper-image)
HELPER_IMAGE="${2:?--helper-image requires a value}"
shift 2
;;
--skip-stop-app-for-backup)
SKIP_STOP_APP_FOR_BACKUP=1
shift
;;
--skip-database-backup)
SKIP_DATABASE_BACKUP=1
shift
;;
--skip-object-storage-backup)
SKIP_OBJECT_STORAGE_BACKUP=1
shift
;;
--skip-object-storage-restore)
SKIP_OBJECT_STORAGE_RESTORE=1
shift
;;
--restore-env)
RESTORE_ENV=1
shift
;;
--confirm-restore)
CONFIRM_RESTORE="${2:?--confirm-restore requires a value}"
shift 2
;;
--health-timeout-seconds)
HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数:$1"
;;
esac
done
read_env_value() {
local key="$1"
local path="$2"
local line
if [[ ! -f "$path" ]]; then
return 0
fi
line="$(grep -E "^[[:space:]]*${key}=" "$path" | tail -n 1 || true)"
[[ -z "$line" ]] && return 0
printf '%s' "$line" | sed -E "s/^[^=]*=//;s/\r$//;s/^['\"]//;s/['\"]$//"
}
json_escape() {
printf '%s' "${1:-}" | sed -E 's/\\/\\\\/g;s/"/\\"/g'
}
is_external_database() {
local database_url host
database_url="$(read_env_value "DATABASE_URL" "$ENV_PATH")"
[[ -z "$database_url" ]] && return 1
host="$(printf '%s' "$database_url" | sed -E 's#^[a-zA-Z0-9+.-]+://[^@]*@([^/:?]+).*#\1#')"
[[ "$host" != "postgres" && "$host" != "$database_url" ]]
}
is_external_object_storage() {
local endpoint host
endpoint="$(read_env_value "S3_ENDPOINT" "$ENV_PATH")"
[[ -z "$endpoint" ]] && return 1
host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')"
[[ "$host" != "minio" ]]
}
run_docker() {
docker "$@"
}
run_compose() {
run_docker "${COMPOSE_ARGS[@]}" "$@"
}
service_container_id() {
local service="$1"
local container_id
container_id="$(run_compose ps -q "$service" | tr -d '\r')"
[[ -n "$container_id" ]] || fail "未找到服务容器:$service。请先确认服务已启动。"
printf '%s' "$container_id"
}
optional_service_container_id() {
local service="$1"
run_compose ps -q "$service" 2>/dev/null | tr -d '\r' || true
}
service_running() {
local service="$1"
local container_id status
container_id="$(optional_service_container_id "$service")"
[[ -n "$container_id" ]] || return 1
status="$(run_docker inspect "$container_id" --format '{{.State.Status}}' 2>/dev/null || true)"
[[ "$status" == "running" ]]
}
stop_app_for_backup() {
if [[ "$SKIP_STOP_APP_FOR_BACKUP" -eq 1 ]]; then
warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。"
return 1
fi
if ! service_running "$APP_SERVICE"; then
info "$APP_SERVICE 服务未运行或不存在,跳过停写入步骤。"
return 1
fi
info "进入备份维护窗口:暂时停止 $APP_SERVICE 服务,避免备份期间继续写入数据库或素材文件。"
run_compose stop "$APP_SERVICE"
return 0
}
restart_app_after_backup() {
info "备份维护窗口结束:恢复启动 $APP_SERVICE 服务。"
run_compose up -d "$APP_SERVICE"
}
save_command_output() {
local path="$1"
shift
set +e
"$@" >"$path" 2>&1
local status=$?
set -e
if [[ "$status" -ne 0 ]]; then
printf '命令执行失败,退出码:%s\n' "$status" >>"$path"
fi
}
backup_database() {
local output_path="$1"
info "备份 Compose 内置 PostgreSQL"
run_compose up -d "$POSTGRES_SERVICE"
local container_id
container_id="$(service_container_id "$POSTGRES_SERVICE")"
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges -f /tmp/flnxi-backup.sql && test -s /tmp/flnxi-backup.sql'
run_docker cp "${container_id}:/tmp/flnxi-backup.sql" "$output_path"
[[ -s "$output_path" ]] || fail "数据库备份复制失败:$output_path"
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-backup.sql'
ok "数据库备份完成:$output_path"
}
restore_database() {
local input_path="$1"
info "恢复 Compose 内置 PostgreSQL"
run_compose up -d "$POSTGRES_SERVICE"
local container_id
container_id="$(service_container_id "$POSTGRES_SERVICE")"
run_docker cp "$input_path" "${container_id}:/tmp/flnxi-restore.sql"
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql'
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-restore.sql'
ok "数据库恢复完成"
}
backup_object_storage() {
local volume_name="$1"
local output_path="$2"
local backup_dir
[[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。"
backup_dir="$(dirname "$output_path")"
info "备份 Compose 内置 MinIO 数据卷:$volume_name"
run_docker volume inspect "$volume_name" >/dev/null
run_docker run --rm -v "${volume_name}:/source:ro" -v "${backup_dir}:/backup" "$HELPER_IMAGE" sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ."
[[ -s "$output_path" ]] || fail "对象存储备份失败。请确认 helper 镜像可用:$HELPER_IMAGE"
ok "对象存储备份完成:$output_path"
}
restore_object_storage() {
local volume_name="$1"
local input_path="$2"
local backup_dir
[[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。"
backup_dir="$(dirname "$input_path")"
info "恢复 Compose 内置 MinIO 数据卷:$volume_name"
if ! run_docker volume inspect "$volume_name" >/dev/null 2>&1; then
run_docker volume create "$volume_name" >/dev/null
fi
run_docker run --rm -v "${volume_name}:/target" -v "${backup_dir}:/backup:ro" "$HELPER_IMAGE" sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz"
ok "对象存储恢复完成"
}
backup_local_directory() {
local name="$1"
local output_path="$2"
[[ -d "$PROJECT_DIR_ABS/$name" ]] || return 1
tar -czf "$output_path" -C "$PROJECT_DIR_ABS" "$name"
ok "本地目录备份完成:$name -> $output_path"
return 0
}
restore_local_directory() {
local name="$1"
local input_path="$2"
[[ -f "$input_path" ]] || return 0
local target_path before_path
target_path="$PROJECT_DIR_ABS/$name"
if [[ -d "$target_path" ]]; then
before_path="$PROJECT_DIR_ABS/$name.before-restore-$(date +%Y%m%d-%H%M%S)"
mv "$target_path" "$before_path"
ok "$name 恢复前副本:$before_path"
fi
tar -xzf "$input_path" -C "$PROJECT_DIR_ABS"
ok "本地目录恢复完成:$name"
}
wait_health() {
local health_url="$1"
local timeout="$2"
local deadline
deadline=$(( $(date +%s) + timeout ))
while [[ "$(date +%s)" -le "$deadline" ]]; do
if curl -fsS "$health_url" >/dev/null 2>&1; then
ok "/api/health 正常"
return 0
fi
sleep 5
done
fail "/api/health 在 $timeout 秒内没有通过:$health_url"
}
verify_install() {
info "检查 Compose 配置"
run_compose config --quiet
info "查看容器状态"
run_compose ps
local public_url
public_url="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")"
if [[ -z "$public_url" ]]; then
public_url="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")"
fi
if [[ -z "$public_url" ]]; then
warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。"
return 0
fi
info "检查健康接口:${public_url%/}/api/health"
wait_health "${public_url%/}/api/health" "$HEALTH_TIMEOUT_SECONDS"
}
write_manifest() {
local target_dir="$1"
local minio_volume_name="$2"
local external_db="$3"
local external_object_storage="$4"
local data_backed="$5"
local uploads_backed="$6"
local app_stopped="$7"
cat >"$target_dir/manifest.json" <<EOF
{
"createdAt": "$(date +%Y-%m-%dT%H:%M:%S)",
"projectDir": "$(json_escape "$PROJECT_DIR_ABS")",
"composeFile": "$(json_escape "$COMPOSE_PATH")",
"envFile": "$(json_escape "$ENV_PATH")",
"appImage": "$(json_escape "$(read_env_value "APP_IMAGE" "$ENV_PATH")")",
"appPublicUrl": "$(json_escape "$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")")",
"postgresService": "$(json_escape "$POSTGRES_SERVICE")",
"minioVolumeName": "$(json_escape "$minio_volume_name")",
"externalDatabase": $external_db,
"externalObjectStorage": $external_object_storage,
"localDirectories": {
"data": $data_backed,
"uploads": $uploads_backed
},
"appStoppedForConsistentBackup": $app_stopped,
"restoreConfirmPhrase": "$RESTORE_CONFIRM_PHRASE"
}
EOF
}
backup_action_body() {
local timestamp backup_root target_dir minio_volume_name external_db external_object_storage
timestamp="$(date +%Y%m%d-%H%M%S)"
backup_root="$PROJECT_DIR_ABS/$BACKUP_DIR"
target_dir="$backup_root/flnxi-backup-$timestamp"
mkdir -p "$target_dir"
cp "$COMPOSE_PATH" "$target_dir/docker-compose.prod.yml"
[[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$target_dir/.env"
[[ -f "$PROJECT_DIR_ABS/VERSION.txt" ]] && cp "$PROJECT_DIR_ABS/VERSION.txt" "$target_dir/VERSION.txt"
cat >"$target_dir/README-SENSITIVE.txt" <<'EOF'
This backup may contain .env secrets, database data, license cache, and customer materials.
Store it in a controlled location. Do not commit it to Git.
EOF
save_command_output "$target_dir/compose-config.txt" docker "${COMPOSE_ARGS[@]}" config
save_command_output "$target_dir/compose-ps.txt" docker "${COMPOSE_ARGS[@]}" ps
save_command_output "$target_dir/docker-images.txt" docker images --digests
if is_external_database; then external_db=true; else external_db=false; fi
if [[ "$SKIP_DATABASE_BACKUP" -eq 1 ]]; then
printf '已按参数跳过数据库备份。请确认外部备份已存在。\n' >"$target_dir/DATABASE_BACKUP_SKIPPED.txt"
elif [[ "$external_db" == true ]]; then
printf '检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。\n' >"$target_dir/EXTERNAL_DATABASE_BACKUP_REQUIRED.txt"
else
backup_database "$target_dir/postgres.sql"
fi
minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")"
minio_volume_name="${minio_volume_name:-flnxi-minio-data}"
if is_external_object_storage; then external_object_storage=true; else external_object_storage=false; fi
if [[ "$SKIP_OBJECT_STORAGE_BACKUP" -eq 1 ]]; then
printf '已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。\n' >"$target_dir/OBJECT_STORAGE_BACKUP_SKIPPED.txt"
elif [[ "$external_object_storage" == true ]]; then
printf '检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。\n' >"$target_dir/EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt"
else
backup_object_storage "$minio_volume_name" "$target_dir/minio-data.tar.gz"
fi
local data_backed uploads_backed
data_backed=false
uploads_backed=false
if backup_local_directory "data" "$target_dir/data.tar.gz"; then data_backed=true; fi
if backup_local_directory "uploads" "$target_dir/uploads.tar.gz"; then uploads_backed=true; fi
write_manifest "$target_dir" "$minio_volume_name" "$external_db" "$external_object_storage" "$data_backed" "$uploads_backed" "$APP_STOPPED_FOR_BACKUP"
ok "备份目录:$target_dir"
}
backup_action() {
APP_STOPPED_FOR_BACKUP=false
if stop_app_for_backup; then
APP_STOPPED_FOR_BACKUP=true
fi
set +e
backup_action_body
local status=$?
set -e
if [[ "$APP_STOPPED_FOR_BACKUP" == true ]]; then
restart_app_after_backup
if [[ "$status" -eq 0 ]]; then
verify_install
status=$?
fi
fi
return "$status"
}
restore_action() {
[[ -n "$BACKUP_PATH" ]] || fail "恢复必须传入 --backup-path。"
[[ "$CONFIRM_RESTORE" == "$RESTORE_CONFIRM_PHRASE" ]] || fail "恢复会覆盖客户数据。请显式传入 --confirm-restore $RESTORE_CONFIRM_PHRASE。"
local source_dir db_backup object_backup env_backup_path minio_volume_name
source_dir="$(cd "$BACKUP_PATH" && pwd)"
db_backup="$source_dir/postgres.sql"
object_backup="$source_dir/minio-data.tar.gz"
warn "即将恢复备份目录:$source_dir"
run_compose stop "$APP_SERVICE" || true
if [[ "$RESTORE_ENV" -eq 1 && -f "$source_dir/.env" ]]; then
env_backup_path="$PROJECT_DIR_ABS/.env.before-restore-$(date +%Y%m%d-%H%M%S)"
[[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$env_backup_path"
cp "$source_dir/.env" "$ENV_PATH"
ok ".env 已恢复,恢复前副本:$env_backup_path"
fi
if [[ -f "$db_backup" ]]; then
restore_database "$db_backup"
else
warn "备份目录没有 postgres.sql,跳过数据库恢复。"
fi
if is_external_object_storage; then
warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。"
elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 0 && -f "$object_backup" ]]; then
run_compose stop "$MINIO_SERVICE" || true
minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")"
minio_volume_name="${minio_volume_name:-flnxi-minio-data}"
restore_object_storage "$minio_volume_name" "$object_backup"
elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 1 ]]; then
warn "已按参数跳过对象存储恢复。"
else
warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。"
fi
restore_local_directory "data" "$source_dir/data.tar.gz"
restore_local_directory "uploads" "$source_dir/uploads.tar.gz"
run_compose up -d
verify_install
ok "恢复和基础验收完成"
}
command -v docker >/dev/null 2>&1 || fail "未找到 docker 命令"
docker compose version >/dev/null 2>&1 || fail "当前 Docker 不支持 compose v2,请先安装 Docker Compose V2"
command -v curl >/dev/null 2>&1 || fail "未找到 curl 命令"
command -v tar >/dev/null 2>&1 || fail "未找到 tar 命令"
PROJECT_DIR_ABS="$(cd "$PROJECT_DIR" && pwd)"
COMPOSE_PATH="$PROJECT_DIR_ABS/$COMPOSE_FILE"
ENV_PATH="$PROJECT_DIR_ABS/$ENV_FILE"
[[ -f "$COMPOSE_PATH" ]] || fail "Compose 文件不存在:$COMPOSE_PATH"
cd "$PROJECT_DIR_ABS"
COMPOSE_ARGS=(compose -f "$COMPOSE_PATH")
if [[ -f "$ENV_PATH" ]]; then
COMPOSE_ARGS+=(--env-file "$ENV_PATH")
fi
case "$ACTION" in
backup)
backup_action
;;
restore)
restore_action
;;
verify)
verify_install
;;
esac