---
title: "备份、时间点恢复与恢复演练"
description: "备份 Quire、将其恢复到某个时间点，并通过恢复演练验证备份。"
image: "https://docs.quirelms.com/og.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.quirelms.com/zh-Hans/llms.txt
> Use this file to discover all available pages before exploring further.

# 备份、时间点恢复与恢复演练

<span id="backup-point-in-time-recovery-and-the-restore-drill"></span>

架构设计见 `docs/architecture/23-ops.md` 第 8 节。此处是 Docker Compose 产品的操作手册，供未编写本文档的人按照步骤操作；如果某个步骤不清楚，说明本文档需要修正。

## 保护哪些内容以及如何保护 <!--quire:what-is-protected-and-how-->

| 资产 | 方式 | 位置 |
| --- | --- | --- |
| 数据库 | 从首次启动开始持续归档 WAL，间隔最多 60 秒 | `pgwal` 卷 |
| 数据库 | 默认每日使用 `pg_basebackup` 创建基础备份（`backup-scheduler`） | `pgbackup` 卷 |
| 数据库 | 每五分钟创建一次基础备份和 WAL 的加密副本（`backup-offsite`） | 您指定的独立存储 |
| 文件 | `files` 卷。可用主机备份工具复制，或使用启用版本控制的对象存储 | `files` 卷 |
| 密钥 | `docker/.env`，尤其是 `QUIRE_MASTER_KEY`（以及仍在使用的 `QUIRE_MASTER_KEY_RETIRED`）、`QUIRE_BACKUP_ENCRYPTION_KEY` 和 `docker/secrets/audit-signing-key.pem` | 在此主机之外保存副本 |
| 搜索索引、缓存、媒体版本 | 不备份，之后重新生成 | |

目标：发生故障后最多丢失 60 秒数据，并在 60 分钟内恢复 500 GB 数据库。

常见错误有两种。数据库恢复后**未恢复文件**，页面就会显示损坏。数据库恢复后**没有 `QUIRE_MASTER_KEY`**，则无法解密其中保存的 SSO、webhook 和集成凭据；在主密钥轮换完成且没有未解决项之前（参见 [key-rotation.md](/zh-Hans/ops/key-rotation/)），还包括退休密钥。这些都属于备份内容。

## 创建备份 <!--quire:taking-backups-->

创建整个集群的基础备份：

```sh
docker compose -f docker/compose.yaml --profile backup run --rm backup
```

系统会保留最新的 `QUIRE_BACKUP_KEEP` 个基础备份（默认值为 5），并清理最旧备份不再需要的 WAL，因此归档不会无限增长。请在主机上通过 cron 或 systemd 定时器每日安排备份：

```cron
15 2 * * * cd /srv/quire && docker compose -f docker/compose.yaml --profile backup run --rm backup >> /var/log/quire-backup.log 2>&1
```

也可以让服务栈自动安排备份：`backup` 配置档案会启动 `backup-scheduler`，每隔 `QUIRE_BACKUP_INTERVAL_HOURS` 小时（默认 24 小时）创建一次基础备份，并启动下文所述的 `backup-offsite`。

```sh
docker compose -f docker/compose.yaml --profile backup up -d
```

## 异地加密副本 <!--quire:encrypted-off-host-copies-->

这两个卷和数据库位于同一主机上；故障主机上的备份不算真正的备份。`backup-offsite` 会通过存储端口，将每份基础备份和归档 WAL 段加密后复制到独立存储，并按保留规则保存：

- **加密**：使用 `QUIRE_BACKUP_ENCRYPTION_KEY`（或 `QUIRE_BACKUP_ENCRYPTION_KEY_FILE` 指定的文件）进行 AES-256-GCM 加密，密钥为 32 字节，可用 `openssl rand -hex 32` 生成。每个文件都有自己的 nonce 和认证标签，因此没有密钥就无法读取副本，任何更改也会被检测到。请将此密钥与 `QUIRE_MASTER_KEY` 一起保存在此主机和备份存储之外。没有该密钥就无法恢复。
- **位置**：`QUIRE_BACKUP_STORAGE_DRIVER` 可设为 `s3`、`azure` 或 `local`（挂载到 `QUIRE_BACKUP_STORAGE_ROOT` 的远程磁盘）。设置与文件存储相同，但前缀为 `QUIRE_BACKUP_`：例如 `QUIRE_BACKUP_S3_ENDPOINT`、`QUIRE_BACKUP_S3_BUCKET`、`QUIRE_BACKUP_S3_ACCESS_KEY_ID` 等。请使用不同的存储桶，最好也使用与文件存储不同的账户；如服务提供商允许，凭据应能写入但不能删除。
- **保留期限**：保留最新的 `QUIRE_BACKUP_OFFSITE_KEEP` 个基础备份（默认值取 `QUIRE_BACKUP_KEEP`，否则为 7），以及最旧基础备份所需的 WAL；存储中的旧备份集和片段会被删除。
- **频率**：每隔 `QUIRE_BACKUP_SHIP_INTERVAL_SECONDS` 秒发送一次（默认 300 秒）。传输具有幂等性：已存储的内容会跳过；只有在最后写入清单后，基础备份才算存储完成。

也可以手动运行相同命令：

```sh
docker compose -f docker/compose.yaml run --rm backup-offsite bun apps/worker/src/backups/main.ts ship
docker compose -f docker/compose.yaml run --rm backup-offsite bun apps/worker/src/backups/main.ts verify
```

要在新主机恢复，请先取回一组备份，然后按照下文步骤操作；将下载的目录作为 `pgbackup` 卷的替代，并将下载的 `wal-archive` 作为 `pgwal` 的替代：

```sh
bun apps/worker/src/backups/main.ts fetch base-20260924T021500Z /srv/restore
```

## 恢复到某个时间点 <!--quire:restoring-to-a-point-in-time-->

发生数据丢失后使用此流程，例如导入错误、课程误删，或需要撤销的契约迁移。此操作会替换线上数据库，因此请先按下方演练进行排练。

1. **选择目标时间**，使用 UTC，并选在损坏发生之前，例如 `2026-09-24 09:30:00+00`。通常可从审计日志（`/admin/audit`）查看具体时间。
2. **停止所有写入服务**：`docker compose -f docker/compose.yaml stop web content worker scheduler collab`。
3. **保留受损集群**，直到确认恢复成功：
   ```sh
   docker compose -f docker/compose.yaml stop postgres
   docker run --rm -v quire_postgres18-data:/from -v quire_postgres-damaged:/to alpine cp -a /from/. /to/
   ```
4. **将早于目标时间的最新基础备份**解压到数据卷，并请求定向恢复：
   ```sh
   docker run --rm -v quire_pgbackup:/backups:ro -v quire_postgres18-data:/var/lib/postgresql postgres:18-alpine sh -euc '
     base="$(ls -1d /backups/base-* | sort | tail -n 1)"   # or the one before the target
     rm -rf /var/lib/postgresql/18/docker && mkdir -p /var/lib/postgresql/18/docker
     tar -xzf "$base/base.tar.gz" -C /var/lib/postgresql/18/docker
     touch /var/lib/postgresql/18/docker/recovery.signal
     chown -R postgres:postgres /var/lib/postgresql/18/docker && chmod 700 /var/lib/postgresql/18/docker'
   ```
5. **执行恢复**：通过 Compose 覆盖文件使用恢复设置启动一次 Postgres，避免更改常规配置文件：
   ```yaml
   # docker/compose.recover.yaml
   services:
     postgres:
       command: [postgres, -c, "restore_command=cp /var/lib/postgresql/wal-archive/%f %p",
                 -c, "recovery_target_time=2026-09-24 09:30:00+00",
                 -c, recovery_target_action=promote, -c, archive_mode=off,
                 -c, max_connections=200, -c, hba_file=/etc/postgresql/pg_hba.conf]
   ```
   覆盖文件会替换整个命令，因此必须重复恢复所需的两项设置：`max_connections` 不得低于主库的设置（否则恢复会因 “insufficient parameter settings” 中止），以及挂载的 `pg_hba.conf`。
   ```sh
   docker compose -f docker/compose.yaml -f docker/compose.recover.yaml up -d postgres
   docker compose -f docker/compose.yaml logs -f postgres   # wait for "database system is ready"
   ```
6. **检查结果**后再允许用户访问：验证审计链（`docker compose -f docker/compose.yaml run --rm worker bun tooling/audit-verify/run.ts`），并确认丢失的数据已恢复。
7. **恢复正常运行**：`docker compose -f docker/compose.yaml up -d`。这会重新启用归档并重启 Postgres，同时开始新的 WAL 时间线。请立即创建新的基础备份。

每个卷名称前都有项目名 `quire`；运行 `docker volume ls` 可查看准确名称。

## 定期验证演练 <!--quire:the-scheduled-verification-drill-->

`backup-offsite` 还会每隔 `QUIRE_BACKUP_DRILL_INTERVAL_HOURS` 小时运行一次演练（默认 168，即每周一次）；演练失败后，也会在下一轮再次执行。它会获取最新的异地主基础备份及之后的所有 WAL 片段，逐个解密（证明密钥仍可解开文件且内容未被更改），将每个文件与清单比较，确认归档是 Postgres 数据目录，并检查从基础备份开始的 WAL 是否连续。报告会写入存储中的 `reports/drill-<time>.json` 和服务日志；演练失败时会指出文件名或第一个缺失的片段。

## 恢复演练 <!--quire:the-restore-drill-->

从未恢复过的备份不算备份。演练会将早于目标时间的最新完整基础备份及 WAL 归档恢复到与线上环境完全隔离的临时 Postgres 中，并验证结果：

```sh
docker/scripts/restore-drill.sh                                  # to ninety minutes ago
docker/scripts/restore-drill.sh --target "2026-09-24 09:30:00+00"
```

目标必须使用 UTC，并严格采用此格式。演练需要早于目标时间的基础备份以及晚于目标时间的归档 WAL：新安装时，请先创建基础备份并等待下一个 WAL 片段归档（有写入时最长约一分钟），再选择晚于备份的目标时间。主机只需要安装 Docker 和 bash。

任一环节失败都会导致演练失败：

1. **可恢复性**：临时集群可以重放到目标时间并启动。
2. **完整性**：对比每张表在当前数据库中的行数（`tooling/restore-drill`）。当前数据库的数据晚于目标时间，因此任一方向的差异允许达到 500 行或表行数的十分之一（取较大值；写入会使恢复结果落后，删除会使其保留较多记录）；目标时间之后创建的分区不算丢失表。较繁忙的安装可用 `QUIRE_DRILL_MAX_BEHIND` 和 `QUIRE_DRILL_MAX_DRIFT_RATIO` 放宽范围。表缺失或被清空则失败。
3. **完整性校验**：还原副本中的审计哈希链通过验证。
4. **可用性**：应用角色可通过行级安全机制读取数据。
5. **耗时**：从启动到成功的时间满足 `QUIRE_DRILL_RTO_SECONDS`（默认 3600）。

演练绝不会向线上数据库或其卷写入数据：备份和 WAL 卷以只读方式挂载，临时集群无论成功还是失败都会在结束时删除。

设置 `QUIRE_DRILL_REPORT` 为某个路径，可在成功或失败后都写入 JSON 报告；您可以从 Docker 主机安排定期运行：

```cron
30 3 1 * * cd /srv/quire && QUIRE_DRILL_REPORT=/var/log/quire-drill.json docker/scripts/restore-drill.sh >> /var/log/quire-drill.log 2>&1
```

每月运行一次，并在每次升级前运行。演练失败会阻止升级。每季度请让一位没有撰写本操作手册的人仅根据本文档，在备用主机上执行真正的时间点恢复。

## 文件 <!--quire:files-->

本地文件保存在 `files` 卷中。请与数据库同时备份，并在恢复时一起还原：

```sh
docker run --rm -v quire_files:/files:ro -v "$PWD":/out alpine tar -czf /out/files-$(date -u +%Y%m%d).tar.gz -C /files .
```

使用对象存储时，请启用存储桶版本控制并保留 35 天的非当前版本；这样，文件的时间点恢复由存储桶自身负责。

Source: https://docs.quirelms.com/zh-Hans/ops/backup-restore/index.mdx
