rm -rf 这条命令,我见过的翻车次数比任何一条 Linux 命令都多。它短,四个字符加一个减号再加两个字母,敲下去连个回车反馈都没有——没有进度条,没有确认提示,没有后悔药。等到你ls一下发现目录空了,事情已经发生完了,剩下能做的只有翻备份或者给客户写说明。这篇文章不讲花哨的,就把 rm -rf 从参数语义到系统调用、从路径解析到通配符展开、从误删之后的抢救窗口到日常防护手段,一层一层拆开讲清楚。不管你是刚接触 Linux 的新手,还是天天在服务器上敲命令的老手,只要你的日常里有"删东西"这个动作,Linux 文件删除的风险就和你有关——因为它的危险从来不是命令本身有多复杂,恰恰相反,是因为它太简单了,简单到人脑还没反应过来,磁盘上已经没东西了。
1. rm -rf 到底做了什么:从参数拆解到系统调用
1.1 -r、-f、-- 三个符号背后的语义差异
很多人把rm -rf当成一个整体词来记,实际上它是三个独立语义的叠加,每一个都在削弱你的安全边界。-r是 recursive,递归,意思是遇到目录就钻进去,把里面的东西全删完再删目录本身。没有-r的 rm 只能删文件,碰到目录会直接报 "Is a directory",这层保护其实挺有价值。-f是 force,强制,它做两件事:一是忽略不存在的文件,不报错也不返回非零退出码;二是取消对写保护文件的交互确认。也就是说,-f把"这个文件受保护,要不要继续"这最后一道人工确认给关掉了。
把这两个参数叠在一起,含义就变成了"递归地、不询问地、遇到什么删什么、删不到也不吭声"。最后那个--不是参数,它是选项终止符,告诉 rm 后面的内容一律当路径处理。这个符号在删奇怪文件名时是救命的,比如有个文件叫-rf,你直接rm -rf会被解释成参数,写成rm -- -rf才是删文件。我见过有人在脚本里拼接路径,结果用户输入的内容里带了减号开头的文件名,整条命令的参数解析就全乱了。
还有一个容易被忽略的默认行为:GNU coreutils 从很早就默认开启了--preserve-root,所以rm -rf /会被拒绝,报 "it is dangerous to operate recursively on '/'"。但这个保护只认字面量为/的参数。你写rm -rf /*,shell 会先把/*展开成/bin /boot /dev /etc ...一串具体路径,这个参数列表里没有任何一个是/本身,保护直接失效。这是个非常关键的认知差:很多人以为自己被保护了,实际上没有。
1.2 一条命令从终端到磁盘:删除动作在内核里怎么走
理解风险的前提是理解删除的成本有多低。你敲下回车之后,shell 先做词法拆分,再把rm这个可执行文件加载起来(通常是/bin/rm),接着 rm 对每个路径参数调用lstat判断类型,是目录就继续递归,是文件就调用unlinkat或者unlink系统调用。整个过程没有任何"预演"阶段,没有事务,没有撤销日志。
unlink在内核里做的是:从父目录的目录项(dentry)里找到这个名字,把它摘掉,然后把对应 inode 的链接计数减一。注意,减一不等于释放。只有当链接计数归零、并且没有任何进程还持有这个文件的打开文件描述符时,内核才会真正把 inode 和数据块标记为空闲。这就是为什么你删掉一个正在被服务进程写日志的文件,ls看不到了,但df显示的磁盘占用一点没降——文件还活着,只是从目录树上摘下来了,进程手里还攥着它。
再往下,文件系统层要做的事是更新 inode 位图和数据块位图,把这些块标记成可用。在 ext4 上,这个更新会进入日志(journal),但日志里记的是"这些块现在空闲了"这个元数据变更,不是文件内容本身。所以指望从日志里把文件内容捞回来是不现实的,日志能帮你的仅限于元数据层面。
整个链路里最讽刺的一点是:删除一个 100GB 的目录树,可能只需要几百毫秒,因为文件系统只需要改位图,不需要擦除数据;而复制这 100GB 要几十分钟。删除的成本极低,这是文件系统设计的必然结果,也是 rm -rf 危险的根本原因——它的操作代价和破坏后果严重不对等。
1.3 为什么 rm 没有回收站:inode、硬链接与"删除"的真实含义
习惯了图形界面的人第一次用 Linux 都会问同一个问题:删掉的东西去哪了?答案是什么地方都没去,它本来就不存在"移到回收站"这个动作。回收站是一个上层应用的概念,需要额外的存储空间和额外的元数据管理,而 POSIX 文件系统的设计哲学里,unlink就是一个原子的、立即生效的命名空间操作。
这里有个很好的心智模型:文件名不是文件,文件名只是指向 inode 的一个入口。同一个 inode 可以有多个名字,这就是硬链接。ln a.txt b.txt之后,两个名字指向同一份数据,你rm a.txt只是摘掉了一个入口,b.txt照样能读。链接计数这个概念在排查磁盘问题时特别有用——如果一个文件明明被删了但空间没释放,你可以用lsof +L1把所有"链接数为 0 但仍被进程打开"的文件列出来,这个命令我在生产环境用过很多次,每次都能精准定位到那个忘记 reload 的服务进程。
普通用户感知不到这一层,是因为绝大多数文件只有一个链接。但这层认知非常重要,它解释了为什么 rm 是"不可逆"的:一旦最后一个链接被摘掉、最后一个持有者关闭了 fd,inode 就被回收,数据块的归属信息就没了。此时磁盘上的字节可能还在,但文件系统已经不知道它们属于谁、有多长、从哪开始。这就是后面要讲的"恢复"之所以这么难的根本原因。
2. 最危险的写法:路径解析与通配符的暗坑
2.1 变量为空时,路径会自己"塌缩"成根目录
这是 rm -rf 事故里最常见、也最经典的一类。写一个清理脚本,路径放在变量里,看起来人畜无害:
#!/bin/bash TARGET_DIR=$1 rm -rf $TARGET_DIR/logs如果调用的时候忘了传参数,$TARGET_DIR是空字符串,展开后命令就变成了rm -rf /logs。这还算运气好的,因为/logs大概率不存在,-f会让它静默失败。真正致命的是这种写法:
rm -rf $TARGET_DIR/当变量为空,展开结果是rm -rf /。虽然有--preserve-root兜着,但这已经是在悬崖边上了。更可怕的是rm -rf ${TARGET_DIR}/*这种写法,变量为空时变成rm -rf /*,前面说过,这个写法能绕过--preserve-root。
正确的防御写法有三层,我建议全都用上:
set -u # 未定义变量直接报错退出 TARGET_DIR="${1:?path required}" # 为空或未设置时立刻终止 rm -rf -- "${TARGET_DIR:?}"/logs # :? 再兜一层${VAR:?message}这个语法很多人不熟,它的语义是"如果 VAR 未设置或者为空字符串,就把 message 打到 stderr 并让当前 shell 退出"。注意它和${VAR?message}的区别:带冒号的版本连空字符串也拦,不带冒号的只拦未设置。删除场景下必须用带冒号的版本,因为TARGET_DIR=""和TARGET_DIR未设置导致的后果是一样的。
还要提醒一句,set -u只能拦住未设置,拦不住显式赋值为空。所以两个手段要配合用,别指望一个搞定。我在自己的脚本模板里,凡是涉及删除的变量,一律走${VAR:?},这已经变成肌肉记忆了。
2.2 通配符展开发生在 rm 执行之前
第二个高频误区是把*当成 rm 的能力。不是的,*是 shell 的 glob,在 rm 启动之前就已经展开成一串具体文件名了。rm 收到的从来不是星号,而是展开后的列表。理解这个顺序,很多诡异现象就说得通了。
rm -rf *不匹配以点开头的隐藏文件,这是 glob 的默认规则。所以你以为清空了目录,.git、.env、.ssh全都还在。很多人因此写出rm -rf .*,这更危险:在 bash 里.*会展开成.、..以及所有隐藏文件。GNU rm 会拒绝删除.和..并报错,但剩下的.bashrc、.config、.ssh照样会被删掉。如果你的家目录是当前目录,这一条下去基本等于重装系统。
rm -rf dir/*还有个隐蔽行为:如果dir是空的,glob 匹配不到任何东西,bash 默认会把dir/*原样保留作为字面量传给 rm,rm 找不到这个文件,加上-f就静默成功了。看起来没事,但如果你的脚本后续依赖"目录已经被清空"这个状态,或者dir因为某种原因变成了文件,行为就完全不可预期。可以在脚本开头加shopt -s nullglob,让匹配不到时展开为空,行为更可预测,但空展开配合 rm -rf 又会变成"什么都不删",得看你想要哪种语义。
真正稳妥的做法是绕开 glob,用 find:
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -print先-print看一眼输出,确认无误再把-print换成-delete。这个"先打印后删除"的两步习惯,帮我避免过至少三次事故。多敲一次回车,换来的是可以复核的机会。
2.3 空格、连字符、换行:文件名里的隐形杀手
Linux 文件名允许除/和\0之外的任何字节,包括空格、制表符、换行符、减号开头。这些在 rm 面前全是地雷。
空格的问题出在 shell 的单词拆分。rm -rf $DIR如果DIR的值是/data/my logs,会被拆成两个参数/data/my和logs,于是你删掉了一个叫/data/my的东西(如果存在),同时在当前目录下找logs。加引号能解决:rm -rf "$DIR"。这条规则简单,但在长脚本里经常被忘掉,特别是路径是函数返回值的时候。
减号开头的问题前面提过,用--终止选项解析。另外还有个小技巧,给路径加./前缀,rm -rf ./-rf也能正常工作,因为参数不再以减号开头了。
换行符的问题最阴。文件名里带换行,ls输出会错位,for f in $(ls)这种写法会被拆得七零八落,xargs 默认也按空白拆分。正确姿势是全程用 NUL 分隔:
find "$DIR" -type f -print0 | xargs -0 rm -f-print0和-0这一对是处理任意文件名的标准组合。如果你的清理逻辑是自己写的循环,那也应该用while IFS= read -r -d '' f来遍历,而不是for f in $(...)。这个细节平时不影响功能,出事的时候影响的是"你到底删了什么"。
2.4 相对路径与当前工作目录:cd 失败后继续执行的脚本
有一类事故特别隐蔽:脚本本身逻辑是对的,但执行环境不对。典型写法是:
cd /data/backup rm -rf *如果/data/backup因为挂载失败或者权限问题cd不进去,bash 会打印一条错误,然后继续执行下一行。此时当前目录还是脚本启动时的工作目录,rm -rf *就把那个目录清空了。如果这个脚本是从/或者某个业务根目录启动的,后果你自己想。
修法很简单:用&&而不是换行或者;,或者在脚本开头加set -e。但set -e有一堆边界情况不生效(比如命令在if条件里、在管道非末位、在&&左侧等),所以我更倾向于显式写:
cd /data/backup || { echo "cd failed" >&2; exit 1; } rm -rf -- ./*还有个相关的小知识:cd到一个软链接目录之后,..指向的是物理父目录,不是软链接所在的父目录。如果你cd /var/log/app而app是指向/mnt/data/app的软链接,此时rm -rf ../*删的是/mnt/data/*。这类行为在写清理脚本时一定要心里有数,最稳的办法是全程用绝对路径,别依赖cd和相对路径的组合。
顺带说一个常见误解:rm -rf somelink只删除软链接本身,不会顺着链接删目标内容。但rm -rf somelink/(带尾斜杠)会在某些情况下被解析成目录,行为就变了;而rm -rf somelink/*会穿过链接删掉目标目录里的东西。所以删软链接时,别加斜杠,别加星号。
3. 误删之后:文件还能不能救回来
3.1 第一动作不是找工具,是止血
我在群里见过太多次这样的对话:有人说自己 rm -rf 删错了目录,下面立刻一堆人推荐各种恢复工具。这个顺序是错的。误删之后最紧急的事,是阻止任何新的写入,因为每写入一个字节,都可能覆盖掉你要恢复的数据块。
具体做什么:如果删的是数据盘,第一时间mount -o remount,ro /data把分区改成只读;如果整个系统都在这个盘上,能停机就停机,不能停机至少把相关服务停掉,把日志输出重定向到别的分区。别在这个盘上再装恢复工具,别在那个目录下再执行任何命令,连ls都尽量少做——虽然ls本身不写数据,但它会更新 atime,某些挂载配置下这就是一次写入。
如果条件允许,最稳妥的做法是把整块盘做镜像,然后在镜像上操作:
dd if=/dev/sdb of=/mnt/rescue/disk.img bs=4M status=progress镜像的过程是只读的,不影响原盘。之后所有恢复尝试都在disk.img上做,试错成本为零。这个流程听起来重,但对于真正重要的数据,它比反复在原盘上折腾要靠谱得多。
还有个现代硬件带来的新问题:SSD 的 TRIM。如果文件系统挂载时带了discard选项,或者系统定期跑fstrim,那么删除动作会通知闪存控制器把对应块的物理擦除排进队列。这种情况下数据可能在你反应过来之前就已经物理消失了,恢复概率大幅下降。所以 SSD 上误删,时间窗口比你想象的短得多,止血要更快。
3.2 ext4 下为什么比 ext3 难恢复
老资料里经常推荐 extundelete 这类工具,它们在 ext3 时代确实好用。原因是 ext3 的 inode 里存的是一组直接块指针加间接块指针,删除时这些指针虽然被清零,但文件系统日志里往往还留着 inode 的旧副本,工具可以通过比对日志把指针拼回来。
ext4 默认启用了 extent 树,一个文件的块映射被组织成一棵树,删除时整棵树的信息被清掉。日志里能留下的线索少了很多,恢复工具的成功率因此明显下降。而且 ext4 默认开了延迟分配和多块分配,文件在磁盘上的布局比 ext3 更分散,即使恢复了 inode,块内容也可能已经被部分覆盖。
XFS 又是另一套逻辑,它的 B+ 树结构和延迟日志机制让传统的 inode 恢复思路基本失效。所以现实是:文件系统越现代,误删恢复越接近于不可能。这不是工具不够好,是设计上就没打算让你恢复。想清楚这一点,你就知道把精力放在"删之前"比放在"删之后"划算得多。
3.3 常见恢复工具的能力边界与实操思路
工具不是不能用,但要对它们的输出有合理预期。粗略分两类:
| 工具类型 | 代表工具 | 依赖的信息 | 能恢复什么 | 主要局限 |
|---|---|---|---|---|
| 元数据恢复 | debugfs、extundelete | inode、日志、目录项残留 | 文件名、目录结构、近期删除的文件 | ext4 上成功率低,越晚越差 |
| 文件雕刻 | PhotoRec、foremost | 文件内容签名(magic number) | 能识别格式的文件内容 | 丢失文件名和路径,碎片化文件难还原 |
元数据恢复的思路是先找到被删 inode 的残留信息,再把块指针或 extent 拼回来。debugfs是最底层的入口,可以交互式地看 inode 表、块位图:
debugfs -R "lsdel" /dev/sdb1lsdel会列出最近被删除但 inode 还没被复用的条目。注意这个命令在 ext4 上经常返回空,因为 ext4 在 unlink 时会主动清掉 inode 里的块映射,lsdel依赖的那部分信息已经不在了。这时候只能靠日志里的元数据副本,工具会自动去扫,但能不能扫出来全看运气和删除后经过了多久。
文件雕刻是另一条路,完全放弃文件系统元数据,直接按扇区扫,找 JPEG 的FFD8FF、PDF 的%PDF、ZIP 的PK\x03\x04这类签名,从签名位置往下按格式结构解析出文件长度。它能捞回内容,但文件名、目录层级、修改时间全丢,而且如果文件在磁盘上不连续,恢复出来的图片可能只有上半截。我在一次事故里用这种方式捞回过一批日志文件,能读,但要靠内容里的时间戳自己重新组织,工作量不小。
实操顺序我建议这样:先做镜像,再在镜像上跑元数据恢复(快,如果成功代价最小),元数据恢复不出来再跑文件雕刻(慢,但覆盖面广)。整个过程别在原盘上做,别装工具到原盘上,别开任何会写盘的服务。
3.4 比恢复更靠谱的:从备份和快照里捞
说句实在话,我从业这些年,真正靠恢复工具救回来的数据,比例低得可怜。绝大多数成功案例都是因为有备份或者快照。这不是泼冷水,是让你把力气用在对的地方。
LVM 快照是最轻量的方案,创建几乎瞬时,占用空间按写入量增长:
lvcreate -L 20G -s -n data_snap /dev/vg0/data代价是需要预留空间,如果快照卷被写满,快照会失效,而且失效得悄无声息。所以监控快照使用率是必须的,别建了就不管。
btrfs 和 zfs 的快照更自然,因为是文件系统原生能力,空间共享,几乎零成本。ZFS 的zfs snapshot pool/data@before-cleanup这种"操作前先打个快照"的习惯,一旦养成,删除操作的心理压力会小很多。我现在但凡要执行批量删除,先打快照,再执行,这个顺序已经刻进流程里了。
云环境的磁盘快照也是同样思路,只是要注意快照是一致性快照还是崩溃一致性快照——如果快照时文件系统还在写,恢复出来可能需要 fsck。对于数据库这类应用,要么停写,要么用应用层的备份工具,别指望块级快照能直接给出一个干净的数据目录。
4. 给 rm 上锁:几层真正能挡住误操作的手段
4.1 alias 和包装脚本能挡住的与挡不住的
网上最常见的建议是alias rm='rm -i'。这招在交互式 shell 里确实有用,每次删除都问你一句,逼你停下来想一秒。但它有三个明确的失效场景,必须知道。
第一,脚本里不生效。alias 是 shell 的交互特性,非交互式 shell 默认不展开 alias(bash 里要shopt -s expand_aliases才行,而且即便开了,脚本里的 alias 也不一定按你预期工作)。所以定时任务、部署脚本、CI 流程里的 rm 完全不受保护。
第二,会被绕过。\rm、command rm、/bin/rm、find -delete、unlink,随便哪个都能跳过 alias。它不是安全边界,只是提醒。
第三,会让人麻木。一天问两百次"是否删除",第三周你就会条件反射地按 y,保护效果归零。这是人因工程里的经典问题:过多的确认提示会让确认动作本身失去意义。
比 alias 稍好一点的是包装函数,可以加上路径白名单、大小判断、日志记录:
rm() { local p for p in "$@"; do case "$p" in -*) continue ;; esac if [[ "$p" == "/" || "$p" == "/data" || "$p" == "/etc" ]]; then echo "refused: $p" >&2 return 1 fi done command rm "$@" }这段代码能拦住最明显的几条,但它的定位仍然是"降低误操作概率",不是"保证不可能删错"。任何在 shell 层面做的防护,都能被绕过,也都有可能被自己的逻辑漏洞绕进去——比如上面这段只检查了参数里有没有危险路径,如果有人传rm -rf /data/*,展开后的参数是/data/app1 /data/app2,白名单检查直接通过。
4.2 用权限和目录结构做硬约束
软约束靠不住,就得靠硬约束。硬约束的核心思想是:让执行删除的那个身份,根本没有删除的权限。
第一条,别用 root 干日常活。这条说了几十年了,但现实中还是有人图省事直接 root 登录。日常操作走普通账号,需要提权时用 sudo,并且 sudoers 里限制具体命令,别给ALL=(ALL) NOPASSWD: ALL。这样至少多了一道密码或者审批环节,人在冲动的时候有个缓冲。
第二条,权限分离。业务进程跑在独立用户下,数据目录的属主是业务用户,属组是运维组,权限设成750或者770。这样运维账号如果不加入属组,就没有写权限,rm 会直接报 Permission denied。这层保护是内核级的,绕不过去。
第三条,用 immutable 属性保护关键目录:
chattr +i /data/critical加了i属性的目录,连 root 都不能在里面创建、删除、重命名文件,必须先用chattr -i解除。这个操作要显式执行,等于给高危操作加了一道"仪式感"。我在几台关键机器上对/etc下的几个核心配置目录用过这招,确实挡住过一次手滑。
但要提醒它的副作用:immutable 会连正常的运维操作一起挡住,比如包管理更新配置、日志轮转写入。所以只对真正静态的目录用,别一股脑全加上,否则三个月后你自己都忘了为什么某个服务起不来。
第四条,只读挂载。对于备份盘、归档盘这类"只写一次读多次"的场景,直接mount -o ro挂载,物理上杜绝删除。需要写入时临时 remount 成 rw,用完再切回 ro。这个习惯对长期归档数据特别有效。
4.3 trash-cli:给 Linux 装一个回收站
如果你的场景是个人开发机或者交互式操作,trash-cli 是个很实用的折中方案。它提供trash-put、trash-list、trash-restore、trash-empty一整套命令,删除时把文件移到~/.local/share/Trash下,遵循 freedesktop 规范,图形界面和命令行能共享同一个回收站。
trash-put ./build trash-list trash-restore trash-empty 30 # 清掉 30 天前的配合 alias 用起来体验接近桌面系统:
alias rm='trash-put'但它的局限同样要清楚。第一,跨文件系统移动会变成复制加删除,删大目录会很慢,甚至因为空间不足失败。第二,回收站目录本身会占空间,如果删的是几百 GB 的数据,磁盘可能先被回收站撑满。第三,脚本里依然不生效,除非你显式调用trash-put。第四,需要额外的清理策略,否则回收站会无限膨胀。
我的用法是:交互式终端里 alias 到 trash-put,脚本里一律用rm但走前面说的变量防护那一套。两套机制针对两种场景,别指望一个方案通吃。
4.4 备份与快照:最后一道,也是唯一可靠的一道
前面所有手段,本质都是在降低"删错"的概率。只有备份和快照,是在解决"删错了怎么办"。这两件事的优先级完全不同:前者是锦上添花,后者是底线。
最基本的原则是 3-2-1:三份数据,两种介质,一份异地。落到实操上,很多团队做得不到位的地方不是"有没有备份",而是"备份能不能恢复"。我见过备份任务跑了两年,真出事的时候发现备份文件全是 0 字节,因为源目录早就改名了,rsync 一直在同步一个空目录。所以定期做恢复演练是必须的,哪怕一个季度随机抽一个文件恢复一次,也比只看备份任务的成功日志强。
还有一类特别隐蔽的坑:rsync --delete用错了同样会清空数据。如果源路径写错或者源目录被挂载失败变成了空目录,rsync -a --delete /empty/ /data/会把/data清得干干净净,效果和 rm -rf 一样,而且速度更快。所以带--delete的同步任务,源路径的可用性检查一定要做,别裸奔。
5. 几个翻车现场复盘与我自己的操作纪律
5.1 定时清理脚本把业务目录扫空了
一个很典型的案例:某台机器磁盘告警,运维加了个定时任务清理日志,写的是find /data -name "*.log" -mtime +7 -delete。本意是清/data/logs,少写了一级。结果/data下面所有超过七天的.log文件全被删了,包括某个业务自己生成的、用来做数据回放的历史日志。等发现的时候已经过了两周,备份策略只覆盖数据库不覆盖文件目录,彻底没了。
这个案例里值得复盘的点有三个。第一,-name "*.log"的范围太宽,没有一个"只在这个目录下"的边界。第二,-delete是立即生效的,没有先-print预览。第三,定时任务上线前没有在测试环境跑过一次 dry-run。
改进后的写法至少应该是这样:
find /data/logs -maxdepth 1 -type f -name "*.log" -mtime +7 -print先跑一周,看输出是否符合预期,确认无误再换成-delete,并且加上-maxdepth 1限制深度。这个流程听起来慢,但比事后解释快得多。
5.2 变量拼接导致的路径越界
另一个案例是部署脚本里的清理逻辑:
APP_HOME=/opt/apps rm -rf ${APP_HOME}/${APP_NAME}/tmp某次发布时APP_NAME因为配置文件读取失败变成了空,命令就成了rm -rf /opt/apps//tmp。双斜杠在路径里会被规范化,实际删的是/opt/apps/tmp。如果/opt/apps下恰好有个同名的共享临时目录,那就一起没了。更坏的情况是APP_HOME也空了,命令变成rm -rf //tmp,等价于/tmp。
这类问题的通用解法是:任何参与路径拼接的变量,在使用前都要做非空校验。我现在写脚本的习惯是,所有从环境变量、配置文件、命令行参数来的路径片段,进函数第一件事就是校验:
require_var() { local name=$1 value=$2 if [[ -z "$value" ]]; then echo "required variable $name is empty" >&2 exit 1 fi if [[ "$value" == *..* || "$value" == /* ]]; then echo "invalid path fragment: $value" >&2 exit 1 fi }后面那个检查是防止路径穿越,虽然对内部脚本来说有点过度,但成本极低,加上不亏。
5.3 多人协作服务器上的"我以为"
还有一类事故不是技术问题,是沟通问题。几个人共用一台机器,A 以为自己建的临时目录只有自己在用,删之前也没问;B 恰好把一个中间结果放在里面跑批处理。A 一个rm -rf /data/tmp/下去,B 的批处理第二天才发现失败了,因为脚本里没检查中间文件是否存在,直接拿空输入跑出了错误结果。
这类问题的解法不在命令层面,在流程层面。我待过的团队后来都推了几条规矩:共享机器上任何/data下的目录都要有属主说明,写在一个 README 里;删除超过一定大小的目录前在群里同步一句;临时目录统一放在/data/tmp/<用户名>/下,每个人只删自己的。这些规矩看起来很土,但确实把这类事故降到了接近零。
技术上还能加一层:给共享目录配 sticky bit(chmod +t),这样用户只能删除自己拥有的文件,删不了别人的。/tmp默认就是这个配置,很多人不知道原理,其实这就是它的保护机制。
5.4 我给自己定的几条规矩
踩过足够多的坑之后,我现在执行删除操作有一套固定的动作序列,写出来给需要的人参考。
只要涉及rm -rf并且目标不是自己刚创建的东西,先ls一遍。不是ls目录名,是ls那个具体路径,看清楚里面到底有什么。这一步三十秒,能过滤掉大部分"我以为里面是空的"。
路径一律用绝对路径,一律加引号,一律用${VAR:?}兜底。这三个"一律"是死规矩,没有例外。
超过一个目录的批量删除,先跑 dry-run。find ... -print、rsync --dry-run、rm前面加个 echo 看看展开成什么,都是有效手段。看一遍输出的成本,远低于恢复的成本。
重要操作前打快照。没有快照能力的环境,至少把要删的目录mv到一个带时间戳的临时位置,观察一两天确认没影响再真删。mv是原子的,比rm安全得多,这个技巧在空间允许的情况下非常实用。
最后一点,也是最难做到的:别在疲惫的时候做删除操作。我自己的两次手滑,一次是凌晨三点处理故障,一次是连续开会到晚上十点。人在疲劳状态下的路径判断能力会明显下降,这时候最该做的是把命令写下来,第二天再执行。
顺带分享一个小工具,rm -I(大写的 i)。它和-i的区别是只在删除超过三个文件或者递归删除时才询问,日常单个文件删除不会打扰你。这个折中方案比rm -i实用得多,我在几台跳板机的.bashrc里都把rmalias 到了rm -I,既保留了提醒,又不至于让人麻木。真正需要批量删除时,显式写command rm -rf绕过它,这个"多打几个字"的动作本身就是一次确认。