对象存储迁移三种“假绿”:分片 ETag 不是 MD5、归档层 GET 静默 403、sync 重猜 MIME 把 .gz 改坏
先把结论摆在最前面
做了这么多年迁移,最怕的不是迁移报错,而是迁移脚本退出码 0、校验报告全绿、三天后业务反馈“附件打不开 / 图片变成下载 / 首页白屏”。对象存储迁移的坑,九成集中在“校验”这一环——你以为你在校验,其实你什么都没校验。
下面这三条,是踩过之后才会记一辈子的。
坑一:把 ETag 当 MD5 用,multipart 对象一个都对不上
这是最经典也最容易被“看起来很专业”的脚本坑死的一条。很多人写对账脚本时理所当然地认为 ETag == md5sum 文件,然后跑完发现 30%~80% 的对象对不上,第一反应是“迁移丢数据了”,连夜返工重传一遍,还是对不上。
ETag 到底等于什么
- 单次 PutObject(对象小于上传阈值、且未走分片):ETag 就是内容的 MD5 十六进制,这时
ETag == md5sum成立。 - Multipart Upload:ETag =
MD5( concat( 每个 part 的二进制 MD5 ) )+"-"+part 数量。注意拼接的是二进制 digest,不是十六进制字符串。 - 关键细节:即使你只分了 1 个 part(比如 8MB 文件配 8MB chunk),走 multipart 的 ETag 也是
md5(md5(part))-1,跟单次 PUT 的结果完全不同。所以不能靠“ETag 里有没有-”来判断是不是分片。 - 服务端加密:SSE-S3 不影响 ETag;但 SSE-KMS / DSSE-KMS 下 ETag 不再等于内容 MD5,彻底不可比。
- 兼容实现的差异:不少 S3 兼容厂商的 ETag 是内部哈希(尤其带压缩、去重、纠删码的实现),甚至同一对象重传两次 ETag 都不一样。这类存储上你做 ETag 对账,纯属自虐。
真要重算,得先知道 part size
问题来了:迁移完成后,你根本无法从 HeadObject 里得知当初的 part size(标准 S3 API 不返回这个)。所以你必须在迁移时就把 chunk size 钉死并记录下来,否则事后重算 ETag 是解不出来的。
# 迁移时锁定 chunk size,并写进迁移交接文档
rclone copy /data/mnt s3dst:bucket-name \
--transfers 64 --checkers 128 \
--s3-chunk-size 64M \
--s3-upload-concurrency 8 \
--s3-no-check-bucket \
--retries 8 --retries-sleep 5s \
--log-level INFO --log-file /var/log/mig-$(date +%F).log \
--stats 10s
之后对账时,用下面的脚本按 64M 重算,注意单 part 的边界情况:
import hashlib, os
def calc_etag(path, part_size=64 * 1024 * 1024):
md5s = []
with open(path, 'rb') as f:
while True:
chunk = f.read(part_size)
if not chunk:
break
md5s.append(hashlib.md5(chunk).digest())
if len(md5s) == 1:
# 单 part 的 multipart 上传,ETag 形如 xxx-1,必须走这个分支
return hashlib.md5(md5s[0]).hexdigest() + "-1"
return hashlib.md5(b"".join(md5s)).hexdigest() + "-%d" % len(md5s)
# 单次 PutObject 的对象才用这个
def calc_plain_md5(path):
h = hashlib.md5()
with open(path, 'rb') as f:
for b in iter(lambda: f.read(1 << 20), b''):
h.update(b)
return h.hexdigest()
更靠谱的做法:别用 ETag,用 CRC32C
S3 在 2022 年之后支持了 x-amz-checksum-* 系列,这才是端到端可比的校验值。上传时指定算法,之后 HeadObject 直接能拉出来比对,跟分片、加密都无关:
aws s3api put-object --bucket dst-bkt --key "a/b.bin" --body ./b.bin \
--checksum-algorithm CRC32C
aws s3api head-object --bucket dst-bkt --key "a/b.bin" --checksum-mode ENABLED
# 输出里会有 "ChecksumCRC32C": "xxxxxx=="
如果你的源端和目标端都支持,迁移验收就用 CRC32C,把 ETag 从流程里彻底删掉。这一步能省掉后面所有的扯皮。
坑二:归档层的对象 GET 直接 403,被脚本当“文件不存在”吞掉
源端桶里躺着一批一年前的日志和冷备,存储类别是 GLACIER / DEEP_ARCHIVE / 归档型。你的迁移脚本对每个 key 发 GET,返回 403 InvalidObjectState。如果你在循环里写了 || true、或者用了 --ignore-errors 这类开关、或者干脆 2>/dev/null,这个错误就彻底消失了,最后脚本干干净净退出,你还以为迁完了。
先摸清底数,别盲迁
# 源端按存储类别统计,先看清楚有多少冷数据
aws s3api list-objects-v2 --bucket src-bkt --output json \
--query 'Contents[].[StorageClass]' \
| jq -r '.[] | .[0] // "STANDARD"' | sort | uniq -c
解冻再迁
aws s3api restore-object --bucket src-bkt --key "cold/2023-06.tar.zst" \
--restore-request '{"Days":5,"GlacierJobParameters":{"Tier":"Bulk"}}'
# 轮询状态,注意 Restore 字段的三种形态:ongoing-request="true" / ongoing-request="false" + expiry-date / 无
aws s3api head-object --bucket src-bkt --key "cold/2023-06.tar.zst" \
--query 'Restore' --output text
两个容易忽略的点:
Days是解冻后可读的天数,从解冻完成时开始算。你按 Bulk 解冻 5 天,但迁移排期排到第 8 天,第一批解冻的对象又变回不可读,迁移脚本第二次跑照样 403,且这次你会以为是“偶发失败”。建议Days≥ 迁移窗口 + 复验窗口 + 2 天缓冲。- 在 AWS 上,同桶 CopyObject 到 STANDARD 不需要先 Restore(服务端复制),可以先用这招把冷数据“预热”成标准层再做跨桶/跨厂商复制,比一个个解冻快得多。跨厂商就没这个福利了,只能老实等解冻。
坑三:key 里的空格、%、+、中文,把签名和 XML 一起搞崩
业务方传上来的文件名里带空格、#、%、中文、甚至换行符,是常态。这些 key 在迁移时会在两个地方翻车。
翻车点 A:ListObjects 的 XML 解析炸
key 里含有 XML 非法字符(比如 \x01~\x08 这类控制字符)时,不开启 encoding-type=url 的 ListObjectsV2 会返回一个无法被解析的 XML,你的清单脚本直接抛异常,而很多人此时的选择是“加个 try 跳过这个 key”——于是这个 key 就永远不在清单里。
aws s3api list-objects-v2 --bucket src-bkt --encoding-type url --output json \
| jq -r '.Contents[]? | [.Key, (.Size|tostring), .ETag] | @tsv' > src.tsv
注意:开了 --encoding-type url 之后,返回的 Key 是 百分号编码的,你必须再解码一次才能拿去比对本地文件。忘记解码,你会发现清单里全是 %E4%B8%AD%E6%96%87.txt,跟目标端对不上,又是一轮深夜排查。
翻车点 B:URL 编码两次 → SignatureDoesNotMatch
S3 签名里 CanonicalRequest 的 URI 路径要做一次 encode,而很多自研迁移脚本在校验 Signature 之前又把 key 编码了一次,或者用 quote_plus 把空格编成了 +(S3 规范要求 %20)。结果就是:普通 key 全过,一旦碰到带空格或 + 的 key 就 403 SignatureDoesNotMatch。
# 正确:路径里的空格必须是 %20,'+' 必须被编码成 %2B
from urllib.parse import quote
key_path = quote(key, safe='/') # safe='/' 保留目录分隔符
# 错误示范:quote_plus 会把空格变 '+',S3 侧按字面 '+' 解析,签名直接对不上
顺带一提,key 里如果出现连续 // 或者以 / 开头,部分实现会自动归一化,导致“迁过去了但访问 404”。迁移前先扫一遍:
grep -P '/{2,}' src.tsv | head
grep -P '^\t*/' src.tsv | head
坑四:sync 重新猜 MIME,把 .gz、.woff、无扩展名文件全改坏
这条是业务侧最容易“当场发现、但迁移当晚没人发现”的。用 aws s3 sync 或 aws s3 cp 做迁移时,默认行为是根据文件扩展名重新猜测 Content-Type 并覆盖源端元数据。
app.js.gz→ 被猜成application/x-gzip,前端 fetch 后解压失败font.woff2在某些 CLI 版本的 mime 表里不认识 → 落到binary/octet-stream,浏览器直接拒绝加载字体- 无扩展名的静态资源(
/static/abc123这种 hash 命名)→ 全部变成 octet-stream Content-Encoding: gzip头丢失 → 对象明明压缩过,但浏览器不解压,页面全是乱码
修法:永远带上 --no-guess-mime-type,让 CLI 用源端对象的 Content-Type,而不是自己猜。
aws s3 sync s3://src-bkt/ s3://dst-bkt/ \
--no-guess-mime-type \
--exact-timestamps \
--cli-read-timeout 120 --cli-connect-timeout 30
用 rclone 的话,默认行为相对保守,但要显式保留自定义元数据(x-amz-meta-* 是业务存业务字段的地方,丢了你连“这条对象属于哪个租户”都查不回来):
rclone copy s3src:bucket s3dst:bucket --metadata --metadata-set cache-control=max-age=3600
验收时怎么查这一条
抽样比对关键前缀下对象的 Content-Type 和 Content-Encoding,比对比对全量大小更有效:
for k in $(head -200 keylist.txt); do
s=$(aws s3api head-object --bucket src-bkt --key "$k" --query '[ContentType,ContentEncoding,ContentDisposition]' --output text)
d=$(aws s3api head-object --bucket dst-bkt --key "$k" --query '[ContentType,ContentEncoding,ContentDisposition]' --output text)
[ "$s" != "$d" ] && echo -e "MISMATCH\t$k\t[$s] -> [$d]"
done
顺手两个成本类坑,不致命但很贵
- 未完成的分片上传残留:迁移中断后,已上传的 part 会一直躺在桶里占容量、并且照常计费,而 ListObjects 里根本看不到(它不是对象)。迁移前后都跑一次:
aws s3api list-multipart-uploads --bucket dst-bkt --output json \ | jq -r '.Uploads[]? | [.Key, .UploadId, .Initiated] | @tsv' # 清理超过 3 天的 aws s3api list-multipart-uploads --bucket dst-bkt --output json \ | jq -r '.Uploads[]? | select(.Initiated < (now - 3*86400 | strftime("%Y-%m-%dT%H:%M:%SZ"))) | [.Key, .UploadId] | @tsv' \ | while IFS=$'\t' read -r k u; do aws s3api abort-multipart-upload --bucket dst-bkt --key "$k" --upload-id "$u"; done - 目标桶开了版本控制:重跑同步 = 每个对象多一个版本,存储量直接翻倍,一个月后账单找你。迁移前确认
aws s3api get-bucket-versioning的结果,需要重跑的场景先关或改用--size-only/--ignore-existing策略。
真正能过验收的三步流程
别再只看“退出码 0”和“传输字节数对得上”了,按这三步走:
- 清单级对比:两端各出一份
key + size全量清单,sort后join比对,输出“仅源端 / 仅目标端 / size 不一致”三个文件。这一步能抓住 95% 的问题。 - 哈希级抽样:对 TOP N 大对象 + 全部小对象随机抽样,用 CRC32C(或极少数单次 PUT 场景下的 MD5)比对,抽 5%~10% 的量,够用。
- 元数据比对:抽样比对 Content-Type / Content-Encoding / x-amz-meta-*,专门抓坑四那类“数据没丢但业务坏了”的问题。
# 清单比对骨架
aws s3api list-objects-v2 --bucket src-bkt --encoding-type url --output json \
| jq -r '.Contents[]? | [.Key,(.Size|tostring)] | @tsv' | sort > src.size.tsv
aws s3api list-objects-v2 --bucket dst-bkt --encoding-type url --output json \
| jq -r '.Contents[]? | [.Key,(.Size|tostring)] | @tsv' | sort > dst.size.tsv
join -t $'\t' -v1 src.size.tsv dst.size.tsv > only-src.tsv # 源端有目标端没
join -t $'\t' -v2 src.size.tsv dst.size.tsv > only-dst.tsv # 目标端多出来的
join -t $'\t' src.size.tsv dst.size.tsv \
| awk -F'\t' '$2 != $3' > size-mismatch.tsv # 大小不一致
对象数上到百万级时,别一次性把 JSON 全load进内存,用 --page-size 1000 配合 NextContinuationToken 循环流式落盘,机器内存能省一个数量级。
最后两句
迁移客户端千万别和业务抢公网带宽,如果目标端是同地域的对象存储,走内网 endpoint,这部分流量出来基本不花钱,速度还稳定;我们这边跑大批量迁移时习惯在轻云互联的机器上起迁移节点,内网直连存储 endpoint,省下的公网出口费和抖动风险都很实在。
另外,迁移日志别只留 stdout。grep -Ei 'AccessDenied|InvalidObjectState|SignatureDoesNotMatch|SlowDown|429|503|RequestTimeout' mig.log 这条命令,建议你在每次迁移结束后、删源数据之前,当成肌肉记忆敲一遍。删源端数据这件事,一天都别提前。