四路备份、双写异地:我的多服务备份体系是怎么搭的
摘要
手里服务一多,备份最容易长成两种极端:要么每台机器一个整机打包,恢复时全有或全无;要么全靠“我记得要备”,出事才想起来。我现在的做法是按服务分路备份:四个服务、四条备份链路,各自只备自己恢复时真正需要的东西,全部每天凌晨跑一次,同一份包写到两家对象存储,14 天保留,成功静默、失败才告警。这篇文章记录这四条链路分别在备什么、怎么保证拷出来的数据库是一致的、以及双写和保留期背后的取舍。
一、为什么按服务分路,而不是整机打包
最早我也想过最省事的办法:每天对每台机器做一次全盘快照。但实际用下来,整机备份有三个问题。
第一,恢复粒度太粗。丢的往往是一个服务的数据库或一份配置,不是一整台机器。整机恢复意味着要把没问题的服务也一起回滚,代价比丢数据的服务本身还大。
第二,信任级别混在一起。密码库的数据是主密码加密过的,丢出去也只是看不懂的密文;网关的数据库里却躺着各渠道的 API Key,明文。把两种东西打进同一个包,就只能按最敏感的那一级来保管,白白抬高所有备份的保管成本。
第三,体积和时间都失控。日志目录、搜索索引、缓存这些东西,备份的价值几乎为零,体积却占大头。整机打包等于每天都在为不需要恢复的东西付流量和存储。
所以最后的形态是:一个服务一条链路,备份内容由这个服务的恢复路径反推——恢复时需要什么,就备什么;恢复时不需要的,再大也不备。四条链路共用同一套脚本骨架(拉取、打包、哈希比对、双写、保留清理、失败告警),但每条链路的“备什么”各不相同。
二、四路备份分别在备什么
目前在跑的四条链路,分散在三台机器上,都排在凌晨、北京时间 3 点到 5 点之间错峰执行。
第一路:AI 网关。 备的是网关的 SQLite 数据库,加上启动必需的 .env 和 docker-compose.yml。数据库只有一百多 KB,而且每天都在变大——11 月初从七十多 KB 长到了一百一十多 KB,因为调用记录和配置在持续累积。日志目录不备:请求日志丢了可以重建,不值得每天跟着打包。
第二路:书签服务。 备的是两个 SQLite 库(主库和任务队列库)、附件目录、.env 和 compose 文件,包大约 680KB。刻意不备搜索索引卷:索引是派生数据,服务启动后会自己从主库重建,备它只是多花流量。
第三路:密码库。 备的是整个数据目录,大约 2.7MB。这一路的信任模型和其他三路不一样:库内容本身就是主密码加密过的,没有主密码就是一堆看不懂的密文,所以它可以放心地放在对象存储里。管理后台的口令不在备份里,丢了就重新生成,不靠备份找回。
第四路:告警链配置。 备的是监控面板的 SQLite 数据库、微信推送中继的代码和配置、推送服务的配置文件和账号库,大约 2.8MB。这一路的存在感最低,但丢了最麻烦:告警规则、通知分组、机器人 webhook 全在里面,真丢了要凭记忆一条条重建。所以它和密码库一样重要,只是平时没人想起它。
四路加起来每天不到 7MB,备份这件事的成本从来不在体积上,而在“丢了能不能原样回来”上。
三、一致性快照:服务正在跑的时候,怎么拷数据库
四路里有三路的核心是 SQLite 数据库。SQLite 最忌讳的就是在服务运行时直接 cp 数据库文件:WAL 模式下,刚写入的数据可能还在 -wal 文件里没落盘,直接复制主文件拷出来的是一份时间点错乱、不保证能打开的库。
我的做法是统一用 SQLite 自带的 backup API 做快照:以只读方式打开正在运行的库,把事务一致的副本写到临时目录,再对临时目录打包。核心逻辑只有几行:
import sqlite3
src = sqlite3.connect(“file:/opt/gateway/data/app.db?mode=ro”, uri=True)
dst = sqlite3.connect(“/tmp/snap/data/app.db”)
src.backup(dst)
dst.close()
src.close()
只读打开保证快照过程不干扰正在跑的服务,backup API 保证拷出来的是某个事务边界上的完整状态。快照脚本通过 SSH 发到机器上执行,输出必须是干净的 tar 流——脚本里不打印任何东西,否则会污染数据流,这一点是在调试时踩过坑才定下来的规矩。
打包、压缩、回传都在同一条 SSH 通道里完成,临时目录用完就删,机器上不留中间文件。恢复的步骤也写在每条链路的脚本注释里,大意都一样:解包,把数据库拷回数据卷,把 .env 和 compose 文件放回原位,重新拉起容器。
这里还有一条经验:备份从来不等于“能恢复”。书签服务从一台机器迁移到另一台时,实际上就是一次完整的异地恢复演练——数据卷整个搬过去,容器拉起来,书签一条不少。这种不着急的时候演练过的恢复,真出事那天才敢直接动手。
四、双写与哈希跳过:同一份包写两家云
每份包都会写两份:一份到一家云的对象存储,一份到另一家云的对象存储。两份互为兜底,不把恢复的希望押在单一厂商、单一桶上。
双写不是简单地上传两次。两个目的地各有自己独立的哈希状态和保留清理,互不干扰。镜像那一路如果失败,整次备份会被判定为失败并触发告警,而不是“主份成功了就当没事”。这个设计是有意的:静默的部分成功比明确的失败更危险——失败会喊人,部分成功只会让人以为一切正常。
上传之前还有一道哈希比对:先算出包的 SHA-256,和上一次成功上传的哈希比较,没变化就不重复上传。设计上有这道关,但要说实话:对 SQLite 数据库来说,它很少真的触发。数据库文件每天都在变,哪怕书签服务那一路包的大小连续三天都停在 682KB 上下,哈希每天都不一样——文件头的时间戳和内部页面的变化就足够让哈希改变。所以哈希跳过更像一道安全阀,而不是日常省流量的主力;真正省流量的是第二点里“只备恢复需要的东西”。
保留期定的是 14 天。这个数字是在“能覆盖一次出差加一次疏忽”和“不无限囤积”之间取的平衡:保留期太短,误删后发现得晚就追不回来了;太长,存储和清理成本又会悄悄上涨。清理是按文件名里的日期自动算的,每天跑完备份顺手删掉 14 天前的旧包,日志里能看到每天都有一条旧包被删掉的记录,不靠人工记。
五、保留期与告警:成功静默,失败才喊
四条链路共用一条告警原则:成功了什么都不说,失败了立刻喊人,而且要说清楚是哪一路、在哪一步失败。
喊人的通道是双份的:聊天消息和自建的推送通道同时发。告警信息里写明失败的服务和步骤,否则半夜收到一条“备份失败”也只能干瞪眼。备份脚本的退出码就是告警的触发器——任何一步失败(SSH 超时、快照出错、上传失败、镜像失败、保留清理失败)都会让脚本以非零退出,定时任务的告警接住这个信号再往外发。
这套原则的反面教训我也遇到过:早期有一路备份的镜像失败时没有让整次运行失败,结果主份一直在更新、镜像悄悄停了好几天,直到查日志才发现。所以现在每条链路的规则是:宁可多报一次失败,也不接受一次静默的部分成功。
还有一个细节是备份时间的选择。四路都放在凌晨、北京时间 3 点到 5 点之间错峰跑,避开白天服务的访问高峰,也避开其他定时任务。备份本身对服务几乎没有可感知的打扰——快照是只读的,打包在临时目录里做,流量只有几 MB——但把它放在深夜,仍然是图个安心。
总结
这套备份体系可以浓缩成五句话:按服务分路,恢复需要什么就备什么;SQLite 用 backup API 做一致性快照,运行中直接复制是错的;同一份包双写两家对象存储,镜像失败算整次失败;哈希比对、无变化跳过,更多是安全阀而不是省流量的主力;14 天保留自动清理,成功静默、失败双通道告警。
它没有哪一步是高深的,胜在每一步都有明确的恢复指向:每份包都知道自己是为哪次恢复准备的,每次失败都知道该喊谁、该说什么。备份的终点不是“每天都有一份包躺在对象存储里”,而是真丢数据那天,能在不慌乱的情况下把服务原样带回来。验证做在平时,出事那天才不用现补功课。
- 点赞
- 收藏
- 关注作者
评论(0)