跨实现迁移 MinIO→RustFS:mc diff 静默返回才是最容易翻车的一步

做跨实现对象存储迁移的人,几乎都会踩同一个坑:mc mirror 跑完,mc diff 什么都没打印,就以为搬完了。

危险不在报错,在静默。没有遗漏清单、没有警告,你自然就接着去改 DNS、切流。等第二天三个问题同时冒出来------审计说合规桶的对象锁定状态查不到了、想回滚昨天误覆盖的配置发现历史版本没了、运维说新集群磁盘用量跟老集群对不上,可两边 ls 出来的对象数一模一样------你才意识到,mirror 搬走的只是对象,漏掉的那些才是麻烦。

这类坑不分存储厂商。只要源端和目标端不是同一套实现,对象能平搬过来,不代表版本、配置、锁定状态、未完成分片这些"对象之外"的东西也能过来。很多人栽,就栽在把"mc diff 一致"当成了"迁移完成"。

本文以一次 MinIO → RustFS 的迁移为例(反过来从 RustFS 迁到 MinIO 也一样),把跨实现迁移里真正会漏的几类东西列清楚,从"mc diff 到底比了什么"讲到"完整校验怎么做",最后给一张切流前该逐条对过的单子。命令都能直接跑。


一、先搞清楚 mc diff 到底比的是什么

很多人对这个命令的信任是错位的。

mc diff 比对的是两端的对象名和大小,它不下载对象内容,不做逐字节校验。名字对得上、大小对得上,它就认为一致。

这在同构复制里够用,跨实现迁移就不够了

有人会想:那我用 ETag 兜底总行了吧。不成。分片上传出来的 ETag 不是文件的 MD5,而是各分片 MD5 拼接后再算一次的复合值,末尾还带分片数:

bash 复制代码
aws --endpoint-url https://minio.example.com \
    s3api head-object --bucket prod --key big/dataset.tar \
    --query 'ETag' --output text

只要两端的分片大小不同(客户端换了、SDK 默认值不同、mirror 自己重新切片),这个值必然对不上。拿 ETag 做跨集群校验,结果不是"发现差异"就是"假装没差异",两种都没用。

所以:mc diff 静默返回,只说明名字和大小对得上。剩下的全得自己查。


二、mc mirror 搬得走什么,搬不走什么

先把这件事想清楚,比急着敲命令重要。漏项基本集中在这五处。

漏项 1:历史版本和删除标记

mc mirror 搬的是每个 key 的当前版本。开了版本控制的桶,历史版本和删除标记不会跟着走。

先看清楚有多少东西会丢:

bash 复制代码
aws --endpoint-url https://minio.example.com \
    s3api list-object-versions --bucket prod --prefix config/ \
    --query 'length(Versions)'

三条路,代价各不相同:

  • 按 VersionId 逐个拉下来再顺序写入目标端。内容能搬过去,但版本 ID 和时间戳全变了,指着旧 VersionId 的代码和审计记录一样对不上;
  • 只搬当前版本,历史直接放弃。多数业务其实能接受,前提是有人拍板;
  • 老集群切只读留着当历史归档,新数据走新集群。最省事,代价是多养一套只读集群。

第三条通常是性价比最高的------前提是你在切流前就想清楚了保留多久,而不是切完才发现回不去。

漏项 2:桶级配置一样都不会跟着走

版本控制、桶策略、生命周期、CORS、加密、标签、对象锁定------这些都是桶的子资源,跟对象没关系,mirror 一样都不搬。

迁移前把它们全导出来:

bash 复制代码
EP="--endpoint-url https://minio.example.com"
aws $EP s3api get-bucket-versioning              --bucket prod
aws $EP s3api get-bucket-policy                  --bucket prod
aws $EP s3api get-bucket-lifecycle-configuration --bucket prod
aws $EP s3api get-bucket-tagging                 --bucket prod
aws $EP s3api get-bucket-cors                    --bucket prod
aws $EP s3api get-bucket-encryption              --bucket prod
aws $EP s3api get-object-lock-configuration      --bucket prod

最后那条命令没有 bucket- 前缀,不是笔误,S3 的接口命名本来就不统一。

对象锁定这条要单独拎出来说 :Object Lock 只能在建桶时 启用,桶建完再想开就晚了。而 mc mirror 碰到目标端不存在的桶会顺手替你建一个------建出来的是普通桶。等你发现的时候数据已经在里面了,只能重来。

所以合规桶必须手工先建:

bash 复制代码
aws --endpoint-url https://rustfs.example.com \
    s3api create-bucket --bucket prod-compliance \
    --object-lock-enabled-for-bucket

还有一层更容易漏:桶开了锁,不代表对象上的保留期和法律保留(legal hold)会跟着过来。那是对象级属性,得逐个读出来在目标端重打。合规桶的迁移工作量基本都花在这一步,别按普通桶估工时。

漏项 3:那些没跑完的分片上传

ls 看不见它们,但它们实实在在占着盘。

bash 复制代码
aws $EP s3api list-multipart-uploads --bucket prod \
    --query 'Uploads[].[Key,UploadId,Initiated]' --output text | head

mc ls --incomplete --recursive minio-old/prod

上传中断、客户端崩了、CI 任务被杀,都会留下残片。跑了几年的桶里攒出几个 TB 很常见------这也是开头那个"对象数一样、用量对不上"的答案:老集群的账面里含着这些残片,新集群没有。

单个清理:

bash 复制代码
aws $EP s3api abort-multipart-upload \
    --bucket prod --key path/to/obj --upload-id <UploadId>

更省心的是配一条自动 abort 规则,两端都要配:

json 复制代码
{
  "Rules": [
    {
      "ID": "abort-stale-multipart",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}

注意这条规则本身也属于生命周期配置,也就是上一节说的"不会跟着 mirror 走"的东西之一。

漏项 4:账号、密钥和 mc admin 那一套

网上不少迁移教程会让你 mc admin config export oldminio > minio-config.json。这条命令能跑,导出来的 JSON 也是真的,但别指望在目标端 import 回去

mc admin 系列走的是 MinIO 自己的管理接口,不属于 S3 标准 。跨实现迁移时,凡是挂在 mc admin 底下的能力都要当作"不可移植"来规划:

  • 桶策略是 S3 标准,可以原样搬;
  • 用户、组、内置策略绑定,手工重建;
  • 应用用的 access key / secret,在新集群重新建一套,顺便做一次凭证轮换,别复用旧的;
  • STS / AssumeRole 换临时凭证的链路,单独验证目标端支持到什么程度。这条最坑------它平时不出现在任何清单里,往往切流当晚才被发现。

桶策略的搬运没什么花样:

bash 复制代码
aws --endpoint-url https://minio.example.com s3api get-bucket-policy \
    --bucket prod --query Policy --output text > prod-policy.json

aws --endpoint-url https://rustfs.example.com s3api put-bucket-policy \
    --bucket prod --policy file://prod-policy.json

漏项 5:客户端那侧的隐式依赖

存储搬完了,调用方未必跟得上:

  • 把 ETag 当 MD5 用的校验代码。前面说过,分片上传下这个假设本来就不成立,换实现后更不成立;
  • path-style 开关 。自建集群基本都走 path-style,SDK 里的 force_path_style / path_style_access 要显式打开,别赌默认值;
  • region 字符串 。很多自建集群填 us-east-1 纯属占位,但它参与签名计算,改 endpoint 时顺手改了 region 就会签名失败;
  • 事件通知 。老集群配了 bucket notification 往消息队列投递的,迁移后这条链路是断的,而且不报错,只是再没有人触发;
  • 散落各处的 endpoint。配置中心、CronJob、备份脚本、Terraform 文件里都可能硬编码着老地址。

最后这条粗暴 grep 一遍,找到的通常比记忆里多:

bash 复制代码
grep -rIn -e 'minio.example.com' -e 's3.internal:9000' . \
     --exclude-dir={.git,node_modules,vendor,target}

三、那到底该怎么校验

就两步:先比清单,再抽样比内容

bash 复制代码
SRC="--endpoint-url https://minio.example.com"
DST="--endpoint-url https://rustfs.example.com"

aws $SRC s3api list-objects-v2 --bucket prod \
    --query 'Contents[].[Key,Size]' --output text | sort > src.list
aws $DST s3api list-objects-v2 --bucket prod \
    --query 'Contents[].[Key,Size]' --output text | sort > dst.list

wc -l src.list dst.list
diff src.list dst.list | head -50

清单一致只能证明"名字和大小对得上",跟 mc diff 是一个量级的结论。真要确认内容,得下载:

bash 复制代码
shuf -n 200 <(cut -f1 src.list) | while read -r key; do
  a=$(aws $SRC s3 cp "s3://prod/$key" - | md5sum | cut -d' ' -f1)
  b=$(aws $DST s3 cp "s3://prod/$key" - | md5sum | cut -d' ' -f1)
  [ "$a" = "$b" ] || echo "MISMATCH $key"
done

或者交给 rclone,--download 会真的把两端内容拉下来比对,绕开 ETag 不可比的问题:

bash 复制代码
rclone check minio-old:prod rustfs-new:prod --download --one-way

代价是数据被完整读两遍。几十 TB 的桶别整桶跑,挑核心前缀加随机抽样就够了------抽样发现不了 0.001% 的坏对象,但能发现"整个前缀没搬过来"这类真正会出事的问题,而后者才是迁移事故的主要形态。


四、一个 12TB 真实案例的规模感

我知道纯讲方法论有点虚,说个公开的真实案例:有团队在 2026 年 3 月把 12.4TB、4700 万个对象从 MinIO 迁到 RustFS,单周末切完,硬件是 4 节点、32 vCPU / 64GB / NVMe、节点间 25Gbps。

他们的路径值得抄:

  1. 先在等价硬件起一套 RustFS,用 s3-tests 验一遍兼容性,把失败项记下来;
  2. mc mirror --watch 做全量 + 增量同步,期间业务照常跑 MinIO;
  3. 选低峰窗口切只读、校验 mc diff 一致,保留旧集群只读副本做回滚
  4. warp 对典型对象尺寸扫一遍 PUT/GET,存好基线再决定全量切换。

整个过程没有"停服大迁移",是渐进式切流

他们迁移前后同一套硬件的对照数字(来源:该团队公开的迁移报告):4KB 对象 PUT 吞吐 2.88 MiB/s → 6.1 MiB/s;P99 写延迟 330ms → 77ms;空载常驻内存约 300MB → 约 95MB;二进制体积 320MB → 93MB。

但我得说句公道话 :这些改善集中在写路径和小对象。读路径上,官方 beta.10 的基准里,RustFS 在小对象(1KiB--16KiB)和大于 4MiB 的大对象段领先,但 100KiB--1MiB 中段 MinIO 暂时还占优 。如果你的负载是"读多写少 + 中段尺寸为主",别只看 PUT 数字,自己用真实样本扫一遍再下结论


五、还有一条更省事的路:原地替换

如果你的 MinIO 是同数据目录、同盘位的部署,其实还有一条完全不用搬数据的路------RustFS 支持二进制/镜像原地替换,直接复用 MinIO 的数据盘。

停掉 MinIO,用同一数据目录起 RustFS:

bash 复制代码
./rustfs /data/minio \
  --address ":9000" \
  --console-enable \
  --console-address ":9001" \
  --access-key "rustfsadmin" \
  --secret-key "rustfsadmin"

容器部署更简单,把 image 从 MinIO 换成 rustfs/rustfs,数据 volume 不变即可。

但这条路的边界必须记牢(官方也明确提示过):

  • 同构优先 :只适用于"同数据目录、同盘位"。盘位拓扑变了就别硬套,老实走 mc mirror
  • 格式差异 :两端内部数据格式不同,并非所有 MinIO 数据都能被自动接管 。迁移后必须校验,先对关键桶做 mc mirror 校验再弃旧;
  • 先 staging:非生产环境用相同数据目录跑一遍,确认桶、对象、ACL 都正常,再动生产;
  • 回滚简单:真出问题,停掉 RustFS、用原 MinIO 二进制指回同一目录即可回退------这正是它比"导出导入"安全的地方。

六、切流编排:几个容易踩的取舍

--watch 不等于零停机。 源端还在写的时候,最后那批对象的到达顺序不保证,覆盖类写入有可能落成旧值。真要一致,还是得留几分钟只读窗口。用几分钟停写换一次干净切换,比事后逐个对账便宜太多。

老集群留 7 到 14 天意味着这段时间存两份,成本翻倍。 但回滚代价的区别是"改一行配置"和"再搬两天数据",这笔钱值得花。

迁移期间别开 --remove 源端一次误删会被忠实地同步到目标端,把你唯一的另一份也抹掉。等切流稳定之后再考虑。


七、切流前,对一遍这张单子

  • 合规桶是不是在建桶时就开了 object lock,对象级的保留期和 legal hold 有没有重打
  • 每个桶的 versioning / policy / lifecycle / CORS / encryption 是否逐条 put 到目标端
  • 源端 list-multipart-uploads 是否清空,两端是否都配了自动 abort 规则
  • 历史版本的处置方案有没有人拍板,老集群保留多少天有没有写进文档
  • 清单 diff 是否为空,抽样内容比对是否 0 mismatch
  • 应用侧 path-style 开关、region、endpoint 是否全量 grep 过一遍
  • 事件通知链路是否在新集群重建并实测触发过
  • 老集群是否已切只读,回滚操作是否有人在预发演练过

这张单子跑通了再谈切流时间。


最后说句实话

写这篇不是因为"换个存储很简单"。恰恰相反------mc diff 静默返回那种"看起来成功了"的错觉,才是迁移里最危险的东西

真正让人放心的不是跑分,是你能不能在切流前把上面这些问题一条条问出来、答上来。至于选哪个产品做目标端,反而是最后一步的事:确认它的边界(比如中段读性能)、跑一遍自己的真实负载、留好回滚路,再切。

有迁过的朋友欢迎在评论区聊聊你们踩的坑,尤其是那种"文档里没写、切完才发现"的。

相关推荐
吃饱了得干活1 小时前
Java 单点登录实战:一条主线看懂 Session 共享、CAS 与 OAuth2+JWT
java·后端
tedcloud1231 小时前
emilkowalski/skills:让 AI 写出来的网页更有“设计感”
服务器·人工智能·开源·音视频·ai编程
卷无止境1 小时前
Jev来了,一个不会说话的AI模型正在改写自动化的规则
人工智能·后端
IT_陈寒1 小时前
React的useEffect依赖项居然骗了我三年
前端·人工智能·后端
AINative软件工程2 小时前
LLM 应用的 Bulkhead 隔离工程实践:用舰壁模式防止一个功能的过载拖左整个 AI 系统
后端·llm·ai编程
容器魔方2 小时前
基于 KubeEdge 为云边协同 AI 流数据分析提供基础设施
大数据·云原生·容器·开源·边缘计算
doiito(Do It Together)2 小时前
【Agent Harness】Gliding Horse 最新进化:从“能学习”到“可验证的自主进化”
人工智能·rust·系统架构·开源
szephyr2 小时前
消息队列入门:RabbitMQ 和 Kafka 到底怎么选,什么时候不该用
后端·架构·kafka·消息队列·rabbitmq
Flynt3 小时前
阿里开源的 AI 代码评审工具,我喂了 5 个坑,一个没漏
开源·ai编程·代码规范