#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml" BACKEND_ENV="${ROOT_DIR}/backend/.env" BACKUP_ROOT="${BACKUP_ROOT:-${ROOT_DIR}/backups}" HELPER_IMAGE="${BACKUP_HELPER_IMAGE:-redis:7.4-alpine}" if [[ "${BACKUP_ROOT}" != /* ]]; then BACKUP_ROOT="${ROOT_DIR}/${BACKUP_ROOT}" fi RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[1;34m' NC='\033[0m' log() { printf "${BLUE}[backup]${NC} %s\n" "$*" >&2 } log_success() { printf "${GREEN}[backup]${NC} %s\n" "$*" >&2 } log_warn() { printf "${YELLOW}[backup]${NC} %s\n" "$*" >&2 } log_error() { printf "${RED}[backup]${NC} %s\n" "$*" >&2 } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then log_error "缺少命令:$1" exit 1 fi } absolute_dir() { local dir="$1" ( cd "${dir}" pwd -P ) } show_help() { cat << EOF 用法: ./scripts/backup-prod.sh <命令> [选项] 命令: backup 创建完整生产备份 restore <备份目录> 从备份目录恢复生产数据(需要 --yes) list 列出本机备份目录 选项: --yes 确认执行破坏性恢复,只用于 restore --no-stop backup 时不主动停止 backend/caddy,适合只读低风险窗口(不推荐) -h, --help 显示帮助 环境变量: BACKUP_ROOT=/path 备份根目录,默认 ./backups BACKUP_HELPER_IMAGE=img 用于打包/还原 Docker volume 的镜像,默认 redis:7.4-alpine 备份内容: - MySQL 逻辑备份 mysql.sql - MinIO volume: minio_data.tgz - Redis volume: redis_data.tgz - Caddy 证书/配置 volume: caddy_data.tgz, caddy_config.tgz - backend/.env、deploy 配置、backend/logs - 当前 Git commit、Docker Compose 状态、goose 迁移状态 示例: ./scripts/backup-prod.sh backup ./scripts/backup-prod.sh list ./scripts/backup-prod.sh restore backups/20260618_153000 --yes EOF } compose() { local caddy_domain caddy_email admin_base_path caddy_domain="${CADDY_DOMAIN:-$(env_value CADDY_DOMAIN)}" caddy_email="${CADDY_EMAIL:-$(env_value CADDY_EMAIL)}" admin_base_path="${VITE_ADMIN_BASE_PATH:-$(env_value VITE_ADMIN_BASE_PATH)}" CADDY_DOMAIN="${caddy_domain:-backup.localhost}" \ CADDY_EMAIL="${caddy_email:-backup@example.com}" \ VITE_ADMIN_BASE_PATH="${admin_base_path:-/manage-7x9k2p}" \ docker compose --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" "$@" } require_env_file() { if [[ ! -f "${BACKEND_ENV}" ]]; then log_error "未找到 backend/.env,无法读取生产环境变量" exit 1 fi } env_value() { local key="$1" awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKEND_ENV}" } require_env() { local key="$1" local value value="$(env_value "${key}")" if [[ -z "${value}" ]]; then log_error "backend/.env 缺少或未填写 ${key}" exit 1 fi printf "%s" "${value}" } container_id() { local service="$1" local cid cid="$(compose ps -a -q "${service}" 2>/dev/null || true)" if [[ -z "${cid}" ]]; then log "创建 ${service} 容器以挂载 volume..." compose create "${service}" >/dev/null cid="$(compose ps -a -q "${service}" 2>/dev/null || true)" fi if [[ -z "${cid}" ]]; then log_error "无法找到 ${service} 容器" exit 1 fi printf "%s" "${cid}" } service_was_running() { local service="$1" local cid status cid="$(compose ps -q "${service}" 2>/dev/null || true)" if [[ -z "${cid}" ]]; then return 1 fi status="$(docker inspect -f '{{.State.Status}}' "${cid}" 2>/dev/null || true)" [[ "${status}" == "running" ]] } start_services() { if [[ "$#" -eq 0 ]]; then return fi log "启动服务:$*" compose up -d "$@" } stop_services() { if [[ "$#" -eq 0 ]]; then return fi log "停止服务:$*" compose stop "$@" } start_recorded_services() { if [[ "$#" -eq 0 ]]; then return fi start_services "$@" } wait_service_ready() { local service="$1" local label="$2" local timeout="${3:-120}" local attempt cid status log "等待 ${label} 就绪..." for ((attempt = 1; attempt <= timeout; attempt++)); do cid="$(compose ps -q "${service}" 2>/dev/null || true)" if [[ -n "${cid}" ]]; then status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${cid}" 2>/dev/null || true)" if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then log_success "${label} 已就绪" return fi fi sleep 1 done log_error "${label} 启动超时" compose logs --tail=80 "${service}" || true exit 1 } remember_running() { local service="$1" local result_array_name="$2" if service_was_running "${service}"; then eval "${result_array_name}+=(\"${service}\")" fi } tar_from_service_volume() { local service="$1" local source_dir="$2" local backup_dir="$3" local output_name="$4" local cid cid="$(container_id "${service}")" log "备份 ${service}:${source_dir} -> ${output_name}" docker run --rm \ --volumes-from "${cid}:ro" \ -v "${backup_dir}:/backup" \ "${HELPER_IMAGE}" \ sh -c "cd '${source_dir}' && tar czf '/backup/${output_name}' ." } restore_service_volume() { local service="$1" local target_dir="$2" local backup_dir="$3" local input_name="$4" local cid if [[ ! -f "${backup_dir}/${input_name}" ]]; then log_warn "跳过 ${service}:${target_dir},备份文件不存在:${input_name}" return fi cid="$(container_id "${service}")" log "还原 ${input_name} -> ${service}:${target_dir}" docker run --rm \ --volumes-from "${cid}" \ -v "${backup_dir}:/backup:ro" \ "${HELPER_IMAGE}" \ sh -c "rm -rf '${target_dir}'/* '${target_dir}'/.[!.]* '${target_dir}'/..?* 2>/dev/null || true; tar xzf '/backup/${input_name}' -C '${target_dir}'" } copy_if_exists() { local source="$1" local target="$2" if [[ -e "${source}" ]]; then cp -a "${source}" "${target}" fi } write_manifest() { local backup_dir="$1" { printf "created_at=%s\n" "$(date -Iseconds)" printf "host=%s\n" "$(hostname)" printf "git_commit=%s\n" "$(git -C "${ROOT_DIR}" rev-parse HEAD 2>/dev/null || true)" printf "git_branch=%s\n" "$(git -C "${ROOT_DIR}" branch --show-current 2>/dev/null || true)" printf "mysql_database=%s\n" "$(require_env MYSQL_DATABASE)" printf "storage_bucket=%s\n" "$(require_env STORAGE_BUCKET)" } > "${backup_dir}/manifest.env" git -C "${ROOT_DIR}" log --oneline -5 > "${backup_dir}/git-log.txt" 2>/dev/null || true compose ps > "${backup_dir}/docker-compose-ps.txt" 2>/dev/null || true compose config > "${backup_dir}/docker-compose.config.rendered.yml" 2>/dev/null || true } backup_mysql() { local backup_dir="$1" log "备份 MySQL -> mysql.sql" compose exec -T mysql sh -c \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -uroot --single-transaction --routines --triggers --events --hex-blob --default-character-set=utf8mb4 "$MYSQL_DATABASE"' \ > "${backup_dir}/mysql.sql" compose exec -T mysql sh -c 'mysql --version' > "${backup_dir}/mysql-version.txt" 2>/dev/null || true } backup_goose_status() { local backup_dir="$1" log "记录 goose 迁移状态" compose run --rm --no-deps backend /app/goose -dir /app/migrations mysql "$(goose_dsn "$(require_env MYSQL_DSN)")" status \ > "${backup_dir}/goose-status.txt" 2>&1 || true } goose_dsn() { local dsn="$1" if [[ "${dsn}" != *"multiStatements="* ]]; then if [[ "${dsn}" == *"?"* ]]; then dsn="${dsn}&multiStatements=true" else dsn="${dsn}?multiStatements=true" fi fi printf "%s" "${dsn}" } backup_files() { local backup_dir="$1" log "备份配置文件" mkdir -p "${backup_dir}/config" copy_if_exists "${BACKEND_ENV}" "${backup_dir}/config/backend.env" copy_if_exists "${COMPOSE_FILE}" "${backup_dir}/config/docker-compose.prod.yml" copy_if_exists "${ROOT_DIR}/deploy/caddy/Caddyfile" "${backup_dir}/config/Caddyfile" copy_if_exists "${ROOT_DIR}/deploy/mysql/my.cnf" "${backup_dir}/config/mysql.my.cnf" if [[ -d "${ROOT_DIR}/backend/logs" ]]; then log "备份后端日志 -> backend_logs.tgz" tar czf "${backup_dir}/backend_logs.tgz" -C "${ROOT_DIR}/backend" logs fi } restore_files() { local backup_dir="$1" local safety_dir="${ROOT_DIR}/backups/pre-restore-files-$(date +%Y%m%d_%H%M%S)" mkdir -p "${safety_dir}" log "恢复配置文件(当前文件会先备份到 ${safety_dir})" copy_if_exists "${BACKEND_ENV}" "${safety_dir}/backend.env" copy_if_exists "${COMPOSE_FILE}" "${safety_dir}/docker-compose.prod.yml" copy_if_exists "${ROOT_DIR}/deploy/caddy/Caddyfile" "${safety_dir}/Caddyfile" copy_if_exists "${ROOT_DIR}/deploy/mysql/my.cnf" "${safety_dir}/mysql.my.cnf" copy_if_exists "${backup_dir}/config/backend.env" "${BACKEND_ENV}" copy_if_exists "${backup_dir}/config/docker-compose.prod.yml" "${COMPOSE_FILE}" copy_if_exists "${backup_dir}/config/Caddyfile" "${ROOT_DIR}/deploy/caddy/Caddyfile" copy_if_exists "${backup_dir}/config/mysql.my.cnf" "${ROOT_DIR}/deploy/mysql/my.cnf" if [[ -f "${backup_dir}/backend_logs.tgz" ]]; then log "恢复后端日志" mkdir -p "${ROOT_DIR}/backend" rm -rf "${ROOT_DIR}/backend/logs" tar xzf "${backup_dir}/backend_logs.tgz" -C "${ROOT_DIR}/backend" fi } prepare_restore_env() { local backup_dir="$1" if [[ ! -f "${BACKEND_ENV}" ]]; then if [[ ! -f "${backup_dir}/config/backend.env" ]]; then log_error "当前缺少 backend/.env,备份中也没有 config/backend.env,无法恢复" exit 1 fi log_warn "当前缺少 backend/.env,先从备份恢复环境变量文件" mkdir -p "$(dirname "${BACKEND_ENV}")" cp -a "${backup_dir}/config/backend.env" "${BACKEND_ENV}" fi if [[ ! -f "${COMPOSE_FILE}" && -f "${backup_dir}/config/docker-compose.prod.yml" ]]; then log_warn "当前缺少 deploy/docker-compose.prod.yml,先从备份恢复 Compose 文件" mkdir -p "$(dirname "${COMPOSE_FILE}")" cp -a "${backup_dir}/config/docker-compose.prod.yml" "${COMPOSE_FILE}" fi require_env_file } restore_mysql() { local backup_dir="$1" local database database="$(require_env MYSQL_DATABASE)" if [[ ! "${database}" =~ ^[A-Za-z0-9_]+$ ]]; then log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${database}" exit 1 fi if [[ ! -f "${backup_dir}/mysql.sql" ]]; then log_error "缺少 MySQL 备份文件:${backup_dir}/mysql.sql" exit 1 fi start_services mysql wait_service_ready mysql "MySQL" log "重建数据库 ${database}" compose exec -T mysql sh -c \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -e "DROP DATABASE IF EXISTS \`$MYSQL_DATABASE\`; CREATE DATABASE \`$MYSQL_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"' log "导入 MySQL 备份" compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$MYSQL_DATABASE"' < "${backup_dir}/mysql.sql" } backup_all() { require_env_file need_cmd docker local no_stop="$1" local backup_dir="${BACKUP_ROOT}/$(date +%Y%m%d_%H%M%S)" local restart_services=() local backup_ok=0 backup_failure_cleanup() { local exit_code=$? trap - ERR if [[ "${no_stop}" == "0" && "${backup_ok}" != "1" ]]; then log_warn "备份失败,尝试恢复备份前运行的服务" start_recorded_services "${restart_services[@]+"${restart_services[@]}"}" || true fi exit "${exit_code}" } trap backup_failure_cleanup ERR umask 077 mkdir -p "${backup_dir}" chmod 700 "${backup_dir}" log "备份目录:${backup_dir}" if [[ "${no_stop}" == "0" ]]; then remember_running caddy restart_services remember_running backend restart_services stop_services caddy backend else log_warn "已开启 --no-stop,备份期间仍可能有写入,建议只在低风险窗口使用" fi start_services mysql wait_service_ready mysql "MySQL" backup_mysql "${backup_dir}" backup_goose_status "${backup_dir}" if [[ "${no_stop}" == "0" ]]; then remember_running minio restart_services remember_running redis restart_services stop_services minio redis caddy fi tar_from_service_volume minio /data "${backup_dir}" minio_data.tgz tar_from_service_volume redis /data "${backup_dir}" redis_data.tgz tar_from_service_volume caddy /data "${backup_dir}" caddy_data.tgz tar_from_service_volume caddy /config "${backup_dir}" caddy_config.tgz backup_files "${backup_dir}" write_manifest "${backup_dir}" if [[ "${no_stop}" == "0" ]]; then start_recorded_services "${restart_services[@]+"${restart_services[@]}"}" fi backup_ok=1 trap - ERR log_success "备份完成:${backup_dir}" log_warn "备份包含生产密钥,请离线加密保存,不要提交到 Git。" } restore_all() { need_cmd docker local backup_dir="$1" local yes="$2" local restore_ok=0 if [[ -z "${backup_dir}" || ! -d "${backup_dir}" ]]; then log_error "备份目录不存在:${backup_dir}" exit 1 fi if [[ "${yes}" != "1" ]]; then log_error "restore 会覆盖当前生产数据库、MinIO、Redis、Caddy volume 和配置文件。确认执行请追加 --yes" exit 1 fi backup_dir="$(absolute_dir "${backup_dir}")" restore_failure_cleanup() { local exit_code=$? trap - ERR if [[ "${restore_ok}" != "1" ]]; then log_warn "恢复失败,尝试启动当前配置下的服务,便于继续排查" compose up -d || true fi exit "${exit_code}" } trap restore_failure_cleanup ERR prepare_restore_env "${backup_dir}" log_warn "即将从 ${backup_dir} 恢复,当前生产数据会被覆盖。" stop_services backend caddy minio redis restore_files "${backup_dir}" restore_mysql "${backup_dir}" restore_service_volume minio /data "${backup_dir}" minio_data.tgz restore_service_volume redis /data "${backup_dir}" redis_data.tgz restore_service_volume caddy /data "${backup_dir}" caddy_data.tgz restore_service_volume caddy /config "${backup_dir}" caddy_config.tgz log "启动生产服务" compose up -d restore_ok=1 trap - ERR log_success "恢复完成。建议立刻执行健康检查并抽查图片/二维码/订单数据。" } list_backups() { if [[ ! -d "${BACKUP_ROOT}" ]]; then log_warn "备份目录不存在:${BACKUP_ROOT}" return fi find "${BACKUP_ROOT}" -mindepth 1 -maxdepth 1 -type d | sort } main() { local command="${1:-}" local no_stop=0 local yes=0 local restore_dir="" if [[ -z "${command}" || "${command}" == "-h" || "${command}" == "--help" ]]; then show_help exit 0 fi shift || true case "${command}" in backup) while [[ $# -gt 0 ]]; do case "$1" in --no-stop) no_stop=1; shift ;; -h|--help) show_help; exit 0 ;; *) log_error "未知选项:$1"; show_help; exit 1 ;; esac done backup_all "${no_stop}" ;; restore) restore_dir="${1:-}" if [[ $# -gt 0 ]]; then shift fi while [[ $# -gt 0 ]]; do case "$1" in --yes) yes=1; shift ;; -h|--help) show_help; exit 0 ;; *) log_error "未知选项:$1"; show_help; exit 1 ;; esac done restore_all "${restore_dir}" "${yes}" ;; list) list_backups ;; *) log_error "未知命令:${command}" show_help exit 1 ;; esac } main "$@"