# 备份与恢复 (/zh/docs/operations/backup)

元数据库自动快照、从快照恢复、主机全损后的灾难恢复、元数据导出与对账 GC。

## 需要备份什么 [#需要备份什么]

渠道里的对象用内容哈希命名，图片与短链、用户、Token 的对应关系全在元数据库里。元数据库丢了，渠道里的图片就无法再对应回来；主密钥丢了，实例拒绝启动，渠道凭证与加密渠道的数据也都无法解密。

| 内容 | 位置 | 如何备份 |
|---|---|---|
| 主密钥 | `<数据目录>/master.key`，或环境变量 `IMGYOUNG_MASTER_KEY` | 首次启动后复制一份离线保存，与快照分开存放，见[配置](../configuration.mdx#主密钥) |
| 元数据库 | `<数据目录>/imgyoung.db` | 自动快照（写到每个健康渠道），另外定期下载一份存到别处 |
| 图片 | 各渠道 | 配置多个渠道，由副本补齐保证每张图不止一份；内置 local 渠道的数据在 `<数据目录>/blobs/` |
| 加密渠道的 rclone 配置 | 后台「存储渠道」页的「导出 rclone 配置」 | 每个加密渠道导出一份，和主密钥一样离线保存，见[渠道存储加密](../channels/encryption.mdx) |
| 部署配置 | 环境变量、systemd drop-in、`compose.yaml`、反代配置 | 纳入你自己的配置管理 |

`<数据目录>/variants/` 是衍生图缓存，丢失后按需重新生成，不必备份。服务运行中不要直接复制 `imgyoung.db`：数据库处于 WAL 模式，最近的写入可能还在 `imgyoung.db-wal` 里。要拿一份一致的副本，用后台下载快照，或先停服再复制。

## 元数据库自动快照 [#元数据库自动快照]

- **生成**：每轮用 SQLite 的 `VACUUM INTO` 生成一份一致的快照，先写到 `<数据目录>/backup-snapshot.tmp`（需要约一个数据库大小的空闲空间），写完各渠道后删除。
- **存放位置**：同一份快照写到**每个**健康且不在清理中的渠道，对象键为 `_imgyoung/backup/imgyoung-<UTC 时刻>.db`（如 `imgyoung-20261015-083000.123.db`），位于渠道路径之下；内置 local 渠道上即 `<数据目录>/blobs/_imgyoung/backup/`。
- **加密**：写到加密渠道上的快照随渠道一起加密，渠道商看不到内容。
- **节奏**：每隔「备份间隔」（`backup_interval_hours`，出厂 24 小时）一次，间隔从进程启动起算，修改间隔后从修改时刻重新计时。定时备份与手动「立即备份」依次执行，同时发生时排队。
- **启动时**：任一健康渠道上没有备份间隔内的快照，就立即备份一次，否则跳过。新接入渠道后的下一次启动会补上一份；频繁重启也不会用同一天的快照挤掉轮换下来的历史。
- **保留**：每个渠道写入成功后，各自只保留最新的「备份保留份数」（`backup_keep`，出厂 7）份，更旧的删除。
- **失败**：单个渠道写失败不影响其余渠道继续写，但只要有渠道没写成，这一轮就算失败，开启 `backup_failed` 告警；写成的渠道保留这份快照。没有健康渠道、生成快照失败同样算失败。下一次成功后告警自动恢复，见[监控与告警](./monitoring.mdx)。
- **删除渠道时**：「删除并清理」会连同渠道上的快照一起删除；「仅移除」不动远端对象，快照留在原处。

<Callout type="warn" title="数据只存在这台主机上">
本机磁盘以外没有健康的渠道时（只有内置 local 渠道，或其他渠道都不健康），图片副本和快照都只在这台主机上，主机损坏即全部丢失，总览页会提示「数据只存在这台主机上」。接入一个对象存储或网盘渠道，快照就会同时写到它上面，不需要调整优先级。
</Callout>

<Callout type="warn" title="加密渠道上的快照">
写到加密渠道上的快照是密文，解密用的 crypt 密码与盐由主密钥与该渠道在元数据库里的编号、创建时刻派生。元数据库整体丢失后，只要还有未加密的渠道持有快照，直接从它取回即可；快照只存在加密渠道上时，要靠事先用「导出 rclone 配置」下载并离线保存的配置文件读出，做法见[渠道存储加密](../channels/encryption.mdx)。
</Callout>

## 在后台查看与管理快照 [#在后台查看与管理快照]

「设置 → 备份与导出 → 元数据库快照」列出各健康渠道上快照的并集，最新在前；「所在渠道」列出持有每份快照的渠道，按优先级排列。

- 「立即备份」：马上做一次快照，写到每个健康渠道，同样会清理各渠道超出保留份数的旧快照。有渠道没写成时返回错误并列出失败的渠道与原因，形如 `备份失败:快照 imgyoung-20261016-083000.123.db 有 1/3 个渠道未写成: 渠道 "r2-main": 上传快照: …`，写成的渠道保留这份快照；没有健康渠道时返回 `没有健康渠道可存放快照`。
- 「下载」：按优先级依次从持有该快照的健康渠道读取，读不到换下一个。下载的文件就是一个完整的 SQLite 数据库。
- 「恢复」：同样按优先级读取，见下一节。

不健康或清理中的渠道上的快照不在列表里，也不参与下载与恢复。任一健康渠道列举失败时列表整体报错（500）。没有健康渠道时各项操作都返回 503：列表为 `没有健康渠道,无法列举快照`，下载与恢复为 `没有健康渠道,无法读取快照`，立即备份为 `没有健康渠道可存放快照`。健康渠道上都没有这份快照时，下载与恢复返回 404 `快照不存在`；有渠道读取失败、又没有一个读得出时返回 500。

## 从快照恢复 [#从快照恢复]

运行中的 SQLite 文件无法安全替换，所以恢复分两步：先暂存，再在下一次启动、打开数据库之前换入。

<Steps>
<Step>

**暂存并停机**：在快照行点「恢复」，确认「恢复并重启」。服务把快照下载到 `<数据目录>/imgyoung.db.restore`（先写临时文件，完整后再改名），记一条 `backup.restore` 审计日志，返回后开始优雅停机：先停止接收新请求，在途请求最多等 6 秒，再停后台循环与补齐任务，最多等 2 秒。

</Step>
<Step>

**由进程管理器拉起**：进程正常退出，需要进程管理器把它重新拉起。Docker 用 `--restart unless-stopped`、Compose 用 `restart: unless-stopped`、systemd 用 `Restart=always`（`deploy/` 里的示例都已这样配置）。直接在终端运行的二进制会就此退出，暂存文件留到你下次手动启动时换入。

</Step>
<Step>

**换入**：启动时、打开数据库之前，先在数据目录写下换入标记 `imgyoung.db.restored`，再把现有的 `imgyoung.db` 连同 `-wal`、`-shm` 改名为 `imgyoung.db.pre-restore-<UTC 时刻>` 留存，暂存文件改名为 `imgyoung.db`。随后照常执行数据库迁移，较早版本的快照会被迁移到当前版本。

</Step>
<Step>

**重置绑定时刻**：迁移之后、加载主密钥之前，见到换入标记就把全部渠道的绑定时刻（见[对账 GC](#对账-gc)）重置为当前时刻，随后删除标记，日志 `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`，字段 `channels` 为重置的渠道数。这一步失败时标记保留，下次启动会再做。

</Step>
<Step>

**重新登录**：后台页面在服务恢复后自动刷新。会话与密码都回到快照时刻的状态，要用那时的密码登录。

</Step>
</Steps>

留存的 `imgyoung.db.pre-restore-*` 不会自动删除，确认恢复无误后手动清理。

恢复把元数据整体退回快照时刻，在此之后发生的变化与渠道上的实际对象不再一致：

- **快照之后上传的图片**：元数据里没有它们。它们在渠道上的对象早于重置后的绑定时刻，对账 GC 只计数（`foreign`）、不回收；对象留在渠道上，但无法再通过 ImgYoung 访问；
- **快照之后删除的图片**：元数据里还在，但渠道对象已删，访问失败，并以「副本补齐失败」告警出现，在后台删掉即可；
- **快照之后的其他改动**：新建的渠道、用户、Token，修改过的运营配置与防盗链规则，全部退回快照时刻。

## 主机全损后的灾难恢复 [#主机全损后的灾难恢复]

前提：你有快照所属实例的 master.key 备份，并且能拿到一份快照文件，来源可以是：

- 自己下载保存的快照；
- 任一**未加密**渠道上的快照：用渠道服务商的控制台或 rclone 下载渠道路径下 `_imgyoung/backup/` 里最新的 `imgyoung-*.db`；
- 加密渠道上的快照：用事先导出的 rclone 配置文件经 crypt 远端读出，见[渠道存储加密](../channels/encryption.mdx)。

<Steps>
<Step>

**在新机器上安装同一或更新版本**，见[安装](../install/index.mdx)。已经启动过也没关系：换入快照时，全新实例的数据库会被改名留存；只是它首次启动时生成的 `master.key` 必须在下一步换成原来那把。元数据库里记着主密钥的指纹，换入快照后，主密钥缺失或与快照不符时进程拒绝启动，不会生成新密钥。

</Step>
<Step>

**停服，放入主密钥与快照**：在数据目录里放入原来的 `master.key`，把快照文件放为 `imgyoung.db.restore`。

快照必须放成 `imgyoung.db.restore`，不要直接覆盖 `imgyoung.db`：只有经暂存文件换入，启动时才会写下换入标记、重置渠道绑定时刻。直接覆盖的话，快照之后写进渠道的对象在对账 GC 看来就是本库的孤儿，过了宽限期会被删除。

<Tabs items={['二进制', 'Docker', 'Compose', 'systemd']}>
<Tab value="二进制">

```bash title="终端"
mkdir -p data
cp imgyoung-master.key data/master.key && chmod 600 data/master.key
cp imgyoung-20261015-083000.123.db data/imgyoung.db.restore
```

</Tab>
<Tab value="Docker">

镜像以 uid 65532 运行且没有 shell，借一个临时容器写入数据卷并修正属主：

```bash title="终端"
# 容器已在运行时先 docker stop imgyoung;还没有容器时按原来的参数先建出来(不启动)
docker create --name imgyoung --restart unless-stopped -p 8721:8721 \
  -v imgyoung-data:/data ghcr.io/x-haose/imgyoung:latest
docker run --rm --volumes-from imgyoung -v "$PWD":/in:ro alpine:3.24 sh -c '
  cp /in/imgyoung-master.key /data/master.key &&
  cp /in/imgyoung-20261015-083000.123.db /data/imgyoung.db.restore &&
  chmod 600 /data/master.key &&
  chown 65532:65532 /data /data/master.key /data/imgyoung.db.restore'
docker start imgyoung
```

</Tab>
<Tab value="Compose">

```bash title="终端"
docker compose stop        # 还没有启动过时改用 docker compose create
docker run --rm --volumes-from "$(docker compose ps -a -q imgyoung)" -v "$PWD":/in:ro alpine:3.24 sh -c '
  cp /in/imgyoung-master.key /data/master.key &&
  cp /in/imgyoung-20261015-083000.123.db /data/imgyoung.db.restore &&
  chmod 600 /data/master.key &&
  chown 65532:65532 /data /data/master.key /data/imgyoung.db.restore'
docker compose up -d
```

</Tab>
<Tab value="systemd">

`install.sh --service` 安装后服务已经启动过一次，`/var/lib/imgyoung` 已由 systemd 建好并归属运行用户。放入文件后把属主改成与现有数据库一致：

```bash title="终端"
sudo systemctl stop imgyoung
sudo cp imgyoung-master.key /var/lib/imgyoung/master.key
sudo cp imgyoung-20261015-083000.123.db /var/lib/imgyoung/imgyoung.db.restore
sudo chown --reference=/var/lib/imgyoung/imgyoung.db \
  /var/lib/imgyoung/master.key /var/lib/imgyoung/imgyoung.db.restore
sudo chmod 600 /var/lib/imgyoung/master.key
sudo systemctl start imgyoung
```

</Tab>
</Tabs>

主密钥用环境变量 `IMGYOUNG_MASTER_KEY` 提供的，改设环境变量即可，不必放文件。

</Step>
<Step>

**启动并检查**：二进制方式此时启动 `imgyoung`。日志里依次出现 `已用暂存快照换入元数据库,原库留存` 与 `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`，即换入完成。用快照时刻的账号密码登录，在「存储渠道」页确认各渠道恢复健康。

如果启动失败并提示主密钥与数据库不符，或找不到主密钥，说明放进去的不是快照所属实例的主密钥，或者没有放。此时快照已经换入，换上正确的主密钥再启动即可，不必重新放快照。

</Step>
</Steps>

恢复后，原来只存在 local 渠道上的图片已随主机丢失；其他渠道上有副本的图片照常可访问，local 渠道上缺失的副本会被对账 GC 发现并从其他渠道补回。

快照在未加密渠道上时，也可以不手动取文件：先启动一个全新实例，在后台按原参数接入存放快照的渠道，再从「元数据库快照」里恢复；列表包含所有健康渠道上的快照，不需要调整优先级。这个全新实例必须从首次启动起就用原来的主密钥（首次启动前放入 `master.key` 或设置 `IMGYOUNG_MASTER_KEY`），否则恢复后重启时主密钥与快照不符，进程拒绝启动。已经用自动生成的主密钥启动过的，先停服，换上原来的 `master.key`，删掉数据目录里全新的 `imgyoung.db`（连同 `-wal`、`-shm`）后再启动，然后再接入渠道。新接入的渠道以接入时刻为绑定时刻，桶里原有的对象都早于它，对账 GC 只计数、不回收，等待期间不会被删。

<Callout type="warn" title="接入旧渠道后尽快恢复">
接入后、恢复前，不要点「立即备份」，也不要重启全新实例：全新实例的空数据库快照会写进每个健康渠道，包括刚接入的旧渠道，并按保留份数挤掉那里最旧的一份原有快照。点「恢复」引起的那次重启没有这个问题：启动时先换入快照，之后备份的是恢复后的数据库。
</Callout>

## 元数据导出 [#元数据导出]

「设置 → 备份与导出 → 数据导出 → 导出 JSON」下载 `imgyoung-export.json`（每次导出记一条 `data.export` 审计日志），内容：

| 字段 | 内容 |
|---|---|
| `users` | 用户：ID、登录名、角色、配额、默认有效期、是否禁用、用量与图片数；不含密码哈希 |
| `channels` | 渠道：ID、名称、驱动、优先级、健康状态、是否加密、是否允许直链、创建时刻、副本水位；不含任何配置参数与凭证 |
| `tokens` | Token：名称、归属用户、明文前缀、授权范围、吊销与签发时刻；不含哈希 |
| `images` | 每条图片引用：短 ID、物理图、归属用户与 Token、原始文件名、对象键别名、有效期、访问统计、清理标记 |
| `blobs` | 每个物理图：SHA-256、感知哈希、渠道内对象键、MIME、大小、宽高、引用计数、删除状态 |
| `blob_channels` | 每份副本在哪个渠道、状态与最近错误 |

导出可读、自包含，即使不再运行 ImgYoung，也能凭它和渠道里的对象把图片对应回来。它不是恢复用的格式：恢复用快照。运营配置、防盗链规则、审计日志与告警不在导出内。

## 对账 GC [#对账-gc]

补齐子系统每 24 小时对每个健康渠道做一次双向对账（第一次在进程启动 24 小时后），渠道内 `_imgyoung/` 前缀下的系统对象（含快照）不参与。

孤儿判定以渠道的**绑定时刻**为界：渠道记录开始对应当前存储位置的时刻，ImgYoung 只会在这之后往该位置写对象。

| 情形 | 绑定时刻 |
|---|---|
| 新建渠道 | 创建时刻 |
| 后台编辑渠道，后端名或任何配置参数有改动 | 重置为编辑时刻；重新输入同一个密码也算改动（rclone 编码密码时带随机 IV，编码结果每次不同）|
| 只改名称、优先级或直链开关；服务商刷新令牌后的写回 | 不变 |
| 从快照恢复（经 `imgyoung.db.restore` 换入）| 全部渠道重置为换入后启动的时刻 |

- **孤儿**：同时满足以下条件的对象被删除，免得按量计费的渠道为无主对象付费：
  - 键名是本服务的对象键格式 `<短 ID>_<SHA-256 前 8 位>`；
  - 元数据库里没有它在该渠道上的任何副本记录（不论状态）；
  - 修改时间不早于该渠道的绑定时刻；
  - 修改时间已超过 48 小时宽限期，宽限期覆盖「已写入渠道、尚未落库」的正常中间状态。
- **深探测残留**：深度健康探测写入后即删的临时对象 `.imgyoung-probe-*`，删除失败时才会留下。过了 48 小时宽限期即删除，不看写入时刻（测试连接时留下的残留会早于渠道记录），也不计入对象数与用量。
- **外来对象**：元数据库里没有记录、但键名不是本服务格式，或修改时间早于绑定时刻的对象，比如别的程序写进同一路径的文件、灾备时新实例接入的旧桶里原有的对象、从快照恢复后快照之后写入的对象。它们只计数，不回收。
- **幻影**：元数据库记为已就绪、渠道上实际缺失的副本。状态退回待补齐，重新入队从其他渠道复制。

每个渠道对账完成后记一条 `渠道对账完成` 日志，字段 `objects`（对象数）、`bytes`（字节数）、`foreign`（外来对象数）、`orphans_deleted`（回收的孤儿数，含深探测残留）、`phantoms_requeued`（重新入队的幻影数）。

<Callout type="error" title="两个实例不能共用同一渠道路径">
两个 ImgYoung 实例接入同一个存储路径时，对方在本实例绑定之后写出的对象，在本库看来就是孤儿，过了宽限期会被删掉。每个实例要用各自的桶或路径。
</Callout>

此外每 10 分钟有一次副本对账（不列举渠道对象）：把恢复健康的渠道上失败的副本重新置为待补齐，把未完成的副本重新入队。
