0. 阅读须知
这篇文章是给一线运维写的“事故复盘 + 可落地手册”,不是科普文。整篇会沿着一条真实的故障时间线展开:从误删发生的第一秒讲起,到工具准备、磁盘保护、文件系统选择、恢复命令、风险判断、验证、复盘,最后落到防误删的工程实践。文中所有命令、配置、参数,都按 RHEL/CentOS 7/8、Ubuntu 20.04/22.04 上的常见环境编写;不同发行版、不同文件系统、不同内核版本在具体行为上可能略有差异,关键步骤我都标了“以实际环境为准”。
文中涉及的所有破坏性操作(包括但不限于:dd、mkfs、umount、fuser -k、lvremove、zfs destroy、kubectl delete、iptables -F、rm -rf 演示等)都已经脱离生产环境,演示前请务必确认在测试机或快照上操作。生产环境操作必须经过变更审批、备份、灰度、回滚预案四步走。
阅读过程中如果只想看“立即能用的命令”,可以跳到第 11 章《应急工具包与一键脚本》;如果想了解“为什么 rm -rf 几乎不可逆”,可以重点看第 3、4 章;如果想搭建防御体系,看第 15、16 章。
1. 事故背景:那条 rm -rf 是怎么敲下去的
事情发生在某周二凌晨 02:17,运维小 A 在清理一台老旧的备份服务器。这台机器是公司两年前采购的,用于承接应用层每天 02:00 的 mysqldump 输出,然后通过 rsync 推送到远端异地机房。机器是典型的“用着没出过事、所以半年没巡检”的状态。
起因是一个看似合理的清理任务:上一任运维离职时留下了一段在 /opt/scripts/cleanup.sh 里的脚本,里面有一行核心逻辑是:
find -L /data/backup -type d -mtime +30 -exec rm -rf {} \;
find -L 是上一任运维加的,本意是“跟软链,免得漏清”。这条命令的初衷是清理 30 天前的备份目录。但小 A 接手后做了一次目录重构,把 /data/backup 拆分成了:
/data/backup/mysql:mysqldump 的 SQL 文件
他只是改了 cleanup.sh 里的路径,把 /data/backup 改成了 /data/backup/tmp,因为他觉得临时目录总归要清。但这一步没有经过 Code Review,也没有在预发环境演练过。
当天凌晨 02:00,cron 触发了清理任务。02:17,小 A 收到远端机房同事的微信:你们的备份没过来。
他 ssh 上去一看,/data/backup/mysql 和 /data/backup/app 都空了。/data/backup/logs 还在。脚本里的 find -L ... -exec rm -rf {} \; 在两个被软链引用的目录上执行了——而这个软链是上一任运维为了偷懒做的:
/data/backup/tmp -> /data/backup
也就是说,/data/backup/tmp 是个指向父目录的软链。find -L 主动跟软链,等于把 /data/backup 当成“临时目录”展开去清理,对 /data/backup 下的所有子目录执行了 rm -rf。/data/backup/logs 之所以还在,是因为上一次 cleanup.sh 在 find 走到 logs 目录时刚好被某个后台进程打开了几个文件,rm -rf 删掉了目录的硬链接但文件 inode 还被进程持有,看起来“还在”,其实只是一个假象。
更糟的是,凌晨 02:30,远端异地机房的 rsync 接收端因为拿不到文件,触发了告警:连续三次推送失败,监控把告警升级到了 P1。
这就是这次事故的完整背景。下面进入正文,从事故响应的第一秒讲起。
2. 事故响应的第一秒:先停下来,再开始救
误删发生后,新人最容易犯的错是“马上动手救”。这是非常危险的直觉,因为绝大多数“想当然的恢复操作”反而会扩大事故。正确的第一反应是按顺序做下面五件事。
2.1 保持现状,不要重启
重启会让内存中的 page cache 全部丢弃,原本还活着的 inode 信息(文件被打开时内核仍持有)会随着进程退出而消失。所以:
- 不要
init 6 / shutdown -r now - 不要
sync(虽然 sync 本身不会丢数据,但会让内核尝试把脏页回写,如果磁盘本来就有压力,反而会触发一些不可预期的写)
正确的做法是:先观察,不要做任何状态变更。
2.2 立即隔离写流量
如果误删的是关键业务目录,要做的第一件事是停止该机器上一切会继续往这块盘写入的进程。对于我们这个案例,具体动作是:
# 1. 停掉 cron,避免 cleanup.sh 二次执行systemctl stop crond# 2. 停掉 mysqldump 的定时任务(如果有 systemd timer)systemctl list-timers | grep -i mysql# 3. 停掉应用层的归档/上传/打包任务# (以你们实际的进程名为准)ps -ef | grep -E "tar|rsync|mysqldump|java|python" | grep -v grep
如果业务不能停,至少要通过 systemctl mask 把定时任务彻底屏蔽:
systemctl mask crond
mask 会创建一个指向 /dev/null 的软链到 service 文件,cron 不再能被 start,直到 unmask。
2.3 把机器从负载均衡/调度池摘掉
如果这台机器在 LVS、Nginx upstream、Kubernetes pod、Service 节点池里,要立刻摘流量。这一步不是技术问题,是组织流程问题:
我们这次事故里,这台备份服务器不在业务主链路上,摘流操作比较简单,直接在备份调度系统里把它置为“不可用”即可。
2.4 记录现场状态
把当时能收集到的状态全部记下来,便于后续复盘和评估恢复方案:
# 1. 当前时间、uptime、负载dateuptime# 2. 磁盘使用情况df -hdf -i# 3. 内存使用free -h# 4. 进程列表ps auxf > /tmp/ps_auxf_$(date +%Y%m%d_%H%M%S).txt# 5. 已挂载文件系统mount > /tmp/mount_$(date +%Y%m%d_%H%M%S).txtcat /proc/mounts >> /tmp/mount_$(date +%Y%m%d_%H%M%S).txt# 6. 打开的文件描述符(重要,可能还有活口)lsof > /tmp/lsof_$(date +%Y%m%d_%H%M%S).txtlsof /data/backup > /tmp/lsof_data_backup_$(date +%Y%m%d_%H%M%S).txt# 7. 磁盘 IO、块设备iostat -dx 1 3 > /tmp/iostat_$(date +%Y%m%d_%H%M%S).txtlsblk > /tmp/lsblk_$(date +%Y%m%d_%H%M%S).txt# 8. 文件系统类型blkid > /tmp/blkid_$(date +%Y%m%d_%H%M%S).txtfile -s /dev/sd* >> /tmp/blkid_$(date +%Y%m%d_%H%M%S).txt# 9. fstab 配置cp /etc/fstab /tmp/fstab_$(date +%Y%m%d_%H%M%S).bak# 10. 内核日志dmesg > /tmp/dmesg_$(date +%Y%m%d_%H%M%S).txtjournalctl -k --since "1 hour ago" > /tmp/journal_kernel_$(date +%Y%m%d_%H%M%S).txt
这些信息看起来很啰嗦,但每一项都有用。后面判断“还能不能恢复”时,/proc/mounts 可以告诉你挂载方式,lsof 还能告诉你哪些文件被进程持有,blkid 告诉你文件系统类型,iostat 告诉你是否还有大量写 IO。
2.5 通知相关方
误删属于 P1 级别事故,必须在第一时间通知:
通知内容要包括:时间、影响范围、初步判断、当前正在做什么、预计下一步动作。不要隐瞒、不要美化、不要在群里反复讨论细节。具体的故障复盘可以在事故结束后单独开 review。
3. 为什么 rm -rf 几乎不可逆
在讲恢复之前,必须先理解“为什么 rm -rf 这么难救”。很多新人以为 rm -rf 跟 Windows 的“Shift + Delete”差不多,实际上差异巨大。
3.1 Linux 文件删除的本质
Linux 下“删除一个文件”并不是把磁盘上的字节清零,而是做三件事:
- 如果链接计数降到 0,文件变为“unlinked”状态,等待被回收
只有当文件 unlinked 且没有被任何进程持有(open file count = 0)时,内核才会把这个 inode 释放回 inode 位图。inode 被释放后,对应的磁盘块才会被标记为 free,加入到空闲块池里,等待被新数据覆写。
所以“删除”只动了 inode 和目录项的元数据,磁盘上的实际数据是完整保留的——直到被新数据覆盖。
3.2 ext4 文件系统下 rm -rf 的实际动作
以 ext4 为例,rm -rf 在 VFS 层依次触发:
vfs_unlink / vfs_rmdir -> ext4_unlink / ext4_rmdir -> ext4_delete_entry(删目录项) -> ext4_dec_count(链接计数 -1) -> 如果是文件:调用 ext4_free_inode_after_ordered 之类函数,把 inode 标为 free -> 把对应的 block 标为 free
关键点:ext4 在删除时只是把 block 标记为空闲,并不会清零 block 内容。所以只要 block 没被新数据覆盖,理论上就能恢复。
3.3 xfs 文件系统下的差异
xfs 的元数据布局跟 ext4 完全不同:
- xfs 使用 B+ 树管理 inode(AGI)和 block(AGF)
- xfs 的目录项存放在 dir2 格式中,删除动作比 ext4 更复杂
- xfs 在删除大文件时,extent 分配器会很快把 block 标记为 free,但不会清零
xfs 不可逆的另一个原因是:xfs 的日志(xlog)相对激进,元数据修改会先写日志。恢复工具必须正确解析 xfs 的所有 on-disk 结构。
社区上有一个流传很广的说法:“xfs 删了就没了,ext4 还能救”。这句话不严谨,但方向上是对的:xfs 的恢复工具少、效果差,主流方案是 xfs_undelete(在某些版本上效果也一般);ext4 的恢复工具链成熟(extundelete、debugfs、testdisk)。
3.4 为什么不能 100% 恢复
即使文件系统保留 block 不清零,下列情况也会导致数据无法恢复:
- 块已经被新数据覆盖:内核把 block 分配给新文件
- journal 重放:ext4 的日志回放可能修改元数据
- 文件被 truncate 后再写入:恢复出来的是空洞 + 后续覆盖的数据
- 文件被碎片化严重:恢复出来的 block 顺序可能错乱
- 文件名丢失:很多工具只能按 inode 恢复,文件名无法对应
- 文件系统是 btrfs/zfs 这种 CoW 文件系统:snapshot 存在就能完全回滚,snapshot 不存在就跟 ext4/xfs 一样看运气
所以“恢复”本质是抢救,不是保证。恢复出来的文件可能:
这些风险必须在动手前就跟业务方讲清楚。
3.5 为什么不能依赖“回收站”
Linux 默认没有回收站。rm 走的是 VFS unlink,没有回收站机制。一些桌面环境(GNOME、KDE)有自带的回收站(~/.local/share/Trash),但服务器上几乎不用。
很多人会装 trash-cli 来模拟 macOS 的回收站,命令从 rm 改成 trash。这确实是防误删的好习惯,但有几个坑:
- 跨服务器不同步:rm 在远程机器上跑,回收站也在那台机器
- 习惯难迁移:老脚本里全是
rm -rf,改造工作量大
我们这次事故中,软链 + 旧脚本 + 路径变更,三个独立的小问题叠加才造成。如果你只解决其中任何一个,都不会出问题。
4. 文件系统层原理:删一个文件到底动了什么
要做专业级的恢复,必须理解文件系统在磁盘上是怎么组织的。这一章会从磁盘分区表一直讲到 ext4 的 inode 结构,目的是让你看懂 extundelete 和 debugfs 输出的每一行。
4.1 磁盘到文件系统的层次
物理磁盘 /dev/sda -> 分区表(MBR / GPT) -> /dev/sda1, /dev/sda2 ... -> LVM PV / 直接文件系统 -> mkfs.ext4 / mkfs.xfs ... -> mount 到 /data -> 用户文件 / 目录
误删发生的位置是“用户文件 / 目录”层,但恢复工具操作的是“文件系统”层。中间任何一个环节错位,都会导致恢复失败。
4.2 ext4 的磁盘布局
ext4 把一个分区划分成多个 block group(块组),默认每个块组大小由 mkfs.ext4 自动算。核心数据结构:
| |
|---|
| 文件系统元信息:block size、inode count、magic number 等 |
| |
| |
| |
| 存储 inode,每个 inode 默认 256 字节 |
| |
mkfs.ext4 时的关键参数:
mkfs.ext4 -b 4096 -I 256 -N 1000000 /dev/sda1# -b 4096: block size 4KB# -I 256: inode size 256 字节# -N 1000000: 预留 100 万个 inode
不同的 -b 和 -I 选择会直接影响恢复工具的解析逻辑。extundelete 默认会自动识别,但遇到 journal 损坏的极端情况会失败。
4.3 inode 是恢复的关键
inode 是 Unix “一切皆文件” 的灵魂。每个 inode 包含:
structext4_inode { __le16 i_mode; // 文件类型 + 权限 __le16 i_uid; // owner __le32 i_size_lo; // 文件大小低位 __le64 i_blocks_lo; // 占用的 block 数量 __le32 i_atime; // 访问时间 __le32 i_ctime; // 状态变更时间 __le32 i_mtime; // 修改时间 __le32 i_dtime; // 删除时间(关键!) __le16 i_gid; __le16 i_links_count; // 链接计数 __le32 i_flags; __le32 i_osd1; __le32 i_block[15]; // 直接 / 间接块指针 __le32 i_generation; __le32 i_file_acl_lo; __le32 i_size_high; __le32 i_obso_faddr; __le16 i_osd2[3]; __le16 i_extra_isize; __le16 i_checksum_hi; __le32 i_ctime_extra; __le32 i_mtime_extra; __le32 i_atime_extra; __le32 i_crtime; __le32 i_crtime_extra; __le32 i_version_hi; __le32 i_projid; __le16 i_checksum_lo; __le16 i_reserved;};
其中 i_dtime 是删除时间,i_links_count 是链接计数。如果一个 inode 的 i_dtime != 0 但 i_blocks > 0,说明这是一个“unlinked 但 block 还没被回收”的文件,恢复工具可以扫到。
i_block[15] 数组:
i_block[0..11]:12 个直接块指针,直接指向数据 blocki_block[12]:1 个间接块指针,指向一个 block,block 里再放指针
对于小文件(< 48KB,假设 block size 4KB),所有 block 都在 i_block[0..11] 里,恢复时直接读指针即可。对于大文件,需要解析间接块,这就是为什么“恢复出来的大文件可能有部分缺块”。
4.4 ext4 的日志
ext4 默认开启 journal(除非显式指定 -O ^has_journal)。日志的作用是:所有元数据修改先写到日志区,再异步刷到主文件系统。
删除文件时:
ext4_journal_start:分配一个 handle- 把 inode 变更、目录项变更等元数据写入 journal
ext4_journal_stop:handle 提交,日志刷盘
恢复工具读取时:
- 如果日志尚未 replay,恢复工具能读到完整的删除轨迹
- 如果日志已经 replay 且被覆盖,则只能通过 inode 扫描找“unlinked inode”
4.5 xfs 的磁盘布局
xfs 的核心结构:
| |
|---|
| 把整个 FS 拆成多个 AG,每个 AG 独立管理 |
| |
| |
| |
| |
| |
| |
xfs 的目录项采用 B+ 树,删除时只把对应项标 deleted,目录树结构本身保留。但因为 xfs 的元数据更复杂,恢复工具实现难度大得多。
4.6 btrfs / zfs 的“天然保护”
btrfs 和 zfs 是 CoW (Copy-on-Write) 文件系统,天然支持 snapshot:
- btrfs:
btrfs subvolume snapshot /data /data/bak_20260609 - zfs:
zfs snapshot data/mysql@20260609
如果误删发生在 CoW 文件系统上,且有近期 snapshot,恢复成功率接近 100%。这也是为什么云厂商(AWS EBS snapshot、阿里云快照、腾讯云 CBS 快照)会用 CoW 风格的快照。
我们这次事故是 ext4,没有 snapshot 兜底,只能走传统恢复工具链。
5. 立即止血:避免“恢复行动”本身造成二次伤害
讲原理讲完了,下面进入“怎么做”的环节。但要再次强调:在你做任何恢复操作之前,必须先做“止血”,否则恢复过程中产生的中间文件、临时挂载、工具日志,可能会把还在空闲块池里的原始数据覆盖掉。
5.1 把误删的分区设为只读
最有效的一招:把误删的分区 remount 成 read-only。
# 1. 找到误删数据所在的挂载点对应的设备df /data/backup# 假设输出:/dev/sdb1 50G 30G 18G 63% /data# 2. 重新挂载为只读mount -o remount,ro /data# 3. 验证mount | grep /data# 应该是:/dev/sdb1 on /data type ext4 (ro,...)
注意:
mount -o remount,ro 要求该文件系统没有任何正在写的文件描述符。如果有进程在写,会报 EBUSY- 如果有进程持有写句柄,可以先
fuser -v /data 找到进程,再判断是否能让它退出
另一种更安全的方式:把整个块设备设为只读(块设备层 readonly):
# 块设备层 readonly,会让该设备上所有文件系统都进入只读blockdev --setro /dev/sdb1
blockdev --setro 不会触发写回,也不会要求卸载,是最稳妥的“冻结”手段。
5.2 如果无法 remount,做 LVM 快照(最推荐的工业级方案)
如果你用的是 LVM,那 lvcreate -s 是救命的银弹:
# 1. 查看 VG 剩余空间vgdisplay | grep -E "VG Name|Free"# 2. 给误删的 LV 创建快照# -s: snapshot# -L: 快照大小(按需给,至少能覆盖 LV 写放大)# -n: 快照名lvcreate -s -L 10G -n data_snap_20260609 /dev/vg0/data# 3. 挂载快照mkdir -p /mnt/snapmount -o ro /dev/vg0/data_snap_20260609 /mnt/snap# 4. 在快照上做恢复实验ls -la /mnt/snap/data/backup/mysql/
LVM 快照的原理是 COW(Copy-on-Write):快照创建后,对原 LV 的写操作会把原数据复制到快照空间;快照空间内的数据保持创建那一刻的视图。也就是说,快照创建瞬间,原 LV 上所有未分配 block 的内容都被“冻结”了,恢复操作可以在快照上安全进行。
如果你的环境是云服务器(ECS、CVM、EC2),云厂商一般也提供磁盘快照,原理类似:
# 阿里云示例:通过 OpenAPI 创建快照# aws ec2 create-snapshot --volume-id vol-xxx --description "pre-recovery-$(date +%F)"# 腾讯云 / 阿里云控制台有 GUI 操作
5.3 如果既不能 remount 又没有 LVM,做磁盘镜像
最差情况:物理机 / 虚拟机 / 文件系统不支持快照。这种情况下,做一个 dd 镜像到另一块盘:
# 1. 找一块至少同样大的目标盘lsblk# 2. 用 dd 做全盘镜像(注意:这一步会读整个源盘,耗时长)# status=progress 显示进度# conv=noerror,sync 出错不停止,缺失块补零dd if=/dev/sdb of=/dev/sdc bs=4M status=progress conv=noerror,sync# 3. 验证镜像md5sum /dev/sdbmd5sum /dev/sdc
风险提示:
dd 误把 of 写错是真实存在的灾难。一定要 double check 三遍。- 建议加
oflag=direct 绕过 page cache - 镜像会读整个磁盘,如果磁盘有坏道,
conv=noerror 防止中断
如果机器是云上 VM,可以直接对云盘做“磁盘快照”功能。云盘快照底层就是基于 COW 的,对原盘的影响极小。
5.4 一个常被忽略的细节:进程持有文件
lsof 是这一环节的另一个关键工具。rm -rf 删除了目录项,但只要有进程还 open 着这个文件,内核就不会真的释放 inode 和 block。我们可以走 /proc/$PID/fd/$N 路径把文件复制出来:
# 1. 找出所有还在 /data/backup 下的打开文件lsof +L1 /data/backup# +L1 表示 link count 已经 <= 1(典型的 unlinked 状态)# 2. 也可以直接找被删但还在跑的lsof | grep deleted# 输出形如:# mysqld 1234 mysql 8u REG 253,1 1048576 12345 /data/backup/mysql/dump_20260608.sql (deleted)
注意 deleted 标志。这表示文件已经被 unlinked 但仍在被进程持有。
复制方法:
# 1. 找到 PID 和 FDls -la /proc/1234/fd/8# lrwx------. 1 mysql mysql 64 Jun 9 02:18 8 -> /data/backup/mysql/dump_20260608.sql (deleted)# 2. 直接 cpcp /proc/1234/fd/8 /tmp/recovered_dump_20260608.sql
这是恢复成本最低的途径,必须第一时间执行,因为进程一旦退出,文件就真没了。
5.5 临时禁止 syslog 写入
很多机器的 syslog 会持续写 /var/log/messages 或 /var/log/syslog,如果这块盘恰好是误删的盘,syslog 的写入会持续覆盖空闲块。
应对:
# 方法 1:临时停 rsyslogsystemctl stop rsyslog# 方法 2:让 rsyslog 写到内存(tmpfs)# /etc/fstab 加一行:# tmpfs /var/log tmpfs defaults,noatime,size=512M 0 0# 然后 mount -a
6. 评估能否恢复:环境调查清单
正式进入恢复前,需要把环境摸清楚。下面是一个 7 步调查清单,列在前面是因为它能直接决定恢复方案。
6.1 第一步:确认文件系统类型
# 方法 1:blkidblkid /dev/sdb1# /dev/sdb1: UUID="..." TYPE="ext4"# 方法 2:mountmount | grep /data# /dev/sdb1 on /data type ext4 (rw,relatime,seclabel)# 方法 3:dumpe2fs(只对 ext 系列)dumpe2fs -h /dev/sdb1 | head -30
6.2 第二步:确认挂载点和挂载选项
cat /proc/mounts | grep /datafindmnt /data# 输出示例:# TARGET SOURCE FSTYPE OPTIONS# /data /dev/sdb1 ext4 rw,relatime,seclabel
特别注意 relatime/strictatime/noatime:如果是 noatime,inode 上的 i_atime 不会被更新,恢复时能看到更准确的访问时间。
6.3 第三步:确认磁盘是否还有大量写入
iostat -dx 1 5# 看 %util 和 w/s。如果 w/s 持续 > 0,说明有进程在写,越早止血越重要。
6.4 第四步:磁盘健康度
# smartctl(需要安装 smartmontools)# CentOS / RHELyum install -y smartmontools# Ubuntu / Debianapt-get install -y smartmontoolssmartctl -H /dev/sdbsmartctl -a /dev/sdb
如果磁盘本身有坏道,恢复的成功率会显著下降。
6.5 第五步:磁盘空间
df -h /datadf -i /data# 重点看 inode 使用率。ext4 上即使磁盘空间够,inode 满了也无法创建文件。
6.6 第六步:文件系统是否已经损坏
# ext 系列fsck -n /dev/sdb1# -n 表示只读检查,不修# xfsxfs_repair -n /dev/sdb1
fsck -n 可以列出错误,但不会自动修复。这步是“诊断”,不是“治疗”。
6.7 第七步:内核日志
dmesg | tail -100journalctl -k -p err --since "1 hour ago"
如果看到 EXT4-fs error、I/O error、Buffer I/O error 等,说明磁盘或 FS 本身有健康问题。
7. 方案 A:ext4 + extundelete(首选)
extundelete 是 ext 系列文件系统的“第一恢复工具”,作者是 ext2fsprogs 维护者之一。它能:
重要约定:下面所有 extundelete 命令的路径参数都是相对文件系统根(FS root),不是相对 OS 挂载点。如果 /dev/sdb1 挂在 /data,文件在 OS 里是 /data/backup/mysql/dump.sql,那么 extundelete 接收的路径必须是 /backup/mysql/dump.sql,不是/data/backup/mysql/dump.sql。这是新人最容易踩的坑,路径写错不会报错,但恢复结果为空。
7.1 安装 extundelete
# CentOS / RHEL(需要 EPEL)yum install -y epel-releaseyum install -y extundelete# Ubuntu / Debianapt-get install -y extundelete# 源码编译(如果包仓库里没有对应版本)wget https://sourceforge.net/projects/extundelete/files/extundelete/0.2.4/extundelete-0.2.4.tar.bz2tar xf extundelete-0.2.4.tar.bz2cd extundelete-0.2.4./configuremakemake install
7.2 准备工作
# 1. 先把被删的 FS 设为只读(前面讲过)mount -o remount,ro /data# 或者umount /data# 2. 把恢复结果输出到独立的磁盘(不能写回原盘)mkdir -p /mnt/recoverymount /dev/sdc1 /mnt/recovery # 假设 /dev/sdc1 是新盘
7.3 恢复指定文件
# 假设 /dev/sdb1 挂在 /data,文件 OS 路径是 /data/backup/mysql/dump_20260608.sql# 那么 extundelete 接收的是 FS 根相对路径:extundelete /dev/sdb1 --restore-file /backup/mysql/dump_20260608.sql# 输出在当前目录的 RECOVERED_FILES/ 下
注意:
- 路径是从 FS 根开始的相对路径(去掉挂载点前缀)
- 如果你不知道完整路径,可以先看 journal(见 7.5)或用
--list-all - 路径分隔符在 extundelete 命令行里用
/
7.4 恢复整个目录
extundelete /dev/sdb1 --restore-directory /backup/mysql
7.5 按时间窗口恢复
# 恢复到 2026-06-09 00:00:00 之前的所有文件extundelete /dev/sdb1 --restore-all --before "2026-06-09 00:00:00"
--before 接受的时间格式默认按 strptime 解析,常用写法是 "%Y-%m-%d %H:%M:%S",对应 "2026-06-09 00:00:00"。不同版本的 extundelete 对日期字符串的容忍度不同,建议先在小 FS 上验证。
类似地,--after 表示“恢复这个时间点之后”的文件,组合使用可缩小范围。
7.6 扫描所有可恢复文件
# 只列出,不恢复extundelete /dev/sdb1 --list-all# 真正恢复所有可恢复文件extundelete /dev/sdb1 --restore-all
--list-all 输出非常多,建议重定向到文件:
extundelete /dev/sdb1 --list-all > /tmp/extundelete_list.txt 2>&1
7.7 实际使用中的几个坑
坑 1:路径写错
# 错误:传了 OS 路径(含挂载点前缀)extundelete /dev/sdb1 --restore-file /data/backup/mysql/dump_20260608.sql# 命令不会报错,但 RECOVERED_FILES/ 为空# 正确:去掉挂载点前缀,传 FS 根相对路径extundelete /dev/sdb1 --restore-file /backup/mysql/dump_20260608.sql
正确做法:先 extundelete --list-all 看完整目录树,再针对性恢复。--list-all 的输出本身就是 FS 相对路径,可以直接复制。
坑 2:extundelete 找不到 journal
# 报错:can't find ext3 journal
这通常是因为 extundelete 版本和内核版本不兼容。CentOS 7 默认 yum 装的 extundelete 0.2.4 在 4.x 内核上正常,在 5.x 内核的 RHEL 8 上可能有问题。出现这种情况,建议源码编译 0.2.4 加上 patch。
坑 3:恢复出来的文件 size = 0
# 看到的 recovered 文件是 0 字节
原因:inode 的 i_size 在某些情况下被覆写了。但 block 还在。可以用 dd 按 block 范围读出来:
# 找到 inode 号extundelete /dev/sdb1 --list-all | grep "dump_20260608"# 输出形如:12345 ... /data/backup/mysql/dump_20260608.sql# debugfs 读取(见方案 B)
坑 4:恢复出来的文件名变成 inode_NNNN
这是正常的,因为目录项丢了。只能按 inode 来对应原文件名。
7.8 验证恢复结果
# 1. 数量对比find RECOVERED_FILES/ -type f | wc -l# 跟原始文件数对比# 2. 大文件检查ls -lS RECOVERED_FILES/ | head -20# 3. 文件类型识别file RECOVERED_FILES/*# 4. 抽样校验md5sum RECOVERED_FILES/mysql/dump_20260608.sql# 跟应用方确认的预期值对比
8. 方案 B:ext4 + debugfs(精细化恢复)
debugfs 是 e2fsprogs 自带的 ext 文件系统调试器,比 extundelete 更底层。适用于:
8.1 进入 debugfs
debugfs /dev/sdb1# 进入交互模式
注意:默认 debugfs 会以 read-write 模式打开 FS,可能在某些命令上造成修改。务必加 -c 参数以 cat-like 模式打开(只读):
debugfs -c /dev/sdb1# 或者显式指定只读debugfs -R "stats" /dev/sdb1 -c
-c 模式下,所有写命令都会被拒绝。
8.2 常用 debugfs 命令
# 1. 列出指定 inode 的内容debugfs: stat <12345># 2. 把 inode 12345 的内容 dump 到 /tmp/recovered_inode_12345debugfs: dump <12345> /tmp/recovered_inode_12345# 3. 列出目录下的所有项(包括 deleted)debugfs: ls -l /data/backup/mysql# 输出形如:# 12345 40700 1000 1000 4096 9-Jun-2026 02:17 .# 12344 40755 1000 1000 4096 9-Jun-2026 02:17 ..# <12346> 100644 1000 1000 52428800 9-Jun-2026 01:55 dump_20260608.sql# 注意 <12346>,尖括号表示已删除# 4. 列出所有 unlinked 的 inodedebugfs: lsdel# 输出每个 unlinked inode 的 owner、size、dtime# 5. 列出所有 inode(很慢)debugfs: ncheck 12346# 把 inode 号映射回路径
8.3 实战:按 inode 恢复
# 1. 先找到对应 inodels -i /data/backup/mysql # 这一步在已经删除的目录上跑不了,只能从备份的元数据推断# 2. 如果有上一周的 ls 输出,可以从历史里拿 inode# 假设从聊天记录里找到了:# 12346 dump_20260608.sql# 12347 dump_20260607.sql# 3. 恢复debugfs -c /dev/sdb1 -R "dump <12346> /mnt/recovery/dump_20260608.sql"# 4. 退出debugfs -c /dev/sdb1 -R "quit"
8.4 实战:恢复被删目录下的所有文件
# 1. 找到被删目录的 inode# 注意:此时目录已经被删,ls -id 跑不了# 只能从 lsdel 输出、备份的元数据、或人工记忆里拿# 假设我们从某次巡检记录里知道目录 inode 是 10001echo"假设目录 inode = 10001"# 2. 用 lsdel + ncheck 映射路径# 注意:debugfs 的 ncheck 输出的是 FS 根相对路径,# 不是 OS 挂载点路径,所以输出里没有 /data 前缀debugfs -c /dev/sdb1 -R "lsdel" | tee /tmp/lsdel.txtdebugfs -c /dev/sdb1 -R "ncheck 12346 12347 12348"# 输出形如:# 12346 /backup/mysql/dump_20260608.sql# 12347 /backup/mysql/dump_20260607.sql# 3. 批量 dumpfor ino in 12346 12347 12348; do debugfs -c /dev/sdb1 -R "dump <$ino> /mnt/recovery/ino_$ino"done
8.5 debugfs 常见问题
Q:lsdel 跑得特别慢?
A:因为要遍历所有 inode。如果 FS 很大(>1T),可能要几十分钟。可以提前用 dumpe2fs -h /dev/sdb1 看 inode 总数:
Q:dump 出来的文件大小不对?
A:可能 i_size 被覆写,但 block 还在。可以用 dd 按 block 范围读:
# 找出文件占用的 blockdebugfs -c /dev/sdb1 -R "stat <12346>"# 看 i_block[0..14]# 假设直接块是 1000000~1000124dd if=/dev/sdb1 of=/mnt/recovery/raw_12346 bs=4096 skip=1000000 count=125
Q:文件类型识别错?
A:file 命令识别错的常见原因是前几个 block 被覆写。可以尝试从后向前读。先确认文件占用的 block 范围:
# 假设 inode 12346 的文件占用 block 1000000 ~ 1000255(共 256 块 = 1MB)# 但只希望读最后 256 块(1MB)作识别用start=$((1000255 - 256 + 1)) # 1000000dd if=/dev/sdb1 of=/mnt/recovery/tail_12346 bs=4096 skip=$start count=256
9. 方案 C:xfs 文件系统恢复
xfs 不可逆性比 ext4 强,但还是有几条路。
9.1 xfs_undelete 工具
# CentOS / RHELyum install -y xfs_undelete# Ubuntuapt-get install -y xfs_undelete# 恢复指定目录下的所有文件xfs_undelete -t /data/backup/mysql /dev/sdb1# 恢复到指定目录xfs_undelete -t /data/backup/mysql -o /mnt/recovery /dev/sdb1
注意:
- xfs_undelete 是对目录树扫描,不是从 journal 恢复
9.2 xfs_db(debugfs 的 xfs 版)
xfs 自带 xfs_db,跟 debugfs 类似但语义不同:
xfs_db /dev/sdb1# 进入交互模式xfs_db> helpxfs_db> blockgetxfs_db> blockusexfs_db> quit
xfs_db 主要是诊断和修复,恢复功能有限。生产中更推荐用 xfs_undelete。
9.3 实在救不回来怎么办
xfs 救不回来的时候,最佳选择是:
很多公司的备份服务器就是用 ext4 + LVM,每天一个 snapshot 的方式跑,比 xfs 安全得多。
10. 方案 D:testdisk + photorec(无差别按块扫)
testdisk 和 photorec 是 sleuthkit 套件里的工具,是“无差别按块扫”的终极手段。优点:
- 不依赖文件系统元数据,纯按 block 内容特征识别
- 支持几乎所有常见文件系统(ext、xfs、ntfs、fat、btrfs、zfs、hfs+ 等)
缺点:
10.1 安装
yum install -y testdisk# 或者apt-get install -y testdisk
10.2 使用 testdisk
# 启动(交互式)testdisk /dev/sdb1# 一般流程:# 1. 选择 [Create] 创建日志# 2. 选择磁盘# 3. 选择分区表类型(一般 Intel / GPT)# 4. 选择 [Advanced] 高级# 5. 选择分区# 6. 选择 [Undelete] 恢复文件# 7. 选择目标目录
10.3 使用 photorec
# 启动(交互式)photorec /dev/sdb1# 一般流程:# 1. 选择磁盘# 2. 选择分区# 3. 选择文件系统类型(一般选 Other)# 4. 选择恢复目录
photorec 的恢复结果默认会生成几千个 f0001.jpg、f0002.txt 这样的文件,需要人工归类。
10.4 适合 photorec 的场景
对于备份服务器里几百 GB 的 mysqldump SQL 文件,photorec 不太合适——它会把每个 block 切碎然后按特征匹配,SQL 文件内部有大量重复模式,容易被切碎。
11. 方案 E:lsof 抢救未关闭文件(成本最低的恢复)
前面 5.4 节已经提过,这里展开讲。这是最容易成功、风险最低、速度最快的恢复方式。
11.1 找到 deleted but still open 的文件
# 列出所有 deleted 文件lsof | grep deleted
输出示例(注意:不同 lsof 版本会显示或不显示 TID 列):
mysqld 1234 1234 mysql 8u REG 253,1 104857600 12345 /data/backup/mysql/dump_20260608.sql (deleted)rsync 5678 5678 root 3r REG 253,1 52428800 12346 /data/backup/app/app-20260608.tar.gz (deleted)
其中:
- 第 5 列:FD(含访问模式字母,如
8u 表示 FD=8 的 u=read+write) - 第 9 列:原始路径 +
(deleted) 标记
为什么推荐 lsof -F 解析:手工数列数(awk $2、awk $4)在 TID 列存在与否的两种 lsof 输出下表现不同,新人最容易在这里翻车。下面 11.3 的脚本用 -F 规避这个问题。
11.2 恢复单个文件
# 把 1234 进程的第 8 个 fd 复制出来cp /proc/1234/fd/8 /tmp/recovered_dump_20260608.sql# 验证大小ls -la /tmp/recovered_dump_20260608.sql# 应该跟 deleted 文件的大小一致
11.3 批量恢复脚本
#!/bin/bash# recover_deleted.sh# 恢复所有 deleted 但被进程持有的文件# 用法:./recover_deleted.sh /mnt/recovery## 说明:使用 lsof -F 输出(每行一个键值对)规避 awk 字段数随版本# 变化(TID 列有时存在有时缺失)的问题。# p=<PID> f=<FD> n=<NAME> 是我们关心的三种字段OUT_DIR="${1:-/tmp/recovered}"mkdir -p "$OUT_DIR"# 1. 抓出所有 deleted 文件# -F pfn: 仅输出 p/f/n 三个字段# 2>/dev/null: 忽略 lsof 因权限不足输出的部分 warninglsof -F pfn 2>/dev/null > /tmp/lsof_f.txt# 2. 解析:把 p/f/n 三行组合成一条记录awk -v OUT_DIR="$OUT_DIR"'/^p/ { pid = substr($0, 2) fd = "" name = "" deleted = 0 next}/^f/ { fd = substr($0, 2) next}/^n/ { name = substr($0, 2) if (name ~ / \(deleted\)$/) { deleted = 1 # 去掉 " (deleted)" 标记 name = substr(name, 1, length(name) - 10) }}{ if (deleted && pid != "" && fd != "" && name != "") { # 构造目标文件名:pid_fd_原路径 safe = name gsub(/\//, "_", safe) target = OUT_DIR "/" pid "_" fd "_" safe # 调用 cp 复制 cmd = "cp -a /proc/" pid "/fd/" fd " \"" target "\" 2>/dev/null" if (system(cmd) == 0) { print "RECOVERED: " name " -> " target } deleted = 0 }}' /tmp/lsof_f.txtrm -f /tmp/lsof_f.txt
使用:
chmod +x recover_deleted.sh./recover_deleted.sh /mnt/recovery
风险提示:
- 这个脚本会触发对
/proc/$pid/fd/$fd 的访问,可能短暂占用 fd - 对每个进程有权限要求,root 才能访问其他用户的 fd
lsof -F 的输出里 NAME 可能包含空格,脚本里的 cp 已加引号兜底- 如果 lsof 版本过老不支持
-F,可改用 lsof +c 0 -P -n 加手工解析
11.4 实战中的几个问题
Q:进程是 root 启动的,但文件实际属于 mysql 用户,能恢复吗?
A:能。内核只校验调用进程的权限,root 可以访问任何 fd。
Q:lsof 输出里有 deleted 文件,但 /proc/$pid/fd 路径不存在?
A:可能进程已经退出了。lsof 是当时的状态,进程退出后 /proc/$pid 整个消失。
Q:lsof 输出里有 N 个 deleted,但我用上面的脚本只恢复了 M 个(M < N)?
A:可能某些 fd 已经被进程关闭但还没被内核回收。增加睡眠重试,或者直接按 inode 扫。
12. 方案 F:LVM / ZFS / btrfs 快照回滚(成功率最高)
如果误删发生在支持快照的文件系统上,且近期有 snapshot,恢复成功率是 100%。这一节讲三类系统的快照操作。
12.1 LVM 快照
# 1. 创建快照lvcreate -s -L 20G -n data_snap_recovery /dev/vg0/data# 2. 挂载(ext4 快照直接挂载即可;xfs 在多 LV 同 VG 场景下可能要加 nouuid)mount /dev/vg0/data_snap_recovery /mnt/snap# 如果快照里是 xfs 且 VG 内出现 UUID 冲突,可加 -o nouuid:# mount -o ro,nouuid /dev/vg0/data_snap_recovery /mnt/snap# 3. 复制数据cp -a /mnt/snap/data/backup/mysql/dump_20260608.sql /mnt/recovery/# 4. 验证diff /mnt/recovery/dump_20260608.sql /data/backup/mysql/dump_20260608.sql# 5. 卸载 + 删除快照umount /mnt/snaplvremove -f /dev/vg0/data_snap_recovery
LVM 快照的局限:
12.2 btrfs 快照
# 1. 列出已有快照btrfs subvolume list /data# 2. 创建新快照btrfs subvolume snapshot /data /data/.snapshots/$(date +%F_%H%M%S)# 3. 恢复:把快照里的文件直接 cp 出来# btrfs 快照是“可写快照”,可以挂载后操作mkdir -p /mnt/snapmount -o subvol=.snapshots/2026-06-09_021800 /dev/sdb1 /mnt/snapls /mnt/snap/data/backup/mysql/# 4. 也可以用 btrfs restore 把整个 subvolume 恢复到另一个位置btrfs restore /dev/sdb1 /mnt/recovery_btrfs
btrfs 的优势:
- 可以做增量备份(
btrfs send/receive)
12.3 zfs 快照
# 1. 列出已有快照zfs list -t snapshot | grep data# 2. 创建新快照zfs snapshot data@recover_$(date +%F_%H%M%S)# 3. 访问快照ls /data/.zfs/snapshot/recover_2026-06-09_021800/data/backup/mysql/# 4. 复制数据cp -a /data/.zfs/snapshot/recover_2026-06-09_021800/data/backup/mysql/* /mnt/recovery/# 5. 删除快照zfs destroy data@recover_2026-06-09_021800
zfs 的优势:
zfs rollback 可以把整个 FS 回到某个时刻- 跨主机
zfs send/receive 是工业级备份
12.4 云盘快照
云上的块存储(EBS、CBS、Disk)都支持快照:
# AWS CLIaws ec2 create-snapshot \ --volume-id vol-0abc1234 \ --description "pre-recovery-$(date +%F)"# 阿里云 CLIaliyun ecs CreateSnapshot \ --DiskId d-abc1234 \ --Description "pre-recovery-$(date +%F)"# 腾讯云 CLItccli cbs CreateSnapshot \ --DiskId disk-abc1234
云盘快照的特点:
我们的生产环境在事件后第 3 周统一做了改造:所有 ext4 卷都迁到 LVM,每天一个 snapshot,保留 14 天。
13. 实战时间线:一次完整的误删恢复全过程
把前面讲的工具串起来,按真实事故的时间线给一个完整流程。假设场景:
- 主机:backup-01.example.com,CentOS 7.9
- 误删原因:脚本里
find ... -exec rm -rf {} \; 沿软链展开 - 误删对象:约 200 个 mysqldump 文件,共 380GB
- 备份:本地 LVM snapshot(昨天 02:00 整)
13.1 02:17 - 02:25:响应与止血
# 1. ssh 上去ssh backup-01# 2. 立刻停 cronsudo systemctl stop crondsudo systemctl mask crond# 3. 停 mysqldump 任务(如果还在跑)ps -ef | grep -E "mysqldump|cleanup" | grep -v grep# 假设 PID 5678 是 cleanup.shsudo kill -STOP 5678 # 暂停进程,先不杀# 也可以直接 kill,但要先把后面 lsof 信息抓了# 4. 抓现场sudo sh -c 'date; uptime; df -h; df -i; free -h; mount > /tmp/mount.txt; lsof > /tmp/lsof.txt; lsblk > /tmp/lsblk.txt; blkid > /tmp/blkid.txt' > /tmp/initial_state.txt 2>&1# 5. 立即把盘设为只读sudo mount -o remount,ro /data# 报 EBUSY,看下谁在写sudo fuser -vm /data# 假设是 mysqldump 进程sudo kill -STOP $(pidof mysqldump)sudo mount -o remount,ro /data# 这次成功了# 6. 验证mount | grep /data
13.2 02:25 - 02:30:LVM 快照
# 1. 查看 VG 空间sudo vgdisplay vg0 | grep -E "VG Name|Free"# 假设 Free: 50G# 2. 创建快照sudo lvcreate -s -L 30G -n data_snap_recovery /dev/vg0/data# 3. 挂载快照sudo mkdir -p /mnt/snapsudo mount -o ro /dev/vg0/data_snap_recovery /mnt/snap# 4. 验证快照可读sudo ls -la /mnt/snap/data/backup/mysql/ | head -20# 看到 380GB 的文件都在
13.3 02:30 - 02:50:lsof 抢救
# 1. 抓 deletedsudo lsof | grep deleted | grep backup > /tmp/deleted_files.txt# 看到约 30 个文件被 mysqldump 进程持有# 2. 准备恢复目录sudo mkdir -p /mnt/recovery# 3. 批量恢复sudo /opt/scripts/recover_deleted.sh /mnt/recovery# 恢复出 30 个文件,约 80GB# 4. 验证sudo ls -la /mnt/recovery/ | head -20
13.4 02:50 - 04:30:extundelete 全量恢复
# 1. 准备恢复目标盘sudo mkdir -p /mnt/recovery2sudo mount /dev/sdc1 /mnt/recovery2 # 假设 sdc1 是 1TB 独立盘# 2. 卸载原盘(避免误操作)sudo umount /mnt/snap# 保留 snapshot lv 不动# 3. 跑 extundeletecd /mnt/recovery2sudo extundelete /dev/sdb1 --restore-all --before "2026-06-09 02:00:00"# 这一步大约 1 小时# 4. 验证ls -la /mnt/recovery2/RECOVERED_FILES/ | headls -la /mnt/recovery2/RECOVERED_FILES/data/backup/mysql/ | head
13.5 04:30 - 06:00:业务校验
# 1. 把恢复出来的 mysqldump 文件加载到测试库for f in /mnt/recovery2/RECOVERED_FILES/data/backup/mysql/dump_*.sql; do# 抽样前 100 行 head -100 "$f" | mysql -u root -p <db_test>if [ $? -ne 0 ]; thenecho"BROKEN: $f"fidone# 2. 校验关键文件大小find /mnt/recovery2/RECOVERED_FILES/data/backup/mysql/ -type f -size -100k -ls# 找出 0 字节和异常小的文件# 3. 对比 mysqldump 的预期行数mysql -u root -p -e "SELECT * FROM <db>.tables" | wc -l# 跟某个 dump 文件的 INSERT 行数对比
13.6 06:00 - 08:00:补传 + 业务验证
# 1. 把恢复出来的文件推回业务机rsync -avz /mnt/recovery2/RECOVERED_FILES/data/backup/mysql/ \ backup-target:/data/backup/mysql/# 2. 让应用方做端到端校验# 业务方反馈:恢复出来的文件能正常恢复# 缺失的 12 个文件从异地机房拉# 3. 通知变更完成
13.7 复盘
- 改进:所有清理脚本必须 Code Review、必须 dry-run
14. 风险点与不可恢复场景
下面这些场景,恢复工具都救不回来。提前认清边界。
14.1 块已被覆写
# 查看空闲块水位dumpe2fs /dev/sdb1 | grep -E "Free blocks|Free inodes"
Free blocks 越小,说明可用空间越紧张,覆写风险越高。如果空闲块数已经很少,新数据写入会很快把误删文件覆盖。
14.2 文件系统已重格式化
# 如果有人在修复过程中 mkfs 了mkfs.ext4 /dev/sdb1# 那么元数据被重置,恢复工具扫到的是全新的 FS 结构
遇到这种情况,extundelete 几乎无解,唯一的希望是 photorec 按块扫。
14.3 磁盘出现坏道
# smartctl 报告坏道smartctl -a /dev/sdb | grep -E "Reallocated|Pending|Uncorrectable"
坏道上的数据物理上读不出来,恢复出来的文件会有零字节块或随机内容。
14.4 文件被部分覆写
# 假设 dump_20260608.sql 被覆写了头部 1MB# 恢复出来的文件从原 1MB 位置开始,前面 1MB 是新数据
这种文件通常校验失败,需要从其他渠道(远端备份、其它机器)补全。
14.5 文件名彻底丢失
extundelete 恢复出来的文件名是 inode_12345.dump 这种,需要靠 inode 推回去。如果连 inode 都没记(应用层没打 tag),就只能按文件大小、修改时间、文件类型人工归类。
14.6 加密文件系统
LUKS 加密的 FS,恢复时需要解锁。如果密钥丢了,数据不可救。生产环境建议:
- 密钥用 key escrow(Key Custodian)机制
14.7 写入放大
如果误删的是数据库文件,且 DB 仍在跑,DB 的 checkpoint、redolog 写入会持续覆写空闲块。这种情况下,越早停 DB 越好。
15. 防误删:制度、命令、备份、监控
讲完恢复,必须讲防御。下面是我们在事件后落地的一套防御体系。
15.1 制度层
15.1.1 清理脚本 Code Review 制度
任何 find ... -exec rm、rm -rf、shred 等破坏性命令,必须:
15.1.2 清理脚本必须有 dry-run
#!/bin/bash# cleanup.sh# 清理 30 天前的旧备份# 必须支持 DRY_RUN 环境变量DRY_RUN=${DRY_RUN:-1}# 默认 dry-runLOG_FILE=${LOG_FILE:-/var/log/cleanup.log}log() {echo"$(date '+%F %T') $*" | tee -a "$LOG_FILE"}if [ "$DRY_RUN" = "1" ]; thenlog"DRY-RUN: would remove the following:" find /data/backup/mysql -type f -mtime +30 -printexit 0fi# 真正的清理逻辑log"REAL-CLEAN: starting"find /data/backup/mysql -type f -mtime +30 -print -deletelog"REAL-CLEAN: done"
使用方式:
# 1. 演练DRY_RUN=1 LOG_FILE=/var/log/cleanup_test.log /opt/scripts/cleanup.sh# 2. 确认无误后真实运行DRY_RUN=0 LOG_FILE=/var/log/cleanup_real.log /opt/scripts/cleanup.sh
15.1.3 强制二次确认
#!/bin/bash# rm_with_confirm.sh# 包装 rm,强制二次确认TARGET="$1"if [ -z "$TARGET" ]; thenecho"Usage: $0 <path>"exit 1fi# 1. 提示echo"==== WARNING ===="echo"About to remove: $TARGET"echo"Resolved path: $(readlink -f "$TARGET")"echo"Disk usage: $(du -sh "$TARGET" 2>/dev/null | awk '{print $1}')"echo"==== END ===="# 2. 强制输入 YESread -p "Type 'YES' to continue: " confirmif [ "$confirm" != "YES" ]; thenecho"Aborted."exit 1fi# 3. 删除rm -rf -- "$TARGET"echo"Removed."
15.1.4 审计日志
# 在 /etc/profile 里加 aliasalias rm='/usr/local/bin/audit_rm.sh'
# /usr/local/bin/audit_rm.sh#!/bin/bashLOG_FILE=/var/log/rm_audit.logUSER=$(whoami)PWD_PATH=$(pwd)TIMESTAMP=$(date '+%F %T')# 记录命令echo"$TIMESTAMP user=$USER pwd=$PWD_PATH cmd=rm args=$*" >> "$LOG_FILE"# 调用真实 rmexec /bin/rm "$@"
15.2 命令层
15.2.1 替换 rm
trash-cli 是一个跨平台的回收站替代品:
# Fedora / RHEL 8 + EPELyum install -y trash-cli# Ubuntu / Debianapt-get install -y trash-cli# 仓库里没有时,pip 兜底pip3 install trash-cli# 使用trash-put foo.txt # 移动到回收站trash-list # 列出回收站trash-restore foo.txt # 恢复trash-empty # 清空回收站# 替换 aliasalias rm='trash-put'
trash-cli 的坑:
15.2.2 safe-rm
# 安装yum install -y safe-rm# 配置黑名单cat /etc/safe-rm.conf//etc/usr/var/data/backup # 把重要目录加进去
safe-rm 是一个 rm 的 wrapper,会拦截对黑名单路径的删除。
15.2.3 慎用 find -exec rm
find ... -exec rm -rf {} \; 配合软链非常危险。find 默认不跟软链,但 -L 会跟。
# 安全做法:先 print 看一下find /data/backup/mysql -type f -mtime +30 -print# 确认无误后 -deletefind /data/backup/mysql -type f -mtime +30 -delete
find -delete 跟 find -exec rm 的区别:
-delete 是 find 内置的,更安全(不会执行任意命令)
15.2.4 通配符小心
# 危险写法rm -rf /data/backup/*# 如果 /data/backup 是空目录,而当前 shell 把 * 展开成别的,就出事了# 安全写法rm -rf /data/backup/*.sql # 明确匹配# 更安全:先 lsls /data/backup/*.sqlrm -rf /data/backup/*.sql
15.3 备份层
15.3.1 3-2-1 备份策略
对于我们这种备份服务器,3 份副本的实现:
15.3.2 自动 snapshot 脚本
#!/bin/bash# daily_snapshot.sh# 每天凌晨 2 点创建 LVM snapshot,保留 7 天VG_NAME=vg0LV_NAME=dataSNAP_SIZE=20GKEEP_DAYS=7SNAP_PREFIX=data_daily# 1. 创建快照DATE=$(date +%Y%m%d)SNAP_NAME="${SNAP_PREFIX}_${DATE}"lvcreate -s -L ${SNAP_SIZE} -n ${SNAP_NAME} /dev/${VG_NAME}/${LV_NAME} 2>&1 | logger -t snapshotif [ $? -ne 0 ]; then logger -t snapshot "ERROR: failed to create snapshot ${SNAP_NAME}"exit 1fi# 2. 删除过期快照for old_snap in $(lvs --noheadings -o lv_name ${VG_NAME} | grep "${SNAP_PREFIX}_"); do snap_date=$(echo$old_snap | sed "s/${SNAP_PREFIX}_//")if [ -n "$snap_date" ]; then snap_ts=$(date -d "$snap_date" +%s 2>/dev/null)if [ $? -eq 0 ]; then age_days=$(( ($(date +%s) - snap_ts) / 86400 ))if [ $age_days -gt $KEEP_DAYS ]; then lvremove -f /dev/${VG_NAME}/${old_snap} 2>&1 | logger -t snapshotfififidone
15.3.3 异地备份
# rsync over sshrsync -avz --delete \ /data/backup/mysql/ \ backup@backup-dr.example.com:/data/backup/mysql/# 用对象存储aws s3 sync /data/backup/mysql/ s3://my-bucket/mysql/ --delete
15.4 监控层
15.4.1 监控重要目录的存在性
#!/bin/bash# /opt/mon/check_backup.sh# 检查关键目录是否存在CRITICAL_DIRS=("/data/backup/mysql""/data/backup/app""/data/backup/logs")for dir in"${CRITICAL_DIRS[@]}"; doif [ ! -d "$dir" ]; then# 触发告警 curl -X POST "https://alert.example.com/alert" \ -d "host=$(hostname)&dir=$dir&msg=directory missing"fidone
15.4.2 监控文件数量
# 写一个 prometheus textfile collectorCRITICAL_DIRS=("/data/backup/mysql")for dir in"${CRITICAL_DIRS[@]}"; do count=$(find "$dir" -type f 2>/dev/null | wc -l)echo"backup_file_count{dir=\"$dir\"} $count" >> /var/lib/node_exporter/textfile/backup.promdone
15.4.3 监控清理脚本的运行
# 在 cleanup.sh 里发送心跳logger -t cleanup "started with DRY_RUN=$DRY_RUN"# 同时把日志发到集中日志系统(ELK / Loki)curl -X POST "https://logs.example.com/collect" \ -d "{\"job\":\"cleanup\",\"status\":\"started\",\"host\":\"$(hostname)\"}"
15.4.4 告警:删除事件
#!/bin/bash# /opt/mon/audit_rm_watch.sh# 实时监控 /var/log/rm_audit.log,发现危险操作立即告警tail -F /var/log/rm_audit.log | whileread -r line; do# 检测 -rf、/、* 这类危险模式ifecho"$line" | grep -qE "(rm -rf|/ | rm -rf)"; then curl -X POST "https://alert.example.com/alert" \ -d "host=$(hostname)&line=$line&severity=high"fidone
15.5 配置层
15.5.1 /etc/skel/.bashrc 加 alias
# /etc/skel/.bashrcalias rm='echo "Use trash-put or /opt/scripts/safe_rm.sh"; false'alias mv='mv -i'alias cp='cp -i'
新建用户会自动继承。生产环境谨慎给 root 用。
15.5.2 /etc/profile.d/rm_alias.sh
# 强制所有用户加载cat > /etc/profile.d/rm_alias.sh << 'EOF'alias rm='/usr/local/bin/audit_rm.sh'alias cp='cp -i'alias mv='mv -i'EOFchmod +x /etc/profile.d/rm_alias.sh
16. 替代 rm 的安全删除方案
如果你的团队能接受“换个命令”,下面是几个更安全的替代品。
16.1 trash-cli
前面讲过,跨平台,使用简单。缺点是不解决软链问题。
16.2 rmtrash
# macOS 用户熟悉的brew install rmtrash
16.3 移动到隔离目录
#!/bin/bash# /opt/bin/saferm# 移动到隔离目录,30 天后自动清理QUARANTINE=/var/spool/quarantineRETENTION_DAYS=30mkdir -p "$QUARANTINE"for target in"$@"; do real=$(readlink -f "$target") ts=$(date +%Y%m%d_%H%M%S) safe=$(echo"$real" | tr '/''_') mv "$target""$QUARANTINE/${ts}_${safe}"done# 清理过期find "$QUARANTINE" -mtime +$RETENTION_DAYS -delete
16.4 用 Git / Mercurial 做版本控制
对于配置文件、关键脚本:
# 初始化cd /opt/scriptsgit initgit add .git commit -m "initial"# 每次改之前git commit -am "before change rm logic"# 改错了git checkout HEAD -- cleanup.sh
16.5 用 Git LFS / DVC 做大数据版本控制
对于数据文件,可以用 DVC(Data Version Control)做轻量级版本管理。
17. 复盘总结与给初中级运维的建议
事故复盘是一线运维的“软基建”,但很多团队不做或者走形式。我们这次复盘真正落地的有几条:
17.1 复盘要点
- 主因:清理脚本没有 Code Review,没有 dry-run,配合软链造成扩删
- 影响:约 380GB 数据不可见,异地备份还有,但延迟 1 天恢复
17.2 行动项
- 所有清理脚本纳入 Git 管理 + Code Review
- 备份服务器从 ext4 迁到 ext4 + LVM,启用 daily snapshot
17.3 给初中级运维的几条建议
- 任何 rm -rf 之前先 ls 一遍,哪怕你自己写的脚本
- find -delete 优于 find -exec rm,是更安全的写法
- 生产环境的清理脚本必须支持 dry-run,上线前演练
- 软链是 rm -rf 的最大帮凶,清理脚本要显式
-type d 或 -type l 区分 - 重要目录加监控,目录存在性 + 文件数量 + 文件总大小
- 永远不要在生产环境做新工具的“第一次使用”,先在测试机
17.4 给团队 Leader 的建议
- 强制 Code Review,把 Git 仓库的权限收紧
- 配置 review 检查清单,把软链、绝对路径、rm 包装列入
18. 附录 A:常用命令速查表
18.1 状态检查
| |
|---|
df -h | |
df -i | |
mount | |
cat /proc/mounts | |
blkid | |
lsblk | |
iostat -dx 1 5 | |
| |
smartctl -H /dev/sdX | |
18.2 恢复工具
| | |
|---|
extundelete | | --restore-all --before "时间" |
debugfs | | lsdel |
xfs_undelete | | -t <dir> |
xfs_db | | |
testdisk | | |
photorec | | |
btrfs restore | | |
| | |
zfs rollback | | |
18.3 LVM 操作
| |
|---|
vgdisplay | |
lvdisplay | |
lvcreate -s -L 20G -n snap | |
lvremove | |
mount -o ro /dev/vg/lv | |
18.4 btrfs 操作
| |
|---|
btrfs subvolume list | |
btrfs subvolume snapshot | |
btrfs restore | |
btrfs send/receive | |
18.5 zfs 操作
| |
|---|
zfs list -t snapshot | |
zfs snapshot | |
zfs rollback | |
zfs send/receive | |
19. 附录 B:常见错误码与排查
| | |
|---|
EBUSY | | fuser |
EACCES | | |
ENOSPC | | |
EIO | | |
EINVAL | | |
ENOMEM | | |
20. 附录 C:常见误区澄清
20.1 误区 1:rm 之后立刻 sync 还能救
错。sync 只把内存里的脏页刷到磁盘。rm 已经把目录项和 inode 元数据改了,sync 不能“撤销”这个改动。
20.2 误区 2:磁盘格式化后立刻重启就找不到原数据
不一定。元数据被重置,但 block 数据还在。photorec 还能扫到一部分。但成功率和 FS 类型、覆写率强相关。
20.3 误区 3:rm -rf / 一定能把系统搞坏
取决于根分区的类型。如果根分区是单独 mount 的,rm -rf / 不会真的删根目录(Linux 内核会拒绝)。但如果根目录是用 bind mount 把 /data 映射到 / 的,就会真的全删。
20.4 误区 4:xfs 不能恢复
不严谨。xfs_undelete 多数情况下能恢复部分文件,photorec 也能扫一部分。成功率比 ext4 低,但并不是 0。
20.5 误区 5:固态硬盘恢复成功率低
不严谨。SSD 的 TRIM 指令会主动清零空闲块。如果 SSD 开启了 TRIM 且运行了足够时间,恢复率确实接近 0。但很多企业级 SSD 默认关闭 TRIM,或延迟 TRIM,恢复率跟 HDD 接近。hdparm -I /dev/sdX | grep TRIM 可以看是否支持。
20.6 误区 6:恢复后文件能 100% 还原
不严谨。恢复工具只保证 block 层面拼回去,元数据(创建时间、权限、扩展属性、ACL)可能丢失。
20.7 误区 7:dd 镜像比 rsync 安全
各有各的用法。dd 适合整盘镜像、无法 mount 的磁盘、底层读取场景。rsync 适合文件系统级别的复制。
20.8 误区 8:rm -rf 跟 rm 等价
错。-r 是 recursive,-f 是 force(不提示、忽略不存在的文件)。rm -rf 在交互场景下完全不会等你。
21. 附录 D:极端场景下的兜底方案
下面几个方案是“实在救不回来”才考虑。
21.1 联系数据恢复公司
国内主流公司:
服务特点:
适用于:
21.2 从备份恢复
如果误删的文件是“备份数据”本身,恢复路径就是从“备份的备份”恢复:
磁带是“最后的最后”的手段:
21.3 业务层降级
实在救不回来,业务层要启动降级:
22. 附录 E:磁盘镜像与远程恢复
当恢复工具无法直接操作原盘时,需要做磁盘镜像。
22.1 dd 镜像
# 本地镜像dd if=/dev/sdb of=/mnt/recovery/sdb.img bs=4M status=progress conv=noerror,sync# 远程镜像dd if=/dev/sdb bs=4M conv=noerror,sync | gzip | ssh user@backup "cat > /mnt/recovery/sdb.img.gz"# 还原dd if=/mnt/recovery/sdb.img of=/dev/sdb bs=4M status=progress
22.2 ddrescue 增量恢复
# 安装yum install -y ddrescue# 第一次:全量ddrescue /dev/sdb /mnt/recovery/sdb.img /mnt/recovery/sdb.rescue.log# 第二次:跳过已读ddrescue -d -r3 /dev/sdb /mnt/recovery/sdb.img /mnt/recovery/sdb.rescue.log
ddrescue 比 dd 智能,能跳过坏道并多次尝试。
22.3 镜像后操作
# 把镜像文件当磁盘用losetup -f /mnt/recovery/sdb.imglosetup -a# 在 loop 设备上跑 extundeleteextundelete /dev/loop0 --restore-all
23. 附录 F:演练剧本(生产环境慎用)
23.1 演练环境准备
# 准备一台测试机# 创建一个小 FSdd if=/dev/zero of=/tmp/test.img bs=1M count=1024mkfs.ext4 /tmp/test.imgmkdir -p /mnt/testmount -o loop /tmp/test.img /mnt/test# 准备测试数据mkdir -p /mnt/test/{mysql,app,logs}echo"test data" > /mnt/test/mysql/dump.sqldd if=/dev/urandom of=/mnt/test/mysql/large_file bs=1M count=10# 记录元数据ls -la /mnt/test/mysql > /tmp/before_state.txtls -i /mnt/test/mysql > /tmp/before_inodes.txt
23.2 模拟误删
# 模拟误删rm -rf /mnt/test/mysql
23.3 恢复演练
# 1. 立即 remount romount -o remount,ro /mnt/test# 2. extundeletemkdir -p /mnt/recoverycd /mnt/recoveryextundelete /tmp/test.img --restore-all# 3. 验证diff -r /mnt/recovery/RECOVERED_FILES/mysql /tmp/before_state.txt
23.4 演练评估
24. 附录 G:监控指标建议
对于备份目录的健康度,建议监控以下指标:
# 1. 关键目录文件数量backup_file_count{dir="/data/backup/mysql"}# 2. 关键目录总大小backup_dir_size_bytes{dir="/data/backup/mysql"}# 3. 关键目录最近一次修改时间backup_last_modified_timestamp{dir="/data/backup/mysql"}# 4. 磁盘使用率node_filesystem_avail_bytes{mountpoint="/data"}# 5. inode 使用率node_filesystem_files_free{mountpoint="/data"}# 6. LVM 快照数量lvm_snapshot_count{vg="vg0"}# 7. 异地备份最后一次同步时间remote_backup_last_sync_timestamp
告警规则示例:
groups:-name:backup_alertsrules:-alert:BackupDirectoryMissingexpr:backup_file_count{dir="/data/backup/mysql"}==0for:5mlabels:severity:criticalannotations:summary:"备份目录文件数为 0"-alert:BackupDirectoryLowFileCountexpr:backup_file_count{dir="/data/backup/mysql"}<100for:30mlabels:severity:warningannotations:summary:"备份目录文件数低于阈值"-alert:BackupSyncFailedexpr:time()-remote_backup_last_sync_timestamp>86400for:1hlabels:severity:criticalannotations:summary:"异地备份超过 24 小时未同步"
25. 附录 H:工具对照表
26. 附录 I:SOP 模板
# 数据误删应急响应 SOP## 触发条件- 收到删除事件告警- 用户报告文件丢失- 监控显示目录文件数突降## 响应步骤1. 确认事故(10 分钟内) - 联系报告人确认现象 - ssh 到目标主机初步确认2. 立即止血(5 分钟内) - 停 cron、停相关进程 - remount ro 或做 LVM 快照3. 现场记录(15 分钟内) - 抓 mount、lsof、ps、dmesg4. 评估恢复方案(30 分钟内) - 确认 FS 类型 - 选择恢复工具5. 执行恢复(视情况) - extundelete / debugfs / lsof - 写入独立磁盘6. 业务校验 - 文件大小、类型、内容 - 应用方确认7. 复盘(事故后 24 小时内) - 写复盘文档 - 落地行动项
27. 附录 J:推荐阅读与工具
- ext2fsprogs 文档:debugfs、e2fsck、mke2fs 的官方手册
- e2fsprogs 源码:理解 ext4 内部实现
- ZFS 文档:理解 zfs send/receive
工具:
- testdisk / photorec (cgsecurity.org)
- sleuthkit (sleuthkit.org)
- trash-cli (github.com/andreafrancia/trash-cli)
28. 结语
rm -rf 不可怕,可怕的是“以为自己有备份所以不担心”。做运维越久,越会敬畏“删除”这个动作:它不像写,写错了能 git revert;它更像 SQL 的 DROP TABLE,跑完就没了。
本文的真正意义不是教你用 extundelete 救命,而是希望你:
- 永远有 Plan B(备份、快照、Code Review)
技术会变,ext4 会变成 btrfs,centos 会变成 rocky,rm 会被各种 wrapper 包装。但“删除”这件事的本质不会变:它永远是不可逆的、永远需要审批的、永远需要备份的。
希望这篇文章能让你下次面对 rm -rf 时,多一份从容,多一份底气。
29. 引用与版本说明
- 本文涉及的内核版本以 3.10、4.18、5.4 为例
- ext4 格式参考 kernel.org Documentation/filesystems/ext4
- btrfs 来自 btrfs.wiki.kernel.org
不同版本字段可能略有差异,实际操作请以目标环境的工具版本手册为准。