# 用 Btrfs 快照 + Backrest Hook 做数据库一致性备份


用 restic 或 Backrest 做文件级备份时，如果备份目录里跑着 PostgreSQL、SQLite 这类数据库，直接拷贝运行中的数据文件，恢复时可能起不来，或者更糟——静默启动成功但数据是坏的。本文的方案是用 btrfs 子卷快照把整个数据目录冻结成一致状态，再让 Backrest 在每次备份前通过 hook 自动创建快照，实现原地备份、原地恢复。

## 为什么文件级备份不可靠

### PostgreSQL：拷到的是"半套崩溃现场"

PostgreSQL 官方文档（25.2 文件系统级备份）明确：运行中直接拷贝数据目录，恢复时靠回放 WAL（预写日志）自愈，这要求 WAL 必须包含在备份里。如果备份配置的 excludes 里有 `**/pg_wal/*`，快照里就缺了 checkpoint 之后的 WAL 段，PG 恢复时会直接 PANIC（`could not locate a valid checkpoint record`），而不是"可能丢点数据"。

即使去掉 WAL 排除，restic 在线拷贝仍有固有缺陷：**逐个文件读取，各文件来自不同时刻**。读数据文件是 T1，读 WAL 是 T2（中间可能隔几分钟，PG 一直在写、checkpoint 一直在推进），快照里是"T1 的数据文件 + T2 的 WAL"——这是磁盘上从不存在的状态，PG 无法恢复。

### 页撕裂与静默损坏

restic 读某个 8KB 页时 PG 正在写该页，可能拷到"半旧半新"的页：

- **校验和开启**（`data_checksums=on`）：启动时能检测到，报 `invalid page in block`，明确失败（换更早快照即可）
- **校验和关闭**（默认）：**静默启动成功，但该页数据是坏的**，而且没有任何报错

### SQLite WAL 模式

只拷 SQLite 主数据库文件会丢 WAL 里未 checkpoint 的最近写入；restic 交错读取主文件和 WAL 的时间点还不一致。SQLite 官方明确 cp 运行中的库不安全。

文件级备份对运行中数据库是"概率性生存"：多份快照互相兜底，但不是设计保证。一致性必须来自数据库自己的工具或文件系统快照，restic 只负责传输、去重、加密。

## 方案对比与选型

| 方案                         | 异地恢复方式              | 一致性               | 改动量         |
| ---------------------------- | ------------------------- | -------------------- | -------------- |
| 文件级裸拷                   | 原地，但可能 PANIC/静默坏 | 概率性               | 0              |
| pg_dump + systemd timer      | pg_restore，非原地        | 100%                 | 脚本+timer     |
| **btrfs 快照法（本文方案）** | **原地**                  | 100%（btrfs 原子性） | 数据目录迁子卷 |

如果要求**原地备份、原地恢复**——备份的就是数据目录本身，恢复时铺回原位直接启动，不碰 dump/import 那套——pg_dump 方案不合适（恢复要 pg_restore），btrfs 快照法是最直接的路径。

### 为什么 btrfs 快照能保证一致性

btrfs 快照不是"文件级备份"，而是 CoW（写时复制）元数据操作，毫秒级冻结整个子卷——快照里每一个文件（数据文件、WAL、pg_control）都来自**同一个瞬间 T0**。这是 PostgreSQL 官方认可的 "consistent snapshot" 模型：

> 快照 = PG 突然断电时的磁盘状态；WAL 先于数据落盘（write-ahead），所以快照里 WAL 一定覆盖数据文件已反映的所有事务；恢复时 PG 从 checkpoint 开始重放 WAL——这是它每次崩溃后开机都在做的事，路径完全成熟。

关键区别：restic 在线拷贝各文件时刻随机（T1/T2/T3），btrfs 快照全部 T0。PG 恢复只需要"整棵树来自同一瞬间"这一个保证。

### 固定名单快照 + 轮转（关键设计）

本地不需要堆一堆历史快照——**restic 的快照链本身就是历史**（blob 去重只存增量）。本地只需要**一个固定名字的快照**，每次备份前删掉旧的、创建新的：

```
每 3h（backrest SNAPSHOT_START hook）:
  1. btrfs subvolume delete snapshots/data-latest      # 删旧的（秒级）
  2. btrfs subvolume snapshot -r data snapshots/data-latest  # 冻结当前

backrest: 备份 /path/to/snapshots/data-latest        # 路径永远不变
  → restic 对比同一路径 → 只上传 3h 窗口内变化的块（增量）
  → 每个 restic 快照 = 当时的完整一致状态
  → 保留策略 24h + 30d + 12m = 66 个一致版本
```

历史版本完全由 restic 快照链承担，本地只留 `data-latest` 这一个"静止源"。

## 前置：把数据目录迁到独立 btrfs 子卷

`btrfs subvolume snapshot` 只能作用于子卷，如果数据目录（本文以 `~/data` 为例）只是普通目录，需要先把它迁到独立子卷。迁移要停掉所有容器，建议脚本化执行：

```bash
# 1. 停全部容器服务

# 2. 建新子卷 + 改属主 + reflink 复制（CoW，不占双倍空间）
sudo btrfs subvolume create /path/to/data.new
sudo chown $USER:$USER /path/to/data.new
cp -a --reflink=always /path/to/data/. /path/to/data.new/

# 3. 一致性校验（文件数 + rsync 逐文件比对）
[[ $(find /path/to/data -type f | wc -l) -eq $(find /path/to/data.new -type f | wc -l) ]] || exit 1
rsync -a --dry-run --itemize-changes /path/to/data/ /path/to/data.new/ | grep . && exit 1

# 4. 原子换名（mv 只改路径入口，子卷身份 subvolid 不变）
mv /path/to/data /path/to/data.old
mv /path/to/data.new /path/to/data

# 5. 重启全部容器
```

**验证子卷身份**：`sudo btrfs subvolume show /path/to/data` 显示 `Subvolume ID` 即成功。`data.old` 保留数日，确认无误后 `sudo rm -rf /path/to/data.old`。

## 写快照脚本

`~/.local/bin/data-snapshot.sh`，固定名轮转，带防重入锁：

```bash
#!/usr/bin/env bash
set -euo pipefail
DATA=/path/to/data
SNAP_DIR=/path/to/snapshots
SNAP="$SNAP_DIR/data-latest"

# 防重入锁
if [[ -f /tmp/data-snapshot.lock ]]; then
    kill -0 "$(cat /tmp/data-snapshot.lock)" 2>/dev/null && { echo "already running"; exit 1; }
    rm -f /tmp/data-snapshot.lock
fi
echo $$ > /tmp/data-snapshot.lock
trap 'rm -f /tmp/data-snapshot.lock' EXIT

mkdir -p "$SNAP_DIR"
[[ -d "$SNAP" ]] && sudo btrfs subvolume delete "$SNAP"
sudo btrfs subvolume snapshot -r "$POD" "$SNAP"
[[ -d "$SNAP" ]] || exit 1
```

如果 sudo 不是 NOPASSWD，在 sudoers 里只放行这两条固定命令即可。

## 配置 Backrest：路径指向快照 + Hook

编辑 `~/data/backrest/config/config.json`（改前先备份）：

```json
{
  "plans": [
    {
      "id": "remote",
      "repo": "remote",
      "paths": ["/path/to/snapshots/data-latest"],
      "excludes": [
        "*.log",
        "**/cache/*",
        "**/tmp/*",
        "**/pg_xlog/*",
        "**/valkey/*",
        "**/redis/*",
        "**/kuma.db*",
        "..."
      ],
      "hooks": [
        {
          "conditions": ["CONDITION_SNAPSHOT_START"],
          "onError": "ON_ERROR_FATAL",
          "actionCommand": {
            "command": "/path/to/.local/bin/data-snapshot.sh"
          }
        }
      ],
      "schedule": { "maxFrequencyHours": 3, "clock": "CLOCK_LAST_RUN_TIME" },
      "retention": {
        "policyTimeBucketed": { "hourly": 24, "daily": 30, "monthly": 12 }
      }
    }
  ]
}
```

## 触发一次备份验证全链路

```bash
# Connect RPC 端点（不是裸 /v1/backup，405 会误导）
curl -X POST http://127.0.0.1:9898/v1.Backrest/Backup \
  -H 'Content-Type: application/json' -d '{"value":"remote"}'
```

验证点：journalctl 里看到 hook 的 sudo btrfs 记录 → restic 进程拉起 → `restic snapshots` 出现新快照（tag `plan:remote`，`--parent` 正确复用旧链做增量）。

## 恢复流程（原地恢复）

### 本地恢复（盘没坏，秒级）

```bash
# 直接拷回快照
sudo btrfs subvolume snapshot /path/to/snapshots/data-latest /path/to/data.restored
# 或从任意历史快照恢复
```

### 异地恢复（盘坏了，从 restic 拉回）

```bash
# 1. 从 restic 恢复快照目录（任选一个一致版本）
restic restore <snapshot-id> --target /path/to/restore/

# 2. 重建子卷并铺回
sudo btrfs subvolume create /path/to/data
cp -a --reflink=always /path/to/restore/snapshots/data-latest/. /path/to/data/

# 3. 启动容器 —— PG 按标准崩溃恢复回放 WAL，直接可用
```

**为什么能原地恢复**：快照 = 完美崩溃现场，PG 恢复路径与断电重启完全一致，不需要 pg_restore、不需要重建集群。

## 参考

- PostgreSQL 文档 25.2 File System Level Backup：运行中拷贝需 WAL 完整，或使用文件系统一致快照
- SQLite Backup API 文档：cp 运行中的库不安全，推荐 Online Backup API / VACUUM INTO
- restic 文档 040_backup：`--stdin-from-command` 优于管道，避免掩错
- [Backrest](https://github.com/garethgeorge/backrest)（restic 的 WebUI 编排器）源码：`gen/go/v1/config.pb.go`（Hook 结构）、`internal/env/environment.go`（环境变量）、`gen/go/v1/service_grpc.pb.go`（RPC 端点）


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/backrest-btrfs-backup/  

