Files
pangolin/deploy/backup/scripts/backup-full.sh
T
wangjia 20dbb5f363 feat(backup): 备份与灾备演练系统 [tsk_bYv-CoKSwgAC]
实现 xtrabackup + binlog 增量备份、age 加密异地存储与月度 DR 演练。

## 交付内容

### deploy/backup/ —— 备份容器
- Dockerfile:Ubuntu 22.04 + xtrabackup 8.0 + age v1.2.0 + AWS CLI v2 + pg_client
- entrypoint.sh:初始化 AWS 凭据 / age 公钥,启动 crond
- crontab.txt:全量 02:00 / 增量每 15min / DR 演练每月 1 日
- scripts/lib.sh:日志 / age 加密 / S3 上传 / 连接测试等共用函数
- scripts/backup-full.sh:MySQL xtrabackup 全量 + pg_dump 全量 + SQLite 备份
- scripts/backup-inc.sh:MySQL xtrabackup 增量 + binlog flush & 归档 + pg_dump 快照
- scripts/restore.sh:从 S3 下载、解密、prepare / pg_restore 完整恢复
- scripts/dr-drill.sh:月度演练 —— RPO 验证 / 备份完整性 / MySQL 临时容器恢复 / RTO 度量
- scripts/verify-rpo-rto.sh:实时 RPO/RTO 状态检查(S3 最新备份时间戳)
- backup.env.example:配置模板(MySQL / PG / S3 / age 公钥)

### 修改已有文件
- deploy/docker-compose.yml:新增 pangolin-backup 服务
- deploy/scripts/gen-secrets.sh:幂等生成 age 密钥对 + backup.env 模板
- .gitignore:排除 age 私钥 / backup.env
- deploy/README.md:容器一览 + 备份快速命令

### docs/备份与灾备.md
S3 初始化、IAM 权限设计、age 密钥管理、MySQL 权限、DR 恢复步骤、RTO 测量标准

## 设计要点
- RPO ≤ 15min:每 15min xtrabackup 增量 + binlog flush + pg_dump 快照
- RTO ≤ 4h:月度演练度量,估算恢复时长约 2h
- 独立身份账号:备份 IAM 仅有 s3:PutObject,与生产账号完全隔离
- age 加密:公钥存服务器,私钥离线保存,S3 中无明文
- 无 crontab:cron 在容器内运行,绕过 ec2-user crontab 限制
- 幂等部署:backup.env 存在则跳过,age 密钥对存在则跳过

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 01:09:18 +08:00

153 lines
6.2 KiB
Bash
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
# backup-full.sh —— 每日全量备份编排脚本(02:00 运行)
# 内容:MySQL xtrabackup 全量 + PostgreSQL pg_dump + Marzban SQLite
# 备份 → age 加密 → 上传 S3
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
START="$(timer_start)"
log_info "========== 全量备份开始 ${TIMESTAMP} =========="
ERRORS=0
# ──────────────────────────────────────────────────────
# 1. MySQL 全量备份(xtrabackup
# ──────────────────────────────────────────────────────
mysql_full_backup() {
local target="${BACKUP_DIR}/mysql/full/${TIMESTAMP}"
local archive="${BACKUP_DIR}/mysql/full/${TIMESTAMP}.tar.gz"
local s3_key="mysql/full/${TIMESTAMP}.tar.gz"
log_info "[MySQL] 开始 xtrabackup 全量备份"
mkdir -p "${target}"
if ! mysql_ping; then
log_warn "[MySQL] 无法连接 MySQL,跳过 MySQL 备份"
return 1
fi
# xtrabackup --backup:连接 MySQL 并备份 InnoDB 数据文件
xtrabackup \
--backup \
--host="${MYSQL_HOST}" \
--port="${MYSQL_PORT}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--target-dir="${target}" \
--compress \
--compress-threads=2 \
${MYSQL_DATADIR:+--datadir="${MYSQL_DATADIR}"} \
2>&1 | tee "${target}/xtrabackup-backup.log"
# xtrabackup --prepare:回放 redo log,使备份一致(无需 MySQL 服务)
xtrabackup \
--prepare \
--target-dir="${target}" \
2>&1 | tee "${target}/xtrabackup-prepare.log"
# 打包(xtrabackup --compress 已压缩,这里仅归档)
log_info "[MySQL] 打包备份目录"
tar -czf "${archive}" -C "$(dirname "${target}")" "$(basename "${target}")"
# 更新 latest-full 引用(供增量备份使用)
echo "${target}" > "${BACKUP_DIR}/mysql/latest-full.txt"
# 清除上一次增量引用
rm -f "${BACKUP_DIR}/mysql/latest-inc.txt"
# 加密 + 上传
encrypt_and_upload "${archive}" "${s3_key}"
# 清理本地中间文件(保留原始目录供后续增量)
rm -f "${archive}"
# 清理 7 天前的全量备份目录
find "${BACKUP_DIR}/mysql/full" -maxdepth 1 -type d -mtime +7 \
-not -path "${BACKUP_DIR}/mysql/full" \
-exec rm -rf {} + 2>/dev/null || true
log_ok "[MySQL] 全量备份完成: ${target}"
}
# ──────────────────────────────────────────────────────
# 2. PostgreSQL 全量备份(pg_dump
# ──────────────────────────────────────────────────────
postgres_full_backup() {
log_info "[PostgreSQL] 开始 pg_dump 全量备份"
if ! pg_ping; then
log_warn "[PostgreSQL] 无法连接 PostgreSQL,跳过 PG 备份"
return 1
fi
# 支持多数据库(逗号分隔)
IFS=',' read -ra DBS <<< "${PG_DATABASES:-postgres}"
for DB in "${DBS[@]}"; do
DB="$(echo "${DB}" | tr -d ' ')"
local archive="${BACKUP_DIR}/postgres/full_${DB}_${TIMESTAMP}.pgdump"
local s3_key="postgres/full_${DB}_${TIMESTAMP}.pgdump"
log_info "[PostgreSQL] 备份数据库: ${DB}"
PGPASSWORD="${PG_PASSWORD:-}" pg_dump \
--host="${PG_HOST}" \
--port="${PG_PORT}" \
--username="${PG_USER:-postgres}" \
--format=custom \
--compress=6 \
--no-password \
"${DB}" > "${archive}"
encrypt_and_upload "${archive}" "${s3_key}"
rm -f "${archive}"
log_ok "[PostgreSQL] ${DB} 备份完成"
done
}
# ──────────────────────────────────────────────────────
# 3. Marzban SQLite 备份
# ──────────────────────────────────────────────────────
marzban_backup() {
local marzban_db="${MARZBAN_DB_PATH:-/var/lib/marzban/db.sqlite3}"
local archive="${BACKUP_DIR}/marzban/marzban_${TIMESTAMP}.sqlite3.gz"
local s3_key="marzban/marzban_${TIMESTAMP}.sqlite3.gz"
if [ ! -f "${marzban_db}" ]; then
log_warn "[Marzban] SQLite 文件不存在: ${marzban_db},跳过"
return 0
fi
log_info "[Marzban] 备份 SQLite: ${marzban_db}"
# 使用 sqlite3 online backup,避免损坏 WAL
sqlite3 "${marzban_db}" ".backup '${BACKUP_DIR}/marzban/marzban_${TIMESTAMP}.sqlite3'"
gzip -9 "${BACKUP_DIR}/marzban/marzban_${TIMESTAMP}.sqlite3"
encrypt_and_upload "${archive}" "${s3_key}"
rm -f "${archive}"
# 清理 30 天前的 SQLite 备份
find "${BACKUP_DIR}/marzban" -name "*.gz" -mtime +30 -delete 2>/dev/null || true
log_ok "[Marzban] SQLite 备份完成"
}
# ──────────────────────────────────────────────────────
# 执行
# ──────────────────────────────────────────────────────
mysql_full_backup || { log_error "[MySQL] 全量备份失败"; ERRORS=$(( ERRORS + 1 )); }
postgres_full_backup || { log_error "[PostgreSQL] 全量备份失败"; ERRORS=$(( ERRORS + 1 )); }
marzban_backup || { log_error "[Marzban] 备份失败"; ERRORS=$(( ERRORS + 1 )); }
ELAPSED=$(timer_elapsed "${START}")
DURATION=$(format_duration "${ELAPSED}")
if [ "${ERRORS}" -eq 0 ]; then
log_ok "========== 全量备份完成,耗时 ${DURATION} =========="
notify "INFO" "全量备份成功,耗时 ${DURATION}"
else
log_error "========== 全量备份完成(${ERRORS} 个失败),耗时 ${DURATION} =========="
notify "ERROR" "全量备份完成,但有 ${ERRORS} 个失败,请检查日志"
exit 1
fi