rsync 退出码 11 排查实战:从 ENOSPC 到脚本语法 bug 的完整链路

2026-07-09

背景

我有一个自动备份脚本,每周二凌晨执行,使用 rsync --link-dest 将 Docker 容器的配置数据(约 30+ 个容器)增量备份到一块 USB 硬盘上。--link-dest 的工作原理是通过硬链接共享未变动的文件,这样每次增量备份只占用实际改动的空间。

2026年7月7日(周二)凌晨 05:00,备份脚本退出码 11,触发了失败通知。

第一个直觉:查磁盘

rsync 退出码 11 对应 error in file I/O。第一反应可能会怀疑文件权限变动、路径变更、或者磁盘坏道——但经验告诉我,先查空间。

SSH 到服务器检查 USB 设备:

mount /dev/sdb1 /tmp/backup
df -h /tmp/backup

输出显示:

Filesystem      Size  Used Avail Use%
/dev/sdb1        15G   15G  228M  99%

15GB 的 USB 硬盘,已经用了 99%,只剩 228MB。第一个根因锁定:空间不足。

但问题是,这 228MB 理论上可能够一次增量备份——如果只有少量文件变更的话。为什么 rsync 会撑爆?我们继续往下挖。

备份完整性分析

对比备份目录后发现了一个奇怪的现象:

备份日期 大小 文件数 状态
6月30日(完整) 5.0G 61,740 完整
7月7日(当前) 680M 9,073 不完整
最新软链接 未更新,仍指向6月23日

7月7日的备份只写入了 680M/9,073 个文件就停了——rsync 写到一半磁盘满了,被 ENOSPC(No space left on device)杀死。退出码 11 确实是经典的空间不足信号。

但更让人疑惑的是:6月30日明明有一个 5.0G 的完整备份,为什么 latest 软链接还指向 6月23日?难道 6月30日也失败了?

深挖 6月30日的异常

仔细看脚本逻辑:

  1. rsync 执行增量备份(--link-dest 硬链接模式)
  2. rm -rf latest 删除旧软链接
  3. ln -s 创建新软链接指向最新备份
  4. 执行 df -hdu -sh 检查空间
  5. 卸载 USB

推论:6月30日 rsync 靠硬链接撑过了增量写入,但脚本后续步骤(步骤 2-4)在磁盘接近 100% 时,某个命令返回了非零退出码。而脚本开头启用了 set -o errexit——任何非零退出码都会让脚本立即终止。结果 latest 软链接从未被更新。

手动验证触发更多发现

清理掉不完整的 7月7日备份(释放 241MB),剩余空间 469MB。然后手动执行备份脚本:

rsync: write failed on ".../pack-8513cb83...pack": No space left on device (28)
rsync error: error in file IO (code 11) at receiver.c(374) [receiver=3.1.3]

469MB 还是不够。 为什么一次增量备份需要这么多空间?

追踪后发现:青龙面板(一个定时任务管理平台)下有三个完整的 Git 仓库——每个仓库的 .git/objects/pack/*.pack 文件都有数百 MB。每次 git fetch/pull 都会生成新的 pack 文件。rsync --link-dest 遇到这些新 pack 文件时,没办法硬链接(因为文件内容变了),必须真写。一次就把磁盘撑爆了。

这就是 --link-dest空间幻觉——硬链接增量备份看起来”不占空间”,但变动的文件需要真写新数据。Git 仓库的 pack 文件是增量写入的”暗雷”。

多轮空间清理

执行了 8 轮清理,从删除旧备份到排除不必要的目录:

轮次 操作 释放 累计剩余
初始 228MB
1 删不完整备份 +241MB 469MB
2 删最旧备份 +751MB 1.2GB
3 手动测试(失败)
4-6 继续清理旧备份 +1.9GB 3.1GB
7 清理排除目录的残留 +1.1GB 3.5GB
8 新增排除规则 +200MB 3.7GB

最终剩余 3 个完整备份,3.7GB 可用空间(76%)。

脚本语法 bug:第二个根因

清理空间后,我重新审查了备份脚本,发现两个问题:

问题 1:排除规则重复

--exclude=".cache"
--exclude=".cache"    # ← 重复行

一个简单的 copy-paste 残留,不影响功能但说明脚本缺少审视。

问题 2:awk 引用错误

脚本中有一个计算剩余空间的命令,使用了多层引号嵌套(本地 Shell → SSH → 远端 Shell)。在多层嵌套下,$4$2 的转义在特定环境下可能出现引用错误。加上 set -o errexit,一旦 awk 或 df 因为磁盘满返回异常,脚本就提前退出了——这也是 6月30日 latest 没更新的根因。

踩坑总结

这条排查链路涉及 7 层推理,从最表面的”退出码 11”一路挖到脚本语法细节。几个值得记住的教训:

1. rsync 退出码 11 先查磁盘

不是文件权限、不是路径变更——遇到退出码 11,df -h 应该是第一反应。80% 的情况是空间不足。

2. --link-dest 不防 Git 仓库

硬链接对未修改的文件有效,但 Git 仓库的 pack 文件每次 fetch 都会变化。备份前排查已知的大目录(特别是 .git/objects/pack/),提前排除或单独处理。

3. set -o errexit 是一把双刃剑

它在正常情况下让脚本更安全——任何错误都会终止。但磁盘满这种渐进式故障下,它会让脚本在中间步骤就崩了,连清理和标记都来不及做。考虑用条件判断代替全局的 set -e,或者给关键命令加 || true 兜底。

4. 增量备份要预留 2x 空间

一次完整增量写入可能比你想象的大得多。特别是当备份目录包含 Git 仓库、node_modules 等频繁变动的目录时。保守估算:预留一次完整增量写入的 2 倍空间。

5. 定期审查排除规则

随着容器不断增加,备份脚本的排除规则也需要同步更新。本例中新增了一个 dep_cache 排除规则,释放了约 200MB——这说明之前的排除规则已经落后于实际部署了。

最终成果

指标 修复前 修复后
磁盘剩余空间 228MB (1%) 3.7GB (76%)
备份数量 7个完整+1个不完整 3个完整
排除规则 7条 8条(新增大目录)
最新软链接 指向6月23日 指向最新备份
脚本语法 存在引用错误 已修复

一个 rsync 退出码 11,牵扯出磁盘满载、硬链接空间幻觉、脚本语法错误三个层次的问题。排查过程本身就是最好的学习材料——下次遇到类似的备份失败,你会从哪一层开始查?