添加生产备份恢复脚本

This commit is contained in:
yml
2026-06-18 12:07:51 +08:00
parent 7934141ba2
commit abe512b5f9
2 changed files with 502 additions and 0 deletions
+501
View File
@@ -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 "$@"