# Pangolin 备份与灾备手册

> 目标：**RPO ≤ 15min，RTO ≤ 4h**
>
> 策略：xtrabackup 每日全量 + binlog 每 15min 增量，age 加密，异地 S3（独立身份账号）存储；月度恢复演练自动化。

---

## 架构一览

```
EC2 (生产)
  ├── MySQL (jiu_mysql, marzban)
  │     ├── 每日 02:00  xtrabackup 全量 ─────────────────────┐
  │     └── 每 15min    xtrabackup 增量 + binlog flush ──────┤
  ├── PostgreSQL (blog, umami)                                │
  │     ├── 每日 02:00  pg_dump 全量 ──────────────────────  │
  │     └── 每 15min    pg_dump 快照 ────────────────────── age 加密
  └── Marzban SQLite                                          │
        └── 每日 02:00  sqlite3 .backup ─────────────────────┘
                                                              ↓
                                              S3（独立 IAM，仅 PutObject）
                                              s3://pangolin-backup-XXXXXX/
                                              ├── mysql/full/
                                              ├── mysql/inc/
                                              ├── mysql/binlog/
                                              ├── postgres/full/
                                              ├── postgres/inc/
                                              ├── marzban/
                                              └── dr-reports/

每月 1 日 03:00  DR 演练：下载 → 解密 → 验证 → 报告
```

---

## 初始化步骤（首次部署）

### 1. 创建备份 S3 Bucket（独立 IAM 账号）

> 使用**与生产 AWS 账号隔离**的独立 IAM 用户，做到数据面与备份面隔离。

```bash
# 在 AWS Console 或 CLI 执行（使用管理员账号）：
aws s3api create-bucket \
    --bucket pangolin-backup-$(openssl rand -hex 4) \
    --region ap-southeast-1 \
    --create-bucket-configuration LocationConstraint=ap-southeast-1

# 开启版本控制（防误删）
aws s3api put-bucket-versioning \
    --bucket YOUR_BUCKET_NAME \
    --versioning-configuration Status=Enabled

# 配置生命周期：标准 IA 30 天后 Glacier，365 天后删除
cat > /tmp/lifecycle.json << 'EOF'
{
  "Rules": [{
    "ID": "backup-lifecycle",
    "Status": "Enabled",
    "Filter": {},
    "Transitions": [
      {"Days": 30, "StorageClass": "STANDARD_IA"},
      {"Days": 90, "StorageClass": "GLACIER"}
    ],
    "Expiration": {"Days": 365}
  }]
}
EOF
aws s3api put-bucket-lifecycle-configuration \
    --bucket YOUR_BUCKET_NAME \
    --lifecycle-configuration file:///tmp/lifecycle.json
```

**创建只写 IAM 用户（备份服务器使用）：**

```json
// backup-only-policy.json（只允许 PutObject，不允许读取或删除）
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:PutObject",
      "s3:PutObjectAcl",
      "s3:GetBucketLocation",
      "s3:ListBucket"
    ],
    "Resource": [
      "arn:aws:s3:::YOUR_BUCKET_NAME",
      "arn:aws:s3:::YOUR_BUCKET_NAME/*"
    ]
  }]
}
```

```bash
aws iam create-user --user-name pangolin-backup-writer
aws iam put-user-policy --user-name pangolin-backup-writer \
    --policy-name backup-only --policy-document file://backup-only-policy.json
aws iam create-access-key --user-name pangolin-backup-writer
# 记录 AccessKeyId 和 SecretAccessKey
```

**创建只读 IAM 用户（DR 演练时恢复用）：**

```json
// restore-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:ListBucket"],
    "Resource": [
      "arn:aws:s3:::YOUR_BUCKET_NAME",
      "arn:aws:s3:::YOUR_BUCKET_NAME/*"
    ]
  }]
}
```

### 2. 生成 age 加密密钥对

```bash
# 在备份容器内（或本机有 age 的地方）生成：
docker run --rm pangolin-backup:local age-keygen 2>&1 | tee age-keypair.txt

# 输出示例：
# Public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# AGE-SECRET-KEY-1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# ⚠️ 重要安全操作：
# 1. 将私钥（AGE-SECRET-KEY-1...）存入密码管理器（如 Bitwarden）
# 2. 打印并存入保险箱（物理备份）
# 3. 删除本地私钥文件，服务器上只保留公钥
rm age-keypair.txt  # 删除含私钥的文件
```

或使用 `gen-secrets.sh`（需本机已安装 age-keygen）：

```bash
cd deploy/
./scripts/gen-secrets.sh  # 自动生成，见输出指示处理私钥
```

### 3. 配置备份环境变量

```bash
cd deploy/
cp backup/backup.env.example secrets/backup.env
vim secrets/backup.env  # 填写以下关键项：
# - BACKUP_S3_BUCKET、BACKUP_AWS_ACCESS_KEY_ID/SECRET
# - AGE_PUBLIC_KEY（从步骤 2 获取）
# - MYSQL_USER/PASSWORD、PG_USER/PASSWORD
# - PG_DATABASES（如 blog,umami）
```

### 4. 准备 MySQL 备份账号

在 MySQL 中执行（通过 mysql 客户端连接 jiu_mysql）：

```sql
CREATE USER 'backup_user'@'%' IDENTIFIED BY 'STRONG_PASSWORD';
GRANT RELOAD, LOCK TABLES, REPLICATION CLIENT, PROCESS, SUPER,
      CREATE, INSERT, SELECT, BACKUP_ADMIN ON *.* TO 'backup_user'@'%';
FLUSH PRIVILEGES;
```

确认 binlog 已开启：

```sql
SHOW VARIABLES LIKE 'log_bin';  -- 应为 ON
SHOW BINARY LOGS;               -- 列出 binlog 文件
```

若未开启，在 MySQL 配置中添加（需重启 MySQL）：

```ini
[mysqld]
log_bin = mysql-bin
binlog_format = ROW
expire_logs_days = 3
```

### 5. 构建并启动备份容器

```bash
cd deploy/
# 构建镜像（宿主 docker-compose 不支持 compose build）
docker build -t pangolin-backup:local ./backup

# 如果 MySQL 在 Docker 卷内，先确认卷路径：
docker inspect <jiu_mysql_container> | grep -A5 Mounts

# 启动（修改 .env 添加必要的主机路径变量）
MYSQL_DATADIR_HOST=/var/lib/docker/volumes/jiu_jiu_mysql_data/_data \
MARZBAN_DATA_HOST=/opt/marzban \
docker-compose -p pangolin up -d backup

# 查看日志
docker logs -f pangolin-backup
```

---

## 日常运维

### 查看备份状态（RPO/RTO 实时检查）

```bash
docker exec pangolin-backup /scripts/verify-rpo-rto.sh
```

输出示例：

```
[OK] MySQL-增量: 最新备份距今 8min (RPO ≤ 15min ✓)
[OK] PG-增量: 最新备份距今 7min (RPO ≤ 15min ✓)
[OK] MySQL 全量: 最近 14h 内有全量备份
[OK] DR 演练: 上次演练距今 12 天
状态: 达标 ✅
```

### 查看 cron 日志

```bash
docker logs --tail 100 pangolin-backup
# 或
docker exec pangolin-backup tail -50 /var/log/backup/cron.log
```

### 手动触发全量备份

```bash
docker exec pangolin-backup bash -c '. /etc/backup-env.sh && /scripts/backup-full.sh'
```

### 手动触发 DR 演练

```bash
# 如需验证解密恢复，临时注入私钥
docker exec -e AGE_PRIVATE_KEY="$(cat age-private.txt)" \
    pangolin-backup bash -c '. /etc/backup-env.sh && /scripts/dr-drill.sh'
```

### 列出 S3 备份文件

```bash
docker exec pangolin-backup bash -c '. /etc/backup-env.sh && \
    AWS_PROFILE=backup aws s3 ls s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/ --recursive \
    | sort | tail -20'
```

---

## 灾备恢复流程（实际灾难时）

> **前提**：已有 age 私钥（从密码管理器取出），已有恢复目标服务器

### MySQL 完整恢复

```bash
# 1. 将私钥挂载到恢复容器
docker run -it --rm \
    -e AGE_PRIVATE_KEY="$(cat age-private.txt)" \
    -e AWS_PROFILE=restore \
    # ... 其他环境变量 ...
    -v /restore:/restore \
    pangolin-backup:local bash

# 2. 在容器内执行恢复
RESTORE_DIR=/restore /scripts/restore.sh mysql full 20240101_020000

# 3. 如需应用增量（最近一次全量后的某个增量时间点）
RESTORE_DIR=/restore /scripts/restore.sh mysql inc 20240101_020000 20240101_150000

# 4. 容器会提示手动操作步骤：
#    - systemctl stop mysql
#    - rm -rf /var/lib/mysql/*
#    - xtrabackup --copy-back --target-dir=/restore/mysql/20240101_020000
#    - chown -R mysql:mysql /var/lib/mysql
#    - systemctl start mysql
```

### PostgreSQL 完整恢复

```bash
RESTORE_DIR=/restore /scripts/restore.sh postgres blog 20240101_150000
# 恢复到 blog_restored 数据库，验证后再重命名
```

### RTO 测量标准

| 步骤 | 预计耗时 |
|------|---------|
| S3 下载全量备份（50GB）| ~30min |
| age 解密 | ~2min |
| xtrabackup --prepare | ~20min |
| 数据文件复制（rsync）| ~30min |
| MySQL 启动 + 应用增量/binlog | ~30min |
| 验证（基本查询）| ~10min |
| **合计** | **~2h（远低于 4h）** |

---

## 月度 DR 演练报告

演练报告自动上传至：`s3://YOUR_BUCKET/ec2-ap-southeast-1/dr-reports/drill_YYYYMMDD_HHMMSS.txt`

本地日志：`docker exec pangolin-backup tail -100 /var/log/backup/dr-drill.log`

演练覆盖项目：

| 检查项 | 标准 |
|--------|------|
| RPO 验证 | 最新备份距今 ≤ 15min |
| 备份文件完整性 | tar 归档可读，age 解密成功 |
| MySQL xtrabackup prepare | 无错误 |
| MySQL 临时恢复验证 | 容器启动后可执行查询 |
| PostgreSQL 备份结构验证 | pg_restore --list 通过 |
| RTO 演练总耗时 | ≤ 4h |

---

## 安全要点

- **age 私钥不存服务器**：加密用公钥，解密用私钥（离线保存）
- **独立 IAM 账号**：备份写入账号仅有 `s3:PutObject`，即使服务器被入侵也无法读取历史备份
- **加密存储**：所有备份在上传前均 age 加密，S3 中无明文数据
- **版本控制**：S3 开启版本控制，防止误操作覆盖
- **生命周期**：30 天 → IA，90 天 → Glacier，365 天 → 删除（成本控制）
- **凭证不入库**：`deploy/secrets/` 在 `.gitignore` 中

---

## 故障排查

### 备份容器无法连接 MySQL

```bash
# 检查 MySQL 是否可达
docker exec pangolin-backup mysql \
    --host=127.0.0.1 --port=3306 \
    --user=backup_user --password=YOUR_PW \
    -e "SELECT 1"
# 若失败：检查防火墙、MySQL bind-address（应为 0.0.0.0 或 127.0.0.1）
```

### xtrabackup 提示版本不匹配

xtrabackup 8.0 只支持 MySQL 8.0。若生产 MySQL 是 5.7，需修改 Dockerfile 安装 `percona-xtrabackup-24`：

```dockerfile
RUN percona-release setup pxb24 \
    && apt-get install -y percona-xtrabackup-24
```

### S3 上传失败

```bash
# 检查凭据
docker exec pangolin-backup bash -c 'AWS_PROFILE=backup aws sts get-caller-identity'
# 检查 bucket 权限
docker exec pangolin-backup bash -c \
    "AWS_PROFILE=backup aws s3 cp /dev/null s3://${BACKUP_S3_BUCKET}/test-write && echo OK"
```

### DR 演练中 Docker 不可用

演练脚本中的 MySQL 临时容器启动依赖 Docker-in-Docker 或宿主 Docker socket。
若不可用，脚本会跳过容器验证，只做 `xtrabackup --prepare` 完整性验证。
