一次 ext4 数据盘误删后的恢复记录

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
ext4数据恢复DockerPostgreSQLMySQL

2026 年 11 月 6 日,一条本来用于清理 VS Code Server 的 rm 命令多了一个目录参数,递归删除进了下面的 ext4 数据盘。没有误删前的快照,部分 Docker 容器仍在运行。我最后恢复了正式业务库、SQLite 和业务引用的对象文件。这篇把实际用过的操作按用途放在一起。设备、路径、容器名和库名用了示例值,复制命令前先换成自己的。

确认数据盘,检查还打开的文件

先用 findmnt 核对挂载点,再看容器用的镜像、PID 和绑定挂载。我当时还需要保住运行中的服务,所以没有一上来就停掉全部容器。完整的 docker inspect 输出可能含密码,排查挂载只取需要的字段。

lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS
findmnt -T /srv/data
docker ps --format '{{.Names}} {{.Image}} {{.Status}}'
docker inspect --format '{{.State.Pid}} {{json .Mounts}}' api
docker top api -eo pid,comm

宿主机的 /proc/PID/fd 可以找到进程仍持有的文件。SQLite 的主文件和 WAL 要一起看。这次就是靠打开的句柄定位了两个 inode,再从镜像恢复对应文件。下面的 PID 和描述符编号由现场输出确定。

sudo ls -l /proc/PID/fd
sudo stat -Lc 'inode=%i bytes=%s' /proc/PID/fd/7 /proc/PID/fd/8

拿到主文件和 WAL 后,我把它们放进工作目录,使用同一个基名。确认来源和一致性后,再生成用于恢复的 SQLite 文件。运行中的库不能靠先后复制两个句柄来保证同一时点,能连上原库时优先使用 SQLite 的在线备份。 SQLite Backup API

# work/ 内已有 app.sqlite 和同一份数据库的 app.sqlite-wal
sqlite3 work/app.sqlite ".backup 'recovered.sqlite'"
sqlite3 -readonly recovered.sqlite 'PRAGMA integrity_check;'

业务 API 和后台服务的 SQLite 都通过了检查。我还查询了业务表,后台库里读出了 369 条主体记录、1,027 条商品记录和 2,054 条积分流水。

在只读镜像上核对 inode 和目录

原盘约 100 GiB。我在短时间冻结写入期间获取了分区镜像和日志,并抽样校验镜像与源盘。冻结期间会阻塞写入,要根据业务情况安排窗口。有条件时可以从存储快照或离线设备取镜像。后面的设备名以实际探测结果为准。

image=/recovery/vdb1.img
stat -c '%s bytes' "$image"
sudo debugfs -R 'stats' "$image"
sudo debugfs -R 'ls -l /' "$image"
sudo debugfs -R 'stat <3670018>' "$image"

分区离线或已有稳定快照时,可用下面的命令取一份普通镜像。这是等效的离线取镜像示例。事故现场用的是从服务器分段传到本机的方式,源设备有 I/O 错误时应改用能记录坏块和续读进度的工具。输出目录应位于另一块盘,例子中的分区号须先用 lsblk 确认。

sudo dd if=/dev/vdb1 of=/recovery/vdb1.img bs=16M status=progress conv=excl,fsync
sha256sum /recovery/vdb1.img > /recovery/vdb1.img.sha256

这次先根据 ext4 日志里的旧 inode、目录项和 extent 找文件。后面又用 device mapper 的 snapshot 做写时复制层,将选中的旧元数据块写到副本,再修复目录树。能认出父目录的文件按原路径提取,其余留在 lost+found 按 inode 查。ext4 日志文档说明了事务中有哪些记录。手头的日志只保留了部分块版本,无法把整个文件系统还原到任意一分钟。

修复前可以先在未挂载的工作副本上跑 e2fsck -fn 查看问题。当时使用 e2fsck -fy 修复写时复制层,目录和 inode 的改动都落在该层上。修复结果有孤立文件时,从副本的 lost+found 提取。

下列命令是当时使用的挂载方式。示例假设已经有配套的 rollback-test.cow,其中保存了元数据重建和修复结果。空白 COW 文件不会产生这些结果。e2fsck 的写入也只在未挂载的工作副本上做。

origin=$(sudo losetup --find --show --read-only /recovery/vdb1.img)
cow=$(sudo losetup --find --show /recovery/rollback-test.cow)
sectors=$(sudo blockdev --getsz "$origin")
sudo dmsetup create recovery-view --readonly \
  --table "0 $sectors snapshot $origin $cow P 8"
sudo mkdir -p /mnt/recovery-view
sudo mount -t ext4 -o ro,noload /dev/mapper/recovery-view /mnt/recovery-view
sudo ls -li /mnt/recovery-view/lost+found

提取文件后,先核对原路径或文件格式,再验证内容。旧目录项可能还留着名字,指向的 inode 却已经被复用。这次 MySQL 启动失败就和这样的旧条目有关。用完后按顺序卸载。

sudo umount /mnt/recovery-view
sudo dmsetup remove recovery-view
sudo losetup -d "$cow"
sudo losetup -d "$origin"

PostgreSQL:从可读表导出,再导入新库

旧 PostgreSQL 18 的系统目录不完整。我在同版本的隔离实例中重建了读取所需的表结构,把能确认归属的堆文件接进去,结合旧事务状态读出可见记录。表文件的归属用 WAL 记录中的数据库、关系和块号来核对。业务表能数出行数后,还要读实际字段,尤其是依赖 TOAST 的大字段。这次就是在读取整行时发现 TOAST 索引需要重建。

下面的命令用 PostgreSQL 18 的 pg_waldump 查看一段可连续解码的 WAL。示例 LSN 要换成已知有效的记录边界。从输出中的块引用得到关系定位,再用页上的 LSN 和逻辑块号与候选文件交叉核对。

pg_waldump -p /recovery/wal -s 0/17000028 -n 20

下面从已经可读的隔离实例开始。pg_dump 不能直接读取散落的表文件。手头只有几段 WAL,也不足以完成时间点恢复。 PostgreSQL 18 归档与时间点恢复

docker exec pg-reader psql -U postgres -d business -v ON_ERROR_STOP=1 \
  -c 'SELECT count(*), sum(octet_length(row_to_json(t)::text)) FROM public.users AS t;'

docker exec pg-reader pg_dump -U postgres -d business \
  --no-owner --no-privileges > business-restored.sql

docker exec pg-clean createdb -U postgres business
docker exec -i pg-clean psql -U postgres -d business -v ON_ERROR_STOP=1 \
  < business-restored.sql

导入后,我在两个实例逐表跑相同的行数和内容校验,再重启新实例复查。下面是其中一张表的查法。空表的聚合值为 NULL。

SELECT count(*),
       md5(string_agg(row_to_json(t)::text, E'\n' ORDER BY row_to_json(t)::text))
FROM public.users AS t;

正式业务库的 15 张表通过了导入前后的比对,包含 2,984 个用户。重建后的库可以正常启动,之后才恢复到服务器。两个测试库只提取了部分数据,没有上线。

MySQL:检查 redo,再按表空间编号找文件

恢复目录里混进了一个过期的 redo 文件,正常启动报格式错误。我对照删除前的有效目录项,确认那一个文件已经不属于当时的 redo 集合,才在工作副本中移出。不要从这个案例推断 redo 都可以丢掉。应先看具体报错,再比对旧目录和文件归属。 MySQL 8.4 InnoDB 恢复说明

实例正常启动后,另一个业务库仍报 ERROR 1812。MySQL 字典里有表,磁盘上少了对应的 .ibd。用下面的查询列出表空间编号,再去恢复目录中找相同编号的文件。

SELECT @@innodb_force_recovery, @@read_only, @@super_read_only;

SELECT SPACE, NAME, FILE_SIZE, STATE
FROM information_schema.INNODB_TABLESPACES
WHERE NAME LIKE 'business/%'
ORDER BY SPACE;

我检查了候选文件第 0 页的页号、页类型和表空间编号。下面是读这些字段的最小脚本。编号对应后,还得在隔离实例里验证能否读取业务表。

python3 - /recovery/candidate.ibd <<'PY'
import struct
import sys

with open(sys.argv[1], "rb") as source:
    header = source.read(38)

if len(header) != 38:
    raise SystemExit("文件头不完整")

page = struct.unpack_from(">I", header, 4)[0]
kind = struct.unpack_from(">H", header, 24)[0]
space = struct.unpack_from(">I", header, 34)[0]

if page != 0 or kind != 8:
    raise SystemExit("未匹配到 InnoDB 表空间第 0 页")
print(space)
PY

18 个缺失表空间都找到了。装回隔离副本后,我确认 innodb_force_recovery=0,业务表能查询,才做逻辑导出。这里沿用了容器里的 MYSQL_ROOT_PASSWORD,命令里没有写明文密码。

docker exec mysql-recovery sh -c '
  MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot \
    --single-transaction --skip-lock-tables --routines --triggers --events \
    --set-gtid-purged=OFF --databases business
' > business-mysql.sql

服务器最终导入了受影响的业务库,18 张表和 7 个视图都能查询。上线时要先核对目标库和已有数据,再决定导入方式。mysqldump 默认可能生成 DROP TABLE,导入前要先检查 SQL 并在隔离库验证。

验证对象内容和 Git 引用

对象存储恢复后,我从正式库提取业务引用清单,用带认证的 S3 GET 逐个读取,对照原元数据核对长度和 MD5。下面两条是检查落地对象的命令,MD5 对照值来自原对象元数据。只有确认元数据里的 ETag 表示文件内容的 MD5 时,才可以直接比较。

wc -c < recovered-object.jpg
md5sum recovered-object.jpg

有张图只有元数据。我先按文件名里的 SHA-256 扫镜像,没匹配上。拿已恢复图片对照后,发现文件名里的哈希与存储内容不同。改按原元数据的 MD5 和大小匹配,找到了数据块。正式库引用的 1,061 个对象最后全部通过读取和校验。另有一个旧缩略图缓存条目缺分片。

Gogs 的 SQLite 查完以后,再遍历仓库引用。--missing=print 会列出缺失对象,看到退出码为 0 时也要检查输出。 Git rev-list 的缺失对象选项

git --git-dir=/recovery/repository.git rev-list --objects --all --missing=print

三个仓库里有两个的可达对象齐全。另一个仍缺一个 tree 和一个历史 commit,原引用保留。

上传、复核和归档

我把要回传的恢复文件打成压缩包,排除了各级 backup 目录。服务器解包到单独的恢复目录。缺失路径可以补回去,和运行中数据库重叠的文件保留独立副本。下面是本次打包和校验所用的命令形式。HOST 和服务器目录换成自己的。

tar --zstd --exclude=backup --exclude='*/backup' \
  -cf recovered.tar.zst -C /recovery/device .
sha256sum recovered.tar.zst > recovered.tar.zst.sha256
scp recovered.tar.zst recovered.tar.zst.sha256 HOST:/srv/recovery-upload/
cd /srv/recovery-upload
sha256sum -c recovered.tar.zst.sha256
mkdir extracted
tar --zstd -xf recovered.tar.zst -C extracted

压缩包的哈希能验证传输。我还对照本地清单,核对了服务器原路径和独立恢复目录中的 56,830 项,普通文件比较 SHA-256,符号链接比较目标。镜像删除前额外找到的 6 个 MSSQL .mdf 候选和 2 个系统库文件头片段也单独补传并校验了。MDF 只核对了长度和页号,没做 SQL Server 挂载验证。

确认这批旧库无需继续寻找后,我才删除原始镜像。清理本地工作目录前另做了完整归档,这份包含 backup。tar -d 比较压缩包中的文件和源目录,确认通过后保存校验值。

tar --acls --xattrs --sparse --zstd -cf device.tar.zst -C /recovery device
tar --acls --xattrs --zstd -df device.tar.zst -C /recovery
sha256sum device.tar.zst > device.tar.zst.sha256

这次本地归档约 7.65 GB。清理恢复目录后,WSL 内部释放了约 52.4 GiB,C 盘上的 VHDX 还需要另外处理。 WSL2 的 VHDX 为什么只增不减

给 sudo rm 加一层目录保护

这次误删的参数是一个父目录,数据盘挂在它下面。Ubuntu 可以装 safe-rm,让 rm 先检查命令行参数。它按路径拦截,-rf 也会经过检查。 Ubuntu safe-rm 软件包 · Ubuntu safe-rm 手册

安装并配置保护路径

sudo apt update
sudo apt install safe-rm
sudoedit /etc/safe-rm.conf

保留已有规则,再追加需要保护的绝对路径,每行一个。下面以挂在 /srv/data 的数据盘为例,同时保护可能误传给 rm 的父目录 /srv。

/srv
/srv/data
/srv/data/*

/srv/data 保护挂载点本身,/srv/data/* 匹配直接传入的下一层路径,两条都要写。我用 Ubuntu 24.04 的 safe-rm 在临时目录试过,/* 能拦住直属文件,显式传入更深一层的文件仍可能删掉。

确认 sudo rm 也走包装器

sudo sh -c 'p=$(command -v rm) && printf "%s\n" "$p" && readlink -f "$p"'

看最后一行是否指向 /usr/bin/safe-rm。Ubuntu 包会给登录 shell 配置 PATH,sudo 还可能使用自己的 secure_path。如果上面的命令解析到系统原版 rm,且 /usr/local/bin 在 sudo 的搜索顺序中排在原版前面,可以建下面的链接。链接已存在时先检查它的用途。 Ubuntu sudoers 的 secure_path 说明

sudo ln -sT /usr/bin/safe-rm /usr/local/bin/rm
sudo sh -c 'p=$(command -v rm) && printf "%s\n" "$p" && readlink -f "$p"'

如果仍解析到原版,检查 sudo -l 显示的 secure_path。需要调整时,用 visudo 把由 root 管理的 /usr/local/bin 放到原版 rm 所在目录之前,再重复检查。当前规则不方便改时,可以显式使用 sudo /usr/bin/safe-rm。

再用一个空的临时目录验证实际执行的 sudo rm。先把第一段打印出来的绝对路径加入 /etc/safe-rm.conf,在同一个 shell 执行第二段。即使包装器没有生效,测试的目标也只是刚创建的空目录。

probe=$(mktemp -d /tmp/safe-rm-check.XXXXXXXX)
printf '%s\n' "$probe"
sudoedit /etc/safe-rm.conf
if [ -n "$probe" ] && [ -d "$probe" ]; then
  sudo rm -rf -- "$probe"
  if [ -d "$probe" ]; then
    printf '%s\n' '测试目录仍在,请核对上面的 Skipping 提示'
  else
    printf '%s\n' '测试目录被删除,sudo rm 的保护未生效' >&2
  fi
fi

看到 Skipping 且目录仍在,才算拦截成功。不要只看退出码。验证完,从配置里移除临时规则,再用 rmdir -- "$probe" 清理还在的空目录。

哪些操作仍可能删掉数据

  • 递归删除未列入配置的上级目录时,safe-rm 只检查传给它的参数。进入保护目录后用通配符删除内容,也要看展开后的参数是否逐个匹配规则。
  • 直接调用 /usr/bin/rm 或 /bin/rm、运行用其他 PATH 的脚本,或者使用 find -delete、程序自己的删除接口,都可能绕过包装器。
  • 它也不会按文件总数拦截。GNU rm -I 可以在递归删除或一次传入超过三个文件参数时提示,但后面的 -f 会取消这个确认。 GNU rm 选项说明