---
title: "备份与恢复"
description: "用镜像内置命令做在线备份：不停服就能取得一份经过校验的 ZIP，校验它，再恢复到干净的数据根目录。"
---

# 备份与恢复

Notarium 的备份是**镜像内置的命令**，而不是从外部复制文件。`notarium backup` 会在服务持续对外可用的同时组装一个逻辑 ZIP：读取全程可用，写入只在两个短暂的检查点被拦一下。你只需要 Docker 和一个运行中的容器——不必停机。

> [!danger] 切勿复制正在使用的 `meta.db`
> `cp /data/meta.db` 不是备份。元数据库运行在 WAL 模式下：已提交的行可能还躺在 `meta.db-wal` 里，而逐个复制出来的文件也不是同一时刻的快照。从这样的副本恢复出来的实例，会悄无声息地丢数据。

## 做一次备份

```bash
docker compose exec -T notarium backup > notarium-20260731.zip
```

这是一次货真价实的备份，而不是“能不能跑”的探测：命令会先构建归档，再用自带的校验器过一遍，然后才把字节流送出去。`-T` 标志**必不可少**——不加它，Compose 会分配一个伪终端，而二进制流无法完好无损地穿过伪终端，最后你拿到的是一个损坏的归档。裸 `docker exec` 默认不分配伪终端，所以那里不需要这个标志。标准输出严格保留给 ZIP 字节；进度和最后的汇总走 stderr，绝不会污染归档。

## 用于定时任务

`>` 重定向有一个陷阱：以最终名字命名的文件在命令开始干活**之前**就已经被创建出来。如果 Docker 或备份中途挂掉，你手上就会留下一个名字正确、内容却不完整的文件。下面这套发布流程在出错时也是安全的：先写入临时文件，刷盘，再用原子硬链接发布。

```bash
backup="notarium-$(date -u +%Y%m%dT%H%M%SZ).zip"
partial="${backup}.partial.$$"
set -eu
umask 077
committed=0
cleanup() { test "$committed" -eq 1 || rm -f "$partial"; }
trap cleanup EXIT
docker compose exec -T notarium backup > "$partial"
sync -f "$partial"
sync -f "$(dirname "$backup")"
committed=1
ln "$partial" "$backup"
if sync -f "$(dirname "$backup")"; then
  if rm "$partial"; then
    sync -f "$(dirname "$backup")" ||
      echo "backup warning: final is durable; partial cleanup fsync failed" >&2
  else
    echo "backup warning: final is durable; retaining recovery partial $partial" >&2
  fi
else
  echo "backup warning: final is visible; retaining durable recovery partial $partial" >&2
fi
trap - EXIT
```

`set -e` 保证 Docker 或备份一旦返回错误，归档就不会被发布。临时文件名里带着 PID，因此两个同时跑的任务不会争抢同一个文件。临时文件和它所在的目录都会在发布点**之前**刷到磁盘，而发布本身是一次绝不覆盖的原子硬链接：两个盯着同一个目标名的任务不会互相踩掉对方。

发布点**之后**的失败只是警告，而不是真的出了事：最终文件已经就位，遇到判断不清的情况，临时文件还会作为备用副本保留下来。哪怕 `ln` 返回了含义不明的非零退出码，也照样保留——链接有可能就是在进程被打断前刚刚建好的。`umask 077` 让归档只有属主可读。请把临时文件和最终文件放在同一个文件系统上。

如果容器是用裸 `docker run` 以 `notarium` 为名启动的，上面的一切照样成立——只需改一行：

```bash
docker exec notarium backup > "$partial"
```

> [!tip] 如果容器已经能看到你的备份目录
> 上面这层包装之所以存在，是因为 stdout 这条通道本身没法发布文件。当归档目录已经挂载进来时——备份共享目录、NFS，或者只读根文件系统旁边挂的一个临时卷——`backup --output /path/archive.zip` 做的是同一件事：写入目标文件旁边的临时文件、刷盘、校验，再用绝不覆盖的原子硬链接发布；出错时不会在目标名下留下任何东西。这时就不需要 shell 包装了，stdout 里只会输出一份 JSON 汇总。

> [!note] 备份需要一个运行中的容器
> 这个命令要用 `docker exec` 在已经对外提供服务的那个容器里执行，而不是另起一个容器：要取得一致的快照，就得和活着的应用打配合。

## 备份什么时候会失败

备份的设计目标是**绝不让你悄无声息地拿到一份不一致的归档**：快照取不到时，命令直接报错退出，什么都不发布。有两种情况会这样：

- **编辑不断涌入。** 组装归档期间数据必须保持不动；与之重叠的写入会触发重试，而在无休止的编辑流下，命令会放弃并报错。实际中你会在繁忙的实例上碰到它——稍后重试即可。
- **正在跑一个耗时的导入或导出。** 备份需要两次极短的写入暂停，而长时间运行的任务塞不进这么短的间隙。别把备份安排在批量导入的同一个时间窗口里。

这两种情况都不会伤到服务：写入队列会立刻释放，运维命令绝不会把应用卡住。无论哪种情况，普通的读取在备份的全过程中都保持可用。

## 归档里有什么

| 归档中的路径 | 这是什么 |
|---|---|
| `data/meta.db` | 账户、会话、成员资格、稳定标识符、版本历史，以及任务状态。 |
| `data/spaces/` | 作为唯一可信来源的 Markdown，包括智能体记忆和项目标记文件。 |
| `data/jobs/` | 已完成的产物，以及导入的持久化上传件。 |
| `manifest.json` | 格式版本、时间戳、精确的目录集合，外加每个文件的大小、mtime 和 SHA-256。 |

派生目录 `data/engine/` **不**在归档之列：索引会在恢复之后从文件重建。未完成的文件里，只有内部文件会被跳过——笔记原子写入的临时文件、只传了一半的导入上传件，以及导出产物的分片。名字以 `.part` 结尾的普通用户文件仍会留在归档里：它们是正当的文件。

> [!warning] 归档属于敏感数据
> 这个 ZIP 里装着来自元数据库的账户和会话状态。请把它当机密对待：上面片段里的 `umask 077` 会让新归档只有属主可读。

## 校验

校验不改动任何东西，它应当是每个备份任务的固定一环：

```bash
docker compose exec -T notarium backup verify < notarium-20260722.zip

# 裸 docker run 时：
docker exec -i notarium backup verify < notarium-20260722.zip
```

成功时命令会打印一份 JSON 汇总，并以退出码 0 结束。它会拒绝：不安全的路径和重复路径、清单里没有记录的文件、对不上号的目录集合、大小与哈希不符、时间元数据无效、超出限额，以及未通过的 SQLite 完整性检查。整个过程既不读取也不改动正在使用的数据目录。

> [!important] 校验和不是签名
> 哈希能抓住意外损坏，却挡不住蓄意篡改：谁要是能把内容和清单一起换掉，就能通过校验。请把备份存储当作访问受限的可信状态，或者在搬运归档的那一层加上签名或加密。

## 恢复

恢复是一次**离线的灾难操作**。它只接受干净、空的数据根目录，绝不会与现有实例合并，也不会覆盖现有实例。

在启动**之前**准备好一个全新的数据卷，并把服务切到它上面：

```bash
set -eu
docker compose stop notarium
# 把旧数据卷挪到一边；在 compose 里挂载一个空的 /data
docker compose run --rm --no-deps -T notarium restore \
  < notarium-20260722.zip
docker compose up -d --force-recreate --no-deps notarium
```

之后必须重新创建容器，而不是简单地启动它：`docker compose start` 会把旧容器连同它那套旧的挂载配置一起拉起来。在验证恢复出的实例之前，旧数据卷先留着别删。

恢复会先把整个归档完整校验一遍，然后才安装任何内容。如果过程在安装中途被打断，它会留下一个明确的标记：请把那个目标视为一次性的，改为恢复到一个新的空目标，而不是硬着头皮往下推、或者做合并。

恢复之后要检查什么：

1. 用备份里的账户登录。
2. 打开几个空间，确认地址和标识符都还在。
3. 打开一篇你改过的笔记，看看它的历史。
4. 检查那些上传件或产物对你来说重要的导入和导出任务。

> [!note] 元数据库结构兼容性
> 你要恢复的元数据库必须带着目标构建能够接受的迁移记录表。非空却没有迁移记录表的库会直接失败（fail closed）——恢复既不会去猜它的版本，也不会自作主张地补上标记。参见[数据库](/docs/self-hosting/database/)。

## 边界

内置命令只支持规范的布局：单一数据根目录，以及存放在 SQLite 文件里的元数据库。如果 `META_DB_URL` 指向 Postgres，或者笔记放在 `DATA_DIR` 之外，命令会**直接失败**，而不是塞给你一份残缺的归档——这时请改用你所用数据库的原生工具，再配合对已挂载目录的快照。

备份和校验的中间文件默认落在 `/tmp`。流式备份在发布前会自我校验，因此可能临时需要“一份归档加两份展开阶段”的空间；单独的校验则是“一份归档加一份展开”。恢复会把进来的数据流缓冲在临时空间里，但归档本身是直接展开到那个全新的数据根目录中的。如果容器的根文件系统是只读挂载的，或者你的数据量很大，就把 `NOTARIUM_BACKUP_TMPDIR` 指向一个可写的挂载目录。

压缩后与展开后的输入上限是 64 GiB 和一百万个条目；文件名、ZIP 的内部结构和 `manifest.json` 另有 32 MiB 的内存上限。受信任的大型部署可以调高 `NOTARIUM_BACKUP_MAX_BYTES`、`NOTARIUM_BACKUP_MAX_ENTRIES` 和 `NOTARIUM_BACKUP_MAX_METADATA_BYTES`——参见[环境变量](/docs/reference/environment-variables/)。

## 下一步

- [镜像 CLI](/docs/self-hosting/cli/)——命令、数据流与退出码的完整约定。
- [数据库](/docs/self-hosting/database/)——元数据库究竟存了什么，以及为什么它绝不能被排除在备份之外。
- [生产环境](/docs/self-hosting/production/)——反向代理、单实例不变量、日常运维。
