目标与现状
把「旧中国老照片」归档(Hyper-V 动态 VHDX)在 NixOS 上手动挂载、全程只读、mtime 不失真——判据是显示 1800 年代的拍摄年代,不能被夹成 1970;并要求在 Dolphin 左侧「存储设备」里出现两个固化的可点击设备(history-img / history-vhdx),默认卸载、挂载参数全部在 nix 里预先配好。
| 项 | 值 |
|---|---|
| 镜像目录 | /s500data/DCIMData/1爱好/旧中国老照片_镜像文件/ |
| 归档(保真载体,Windows 侧用) | China-modern-history_NTFS.vhdx |
| raw 形态(宿主用,稀疏) | China-modern-history_NTFS.img |
| 挂载点 1(来自 .img) | /s500data/DCIMData/1爱好/history-img |
| 挂载点 2(来自 .vhdx) | /s500data/DCIMData/1爱好/history-vhdx |
| 声明式模块 | /etc/nixos/modules/system/mount/China-modern-history_NTFS_img.nix/etc/nixos/modules/system/mount/China-modern-history_NTFS_vhdx.nix |
| 设备服务 | history-img-attach(.img → /dev/nbd1p1)、history-vhdx-attach(.vhdx → /dev/nbd0p1) |
| 操作手册 | 镜像目录下 操作命令.txt |
| 侧车文件 | manifest_old-china-photo.csv(38,265 行)、photo-archive.vhdx.sha256 |
| 默认状态 | 两个都未挂载(fstab noauto);开机只把镜像 attach 成块设备,不挂文件系统 |
双形态各自一等公民:宿主走 raw(内核 loop / nbd 内建);VHDX 形态留给 Windows 原生「附加 VHD」只读挂载。两者同一内容,等价性用 qemu-img compare 核对。只读是红线:宿主 ro、Windows 侧 Read-only——两边同时可写挂同一份即双写损坏。
相对上一版的变化:目录改名到镜像文件同级的 旧中国老照片_镜像文件/;挂载点由 /mnt/photo 变为两个以目标文件命名的固化挂载点;vhdx→img 自动转换已取消(改为手册里的手动命令);两个镜像都改为经 qemu-nbd 暴露块设备,以便在 Dolphin「存储设备」里出现并可点击挂卸。
前置事实(实测)
| 项 | 实测值 |
|---|---|
| VHDX 形态 | 动态盘,虚拟 21.475 GB,块 32 MB,逻辑扇区 512,无父盘 |
| 分区表 | MBR,单分区 type=0x07(NTFS);分区起始 LBA 2048 → 偏移 1048576 字节 |
| raw 形态 | 表观 21.475 GB / 实占 9.129 GB(稀疏省 57.5%) |
| 归档等价性 | qemu-img compare -f vhdx -F raw ⇒ Images are identical. |
| 内容规模 | 38,266 个文件;挂载视图顶层 80 项(img 与 vhdx 两形态一致) |
| 侧车 manifest | 列 RelPath, Size, MTime, CTime, MTimeUtc;MTime 是本地 CST,MTimeUtc 才是 UTC |
| 现场副本(非本归档) | 38,265 个文件 mtime 已被改写(1970 出现 947 次、无任何早于 1900)⇒ 归档才是保真载体 |
| 宿主能力 | 内核 ntfs3 / loop / nbd 齐备(挂载本身不需要装任何包) |
| 原 VHDX sha256 | 846EC34810186A6AE14FB3F117D1C7743B55A7FE3ADDF7A96BFD10EB182DD77D |
最终方案:两个「固化存储设备」
目标形态:Dolphin 左侧「存储设备」里出现 history-img 与 history-vhdx 两条,默认卸载,点一下即挂载(再点卸载),挂载点与参数全部由 nix 决定;命令行 sudo mount/umount "<挂载点>" 与点击完全等价。
| 环节 | 做法 | 为什么这么做(实测依据) |
|---|---|---|
| 设备来源 | history-img-attach / history-vhdx-attach 两个 oneshot 服务开机用 qemu-nbd --read-only 把镜像暴露成 /dev/nbd1 / /dev/nbd0 | .vhdx 是容器格式,内核 loop 直接挂报「坏超级块」,必须 qemu-nbd;.img 本可纯 loop,但 KDE/udisks2 把 loop 设备一律 HintIgnore 隐藏,nbd 块设备才会出现在设备列表里 |
| 显示名 | udev 规则把 ID_FS_LABEL 改写为 history-img / history-vhdx | 设备名 = 文件系统卷标(镜像里是 PHOTOARCH);只改 udev 呈现名,镜像文件零改动。KDE 的 fstab 后端虽支持 x-gvfs-name,但 KF6 只收网络盘/fuse 盘(fstabhandling.cpp:_k_isFstabSupportedLocalFileSystem),本地 ntfs3 不收,那条路不通 |
| 挂载点/参数 | nix fileSystems."<挂载点>" → fstab 条目(noauto,user,ro,noatime,uid=1000,gid=100,umask=022) | 命令行 mount 读 fstab;Dolphin 点击走 udisks2 的 Mount,udisks2 同样取 fstab 条目——实测 udisksctl mount -b /dev/nbd1p1 即挂到 /s500data/DCIMData/1爱好/history-img 且参数与 fstab 一致 |
| 可见性判据 | 无需额外配置 | Dolphin 侧栏设备谓词(kio-6.26.0 kfileplacesmodel.cpp:434):StorageVolume.ignored == false AND usage ∈ {FileSystem, Encrypted};nbd 设备实测 StorageVolume.ignored=false、usage=FileSystem ⇒ 会被列出 |
| 默认卸载 | noauto;attach ≠ 挂载 | 开机只建立块设备,文件系统未挂载;需要时点击或 mount |
实现过程
raw 形态生成(改为手动,不自动执行)
qemu-img convert -p -f vhdx -O raw -t none -T none \
"/s500data/DCIMData/1爱好/旧中国老照片_镜像文件/China-modern-history_NTFS.vhdx" \
"/s500data/DCIMData/1爱好/旧中国老照片_镜像文件/China-modern-history_NTFS.img"
实测 3.2 秒完成;-O raw 默认按零块识别跳过 ⇒ 输出保稀疏(表观 21.475 GB / 实占 9.129 GB),不需要额外加参数。转换后 sudo systemctl restart history-img-attach 让新镜像生效。
声明式模块(两个,均在 configuration.nix imports 里启用)
waitNbd0p1 = pkgs.writeShellScript "wait-nbd0p1" ''
for _ in $(seq 1 100); do [ -b /dev/nbd0p1 ] && exit 0; sleep 0.1; done
echo "/dev/nbd0p1 等待超时(10s),未出现分区设备" >&2; exit 1
'';
systemd.services.history-vhdx-attach = {
description = "老照片 vhdx → 块设备 /dev/nbd0p1(Dolphin 设备列表 / 手动挂载)";
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
serviceConfig.RemainAfterExit = true;
serviceConfig.ExecStart = [
"${pkgs.qemu-utils}/bin/qemu-nbd --read-only --format=vhdx --connect=/dev/nbd0 ${vhdx}"
"${waitNbd0p1}"
];
serviceConfig.ExecStop = "${pkgs.qemu-utils}/bin/qemu-nbd --disconnect /dev/nbd0";
};
services.udev.extraRules = ''
SUBSYSTEM=="block", KERNEL=="nbd0p1", ENV{ID_FS_LABEL}="history-vhdx", ENV{ID_FS_LABEL_ENC}="history-vhdx"
'';
fileSystems."/s500data/DCIMData/1爱好/history-vhdx" = {
device = "/dev/nbd0p1";
fsType = "ntfs3"; # 内核驱动(非 FUSE)
options = [ "noauto" "user" "ro" "noatime" "uid=1000" "gid=100" "umask=022" ];
};
.img 模块结构相同,差异:--format=raw、设备 /dev/nbd1//dev/nbd1p1、显示名 history-img。生效:sudo nixos-rebuild switch。
单元内的有界等待是必需的:qemu-nbd --connect 返回后 /dev/nbdXp1 由内核/udev 异步创建,紧接着 mount 会报 fsconfig() failed: /dev/nbd0p1: Can't lookup blockdev;且不能写 After/Requires=dev-nbd0p1.device——该设备正是本单元创建的,会循环依赖死锁。
为什么必须是内核 ntfs3,而不是 ntfs-3g(FUSE)
归档里是 1860 年代的拍摄年代(mtime 保真是本项验收判据「不是 1970」)。内核 ntfs3 读写前置 1970 的时间戳不夹取,FUSE 路线有夹取风险。
实测证据
| 判据 | 实测值 |
|---|---|
| 只读挂载形态 | /dev/nbd1p1 /s500data/DCIMData/1爱好/history-img ntfs3 ro,nosuid,nodev,noexec,noatime,uid=1000,gid=100,dmask=0022,fmask=0022 |
| 只读生效 | 写入被拒:EROFS (30) Read-only file system |
| 点击路径实测 | udisksctl mount -b /dev/nbd1p1 ⇒ Mounted /dev/nbd1p1 at /s500data/DCIMData/1爱好/history-img(挂载点与参数取自 nix/fstab,无需密码) |
| 普通用户可挂 | mount "<挂载点>" 不带 sudo 成功(fstab user + NixOS mount/umount setuid root) |
| 设备名 | udevadm info /dev/nbd1p1 ⇒ ID_FS_LABEL=history-img;Solid 侧 description=history-img、StorageVolume.ignored=false、usage=FileSystem ⇒ 命中 Dolphin 侧栏谓词 |
| mtime 保真(两形态同值) | 同一张照片:history-img 与 history-vhdx 均 mtime=1860-05-13 02:47:33、size=186480;直方含 1840:1 / 1860:111 / 1870:46 / 1900:581 / 1924:2540 / 1930:5020…,未被夹成 1970 |
| 顶层条目数 | img 侧 80、vhdx 侧 80(一致) |
| manifest 对照 | 用 MTimeUtc + Size 抽查 6 项(3 项 1860s + 3 项随机)⇒ 6/6 完全一致 |
| raw 与 VHDX 等价 | qemu-img compare -f vhdx -F raw ⇒ Images are identical. |
| vhdx 全链路 | 用干净设备 /dev/nbd2 复测:挂载成功、顶层 80 项、样本 mtime 1860-05-13 02:47:33、size 186480(与 img 完全一致) |
| 默认不挂载 | rebuild 后 /proc/mounts 无 history 条目;两个 attach 服务 active (exited),/dev/nbd0p1、/dev/nbd1p1 就位 |
管理命令
| 用途 | 命令 |
|---|---|
| 图形界面挂/卸 | Dolphin 左侧「存储设备」点 history-img / history-vhdx(再点一次卸载,无需密码) |
| 命令行挂 / 卸 | sudo mount "/s500data/DCIMData/1爱好/history-img" / sudo umount "/s500data/DCIMData/1爱好/history-img"(vhdx 同理) |
| 等价于点击的 udisks2 命令 | udisksctl mount -b /dev/nbd1p1 / udisksctl unmount -b /dev/nbd1p1 |
| 设备服务状态 / 断开 / 接回 | systemctl status|stop|start history-img-attach(vhdx 同名替换) |
| 看设备与显示名 | ls -l /dev/nbd0p1 /dev/nbd1p1;udevadm info /dev/nbd1p1 | grep ID_FS_LABEL |
| 设备详情 | udisksctl info -b /dev/nbd1p1 |
| 确认挂上了且是 ro | grep history /proc/mounts 或 findmnt "/s500data/DCIMData/1爱好/history-img" |
| 只读自检(预期被拒) | touch "/s500data/DCIMData/1爱好/history-img/__probe__" ⇒ Read-only file system |
| 看某个文件的 mtime | stat -c '%y %s %n' "/s500data/DCIMData/1爱好/history-img/<路径>" |
| 重建 raw(归档更新后) | 见「实现过程」的 qemu-img convert 命令,然后 sudo systemctl restart history-img-attach |
| 等价性核对 | qemu-img compare -f vhdx -F raw <vhdx> <img> ⇒ Images are identical. |
| 改配置后生效 | sudo nixos-rebuild switch(NixOS 的 /etc/fstab 只读,只能改 nix 模块) |
| nbd 设备卡住后复位 | sudo systemctl stop history-img-attach history-vhdx-attach → sudo rmmod nbd && sudo modprobe nbd max_part=16 → sudo systemctl start ...;rmmod 报「模块被占用」时重启一次即复位 |
踩坑记录
- 整盘 raw 直接按 ntfs3 挂会失败:报
ntfs3(loop0): Primary boot signature is not NTFS—— NTFS 卷在 MBR 分区里,必须加offset=(本档 = 1048576 = LBA 2048 × 512)。 .vhdx不能用 losetup:容器格式(1MB 头 + 区域表 + 块映射表),内核 loop 看到的是容器字节 ⇒ 报「坏超级块」;必须qemu-nbd --format=vhdx。/dev/nbdXp1是异步创建的:qemu-nbd --connect返回后立刻 mount 会报fsconfig() failed: ... Can't lookup blockdev⇒ 单元内必须有界等待。- 不能写
After/Requires=dev-nbd0p1.device:该设备正是该单元自己创建的 ⇒ 循环依赖死锁 ⇒ 改为单元内writeShellScript有界轮询(10s 超时后明确报错退出,不静默继续)。 - nbd 设备会被「卡住」:异常中止(单元被 cgroup 杀、反复 connect/disconnect 实验)后设备停在「size 已设、读全 EIO」状态,
qemu-nbd --disconnect也清不掉、rmmod nbd报「模块被占用」⇒ 重启一次复位。本轮即把/dev/nbd0实验卡住,故用干净/dev/nbd2复测 vhdx 全链路通过。 - udev 改名要触发才生效:设备已存在时新增规则需
udevadm control --reload && udevadm trigger --sysname-match=nbdXp1;开机路径由服务在规则就绪后创建设备,无需手工触发。 - KDE 有「两个 ignored」别搞混:
StorageAccess.ignored(未挂载且挂载点不在/media、/run/media、$HOME下时为 true)≠StorageVolume.ignored(侧栏真正看的字段,nbd 设备为 false)。udisksctl info的HintIgnore又是 udisks2 自己的一层。 - KF6 Solid 的 fstab 后端不收本地盘:只收网络盘(nfs/cifs/smb3/fuse.sshfs/fuse.rclone 等)与 fuse.encfs/cryfs/gocryptfs/overlay ⇒ 挂载点写进 fstab 也不会出现在「存储设备」列表;能出现是因为 nbd 是 udisks2 管理的块设备。
- manifest 的
MTime是本地时间:与挂载视图会差 8 小时(CST vs NTFS 内部 UTC)⇒ 判读/对照一律用MTimeUtc,否则会误判成「时间不一致」。 - mtime 里出现 1970 的那 947 个文件是原始数据本身没有时间戳(与现场副本同数),不是本次挂载造成的夹取。
- 稀疏不用手动保:
qemu-img convert -O raw默认按零块识别跳过(实测省 57.5%);加-S 0反而会关掉这层检测、把整盘写满。 - NixOS 的
/etc/fstab只读(store 生成)⇒ 改挂载只能改 nix 模块 +nixos-rebuild switch;手工cat >>会报「只读文件系统」。 - Trilium 更新陷阱(本轮踩到):
update_note是整篇覆盖——只想改一处也必须提交完整正文,否则内容被截断。
边界
- 本档只做只读;任何写入需求(补元数据、整理目录等)不在范围内。
- Windows 侧的「附加 VHD」只读挂载与宿主
history-vhdx是同一内容的两形态,可用于交叉核对同一张 1800 年代照片的「修改日期」。 - 待一次重启:线上
/dev/nbd0被本轮实验卡住,重启后history-vhdx与history-img完全同路径工作(img 侧已现行验证;vhdx 侧已用干净设备端到端验证)。 - vhdx→raw 的自动转换已取消,改为手册中的手动命令;后续若要恢复自动,需重新引入带
ConditionPathExists的 oneshot 单元。