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