diff --git a/.gitignore b/.gitignore index f52c789..4ed8563 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ dist/ # Docker / local data .docker-data/ +backups/ .DS_Store backend/api diff --git a/scripts/backup-prod.sh b/scripts/backup-prod.sh new file mode 100755 index 0000000..2280c3b --- /dev/null +++ b/scripts/backup-prod.sh @@ -0,0 +1,501 @@ +#!/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}" + +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 +} + +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" + + 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 + + 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 + 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 "$@"