ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

硬链接快照备份:用 rsync 和几十行 bash 打造极简工具 caveman

硬链接快照备份:用 rsync 和几十行 bash 打造极简工具 caveman 最近整理服务器的时候我顺手写了一个叫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 份快照完整 100GB100GB第 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_SNAPSHOTS14脚本启动后先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.tmprsync 写入临时目录成功后再用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_subdirdiff -r在大目录上会比较慢所以我一般抽样关键子目录。如果对完整性要求更高应该在备份时生成 checksum 清单find $SOURCE_DIR -type f -exec sha256sum {} $SOURCE_DIR/.checksums恢复后用sha256sum -c校验一遍。真实生产环境里硬件故障不会先打招呼定期拿一两个快照做diff -r演练是对自己备份方案信心的来源。5. 我用 caveman 踩过的坑和对应处理5.1 符号链接被跟随或被打包rsync 的-a模式默认会把符号链接作为符号链接复制不会跟随链接指向的实际文件。这个行为是正确的。但如果你在 rsync 命令里额外加了-L它就会把符号链接替换成目标文件内容快照体积会暴涨还会破坏符号链接结构。我在最初测试时因为复制网上某条命令出现了这问题。排查时需要检查快照目录里到底有没有 symlinkfind $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但环境不一致。我的解决办法在脚本开头强制设置 PATHexport 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写上“这次备份前我改了什么东西为什么担心”。三个月后翻快照看着这些小纸条你就能准确知道当时系统发生了什么这种可解释性比任何高级仪表盘都让人踏实。
返回列表