mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3366 字
9 分钟
用 Restic 备份本地却在疯狂吃磁盘?一次去重备份的本地瘦身实录
2026-05-25

写在前面:就在昨天,我高高兴兴地为五台个人服务器部署了统一的基于 Restic + DigitalOcean Spaces 的自动备份体系,并且成功把首发、定时任务、Telegram 通知和阿里云 SSH 中继链路全部跑通。然而,今天凌晨 3:58,Telegram 机器人冷不防弹出一行黄牌警告:⚠️ 备份异常|<SERVER1_ALIAS>,同时顺藤摸瓜,我发现其中一台有着重度备份需求的核心节点(Server 3)本地的备份临时目录,居然在以每天 2.7GB 的惊人速度吞噬磁盘空间。

这是一次非常典型的“备份体系落地后微调”实录。本文记录了我如何排查环境误报,以及如何通过破除传统备份的思维惯性,为服务器本地瞬间夺回近 8GB SSD 空间的优化过程。


第一幕:幽灵警告——环境变量错配引发的误报#

今天早晨,定时任务跑完后,Telegram Bot 收到了如下通知:

⚠️ 备份异常|<SERVER1_ALIAS>
<SERVER1_INSTANCE>
数据库快照不存在:/var/backups/db/maibot.sqlite.gz
📌 快照:<SNAPSHOT_ID>
摘要
时间 05-25 03:58 CST
耗时 1 分钟
上传 32.5 MiB / 17.9 MiB
总量 438.59 MiB
变化 +57 / ~67

1. 疑点分析#

第一台服务器(<SERVER1_INSTANCE>)的定位非常明确:它仅仅承载了 NapCat 协议转换服务。在整套系统架构中,NapCat 作为适配器插件接入 MaiBot 框架——它负责在 Server 1 接收 QQ 原始消息并进行协议转换,随后输送给 Server 3 上的 MaiBot 核心后端进行 Agent 决策与逻辑处理,最后再将响应指令拉回并发送。这意味着 Server 1 本身仅作为无状态的协议中继节点,根本没有运行任何 SQLite 数据库。 那为什么它会抛出 数据库快照不存在:/var/backups/db/maibot.sqlite.gz 的黄牌警告,还把 MaiBot(这是 Server 3 的服务)的名字给喊了出来?

2. 排查过程#

我通过 SSH 登录到 Server 1,直接调阅了被锁死在 600 权限下的密钥环境文件 /etc/restic/env

sudo cat /etc/restic/env

真相大白!文件尾部赫然写着:

export BACKUP_DB_PATH="/var/backups/db/maibot.sqlite.gz"
export BACKUP_DB_LABEL="SQLite"

原因判定:在昨天初始化多台机器的配置时,第一台机器的环境配置文件直接拷贝自模板(或第三台机器的残余配置),而未能将这两个属于数据库节点的特有变量清理干净。 备份脚本的 notify_success 逻辑非常严密,一旦检测到定义了 BACKUP_DB_PATH,就会物理检查该路径文件是否存在,如果不存在则强制判定为异常。由于 Server 1 压根没有 MaiBot,自然找不到这个 .gz 文件,警报随之长鸣。

3. 解决与顺带优化#

  • Server 1 修复:直接使用 sed 将 Server 1 的 BACKUP_DB_ 环境变量彻底抹除。移除后,Server 1 的备份脚本将不再执行数据库快照存在性校验,重新回归最纯粹的 🟢 状态:OK 绿标。
  • Server 4 审计优化:在顺藤摸瓜审计其他节点时,我发现 Server 4(<SERVER4_INSTANCE> / new-api 服务)每天都在勤勤恳恳地导出 PostgreSQL 裸 SQL dump,但它的 /etc/restic/env 里却漏配BACKUP_DB_PATH。这导致它的成功通知里无法展示数据库的备份体积,且一旦 PostgreSQL 导出失败也无法触发丢失报警。我立即为 Server 4 补齐了以下变量:
    export BACKUP_DB_PATH="/var/backups/db/docker-postgres-all.sql"
    export BACKUP_DB_LABEL="PostgreSQL"
NOTE

环境变量是脚本的“指挥棒”。对于没有数据库的轻量节点,必须保持环境变量的绝对纯净;对于有数据库的核心节点,则应严格对齐变量,让监控警报真正起到哨兵的作用。


第二幕:思想碰撞——破除传统备份的“本地留存惯性”#

sudo ls -lh /var/backups/db

命令返回的结果让我大吃一惊:

total 11G
-rw------- 1 root root 2.6G May 23 21:36 maibot-2026-05-23.sqlite
-rw------- 1 root root 2.6G May 24 18:36 maibot-2026-05-24.sqlite
-rw------- 1 root root 2.6G May 25 10:58 maibot-2026-05-25.sqlite
-rw------- 1 root root 2.6G May 25 10:57 maibot.sqlite

本地这小小的临时目录,居然塞了整整 11GB 的巨量文件!

1. 传统备份的思维惯性#

为什么本地会屯了这么多 2.6G 的大文件?看一眼昨天写下的备份脚本片段就明白了:

# 传统逻辑:
mv "$DB_DIR/maibot.sqlite.tmp" "$DB_DIR/maibot.sqlite"
cp "$DB_DIR/maibot.sqlite" "$DB_DIR/maibot-${TODAY}.sqlite" # 复制出日期命名的本地归档
# 清理 3 天前的日期副本
find "$DB_DIR" -name 'maibot-*.sqlite' -mtime +3 -delete

这是一个极其经典、甚至可以说是教科书式的“传统物理留存备份”写法:为了防止备份介质出问题,或者为了本地能快速找回前几天的版本,脚本会在本地拷贝一份带有日期后缀的文件(如 maibot-2026-05-25.sqlite),并保留最近 3 天。

在传统运维体系下,这很合理。但在现代“快照型去重备份工具”(如 Restic/Borg)的框架下,这无异于画蛇添足。

2. Restic 核心哲学:版本历史应该托管给后端,而不是留存在本地#

为了厘清这个逻辑,我对比一下两种备份哲学的冲突:

维度传统备份方式 (Tar / Dumb S3)Restic 备份哲学 (快照 / 去重)
本地生成物必须生成带日期的独特包,否则后端的旧文件会被无情覆盖。每天只生成一个固定名字的最新快照文件(如 maibot.sqlite)。
历史版本保存本地或云端必须保留 file-YYYY-MM-DD.tar.gz 链条。每次备份自动生成一个 Snapshot(快照),历史数据由 Restic 元数据进行版本控制。
磁盘占用极高。本地保留几天,磁盘占用就直接翻几倍(如 2.6G 翻成 11G)。极低。本地永远只有一倍大小(即 2.6G),没有一字节的多余空间被浪费。
版本找回在本地或云端下载对应日期的特定压缩包并解压。直接通过 Restic 挂载或运行 restic restore <snapshot_id> 恢复任意一天的快照。
IMPORTANT

Restic 自身就是最优秀的“版本管理器”。它拥有极其强大的去重和快照管理能力。我在本地用 cp 强行保留多份日期文件,不仅无端霸占了服务器珍贵的本地 SSD 空间,而且每天备份时 Restic 还要多扫描、读取好几个 GB 的冗余文件,极大增加了本地 CPU 与 I/O 的负担。


第三幕:化繁为简——全节点脚本精简与空间抢救#

想通了这一点后,优化方案呼之欲出:彻底消灭本地的“日期后缀副本”,将历史记录的保存工作 100% 交托给 Restic 托管。

1. 脚本大瘦身#

我对拥有数据库备份的 Server 3 (MaiBot SQLite)、Server 2 (Komari SQLite) 和 Server 4 (new-api PostgreSQL) 的日常备份脚本进行了同步精简。

Server 3 为例,原先臃肿的一致性备份段落被精简为极其纯粹的形态:

mv "$DB_DIR/maibot.sqlite.tmp" "$DB_DIR/maibot.sqlite"
cp "$DB_DIR/maibot.sqlite" "$DB_DIR/maibot-${TODAY}.sqlite"
if [ "${BACKUP_CREATE_COMPRESSED_DB:-0}" = "1" ]; then
if command -v pigz >/dev/null 2>&1; then pigz -1 -p "$(nproc)" -c "$DB_DIR/maibot.sqlite" > "$DB_DIR/maibot.sqlite.gz"; else gzip -1 -c "$DB_DIR/maibot.sqlite" > "$DB_DIR/maibot.sqlite.gz"; fi
gzip -t "$DB_DIR/maibot.sqlite.gz"
fi
find "$DB_DIR" -name 'maibot-*.sqlite' -mtime +3 -delete
find "$DB_DIR" -name 'maibot-*.sqlite.gz' -mtime +3 -delete

2. 物理清理与成效验收#

修改并重新部署脚本后,我远程对 Server 3 执行了物理清理命令,彻底清空了那些躺在临时目录里睡大觉的历史日期文件:

sudo bash -c 'rm -f /var/backups/db/maibot-*.sqlite /var/backups/db/maibot-*.sqlite.gz'
sudo ls -lh /var/backups/db

命令瞬间返回,成效堪称震撼:

total 2.6G
-rw------- 1 root root 2.6G May 25 10:57 maibot.sqlite

本地 /var/backups/db 的体积从 11 GB 瞬间缩减为 2.6 GB,本地 SSD 可用空间直接暴涨了 7.8 GB!

与此同时,我对 Server 2(清理了冗余的 komari-*.sqlite)和 Server 4(清理了冗余的 docker-postgres-all-*.sql)也实施了相同的重构。虽然它们的数据库体积较小,但清理后,全节点的备份规范达到了高度的逻辑对齐。


第四幕:尘埃落定——五节点异构拓扑与最新备份运行机制全景图#

在校准完各个节点的环境变量、脚本及本地物理路径的冗余文件后,这套多服务器 Restic 备份体系终于达到了最稳健、最优雅的状态。

为了直观展示五台异构服务器的角色划分、数据流动以及网络通知中继链路,我绘制了如下系统架构图:

flowchart TD subgraph S3_Cloud ["云端存储 (DigitalOcean Spaces)"] DO_Spaces["DO Spaces S3 仓库"] end subgraph Internal_Servers ["五节点异构服务器集群"] S1["Server 1 (snowluma)"] S2["Server 2 (MyBlog)"] S3["Server 3 (MaiBot)"] S4["Server 4 (new-api)"] S5["Server 5 (Aliyun)"] end %% Network & Services flow (Adapter to Agent Brain) S1 <--> S3 %% Backup uploads via Restic (Merged standard uploads using & operator) S1 & S2 & S5 --> DO_Spaces S3 -- "2.6G SQLite快照 (去重增量)" --> DO_Spaces S4 -- "pg_dumpall (增量)" --> DO_Spaces %% Notifications & Relay (Merged TG bot notification lines using & operator) TG_Bot["Telegram 报警机器人"] S1 & S3 & S4 --> TG_Bot S2 -- "直连/代理中继通知" --> TG_Bot %% Aliyun domestic relay S5 -- "SSH 隧道中继" --> S2 %% Style classes classDef s3 fill:#2B6CB0,stroke:#2B6CB0,stroke-width:2px,color:#fff; classDef srv fill:#2D3748,stroke:#4A5568,stroke-width:2px,color:#fff; classDef tg fill:#0088CC,stroke:#0088CC,stroke-width:2px,color:#fff; class DO_Spaces s3; class S1,S2,S3,S4,S5 srv; class TG_Bot tg;

此外,为了更直观、多维度地展示这套架构,我也尝试使用 Graphviz DOT 语法进行了另一种风格的建模描述。如果你的本地阅读器或博客渲染器无法渲染后者,可以优先阅读上方更加轻量、原生的 Mermaid 流程图;如果支持,则可以直接在下方预览这种学术风的全景效果:

Graphviz DOT

digraph ResticBackupSystem {
// 基础渲染与中文支持优化 (完美解决字体与字重问题)
fontname="Microsoft YaHei,Helvetica,Arial,sans-serif";
node [fontname="Microsoft YaHei,Helvetica,Arial,sans-serif", fontsize=10];
edge [fontname="Microsoft YaHei,Helvetica,Arial,sans-serif", fontsize=9];
// 整体画布属性 (优雅米白色背景与大间距布局)
rankdir=TB;
bgcolor="#fdfdfd";
label="多节点 Restic 备份及通信系统拓扑图 (DOT 建模)";
labelloc="t";
fontsize=14;
nodesep=0.7; // 增加节点间横向间距,防文字挤压
ranksep=0.9; // 增加层级间纵向间距,留出线段空间
// ===== 第一层:异构服务器集群 (水平同排对齐,极简现代圆角) =====
subgraph cluster_servers {
style=filled;
color="#e2e8f0";
fillcolor="#f8fafc";
label="五节点异构服务器集群";
labelloc="t";
fontsize=11;
margin=20; // 内部充足留白
// 水平一字排开,按业务流动排序:S1,S3 (左) | S4 (中) | S2,S5 (右)
{ rank=same; s1; s3; s4; s2; s5; }
s1 [label="Server 1\n(snowluma)", shape=box, style="filled,rounded", fillcolor="#ffffff", color="#64748b", penwidth=1.5];
s3 [label="Server 3\n(MaiBot)", shape=box, style="filled,rounded", fillcolor="#ffffff", color="#3b82f6", penwidth=2.0];
s4 [label="Server 4\n(new-api)", shape=box, style="filled,rounded", fillcolor="#ffffff", color="#64748b", penwidth=1.5];
s2 [label="Server 2\n(MyBlog)", shape=box, style="filled,rounded", fillcolor="#ffffff", color="#64748b", penwidth=1.5];
s5 [label="Server 5\n(Aliyun)", shape=box, style="filled,rounded", fillcolor="#ffffff", color="#ef4444", penwidth=1.5];
}
// ===== 第二层:备份仓库 与 报警接收端 (左右调换,彻底解开交叉死结) =====
{ rank=same; do_spaces; tg_bot; }
// 备份仓库 (左侧)
subgraph cluster_cloud {
style=filled;
color="#e0f2fe";
fillcolor="#f0f9ff";
label="云端加密备份仓库";
labelloc="b";
fontsize=11;
margin=15;
do_spaces [label="DO Spaces S3 仓库", shape=cylinder, style=filled, fillcolor="#ffffff", color="#0288d1", penwidth=2];
}
// 报警接收端 (右侧)
tg_bot [label="Telegram 报警机器人", shape=box, style="filled,rounded", fillcolor="#e6fffa", color="#0d9488", penwidth=2, margin="0.2,0.1"];
// ===== 核心业务数据与指令流 =====
// S1 <=> S3 双向协议流 (同在左侧,近距离垂直无干扰)
s1 -> s3 [dir=both, label="QQ 消息 & Agent 指令", color="#f59e0b", penwidth=1.5, fontcolor="#d97706"];
// S5 -> S2 SSH 消息隧道中继 (同在右侧,近距离水平无干扰)
s5 -> s2 [label="受限 SSH 隧道", color="#ef4444", style=dashed, penwidth=1.5, fontcolor="#ef4444"];
// ===== 备份上传数据流 (全部投射至左侧,避开右侧报警) =====
s1 -> do_spaces [color="#3b82f6", style=dashed, penwidth=1.0];
s2 -> do_spaces [color="#3b82f6", style=dashed, penwidth=1.0];
s3 -> do_spaces [color="#16a34a", label="2.6G SQLite 去重快照", penwidth=2.0, fontcolor="#15803d"];
s4 -> do_spaces [color="#2563eb", label="pg_dumpall 增量", penwidth=1.5, fontcolor="#1d4ed8"];
s5 -> do_spaces [color="#3b82f6", style=dashed, penwidth=1.0];
// ===== 监控报警通知流 (全部投射至右侧,避开左侧备份) =====
s1 -> tg_bot [color="#0d9488", style=dashed, penwidth=1.0];
s3 -> tg_bot [color="#0d9488", style=dashed, penwidth=1.0];
s4 -> tg_bot [color="#009688", style=dashed, penwidth=1.0];
s2 -> tg_bot [label="直连 / 代理中继", color="#0d9488", penwidth=1.5, fontcolor="#0f766e"];
}

接下来,我对这套最新对齐的体系中,各节点的异构定制与分工细节进行了一次全面总结:

节点角色定位备份核心路径数据库处理原理通知中继机制
Server 1snowluma / napcatnginx、letsencrypt、Compose 文件、Docker volume裸数据目录无数据库。跳过一切 SQL/SQLite 生成步骤,免除冗余校验。海外网络,直接调用 Telegram Bot API 发送 HTML 格式通知。
Server 2MyBlog / Komari 面板MyBlog 博客源站、Komari compose 目录及数据SQLite 在线热备份。利用 Python 脚本生成未压缩的 komari.sqlite,校验 Integrity 后备份。海外网络,直接调用 Telegram Bot API 发送 HTML 格式通知。
Server 3MaiBot 核心/重需求DBMaiBot 核心配置、插件及数据 (排除在线 WAL 原始库)SQLite 精细化热备份。Python 在线备份生成 maibot.sqlite 元数据库,通过块去重极大缩减增量上传量。海外网络,直接调用 Telegram Bot API 发送 HTML 格式通知。
Server 4new-api / qwen2API / cli-proxy各容器 Compose 配置、环境文件及挂载数据PostgreSQL 容器导出。在容器外通过 docker compose exec 导出未压缩的 docker-postgres-all.sql 全量包。海外网络,直接调用 Telegram Bot API 发送 HTML 格式通知。
Server 5阿里云加速节点 / 宝塔 Nginx宝塔 nginx 配置、SSL 证书、MyBlog 加速静态产物无数据库。备份主要以宝塔网站 Vhost 配置和加速包静态文件为主。SSH 消息中继网关。国内机不直连 TG,通过受限私钥管道将消息推至 Server 2 发送,爆炸半径为零。
NOTE

这里的 Server 3 由于运行了核心的 MaiBot 机器人(该节点承载着高频且重度写入的数据库,同时作为各种适配器插件与核心 Agent 的业务数据交汇枢纽),因而有着最为迫切与沉重的备份需求。在重塑为固定命名的 maibot.sqlite 一致性热备份并彻底托管给 Restic 之后,不仅后端获得了近乎无限的历史快照版本,服务器本地的 CPU 与 I/O 负担也降到了最低。

🛠️ 共享的底层三大运作机制#

除了各自的异构定制外,全部 5 台机器的备份脚本均共享并对齐了以下底层健壮性机制:

  1. flock 排他文件锁系统: 每日备份和每周维护脚本在启动时,均会锁定同一个锁文件 /run/restic-backup.lock。由于双 timer 共享一把锁,且默认在抢锁失败时静默退出(flock -n),这彻底避免了 Restic 上传任务与 Prune 裁剪任务同时运行导致的元数据损坏或死锁。
  2. Timer 双轨制驱动
    • Daily 备份 (daily-restic-backup.sh):运行备份、元数据快照保留,以及轻量级 restic check --no-cache(只查元数据不读包,速度极快且不吃 S3 请求额度)。
    • Weekly 维护 (weekly-restic-maintenance.sh):每周一清晨触发,运行重兵器 forget --prune 物理释放云端垃圾数据,并结合 check --read-data-subset=1G 深度磁盘物理检验,实现长久存储健康。
  3. trap ERR 错误捕获与日志回溯: 所有脚本头部均绑定了 trap 'notify_fail $?' ERR 捕捉器。一旦任何备份指令异常(如容器未启动导致 Postgres 导出中断),脚本会在失败瞬间捕获状态码,将备份临时日志的最后 40 行打包发送至 TG 警报,让灾难排查直观、高效。

最终总结:写在备份优化之后#

这次微调不仅解决了 Server 1 定时任务的黄牌警报,更重要的是帮助我厘清了现代备份工具与传统打包脚本的区别

在为个人项目或中小系统搭建运维架构时,我很容易把旧习惯带入新系统。比如:

  • 习惯了写 tar -czf backup-$(date +%F).tar.gz,导致在使用支持切块去重的 Restic 时,依然在本地折腾压缩包和日期后缀。
  • 习惯了为每台机器拷贝一份通用脚本,却忽视了每个节点的异构性(比如将有无数据库的节点混为一谈),为后期的监控误警埋下地雷。

把专业的事情交给专业的工具去做。让本地只留存一份最新的一致性快照,而把历史版本、数据校验与失效保留完全托付给后端的加密去重仓库。唯有明确系统各层级的边界,整个运维链路才能跑得足够轻快、足够优雅,并且在关键时刻绝对不掉链子。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

用 Restic 备份本地却在疯狂吃磁盘?一次去重备份的本地瘦身实录
https://github.com/Dawn6666666/MyBlog
作者
黎明
发布于
2026-05-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录