做跨实现对象存储迁移的人,几乎都会踩同一个坑: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。
他们的路径值得抄:
- 先在等价硬件起一套 RustFS,用
s3-tests验一遍兼容性,把失败项记下来; mc mirror --watch做全量 + 增量同步,期间业务照常跑 MinIO;- 选低峰窗口切只读、校验
mc diff一致,保留旧集群只读副本做回滚; - 用
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 静默返回那种"看起来成功了"的错觉,才是迁移里最危险的东西。
真正让人放心的不是跑分,是你能不能在切流前把上面这些问题一条条问出来、答上来。至于选哪个产品做目标端,反而是最后一步的事:确认它的边界(比如中段读性能)、跑一遍自己的真实负载、留好回滚路,再切。
有迁过的朋友欢迎在评论区聊聊你们踩的坑,尤其是那种"文档里没写、切完才发现"的。