Files
hfb_sys/scripts/backup-prod.sh
T
2026-06-18 12:12:16 +08:00

528 lines
15 KiB
Bash
Executable File
Raw 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
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 "$@"