
1. 项目概述与核心思路1.1 “caveman”是什么解决什么问题caveman 是我自己写的一个极简备份工具整个项目就一个 bash 脚本加一个纯文本配置文件加起来不到 500 行。名字取的是“穴居人”的意思——我在里面刻意抛弃了所有现代软件常见的花活没有 Web 管理界面没有数据库没有配置文件解析框架没有依赖安装器甚至连加密都没做。这个工具解决的痛点是家里和公司里有几台 Linux 机器、一台 NAS我需要把一些关键目录代码、nginx 配置、家庭照片、几份数据库导出文件定时备份到移动硬盘和另一台机器上。市面上现成的备份软件要么太重安装要拉一堆依赖配置要看文档半小时要么是黑盒跑完你不知道它到底备份了什么、坏了能不能恢复要么要花钱买授权。可我真正需要的功能其实特别朴素能增量、能保留多个历史版本、出问题时能干净利落地把文件拉回来。如果你也是那种“不想把数据交给看不懂的工具”的人或者你正在找一个小而可靠的备份方案caveman 的实现思路可以直接拿来参考。它不挑发行版只要你的机器有 rsync、coreutils 和 cron或 systemd timer就能跑。我实测下来这套方法运行了半年多中间经过了一次真实的数据恢复结论是越简单的方案越不容易在关键时刻掉链子。1.2 为什么选择“穴居人”式的设计在设计 caveman 时我先列了一个“绝对不做什么”的清单而不是“要做什么”。这条决策路径可能和多数人相反但对个人工具来说非常有效不做加密。备份的数据全在家里 / 公司内网传输加密层会增加恢复时的复杂度一旦密钥丢失备份等于不存在。不做界面。浏览器管理面板意味着要跑一个常驻服务要处理端口、会话、权限这些都不是备份本身的需求。不引入数据库。备份列表就是几十行纯文本用 grep 就能查存进 SQLite 反而多一个需要维护的文件。不做跨平台图形化。macOS 和 Linux 的终端就是最好的环境Windows 用户可以直接跳过这个项目。真正保留的核心只有一个用 rsync 的硬链接机制做快照式增量备份。这个方案极具“穴居人”精神——rsync 是上世纪 90 年代就存在的工具硬链接更是 Unix 文件系统最基础的特性但两者组合起来的效果甚至比不少商业备份软件还优雅。每次备份生成一个完整的时间戳目录看起来像全量备份实际上未变化的文件只是多了一个硬链接既不占额外空间恢复时又不需要任何特殊软件去“解包”直接拷贝文件就行。从技术上对比一下常见方案方案增量能力恢复复杂度依赖适合场景cp -a 全量拷贝无每次全量极低无小目录、低频备份rsync 镜像同步单向增量低仅 rsync单一最新副本够用tar 压缩包归档需手动管理轮转中需解包tar / gzip需要打包分发rsync 硬链接快照完美增量极低rsync多版本历史保留商业备份软件多支持各不相同较重企业统一管理caveman 明显站在“极低恢复复杂度”和“完美增量”的交汇点上这也是我把项目做成这样的根本原因。2. 核心实现与关键细节2.1 项目结构与运行入口整个项目的目录结构非常简单~/caveman/ ├── caveman # 主脚本chmod x ├── conf/ │ └── backup.list # 备份配置一行一个任务 ├── logs/ # 运行日志目录 └── backups/ # 快照根目录主脚本用 bash 写的入口逻辑是标准的子命令分发。我不喜欢一个脚本干太多事所以只暴露了三个子命令backup执行备份、list查看快照、restore恢复文件。#!/usr/bin/env bash set -euo pipefail BASE_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) CONF_FILE${BASE_DIR}/conf/backup.list BACKUP_ROOT${BASE_DIR}/backups LOG_DIR${BASE_DIR}/logs cmd${1:-} case $cmd in backup) shift; cmd_backup $ ;; list) shift; cmd_list $ ;; restore) shift; cmd_restore $ ;; *) echo 用法: $0 {backup|list|restore} 2; exit 1 ;; esacset -euo pipefail三件套是脚本可靠性的底线。-e让脚本在出错时立即退出-u避免变量未定义的坑pipefail保证管道中任何一环失败都会让整个命令失败。没有这三行脚本很容易在某个命令静默失败后继续往下跑直到你发现备份坏了才追悔莫及。2.2 快照式备份的核心原理caveman 的灵魂在cmd_backup()函数里。每条配置格式是源路径::备份标签例如/home/user/workspace::workspace /etc/nginx::nginx-config执行备份时脚本会为每个标签创建一个带时间戳的目录然后用 rsync 把源目录同步进去。核心命令如下run_rsync() { local src$1 dest$2 link_dest$3 label$4 rsync -a --delete \ --link-dest$link_dest \ --exclude-from$CONF_FILE.exclude \ $src/ $dest/ }关键在--link-dest参数。它告诉 rsync目标目录中如果某个文件的内容、大小、权限与link_dest指向的目录里的同名文件完全一致就不要重复复制内容而是直接建一个硬链接指向link_dest中的那个文件。这样这次“看起来像全量拷贝”的快照实际只存储了自上次备份以来真正变化的数据。打个比方如果把文件比作一本书硬链接就是同一个书架上多贴了一个标签标签都指向同一本书。你没买新书只是增加了索引。只有真正改了内容的文件rsync 才会重新“买一本新书”放到新目录里老标签仍然指旧书。这种设计带来的好处很实际每个快照目录都是完整的可以直接浏览、可以直接 cp不需要任何工具“还原”。磁盘空间只受“每次变化量”影响而不是受“数据总量”影响。恢复了上一个快照之后旧文件仍然可用不会因为覆盖而丢失数据。2.3 配置文件与排除规则配置文件故意保持“弱语法”。每行一个任务::作为分隔符#开头是注释。不搞 JSON、不搞 YAML因为备份配置的典型形态是“半年前写一次之后基本不动”没必要为了这种静态内容引入解析器和转义规则。排除规则单独放在conf/backup.list.exclude文件里和 rsync 的--exclude-from配合。这个文件支持 rsync 的通配符语法*.tmp .git/ node_modules/ __pycache__/ *.log .DS_Store注意排除规则里如果写node_modules/那么所有层级名为 node_modules 的目录都会被排除如果只写node_modules不带斜杠同样的效果但建议统一带斜杠语义更清晰也避免匹配到同名文件。2.4 日志、锁与通知脚本里的日志处理没用什么 loguru、syslog就是简单的文本追加log() { echo $(date %Y-%m-%d %H:%M:%S) $* ${LOG_DIR}/backup.log }backup.log按天自然追加配合 grep 就能查某天的记录。从不轮转日志因为我本来就希望它“无限期保留”——备份日志这种一年不过几 MB 的文件没必要让程序费心管理。单实例锁是备份脚本必须要有的不然 cron 触发和手动触发一旦重叠两个 rsync 同时写同一目标目录会产生不可预测的结果。我用flock实现exec 9${BACKUP_ROOT}/.lock flock -n 9 || { echo 已有备份任务在运行; exit 2; }flock -n是非阻塞模式拿不到锁就直接退出配合 cron 的下一次触发自然就跳过了不会造成任务堆积。执行完备份后脚本还会打印一份简短摘要本次处理了多少文件、新增多少数据、总耗时多少日志里同样记录一份方便事后核对。3. 实操过程与核心环节实现3.1 环境准备与初始化caveman 的依赖少到可以在两分钟内部署完。在 Debian/Ubuntu 系上sudo apt-get install rsyncmacOS 自带 rsync不过版本可能偏旧。如果你要用到--link-dest的某些新特性建议用 Homebrew 升级一下brew install rsync目录初始化直接用脚本内置命令mkdir -p ~/caveman/{conf,logs,backups} chmod x ~/caveman/caveman我这里没有用install命令做全局安装原因是这个工具和它的配置、日志、备份数据应该作为一个整体待在一起挪到/usr/local/bin只放脚本反而会让路径关系变乱。让它就住在~/caveman下一切数据都在自己的院子里迁移时直接拷贝整个目录即可。3.2 编写备份配置拿我的实际配置举例# 工作区 /home/user/workspace::workspace # 数据库导出 /var/backups/mysql::mysql # nginx 站点配置 /etc/nginx::nginx-config # 家庭照片库 /media/photo::photos写配置时有一个容易忽略的细节源路径末尾的斜杠。rsync 的语义是/home/user/workspace/带尾斜杠表示“拷贝目录内的内容”而/home/user/workspace不带尾斜杠表示“拷贝目录本身及其内容”。我在调用 rsync 时统一给源路径加了斜杠$src/所以配置里写不带斜杠的路径即可行为一致。这一点新手最容易踩建议看到这里的人直接记住决定“拷贝目录本身还是内容”的是命令末尾的斜杠不是配置文件的写法。3.3 手动运行与验证第一次运行推荐加-v手动执行~/caveman/caveman backup正常的输出应该是这样的[备份] workspace 开始 [备份] workspace 完成新增数据量 12.3MB [备份] mysql 开始 [备份] mysql 完成新增数据量 0KB [备份] 全部完成耗时 35s看到“全部完成”之后不要急着开香槟先做一次验证。进入backups/目录看每个标签下的时间戳目录find ~/caveman/backups -maxdepth 2 -type d | sort你会看到类似这样的结构backups/ ├── workspace/ │ ├── 2025-01-15_033000/ │ └── 2025-01-16_033000/ └── mysql/ └── 2025-01-16_033000/第一次备份必然是完整重放所有文件第二次再跑时观察du -sh结果新增目录的总大小应该远小于源目录总大小说明增量生效了。用ls -i查看某个未变化文件的 inode 编号会和上一个快照里对应文件完全一致这就是硬链接起作用的铁证。3.4 定时任务与恢复演练备份工具不做定时等于没有备份。我用 cron 实现最简单的定时策略# 每天凌晨 3:30 执行备份 30 3 * * * /home/user/caveman/caveman backup /home/user/caveman/logs/cron.log 21注意这里我写的是完整的绝对路径。cron 的 PATH 环境变量很精简通常只有/usr/bin:/bin如果你把 rsync 装在了/usr/local/bin不写绝对路径或者不显式设置 PATHcron 会报rsync: command not found但脚本因为set -e会静默退出日志里只留下一行模糊的错误。另一个细节是 ... 21这样标准输出和标准错误都会被追加到 cron 日志排查问题时有据可查。恢复演练是很多个人备份方案的死角。我建议你至少做一次完整恢复测试而不是等真出事再去研究。caveman 提供的恢复命令其实只是 rsync 的反向操作# 从最近的 workspace 快照恢复到本目录 ~/caveman/caveman restore workspace 2025-01-16_033000 /home/user/workspace实际上我更推荐恢复时直接手动敲 rsync因为恢复场景差异太大恢复到原目录、恢复到临时目录、只恢复部分子目录一个抽象命令反而限制了灵活性rsync -a ~/caveman/backups/workspace/2025-01-16_033000/ /home/user/workspace/这个过程和日常复制文件的感觉一样没有任何“恢复工具”的存在感——这就是硬链接快照方案最大的价值备份时省空间恢复时无感。4. 常见问题与排查技巧实录4.1 符号链接、硬链接与文件属性带来的坑用 rsync 备份时-a参数默认会保留符号链接本身而不解引用它指向的目标这通常是对的。但有一种情况很坑如果源目录里有符号链接指向/proc或/dev下的路径恢复出来的符号链接依然指向这些路径在备份机上看是“坏的”。这正常不用管。真正要注意的是绝对路径符号链接在恢复后会指向原机器的绝对位置如果你把备份恢复到另一台环境不同机器上这些链接大概率失效。解决方法是备份核心配置时确认源目录里没有脆弱的绝对符号链接或者把符号链接作为普通文件备份去掉-l参数但这样会丢失链接语义。我一般倾向于接受链接失效的现实因为配置文件恢复后手动重新指一下代价远比在备份方案里引入复杂处理逻辑小。4.2 增量备份越跑越大的排查有次我发现第二天的快照目录du出来比源目录还大而且每跑一次就大一倍。用du -sh对比多个快照目录后定位到是--link-dest的路径写错了导致每次备份都找不到上一次快照作为参考自然每次都全量拷贝。另一个导致“假增量”的常见原因是权限或时间戳变化。rsync 判断文件是否变化默认看大小和修改时间。如果你用-a保留了属主和权限但从另一台机器同步过来的文件因为 UID 不同被判定为“属性变化”它也会重新拷贝文件内容。这个问题的排查可以用# 比较两个快照目录中同一文件的 inode ls -i backups/workspace/2025-01-15_033000/file.txt ls -i backups/workspace/2025-01-16_033000/file.txt如果 inode 不同说明这个文件没有走硬链接属于“真变化”内容真的改了。如果 inode 相同说明硬链接生效。如果大量文件 inode 都不同但内容明明没改就去查文件属主、权限或 mtime 差异。4.3 cron 运行时的环境差异cron 下跑脚本最容易栽跟头的就是环境变量。我遇到过的情况脚本手动执行一切正常加到 crontab 后就日志为空或者日志只有一行提错误。原因就是PATH里没有/usr/local/binrsync 调不到。解决办法有两种任选其一# 方案一crontab 里显式设置 PATH 30 3 * * * PATH/usr/local/bin:/usr/bin:/bin /home/user/caveman/caveman backup # 方案二脚本开头强制锁定 PATH export PATH/usr/local/bin:/usr/bin:/bin第二个方案更稳妥因为 cron 之外还有 systemd timer、anacron 等触发方式它们的 PATH 可能又不一样。哪怕脚本里已经写了set -u也建议显式export PATH避免环境差异成为隐患。4.4 恢复文件的权限问题有一次我恢复 nginx 配置时因为当时是用普通用户执行的 rsync恢复后的文件属主变成了普通用户nginx 起来时报权限错误。后来我养成一个习惯恢复涉及系统目录时先检查原文件属主再用sudo rsync -a --numeric-ids来恢复。--numeric-ids参数很重要。它让 rsync 不去把 UID 翻译成用户名而是直接按数字 ID 恢复避免备份机和恢复机的用户数据库不一致时出现属主错乱。提示不要用chown -R去粗暴修正恢复出来的文件这个命令会连符号链接的目标也一起改非常危险。宁可在 rsync 命令里多花 10 秒想清楚属主和权限参数。5. 后续扩展思路5.1 增加完整性校验caveman 目前的校验机制依赖 rsync 的传输校验但传输校验不能防止“文件在备份完成后被篡改”的问题。如果你备份的是长期不动的重要档案可以加一个校验清单# 每次备份后生成校验文件 cd backups/workspace/2025-01-16_033000 find . -type f -exec sha256sum {} \; .checksum.sha256之后要验证时就跑sha256sum -c .checksum.sha256。对大多数家庭 / 小型办公场景这个做法已经够硬核了。不要过度追求每次都全量校验因为对于大目录来说sha256sum跑一遍的耗时可能超过备份本身。5.2 快照保留策略硬链接快照方案唯一的“缺点”是如果不做清理每次快照目录本身会越来越多虽然每份增量小但目录数量和部分文件 meta 信息会累积。我加了一个简单的清理函数# 保留最近 30 天快照其余删除 find $BACKUP_ROOT/$label -maxdepth 1 -type d -mtime 30 -exec rm -rf {} \;这个策略的优雅之处在于它只删“目录入口”不真正删数据——只要同一天内还有后续快照引用了这些硬链接数据块仍被保留。只有当所有引用该文件数据的快照都被清理后磁盘空间才会真的释放。5.3 一点通知能力备份完成后如果能给你发个消息你就不会再“担心它没跑”。最简单的方式是写一行摘要配合邮件命令mailx -s [caveman] 备份完成 youexample.com ${LOG_DIR}/latest_summary.txt或者接入微信群机器人 / Telegram Bot本质就是 curl 发一个 POST 请求。我个人的原则是只有当通知本身简单到不行时才值得加入项目。如果你需要写 50 行代码才能完成通知说明这个需求根本不重要。5.4 后续还能往哪走如果你照着这个思路继续做有几个方向值得尝试把备份根目录换成异地挂载的 NFS/SMB 共享就实现了异地备份在恢复命令上再包一层交互式列表选择就变成了一个更像“软件”的东西甚至可以把配置文件拆成多份每台机器有自己独立的备份清单但共享同一个 caveman 脚本。每一条路都比我一开始用 Python SQLite FastAPI 尝试做的“备份管理系统”务实得多。我个人在实际使用中的体会是这个工具最值钱的部分不是那几百行脚本而是“知道自己每一步在干什么”的确定性。商业软件再黑盒也不如自己手写方案出问题时能直接查看的最后一行日志来得踏实。如果你也准备动手写一个这样的工具我的建议是先把备份核心逻辑用最笨的方式跑通然后再考虑要不要封装千万不要一上来就设计得“面面俱到”。大多数人的备份需求真的不需要一个比备份本身更复杂的系统。