起因:文件登记上了,但实体还在老地方
上一轮做的是“原地只读导入”:让 Cloudreve 以专用用户的身份读取 SMB 共享目录里的老文件,把路径登记进数据库,一个字节都不复制。这一步很成功——101 个文件、41 个子目录、143217817324 字节(约 133.4GiB)都能在网盘里看到。
但它的定位只是过渡方案:文件实体仍然留在 /media/ztl/LinuxDate/files/我的文件,而存储策略 2「外置机械硬盘」实际声明的 Blob 模板是 /media/ztl/LinuxDate/files/cloudreve/{uid}/{path},实体名形如 {uuid}_{originname}。也就是说,网盘里看到的路径和磁盘上的真实位置对不上,日后备份、迁移、恢复都会别扭。
所以这一轮的目标是:把 101 个文件的实体搬进策略自己的 Blob 目录,同时保持网盘里的文件名、ID、逻辑结构和容量统计完全不变。
前提条件
动手之前先确认了几个关键条件,缺一个都不能这么干:
“同一文件系统”和“单链接”是这套方案的命门。同一文件系统意味着可以用硬链接指向同一份内容,而不需要真的复制 134GB;单链接意味着改动不会波及到别的引用。
方案:硬链接加一次事务切换
Cloudreve 并没有提供现成的“同策略实体迁移”命令。所以这里用的是已审阅的版本 schema,核心原则是严格保留现有实体与文件 ID,只在一个事务里更新实体记录里的 source 字段。
整个迁移在暂停 Cloudreve 和 SMB 的窗口内按顺序执行:
生成冻结的逻辑数据库备份、源 ACL 快照,以及每个原文件的身份计划;
按策略的逻辑父目录与 UUID 模板建立硬链接,逐个验证设备号、inode、大小、mtime;
用一个事务完成 101 条 source 条件更新,每条都带断言;
确认全部新引用一致之后,才取消旧路径的链接;
之后才调整新实体的属主为 cloudreve,并移除旧 ACL。
整个过程没有改写原文件实体的内容,新的服务目录写权限和启动检查放在独立的 drop-in 里,没有修改厂商内核、其他容器或 SMB 配置。
硬链接是同盘技巧,不是独立备份。它只是让两个路径指向同一份物理数据,数据本身仍然只有一份。迁移中断时该往哪走取决于数据库和路径的实际状态,不要手工删掉新旧任意一边。
迁移工具怎么用
工具是 migrate.py,必须用虚拟环境,标准库即可:
python3 -m venv /root/AI/cloudreve/private/blob-migration/venv
PY=/root/AI/cloudreve/private/blob-migration/venv/bin/python
$PY -m unittest discover -s /root/AI/cloudreve/blob-migration/tests -v
$PY /root/AI/cloudreve/blob-migration/migrate.py plan五个阶段各有明确职责:
命令通过 Docker MySQL 客户端读取运行时的私密配置,密码以 stdin 方式传入,不显示、也不会保存到脚本里。
本次五个阶段全部已经执行完成,不要重放
plan/link/commit/cleanup。清理之后 plan 文件的作用是历史验证依据;如果后续业务更改了逻辑结构,指纹验证会拒绝执行——这是保护机制,不是服务故障。
结果
有一点需要特别说明:这不是重新导入,也没有创建第二份文件。网盘网页里的名字保持原样,磁盘上实体名带 UUID 是正常现象。
运行配置与测试
迁移不只是搬文件,还包含运行环境的配套改动,都在归档里:
归档目录 /root/AI/backup/cloudreve-blob-migration/ 包含上述四件套,迁移工具、测试和文档放在 files/root/AI/cloudreve/blob-migration。普通归档不包含业务文件、数据库、实际 conf.ini 或凭据,也不包含带具体文件名清单的计划。
结果是:专用 Blob 目录在 systemd 命名空间内可写,而原来的 SMB 源树仍然只读。
restore.sh 完整内容
这份脚本默认 dry-run,只恢复运行配置——Blob 目录的命名 ACL、systemd drop-in 与启动检查脚本(改动前就地备份成 *.bak.<时间戳>),并且只对专用目标目录非递归授权,不改变已有文件属主。它不重放迁移、不复制 Blob 内容、不覆盖数据库,也不会擅自启动业务:
#!/usr/bin/env bash
set -euo pipefail
# 默认预演,只恢复Blob运行配置;不自动重放业务迁移或数据库操作。
base=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
mode=${1:-}
[[ -z "$mode" || "$mode" == --apply ]] || { echo '用法:restore.sh [--apply]' >&2; exit 2; }
if [[ -f "$base/MANIFEST.sha256" ]]; then (cd "$base" && sha256sum -c MANIFEST.sha256 >/dev/null); fi
if [[ -d "$base/files" ]]; then files="$base/files"; else echo '缺少files目录。' >&2; exit 1; fi
blob=/media/ztl/LinuxDate/files/cloudreve
parent=/media/ztl/LinuxDate/files
printf '目标Blob目录:%s\n' "$blob"
echo '恢复专用目录ACL、systemd可写范围与启动检查;不复制/移动业务文件,不覆盖数据库。'
[[ "$mode" == --apply ]] || { echo '预演完成,未写入。'; exit 0; }
[[ $(id -u) -eq 0 ]] || { echo '实际还原需要root。' >&2; exit 1; }
[[ $(findmnt -rn -M /media/ztl/LinuxDate -o UUID) == 8b9cbec5-c4d7-466f-acea-d1c9531ccada ]] || { echo '固定UUID硬盘尚未挂载。' >&2; exit 1; }
[[ ! -L "$blob" && ! -L "$parent" ]] || exit 1
getent passwd cloudreve >/dev/null
[[ -d "$parent" ]] || { echo 'files父目录缺失,先恢复业务目录。' >&2; exit 1; }
[[ -d "$blob" ]] || install -d -o cloudreve -g cloudreve -m 0700 "$blob"
# 专用目标目录非递归添加权限,不改变已有文件属主;恢复后的文件应从业务备份保留原权限。
stamp=$(date +%Y%m%d-%H%M%S)
install -d -m 0700 /root/AI/cloudreve/private
getfacl -p /media/ztl /media/ztl/LinuxDate "$parent" "$blob" > "/root/AI/cloudreve/private/blob-acl-before.$stamp.txt"
setfacl -m u:cloudreve:--x /media/ztl /media/ztl/LinuxDate "$parent"
setfacl -m u:cloudreve:rwx "$blob"
install -d -m 0755 /etc/systemd/system/cloudreve.service.d /usr/local/libexec
for target in /etc/systemd/system/cloudreve.service.d/20-blob-storage.conf /usr/local/libexec/cloudreve-blob-check; do
if [[ -e "$target" ]]; then cp -a -- "$target" "$target.bak.$stamp"; fi
done
install -m 0644 "$files/etc/systemd/system/cloudreve.service.d/20-blob-storage.conf" /etc/systemd/system/cloudreve.service.d/20-blob-storage.conf
install -m 0755 "$files/usr/local/libexec/cloudreve-blob-check" /usr/local/libexec/cloudreve-blob-check
systemctl daemon-reload
# 不擅自启动未恢复完整业务数据的系统,也不重新导入/移动文件。
echo '运行配置已恢复;数据库/Blob文件/私密conf.ini需先从一致性备份恢复,再手工重启Cloudreve。'
注意事项与踩坑
硬链接不是备份。 同盘迁移省下了 134GB 的复制,但数据依然只有一份,该做的加密离线备份一个都不能少。
不要手工整理 Blob 文件。 磁盘上的实体文件名含 UUID,改名、移动、删除都会让数据库记录失效。
工具不是通用的。 这个固定任务工具只适用于本次的 101 个实体、用户 1、策略 2 和既定路径模板。换用户、换策略、换文件数量都必须重新审阅代码。
SMB 那边现在没有这批文件了。 原
myfiles共享里已经看不到它们。如果要从 Windows 继续访问,优先用 Cloudreve 下载或 WebDAV,不要把整个 Blob 目录作为普通可写共享自动开放出去。备份别拿旧的顶替。 私密计划、冻结数据库备份与原 ACL 在
/root/AI/cloudreve/private/blob-migration/,目录权限 700、SQL 600,含用户安全信息,不进 Git 或普通归档。迁移前的备份仍然引用旧路径,不能直接拿它配合当前 Blob 目录使用。迁移后的正常备份怎么做:暂停 Cloudreve 写入,做 MySQL 一致性逻辑导出,备份
/opt/cloudreve/data私密运行数据、当前 Blob 实体目录以及其他策略目录,加密保存到设备外。迁移后另留一份私密 after.sql 作为当前 schema 与路径的快照。同盘备份防不了整机重装。系统重装恢复顺序:先恢复 Docker/1Panel/MySQL 与固定 UUID 挂载、cloudreve 专用用户和本体;从对应时间点的业务备份恢复数据库、Blob 内容和实际 conf.ini;校验归档 →
./restore.sh预演 →./restore.sh --apply恢复最小运行 ACL、drop-in 和检查脚本;检查文件权限为 cloudreve/600,再手工启动服务并验证。本 Blob 运行补丁必须最后还原。 依赖 Ubuntu 24.04 ARM64、bash/coreutils/util-linux/acl、Python 3.12 虚拟环境、Docker MySQL 客户端和 systemd。还原脚本不自动迁移、不复制 Blob 内容、不启用 SMB 新共享、不覆盖凭据。
如果要回滚
回滚涉及数据库和源路径,必须暂停 Cloudreve 和 SMB,先备份当前状态,再人工审阅私密 plan.json:
按 plan 里的 src/dst 映射,在原父目录建立指向现有 Blob 的硬链接,严禁覆盖任何已有文件,逐个确认同盘、inode 与大小。如果文件后来经过 Cloudreve 改写、换版本或删除,不能继续套用初始计划;
用条件事务把原实体 source 从 dst 改回 src,保持 ID、引用与容量不变;验证全量引用之后,才能清理对应的新链接;
恢复原文件 uid/gid/mode 及原 ACL 快照,恢复原 systemd 可写范围,确认旧路径可读,再启动服务;
不要直接恢复旧整库而忽略文件路径或备份之后的新业务,这可能把新操作一并撤销。如果只是想撤销运行写入范围,可以停服务、删除本次固定 drop-in 后
daemon-reload,但这不会自动把数据迁回来。
没有提供自动执行的破坏性回滚脚本,就是为了避免新业务发生之后被误回退。计划里保留了原 UID/GID/mode 和完整的旧新路径,可以做定向恢复。
能力边界
“外置机械硬盘”策略的新上传目标现在具备系统写权限了,但实际走到哪条策略上传还取决于用户组和文件夹策略配置,本次没有静默修改用户组的默认策略。目前 101 个文件在服务命名空间可读、实体路径与 ID 一致、目标目录可创建/写入/删除,HTTP 200。
没做的验证同样列出来:没有用用户的 Web 密码登录,没做 Web 端大文件下载和上传,没做整机重启、断电恢复或热拔盘,也没测实际功耗。此外没有读取全部 134GB 内容做独立 SHA256——两条路径同一设备、同一 inode,已经足以证明它们引用同一份内容,但不能据此宣称存在独立的副本校验。
附:完整脚本
上面步骤里用到的完整脚本不再整段贴在正文,已作为附件上传到对象存储,点击文件名即可下载:
migrate.py —— 302 行,五阶段迁移工具 plan/link/commit/cleanup/verify
对象存储策略会自动把文件名改写成 UUID(例如
95D96A8A-FB38-4CCB-AB42-F6E906E70760.py),这是存储策略的行为,不影响文件内容;下载后按正文里的路径改名放好即可。