返回星港

硬链接搬 134GB:Cloudreve 文件实体迁移到 Blob 目录的完整过程

OBSERVER又菜又爱玩的小朱 LAUNCH JUMPS1 次跃迁
硬链接搬 134GB:Cloudreve 文件实体迁移到 Blob 目录的完整过程 · 封面

起因:文件登记上了,但实体还在老地方

上一轮做的是“原地只读导入”:让 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、逻辑结构和容量统计完全不变。

前提条件

动手之前先确认了几个关键条件,缺一个都不能这么干:

条件

实际情况

源与目标是否同一文件系统

是,同一块 ext4 盘,固定 UUID 8b9cbec5-c4d7-466f-acea-d1c9531ccada

目标目录状态

最初为空

源文件链接数

101 个文件全部是单链接,实体引用数为 1

进行中的任务

没有进行中的任务或上传

“同一文件系统”和“单链接”是这套方案的命门。同一文件系统意味着可以用硬链接指向同一份内容,而不需要真的复制 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

五个阶段各有明确职责:

阶段

作用

plan

只做预检,默认不落盘;加 --apply 才保存私密 plan.json

link

建立新的 Blob 路径

commit

切换数据库中的实体引用

cleanup

移除已核验的旧路径

verify

核对最终状态

命令通过 Docker MySQL 客户端读取运行时的私密配置,密码以 stdin 方式传入,不显示、也不会保存到脚本里。

本次五个阶段全部已经执行完成,不要重放 plan / link / commit / cleanup。清理之后 plan 文件的作用是历史验证依据;如果后续业务更改了逻辑结构,指纹验证会拒绝执行——这是保护机制,不是服务故障。

结果

检查项

结果

迁移数量

101 个文件全部迁移,目标用户目录 .../cloudreve/1/

数据量

143217817324 字节(133.4GiB / 143.2GB)

物理身份

101 个新路径逐个保持原设备号、inode、大小、mtime

空间占用

新旧链接曾指向同一物理内容,没有复制 134GB,也没有多占一份

数据库

entities.source 一次事务切换,文件/目录名称、ID、逻辑父子关系、实体引用、策略归属与用户容量指纹保持不变

源目录

仅移除计划内的 101 个源文件目录项;源目录及 41 个子目录为空并保留,以免破坏 SMB 共享路径

权限

新文件由 cloudreve:cloudreve 持有,600 权限、链接数 1;新目标子目录 700

服务

Cloudreve/SMB 恢复运行,HTTP 200,全部线程 CPU 0–3;服务环境下 101 个文件全部能打开并读取 1 字节

目录读写

Blob 目录创建/写入/读取/删除空测试文件通过

有一点需要特别说明:这不是重新导入,也没有创建第二份文件。网盘网页里的名字保持原样,磁盘上实体名带 UUID 是正常现象。

运行配置与测试

迁移不只是搬文件,还包含运行环境的配套改动,都在归档里:

文件

用途

migrate.py

分阶段、默认预演的同盘实体迁移工具,严格拒绝符号链接、未知引用、数量或模板变化

tests/test_migrate.py

9 项局部测试:SQL 编码、路径、逻辑父级、安全拒绝、硬链接身份与元数据变化

files/etc/systemd/system/cloudreve.service.d/20-blob-storage.conf

仅增加 Blob 目标目录的写范围及启动前检查

files/usr/local/libexec/cloudreve-blob-check

固定 UUID、目录权限、无符号链接检查

restore.sh

默认 dry-run 还原运行配置,不迁移/导入数据,不覆盖数据库

归档目录 /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),这是存储策略的行为,不影响文件内容;下载后按正文里的路径改名放好即可。