最近整理服务器的时候,我顺手写了一个叫caveman的小备份工具。名字起得很直接——现在的备份方案越做越复杂,云同步、加密、去重、版本管理、增量索引……看起来什么都管,但真到硬盘挂了那天,我反而说不清楚它到底把数据存成了什么样。caveman 的理念就一条:像原始人把火种藏进山洞一样,把重要目录反复复制几份,只不过用硬链接让这些复制几乎不占额外空间。工具本身是几十行 bash 脚本,核心依赖只有rsync和find,没有守护进程、没有数据库、没有配置文件生成器。这篇文章会把完整的设计思路、脚本拆解、恢复演练和踩坑记录都写出来,给同样想自己掌握备份逻辑的人一个可以直接抄作业的参考。
1. 备份这件事,为什么我决定自己写一个叫 caveman 的工具
1.1 现有备份方案让我不舒服的地方
先说背景。我手上有两台 Linux 服务器,一台跑个人网站和 Docker 服务,一台专门存照片、代码仓库备份和家庭文档。过去两年我试过不少方案:云盘同步、专业备份软件、手动复制。最后都被我从生产环境里移除了。
云盘同步的问题不在功能,在于恢复路径太长。你要找回三个月前的某个文件版本,得先登录网页端、翻目录树、等下载,而且平台一旦改版或者封号,数据就悬了。更关键的是,很多云同步工具会主动清理“冲突副本”,你以为保留了历史版本,实际只留下当前状态。专业备份软件则走了另一个极端:界面华丽,功能齐全,但备份数据本身是个私有格式,离开软件基本打不开。我见过不止一个朋友软件崩溃之后,只能靠厂商客服手工导出,一等就是两三天。手动cp -r复制目录最简单,但有两个致命伤:每次全量复制,时间和空间都扛不住;而且覆盖式复制会悄悄丢掉旧版本,你想后悔都没有机会。
我需要的东西其实特别朴素:每次备份形成一个新的完整目录快照,按时间排列;快照之间共享未变化的文件,不重复占空间;每个快照里的文件就是原始文件本身,随便一个cp命令都能拷出来。这个需求听起来像 Time Machine,但我不想装一整套 macOS 生态,也不想用需要 GUI 的工具。在 Linux 命令行下,最合理的实现方式就是 rsync 加硬链接,而caveman就是要做一个把这两者包装好、且不引入额外复杂度的小工具。
1.2 caveman 的定位与设计原则
我给工具定了几个硬性原则,写进了项目首页最显眼的位置:
| 原则 | 含义 | 为什么这么定 |
|---|---|---|
| 快照即目录 | 每个备份都是根目录下的一个普通文件夹 | 保证可移植性,任何文件管理器都能直接浏览 |
| 增量靠硬链接 | 未变化文件共享 inode,不重复写入 | 空间开销接近零,备份速度接近增量 |
| 零守护进程 | 需要用的时候手动跑一次或交给 cron | 减少故障面,进程不常驻,内存不占用 |
| 配置是环境变量 | 不写复杂的 YAML/TOML 解析 | 减少依赖,配置文件本身也是纯文本 |
| 保留策略显式 | 只保留最近 N 份快照 | 可控磁盘用量,不引入全局去重算法 |
设计的时候就想着,备份的本质是把数据复制到一个安全位置,而不是替你思考哪些数据重要。极简工具不应该试图成为所有人的全部方案,它应该把核心路径做扎实,然后把加密、跨机同步这些需求留给其他工具组合完成。caveman的职责范围非常窄:把源目录变成一份新快照,清理过期快照,仅此而已。
2. 硬链接快照的底层逻辑:为什么几乎不占额外空间
2.1 inode 与硬链接基础
要理解这个工具为什么能以近乎零成本存放多份快照,得先搞清楚 inode 和硬链接的关系。Linux 文件系统里,真正存储文件数据的是 inode,它记录了文件大小、权限、属主、数据块位置等信息。而我们在目录里看到的文件名,其实只是一个“目录项”,这个目录项指向某个 inode。硬链接的本质,就是创建多个目录项指向同一个 inode。
你可以在任意目录下做一个实验:
echo "hello caveman" > original.txt ln original.txt hardlink.txt ls -li original.txt hardlink.txt输出里两行文件的开头是一串相同的 inode 编号。这时候你修改其中任何一个文件,另一个也会同步变化,因为它们是同一份数据。删掉其中一个文件名,不影响另一个,因为 inode 的引用计数减一后并没有变成零。快照备份就是利用了这个特性:每个快照目录里有自己的完整文件名和目录结构,但只要内容没变,它们就共享同一个 inode,磁盘上只有一份真实数据。
2.2 cp -al 与 rsync --link-dest 的工作方式
caveman 的核心命令是rsync --link-dest。这个参数的意思是:同步时以上一个快照作为“基准目录”,如果目标文件与基准目录中对应文件的内容和元数据一致,就不重新传输文件内容,直接在目标目录里创建硬链接指向基准文件。命令行写法是这样:
rsync -a --delete --link-dest="$SNAP_ROOT/$last" "$SOURCE/" "$dest/"其中-a是归档模式,保留权限、属主、时间戳和符号链接;--delete让目标目录与源目录保持一致,源目录里删掉的文件,在快照里也会被删除;--link-dest指定找相同文件时的参考目录。如果是第一次备份,没有基准目录,就退化成普通全量 rsync。
这里有个容易忽略的细节:rsync --link-dest的判断依据是“文件大小 + 修改时间 + 权限等元数据”,如果源文件内容变了但修改时间被刻意改回原样,rsync 可能认为文件没变。所以后面我会提到,生产环境中要小心那些会改写文件却保留时间戳的工具。
2.3 一个简单的空间账目表
我可以用一组实际数字说明空间账目。假设源目录总大小 100GB,里面有一个 2GB 的大文件在这周发生了变化:
| 快照 | 数据内容 | 新增磁盘占用 |
|---|---|---|
| 第 1 份快照 | 完整 100GB | 100GB |
| 第 2 份快照 | 除 2GB 文件的新版本外,其余文件全部硬链接到第 1 份 | 约 2GB 加若干目录项 |
| 第 3 份快照 | 继续链接未变化文件 | 约 0.1GB 等 |
也就是说,保留 7 份快照,磁盘占用约等于“一份全量数据 + 几份增量改动”。这也是为什么 Time Machine 类方案可以天天备份不爆盘。但要注意,一旦某个文件在某个时间点被修改并生成了新 inode,那么旧版本数据会被新快照之前的那个快照继续保留,直到那个快照被清理。所以快照保留得越多,你能追溯的历史版本点就越多,但磁盘占用也会随之上升。
2.4 为什么快照必须只读、按时间留存
硬链接特性带来一个风险:如果你后续在旧快照目录里直接修改文件内容,会同时影响所有指向同一 inode 的硬链接。也就是说,那个共享的“原始数据”会被改动,所有依赖它的快照全都会被污染。因此 caveman 生成的快照目录在逻辑上必须是只读的,我自己会通过文件系统权限把它们挂成只读,或者在操作习惯上严格限定旧快照只能读取、不能写入。
另一个点是要保证快照目录结构稳定。每次备份都新建一个独立的snap-时间戳目录,而不是反复覆盖同一个目录,这样才可能借助硬链接去重历史数据。这也是“快照”和“镜像同步”的根本区别:镜像同步永远只保留最新状态,而快照保留了时间轴上的多个状态。
3. caveman 核心脚本拆解:每一行都在做什么
3.1 用到的外部工具清单
整个脚本只依赖四个外部命令:rsync、date、find、xargs。没有用 Perl 或 Python,这样在绝大多数 Linux 发行版上都能直接跑。rsync负责同步和硬链接,date负责生成快照名,find负责列出旧快照,xargs配合清理过期快照。系统里如果自带cp,也可以用来做手动恢复,但脚本内不需要。
为什么不写成 Go 或 Rust 的单二进制?一个是个人维护成本问题,另一个是 shell 脚本对用户透明,出问题打开就能看到逻辑。这个工具本身就打算给懂命令行的人用,没必要为所谓的“性能”引入编译工具链。真正备份的时候,瓶颈永远在磁盘 I/O 和网络带宽,脚本解释执行的时间可以忽略不计。
3.2 主流程与参数约定
caveman 的命令格式压到最简单:
caveman snapshot [配置文件名] caveman list caveman clean配置文件其实就是 shell 环境变量文件,我用它指定源目录、备份根目录和保留份数。
# ~/.config/caveman/data.conf SOURCE_DIR=/home/user/data SNAP_ROOT=/backup/data KEEP_SNAPSHOTS=14脚本启动后先source这个文件,然后执行对应函数。不用命令行参数解析库,是因为我认为备份工具最好不要提供太多选项,选项越多,自动化时就越容易配错。用环境变量和配置文件,反而能强制你预先想清楚行为。
3.3 快照函数的关键代码
核心的snapshot函数可以精简成这样:
snapshot() { local now last dest now="$(date +%Y%m%d-%H%M%S)" last="$(find "$SNAP_ROOT" -maxdepth 1 -type d -name 'snap-*' 2>/dev/null \ | sort | tail -n 1 | xargs -r basename)" dest="$SNAP_ROOT/snap-$now" mkdir -p "$dest" if [[ -n "$last" && -d "$SNAP_ROOT/$last" ]]; then rsync -a --delete \ --link-dest="$SNAP_ROOT/$last" \ "$SOURCE_DIR/" "$dest/" else rsync -a --delete "$SOURCE_DIR/" "$dest/" fi # 写入快照元信息 { echo "time=$(date -u +%FT%TZ)" echo "source=$SOURCE_DIR" echo "based_on=$last" } > "$dest/.caveman-marker" echo "$(date +%F_%T) snapshot $now from $SOURCE_DIR" >> "$SNAP_ROOT/backup.log" }几个地方要特别说明:rsync的源路径带了末尾斜杠,它表示同步目录本身的内容,而不是把目录嵌套一层;目标$dest必须是全新目录,rsync 会自动填充。find ... | sort依赖快照名按时间排序,因为我用%Y%m%d-%H%M%S生成名字,字典序和时间序一致。xargs -r basename是为了从完整路径里取出最后一级目录名作为基准快照名,-r表示输入为空时不执行命令。
.caveman-marker文件是给每个快照写身份证,记录它是什么时候、从哪个源目录备份来的、基于哪个旧快照。三个月后你面对十来个快照,如果没有标记,光看时间戳根本想不起来哪个目录对应哪个源。
3.4 清理策略与误删防线
清理函数更简单,但也是我写过最谨慎的代码之一:
clean() { cd "$SNAP_ROOT" find . -maxdepth 1 -type d -name 'snap-*' \ | sort \ | head -n -"$KEEP_SNAPSHOTS" \ | xargs -r rm -rf -- }head -n -"$KEEP_SNAPSHOTS"表示除了最后 N 个,其余都输出;rm -rf --后面的--防止快照名以连字符开头时被当成选项。这里最核心的防线是cd "$SNAP_ROOT"先进入目标目录,然后find . -maxdepth 1限定了只处理一级子目录,避免路径为空时对根目录执行灾难性删除。我甚至在脚本里加过一个保护判断:如果$SNAP_ROOT的值等于/直接退出报错。
3.5 日志与输出设计
caveman 把每次快照的时间和来源记录到backup.log,方便事后审计。平时命令行只输出一句话:snapshot 20250107-120000 done (10.3GB base, 128MB new)。这里的数字直接从du -sh "$dest"取,逻辑虽然简单,但会让用户对每次备份增量是否正常产生直觉。如果某天快照体积突然翻了百倍,日志和命令行输出就会提醒你去检查源目录是不是被人塞了奇怪的东西。
4. 从“能用”到“敢用”:我做的可靠性测试与恢复演练
4.1 权限与归属问题
rsync 的-a参数会把文件的所有者、属组、权限原样复制到快照目录。如果你用 root 运行 caveman,那快照里的文件就可能全部属于 root。等需要恢复时,如果当前登录用户不是 root,直接复制回去会碰到权限拒绝。我在设计上要求快照任务必须以一个特定用户身份运行,一般就是源目录的属主,或者统一用root配合执行,并且恢复时也保持同样身份。
另一个方案是给 rsync 加--no-owner --no-group,这样快照文件属主继承当前的执行者。但会丢失原始属组信息,在某些场景下影响恢复正确性。我的建议是:普通用户数据用普通用户跑,系统级数据单独用 root 跑,不要把两套快照混在一个目录里。
4.2 让快照时间稳定
脚本里now="$(date +%Y%m%d-%H%M%S)"用的是本地时间。如果服务器时区后来改了,或者和另一台机器比对快照时,会出现时间命名混乱。但在自动化备份里,更重要的是让每次都稳定输出合法字符、可排序、无歧义。我最终在 marker 文件里额外写入了 UTC 时间date -u +%FT%TZ,用于精确审计,而目录名保持本地可读时间。不要尝试在目录名里加毫秒,对文件系统并不友好,也只会增加麻烦。
4.3 原子性:先 rsync 到临时目录,再 mv 改名
刚开始我直接创建snap-$now目录,rsync 往里面写数据。如果 rsync 中途失败,目录里残留半截数据,它会被后续find当成一个完整快照,下一次备份甚至会把它当作link-dest基准,导致一连串快照建立在损坏数据上。这是备份工具最危险的隐患,所以我改了逻辑:先创建一个临时名字,比如snap-$now.tmp,rsync 写入临时目录,成功后再用mv "$tmp" "$dest"改名。
tmp="$SNAP_ROOT/.inprogress-$now" rsync -a --delete --link-dest="$SNAP_ROOT/$last" "$SOURCE_DIR/" "$tmp/" mv "$tmp" "$dest"目录改名是同文件系统内的原子操作,不会出现半截目录被当成正常快照的情况。如果 rsync 中断,.inprogress-*目录会被下次清理函数顺手删掉。这一招非常重要,是 caveman 从“能跑”变成“敢用”的关键一步。
4.4 恢复流程的完整验证
备份工具如果不做恢复演练,等于没写。我设计了一套验证流程,每隔一段时间手动执行一次。首先模拟误删操作:
# 模拟源目录里一批文件被误删 find "$SOURCE_DIR" -type f -name '*.tmp' -delete # 从最近快照恢复 latest="$(find "$SNAP_ROOT" -maxdepth 1 -type d -name 'snap-*' | sort | tail -n 1)" rsync -a --delete "$latest/" "$SOURCE_DIR/"注意恢复命令同样使用了--delete,意味着恢复不是把文件叠加回去,而是让源目录整体回到快照那一刻的状态。如果你在误删之后、恢复之前又新增了一些重要文件,--delete会把它们也删掉。所以恢复动作要谨慎:要么改为不带--delete的只还原缺失文件,要么先把当前源目录再备份一份。我的习惯是恢复前先跑一次 caveman snapshot,把“误删现场”也留一份,这样万一恢复错了还有退路。
4.5 定期校验:diff 与 checksum
快照目录虽然在文件系统层面是普通目录,但我不会盲目信任磁盘。caveman 之外我会定期跑校验:
diff -r "$SNAP_ROOT/snap-某次/source_subdir" "$SOURCE_DIR/source_subdir"diff -r在大目录上会比较慢,所以我一般抽样关键子目录。如果对完整性要求更高,应该在备份时生成 checksum 清单:
find "$SOURCE_DIR" -type f -exec sha256sum {} + > "$SOURCE_DIR/.checksums"恢复后用sha256sum -c校验一遍。真实生产环境里,硬件故障不会先打招呼,定期拿一两个快照做diff -r演练,是对自己备份方案信心的来源。
5. 我用 caveman 踩过的坑和对应处理
5.1 符号链接被跟随或被打包
rsync 的-a模式默认会把符号链接作为符号链接复制,不会跟随链接指向的实际文件。这个行为是正确的。但如果你在 rsync 命令里额外加了-L,它就会把符号链接替换成目标文件内容,快照体积会暴涨,还会破坏符号链接结构。我在最初测试时因为复制网上某条命令,出现了这问题。排查时需要检查快照目录里到底有没有 symlink:find "$dest" -type l | wc -l,如果数量为零而你源目录里明明有一堆链接,基本可以断定是被跟随了。
5.2 跨文件系统硬链接失败
--link-dest机制只有在快照目录和基准目录位于同一个文件系统时才有效。如果SNAP_ROOT是网络挂载,或者源目录和快照根目录分属两块独立硬盘,rsync 无法创建跨设备硬链接,就会退化成复制文件内容。表面上没有报错,但快照空间暴涨。我一开始把快照根目录放在系统盘,源目录在数据盘,跑了两次发现占用异常。检查方法很简单:对比两个快照目录中某个未变更文件的 inode 是否相同。
stat -c '%i' "$SNAP_ROOT/snap-A/somefile" stat -c '%i' "$SNAP_ROOT/snap-B/somefile"如果两者一致,说明硬链接生效。如果不一致,说明系统在做无用拷贝。使用 caveman 的第一个前提,就是备份根目录和源目录必须位于同一挂载点。
5.3 文件名编码与特殊字符
Linux 上文件名是字节序列,rsync 只把它当原始数据处理,没问题。但我后来把快照目录通过 NFS 共享给一台 macOS 机器做文件浏览时,发现 mac 端显示的某些中文文件名出现乱码。原因是 macOS 默认把文件名存成 NFD 范式,而 Linux 多数是 NFC 范式。这个问题不属于 caveman 本身,但你要意识到:如果快照要被异构系统读取,早期就要统一文件名的规范化策略。最简单的做法是不要让不受控客户端直接写回快照目录,只允许读取。
5.4 大量小文件时的性能与 inode 耗尽
第一次把一堆几十万个文件的代码仓库加进备份时,快照过程极慢,而且文件系统报 “No space left on device”,但df -h明明显示还有几十 GB。原因不是磁盘块满了,而是 inode 被耗尽。硬链接可以减少数据块占用,却无法减少 inode 数量。每次创建新快照,需要为所有文件建立新的目录项和硬链接,都会占用 inode。如果快照保留份数很多,且文件数量庞大,建议把备份目录放在 ext4/xfs 等支持更大 inode 数的文件系统上,并提前规划 inode 总数。
df -i /backup这个命令会告诉你备份卷的 inode 使用率。对于几十万小文件的场景,保留 7 到 14 份快照完全没问题;但如果百万级文件还想保留 30 份,就要认真计算 inode 了。
5.5 清理策略差点误删
我写的clean()函数里有一版直接用了绝对路径查找:
find "$SNAP_ROOT" -maxdepth 1 -type d -name 'snap-*'当时$SNAP_ROOT因为配置文件里漏写了一个字符,变成了/。命令实际上是find / -maxdepth 1 -type d -name 'snap-*',还好-maxdepth 1把它限制在根目录一级,没有递归到整个文件系统,但也在 /、/root、/home 这些系统目录搜索了一遍。如果这时候再被head -n -$KEEP误判,很可能会删除系统关键目录。所以后来我强制在 clean 之前先cd "$SNAP_ROOT" && pwd,并且校验$SNAP_ROOT不是根目录和家目录,否则直接退出。备份工具里所有删除操作都必须先制造人工检查点,这是用自己的数据换来的教训。
5.6 cron 运行环境 PATH 问题
把 caveman 放进 cron 自动执行时,第一次失败找了好久才发现是 PATH 的问题。cron 环境里默认 PATH 往往只有/usr/bin:/bin,而 rsync 部分系统装在/usr/local/bin,要不就是系统上脚本里用了date但环境不一致。我的解决办法:在脚本开头强制设置 PATH:
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"然后所有外部命令尽量用完整路径或者依赖这个稳定 PATH。cron 日志里如果只看邮件摘要,根本不知道快照没跑成,所以 caveman 还设置了一个专门的日志文件,并且每次快照后往 log 里写一行,方便 cron 出现问题时回溯。
6. 极简工具的边界:caveman 故意不做什么
6.1 不加密、不做认证
caveman 生成的文件都是明文的普通目录,没有加密。放到不安全磁盘上,拿到硬盘的人就能直接读。这个设计是有意识的取舍:加密会破坏 rsync 的增量能力,还会让快照目录变成不可直接浏览的文件流,违背了“快照即目录”的初衷。如果源数据本身需要保密,我建议在更底层做处理,比如用 LUKS 加密整个备份硬盘,或者在源目录侧对个别文件加密后再进入备份。分层而不是让备份工具承载所有责任。
6.2 不做跨机推送
caveman 只负责在本地创建快照。如果你想做异地容灾,我建议用rclone或者syncthing把整个SNAP_ROOT同步到远程存储,而不是在 caveman 内部实现网络协议。这样跨机同步是一个独立问题,独立工具解决;快照本身保持简单,远程也只是多存一份一样的目录。我个人目前的组合是:本地 nas 跑 caveman,深夜用 rclone 把/backup目录加密后推到对象存储,两套逻辑互不干扰。
6.3 不做文件监控
有些备份工具会常驻进程监听文件变化。caveman 不做,因为备份频率应该由数据变化速度决定,而不是由事件驱动制造一堆不必要的快照。我现在的做法是 systemd timer 每天凌晨跑一次快照,每周三额外跑一次手动确认。如果某个项目当时正在被频繁修改,就再手动补一次。宁可少备份一次,也不愿意因为监控进程导致的文件句柄问题把系统拖垮。
6.4 未来的演进思路
如果数据继续膨胀,我会优先考虑给 caveman 增加“快照合并”能力,把某几个日期相邻、内容差异极小的快照合成一个,来降低 inode 占用。但这需要记录目录项引用关系,会明显增加脚本复杂度。目前看 14 份快照在我的场景下完全够用,所以未来也有可能什么都不改。这个工具最大的好处是:核心逻辑只有几十行,任何后来者都能看懂、改得动。就算三年后我不用这台服务器了,接手的人也可以直接打开脚本,而不是解一个私有格式。
写这个工具最大的体会是,备份这件事最难的不是技术,而是知道自己每一步在做什么。caveman 的快照目录可以像普通文件夹一样被浏览器打开、被 tar 打包、被diff -r比较,没有任何魔法。最后分享一个我自己的小习惯:每次手动备份前,我会往源目录里放一个README.snapshot.txt,写上“这次备份前我改了什么东西,为什么担心”。三个月后翻快照,看着这些小纸条,你就能准确知道当时系统发生了什么,这种可解释性比任何高级仪表盘都让人踏实。