用 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 为例)只是普通目录,需要先把它迁到独立子卷。迁移要停掉所有容器,建议脚本化执行:

# 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,固定名轮转,带防重入锁:

#!/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(改前先备份):

{
  "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 }
      }
    }
  ]
}

触发一次备份验证全链路

# 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 正确复用旧链做增量)。

恢复流程(原地恢复)

本地恢复(盘没坏,秒级)

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

异地恢复(盘坏了,从 restic 拉回)

# 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(restic 的 WebUI 编排器)源码:gen/go/v1/config.pb.go(Hook 结构)、internal/env/environment.go(环境变量)、gen/go/v1/service_grpc.pb.go(RPC 端点)
编辑此页

目录