这是本节的多页打印视图。 .
设计归档
- 1: SILO、KES 与 OpenBao:密钥归属和恢复
- 2: 写死 TLS 参数与握手兼容性
- 3: 桶配置复制:来源时间、删除与确定性收敛
- 4: SigV4 签名头覆盖边界:拒绝未签名的 x-amz-* 请求头
- 5: Object Lock 复制排序
- 6: SSE-C 副本完整性
- 7: 持久化 IAM 撤销
- 8: 多池对象一致性
- 9: 联邦 CopyObject:保持目标对象契约
- 10: 复制可靠性:删除完成、MRF 可见性与 resync 取消
- 11: FULL_OBJECT 校验和复制:在初始化上传时保留类型
- 12: Alias 区域:小配置改动的兼容性边界
- 13: IAM 策略重载:锁粒度、请求取消与超时预算隔离
- 14: LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界
- 15: 复制标签排序:修订时间戳、墓碑与复活
- 16: SILO Server 20260903 发布前复审
- 17: 多池覆盖亲和:为何最终没有采纳 #245
- 18: 副本元数据归一化:可信复制不得重新注入什么
- 19: 为 SILO 提供多语言支持
- 20: Multipart 提交失败可能让上传卡死
- 21: 局部视图不能把存在的对象报成不存在
- 22: 两阶段原生恢复
- 23: Console 的桶配额:列表显示与数值校验
- 24: Heal 演练不能删除数据
- 25: 大型单池集群的锁域
- 26: 多池生命周期过期:按整个对象决策
- 27: 恢复副本:过期的是副本,不是对象
- 28: 生命周期"删除全部版本"只能作用于一个 key
- 29: Peer 重载后的审计 Webhook
- 30: 失败写入与幽灵空前缀
- 31: 存储池迁移完整性审查:#261、#263 与 #265
- 32: 请求头截止时间:绝对头部上限与滚动的正文空闲超时
- 33: 条件 DELETE:一个条件对应一个逻辑对象
- 34: 只预览文本,绝不执行:SILO Console 文本预览设计
- 35: 数据库通知统一连接串:#53 的兼容性边界
- 36: 混合 EC 布局与迁移列表
- 37: 多池删除:失败时保住最新副本
- 38: Go 1.27 TLS 默认值与 OIDC Discovery 故障诊断
- 39: Tier free version 是内部清理记录
- 40: 鉴权前不做 I/O,Header 不授予权限
- 41: ILM 过期与存储池迁移顺序
- 42: 一个端点,两种权限:彻底分离用户与组状态
- 43: IAM Admin API 授权边界修复
- 44: 配置环境文件不是 Shell 脚本
- 45: 两把 SSE-C 密钥,一份 CopyObject 响应
- 46: ListMultipartUploads 行为契约与设计权衡
- 47: 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录
- 48: 总量未知时,进度条应该说什么
- 49: 列举不能丢掉仍有仲裁的 null 版本
- 50: ListObjects 快捷路径不能把不存在的桶伪装成空桶
- 51: 只读 Checksum 审计与可靠的 CLI 输出契约
- 52: 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性
- 53: BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
- 54: 旧版元数据与扫描响应复用时的残留字段修复
- 55: LIST 漏项修复:仅对存疑对象补读确认并收敛锁等待开销
- 56: 对象锁保留期越权与版本覆写漏洞修复设计(SN-2026-015 / SN-2026-016)
设计归档记录 PGSTY SILO 的核心工程决策:缺陷根因、S3 与 IAM 协议不变量、备选方案取舍、运维边界与回归测试索引。
2026-11-09 Console 多语言
- 为 SILO 提供多语言支持:横跨 Console 2.6.0 与 Console 2.7.0 的十语言界面(涵盖 #83 葡语贡献与 silo#277 土耳其语需求),记录维护分级、英文回退与阿拉伯语 RTL,以及支持逐语言分包、全量加载决策的 gzip 和缓存实测。
2026-11-06 发布验收中发现
- 局部视图不能把存在的对象报成不存在:只能连上部分盘的节点,为什么会对存在的对象返回 404,以及为什么判定“不存在”必须要求每块盘都应答。
- Multipart 提交失败可能让上传卡死:未达到写 quorum 的提交为什么会让上传一直卡到过期清理,以及为什么修复要等到存储操作能够加 fencing 之后再做。
2026-11-04 发布阻塞的生命周期修复
- 多池生命周期过期:按整个对象决策(#278、#279):解释只按单个池内版本做出的过期判断,为何会隐藏、删除或复活另一个池中的版本,以及为什么在对象锁内按合并版本列表复核是最小而完整的修复。
- 恢复副本:过期的是副本,不是对象(#281、#282):解释当前版本的恢复副本过期为何会隐藏对象或抹掉更早的 null 版本,以及哪些存储层与原生恢复防护要与精确版本修复一起交付。
- 两阶段原生恢复(#284):解释原生恢复为何在整个 tier 下载期间持有对象锁,#281 的第一版防护为何还会让失败的普通上传卡住,以及在锁外暂存、再做一次短暂且经过核验的提交,如何取代私有 upload。
- Peer 重载后的审计 Webhook(#259):解释运行时修改 audit_webhook 为何会让除处理请求的服务器外其他所有服务器上的目标停止工作,哪些日志子系统存在同样问题,以及为什么用服务器上下文构建日志目标、并先打开队列存储是最小而完整的修复。
- 失败写入与幽灵空前缀(#285、#286):解释暂存数据在远端 rename 之前消失的写入,为何会留下空对象目录并在之后被列为空前缀,以及为什么在两条 rename 失败路径上清理它就足够。
- Heal 演练不能删除数据(源自 #280):解释 heal 演练为何会删掉 dangling 对象最后的元数据副本,并在远端节点上执行真实的桶 heal,以及为什么提前返回加上把演练标志传给 peer 能同时关闭这两条路径。
- 生命周期"删除全部版本"只能作用于一个 key(在审查 #278 修复时发现):解释 ExpiredObjectAllVersions、DelMarkerExpiration 和批量过期的默认 purge 为何会删除过期 key 名下的所有其他 key,以及为什么在复核后按版本 ID 只清理该 key 自身的版本,是最小而安全的修复。
- Console 的桶配额:列表显示与数值校验(silo-console#72、silo-console#73):解释 Console 设置的配额为何在桶列表中显示为"-"、创建桶时为何会把超大配额变成 1 字节、两者的来源,以及已合并的 Console 修复覆盖了什么。
- 大型单池集群的锁域:解释在 18 台及以上主机的单池集群中,DELETE 与 CopyObject 为何无法与同一 key 上的 PUT 互斥,哪些布局受影响,以及关闭它的路由改动。
2026-11-01 存储池迁移完整性审查
- 存储池迁移完整性审查:#261、#263 与 #265:维护者对三个问题及其修复的审查,记录 #266 的贡献合并、#276 的维护者后续修复、验证证据与合并顺序。
- 混合 EC 布局与迁移列表(#261、#262):解释一次未完成的降级覆写为何会让可读对象从 decommission/rebalance 列表中消失,以及 #262 为何以最小范围解决问题。
- Tier free version 是内部清理记录(#263、#264):解释 free version 为何必须继续供 healing 使用,但不能作为用户可见的删除标记或迁移版本。
- ILM 过期与存储池迁移顺序(#265、#266、#276):解释版本桶的 Expiration 删除标记为何不能让当前版本被跳过,以及维护者后续修复如何保留 tier 清理和完成语义。
2026-10-24 FULL_OBJECT 校验和复制
- FULL_OBJECT 校验和复制:在初始化上传时保留类型(
#254、#255):解释遗漏类型为何让有效的分片 CRC 对象复制失败,共享请求头的一处修复如何同时恢复已有 CopyObject 路径,以及发送端升级与验收边界。
2026-10-24 多池删除与放置决策
-
多池删除:失败时保住最新副本(
#256、#257):解释在线磁盘预检查为何挡不住执行中的 I/O 故障,分阶段清理如何保住每项的权威副本,以及已合并修复的重试、分层引用、性能和兼容性边界。 -
多池覆盖亲和:为何最终没有采纳 #245(
#245):记录原始放置问题、请求级 quorum 与健康判断改进、仅限普通无版本覆盖的收窄设计、两轮 m3 性能实测,以及小对象覆盖仍未达标后的停止决定。实验修改没有合并。
2026-10-23 Alias 区域
- Alias 区域:小配置改动的兼容性边界:按 alias 保存区域、客户端缓存隔离,以及升级、降级和凭据轮换的边界;已按 mcli 20261109 复核。
2026-10-23 IAM 与 KMS 设计记录
- LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界(
#250):解析NormalizeDN的\xx十六进制转义为何触发对象 IAM 后端的单路径分量 255 字节上限,以及为何仅将物理上无法落盘的纯 ASCII 超长分量归类为errConfigNotFound。 - IAM 策略重载:锁粒度、请求取消与超时预算隔离(
#251):在保留writeMu写串行锁的前提下将慢速策略加载与 KMS 解密移出内存鉴权缓存锁,将取消信号贯通至KES.Decrypt,并将etcd单步读取超时与整批解密预算解耦。
Server 20261018 核心设计记录
围绕 Server 20261018 正式版的核心架构改动,请参阅:
- Object Lock 治理保留期与覆写防护(
SN-2026-015、SN-2026-016) - IAM Admin 凭据授权边界(
SN-2026-017) - 持久化 IAM 撤销与按范围撤销 STS(
SN-2026-013) - LIST 恢复锁开销优化
- ListMultipartUploads 回退决策与容量边界
- 多池对象一致性与迁移保护
- KES 与 OpenBao 集成
1 - SILO、KES 与 OpenBao:密钥归属和恢复
SILO 通过 PGSTY KES(MINIO_KMS_KES_ENDPOINT)接入外部密钥管理系统。当前最新版本为 KES 20261026(使用 Go 1.27.3 构建并更新云服务依赖,保持本文的 API、配置与密钥格式不变);此前的 PGSTY KES 20261018(源码 2f7e741db905)完成了将 AWS Secrets Manager 后端迁移至 AWS SDK for Go v2 的主体改造。
三个服务的职责边界
| 服务 | 负责内容 | 调用对象 |
|---|---|---|
| SILO | 对象字节流与对象加密元数据;加解密对象内容 | 通过 HTTPS(mTLS)调用 KES 生成或解密数据加密密钥(DEK) |
| PGSTY KES | 应用身份认证、密钥访问策略与密钥 API;封装或解封装数据密钥 | 通过 OpenBao KV v2 保存或读取根主密钥 |
| OpenBao | KES 根主密钥的持久化记录、后端访问控制与自身恢复材料 | 所配置的持久化存储后端 |
MINIO_KMS_KES_ENDPOINT 必须指向 KES 服务端点,因为 OpenBao 本身不实现 KES 密钥封装 API。KES 通过内置的 keystore.vault 配置访问 OpenBao(针对 OpenBao KV v2 挂载点需设置 version: v2)。同一个 kes 可执行文件同时提供服务端与管理 CLI,多个无状态 KES 实例可共享同一个外部 OpenBao 密钥存储。
SILO 将加密后的数据密钥(DEK)随对象元数据保存在存储桶中。对象存储桶本身不包含外部根密钥备份;一旦丢失 OpenBao 中的根密钥,即便磁盘上的所有对象纠删分片完好无损,已加密对象也将无法解密恢复。
对接 OpenBao KV v2 的最小 KES 配置示例:
针对名为 secret、前缀为 kes 的 KV v2 挂载点,KES AppRole 所需的最小 OpenBao ACL 策略:
身份认证与故障行为
部署时应分别配置 SILO → KES 与 KES → OpenBao 两段链路的 TLS 信任。KES 依据客户端证书的 SHA-256 摘要(kes identity of <cert>)授权 SILO 访问指定密钥名;OpenBao 则通过 AppRole 授权 KES 后端身份。
| 层级 / 配置项 | 故障表现 | 运维处置 |
|---|---|---|
| OpenBao 处于封存(Sealed)或中断状态 | 内存热缓存可能允许部分操作短暂继续,但未命中缓存的密钥请求或重启后的新 SILO 进程会立即失败(Fail-Closed),绝不会降级为明文存储。 | 在启动或滚动重启依赖 KMS 的 SILO 节点前先解封 OpenBao;执行恢复验证时必须重启进程清空热缓存并实际回读对象。 |
| KV v1 与 KV v2 配置不匹配 | 对 KV v2 挂载点配置 version: v1(或 OpenBao ACL 策略未同时授权 data/ 与 metadata/ 路径)会在创建或列举密钥时返回 403 permission denied 或 404。 |
显式设置 engine: <mount>、version: v2,并在 OpenBao 策略中同时授权 <mount>/data/<prefix>/* 与 <mount>/metadata/<prefix>/*。 |
| 客户端证书轮换 | 轮换 MINIO_KMS_KES_CERT_FILE 会改变客户端证书的 SHA-256 Identity。若新 Identity 未提前加入 KES 策略,SILO 启动自检将失败。 |
轮换证书前先在 KES 策略中同时保留旧、新两个 Identity;待所有 SILO 节点切换至新证书后再移除旧 Identity。 |
请妥善保管持久化密钥库备份、KES 配置、TLS 信任根、身份与策略配置以及 OpenBao 解封恢复材料,并在演练中验证恢复后的密钥名称与密钥值完全一致。KES 开发模式(内存模式)仅将密钥保存在进程内存中,严禁用于持久化加密存储。
SDK 迁移与兼容性
PGSTY KES 20261018(2f7e741db905)将 keystore/aws/secretsmanager 从已归档的 github.com/aws/aws-sdk-go(v1)迁移至模块化的 AWS SDK for Go v2(github.com/aws/aws-sdk-go-v2、config、credentials、service/secretsmanager 与 github.com/aws/smithy-go)。
迁移保持以下兼容性不变量:
- 完整保留既有密钥存储格式与 YAML 配置字段。
- KES YAML 中显式配置的凭据继续优先;留空时回退至 SDK 标准凭据链。
- KES 不会隐式加载
~/.aws/config,且配置文件中的endpoint始终优先于 SDK 端点环境变量覆盖。 - AWS Secrets Manager 的
ResourceNotFoundException、ResourceExistsException与参数错误保持与原内部错误(errKeyNotFound、errKeyExists)相同的映射语义。
兼容性验收矩阵(testing/kms-compat.sh)覆盖了 OpenBao KV v2 与 Moto Secrets Manager 的 SDK-v1 -> SDK-v2 -> 回滚 全流程切换,每次切换均重启 SILO 以清空内存密钥缓存并验证加密对象回读。
升级与恢复检查
- 记录当前 SILO 与 KES 版本、主密钥名称、后端端点及客户端证书 Identity,并在确认恢复成功前保留既有密钥与后端备份。
- 变更前读取一个既有加密对象并记录其预期摘要(Digest);基于同一持久化后端将 KES 升级为 PGSTY KES 20261026 二进制。
- 重启 KES 与 SILO(清空热缓存),读取旧加密对象、写入新加密对象并校验两者摘要,验证生产环境实际使用的 TLS 身份与访问策略。
- 在隔离演练环境中,基于同一密钥存储恢复旧版 KES 二进制,重启两项服务并重复验证加密对象回读。
分步配置与运维命令详见配置 KES 与 OpenBao 服务端加密指南、KES 参考配置以及 KES 兼容性测试脚本。
2 - 写死 TLS 参数与握手兼容性
Go TLS 默认值修复 48e184652 随 Server 20260916 发布后,issue #154 的报告人在该版本复测,OIDC discovery 仍然报同一条 connection reset by peer。与此同时,上游 Go 社区的同类 issue 将完全一致的症状定位到检查 signature_algorithms_cert 扩展并发送重置的 TLS 中间设备(见上游的同款案例)。本文在《Go 1.27 TLS 默认值与 OIDC Discovery 故障形态》的基础上,补充发布后的复测与机制分析、20260916 修复的覆盖边界、受影响部署的可选解法,以及由此确立的发布与沟通门禁。
先把本质说清楚
TLS 连接的第一条消息是 ClientHello,客户端在其中列出自己支持的算法。许多企业网络会在链路中部署中间设备(WAF、TLS 检查防火墙、负载均衡器)解析这条消息。部分设备在遇到不认识的算法编号时会直接重置连接。
Go 1.27 在这条消息中新增了三个编号——ML-DSA 后量子签名方案——共计 12 个字节。使用 Go 1.27 编译后,SILO 发出的 TLS 1.3 ClientHello 比 Go 1.26 多出这 12 个字节。报告人 IdP 前方的中间设备拒绝了该报文并返回 TCP RST。同一容器内的 curl 之所以正常,是因为 curl 使用 OpenSSL,不会发送这些编号。
Go 针对混合密钥交换提供了兼容开关 GODEBUG=tlsmlkem=0,但该开关有一个生效前提:仅当应用未显式指定曲线清单(Config.CurvePreferences == nil)时生效。 SILO 此前保留了一份从上游继承的显式曲线清单,其中恰好包含一个后量子条目,导致 GODEBUG=tlsmlkem=0 失效。48e184652 修复移除了这些硬编码曲线清单,使该开关恢复生效。
需要注意两者的区别:GODEBUG=tlsmlkem=0 只能关闭后量子密钥交换(ML-KEM),无法关闭后量子签名算法声明(ML-DSA),而 Go 1.27 新增的 12 个字节正是 ML-DSA。Go 标准库未提供关闭 ML-DSA 的开关。如果中间设备是因为 ML-DSA 编号而重置连接,仅移除显式曲线清单无法消除这 12 个字节——这与报告人的复测结果完全吻合。
Go 到底在解决什么问题
后量子密钥交换需要提前迁移。 在“现在截获、未来解密”(harvest-now-decrypt-later)威胁模型下,攻击者可以先录制当下的加密流量,待未来出现具备密码学破解能力的量子计算机后再行解密。因此,密钥交换的机密性迁移必须在量子计算机实用化之前完成。NIST 于 2024 年正式发布 ML-KEM(FIPS 203)与 ML-DSA(FIPS 204),当前部署采用混合模式:X25519MLKEM768 同时运行经典 X25519 与 ML-KEM 并合并双方共享密钥,即便 ML-KEM 存在未知弱点,连接安全性也不低于单独的 X25519。主流浏览器、CDN 与 SSH 实现自 2024 年起已默认启用混合密钥交换,Go 在 1.24 中跟进。
握手签名的时间表则不同。 握手签名无法被事后追溯伪造——未来的量子计算机无法令 2026 年已完成的握手失效。因此目前 TLS 1.3 中的 ML-DSA 仅用于在握手中声明客户端支持验证 ML-DSA 证书。Go 1.27 将 MLDSA44、MLDSA65、MLDSA87 加入默认签名算法列表,新增的 12 个字节正来源于此:
这些编号只在 TLS 1.3 下定义。isDisabledSignatureAlgorithm 会在配置无法协商到 TLS 1.3 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。
这个开关本来就不管显式清单
暴露 SILO 写死曲线列表的那处变更,在 Go 1.27 发布说明中有明确记载:
Post-quantum hybrid key exchanges can now be explicitly enabled in
Config.CurvePreferenceseven if thetlsmlkem=0ortlssecpmlkem=0GODEBUG options are used. Those options were always meant to only apply to the default set used whenConfig.CurvePreferencesis nil.(后量子混合密钥交换现在可以在
Config.CurvePreferences中显式启用,即使设置了tlsmlkem=0或tlssecpmlkem=0。这些选项本来就只适用于Config.CurvePreferences为 nil 时所用的默认集合。)
标准库自 Go 1.24 起就把两条路径写成二选一:
代码里就是一个分支:给了显式清单就原样使用,只有为空时才查默认集合,而 GODEBUG 住在默认那条路上。
其中的原则是显式配置应当压过环境变量——否则运维改一个环境变量,就能悄悄推翻开发者写在代码里的安全策略。Go 1.26 及更早版本会把显式清单也过滤一遍,因此 SILO 在 Go 1.26 上「开关有效」其实是依赖了一个 Go 认定的错误行为。
此外,Go 把 GODEBUG 的默认值绑定在主模块的 go 指令上,声明旧版本的模块会保持旧行为直到主动上调。SILO 的 go.mod 写的是 go 1.27.1,等于明确声明采用 1.27 的行为。
SILO 的技术债在哪,以及哪些还没还
出问题的清单是 {X25519MLKEM768, CurveP256, X25519, CurveP384, CurveP521},继承自上游,后来被加入了那个后量子条目。写死它带来的是两头不靠:既享受不到标准库默认值的演进,又用不了标准库提供的兼容开关。
48e184652 在全部 8 个 Server TLS 配置点移除该赋值——外部通用 HTTP transport、复制 transport、带客户端证书的云 transport、节点间 transport、两条 grid 链路、etcd,以及入站 S3/Console 监听器——退役 TLSCurveIDs helper,并补充了跨 5 个出站构造函数、2 个对端 TLS 版本、2 种 GODEBUG 设置的线上级回归测试。证书与主机名校验、密码套件策略、代理处理、HTTP/2 选择均未改变,也没有引入任何失败后降级或重试。此前只改 OIDC 的候选补丁被放弃,是因为同一个 transport 还服务身份插件、通知与 Lambda 可达性检查、审计 webhook 和 S3 云分层。
同一类债在下一层依然存在。 密码套件仍然写死:crypto.TLSCiphers() 与 crypto.TLSCiphersBackwardCompatible() 被赋值在出站 transport、LDAP、etcd、grid 链路和入站监听器上。监听器至少还有 MINIO_API_SECURE_CIPHERS 可以在两套之间切换,所有出站路径一个开关都没有。
本设计确立的通用准则:设 MinVersion,其余交给标准库。 如果确有不兼容的对端需要更窄的配置档位,把它做成产品配置——在 mcli admin config 与支持包里可见——而不是写死在源码里。协议僵化是个老问题:TLS 1.3 不得不在线上伪装成 TLS 1.2,GREASE 的存在正是为了逼中间设备容忍自己不认识的编号。
线上真正的差异
在同一个合成 IdP 夹具前测量,默认设置下:
| 构建 | ClientHello | ML-KEM (4588) | ML-DSA 0x0904-6 | User-Agent |
|---|---|---|---|---|
| 20260804,Go 1.26.5 —— 正常 | 1497 B | 有 | 无 | MinIO (…) |
| 20260903,Go 1.27.1 —— 故障 | 1509 B | 有 | 有 | Silo (…) |
| 20260916,Go 1.27.1 —— 已修复 | 1509 B | 有(默认) | 有 | Silo (…) |
20260804 + tlsmlkem=0 |
275 B | 无 | 无 | MinIO (…) |
20260903 + tlsmlkem=0 |
1509 B | 仍然有 | 有 | Silo (…) |
有两点容易被忽略,但至关重要:
- 能正常工作的版本本来就在发 ML-KEM。 除非运维在升级之前就设过
tlsmlkem=0,否则 ML-KEM 不是新旧版本之间的变量,恢复这个开关也就无法单独解释或修复他们的故障。 - 默认设置下,两个版本之间只有两处变化: 三个 ML-DSA 编号,以及改名带来的 HTTP User-Agent 变化(
MinIO (…)→Silo (…))。后者只有在 RST 发生于握手完成之后时才成立。
影响面,以及症状为什么具有误导性
写死的清单覆盖了所有 TLS 路径的两个方向,但后果差别很大:
| 路径 | 入口不兼容时的后果 | 可见度 |
|---|---|---|
| OIDC discovery / JWKS | 身份初始化阻塞,Console 不启动,节点不提供任何服务 | 立刻致命 |
| KMS/KES、LDAP(S) | 加密或目录认证不可用。这两处本来就用默认曲线,GODEBUG 一直有效 | 立刻可见 |
| 跨站复制目标 | 复制静默积压,只能从指标看出来 | 容易漏 |
| 审计 / 通知 webhook、Lambda 检查 | 审计记录丢失、事件不投递 | 容易漏 |
| S3 分层 / 云后端 | 转冷失败,远端对象读不回 | 容易漏 |
| etcd、节点间 grid | 集群内部流量,一般不经过这类设备 | 低 |
| 入站 S3/Console 监听器 | 客户端连不上;由对端的握手决定 | 取决于客户端 |
只有身份路径会阻塞启动,因此它最先被报告。其余几条会安静降级,所以一个部署完全可能已经受影响而无人提单。
哪些部署有暴露面:
| 情形 | 原因 |
|---|---|
| 出站链路上有 SSL 检查 NGFW、SASE、SWG 或 IDS/IPS | 本次报告的场景,也是已确认存在厂商缺陷的那一类 |
| 出口经过会检查 TLS 的企业 VPN | 同一机制,作用于整条出口 |
| 环境按 JA3/JA4 客户端指纹做白名单 | 握手一变指纹就变,每次工具链升级都可能触发,且同样表现为无提示的重置 |
| 端点前面是老式 TLS 终止器或负载均衡器 | 不容忍未知扩展,或对跨分片的 hello 处理有问题 |
| 从用 Go ≤ 1.22 构建的上游 MinIO 版本迁移过来 | 那份握手既没有后量子密钥交换也没有 ML-DSA——约 275 字节对约 1509 字节。这个群体跨度最大,暴露面也最大 |
| 政策上不允许后量子算法的合规环境 | 需要经典档位是出于政策,而非互操作原因 |
出站链路上没有检查设备的部署不受影响,也不应设置下文任何兼容选项。
四个特性叠加,使这个症状在现场几乎无法诊断:身份初始化以随机 0–3 秒间隔重试;discovery 与 JWKS 抓取没有总超时,启动可以无限期挂住;身份离线期间 /minio/health/live 与 /minio/health/ready 都保持 200,Kubernetes 就绪探针会报成功(只有 /minio/health/cluster 返回 503 并携带 X-Minio-Server-Status: iam-offline);而错误文本 read: connection reset by peer 不说明 TLS 握手是否完成。此时从同一镜像跑 curl 还会成功,因为 curl 用的是另一套 TLS 栈并协商了 HTTP/2。
20260916 自身带来的变化
移除写死清单不会恢复此前的线上格式,而是采用当前 Go 默认值,而它更大:
| 版本 | 实际提供的 supported_groups |
|---|---|
| 20260903 及更早(写死) | X25519MLKEM768, X25519, P256, P384, P521 |
| 20260916(Go 默认) | X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519, P256, P384, P521 |
因此对于什么都不设置的运维,20260916 会在每一个 TLS 端点(入站与出站)多提供两个后量子编号。对绝大多数部署这无害,但一个按 group 编号做白名单的入口,理论上可能在 20260903 正常而在 20260916 失败。GODEBUG=tlssecpmlkem=0 可以只关掉这两个并保留 X25519MLKEM768。而对于确实设置了 tlsmlkem=0 的运维,20260916 产生的握手比此前任何版本都更小更保守——这正是修复的目的。
三种机制,一个决定性事实
调查复现了三种都能产生所报错误文本的机制,而 20260916 修复覆盖其中一种。
| 分支 | 机制 | 20260916 是否覆盖 |
|---|---|---|
| A | 入口拒绝 ML-KEM,且运维在升级前就设过 tlsmlkem=0 |
是——修复恢复的正是这条 |
| B | 入口拒绝 ML-DSA 签名编号 | 否。只有 TLS 1.2 上限能抑制该 offer,而产品没有暴露这个设置 |
| C | HTTP 层规则在握手成功之后拒绝变更后的 Silo User-Agent |
否,任何 TLS 改动都与之无关 |
还有第四项观察解释了 curl 对照为什么具有误导性:discovery transport 禁用 HTTP/2 且不发 ALPN,而 curl 协商了 h2。这个差异在正常版本和故障版本里都存在。
一个事实能把 A/B 与 C 分开,夹具结果能把 B 与 A 分开: RST 是紧跟 ClientHello 到达,还是在 GET 写出之后才到达。在故障网络位置做一次抓包,或者拿到入口在同一秒的拒绝原因,就能定案。
上游的同款案例
分支 B 不是假设。上游 Go 有两条 issue 描述了它:
- golang/go#81199,标题即 “add a GODEBUG to disable advertising ML-DSA signature algorithms in the ClientHello (Go 1.27 regression against TLS-inspecting middleboxes)",2026-08-28 至今 open。所报症状与 #154 完全一致:经过 TLS 检查防火墙的所有 TLS 1.3 连接
read: connection reset by peer,而同一台主机上的curl、OpenSSL 3.6、Node.js 24 正常,同一程序用 Go 1.26 编译正常,设置MaxVersion: tls.VersionTLS12也正常。其中点名的产品是 Palo Alto Prisma Access,PAN-OS 10.2.10-h37;Go 团队的回复指出该产品已有修复版本。 - golang/go#79626 已关闭,但其中有完整诊断:一位网络管理员与本单位防火墙团队一起定位到,Palo Alto 的一条 IDS/IPS 威胁特征——针对 OpenSSL CVE-2020-1967,该 issue 中记为 PA 威胁 ID 58033,最后更新于 2022-07-12——会检查
signature_algorithms_cert扩展,并对其算法列表不符合预期的连接发送重置。Palo Alto 已于 2026-06-23 发布内容更新禁用该特征。
CVE-2020-1967 当年正是通过畸形的 signature_algorithms_cert 触发的空指针解引用;厂商为它写的检测规则对合法的现代握手触发了重置。这也精确解释了那 12 个字节:Go 1.27 把三个 ML-DSA 编号同时加进了 signature_algorithms 和 signature_algorithms_cert,即两个扩展各 3 × 2 字节。
Go 不会提供开关。 安全负责人在 #81199 的立场是:不可能为 ClientHello 的每一处底层变化都加 GODEBUG;以往加开关的是会改变协商结果的变更;而增加一个签名算法本不应该改变任何东西。提出的 tlsmldsa 设置(golang/go#81307)尚未合入,此外 Chrome 预计将开始对 signature_algorithms_cert 做 GREASE,这会让剩余的问题设备更快暴露。
对 SILO 有两个直接结论:
- SILO 不能依赖上游新增
GODEBUG开关:针对受分支 B 影响的部署,需要在 SILO 自身提供显式的出站 TLS 兼容配置档位。 - 排查出站链路上的 TLS 检查设备:更新防火墙厂商的威胁特征库即可在网络侧直接消除根因。
全部可选解法
按「谁来动手」分组,每一行只解决特定分支:
入口侧——唯一完整的解法。
| 做法 | 分支 | 代价 |
|---|---|---|
| 更新中间设备——威胁特征库与固件都算——使其容忍不认识的算法编号 | A、B、C | 需要设备归属方配合。对已确认的产品,内容更新已经存在,因此这是最短路径 |
绕开它:把 MINIO_IDENTITY_OPENID_CONFIG_URL 指向不经过该设备的端点,或使用 split-horizon DNS。discovery 文档中的 issuer 必须保持不变 |
A、B、C | 证书主机名与 issuer 一致性必须成立 |
| 用 sidecar(stunnel、Envoy)自行终止 TLS 并连接 IdP | A、B、C | 多一个组件及其信任链 |
走 HTTPS_PROXY |
无 | CONNECT 隧道原样转发同一个 ClientHello;只有自行终止 TLS 的代理才有意义 |
部署侧——今天就能用。
| 做法 | 分支 | 代价 |
|---|---|---|
GODEBUG=tlsmlkem=0 |
A | 进程级关闭混合密钥交换,含节点间链路与入站监听器;证书校验不受影响 |
GODEBUG=tlssecpmlkem=0 |
20260916 新增的两个 group | 更窄;保留 X25519MLKEM768 |
GODEBUG=fips140=on |
A 与 B | 不推荐。 FIPS 白名单里没有 ML-KEM 与 ML-DSA,但它同时替换密码套件与曲线,并对整个进程排除 Ed25519/X25519 |
| 停留在 20260804 | A、B、C | 放弃后续全部安全修复;仅作应急 |
在入口放行 Silo User-Agent |
C | 一条规则改动 |
目前无法通过 GODEBUG 关掉 ML-DSA。 Go 没有为签名算法提供 GODEBUG——internal/godebugs/table.go 中 crypto/tls 的条目只有 fips140ems、tlsmaxrsasize、tlsmlkem、tlssecpmlkem、tlssha1——tls.Config 也没有签名算法字段。唯一的杠杆是版本门:这些编号只在 TLS 1.3 下定义,把 MaxVersion 压到 TLS 1.2 即可抑制。
为什么不回退工具链
用 Go 1.26 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。但它仍然是错误的手段,有五条理由:
- 用整个产品的工具链,去替中间设备的缺陷买单。 而那台设备的厂商已有修复。
- 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布,1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
- 上游保持新默认值。 Go 已明确拒绝增加开关,Chrome 也计划对同一扩展启用 GREASE。
- 四个核心仓库均已要求 Go 1.27。 Server、Console、
mcli和silo-pkg均声明go 1.27.1,使用 Go 1.26.7 编译当前源码会被模块要求拒绝,且传递依赖也已要求 1.27。 - 只覆盖单一分支。 如果连接重置源自分支 C 的 HTTP 层规则,工具链版本根本无关。
保持用 Go 1.27 编译而仅下调 go.mod 中的 go 版本指令同样无效:该机制仅控制绑定了 GODEBUG 的行为,而 ML-DSA 声明没有对应的 GODEBUG 开关。
对于正在等待网络设备更新特征库的受影响环境,在过渡期内暂时停留在 20260804 镜像是合理的应急措施。
由此确立的需求
- 为 discovery 与 JWKS 抓取加上分阶段诊断。 通过
httptrace记录连接建立 → 握手开始 → 握手完成(附协商到的版本、套件、group)→ 请求写出 → 首个响应字节,并在返回的错误中标明最后到达的阶段。无需配置、不改协议行为,且可以用一条日志代替抓包来区分 A、B、C 三条分支。 - 显式的出站 TLS 兼容档位,至少覆盖身份提供方:经典曲线,以及可选的 TLS 1.2 上限。必须 opt-in、启动时打出醒目警告,并在
mcli admin config与支持包中可见。MINIO_API_SECURE_CIPHERS是既有先例。这是分支 B 唯一的进程内解法。 - 启动健壮性:为 discovery 与 JWKS 抓取加总期限与 context 取消,并明确就绪检查是否应当反映身份状态。
- 受支持的诊断子命令,由调查用探针演化而来:握手分阶段、协商参数,以及 classical / TLS 1.2 / User-Agent 对照,使运维可以直接定位这类故障。
- 清理剩余的写死密码套件,至少让出站路径拥有监听器已有的那个开关。
有两个选项被明确否决:
- 发布一个降低了的默认值——在
go.mod写godebug指令,或在镜像里塞ENV GODEBUG=…——会悄悄削弱所有连接的后量子保护,而且仍然解决不了分支 B。 - 握手被 RST 后自动降级重试等于把「注入一个 RST 就能把连接压到 TLS 1.2 加经典曲线」交给主动攻击者;浏览器正是因为这个原因移除了这类 fallback。
发布与沟通门槛
这次修复的兼容前提写在 Server README 的 TLS 小节、Go 1.27 TLS 与 OIDC,以及 20260916 发布说明 中。但在 issue 处置中有三件事没有做到位:
- 未在 issue 中直接说明该修复需要配合设置
GODEBUG=tlsmlkem=0才会生效,以及它针对的是哪条故障分支。 - 该配置要求仅作为变更记录写入说明,而未作为受影响环境升级时的显式操作项列入升级检查清单。
- issue 在补丁合并时即被关闭,未等待报告人复测确认。
当一项修复依赖运维人员显式配置环境变量才能生效时,必须按兼容变更流程执行以下三条门禁:
- 任何「效果取决于运维动作」的修复,都要在发布说明和升级检查清单中列为 required action,与协调升级类条目同等待遇。
- 外部用户报告的 issue,只有在报告人确认复测通过后才能关闭;补丁合并只改变状态标签,不改变 issue 状态。
- 修复无法覆盖所报症状的全部可能分支时,issue 中必须写明:处理的是哪条分支、还剩哪些分支、以及具体需要什么定位证据。
归属
本文在 #154 调查与九月 Go 1.27 栈评审的基础上,补充发布后的复测分析、上游 Go 社区案例、解法空间与流程门槛。Go 行为以 Go 1.27.1 标准库源码与官方发布说明为依据。
3 - 桶配置复制:来源时间、删除与确定性收敛
#77 是已在 Server 20260916(PR #180,提交 48ec10312)中修复的站点复制正确性缺陷。修复前,接收站点将对端事件的来源时间改写为本地到达时间(UTCNow),导致后续真正较新的删除请求因早于本地到达时间而被当作旧事件拒绝;同时部分配置在删除后不再导出时间戳,断连期间遗漏的删除也无法通过自愈(heal)恢复。仅补一个 DELETE 分支无法同时解决这两类问题,确定性收敛必须同时具备来源时间保留、持久化删除墓碑(tombstone)、规范化内容比较键以及整桶元数据锁。
既有工作与本轮范围
此前的发布说明、20260903 安全加固记录与 Server 兼容性说明已概述 #77 的删除收敛限制。本文完整记录其状态模型、方案取舍与验证证据(原始实验记录归档于 docs/investigations/issue-77.md)。
| 已有工作 | 已解决的问题 | #77 补齐的剩余缺口 |
|---|---|---|
| PR #91 | 每站点配置计数、Policy/Quota 统计与畸形字段隔离 | 计数一致不代表配置内容与来源时间已收敛 |
| PR #103 | 整个 .metadata.bin 记录的写入串行化 |
仅在写盘阶段加锁,无法保证锁外作出的新旧判断在拿到锁时仍然有效 |
| #76、#78 | Object Lock 复制载荷与已有桶接管保护 | 未建立覆盖六类配置的统一来源状态比较规则 |
| CORS 复制修复 | 桶级 CORS 的独立删除记录与复制信任边界 | 仅针对 CORS 设计,其语义不能直接套用到全部元数据类型 |
本修复处理 Policy、Tags、SSE、Quota、Versioning 与 Object Lock 六类配置。Lifecycle/expiry 保持其合并载荷时间语义;CORS 保留独立机制;notification、IAM、对象复制可靠性、MRF、resync 与公开统计各自独立维护。
复现确立的故障事实
原始缺陷在 ErasureSD 和 16 盘 Erasure 两种对象层上均稳定复现:源站在过去时刻 $T$ 写入配置,接收端落盘时却写入当前到达时间;随后收到源时间为 $T + 1\text{ 分钟}$ 的删除事件时,接收端因该时间早于刚才记录的到达时间而将其拒绝。因此单看 RPC 返回成功,并不能保证各站点最终状态一致。
| 配置类型 | 修复前 PUT 保留来源时间 | 空事件的既有语义 | 修复前无载荷时导出时间 |
|---|---|---|---|
| Policy | 否 | 删除 | 是 |
| Tags | 否 | 删除 | 否 |
| SSE | 否 | 删除 | 否 |
| Quota | 否 | 删除;零值 JSON 另有语义 | 否 |
| Versioning | 否 | 不操作 | 不作为删除使用 |
| Object Lock | 否 | 不操作 | 不作为删除使用 |
排查过程中还定位了三个必须在入口层修复的问题:
- 较旧的批量(bulk)事件可以覆盖较新的单字段配置。
- 请求在锁外完成新旧判断后再排队等待
metadata.lock,拿到锁时仍按已过期的判断覆盖新状态。 - Tag 的远端 heal 请求漏传了
UpdatedAt时间戳。
因此修复不能只改动 heal 的选源条件,必须覆盖所有真实写入与同步入口。
字段状态模型与确定性排序
状态模型复用 .metadata.bin 中既有的字段载荷、字段 UpdatedAt 和桶 Created,不新增磁盘格式、SDK 字段或持久化 deployment ID。
| 状态 | 判定条件 | 能否作为复制来源 |
|---|---|---|
| 未知或无效 | 创建时间未知、载荷非法,或字段时间早于 Created |
否;缺失或损坏的信息不能当成删除 |
| 空基线 | 空载荷,时间为零或等于 Created |
否 |
| 有值基线 | 合法非空载荷,时间为零或等于 Created |
是;用于历史配置初始化 |
| 真实更新 | 合法非空载荷,时间晚于 Created |
是 |
| 真实删除 | 可删除字段的空载荷,时间晚于 Created |
是;该带时间戳的空值即删除墓碑(tombstone) |
Versioning 和 Object Lock 的空事件继续保持无操作,不会因共用排序辅助函数而获得删除语义。零时间字段在比较时使用 Created 作为基线,不会把历史空值误判为一次新删除。
状态比较按以下四条规则依次裁决:
- 真实状态(
UpdatedAt > Created)优先于基线状态。 - 真实状态之间,来源时间
UpdatedAt较晚者胜出。 - 来源时间相同时,删除墓碑优先于有值载荷。
- 同级且同时间的有值冲突,按规范化内容键的字典序裁决,字典序较大的键胜出。
对于带时间戳的对端事件和 heal,当计算出的有效状态与磁盘已有状态完全相同时,接收端既不写盘也不触发事件通知;本地写入则即使可见载荷不变,也始终分配新的单调递增修订时间。有值基线之间同样按内容键排序,防止较晚创建的默认状态压过真实修改。字典序决胜保证多站点在并发冲突下收敛到同一结果;若业务期望特定配置,管理员仍应在冲突后在权威站点重新提交期望值。
比较键必须与落盘行为一致
复制状态统计、heal 选源与对端应用必须比较与持久化完全一致的表示:
- Policy: Go 中策略集合由 map 支撑,直接 JSON 编码会受 map 遍历顺序影响。服务端对已校验策略的完整 JSON 树(
Statement、Action/NotAction、Resource/NotResource、Principal、Condition)执行递归排序,并保留Sid和数字精度。此外,既有策略解析器支持NotAction与NotResource,但原结构体必填Action/Resource的编码会对空集合报错。修复统一采用显式字段编码,并同步应用于GetBucketPolicy、管理导出和对端复制,既防止含负集合的合法策略“写得进、读不出”,也保证状态统计与 heal 使用完全一致的比较键。 - Quota: 使用解析结果的 JSON 编码作为比较键。
{}、JSONnull或合法零配额文档仍是有值文档。旧版发送端曾将零配额PUT转为对端删除而在本地保留文档;对于mcli quota clear发送的零配额,升级后各站点统一保留该有值零配额文档,容量限制同样处于关闭状态。空 Policy 则遵循既有删除语义:合法的空策略PUT清除策略,后续GET返回NoSuchBucketPolicy。 - XML 配置(Tags、SSE、Versioning、Object Lock): 直接使用有效 XML 文档的字节切片作为比较键,不引入通用 XML 规范化器。Versioning 是唯一需要预处理的例外:比较前必须先应用 Object Lock 约束(启用 Object Lock 时强制
Enabled),再比较最终会落盘的有效文档;否则比较器接受未开启版本控制的文档而Save落盘时将其改写,会导致下一轮 heal 无限重复发送。
在同一把锁内完成决策与落盘
六类字段共同存储在一份 .metadata.bin 中。读取原始记录、校验载荷、比较时间戳、修改字段和写盘保存必须全部位于同一把 metadata.lock 锁内:若先在锁外比较、再进锁内写盘,并发写入下仍会使用过期状态;若拆成按字段加锁,整条记录的读改写又会互相覆盖。
本地写入在 metadata.lock 锁内分配单调时间:
该规则使本地纠正操作能够超过已保存的未来字段时间。带来源时间的对端事件保留其原始 UpdatedAt;完全重复或较旧的带时间对端事件在锁内直接返回,不产生磁盘写入。
批量(bulk)路径仅处理显式提供的字段,逐项校验通过后至多保存一次:省略字段保持不变,显式 null 按类型删除语义处理,任一字段非法都会在落盘前中止整批更新。导入(import)在每桶最终提交锁内为涉及的六类字段分配统一提交时间,磁盘状态和出站复制事件均使用本次提交返回的归一化快照,不在解锁后重新读取存储拼接旧时间。空 Policy 通过专用删除事件发送,避免被批量结构的 omitempty 丢弃。
桶接管不能制造假删除
桶接管(adoption)可能调整桶的 Created 时间戳。若把 Created 改早,却保留空字段原本等于旧 Created 的默认时间戳,空基线就会因 UpdatedAt > Created 被误判为真实删除;若把 Created 改晚,又可能使历史初始配置因 UpdatedAt < Created 变成无效状态。
修复在既有接管锁内,仅将这六类字段中原本为零或等于旧 Created 的默认时间同步调整为新 Created,真实更新和真实删除的时间戳保持不变。
所有入口共用同一套状态规则
| 入口 | 行为契约 |
|---|---|
| 本地 S3/Admin 写入 | 在 metadata.lock 内分配单调时间;由提交快照生成出站事件 |
| 专用对端事件 | 在 metadata.lock 内比较并保存原始来源时间;保留旧 Object Lock 载荷兼容 |
| Bulk/import | 显式区分字段存在性并原子保存;在最终提交锁内统一分配导入时间 |
| 首次同步 | 通过物理磁盘探测补齐缺失的 Created;保留历史有值基线;开关开启后补发真实删除 |
| 本地与远端 heal | 先过滤有效候选再选取最大状态,包含完整来源时间;未知 ID 与故障对端不阻断健康目标 |
| 状态导出 | 值与时间取自同一快照;新增删除时间受滚动升级开关控制 |
首次同步保留既有五类配置发送路径,Versioning 仍通过 MakeBucketHook 初始化并由 heal 对齐。当桶元数据未记录 Created 时间戳时,GetBucketInfo 与 ListBuckets 一致地返回物理目录探测时间,首次同步将该恢复时间传给建桶钩子并落盘,避免无元数据记录的旧桶无法写入配置。
heal 不再先取 map 第一项作为胜者再过滤默认时间,而是先筛选有效候选再取最大状态。公开 mismatch 计数不再作为唯一门控:内容相同但来源时间不同仍需同步;反之,最终有效状态相同时不再重复写盘或发送 RPC。
为什么需要默认关闭的升级开关
启动环境变量 MINIO_SITE_REPLICATION_METADATA_TOMBSTONES 默认为 off,用于控制无载荷的 Tags、SSE、Quota 删除墓碑时间是否在状态接口与首次同步中向外导出。
| 行为 | off(默认) |
on |
|---|---|---|
来源时间排序与 metadata.lock 原子应用 |
启用 | 启用 |
| 普通实时删除事件复制 | 启用 | 启用 |
| Policy 的既有删除时间导出 | 保留 | 保留 |
| Tags/SSE/Quota 无载荷时的真实删除时间导出 | 隐藏 | 导出 |
| 首次同步补发真实删除 | 仅 Policy | 补齐全部四类可删除字段 |
旧版节点无法安全消费全部新增的删除墓碑信息——例如旧版 Quota heal 会在清空原始载荷后保留已解析的内存缓存值。默认保持 off 能够安全隔离滚动升级窗口。
协调升级与降级步骤
- 将所有参与站点的全部节点升级至 Server 20260916 或更高版本,确认同站点配置一致并排空在途请求。
- 在全部节点统一设置
MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=on并重启。 - 如需回滚到旧版本,先在全部节点设置
MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off并重启,再滚动降级软件二进制。
在 off 模式下,被隐藏的 Tags、SSE、Quota 删除墓碑可能导致对端在 heal 时重复发送旧状态并由已升级接收端拒绝;只有全站点统一开启 on 且状态稳定后,后续 heal 周期才会达到零元数据 RPC。
保留与否决的候选方案
| 设计决策 | 工程依据 |
|---|---|
| 保留统一的内部六字段排序辅助函数 | 六类配置复现了完全相同的到达时间覆盖缺陷;共用比较器可消除各入口规则漂移,同时按类型区分删除能力 |
| 保留现有整桶锁、持久化字段与 heal 周期 | 分别承担原子性、删除持久性与断连恢复职责,无需引入外部协调服务 |
不止于把 UTCNow 替换成来源时间 |
仅替换时间戳仍会留下锁外过期判断、无载荷删除丢失、等时冲突与首次同步缺口 |
| 用默认关闭的开关门控新增墓碑导出 | 旧接收端与旧 Quota 缓存路径在混合版本下不安全,必须显式隔离滚动升级窗口 |
| 不以 deployment ID 决胜,也不引入 HLC 或新磁盘 schema | 既有时间戳配合规范化内容键已能保证确定性收敛,无需改动磁盘持久化格式 |
| 不一刀切拒绝零时间专用事件 | 旧版 Tag heal 确实漏传时间戳;以本地单调时间兼容零时间事件可保持协议平滑过渡 |
| 不把所有桶元数据泛化为同一种删除语义 | Versioning 与 Object Lock 不可删除,而 Lifecycle 与 CORS 拥有各自的合并与删除机制 |
生产代码共新增 729 行、删除 692 行 Go 代码(净增 37 行),主要以统一的状态比较器替换原先重复的各类型应用与 heal 分支。每一项保留机制都对应已复现的故障模式:去掉整桶锁会重现跨字段读改写覆盖,去掉规范化 Policy 比较键会在负集合策略上造成永久假 mismatch,去掉升级开关则会在滚动升级期间击穿旧版节点的 Quota 缓存。
验证与测试证据
位于 cmd/site-replication-metadata_test.go、cmd/site-replication-metadata-heal_test.go 与 cmd/site-replication-metadata-gate_test.go 的回归测试套件在 ErasureSD 和 16 盘 Erasure 两种对象层上验证了完整契约:
| 验证范围 | 验证结论 |
|---|---|
| 来源时间保留、四类可删除配置、重复与乱序事件、锁前排队、跨字段并发写入 | 回归测试与 -race 竞态检查在两种对象层上全部通过 |
等时双向到达顺序、删除优先于有值、Policy 规范键与负集合 GET、bulk/import 原子性 |
不同到达顺序收敛至完全相同的最终状态 |
| 创建时间物理探测恢复与旧顺序策略状态比较 | 真实磁盘对象层测试在每块盘上标记桶目录时间,验证无元数据记录的桶可正确恢复 Created,且乱序 Statement 策略不再触发假 mismatch |
| 两个真实站点进程实验(每站 4 个数据目录) | 历史六类配置首次同步保留 Created;丢弃真实 PUT 的出站 RPC 并注入乱序事件后,正常 30 秒 heal 周期恢复一致 |
| 断连遗漏删除与源站重启 | 四类配置的删除墓碑落盘后跨源进程重启保留,恢复连接后自动收敛 |
| 稳态零 RPC 与诊断日志去重 | 连续两个 65 秒观察窗口(各含两个正常 heal 周期)无多余元数据 RPC;重复异常按“桶/字段/原因”独立去重 |
在测试复核中,早期使用桩对象(stub)伪造创建时间探测的测试被替换为操作真实本地盘桶目录时间戳的测试——确保该用例在叠加修复前生产代码时必定失败,仅在真实恢复链路生效时通过。
实现复核与演进记录
从基线 5c5765816 与初版实现 1ee64a8d8 出发,复核发现的三处问题(真实磁盘创建时间恢复、旧顺序 Policy 状态键与 heal 诊断去重)已在 62cf066ff 与 fcbb93e89 中补齐,最终在 461e9a721 完成测试格式整理并通过全量 cmd/internal 测试与 lint 检查。
运维边界与已知限制
- 历史时间污染无法自动还原。 升级前被本地到达时间覆盖的配置和无时间戳旧事件已经丢失原始来源时间。部署升级后,应核对各站点配置并在权威站点重新提交期望的配置或删除操作。
- 零时间专用事件继续兼容。 不带时间戳的专用对端事件会分配本地单调时间并记录限频
legacy-zero警告;bulk 导入的零时间校验约束保持不变。 - 桶创建世代冲突不自动合并。 早于目标桶
Created的事件不予应用(记录before-created)。缺失Created的桶仅从真实物理桶目录修改时间恢复近似值;若已存在桶的物理创建时间仍为零,配置写入返回500 InternalError(缺失桶返回404 NoSuchBucket)。在配置写入或首次站点同步将该恢复时间落盘前,周期性 heal 会在两个方向上跳过该桶。 - 物理时钟不是因果时钟。 本地单调时间允许管理员在未来时间戳之后继续发起本地修正,但跨站点并发写入若与字典序决胜结果不符,仍需人工确认并重新提交。
- 诊断按桶、字段与原因有界去重。
legacy-zero、before-created、indeterminate、unreachable与peer-error复用LogOnceIf以 Warning 级别输出并按小时清理;空基线以及尚未拥有该桶的对端保持静默,而损坏的存量配置即使无可用来源也会输出indeterminate警告且不触发出站 RPC。
4 - SigV4 签名头覆盖边界:拒绝未签名的 x-amz-* 请求头
本文记录 SILO 的未签名请求头覆盖修复(核心提交 123325430,经 PR #173 合入,安全通告编号 SN-2026-011)以及 PR #177 中的后续签名与载荷校验修复(安全通告编号 SN-2026-012)。上述修复已随 Server 20260916 发布。该漏洞由 Oren Yomtov 报告,并在本地预签名与 Authorization 请求头两条 SigV4 路径上复现。
核心结论(TL;DR)
预签名 PUT URL 可以只对 host 一个请求头签名。此前 SILO 只校验签名头列表(SignedHeaders)中声明的请求头是否已到达,却未反向检查请求实际携带的全部请求头,导致列表之外的 x-amz-* 请求头被直接接受并参与业务处理。与此同时,cmd/api-router.go 仅凭请求中存在 x-amz-copy-source 头,就会将任意 PUT 请求分发至 CopyObjectHandler。两处逻辑叠加后,原本仅授权写入单个对象的预签名 URL,可被攻击者转化为以签名者身份读取该密钥可访问的任意对象的服务端复制操作(混淆代理,confused deputy)。当 Authorization 头签名请求未将 x-amz-copy-source 纳入 SignedHeaders 时,同样会触发该问题。
本次修复确立了一条核心不变式:
这一行为与 AWS S3 一致——AWS S3 对携带未签名 x-amz-* 头的同类请求同样返回 AccessDenied(“There were headers present in the request which were not signed”)。
缺陷机制:单向覆盖缺口
cmd/signature-v4-utils.go 中的 extractSignedHeaders 遍历签名头列表,按名称逐一从请求头(或 query 参数)中提取对应值。这仅验证了签名声明的请求头均已存在,却遗漏了反向检查:实际收到的每一个 x-amz-* 请求头是否都包含在签名头列表中?
此前代码中唯一遍历实际到达请求头的函数是 checkMetaHeaders,但它仅匹配 X-Amz-Meta- 前缀,且只在预签名路径(doesPresignedSignatureMatch)中调用;Authorization 头验签路径(doesSignatureMatch)则完全没有对应检查。因此,未签名的 x-amz-copy-source(或任何其他影响操作语义的 x-amz-* 请求头)在两条路径上均能畅通无阻:
本地复现结果:对照组普通 PUT 返回 200 且响应体为空;对同一预签名 URL 附加一个未签名的 x-amz-copy-source 头后,请求同样返回 200,但响应体变为 CopyObjectResult(其 ETag 即为源对象的 MD5),从目标对象回读即可拿到源对象的完整内容。若目标存储桶本身允许匿名 GetObject,攻击者无需任何凭据即可直接读取被复制出来的私有对象。
历史溯源
该缺陷继承自上游 MinIO。cmd/signature-v4-utils.go 中的 SigV4 验签逻辑与 cmd/signature-v4.go 中的 doesSignatureMatch 可追溯至 2016 年;cmd/api-router.go 中基于请求头分发 CopyObject 的路由规则可追溯至 2019 年。上游曾在 2023-07-27 通过 minio/minio#17737(提交 535f97ba6)引入 checkMetaHeaders,当时已注意到未签名请求头需与签名集合保持一致,但将该检查局限在 X-Amz-Meta- 前缀与预签名路径,遗漏了 x-amz-copy-source 等操作控制头以及整条 Authorization 头验签路径。
在本次修复前,SILO 对 cmd/signature-v4-utils.go 仅做过一行依赖导入路径调整(9b11dc946 将 policy 包导入迁移至 pgsty/silo-pkg/v3)。核心修复(123325430)与 PR #177 的后续加固完整闭合了这一边界。
修复设计
修复将 checkMetaHeaders 重命名为 checkUnsignedHeaders,将匹配前缀从 X-Amz-Meta- 扩展至整个 X-Amz- 命名空间,并在预签名与 Authorization 头两条路径上统一调用。任何未被签名集合覆盖的 x-amz-* 请求头,都会在进入业务 handler 之前被直接拒绝并返回 ErrUnsignedHeaders。
以下四项设计决策界定了具体的检验边界:
检查集合成员资格
原先的 checkMetaHeaders 采用 signedHeadersMap.Get(k) == val[0] 比较值是否相等。对于不在签名映射表中的请求头,Get 会返回空字符串 "";一旦攻击者构造首个元素为空串的多值请求头(例如 X-Amz-Copy-Source: ["", "/src/secret"]),val[0] 同样为空串,就能绕过相等比较并将第二个未签名的复制源路径注入后端。修复改为直接检查键是否存在于 signedHeadersMap 中:只要请求头已纳入签名列表,其具体取值自然受签名摘要保护,验签阶段只需判定集合成员资格。
豁免 X-Amz-Content-Sha256
客户端发送 X-Amz-Content-Sha256 时可以不将其列入 SignedHeaders,因为有效载荷哈希已作为独立字段写入 SigV4 规范请求(Canonical Request)。预签名请求优先取 query 参数中的哈希值,仅在 query 未提供时回退读取请求头,显式的 UNSIGNED-PAYLOAD 同样受支持。PR #177 将 IAM 策略条件求值统一对齐到这一有效哈希值(同时保留判断请求头是否存在的语义),并在通用认证路径中补齐了仅通过请求头携带哈希的预签名请求正文摘要校验,确保签名绑定、策略评估与正文校验使用同一个值。
直接从签名日期计算签名年龄
初版修复曾豁免验签后写入内部请求头的临时字段 x-amz-signature-age,但 PUT 和 UploadPart 的授权评估发生在流式验签之前,此时该内部头尚未生成。PR #177 改为直接根据已受签名保护的 X-Amz-Date 计算 s3:signatureAge,并彻底移除了内部临时头、相关常量及豁免分支。伪造日期会直接导致验签失败,客户端自行传入的同名未签名头则会被拦截;同时验签过程不再修改请求头映射,多次调用保持幂等。
将 X-Amz-Tagging 注入移至验签之后
PutObjectTaggingHandler 会从请求正文 XML 中解析并合成 X-Amz-Tagging 请求头供 IAM 策略条件评估读取,而原先这一注入发生在调用 authenticateRequest 之前。将未签名头检查扩展到全部 X-Amz- 前缀后,这个由服务端合成、客户端从未签名的请求头会被误判为未签名头而遭到拒绝。修复将注入时机调整至签名验证通过之后、策略授权评估之前,既不影响策略条件读取标签,又保持了验签边界的纯净。我们否决了“像豁免 content-sha256 一样全局豁免 X-Amz-Tagging”的替代方案——因为一旦全局豁免,客户端就能在任意签名或预签名写入请求上附加未签名的 X-Amz-Tagging 头来篡改对象标签。
跨签名模式的适用范围
Authorization头签名与预签名 SigV4: 两条路径均已强制执行checkUnsignedHeaders。- 流式 SigV4(Streaming SigV4): 种子签名校验器未调用
checkUnsignedHeaders。Copy 系列 handler 通过常规认证分发直接拒绝流式认证,而流式PUT/UploadPart的请求头覆盖属于独立边界。 - SigV2: 不受影响。SigV2 规范化算法在构造待签字符串(string-to-sign)时默认纳入全部
x-amz-*请求头,任何额外附加的x-amz-*头都会改变计算出的签名并触发签名不匹配错误。
状态码权衡:400 与 403
AWS S3 对未签名请求头返回 403 Forbidden,而 SILO 沿用上游 ErrUnsignedHeaders 定义返回 400 AccessDenied。两者的 S3 错误码字符串均为 AccessDenied,仅 HTTP 状态码不同。若将 cmd/api-errors.go 中的状态码改为 403,虽然只需修改一行代码,但会同步改变既有的未签名 x-amz-meta-* 请求头返回码。为避免在安全补丁中引入额外的状态码兼容变更,此处保持 400 AccessDenied 不变。
回归测试
代码中原有的若干单元测试会先构造签名请求,在完成签名之后再手动追加 x-amz-copy-source、x-amz-copy-source-range 或 x-amz-metadata-directive 请求头。这些测试已更新为在设置完操作控制头后重新调用 signRequestV4 签名(signRequestV4 会自动将已有的 Authorization 头排除在签名集合外,因此可安全重签)。新增单元测试覆盖了 checkUnsignedHeaders 的空首值多值头拦截、X-Amz-Content-Sha256 豁免、旧版未签名 x-amz-signature-age 头拒绝,以及对同一预签名请求连续验签两次的幂等性测试 TestPresignedVerifyIdempotent。
验证证据
- 在真实构建的服务端实例上,复现脚本在预签名与
Authorization头两条路径上均确认了修复前的混淆代理行为,并在修复后验证两者均被拒绝;与此同时,对照组的普通PUT、标准minio-goCopyObject、携带用户元数据与标签的PutObject以及基于请求正文的PutObjectTagging均正常通过。 go test ./cmd/全量通过,gofmt、gofumpt与go vet检查无告警。- 首轮代码审查指出的三处边界问题(临时请求头破坏验签幂等性、空首值绕过相等检查、未签名
x-amz-content-sha256误拒)均已修复并通过回归测试验证。 - 第二轮复核确认重签后的既有测试保持原有测试意图:无效 AccessKey 仍返回
InvalidAccessKeyId,错误的 SSE-C 密钥在签名校验通过后仍返回403。
兼容性与运维影响
- 标准客户端: 零影响。遵循 SigV4 规范的客户端与 SDK 会自动将发送的非豁免
x-amz-*请求头纳入SignedHeaders。 - 附加未签名
x-amz-*头的客户端: 请求将被拒绝并返回AccessDenied,与 AWS S3 行为一致。 - 滚动升级: 线协议与底层存储格式无变化。升级过程中,已升级节点立即强制执行新边界,未升级节点在完成替换前仍存在暴露窗口。
- 版本回滚: 新版本写入的数据可被旧版本正常读取,但回滚服务端二进制会重新暴露该混淆代理漏洞。
关联修复与延伸原则
- 安全通告编号: 未签名请求头覆盖修复对应
SN-2026-011,PR #177 中的相邻签名与载荷校验修复对应SN-2026-012。 - HTTP 状态码: 上述
400与403的状态码对齐留待后续大版本评估。 - 相邻签名修复: PR #177 一并消除了重复
x-amz-copy-source请求头的解析歧义、修正了签名年龄的授权评估时序与载荷哈希策略条件取值,并补齐了仅通过请求头携带哈希的预签名请求正文摘要校验。回归测试覆盖了普通签名与预签名请求、上传验签前的策略评估以及真实 HTTP 存储桶策略防篡改验证。 - 通用设计原则: 凡是允许请求语法改变服务端操作语义的控制项,都必须在进入路由或业务分发前确认其已受签名保护,且验签所绑定的值与业务处理器实际消费的值必须严格一致。
总结
SigV4 验签不仅要确认签名头列表声明的请求头悉数到达,还必须反向确认实际收到的每一个 x-amz-* 请求头都已纳入签名列表。在业务处理器执行前拒绝未签名的 x-amz-* 请求头,从根源上闭合了普通签名与预签名 SigV4 路径上的混淆代理漏洞。虽然签名校验改动本身不涉及存储格式变更,但升级至 Server 20260916 同时包含需要协调升级的 IAM 变更。
5 - Object Lock 复制排序
Object Lock 复制时间戳排序的初始修复(f4c1286c9)已随 SILO Server 20260903 发布(收录于安全公告台账)。针对仅携带时间戳的删除(墓碑)、SSE-C 副本完整重传以及跨存储池 Object Lock 状态合并的后续修复(PR #129、PR #134 与 PR #178)已随 SILO Server 20260916 正式发布。
在重建目标元数据前捕获已存状态
此前,副本 COPY 在比较 Object Lock 保留期(Retention)与法务锁定(Legal Hold)修订时间戳之前,会先根据入站请求重建目标端元数据映射表。这导致目标端原有的修订时间戳在比较前即被覆盖丢失,使迟到的旧复制请求能够覆盖目标端较新的锁定状态;此外,旧逻辑还误将法务锁定的时间戳写入了保留期的时间戳键名下。
提交 f4c1286c9 改为在重建目标元数据映射表之前先捕获已落盘的 Object Lock 状态,并将每个字段的修订时间戳独立保存在各自的内部元数据键中。
保留期(mode + retain-until-date)与法务锁定(status)是两个彼此独立的状态寄存器:
- 较新的保留期时间戳不会使同一请求中较旧的法务锁定生效(反之亦然);
- 对象数据本体的修改时间(
ModTime)不能替代任一字段自身的显式修订时间戳; - 仅当入站字段的时间戳严格新于目标端已存储的对应字段时间戳时才应用更新;对于更旧或时间戳相等的入站更新,一律保留目标端已存状态。
将删除操作同样视为带时间戳的有序状态
清空对象的保留期或解除法务锁定后,字段本身不再包含有效值,但仍携带产生该变更的源端时间戳。如果因为字段值为空就丢弃对应的时间戳,网络中延迟到达的旧复制请求就会将已移除的旧保留期或旧法务锁定重新写回。
PR #129 与 PR #134 在复制接收、时间戳比对与重新发送路径中完整识别并传递“仅含时间戳的空值删除”,严格区分“从未设置过该字段(无排序证据)”与“在指定时刻显式清空了该字段(有时间戳的删除)”。
接收端在普通元数据 COPY 与完整的 SSE-C 副本对象重传路径中均保持这一语义:即便因 SSE-C 重传而重写整个对象密文数据流,也不会抹去目标端已有的更新法务锁定或回退目标端更新的保留期。
跨存储池权威状态合并
当同一对象版本在多个存储池中存在物理副本时(例如扩容或退役迁移期间),仅读取单个本地纠删集无法决定最新的字段状态。多池一致性协调层(PR #178,关闭 #133)会在共享命名空间对象锁下收集所有存储池中的同版本副本,并按各自的时间戳分别合并保留期与法务锁定状态。不可读或未达法定人数的存储池会直接报错,而不会被视为空状态。
授权与运维边界
时间戳排序仅用于在已获授权的副本写入上裁决新旧冲突,本身不会授予修改 Object Lock 的权限;复制信任边界校验与相应的 S3/管理端权限(s3:ReplicateObject、s3:BypassGovernanceRetention、s3:PutObjectRetention、s3:PutObjectLegalHold)依然强制执行。该机制建立的是基于源端时间戳的逐跳字段顺序,不提供跨站点未同步时钟之间的因果全序,也不提供部分磁盘故障后的分布式事务回滚。
升级至包含修复的版本可阻止旧复制事件再次覆盖较新的锁定状态,但无法自动重建升级前已被旧事件覆盖的历史状态。运维人员在确认恢复前,应结合权威记录核对各站点精确版本的保留期与法务锁定状态。
验证与参考
cmd/erasure-server-pool-consistency.go 中的 Object Lock 合并实现与 cmd/erasure-object.go 中的副本写入路径已通过新旧字段更新、空值删除墓碑、独立字段时间戳、SSE-C 重传与多池场景回归测试验证。
请结合副本元数据审计手册、SSE-C 副本完整性记录以及组件版本矩阵使用,并在依赖完整排序契约前协同升级所有参与复制的节点。
6 - SSE-C 副本完整性
本文记录由 #122、#123、#124、#126 和 #134 共同构成的 SSE-C 副本完整性契约(随 Server 20260916 发布),与此前在 Server 20260903 中发布的零字节/属性读取认证及目标密钥 checksum 响应修复衔接。
密文与信任
普通 SSE-C 请求提供客户密钥,操作明文。经过授权的副本传输可以携带原始密文和保留原对象所需的密封对象密钥元数据。目标必须原样存储这些字节,再加密一次会产生不可读的双重加密对象。内部标记本身不是授权:仍需精确复制标记和要求的复制权限。
该路径不会泄露客户密钥,也不会允许普通无密钥读取。普通 GET/HEAD 与 GetObjectAttributes 保留各自密钥/权限检查。联邦原始 SSE-C 副本 COPY 明确不支持。
分段大小与校验和
加密分段大小与逻辑明文分段大小不同。副本多段记录必须保留每段实际逻辑大小,使覆盖后的 partNumber 读取、范围读取和 GetObjectAttributes 与源一致。Checksum 必须保留正确加密上下文和逻辑含义;对象已用另一把目标密钥提交后,不能仍用源密钥解密响应 checksum。
普通 SSE-C 密钥轮换只要显式请求 checksum 算法,就走完整重写,即使算法与原值相同。多段源成为单段目标,ETag 可能变化,复制重传对象字节。不带该请求且满足元数据原位更新条件时,保留原 checksum 状态,包括缺失状态。详见操作步骤。
重传与 Object Lock
目标无密钥 HEAD 可能把已存在的 SSE-C 对象报告为不可访问,而不是不存在。发送方须区分它与 NoSuchKey,不能假设普通元数据 COPY 能修好副本。已存在 SSE-C 副本走对象重传。旧副本状态无法解码时,可通过修复后的重传路径替换,同时保留目标更新的 Object Lock 状态,并正确排序删除时间戳。
升级 Server 不会自动修复已损坏的历史副本。审计存量副本时,应使用批准的密钥访问流程重新读取精确源/副本版本,比较逻辑字节和分段边界,并另行检查保留期及 legal hold,不能仅凭元数据 HEAD 成功推断完整性。
压缩与历史对象
SILO 排除所有新 SSE-C 写入的压缩,包括普通 PUT、多段初始化、COPY 和 Snowball。SSE-S3/SSE-KMS 仍服从加密压缩选项。这避免生成传送密文却缺少所需压缩元数据的副本格式。
历史压缩 SSE-C 对象不会自动重写。应保留密钥和精确版本身份,清点影响范围并演练受支持的重写/恢复路径。
验证边界
压缩判定、replication-trust-ssec-replica_test.go、erasure-multipart-ssec-replica_test.go、replication-ssec-retransmit_test.go 和 compression-ssec_test.go 固定了相关源码及回归契约,涵盖错误密钥/未授权对照、多段字节比较和重传场景。
相关内容见 Object Lock 排序、多池一致性及副本恢复。依赖组合行为前须升级所有参与 Server。
7 - 持久化 IAM 撤销
SILO 保留已删除 IAM 记录的版本(Revision),使离线站点在重新连接后无法将已删除的旧身份或旧授权恢复为有效状态。该机制覆盖内置用户、服务账号、组、策略文档,以及用户、STS 父身份和组各自命名空间中的策略映射。内置用户被删除后,即使管理员有意重建同名用户,此前签发的旧服务账号、STS 临时凭证与组授权也不会因此复活。
持久化 IAM 撤销架构(PR #191、#192,提交 f99ed829b5eba549160725f035156c9e020b6a07)已随 Server 20260916 发布并关闭 SN-2026-013;针对直接 RevokeTokens 的按范围 STS 撤销扩展(0cd388c66)已随 Server 20261018 发布。多站点协调升级步骤见 IAM 升级与恢复手册。
十月十八日:按范围撤销 STS
Server 20261018 中的提交 0cd388c66 将九月持久化撤销机制扩展到直接 RevokeTokens 操作。
在旧实现中,节点可能漏掉某次签发通知,进而在执行撤销时只清理自身内存缓存中已有的令牌。修复后,RevokeTokens 会读取持久化的 STS 身份记录并与内存缓存合并,按操作截点(Cutoff)筛选指定父身份及可选令牌类型,为每个命中的不可变令牌 key 提交独立的删除墓碑(Tombstone)。即使批次中某个 key 保存失败,所有已提交的 key 仍会立即通知同集群兄弟节点并发送给站点复制对端,同时向调用者返回保存错误以便重试。
私有 IAM 复制消息携带令牌删除标记与过期时间。即使撤销消息先于创建消息抵达,墓碑仍能阻止随后的重放;带有更晚事件时间戳的创建消息也无法复活这一不可变令牌。既有删除自愈(Deletion Healing)机制负责离线重投。对象存储和 etcd 的完整 IAM 刷新会重建 STS 修订记录——即使并发 IAM 写入推迟了整表缓存替换,也会先从活动缓存中剔除已撤销条目。墓碑保留至令牌自然到期时间加上时钟偏差容限;若过期时间未知则采取保守保留。
单次撤销快照覆盖请求集群在截点之前已知的 STS 身份;在孤立站点签发且从未复制到本集群的令牌不在该快照内。截点之后新签发的 key、无关用户及可复用的父身份策略映射不受影响。所有参与节点须协调升级,旧版本节点未实现新增的私有 STS 复制消息。
执行开销: 管理端撤销会枚举持久化的 STS 身份并按父身份与类型过滤,其读取量与临时映射内存随活跃令牌数量线性增长。扫描期间会释放鉴权缓存锁,但仍持有 IAM 写串行锁,因此其他并发 IAM 写操作可能短暂等待。etcd 既有的周期性刷新也会读取 STS 记录。普通缓存命中鉴权不增加任何网络调用,也未引入新的后台定时器或外部依赖。
回归测试位于 iam-sts-revocation_test.go,覆盖三站点断连/重启恢复以及四进程共享 IAM 存储场景。升级后应逐端点验证旧令牌被拒绝、新令牌继续有效以及节点重启后的状态恢复,详见升级流程。
排序与持久化
删除记录保存在原 IAM 配置路径下,包含源时间戳、Deleted 标志以及必要的 RevokedBefore 边界;身份删除记录不保存 secret key 或 session token。常规 IAM 列表查询与策略求值自动隐藏已删除记录。对象存储与 etcd 均通过分布式锁串行化每个配置路径的版本比较与写入;接收端直接持久化源时间戳,绝不将其替换为接收节点的本地时钟。
版本演进遵循以下确定性规则:
- 旧事件不能覆盖新版本;相同时间戳下,删除记录优先于活记录。
- 有意的本地同名重建会获得比已存储删除记录更新的版本号。
- 若旧的用户或组删除消息在同名重建之后才延迟到达,系统会更新并保留其
RevokedBefore撤销边界,同时完整保留较新的重建活记录。 - 重复收到已应用的 tombstone 不会触发重写或推进版本号。
- 节点冷启动加载持久化 IAM 状态时同样执行上述合并规则。
用户或组的版本写入是删除操作的权威提交点,策略映射与子凭证清理在其后执行。即使后续附属清理失败,也不会回滚已提交的父身份撤销;API 会向调用方返回清理错误,但仍会通知同后端兄弟节点重载已提交的撤销状态。因此,管理接口返回 HTTP 500 清理错误并不代表旧身份仍然有效。管理员可在暂停同名重建与其他 IAM 变更的前提下安全重试该删除操作。当发生“撤销已提交但附属清理失败”时,Admin Handler 不会触发即时跨站点推送钩子,而是交由常规后台删除自愈循环(Deletion Healing)重试跨站点传播。同后端兄弟节点收到删除通知后会重读当前共享状态,因此延迟到达的通知不会误删随后合法重建的同名身份;etcd 部署下兄弟节点还会通过 watch 机制接收持久化变更。
运维上必须保持各节点时钟同步并监控时钟偏移。版本排序基于源端墙钟时间戳,并对同一路径的本地连续写入保证单调递增,但不跨站点建立并发写入的因果偏序。
2026 年 11 月持久化记录编辑与缓存新鲜度加固(a0de92fcb、70914759e、7eed95bc8、07ce95dfd、00270e9f6、c78ef6a6f、bb31c2618): 在多节点集群中,管理端增量修改(SetUserStatus、UpdateGroupMembers、SetGroupStatus、SetPolicy、UpdateServiceAccount 以及每小时一次的 LDAP 组成员刷新)均在跨节点版本锁内读取并修改底层持久化记录,而不再先就地修改当前节点的内存缓存。当写入或创建因底层已存在更新版本或删除墓碑而被拒绝时,当前节点会立即重载或驱逐本地陈旧缓存条目,不再继续提供旧缓存直至下一轮定时刷新。单条目按需加载(Lazy Load)按 key 通过 singleflight 合并并发未命中,并在读取存储前记录 loadedAt,防止跨通知或本地新写入之前启动的慢速读取在返回时覆盖更新的缓存版本或墓碑。
用户、子凭证与组
同名重建后的用户会保留历史 RevokedBefore 边界。新创建的服务账号与内置 STS 临时凭证均携带经加密签名的 siloParentRevocation 声明(Claim),记录签发时刻已知的父身份撤销边界。编辑或重放旧子凭证不会更新这一签名声明;因此即使旧子凭证自身的更新时间晚于父身份删除时间,鉴权时仍会被判定为失效,而针对重建后父身份新签发的子凭证则正常有效。凭证加载与写入时均从已验签的令牌中提取该声明。
在站点复制中,版本号更新的服务账号快照可以替换相同 AccessKey 的旧服务账号(包括有意更换所属父用户或轮换密钥),而本地直接重复创建相同 AccessKey 仍会被拒绝。复制快照会保留禁用状态(Disabled)与服务账号自身的撤销边界,防止旧策略映射重新附着到新服务账号上。相同版本号的重试仅触发重新加载已提交身份而不重复落盘。周期性活记录自愈(Live Healing)会直接比较源版本号(即使公开状态摘要未发生变化),并将已禁用身份一并纳入自愈源。
服务账号快照同时保留绝对过期时间,接收站点不会将本地新签发凭证的最短有效期规则重新套用到复制快照上。版本号更新但已过期的快照仍会覆盖旧密钥、在鉴权时被拒绝,并在常规加载流程中被归集为持久化服务账号墓碑。若缓存或声明加载失败,接收端会直接返回错误以触发重试,而不会提前确认成功;一旦替换提交完成,内存中陈旧的缓存密钥会被立即驱逐。若复制快照与现有内置用户或已缓存 STS 身份发生 AccessKey 类型冲突,系统会拒绝静默转换凭证类型并报错,交由管理员显式处置。
用户组的每个成员都拥有独立的 MemberGrants 授权时间戳。增删其他成员或切换组的启用状态不会刷新既有成员的授权时间。成员关系唯有在其 MemberGrants 时间戳同时晚于该用户与该组各自保留的 RevokedBefore 边界时才算有效;列表展示与 IAM 策略求值共用同一套有效成员判定逻辑。对端复制快照会保留原始授权时间戳(包括旧格式下的未知授权时间),不会把近期快照同步时间当作对已撤销身份的新授权;唯有管理员发起新的显式入组授权才能恢复其组权限。
继承自上游的活组快照合并逻辑仅作增量添加成员,不会自动消解站点断连期间漏掉的普通成员移出操作;本文机制聚焦于用户或组删除边界以及同名重建后的隔离保证。
保留期与过期回收
永久身份(内置用户、服务账号)、用户组、策略文档及策略映射的墓碑记录不设自动 TTL。断连的远端站点或旧备份可能在任意时长后重新接入;对端重放成功确认仅用于抑制重复网络发送,不作为物理回收墓碑历史的依据。
不可变 STS 临时令牌自然过期时,系统会物理删除其 token-key 记录及遗留映射,不会产生永久墓碑。而被提前显式撤销的 STS 令牌墓碑则会保留至该令牌原定过期时间加上时钟偏差容限,从而阻止带更晚事件时间戳的重放请求将其复活。etcd 后端利用原生过期租约(Lease)自动回收;对象存储后端则在既有的凭证加载与清理周期中顺带清理已过期的 STS 墓碑。过期时间未知的记录按保守策略持久保留。
可选的过期回收写入使用短锁超时预算并以尽力而为(Best-Effort)方式执行:若某次清理写入失败,当轮加载会停止可选回收、记录错误日志并继续加载正常用户;已过期凭证依然处于拒绝访问状态并保留既有持久化版本,留待下一轮加载重试。外部 IdP 身份禁用属于提前撤销而非自然过期,其关联的缓存 STS 与服务账号同样纳入清理。带过期时间的服务账号由于其 AccessKey 可被更新复用且旧版本可能未设过期时间,仍会保留持久化修订记录。
在九月 20260916 实现中,删除内置父用户通过持久化父边界即可使所有旧子凭证失效(含未出现在当前删除节点缓存中的子凭证);而跨站点逐令牌的直接 RevokeTokens 同步则由上文十月十八日按范围撤销 STS 补齐。
自愈循环、故障处理与运维成本
每个服务进程启动单个后台自愈循环(Healing Loop)。当节点失去分布式 Leader 租约时,循环暂停等待重新获得领导权,不会永久退出;配置热重载也不会派生额外循环。每次取得领导权后以及每轮自愈完成后等待 30 秒再启动下一轮。
常规 IAM 加载器在内存中维护一份不含敏感密钥的删除记录与撤销边界索引。自愈循环直接读取该内存索引,避免在每个 30 秒周期内重复全量遍历磁盘上的 config/iam/ 目录;仅在进程启动与全量 IAM 刷新时扫描包含墓碑在内的持久化存储记录。
自愈同步按每个对端每批最多 128 条记录发送。发送方记录每个对端已确认的路径与版本号,任一站点上无关路径的新变更不会重置既有确认进度。若某批次发送失败,该批次保持挂起待重试,后续独立批次仍可继续推进;响应丢失只会触发安全的幂等重放。单轮自愈设有总时长上限,超时前已收到的成功确认会被保留。同步协议会汇报节点名称与进程实例 ID:负载均衡器在已知实例间切换不会重置确认进度,唯有检测到全新进程实例首次出现时才会保守地作废旧确认并重新核对一次。因此在从备份恢复持久化状态前,必须先停止相关服务进程,防止恢复后误复用旧进程留下的内存确认记录。
稳态运行下,自愈循环仅遍历并排序内存中的保留集合、检查对端协议状态,并在对端确认后抑制重复的删除 PUT 请求。内存占用随保留路径数与对端站点数量线性增长,冷启动存储读取量随历史记录数增长。首次升级后,若部分存量活记录此前在各接收站点上被赋予了不同的本地时间戳,升级后会触发一轮初始版本对齐写入;规划维护窗口时应预留相应的存储写入与兄弟节点通知开销。
缓存凭证查找仅比对内存中的父版本索引,不产生额外存储读取;STS 签发与冷凭证加载则会核对持久化父版本。版本 I/O 与分布式锁等待期间会释放 IAM 缓存读/写锁,由独立的本地写入互斥锁(writeMu)维护本节点写入顺序,且所有存储与 etcd 锁/租约操作均受超时上下文约束(详见 IAM 策略重载:锁粒度、请求取消与超时预算隔离)。
复制协议与协调升级要求
服务端专用的版本化复制路由为 /minio/admin/v3/site-replication/peer/iam-revisions,在内部传输源版本号、成员授权时间戳以及独立的用户/组撤销条目,不改变公开 Admin 客户端 SDK 或 S3 API。旧版本服务端会拒绝该路由,发送方会直接报告同步失败,而不会降级回退到会丢失版本元数据的旧路由。既有旧 IAM 路由仍保留读取兼容。
集群中的所有参与节点(包括未启用跨站点复制但共享同一 IAM 存储后端的节点)必须统一升级,不支持同一后端下新旧二进制混跑或滚动降级,因为旧版本二进制无法识别墓碑记录与签名父边界。请按以下维护窗口步骤执行协调升级:
- 暂停所有 IAM 变更,并隔离状态未知的离线站点或旧备份。
- 完整备份各站点的底层 IAM 存储及所需 KMS/加密材料;常规在线 IAM Admin 导出不包含删除墓碑历史,不能替代底层存储备份。
- 停止共享同一 IAM 后端的全部节点,替换二进制文件后以新版本启动。所有参与站点完成升级后再恢复 IAM 写操作。
- 检查 IAM 加载日志、站点复制状态与撤销收敛指标,验证代表性的已撤销旧凭证被拒绝、新签发凭证正常可用。
- 显式处置升级前已遗失的撤销记录:若某些旧记录仍残留在部分站点,应在这些站点上再次执行删除;长期离线的陈旧站点应在重新接入前基于已批准的最新状态重建,切勿为了查看其旧凭证而直接将未知旧快照并网。
旧版本服务端曾为同名重建父身份签发的子凭证缺少签名父边界声明,升级后必须由新版本服务端重新签发;从未发生过删除与同名重建的父身份则完全保持既有凭证行为。
20261026 内置策略 Reset 更新: 未经修改的内置默认策略仍受保护,禁止本地直接删除。在 Server 20261026中,删除对内置策略的显式覆盖内容会立即恢复原生内置定义,并保留删除版本以拒绝旧覆盖回放(取代了 Server 20260916/20261018 阻止自动恢复默认策略的旧行为)。对端与 etcd 通知会重新加载已提交状态,保留延迟删除事件之后的新编辑;在用策略检查与自定义策略删除语义保持不变。本地删除不存在的策略保持幂等,不新增墓碑;对端同步来的未知策略删除则保留其版本记录。
如需回滚,必须先停止并隔离受影响站点,评估自备份以来的权限变更后再恢复兼容状态。恢复旧备份本身会丢失备份点之后的撤销记录,重新开放访问前必须完成权限核对与密钥轮换。切勿在线手动删除墓碑文件或仅转换活记录以强行启动旧二进制。
回归与性能验证
专项测试集位于 iam-revocation_test.go、iam-revision_test.go、iam-revision-lock_test.go、iam-revision-boundary_test.go、iam-replication-protocol_test.go、iam-credential-retention_test.go、iam-peer-reload_test.go 与 iam-replay_test.go。将环境变量 SILO_TEST_IAM_REVOCATION_ETCD 指向测试用 etcd 实例即可运行包含隔离命名空间的 etcd 生命周期、分布式锁与边界测试。
基准测试覆盖:
BenchmarkIAMCachedCredential与BenchmarkIAMSetTempUser:测量缓存鉴权与临时用户签发的基线开销。BenchmarkIAMRevisionConvergedHealing:覆盖 1,000 与 10,000 条保留记录下的稳态内存索引遍历与零重复PUT断言。BenchmarkIAMColdLoadExpiredServices:测量存在 100 或 1,000 个已过期可复用凭证时的冷启动加载与清理耗时(使用-benchtime=1x运行)。
错误语义与可观测性
十月二十三日补充(pgsty/silo#250):LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界区分了“单分量超过 255 字节、物理上无法存入对象后端的纯 ASCII 键”与“当前无法读取的已存在记录”。loadIAMConfigBytesWithMetadata 仅将前者按 errConfigNotFound(记录不存在)处理;完整路径超限(PATH_MAX)与真实存储故障继续按 Fail-Closed 向上报错,确保磁盘上已有的撤销墓碑绝不会被掩盖。对象存储后端仍不支持写入超长路径分量的个人策略映射或父身份版本记录。
当权威撤销版本已成功落盘、但后续附属清理步骤失败时,Admin 删除请求可能返回 HTTP 500;这并不代表旧身份仍可使用。此时应在暂停 IAM 写入与同名重建的前提下重试删除,并在各站点验证旧凭证已被拒绝。当持久化父身份版本不可读或签发记录无法提交时,STS 会通过内部错误路径(含 STSInternalError)Fail-Closed 拒绝签发,绝不通过猜测缺失边界来放行新令牌。
可通过每个进程需认证的 /minio/metrics/v3/cluster/iam 端点采集以下指标:
| 指标名称 | 含义说明 |
|---|---|
minio_cluster_iam_revocation_records |
当前进程内存索引中保留的删除墓碑记录与父撤销边界总数 |
minio_cluster_iam_revocation_heal_failures |
进程启动以来失败的自愈收敛轮数 |
minio_cluster_iam_revocation_heal_duration_millis |
最近一轮自愈收敛耗时(毫秒) |
minio_cluster_iam_revocation_heal_last_success_timestamp_seconds |
最近一轮自愈成功的 Unix 时间戳(秒) |
上述索引指标按单进程统计,对共享同一后端的多个节点直接求和会重复计数。自愈指标仅在启用了跨站点复制且当前节点持有 Leader 租约时推进;仅共享后端而未启用站点复制的部署不运行跨站点自愈循环,后三项指标为零属于正常现象。
为什么更简单的方案不成立
在多站点与离线重连场景下,以下看似更简单的方案均存在安全漏洞:
- 直接物理删除配置记录:一旦物理删除,重新连入的离线节点或延迟复制消息将缺少判断新旧顺序的墓碑证据,导致已删除身份被旧快照重新写回。
- 仅比较子凭证自身的更新时间(
UpdatedAt):父用户被删除并重建后,攻击者或延迟重放若对旧子凭证执行一次元数据编辑,其更新时间就会晚于父用户删除时间并借此恢复访问;而签发时写入加密令牌的siloParentRevocation签名父边界不会随编辑而改变。 - 用接收节点的本地时间覆盖源事件时间戳:会破坏跨站点因果与源端顺序,甚至将一次幂等网络重试变成一次具有更新时间戳的新写入。本实现严格保留源时间戳,仅对有意的本地写入推进单调版本号。
- 为永久身份墓碑设置固定 TTL 或仅导入导出活记录:离线站点或冷备份的断连时长无法预设上限,提前淘汰永久身份墓碑或仅恢复活记录导出会直接抹去阻止旧身份重放的历史边界。
具体实现契约参见 cmd/iam-revision.go 与 cmd/metrics-v3-cluster-iam.go。
8 - 多池对象一致性
随 SILO Server 20260916 发布的 PR #178 修复了多存储池修改顺序问题,PR #207 将当前逻辑对象选择扩展至条件 PUT(关闭 #133 与 #144),提交 fced86303 则修复了跨池再平衡(rebalance)与退役(decommission)迁移丢失对象标签的缺陷。随后的 SILO Server 20261018 进一步合入 60c7179dd(纳入 PR #248)与 1748f62c7,修复了跨池迁移期间旧数据覆盖新副本以及删除标记(Delete Marker)丢失的问题。
10 月 26 日:存储超时与回滚边界
Server 20261026 完整保留了下文的多池删除保护,并转发覆盖写回滚所需的存储 RPC 删除选项(旧数据目录)。在最终发版前,提交 dcc2f20ba 撤回了 10 月维护草稿中的已接纳 I/O 排空等待、逐对象变更锁(mutation guard)、回滚所有权校验与备份互斥方案,维持既有的磁盘/RPC 超时、请求取消、对象锁、读仲裁与自愈行为。
需要注意:客户端收到超时响应并不代表底层磁盘或 RPC I/O 已经停止,也不代表写入未提交。 在部分磁盘故障、锁租约失效或网络分区后,仍可能出现迟到提交、延迟回滚,或在部分元数据更新后短暂读到旧值与旧标签;对象锁本身不提供跨节点分布式 fencing,升级也不会自动修复存量不一致对象(更严格的诊断检查仍保留在非默认的 consistency_strict 构建标签下)。
10 月 24 日更新:删除失败时保护最新副本
PR #257(提交 aafe4f8886)将多池删除保护扩展到普通无条件删除与批量删除入口。批量数据删除复用既有共享锁与解析器,先清理非权威池中的旧副本,再删除每项的权威副本,同时完整保留请求项的响应身份、支持部分物理删除后的 quorum 重试,并保护共享远端分层引用。该修改直接作用于现有的容量加权分配器,不依赖 #245。详见多池删除:失败时保住最新副本。
10 月 18 日更新:在池迁移期间保护数据与删除标记
SILO Server 20261018 新增的 60c7179dd(PR #248)与 1748f62c7,补齐了九月修复尚未覆盖的两类 rebalance / decommission 迁移竞态:旧载荷覆盖新副本以及删除标记丢失。
在向目标池提交迁移版本前,池协调层会在共享命名空间对象锁内检查所有池中的当前副本,拒绝源快照已消失、已被替换或其他存储池中已存在更新副本的搬迁操作。若修改时间戳完全相同,则沿用读取路径的池索引决胜规则。验证通过的元数据快照会直接复用于后续字段协调,避免重复读取;多段上传(Multipart)完成阶段会在复制完所有分片后再次复核,分层对象(Tiered Object)搬迁同样启用该保护。
即便删除标记是源池中的唯一版本,迁移流程也会将其迁移至目标池,因为其他存储池中可能仍存有该键的更旧数据版本。null 版本与 UUID 版本的删除标记均保留各自的替换与重试语义。源池清理改为在对象写锁内逐一复核并仅删除遍历快照中确认过的具体版本;若源版本已发生变化,或没有任何目标池副本能继续维持当前外部读取结果,则立即中止源清理,不再盲目删除可能已包含并发新写入或新删除标记的整个对象前缀。
运行时成本与边界: 迁移流程增加了逐池元数据读取与逐版本清理。对于 $P$ 个存储池和 $V$ 个源版本,清理阶段至少执行 $\mathcal{O}(P \times V)$ 次元数据操作,且解码与重写版本元数据的开销随版本历史长度增长。普通已解析的读取请求不会进入该迁移检查路径;本修复未引入新的磁盘格式、公开 S3 参数或外部依赖。历史版本迁移中已经丢失的数据、标签或删除标记无法自动重建,跨站点延迟到达的删除标记问题(#217)仍属独立议题。
版本验收测试实际运行了 2 池与 3 池的 rebalance 和 decommission,完整覆盖普通对象、多段对象、null/UUID 版本与删除标记、并发写删以及双池迁移中断恢复四组场景(详见 20261018 发布验证记录)。在生产环境正式退役源池前,应通过公开 S3 API 核对已确认的版本 ID、字节数、ETag、标签及删除状态。
为什么同一个键会存在多份物理副本
扩容存储池、退役旧池或清理中途中断,都可能在不同存储池中留下同一个对象键的多份物理副本。单纠删集(per-set)锁无法与路由到另一存储池的并发写入串行化;仅读取第一个响应的副本也无法证明其 ETag、标签或 Object Lock 保留期代表当前的逻辑对象。
缺乏跨池协调会导致三类典型故障:
- 某个池中的陈旧 ETag 错误地满足了条件写入(
If-Match/If-None-Match)或条件删除; - 某个池中较新的保留期(Retention)或法务锁定(Legal Hold)被另一池中较旧的对象元数据遮蔽;
- 删除当前选中的副本后,另一池中的旧副本重新暴露,导致已删除数据“复活”。
孤立地操作每个存储池并在最后仅汇总成功状态,无法消除上述并发竞争。
共享选择与锁边界
在 cmd/erasure-server-pool-consistency.go 与 cmd/erasure-server-pool.go 中,池协调层在“选择候选对象”到“完成修改”的全过程中持有同一把命名空间对象锁。objectPoolInfos 会读取每个池(包括正在排空的退役池)中的指定版本;未知或不可读状态会被直接判定为错误,而不会当作“对象不存在”的证据。各池副本按修改时间(ModTime)排序,时间戳相同时以存储池索引决胜。
不带 versionId 的元数据请求会先选定逻辑当前版本,再收集所有池中属于该特定版本的物理副本,绝不混合不同对象版本的元数据。Object Lock 保留期、法务锁定和用户标签各自拥有独立的源修订时间戳;mergedPoolObjectInfo 按各字段自身的修订时间戳顺序合并,而非把对象数据本体的修改时间当作所有元数据字段的版本号。带时间戳的标签清空或锁定解除同样作为显式状态参与排序。
元数据修改、相关对象写入与后台自愈(healing)共用同一把协调锁。需注意,该锁用于串行化并发访问,但并非跨池回滚事务——若在多池落盘中途遭遇底层磁盘故障,已完成写入的池不会自动回滚。
条件请求与删除
对于条件 DELETE,协调层在执行跨池清理前针对选定的逻辑对象统一校验一次前置条件,并在调用下层各池删除接口前清除条件回调;若请求指定了显式 versionId,则针对该特定版本进行比对。
对于条件 PUT,PR #207 在外层命名空间锁保护下先跨所有可用池选出最新的逻辑对象表示(包括删除标记状态),再写入新内容。这修复了目标池中残留的陈旧副本导致 If-Match / If-None-Match 判断结果与集群当前逻辑对象不一致的缺陷。在下一版本 SILO 构建(PR #292,提交 1f3377862 与 af3064f2b)中,PutObject、NewMultipartUpload 与 CompleteMultipartUpload 共用 checkCurrentPreconditions:条件分片上传初始化同样在池协调层写锁保护下跨池评估当前逻辑对象的前置条件,并在放置上传记录期间持续持有写锁;分片完成前置条件失败时保留已上传分片以便重试。
若跨池清理或元数据传播在修改了部分物理副本后遇到存储错误,恢复后应重新核对对象状态并重试。批量 XML DeleteObjects 的 ETag 条件仍不受支持,详见条件 DELETE 设计说明。
Object Lock 与桶复制
副本写入会跨所有存储池与同版本的权威副本协调目标端保留期和法务锁定状态,防止乱序到达的旧副本缩短较新的保留期或关闭较新的法务锁定。对象标签同样携其修订时间戳(包括表示删除的空值)一同传递与比对。详见 Object Lock 复制顺序保护与复制标签顺序一致性。
跨池迁移中的对象标签保持
rebalance 与 decommission 会将每个对象版本读取为 FileInfo,再转换为 ObjectInfo——这一步会将内部标签头从普通 UserDefined 元数据映射表移至独立的 UserTags 字段——随后再将对象写入目标池。自 2022 年引入迁移写入逻辑以来,四个迁移写入入口仅复制了普通 UserDefined 元数据映射表,导致普通对象与多段对象在 rebalance 和 decommission 过程中均会丢失用户标签(2020 年引入的字段拆分本身并无问题)。
提交 fced86303 让四个迁移写入入口共用统一的元数据组装函数:在既有协调锁保护下克隆元数据映射表、写回 UserTags,并原样保留已落盘的标签修订时间戳字段而不伪造新时间戳。回归测试覆盖了普通对象与多段对象、初始标签、更新后标签、清空标签以及版本化与旧格式读取。此前在旧版本迁移中已丢失的标签需从源端或备份恢复。
验证与运维指引
cmd/erasure-server-pool-consistency.go 与 cmd/erasure-server-pool.go 中的多池协调实现已通过多存储池、陈旧副本、删除标记、显式版本、并发写入及故障路径测试验证。
在依赖共享锁一致性保证前,请协同升级集群中的所有参与节点。对于此前曾执行过跨池迁移或多池并发写入的存量部署,建议结合副本元数据审计手册与组件版本矩阵盘点存量副本、标签及 Object Lock 状态。
9 - 联邦 CopyObject:保持目标对象契约
Server 20260916 通过 PR #157、PR #159、PR #163、PR #177 与 PR #179 修复了旧版 etcd 存储桶联邦(bucket federation)的 CopyObject 转发路径。当代理节点将跨集群复制作为远端 PutObject 转发到目标集群时,代理节点流式读取并发送源对象的逻辑字节,由目标端执行加密与 Object Lock 规则,校验完整对象校验和,并原样返回目标端实际提交的写入元数据。
转发一次逻辑字节,由目标端执行加密
代理节点在本地按需解密并解压源对象,读取其逻辑字节流,并在向目标集群发起的 PutObject 请求中声明逻辑字节长度:
- 禁止双重转换: 代理节点绝不在本地预先加密或压缩后再转发给远端重复处理。
- SSE-C 与 SSE-KMS 边界: 读取 SSE-C 源对象仍要求提供源客户密钥并使用安全传输。新对象的加密方式完全由客户端显式携带的目标 SSE 请求头或目标桶自身的默认加密配置决定;当客户端未指定加密头时,代理节点不会注入本地默认加密配置。SSE-KMS 上下文按标准 JSON 对象格式转发。
- 拒绝原始 SSE-C 副本复制: 联邦路径在创建目标对象前以
501 NotImplemented显式拒绝可信原始 SSE-C 副本的CopyObject请求。
校验和与元数据过滤规则
普通转发写入按大小写不敏感规则剥离所有保留的内部元数据前缀,仅通过标准 S3 字段转发公开的用户元数据与对象标签。转发写入的校验和必须描述实际写入目标端的完整逻辑对象:
- 整对象校验和转换: 当请求显式指定校验和算法,或源对象携带分段复合校验和(
COMPOSITE)时,目标端必须生成并保存整对象校验和(FULL_OBJECT)。 - 流式 trailer 与请求头传递: 非空对象流通过
x-amz-trailer传递尾部校验和并由目标端验证;零字节对象无流式尾部,改用普通x-amz-checksum-*请求头;源对象已有的FULL_OBJECT校验和可直接作为普通校验和请求头转发给目标端验证。 - 远端响应校验: 若远端
PutObject响应缺失必需的校验和、格式非法或仍带有分段-N后缀,代理节点直接报错,不会将其包装成合法的整对象结果。由于报错时远端写入可能已经落盘,针对版本化桶重试前应先检查目标端实际生成的版本。
Object Lock 头部类型与时间精度
代理节点通过类型化的 LegalHold 客户端选项转发法律保留状态,确保请求发送标准的 x-amz-object-lock-legal-hold 请求头,而非误转为 x-amz-meta-* 用户元数据。对象保留期(Retention)时间戳完整保留亚秒级精度,不再截断为整秒。目标端继续独立执行权限认证与桶级 Object Lock 校验。
返回实际提交的目标写入身份
CopyObjectResult XML 响应与 ObjectCreated:Copy 事件均直接采用目标端本次写入返回的键名、逻辑大小、ETag、精确 VersionID 与修改时间。代理节点既不在本地伪造修改时间,也不通过后续不带版本号的 HEAD 请求二次读取(避免并发写入下读到其他版本的元数据)。当请求指定了源 versionId 时,源版本响应头继续标识所选中的源版本。
源码与测试索引
- 实现源码:
cmd/object-handlers.go、cmd/object-handlers-common.go(PR #157、PR #159、PR #163、PR #177、PR #179) - 回归测试:
cmd/object-copy-federation*_test.go、cmd/object-federation-time_test.go - 关联记录: SSE-C 副本完整性 · Object Lock 复制排序 · 组件版本矩阵
10 - 复制可靠性:删除完成、MRF 可见性与 resync 取消
本文记录随 Server 20260916 交付的三轮复制可靠性修复的问题分析、方案取舍与验收结论:
- 第一轮(PR #162,合并为
d1105bbb): 修复 #153、#152 与 #137,分别涉及请求入口的操作分类、后台恢复队列的可观测性以及任务取消生命周期。 - 第二轮(PR #196,提交
0c61128d2): 修复复制工作协程(worker)的删除出口、外层状态聚合与持久化 MRF 标记恢复路径(见第二轮小节)。 - 第三轮(提交
eb4f5e5b3、254b19ac0与358ab38fb): 修复指定版本清除(purge)的磁盘写回与仲裁语义、删除标记自愈元数据保留,以及清除后排队创建任务的过期复核(见第三轮小节)。
整体判断与系列边界
本系列修复遵循同一工程准则:在拥有对应不变量的最小模块边界内修复已复现的缺陷,复用现有后台恢复机制,并用确定性测试验证收敛。
| 问题 | SILO 中确认的缺陷 | 选定修复 |
|---|---|---|
| #153:删除标记 purge | 单对象 DELETE 将永久删除误判为删除标记复制,导致远端已删除而源端 purge 状态长期停留在 PENDING |
按 purge 状态分类,与批量删除、scanner/heal、resync 保持一致 |
| #152:MRF 丢弃不可见 | 队列已设容量上限,但内部丢弃计数未输出到管理接口与监控指标;对象与删除的 worker 路由参数顺序不一致 | 暴露现有丢弃计数、在实际丢弃点输出去重告警、统一 worker 路由键 |
| #137:resync 取消不可靠 | 单个共享 token 无法取消并发的多桶任务;阻塞阶段不响应取消;旧任务收尾可能覆盖新状态 | 为每次运行分配专属 context,按 resync ID 取消,并串行化注册、收尾与状态写回 |
这一系列此前已分别闭合另外两项复制不变量:
- #136 / PR #138 修复了计数完整性:先接收并累加最后一个结果,再持久化终态,不再依赖一分钟一次的周期性刷新。
- #139 修复了结果真实性:仅当目标端本次复制请求实际成功时才计入成功,不再因目标端已有同名对象而误判。
- #137 修复了取消与资源生命周期:取消指令下达后,目录遍历器(walker)、工作协程与结果收集器均能及时退出,且已终止的旧任务不会覆盖新任务状态。
复制请求是否有权使用内部特权语义,由鉴权前不做 I/O,Header 不产生权限定义;跨存储池的 Object Lock 权威状态选择由 #133 跟踪并在 #178 中修复,详见多池对象一致性。
#153:先分清复制删除标记,还是永久删除版本
报告与复现为什么不完全相同
原议题报告称生命周期管理(ILM)与复制共同触发了持续的 HTTP 405 请求风暴,并建议将删除标记探测返回的 405 一律视作完成。源码核查与本地复现表明,这一归因并不准确,直接采纳该补丁会破坏删除一致性。
以 450dcb848 为基线,测试启动两个本地构建的 SILO 服务端实例,配合 mcli 与 minio-go 客户端进行验证。排查发现:mcli 即使只删除单个对象键,也会调用批量 DELETE 接口;而批量删除的分类逻辑原本就是正确的。 因此必须直接发送单对象 S3 DELETE 请求才能触发故障路径。
| 检查点 | 修复前的单对象 DELETE | 修复后 |
|---|---|---|
| 目标端对应删除标记版本 | 已删除 | 已删除 |
源端 xl.meta 中同一版本的 purge 状态 |
仍为 PENDING,普通复制状态却被标为完成 |
首次复制后即完成清理 |
| 原始数据版本 | 保留 | 保留 |
| 是否需要等待 scanner 补做清理 | 依赖后续后台恢复 | 无需 scanner 介入 |
如果只检查远端版本是否消失,就会漏掉源端 xl.meta 残留的 PENDING 状态。本地实验复现了首次 purge 的分类错误、完成状态误写以及一次多余的 HEAD 探测;但由于现有的 scanner/heal 与 resync 已经按 purge 状态走正确分支,并不会产生持续的 405 风暴。
一处分类修正为什么足够
永久删除一个已有的删除标记时,对象层会同时返回 DeleteMarker=true 和非空的 VersionPurgeStatus:前者表示被操作版本的对象类型,后者表示当前正在执行永久清除,二者并不矛盾。
旧版单对象删除处理函数仅检查 DeleteMarker,直接将版本 ID 填入 DeleteMarkerVersionID。复制完成后,工作协程随之更新了普通的 ReplicationStatus,而非待完成的 VersionPurgeStatus。DeleteObject 处理函数中的修复逻辑如下:
这一判断条件与批量删除、scanner/heal 及 resync 的既有逻辑完全对齐,无需调整磁盘存储格式或新增后台恢复任务;历史遗留的 PENDING 记录仍由现有的 scanner/heal 清除路径自动回收。
为什么不把 405 一律视为成功
对带版本号的对象发送 HEAD 请求收到 HTTP 405,仅说明“目标端存在该版本的删除标记”。对于复制删除标记,这代表目标端已就位,可按幂等成功处理;但对于永久删除该版本,405 恰恰说明目标端的删除标记尚未清除。
如果在收到 HEAD 405 时直接写入 VersionPurgeComplete,源端就会清理本地元数据,而目标端仍残留本应删除的标记版本。同理,远端返回的 403、405、503 等错误均代表删除未完成,必须按失败重试处理。
追溯提交历史可以看到,仅按 DeleteMarker 分类的逻辑源于上游 2020-11-19 的提交。2023-07-10 的优化将入队判断条件从 dsc.ReplicateAny() 改为检查返回对象的复制状态与 PENDING 清除状态,但保留了仅按 DeleteMarker 赋值的分支;2025-04-02 的提交又在该处将 Pending 枚举替换为 replication.VersionPurgePending。
#152:让有界 MRF 队列的丢弃行为可见
MRF(Most Recent Failures)队列用于暂存近期失败的复制任务,以便后台快速重试。该队列设有明确容量上限:mrfSaveCh 容量为 100000,单条任务最大重试次数 mrfRetryLimit 为 3;当 RetryCount > mrfRetryLimit 或通道已满时,队列会淘汰该条目。源对象及其待复制状态仍保留在磁盘上,被内存队列淘汰的条目后续由后台 scanner 扫描并重新发起恢复。
此前的缺陷在于:TotalDroppedCount 与 TotalDroppedBytes 虽在内部原子递增,却未写入两处管理统计快照,也未注册到 Prometheus v2/v3 指标中。使用容量为 1 的确定性测试夹具可以稳定复现:1 条任务成功入队,20 字节的通道溢出条目与 30 字节的超限重试条目被淘汰后,内部计数为 2 / 50,而管理接口仍返回 0 / 0。
选定的最小改动
- 在两处管理统计快照中原子读取并填充现有的丢弃计数。
- 在 Prometheus v2 与 v3 中注册并导出对应的累计计数器。
- 在重试超限与 MRF 通道已满这两个实际丢弃点记录去重警告,使用固定的日志消息与去重键,避免因计数值变化使日志去重失效。普通工作队列溢出转入 MRF 时并未丢弃任务,不在该处触发丢弃告警。
- 普通对象复制、自愈与删除路径统一按
(bucket, objectName)计算工作协程哈希,保证同一对象的操作路由到同一个 worker。
| 接口 | 新增指标 |
|---|---|
| Prometheus v2 | minio_node_replication_mrf_dropped_operations_total |
| Prometheus v2 | minio_node_replication_mrf_dropped_bytes_total |
| Prometheus v3 | minio_replication_mrf_dropped_operations_total |
| Prometheus v3 | minio_replication_mrf_dropped_bytes_total |
这组指标统计服务端启动以来的累计丢弃量,重启后归零。操作数按队列条目计数,同一对象的多次失败会重复累加;字节数统计已知的对象大小,删除条目记为零字节。运维监控应关注其增长速率,并结合复制积压量与目标站点健康状态综合判断。
本次修复保持原有队列容量与重试次数上限不变,由有界队列控制内存占用,由后台 scanner 承担最终兜底恢复。
#137:绑定单次运行的 resync 取消生命周期
一个共享 token 无法取消一组多桶任务
原实现在取消 resync 时,仅向全局共享通道写入一个不带任务标识的 token。而一次站点级 resync 可能涵盖多个桶(最多 10 个桶并发执行),分发器(dispatcher)与多个工作协程又会竞争消费同一个通道。这会导致只有单个桶停止、无关任务误消费取消信号,或者残留的 token 错误中止后续发起的新任务。
此外还存在两处阻塞死锁:从目录遍历器 Walk 的输出通道读取时未监听取消信号;向已满的 worker 通道发送任务时同样未监听取消信号。一旦 worker 先行退出,dispatcher 就会永久阻塞在无人接收的通道上。同时,Walk 直接复用了父级 context,函数提前返回时无法通知底层的遍历协程退出。
状态管理同样存在漏洞:站点级 updateState 修改了栈上局部变量却未写回 map;桶级 Canceled 分支处理缺失;已取消任务的延迟收尾(finalizer)还可能将状态重新覆盖为 Completed。
注册、取消与状态使用同一个写入边界
修复后,每个 resyncBucket 任务在等待并发槽位之前先创建专属的 context.WithCancelCause 并完成注册。注册与 cancelResyncID 共用 resyncer 的状态互斥锁:
- 注册时校验复制目标仍然有效、resync ID 仍然匹配,并立即检查当前是否已处于取消状态。
- 取消时先将同一 resync ID 下所有处于
Pending或Started的记录标记为Canceled,随后逐一取消已注册的对应 context。 - 已注册但仍在等待并发槽位的任务能立即收到取消信号;取消发生后才尝试注册的任务也会直接读到已取消状态并退出。
Walk、任务分发、worker 执行与结果发送全程监听当前运行专属的 context;不同 resync ID 之间互不干扰,也不会在通道中残留取消 token。
站点级启动与取消的配置准备阶段由专用操作锁串行化;即使部分桶的目标配置更新失败,已启动任务的取消动作仍会照常执行。桶级收尾函数与进度更新均会核对当前目标与 resync ID,自动忽略已被移除、替换或取消的旧任务结果;站点级状态正确写回 map,Canceled 终态不会再被迟到的 Completed 覆盖。
成功与失败的收尾顺序不同
任务收尾必须严格保持 #136 建立的契约——待结果收集协程处理完最后一条结果后再持久化终态:
如果在正常完成路径上先取消 context 再等待 worker,尚未处理完的任务就会被提前中断,导致原本成功的运行变成 Failed。最终实现通过单一 defer 调用 finish,按固定顺序完成清理,无需额外引入 sync.Once。
借助 WithCancelCause,代码能够区分用户主动取消与服务关闭导致的父级 context 中断:用户取消记录为 Canceled;父级中断则不会将未跑完的任务误记为 Completed;若任务在等待并发槽位期间遇到服务关闭,则保留原有的 Pending 状态,以便重启后自动恢复。
取消终态与恢复任务也必须受保护
在检查 context 状态与落盘保存终态之间仍可能并发到达取消请求。因此 markStatus 在持有状态锁时,若发现内存状态已变为 Canceled,即使收尾流程此前计算出的结果是 Completed,也必须保持 Canceled;同时拒绝旧 resync ID 覆盖新任务的状态。
若某个桶在站点级取消到达前已完成并落盘,最终状态会记录为该桶 Completed、站点整体 Canceled。锁内状态检查确保已进入 Canceled 状态的任务不会被迟到的完成信号改写为 Completed。
在重启恢复路径上,loadResync 原本在启动后台恢复协程后立即执行 defer cancel()。核查 SILO 的分布式共享锁实现确认,该 cancel() 会直接终止合并后的 leader context,导致恢复协程被立即取消。修复方案引入 WaitGroup,保持该 context 存活至所有恢复协程退出;当节点失去 leader 身份时,底层 context 仍会按预期触发取消。此外,从磁盘加载历史状态时也不会覆盖内存中更新的启动或取消状态。
设计审查与复杂度取舍
针对基线复现与备选方案的评审结论汇总如下:
| 方案或评审意见 | 最终判断 |
|---|---|
| 将 purge 的 405 探测响应直接判为完成 | 否决:删除标记存在不代表永久删除已完成 |
| 为 MRF 新增持久化的退避重试调度器 | 不引入:现有队列与后台 scanner 已提供完整恢复链路,实际缺口在于可观测性 |
| 向共享通道发送更多取消 token | 否决:无法解决按 ID 路由、多桶广播及后续任务串扰问题 |
| 为取消操作维护独立的墓碑注册表 | 不引入:在同一把状态锁下结合目标状态与 resync ID 即可闭合注册竞态 |
| 为每次运行分配专属 context 并改造阻塞点 | 采用:满队列死锁与遍历器泄漏均有确定性复现用例 |
用 sync.Once 保护 finish |
收窄:合并为单一 defer 调用点后,无需额外引入 sync.Once |
| 等待恢复任务结束后再释放 leader context | 采用:SILO 的 leader context 取消会立即生效,需通过 WaitGroup 延长生命周期并补齐失主测试 |
此外,低频的 resync 启动操作在状态锁内完成配置读写,与终态落盘保持同一锁边界;活跃任务注册表直接复用包含 resyncBefore 的 resyncOpts 作为键。
验收证据与可复验入口
所有修复均遵循“先在未修改基线上编写失败回归测试,再提交生产修复”的流程。新增测试直接驱动生产环境处理函数、纠删码存储层、Prometheus 指标注册表与 resyncBucket 完整流程。
| 验收范围 | 结果与证据 |
|---|---|
| 单对象删除 purge | 在单盘(ErasureSD)与多盘纠删码(Erasure)下验证源端与目标端同步清理;覆盖存量 PENDING 恢复、删除标记幂等,以及 403/405/503 错误处理 |
| MRF 可观测性 | 容量为 1 的队列溢出与重试超限测试;校验管理接口 JSON、Prometheus v2/v3 注册计数器的类型与数值、对象与删除的 worker 路由一致性 |
| resync 取消 | 使用 testing/synctest 覆盖 Walk 阻塞、满 worker 队列、主动取消、排队期取消、跨 ID 隔离、后续任务启动、终态并发竞争、过期 ID 忽略、槽位释放与 leader 恢复 |
| 本地完整套件 | go test ./... -count=1 -timeout=30m 全量通过(共 50 个含测试的包) |
| 并发与重复测试 | 复制相关定向 -race 测试通过;取消回归用例连续运行 100 次全部通过 |
| 工具与契约守卫 | 本地编译、go vet、静态检查、生成文件检查、rebrand-guard 与 git diff --check 全部通过 |
| 双实例联调 | 基于本地源码构建两个服务端实例,直接发送单对象 DELETE:目标端删除标记消失,源端 xl.meta 完成清理,原始数据版本完整保留 |
| 远端 CI | PR 八项检查通过;合入主干后 Go CI 六项任务与 VulnCheck 全部通过 |
固定提交上的回归测试文件:删除标记、MRF 可见性、取消生命周期。复现与验证命令:
| 证据点 | 精确标识 |
|---|---|
| 修复前基线 | 450dcb8484bc1337deba0cf608cc893a6691d794 |
| 最终 PR head | 66fe61ff65c83d68b74baa637a11623015c7aa21 |
| 合并主干 | d1105bbb3d4a0afa33b3a4ac11b821235038ed0e |
| PR Go CI | 34320440012 |
| 主干 Go CI | 34321319278 |
| 主干 VulnCheck | 34321319274 |
后续维护原则
复制与 resync 的回归测试需分别断言五项独立不变量:操作类型判定准确、队列淘汰可观测、单对象复制结果真实、终态计数完整、取消后协程与并发槽位完全释放。
第二轮(2026-09-16):worker 侧 purge 分类与持久 MRF 恢复
第一轮在 HTTP 请求入口修正了删除分类(#153)、公开了 MRF 队列丢弃计数(#152)并闭合了 resync 取消生命周期(#137)。第二轮——PR #196,提交 0c61128d2,集成验证记录 aea3882c9——深入到后台复制链路,修复了 worker 自身的删除出口、外层状态聚合,以及删除标记的持久化 MRF 恢复路径。
第二轮排查出的遗留缺陷
- 旧形态任务被整体跳过。 当任务的
VersionID为空、DeleteMarkerVersionID非空、目标端创建状态为COMPLETED且清除(purge)状态为PENDING时,目标侧的“创建已完成”早退分支会直接跳过该任务,导致永不发出 DELETE 请求。 - 仅修复内层目标函数会导致外层状态误报。 外层状态聚合原本以
VersionID与创建状态为键;若仅放行内层函数,执行失败的旧形态清除任务会被外层汇总为COMPLETED,触发ObjectReplicationComplete事件并跳过持久化 MRF 入队。 - 持久化 MRF 恢复路径丢弃了所有删除标记的 405 响应。 重放磁盘 MRF 中针对删除标记版本的条目时,本地读取会返回真实的删除标记元数据与
MethodNotAllowed错误;原错误处理分支直接将其丢弃,导致删除标记无法通过持久化 MRF 恢复。 - 状态覆盖与重复发送。 失败的清除操作会覆盖已成功的 resync 标记,已完成的清除操作被重复发送,而尚未就绪的
HEAD探测也会无条件覆盖创建状态。 - 多目标空状态解析错误会破坏创建元数据。 当磁盘上的删除标记仅含创建元数据、而内存任务携带清除状态时,正则解析会将两个空的目标状态误判为
Pending;随后的已删除标志守卫会将整个创建元数据块重写为带新时间戳的空记录。
根因在于三处缺失:首先,worker 缺少任务级的清除判定(社区 PR #184 提出的按单目标判定谓词存在缺陷,复合清除状态经该分支处理后永远无法收敛为 COMPLETE,但 Julien Laurenceau 的问题排查与提案为本轮修复提供了重要参考);其次,MRF 恢复路径缺少识别合法删除标记 405 响应的准入条件;最后,删除任务未携带重试计数,导致 mrfRetryLimit 上限在删除路径上形同虚设。
第二轮修复方案
- 统一出口的清除判定。 新增
isVersionPurge()(当VersionID非空,或DeleteMarkerVersionID非空且携带复合清除状态时为真),同时驱动内层单目标执行函数与外层状态聚合。清除路径退出时仅更新VersionPurgeStatus,并将ReplicationStatus留空(存储层将其视作“不修改创建复制状态”)。 - 三字段清空守卫。 在清除路径上显式清空创建复制相关的三个状态字段,彻底阻断多目标空状态在落盘时触发正则误判的可能。
- 发送规范的永久删除请求。 清除操作统一携带显式
versionId与ReplicationDeleteMarker=false,不再执行前置HEAD就绪探测(权限校验直接由 DELETE 请求完成),同时防止网络丢包重试在无版本回退路径上意外重建删除标记。 - MRF 恢复的 405 身份校验。 重放 MRF 收到
MethodNotAllowed时,仅当返回对象确系删除标记、桶名/对象名/版本号完全匹配且修改时间非零时,才重新入队调度恢复。 - 有界重试预算。 删除任务新增重试计数器,在全部三个持久化 MRF 入口(聚合失败、获取锁失败、内存队列满回退)递增并受
mrfRetryLimit约束;超过上限淘汰后,仍可由后台 scanner 重新发起自愈。 - 审计与事件状态兼容映射。 仅在统计与事件通知边界将
COMPLETE映射为COMPLETED,复用既有常量定义。
405 的准确含义
对于创建操作(将删除标记复制到目标端),对目标标记版本发起 HEAD 收到 405 意味着目标标记已经存在,可视作幂等完成。对于清除操作(永久删除指定版本),MRF 恢复阶段探测本地版本收到带完整标记身份的 405 意味着本地标记仍在、尚待清除;清除操作本身的成败完全取决于远端 DELETE 请求的响应,DELETE 返回 403、405 或 503 均计为真实失败。空版本或 null 版本的标记返回的是 ObjectNotFound 而非 405,不会进入该分支。
实现边界
旧形态内存任务不会跨重启持久化,当前代码也已不再生成该形态,保留其兼容分支主要作为防御性保护。未配置远端客户端的目标站点仍仅记录日志;单目标级 resync 覆盖部分清除状态属于既有行为,不在本轮修改范围内。同系列可靠性修复中的标签排序与副本元数据归一化,另见复制标签排序与副本元数据归一化。
第三轮(2026-09-16):指定版本清除、标记 heal 与过期的排队创建
第二轮确保了 worker 的清除出口如实反映执行结果。第三轮——提交 eb4f5e5b3、254b19ac0 与 358ab38fb,随 Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a,配合 silo-pkg 6f27ee6)发布——进一步修复了指定版本清除在底层磁盘上的写回与仲裁逻辑,以及排队中的创建任务在版本清除之后乱序执行的问题。该问题由三站点容器集群测试(三个站点,每站点 4 进程 16 盘,EC 12+4)暴露:一个已被永久清除的删除标记在两个目标站点上死灰复燃,而源站点却仍返回正常的数据版本。
第三轮排查出的遗留缺陷
- 清除操作反而会在缺盘上创建待删标记。 查找层为了标记版本可见性,将待清除版本打上了已删除标志;而清除写回路径把该标志当成了“写入删除标记”的落盘指令。结果原本没有该标记的磁盘反而被写入了新标记,使清除了一半的磁盘组在反复重试中来回翻转、无法收敛。
- 过早确认版本缺失。 读取路径只要看到半数磁盘报告版本不存在,就判定该版本已缺失;导致清除重试直接返回 204 成功,而实际上仍有最多一半的磁盘残留着该删除标记。
- 自愈(heal)删除标记会抹掉其复制元数据。 自愈路径在重建删除标记时传入了空的元数据映射。修复后的删除标记丢失了原有的复制与清除状态,重新退化为待复制状态,随后被当作全新创建的标记再次复制到远端。
- 排队中的创建任务在清除完成后延迟执行。 针对处于 pending 状态的删除标记,每次 GET/HEAD/LIST 请求、后台 scanner 以及 MRF 都会入队一个携带当时标记快照的创建任务;来自多个前端节点的任务在单对象复制锁前排队串行执行。在复现时序中,用户的永久清除请求先在两个目标站点完成确认;约 85 毫秒后,排队中的创建任务携带相同的
VersionID与原始修改时间抵达两个目标站点。由于目标站点此时已无该版本,且请求报文中没有任何字段能区分“首次创建”与“过期的乱序创建”,两个目标站点各自重新创建了 16/16 份删除标记,而源站点为 0/16 份。
第三轮修复方案
- 单点判定清除意图(
isVersionPurge)。 仅当请求携带合法的显式VersionID,不含创建删除标记、前缀匹配、数据迁移、免版本控制、过期或生命周期转换选项,且复制状态为“清除已完成”、“无复制状态”或“可信副本请求”之一时,才判定为永久清除。清除路径绝不设置创建删除标记的落盘标志,并将“磁盘已完成物理删除”与“磁盘确认版本不存在”两类响应合并计入成功票数。仅当实际删除了磁盘上存储的删除标记时,响应才标识为删除标记;此前清除仍处于 pending 状态的数据版本则按正常数据版本返回。 - 确认版本缺失必须满足写仲裁多数(
N/2+1)。 当重试清除在查找阶段发现版本缺失时,会重新读取每一块磁盘。若明确报告“版本不存在”的磁盘不足N/2+1块,则按写仲裁不足返回失败;只要任意磁盘仍残留副本,就触发 MRF 自愈。离线、损坏或读取报错的磁盘绝不计入“版本不存在”票数。多存储池层对查找阶段跳过的存储池执行同样的多数派核查,副本接收端则按版本号跨存储池解析清除请求。 - 自愈删除标记保留完整持久化元数据。 自愈路径完整复制删除标记原有的持久化元数据表,仅剔除自愈、数据迁移与分层相关的临时操作键。类型化状态生成逻辑仅用于不带存量元数据的新建写入,且不再伪造时间戳。
- 发送前在复制锁内复核源端状态。 删除复制 worker 在向远端发送创建请求前,先在对象复制锁内重读源端对应版本。若该版本已不存在、不再是删除标记或已进入清除流程,则判定该排队任务已过期并直接丢弃,不修改源端状态;若重读因临时故障无法得出确定结论,则交由 MRF 重试,而不直接视为版本缺失。
证据与边界
在两组三站点分批故障恢复测试(涵盖源站点重启与不重启、单个目标站点分两批恢复上线)中,集群均在约 15 秒内完成收敛,并在随后 450 秒的观测窗口内保持完全一致。过期排队创建乱序复现用例在未合入修复的 main 分支上稳定复现失败,合入修复后在单盘与 16 盘纠删码夹具上全部通过。
源端复制锁内的发送前复核无法拦截已经发往网络的数据包,也无法拦截其他站点重放的创建请求;彻底消除这一跨站点乱序窗口需要在接收端持久化清除记录或引入单调序号,已由 #217 独立跟踪。此外,若某次清除刚达成多数派确认、所有服务端进程随即同时崩溃,此前离线磁盘上残留的少数派副本最多会保留七个后台扫描周期,在此期间所有前端读取仍一致返回正确的数据版本。
11 - FULL_OBJECT 校验和复制:在初始化上传时保留类型
Issue #254 报告了一类复制故障:使用 FULL_OBJECT CRC32 或 CRC32C 校验和的分片上传对象在源端可正常上传和读取,但复制到目标端并在最后调用 CompleteMultipartUpload 时会因 400 BadDigest 失败。Denis Babchuk(@botdeniska)提交的 PR #255 补回了出站复制请求遗漏的校验和类型头。
该补丁于 2026 年 10 月 24 日以 6d890fc86 合入主干并随 Server 20261026 发布,共修改 5 行生产代码(含 2 行注释) 并新增 257 行回归测试。
故障现象
当分片上传对象的校验和类型记录为 FULL_OBJECT、算法为 CRC32 或 CRC32C 时,启用桶复制或站点复制会出现以下现象:
- 原始分片上传在源端成功完成,源端版本可正常读取。
- 复制发送端将所有分片传往目标端后,目标端在
CompleteMultipartUpload阶段返回400 BadDigest。 - 源端对象复制状态停留在
FAILED,目标端无法生成该版本的完整副本。 - 后续每次复制重试(包括对
FAILED版本执行HeadObject触发的重入队)都会重新传输全部分片字节,并在完成阶段再次因同一错误失败。
分片 CRC64NVME 对象不受影响,因为接收端默认将 CRC64NVME 解释为 FULL_OBJECT;普通单次 PUT 与分片 COMPOSITE 上传也正常。此外,对受影响的分片 FULL_OBJECT 对象执行服务端 CopyObject 后再复制,会触发下文所述的另一条故障路径。
根因:仅转发算法无法区分校验和类型
对于分片 CRC32 和 CRC32C,同一算法支持两种截然不同的对象级校验和计算方式。设 P1 ... Pn 为各分片的字节内容,|| 表示字节拼接:
| 类型 | 对象校验和的计算方式 |
|---|---|
FULL_OBJECT |
对完整拼接内容 `P1 |
COMPOSITE |
拼接各分片 CRC 的原始摘要字节,再对其计算 CRC、编码为 Base64,并在输出时附加 -n 分片数后缀 |
即便分片上传只有一个分片,这两种方式算出的摘要也通常不同。仅转发算法名称与摘要值,不足以还原校验和语义。
复制发送端代码中的完整故障链路如下:
putReplicationOpts()调用getCRCMeta()构造对象校验和请求头。旧实现保留了摘要值和x-amz-checksum-algorithm,但丢弃了x-amz-checksum-type。replicateObjectWithMultipart()将这些选项传给目标端的NewMultipartUpload。当算法为 CRC32 或 CRC32C 且未指定类型时,目标端默认将该分片上传初始化并持久化为COMPOSITE。- 发送端传完各分片后,再次通过
getCRCMeta()构造完成阶段的对象级校验和头,其中携带的摘要仍是源端的整对象(FULL_OBJECT)CRC。 - 目标端的
CompleteMultipartUpload校验逻辑读取初始化时持久化的COMPOSITE类型,将收到的整对象 CRC 与“分片 CRC 的复合 CRC”进行比对,因摘要不匹配返回BadDigest。
| 阶段 | 修复前 | 修复后 |
|---|---|---|
| 源端校验和元数据 | CRC32/CRC32C + FULL_OBJECT |
不变 |
| 目标端初始化请求头 | 仅含算法,缺失类型 | 算法 + FULL_OBJECT |
| 目标端持久化的上传类型 | COMPOSITE |
FULL_OBJECT |
| 完成请求的校验和 | 源端整对象 CRC | 源端整对象 CRC + 显式 FULL_OBJECT 类型 |
| 目标端校验结果 | 400 BadDigest |
校验通过,完成复制 |
核心不变量:复制必须在初始化与完成两个阶段同时保留校验和的算法、摘要值与类型解释方式,使目标端按与源端一致的规则执行完整性校验。
历史演进与上游回归来源
上游 MinIO 于 2025 年 8 月 22 日在 PR #21535 中合入提交 4ea6f3b06,从 getCRCMeta() 中删掉了校验和类型转发。在那之前,getCRCMeta() 会无条件转发所有校验和的类型;但直接回退到无条件转发又会破坏普通单次 PUT 的 SHA 校验和复制(见下文)。
此前 SILO 针对分片完成阶段做过三项相关修复,均处于不同链路环节:
| 此前修复 | 解决的问题 | 为何未覆盖本次复制故障 |
|---|---|---|
c8590413f(#31) |
允许 FULL_OBJECT 完成请求省略逐分片校验和并完成整对象校验 |
前提是接收端在初始化时已知该上传为 FULL_OBJECT |
7e079ff05 |
校验完成请求显式携带的类型是否与初始化类型一致 | 复制发送端在初始化和完成两个阶段均未发送类型 |
| CompleteMultipartUpload 响应补齐 ChecksumType | 向客户端 SDK 的完成响应返回 ChecksumType |
仅作用于上传完成后的响应编码,不影响复制发送端构造的出站请求头 |
最小修复实现
在 getCRCMeta() 复制已识别的校验和摘要与算法之后,新增三行判断:
三个条件分别约束了精确的转发范围:
isMP:解码自已落盘校验和的分片元数据标记(不要求当前对象的 ETag 一定是带-n的分片格式)。cktype.CanMerge():复用既有算法能力判断,仅匹配支持将分片 CRC 合并为整对象 CRC 的算法(CRC32、CRC32C 与 CRC64NVME)。cs[xhttp.AmzChecksumType] == xhttp.AmzChecksumTypeFullObject:仅当源端持久化类型确为FULL_OBJECT时才转发,绝不改动COMPOSITE对象。
由于分片复制的初始化阶段(NewMultipartUpload)与完成阶段(CompleteMultipartUpload)共用 getCRCMeta(),这一处修改同时补齐了两个阶段的请求头。逐分片上传请求读取的是不含对象级类型的分片校验和映射,其请求头保持不变;CRC64NVME 则显式携带与其默认行为一致的 FULL_OBJECT。
为什么不能转发所有已存类型?
普通单次 PUT 上传的 SHA1 或 SHA256 对象,在内部存储和读取元数据中同样会标记为 FULL_OBJECT。但在 S3 请求侧,getContentChecksum() 解析器会拒绝为不可合并算法(如 SHA1、SHA256)显式指定 x-amz-checksum-type: FULL_OBJECT。
若无条件转发所有已存类型,单次 PUT 的 SHA1/SHA256 对象在复制时反而会被接收端拒绝。通过 isMP && cktype.CanMerge() 收窄条件后,分片 CRC 请求得以恢复,同时普通单次 PUT 与 COMPOSITE 上传的既有请求头完全不受影响。
CopyObject 自动恢复单次 PUT 复制路径
对分片 FULL_OBJECT 对象执行服务端 CopyObject 后,生成的新对象不再带有 -n 分片 ETag(!objInfo.isMultipart()),但其底层校验和元数据仍保留分片 FULL_OBJECT 标记。由于 FULL_OBJECT 摘要本身就是完整对象字节的 CRC,通过单次 PutObject 传输到目标端后依然完全有效。
putReplicationOpts() 中原本就包含针对该场景的路由分支:
修复前,由于 getCRCMeta() 丢掉了 x-amz-checksum-type,上述条件永远为假,导致 CopyObject 生成的对象被错误路由到单分片 Multipart 复制路径并同样触发 BadDigest。在 getCRCMeta() 补回类型后,这段既有路由逻辑自动生效:此类对象直接通过单次 PutObject 完成复制,无需额外编写 Copy 专用分支或重算校验和。
备选方案取舍
| 备选方案 | 结论与原因 |
|---|---|
| 恢复上游变更前的无条件类型转发 | 不采纳:显式携带 FULL_OBJECT 的 SHA1/SHA256 请求会被 getContentChecksum() 拒绝 |
仅在 CompleteMultipartUpload 阶段补类型 |
不采纳:目标端在初始化时已持久化为 COMPOSITE,完成阶段传入不匹配的类型同样会被拒绝 |
将所有 CRC32/CRC32C 分片上传默认改为 FULL_OBJECT |
不采纳:破坏合法的 S3 默认 COMPOSITE 语义 |
忽略对象级校验和或跳过 BadDigest 检查 |
不采纳:牺牲复制过程的数据完整性验证 |
在发送端重新计算 COMPOSITE 摘要以迎合目标端 |
不采纳:篡改了源端对象的校验和类型与摘要值,且引入额外计算开销 |
单独编写 CopyObject 复制分支或排除 CRC64NVME |
不需要:putReplicationOpts() 既有路由分支与 CanMerge() 已自然覆盖 |
| 调整复制重试预算、队列或 resync 调度 | 无效:不修复请求头缺失,重试多少次都会发送相同的错误请求 |
验证与实机验收
在合入前的基线 aafe4f888 上运行新增回归测试:FULL_OBJECT CRC32/CRC32C 复制稳定复现 BadDigest,CopyObject 用例在路径选择断言处失败(got true, want false);应用补丁后全部通过。
入库的单元测试与集成测试包含 8 个请求头表驱动用例(含逐分片请求头检查)和 8 个双版本桶 API 集成用例,验证复制路径选择与两端校验和元数据一致性。
独立实机验收启动两个单盘 SILO 服务端实例,通过 mcli 配置站点复制,实测 12 个端到端用例:
| 用例类别 | 算法或变体 | 数量 |
|---|---|---|
单次 PUT |
SHA256、SHA1、CRC32 | 3 |
分片 COMPOSITE |
CRC32、CRC32C、SHA256 | 3 |
分片 FULL_OBJECT |
CRC32、CRC32C | 2 |
默认 FULL_OBJECT 的分片上传 |
CRC64NVME | 1 |
对分片 FULL_OBJECT 对象执行 CopyObject |
CRC32、CRC32C | 2 |
仅含 1 个分片的 FULL_OBJECT 上传 |
CRC32 | 1 |
全部 12 个用例均达到源端 COMPLETED、目标端 REPLICA;独立计算的校验和摘要与类型、版本 ID、ETag、对象大小及目标端下载字节均与源端完全一致。
可复现测试命令:
PR 检查(Go CI、DCO、VulnCheck)与合并提交检查(Go CI、VulnCheck)全部通过。
升级与运维要点
- 升级复制源端:该修复作用于复制发送端构造的请求头。仅升级目标端无法补回被源端丢弃的
x-amz-checksum-type;在双向(active-active)复制拓扑中,两端站点均作为发送端,需同时升级。贡献者在 PR #255 中的混合版本测试验证了已修复源端可向未修复 SILO 目标端正常复制,而未修复源端向已修复目标端复制仍会失败。 - 覆盖路径:桶复制、站点复制与批量复制(
batchReplicationOpts())均通过putReplicationOpts()构造请求头,因此统一获得修复。SSE-C 加密对象继续通过X-Minio-Replication-Ssec-Crc传递加密校验和元数据。 - 存量
FAILED版本恢复与回滚:本次修改不涉及配置项、磁盘元数据格式或 API 变更。此前因该问题停留在FAILED状态的对象版本在源端保有完整的校验和元数据,升级源端后,触发复制重试或 resync 即可完成同步;回滚旧版本不会影响已落盘的目标端副本。
12 - Alias 区域:小配置改动的兼容性边界
2026-10-23 决策:mcli PR #44 已在 91006a587 合入 main。我们接受按 alias 配置区域的方案,保留小范围生产实现并补充回归覆盖。本文记录当时的设计决定。该能力已包含在正式发布的 mcli 20261109 中;以下配置边界按该版本源码复核。
原始补丁和测试由 mr javad seydi 贡献,解决的是上游 issue #5268 所报告的配置能力缺口。
缺少的究竟是什么
原始报告中的内部 S3 服务不实现 GetBucketLocation,调用时返回 501 Not Implemented。SDK 在发送普通对象请求前,先尝试发现桶的区域;发现失败,对象请求也就无法执行。用户向 alias JSON 加入 "region": "us-west-1" 后仍然无效:旧配置结构没有这个字段,会直接忽略它。
访问本身已有绕过办法。MC_REGION 和 AWS_REGION 可以设置 SDK 区域并跳过发现。只访问一个端点时,环境变量已经足够:
真正缺少的是按 alias 持久保存区域。进程级覆盖值无法在同一条命令中表达两个 alias 各自不同的区域,也不会随 alias 导出。这次新增字段填补了该缺口。它属于新增配置能力,不能把此前未受支持的 alias 字段被忽略说成既有功能回归。SILO 本身实现了 GetBucketLocation,原始报告也不能证明 SILO 服务端缺少这个接口。
最小且完整的配置链
原始生产补丁只改四个文件,新增 13 行、删除两行:
| 层次 | 修改 | 必要性 |
|---|---|---|
| Alias JSON | 在 aliasConfigV10 增加可选 region |
能读取、保存和导出这个值 |
| 客户端配置 | 增加 Config.Region |
通过现有客户端接口传递区域 |
| Alias 转换 | 在 NewS3Config 复制字段 |
接通存储配置与客户端构造 |
| S3 工厂 | 为 SDK 和缓存键解析区域 | 使用配置值,并避免复用固定在其他区域的客户端 |
优先级为:
第一个非空值生效。alias 区域缺失或为空时,保留原有发现路径;JSON null 也按空值接受。既有环境变量仍然优先。同名 MC_HOST_<alias> 环境 alias 继续整体替换文件 alias,因此不会继承文件中的区域。
缓存修改是正确性的一部分。两个 alias 可以共用端点和凭据,却分别指定不同区域。如果缓存仍只识别旧身份,第二个 alias 就会复用第一个已经固定区域的 SDK 客户端。把最终生效区域纳入键,才能阻止这种错误复用;当环境变量把两者覆盖成同一区域时,仍可共享客户端。SDK 和缓存使用同一个解析函数,避免两套优先级发生偏差。
SDK 已经支持显式区域,接通现有配置即可,无需 fork SDK、为某个服务增加 501 特判、引入依赖或升级配置版本。遇到发现错误就静默猜测区域,反而会掩盖配置错误,让签名行为更难解释。
固定区域意味着什么
非空区域会固定 SDK 使用的区域,跳过桶区域发现,并可能绕过 SDK 的区域纠正重试路径。它不是“先试这个区域,再发现真实区域”的提示。现有两个环境变量本来就具有同样的强制语义。相关实现见 SDK 的显式区域路径和区域纠正条件。
一个 alias 访问多个区域的桶时,这个区别尤其重要。应留空以使用自动发现,或为不同区域建立独立 alias。端点或服务区域变化时,也要同步更新固定配置。错误区域还会影响生成的签名:share upload 可能生成直到使用时才失败的 POST 表单;share download 会先执行 Stat,因此可能更早失败。
升级后哪些行为可能变化
补丁不会为旧 alias 自动填写区域。旧 CLI 正常生成的配置通常没有该字段,普通升级会保留原有区域发现和环境变量覆盖行为。例外需要分别说明,不能用一句“向前兼容”概括。
| 场景 | 后果 |
|---|---|
旧 alias 没有 region |
区域选择行为不变 |
模板或早先实验在旧 JSON 留下非空 region |
原先被忽略的字段开始生效;错误或过期的值可能导致访问失败 |
旧的未知 region 是数字、布尔值、对象或数组 |
新字符串字段会拒绝它,整份配置解析失败,操作其他 alias 的命令也受影响 |
| 本次验证的改动前客户端读取新配置 | JSON 可读,但区域被忽略,恢复旧选择行为 |
| 该旧客户端修改任意 alias 并保存配置 | 整份文件被重写,所有 alias 的未知区域字段都会丢失 |
最后一种情况很容易漏掉。旧程序只读新配置不会擦除磁盘文件,保存才会,即使用户改的只是另一个 alias。再次升级无法恢复丢失字段。降级前应备份配置;必须混用新旧客户端时,使用独立配置目录。因此,这项改动兼容正常旧配置的升级;对本次验证的前一版本,降级后格式可读,但语义不完整,写回也有损。
更实际的升级意外,是自动化模板一直写着默认区域,或用户早先试填后忘记清理。非字符串值属于更边缘的错误输入。这些风险不足以成为继续忽略新字段的理由,但值得用户检查已有自定义 JSON。
如何配置和维护
对于包含这项改动的客户端,把下面内容保存为 archive.json,替换端点、凭据和实际区域:
导入文档是单个 alias 对象,不是完整的 version 10 配置。Import 会替换目标 alias,轮换凭据时应包含完整的预期配置;导入对象不含 region,就会清除已有区域。Export 命令保留该字段,可以用来检查,但其输出也包含凭据。
alias set 没有 --region 参数。它会重建 alias 并清掉该字段,连接探测也不会继承已保存的区域。因此,启用新能力后,用 alias set 轮换凭据的脚本需要调整。alias list 包括 JSON 输出都不显示区域。这是初版明确的管理边界;CLI 支持可以单独改进,无需扩大本次生产补丁。
建桶另有设置。mb --region 继续使用自己的默认值 us-east-1,alias 区域不会替代它。例如,预期创建在 us-west-1 时,应执行 mcli mb --region us-west-1 archive/new-bucket。固定的客户端配置应与建桶位置一致。
回归覆盖与取舍
正式版本中的区域回归测试通过实际配置 loader 和拒绝 GetBucketLocation 的 HTTP 测试服务,检查固定区域下的 Stat/Put、签名区域、环境变量优先级及客户端缓存隔离。它验证了配置到 SDK 的完整链路,不代表所有第三方 S3 服务都兼容。维护的正式组合是 SILO、SILO Console、mcli 与 silo-pkg;对原厂 MinIO/MC 保持尽最大努力兼容。
这项改动通过 SDK 已有接口解决按 alias 保存区域的需求,正常旧配置仍保留发现行为。运维人员需要管理的边界是固定区域的强制语义、alias 重建和旧客户端写回;新增 CLI 参数可单独评估。
13 - IAM 策略重载:锁粒度、请求取消与超时预算隔离
提交 1b5477f3d(pgsty/silo#251)解决了 KMS/KES 响应缓慢时的两类级联故障:
- 鉴权缓存锁争用:
ListPolicies与ListPolicyDocs在保留 IAM 写操作串行锁(writeMu)的前提下,将慢速的后端策略读取与解密移出内存鉴权缓存锁(store.lock()),避免管理员列举策略时阻塞普通用户的 S3 读写请求。 - 全链路取消传播与超时预算隔离:将调用方的请求
context.Context贯通至 IAM 配置解密、SSE-S3 列表密钥解封以及底层的KES.Decrypt调用;同时将底层存储 RPC 超时严格限制在存储读取阶段,确保耗时超过 30 秒的合法慢查询不会被误杀。
两种故障表象与对应机制
在排查 pgsty/silo#251 时,受控复现实验将 KES 慢响应下的服务端异常拆解为两条独立机制:
- 当管理员在 KES 响应缓慢时执行策略列表查询,查询本身耗时数秒,而普通用户访问无关对象的
GET、HEAD、PUT请求也随之停滞。 - 当客户端中途放弃并断开 SSE-S3 对象列表请求(需解密对象元数据的
ListObjects)后,服务端仍继续向 KES 发起后续对象的密钥解密请求。
| 故障表象 | 根因机制 | 修复措施 |
|---|---|---|
普通用户的 GET、HEAD、PUT 被慢速策略列表阻塞 |
ListPolicies / ListPolicyDocs 在读取和解密全部策略文档期间一直持有内存鉴权缓存写锁 |
A:将慢速存储读取与解密移出鉴权缓存锁 |
| 客户端取消请求后,服务端仍继续向 KES 请求解密 | 中间层辅助函数换用了 context.Background(),导致取消信号在到达 KES 前中断 |
B:在受影响的完整调用链中透传请求 context.Context |
原本能成功的慢查询(> 30 秒)在第一版修复候选中于 30 秒左右返回 503 |
复用的 withIAMStorage 包装函数以及新贯通的 etcd 30 秒读取子 Context,将单步超时强加给了整批解密过程 |
R1:让每个超时预算严格局限于其原本约束的存储操作 |
缩小锁粒度(A)与贯通取消信号(B)互为补充:释放缓存锁保证正常 S3 流量在慢速管理查询期间畅通无阻;贯通取消信号则确保客户端断开后立即停止无效的 KMS 解密请求。
锁耦合与取消断点的形成原因
ListPolicies 和 ListPolicyDocs 会从后端存储重新加载全部持久化策略,并替换内存中的策略映射表。原实现从调用 store.lock()(即 IAMCache 写锁)开始,一直持锁直到所有策略文档读取并解密完毕。在对象存储 IAM 后端上,每个加密策略文档都可能触发一次独立的 KES 解密请求。由于所有普通 S3 请求在鉴权阶段都需要获取同一个 IAMCache 的读锁,管理端列举策略时的数秒 KES 延迟会直接阻塞数据面的全部对象读写。
与此同时,请求取消链路在三个位置发生了断裂:
- IAM 配置解密在调用 KMS 时使用了与 HTTP 请求无关的后台 Context;
- SSE-S3 列表路径(
UnsealObjectKeys)调用了不接收context.Context参数的单对象辅助函数UnsealObjectKey; - KES 客户端适配器最终使用
context.Background()调用Decrypt。
由于最底层被硬编码替换为 context.Background(),即便顶层 HTTP 处理器传入了请求 Context,出站 KES 请求也无法感知客户端断开。
A:释放鉴权缓存锁,保留写入串行顺序
IAM 子系统中的两把锁承担着不同的职责:
- 鉴权缓存锁(
store.lock()/store.rlock()):保护普通 S3 鉴权在每个请求上读取的内存映射表。 - IAM 写串行锁(
writeMu):串行化管理端 IAM 写入与整表缓存替换,防止并发修改交错覆盖。
修复方案 A 在加载策略期间继续持有 writeMu 以保证写入次序,但在执行慢速后端读取与 KMS 解密时释放鉴权缓存锁:
在私有 map 加载和解密期间,普通 S3 鉴权可以继续并发读取已提交的缓存快照,只有其他管理端 IAM 写操作会在 writeMu 上排队。之所以不在加载期间同时释放 writeMu,是因为若完全放开并发,较早启动但较晚结束的慢速列表可能用旧快照覆盖期间刚提交的策略更新或删除。
缓存替换仅在后端加载成功、且重新获取缓存锁后再次确认 ctx.Err() == nil 时才会发布。若仅在重新加锁前检查取消,就会漏掉等待缓存锁期间发生的取消事件。一旦发布前遇到后端读取错误、部分加载或请求取消,原有的内存策略映射表及其更新时间均保持不变。
后台定期刷新同样遵循这一合并与撤销规则。回归测试直接驱动具体的 IAMObjectStore 刷新路径,与策略更新、策略删除以及持久化 IAM 撤销并发执行验证。
2026 年 11 月单条目按需加载扩展(07ce95dfd、00270e9f6、c78ef6a6f、bb31c2618): 单个用户、组和策略的按需加载(Lazy Load)同样在存储读取期间释放鉴权缓存锁以保持已缓存 S3 鉴权可用,按 key 通过 singleflight 合并并发未命中,并在读取存储前记录 loadedAt,防止在途慢速读取覆盖更新的缓存版本或删除墓碑。
B:将取消信号贯通至 KMS 与 KES
受影响的两条解密调用链现在全程保留调用方的 context.Context:
为了避免改动其他不具备请求 Context 的调用点,原有的单对象入口 UnsealObjectKey 保持原函数签名不变,列表路径改调内部新增的带 Context 辅助函数。
在列表循环迭代、历史回退分支以及最终缓存发布边界上均增加了取消检查。特别是当 IAM 解密因请求取消而中断时,加载器会立即返回,不再启动针对旧版密文的附加认证数据(AAD)兼容回退重试;而对于未取消的正常请求,旧密文 AAD 回退、密文格式、密钥顺序以及非取消类的 KES 错误映射均保持不变。
R1:将超时预算严格限制在原存储操作范围内
第一版修复候选在实现 A 时直接复用了 withIAMStorage 辅助函数来释放缓存锁。withIAMStorage 最初在 9 月 15 日的撤销持久化提交(709d50a916)中引入,用于为单条记录的存储读写、分布式锁等待和清理操作施加 30 秒超时保护。将其直接套用到策略全量列举上,相当于给原本没有总时长限制的策略列表额外加上了 30 秒整批硬上限。
边界测试直接暴露了这一回归反例:
- 在修复前的基线版本上,包含 61 个加密策略的慢查询在 32.74 秒后正常返回
HTTP 200; - 在第一版修复候选上,同一查询在 31.22 秒后因 30 秒超时到期而返回
HTTP 503。
然而,仅移除外层的 withIAMStorage 包装对于 etcd 后端仍然不够。etcd 存储实现在调用 Get RPC 时会派生一个 30 秒的子 Context。旧代码的解密逻辑忽略了传入的 Context;当修复 B 将 Context 真正贯通到解密调用后,如果直接把这个 30 秒的 etcd 读取子 Context 传给后续解密,就会让原本只约束 Get RPC 的截止时间继续约束读取完成后的 KMS 解密。一旦遇到“快速 etcd 读取 + 32 秒慢速 KES 解密”的场景,请求就会在解密阶段因超时失败。
修正方案 R1 在策略文档与策略映射加载路径上,将调用方的请求总预算与单次 etcd 读取预算明确分离:
| 时间预算类型 | 作用范围与行为 |
|---|---|
| 调用方的 Deadline 或主动取消 | 约束整个 HTTP 请求及其触发的全部 KMS/KES 解密操作 |
既有的 etcd 读取预算(默认 30 秒) |
仅约束 etcd 的 Get RPC;读取返回后,后续解密切回调用方的父级请求 Context |
| 既有的版本写入、撤销、分布式锁与清理预算 | 保留 withIAMStorage 原有包装函数与超时行为 |
| 第一版候选误加的整批策略列表 30 秒上限 | 从 ListPolicies 与 ListPolicyDocs 两个入口彻底移除 |
由于 etcd 读取 Context 是调用方请求 Context 的子级,若调用方本身设置了更短的超时,读取依然会立即响应取消;而在 etcd 读取完成后,解密操作继续受调用方剩余预算约束——当调用方允许更长等待时间时,超过 30 秒的慢查询依然能够成功返回。
备选方案与取舍
| 备选方案 | 否决原因 |
|---|---|
| 继续持有缓存锁,仅缩短列表超时 | 正常 S3 请求在超时前仍被阻塞,且合法的慢速策略查询会直接失败 |
加载期间同时释放 writeMu 与缓存锁 |
较早启动的慢列表可能在并发 IAM 更新之后完成,用旧快照覆盖新权限 |
| 直接返回内存缓存策略,不再从后端重载 | 破坏管理端查询的强新鲜度语义,且掩盖底层存储或 KMS 故障 |
| 将整批列表超时调大为另一个固定常数 | 只是把兼容性故障推迟到更大的策略数量或更高的 KES 延迟下触发 |
仅移除两个外层 withIAMStorage 超时 |
无法解决 etcd 读取完成后子 Context 超时传递导致的解密失败回归 |
| 删除全部后端存储超时 | 削弱单记录读写、分布式锁等待和清理操作的既有超时保护 |
| 并行解密策略或新增第二层密文缓存 | 引入额外的 KES 并发洪峰与缓存一致性复杂度,修复锁与取消问题无需依赖这些机制 |
| 顺带修改 KES SDK 内部的重试退避逻辑 | 超出服务端缺陷范围,侵入第三方 SDK 行为边界 |
相对于受测基线 a07d655dc,最终生产代码改动涉及 8 个文件(新增 83 行、删除 29 行)及 5 个测试文件,不涉及线协议、密文格式或权限模型变更。
实测对比与回归测试契约
在相同进程级实验夹具下,基线版本 a07d655dc、第一版候选与最终实现 1b5477f3d 的实测对比如下:
| 测试场景 | 修复前 / 第一版被否决候选 | 最终实现(1b5477f3d) |
|---|---|---|
| 5.23 秒慢速策略列表期间并发发起 4 个普通用户对象请求 | 平均耗时 5,284 ms(被策略列表阻塞) | 平均耗时 2.25 ms(策略列表本身并行耗时 5.23 秒) |
| 相同普通对象请求设置 200 ms 客户端超时 | 4 个请求全部超时失败 | 4 个请求均在 1.11–1.82 ms 内返回 200 OK |
| SSE-S3 列表请求在 200 ms 处被客户端主动断开 | 客户端退出后服务端又继续启动 21 次解密 | 客户端退出后新增 0 次解密(取消前共启动 4 次) |
| 对象后端慢速策略查询超过 30 秒(61 个策略) | 基线:32.74 秒返回 200;第一版候选:31.22 秒返回 503 |
32.75 秒正常返回 200 OK,完整返回 61 个策略 |
etcd 快速读取 + 32 秒解密(调用方预算 60 秒) |
第一版候选在读取后的解密阶段超时失败 | 两个策略列表入口与策略映射加载均成功完成 |
| 51 组正常与拒绝访问的 HTTP 对照用例 | 基线状态码、错误码与返回条数 | 51 组全部一致(含分页、KMS 拒绝与签名校验) |
在构造回归测试时,还处理了 KES 客户端的两处实现细节:
- KES SDK 不可取消的退避休眠:KES SDK 在重试时可能执行最多两次、每次最长
999 ms且不监听ctx.Done()的休眠。2 秒的测试观察窗口不足以覆盖300 ms请求超时加上两次退避休眠;将观察窗口调整为 3 秒后,可稳定验证取消后绝不发起任何新的解密请求。 - 周期性
DescribeKey探针:在持续 32 秒的真实长耗时测试中,后台会触发合法的周期性DescribeKey请求,模拟 KES 夹具需同时响应该接口。
持久化回归测试入口位于:
cmd/iam-policy-reload_test.gocmd/iam-policy-refresh_test.gocmd/iam-policy-budget_test.gocmd/kms-cancellation_test.go
运维边界说明
- 并发 IAM 管理写入仍按序排队:由于策略重载期间保留了
writeMu以维护写入顺序,其他管理端 IAM 写操作(如创建或删除策略)仍需等待当前正在执行的策略列表完成或取消;普通 S3 数据面鉴权则完全不受阻塞。 - KES SDK 退避期间的本地返回延迟:取消信号会立即终止正在进行的出站 KES 请求并阻止后续密钥解密或 AAD 回退启动;但如果取消发生时 KES SDK 恰好处于不可打断的退避休眠(最长
999 ms)中,本地函数会在该次休眠结束后返回。
工程复盘要点
- 复用辅助函数时必须审视其全部副作用:为复用锁释放逻辑而直接调用
withIAMStorage,会连带引入原本不存在的 30 秒整批超时上限。 - 修复 Context 传播时须逐层核对子 Context 的生命周期:当原先忽略 Context 的底层函数开始真正遵守 Context 时,上层为单步 RPC 创建的短生命周期子 Context(如
etcd读取超时)如果未及时切回父 Context,就会误伤后续步骤。 - “能取消”与“慢查询能成功”必须成对验证:取消测试只能证明请求能提前终止,必须配合超过单步后端预算(> 30 秒)的正向慢查询测试,才能防止超时范围扩大造成的回归。
相关设计文档:
14 - LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界
Server PR #250(合入提交 d43adc0f2,已随 Server 20261026 发布)修复了 LDAP 用户在规范化可分辨名称(Distinguished Name,DN)超过对象存储 IAM 后端单路径分量 255 字节上限时无法登录的问题,同时严格保留了对真实存储故障与持久化 IAM 撤销记录的 Fail-Closed 错误传播。
原始问题报告、初版补丁(9faeb7a11)及长 DN 回归测试由社区贡献者 @910patin 提交。在合并前的设计复核中,维护者进一步收窄了错误分类守卫条件,确保完整路径超限(PATH_MAX)或多字节 Unicode 文件名读取失败绝不会掩盖磁盘上已有的撤销墓碑记录。
问题背景与故障影响
在企业目录服务中,合法的 LDAP 账号常带有较长的 DN,尤其是当姓名与多级组织单元(ou=)包含中文、俄文等非 ASCII 字符时。即使某个用户完全通过 LDAP 用户组(Group)获取权限、既不需要直接绑定个人策略也没有本地父身份记录,SILO 的登录与鉴权流程仍会尝试读取这些可选的个人 IAM 记录。
在对象存储 IAM 后端上,规范化后的 DN 会直接作为目录名或文件名分量拼接到存储路径中。一旦该分量超过文件系统单分量上限(255 字节),底层读取就会抛出文件名过长错误(ENAMETOOLONG / ObjectNameInvalid)并向上传播,导致原本完全合法的 LDAP 登录、STS 临时凭证签发以及服务账号加载直接失败。
为什么 DN 规范化会让非 ASCII 名称膨胀三倍
DN 标识完整的目录条目(例如 cn=张三,ou=研发,dc=example,dc=com)。根据 RFC 4514 §2.4,特定语法字符必须转义,其余字节允许使用 \xx(反斜杠加两位十六进制数字表示一个字节)格式转义。虽然 RFC 4514 允许直接保留普通 UTF-8 字符,但上游 go-ldap 库的 DN.String() 序列化器选择将所有非 ASCII 的 UTF-8 字节逐字节转义为 \xx。
SILO 的 NormalizeDN 先解析 LDAP 服务端返回的 DN,再调用 DN.String() 生成统一的规范化字符串:
| 表示形式 | 字节长度 |
|---|---|
单个 UTF-8 汉字 张 |
3 字节 |
经 DN.String() 序列化后的 \e5\bc\a0 |
9 个 ASCII 字节 |
cn= + 40 个 张 + ,dc=example,dc=com(原始 UTF-8) |
141 字节 |
同一完整 DN 经 NormalizeDN 序列化后 |
381 个 ASCII 字节 |
由于每个 3 字节汉字都会展开为 9 个 ASCII 字节,原本仅 141 字节的 UTF-8 DN 在序列化后膨胀到了 381 字节。
这条查询结果规范化逻辑由上游 MinIO 于 2024 年 3 月 27 日在 minio/pkg efb73fa5(配套 MinIO PR #19358,作者 Aditya Manthramurthy / @donatello)中引入,目的是强制使用 LDAP 目录返回的权威 DN 作为身份标识,防止用户输入的拼写变体(例如将普通句点输入为 Unicode 变体句点)被当成不同的本地身份。
登录回归是如何形成的
对象存储 IAM 后端使用如下路径存放用户级记录:
主流 POSIX 文件系统对**单个斜杠分隔的路径分量(NAME_MAX)**限制为 255 字节。在 sts-users/<normalized-DN>.json 中,5 字节的 .json 后缀与规范化 DN 共用同一个文件名分量预算:250 个 ASCII 字节加 .json 刚好 255 字节,251 个字节加后缀即达到 256 字节并触发超限。
两处历史实现细节进一步加剧了症状的隐蔽性:
checkPathLength在 Unix 上漏计反斜杠:SILO 的路径长度预检查函数checkPathLength在 Unix 系统上会跳过反斜杠(\)字符。复现样本中 271 字节的转义 DN 被checkPathLength算成了 197 字节,从而绕过预检查,直到真正访问操作系统文件系统时才报错。- 结果随祖先目录是否存在而变化:如果
config/iam/users/前缀目录尚未在磁盘上创建,操作系统内核在解析父目录时就会直接返回ENOENT(文件不存在),根本不会走到后面的超长<normalized-DN>分量——此时登录反而返回HTTP 200成功。一旦集群中曾经写入过任何用户记录、使config/iam/users/目录落盘存在,内核就会继续解析超长子分量并返回ENAMETOOLONG,导致同一用户的登录立刻变成HTTP 500。
| 历史提交 | 在故障链中的作用 |
|---|---|
MinIO ee9077db(2020-05) |
在 checkPathLength 预检查中引入 Unix 下忽略反斜杠的计数逻辑。 |
MinIO 280526ca(2024-03) |
策略缓存未命中时不再忽略磁盘回读错误,而是将读取错误向上传播。 |
MinIO efb73fa5 与 7e45d84a(2024-03) |
将身份与策略映射统一改为规范化 DN,导致非 ASCII 字符按 \xx 展开。 |
SILO 709d50a9(Server 20260916) |
为支持持久化撤销与防重放,在登录流程中新增父身份版本读取(config/iam/users/<DN>/identity.json),使每次 LDAP 登录都会必经该读取路径。 |
修复方案:仅将确定无法落盘的纯 ASCII 超长分量归类为不存在
修复集中在对象存储 IAM 的共享底层读取函数 loadIAMConfigBytesWithMetadata 中。函数首先按原流程尝试读取;仅当读取返回错误,且同时满足以下三个条件时,才将该错误归类为 errConfigNotFound(记录不存在):
- 底层错误为
errFileNameTooLong或ObjectNameInvalid(含标准包装错误); - 完整对象键通过常规的桶名与对象名合法性校验(
isValidBucketName与isValidObjectName); - 至少存在一个按
/分隔的路径分量完全由 ASCII 字符构成(utf8.RuneCountInString(component) == len(component)),且字节长度超过 255(len(component) > 255)。
由于超过 255 字节的纯 ASCII 路径分量在对象存储后端从一开始就不可能写入成功,磁盘上绝不可能存在对应的历史记录。将其转换为 errConfigNotFound 后,上层调用者即可安全进入既有的“无此可选记录”分支——例如继续执行 LDAP 认证并通过用户所属的 LDAP 组策略完成授权——既不会凭空授予任何默认权限,也不会跳过对可存储身份的父版本撤销检查。
写入、删除、etcd IAM 后端以及全局对象路径长度检查均保持不变。核心生产代码仅在 cmd/iam-object-store.go 中新增 25 行(配套新增 318 行回归测试),正常读取路径零额外开销。
为什么要收窄初版补丁的错误分类范围
初版 PR 将任何语法合法键上的 ObjectNameInvalid / errFileNameTooLong 统统归类为 errConfigNotFound。在合并前的设计复核中,我们构造了两个具体反例,证明“读取报长度错误”并不总能推出“该记录从未在磁盘上存在过”:
- 完整路径超限(
PATH_MAX)掩盖已落盘的真实撤销记录:假设管理员先撤销了一个各路径分量均不超过 255 字节的合法身份(撤销墓碑已成功落盘),随后在 macOS 等环境上将数据目录挂载或迁移到了层级更深的父目录之下。此时相对路径下的各个分量依然合法,但完整绝对路径触发了系统的总路径长度上限而导致读取失败。如果把这类错误也吞成errConfigNotFound,就会直接掩盖磁盘上真实存在的撤销墓碑,让已被撤销的身份重新通过检查。限定检查单个/分量len(component) > 255后,完整路径超限仍会按 Fail-Closed 向上报错。 - 按字符数计长的多字节 Unicode 文件名(如 macOS APFS/HFS+):不同文件系统对 255 上限的定义并不相同。在 macOS 实验探针中,由 100 个汉字
界组成的单个路径分量虽然占用 300 个 UTF-8 字节(len(component) == 300,但字符数仅为100),却能够在 macOS 文件系统上成功创建并落盘。如果仅凭len(component) > 255就断定记录不可能存在,就会把这类已经落盘的多字节 Unicode 记录误判为不存在。加上utf8.RuneCountInString(component) == len(component)限制后,该规则仅对纯 ASCII 分量(天然涵盖所有\xx转义后的 DN)生效,而在任何平台上 256 个以上 ASCII 字符都无法存入单分量上限为 255 的文件系统中。
由此确立了严格的安全边界:只有被证明在物理上绝无可能写入成功的路径分量,才允许走 errConfigNotFound 分支;任何可能已经存在但当前无法读取的撤销记录,一律保持报错拒绝。
备选方案与取舍
| 候选方案 | 决策与原因 |
|---|---|
在 loadIAMConfigBytesWithMetadata 中对 >255 字节的纯 ASCII 分量做窄范围缺失分类 |
采用。 一次性覆盖所有共享该读取器的 IAM/STS 调用点,同时对完整路径超限和可落盘的 Unicode 名称保持 Fail-Closed。 |
| 将合法键上的所有名称长度错误一律视作不存在 | 否决。 当数据目录迁移到深层路径触发完整路径上限时,会掩盖磁盘上已有的真实撤销记录。 |
按解码后的字符数计长,或扣除 \xx 转义开销 |
否决。 操作系统文件系统实际存储的就是展开后的字面量字节,修改软件侧计长无法改变内核限制。 |
仅修复 checkPathLength 在 Unix 上漏计反斜杠的问题 |
不充分。 修补预检查只会让同一个长 DN 更早在入口被 ObjectNameInvalid 拒绝,无法恢复登录。 |
修改 NormalizeDN 保留原始 UTF-8,不再做 \xx 转义 |
暂不采用。 会改变存量集群已落盘的身份与策略映射键名,引发兼容性断裂,且依然无法解决更长 DN 的分量超限。 |
对 LDAP 跳过父身份版本检查或回退 709d50a9 |
否决。 会丧失持久化撤销与防重放能力,且依然会在更早的 sts-users/<DN>.json 策略映射读取处失败。 |
| 使用固定长度哈希键存储,将完整 DN 存入文件内容 | 属独立特性范围。 若需支持超长 DN 的持久化写入,需统一设计查找、列举、导入导出、跨站点复制与滚动升级方案。 |
测试验证矩阵
| 验证层级 | 覆盖范围与结果 |
|---|---|
单元与边界回归测试(cmd/iam-revision_test.go) |
覆盖转义后 271/384 字节 DN、255/256 字节临界值、.json 后缀计入、多字节 Unicode 分量、包装错误、非法对象名,以及迁移至深层路径下的真实撤销记录保留报错。 |
端到端 LDAP STS 登录测试(cmd/sts-handlers_test.go) |
验证规范化后长达 384 字节、通过组策略授权的 LDAP 用户在对象后端与 etcd 后端共 8 种配置下的 STS 登录、S3 签名访问与服务账号操作。 |
| 跨平台物理文件系统边界探针 | 在 macOS(可创建 49 组)与 Linux tmpfs(可创建 32 组)上实测 72 组物理文件名:所有能落盘的名称均未被误判为“不可能存在”;并在每个平台上验证了 105 项错误传播断言(5 类父身份名称 $\times$ 7 类存储错误 $\times$ 3 个调用入口)。 |
| CI 与工具链漏洞检查 | PR 提交 3df51549bbbd6e57a6a5f11ac77535390517db65(合并为 a07d655dc69cf04b130c2634d9fa5301078af546)及合并提交 d43adc0f2 通过全部 Go CI 与 VulnCheck 任务(同时将最低 Go 工具链提升至 Go 1.27.3 以修复标准库证书校验漏洞 GO-2026-6809)。 |
运维边界与配置建议
- 读取与写入的能力边界:本次修复恢复了长 DN 用户的登录与只读查询路径,但对象存储 IAM 后端依然不支持写入单路径分量超过 255 字节的文件。直接向规范化后超过 250 字节的个人 DN 绑定策略(写
sts-users/<DN>.json)或写入父身份版本墓碑(写users/<DN>/identity.json)仍会因文件名超限而失败。 - 推荐使用 LDAP 组授权:在使用对象存储 IAM 后端的集群中,对于包含中文或长路径的 LDAP 用户,应通过绑定到 LDAP 组(Group)的策略进行授权(确保组 DN 规范化后不超过 250 字节),避免直接向超长个人 DN 绑定独立策略。
- 无需数据迁移:升级无需执行任何磁盘数据迁移。切勿通过手动删除
.minio.sys/config/iam/下的目录来规避路径报错;多站点升级请遵循 IAM 升级与恢复手册。
15 - 复制标签排序:修订时间戳、墓碑与复活
随 SILO Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a)发布的 PR #193(修复提交 03027727d)与 PR #196(修复提交 680eac66e),修复了对象标签在跨桶复制中保持顺序的两项缺陷:SSE-KMS 目的端复制静默丢弃标签修订时间戳(R4),以及空标签值(删除操作)缺失修订时间戳导致延迟到达的复制事件将已删除标签“复活”(R5)。
共同模型:标签值与其修订时间戳共同构成单一状态
对象标签在复制时通过请求头 X-Minio-Source-Tagging-Timestamp 传递内部修订时间戳(在 xl.meta 中存储为 x-minio-internal-tagging-timestamp)。接收端在对象写锁保护下通过 reconcileStoredObjectTags 执行排序规则:入站复制的标签状态仅在其修订时间戳严格新于目标端已存时间戳时才胜出。
本组两项缺陷均以“丢失修订时间戳而非丢失标签值”的方式破坏了这一规则:
- R4(PR #193): 在写入 SSE-KMS 加密目标端时丢弃了入站时间戳,导致较新的标签更新在存储层对账中输给陈旧的已存状态。
- R5(PR #196): 使空标签值(即删除标签)完全不携带修订时间戳,导致复制协议无法表达“在时刻 $T$ 已删除标签”——网络中迟到的旧复制事件因此能够重新恢复客户端早已删除的标签。
R4:SSE-KMS 目的端复制丢弃标签修订时间戳
故障形态: 发往 SSE-KMS 加密目的端的可信复制 COPY 请求返回 HTTP 200 OK,对象密文加密正确,明文 GET 读取也完全正常——但目的端对象的标签及其时间戳却停留在旧值。由于 HTTP 请求返回成功,不会触发任何告警或 MRF 重试。存储层对账函数 reconcileStoredObjectTags 在对象写锁内比较修订时间戳时,因入站时间戳丢失而直接判定存量旧状态获胜。
触发范围: 不仅限于携带显式 SSE-KMS 请求头的调用;当目的端桶配置了桶级默认 KMS 加密或集群开启了全局自动加密时,均会进入同一代码分支,因此即便请求中不含任何 KMS 请求头也会触发该缺陷。
根因追溯: PUT 类请求的选项构造函数在解析出可信来源标签时间戳并存入 ObjectOptions 后,SSE-KMS 分支重新构造并返回了另一个 ObjectOptions 结构体字面量——该字面量复制了 MTime、ETag、复制信任标志与两个 Object Lock 时间戳,唯独漏掉了 ReplicationSourceTaggingTimestamp。这一遗漏最早可追溯至上游提交 c4373ef290(2021 年);2026 年初的一次 Object Lock 修复向该结构体字面量补齐了两个保留期/法务锁定时间戳,但依然漏掉了标签时间戳。
修复(03027727d): 在 SSE-KMS ObjectOptions 字面量中补齐 ReplicationSourceTaggingTimestamp 字段传递。回归测试覆盖了全部目的端加密方式(无加密、SSE-S3、带与不带 key context 的 SSE-KMS、SSE-C)$\times$ 可信/不可信来源 $\times$ 缺失/有效/畸形时间戳的完整组合矩阵,以及在单盘和 16 盘纠删码后端上以 1–3 纳秒间隔执行的 50 次有序签名 COPY+GET 循环验证。
相近代码观察: 同一个 SSE-KMS ObjectOptions 字面量还遗漏了 ProxyRequest 与 Speedtest 选项字段(全局自动加密下 speedtest PUT 会丢失该标志),该问题作为独立跟进项单独记录,不混入本次复制修复。
R5:空标签值无修订时间戳,导致已删标签被延迟事件复活
故障形态: 基线测试在真实存储与函数级测试中暴露出 9 项具体回归表现:
- 成功的
DeleteObjectTagging从不生成新的修订时间戳,因此迟到的可信元数据COPY只需携带旧标签快照即可将已删标签重新写回。 - 携带空标签状态的较新
COPY被接收端忽略——空字符串被解释为“未提供标签变更”而非“已清空标签”。 - 首次副本
PUT虽然解析了来源标签时间戳,却未将其持久化写入磁盘元数据。 - 当源端与目标端可见标签值相等(或均为空)时,发送端将其折叠为“无需复制”;由于
HEAD响应不暴露内部标签修订时间戳,更新的删除或重加操作不可见,无法恢复正确顺序。 - 排队中的复制事件在完成回调时会将快照中的旧标签写回本地元数据,直接覆盖已提交的删除操作——且在无时间戳保护下,复活的旧标签还会继承删除操作的较新修订状态。
根因: 标签值与其修订时间戳(包括空值的时间戳)共同构成一个完整状态。旧协议只能表达非空状态:DeleteObjectTagging 从不生成修订时间戳、发送端仅在非空分支附带时间戳、接收端也仅在非空分支执行对账判断。
修复(680eac66e):
- 每次标签变更均生成单调递增修订:
PutObjectTagging与DeleteObjectTagging处理器无条件生成单一的 UTCRFC3339Nano修订时间戳,无论该对象当前是否命中复制规则。存储层在写锁内强制保证单调性:若新生成的本地修订不严格新于已存修订,则将其推进至 $\text{stored} + 1\text{ ns}$;多池后端则跨所有存储池计算出严格大于全部池副本的统一修订值。 - 发送端传输删除墓碑(tombstone): 空标签值同样携带其已记录的修订时间戳;对于从未设置过标签的对象(空值且无修订记录)绝不伪造时间戳;若磁盘上已记录的时间戳格式畸形,则按失败关闭(fail-closed)处理,不作静默修补。
- 接收端对账墓碑状态: 复制
COPY在重建元数据前先捕获存量标签与时间戳对,使带时间戳的空值作为可胜出的有效状态进入reconcileStoredObjectTags。内部重复版本抑制(412 PreconditionFailed)仅对严格更新的可信来源标签修订放宽(客户端显式指定的If-Match与If-None-Match依然严格执行)。多段上传完成(CompleteMultipartUpload)从已持久化的 upload 元数据读取标签顺序;复制删除确认回调不再将快照标签写回本地;普通 SSE-C 密钥轮换则会从复制的加密元数据中剥离旧标签时间戳,防止其覆盖新生成的本地修订。
运维可见变化与升级要求
- 时间戳相等时统一判定存量胜出: 在不带版本(unqualified)及显式
null版本的COPY请求上,当入站时间戳与已存时间戳相等时统一保留存量状态(旧逻辑在未限定版本路径上曾让入站请求胜出平局)。 - 携带
X-Amz-Tagging-Directive: REPLACE且无标签的普通COPY: 现在会按 S3 规范真正清空目的端标签,而不再沿用旧默认路径将源对象标签复制过去。 - 复制两端必须协同升级: 旧版本对端仍会丢弃空值修订头,新发送端发出的删除墓碑对旧接收端不可见(关于同步包含的 IAM 变更,请参阅 IAM 与复制升级指南)。
- 显式 resync/heal 期间每个带修订记录的对象增加一次元数据
COPY: 当目的端配置了桶级默认 KMS 或全局自动 KMS 加密时,该元数据COPY会实际重写对象数据,执行大批量重同步时需据此评估 I/O 预算。 - 固有边界: 历史已丢失或被覆盖的删除时间戳无法自动重建,需从权威源端发起一次新的显式标签写入以重新收敛;带标签过滤条件的复制规则仍按删除后的(空)标签状态评估目标资格;跨站点逐跳排序依赖站点间的时钟同步(NTP),畸形的已记录时间戳会使发送端构造持续失败并经由 MRF 重试,直至被新的合法标签变更替换。
评估并否决的候选方案
- 为空值且无修订记录的对象从
ModTime合成墓碑: 会使每个从未打过标签的普通对象都获得标签修订,叠加显式同步时的强制元数据复制后,会导致每个对象在每一跳都触发额外的元数据COPY。 - 仅在标签值为空时传输修订时间戳: 提案者在代码评审中撤回了该方案——在“时刻 $T_1$ 设为
X$\to$ 时刻 $T_2$ 删除 $\to$ 时刻 $T_3$ 重新设为相同值X”的序列下,若非空时省略时间戳会丢失 $T_3$ 的重加修订(已由回归测试TestTaggingRepeatedValueNeedsRevisionDelivery固定该反例)。 - 扩展
HEAD协议返回内部标签修订: 当前固定的minio-go元数据提取器会丢弃内部响应头,修改协议契约收益有限,且当修订不同时最坏情况仍需执行一次元数据COPY。 - 引入分布式因果时钟或在底层提交时重新生成时间戳: 墙钟时间戳配合写锁内的 $+1\text{ ns}$ 单调护栏已是无需修改存储格式的最小正确修复。
- 仅做单池内的单调护栏: 普通读取仅返回单池对象信息,若只在单池内维护单调性,发送端可能发出落后于其他存储池的旧修订;因此必须跨池计算统一的最高修订值。
验证
R4 回归测试覆盖了加密模式 $\times$ 信任状态 $\times$ 时间戳矩阵,以及单盘与 16 盘纠删码后端上的有序序列;R5 额外包含多存储池标签删除回归测试,PR #196 的全部 11 项 CI 检查均已通过。
升级摘要见组件版本矩阵;阻止归一化副本元数据被重新注入的姊妹修复见副本元数据归一化。相关设计记录:复制标签排序 · 副本元数据归一化 · 请求头超时防护 · 副本元数据审计手册。
16 - SILO Server 20260903 发布前复审
本文记录 SILO 20260903(RELEASE.2026-09-03T13-18-01Z,对应提交 9b11dc9469e650815b775cb47b039610644f5da4,候选复审提交 6e112d1856d4f3655f30fc81ee47e9f43d50d8f3,对比基线 RELEASE.2026-08-06T00-00-00Z 即 3be10fcc1a44f6620ded0bd303461f9d688cca23)的发布前工程复审:第二轮复审发现了哪些遗漏缺陷、修复边界如何收窄,以及发布门禁如何组织。后续九月与十月版本的修复见复制可靠性、Server 20260916 与 Server 20261018。
为什么需要第二轮审查
第一轮实现修复了大多数已知问题并通过了现有测试。第二轮复审进一步检查各安全不变量在所有合法协议形态与异常状态下是否严密:
- 同一条不变量能否被另一种合法的请求格式(wire representation)绕过?
- 鉴权前的快路径是否仍会触发磁盘 I/O 或修改内存状态?
- 元数据存在但加载失败时会发生什么?
- 两条各自正确的“读-改-写”路径是否共享同一个串行化边界?
- 请求头清洗是否保留了完整的 SigV4 流式签名状态?
- 验收记录对应的是最终代码树,还是此前的中间提交?
- 复杂的防御逻辑对应的是已复现的故障,还是仅停留在假想场景?
第二轮复审在首轮实现之上定位出多处真实缺陷,并将每项修复与验收结论严格收窄到具体的不变量与代码提交。
分领域复审结果
| 领域 | 第二轮复审发现 | 最终解决方案 | 状态 |
|---|---|---|---|
| 桶元数据 | 各自独立的配置锁会丢失共享 .metadata.bin 记录中的并发更新(#102) |
用一把有界的 metadata.lock 覆盖所有整记录写入、迁移、导入、纳管与自愈路径;站点复制仅合并变更字段,避免旧快照覆盖整条记录 |
候选已关闭 |
| 桶创建 | ForceCreate 与站点纳管可能用默认值覆盖已有桶配置 |
保留已有记录,仅更新创建与纳管状态;补充防覆盖回归测试 | 候选已关闭 |
| Object Lock | 直接将配置字节与单一标准 XML 比对,会漏判包含默认保留规则(Default Retention)的合法配置 | 先解析 Object Lock 配置,再从启用状态推导版本控制不变量;验证更新、读回与磁盘重载 | 候选已关闭 |
| 预鉴权 CORS | 任意 URL 路径段都会触发元数据读取与缓存膨胀 | CORS 查询仅读取常驻内存的元数据,不触发对象层 I/O | 候选已关闭 |
| CORS 启动期 | 元数据尚未初始化时,非常驻桶名可能回退到全局 CORS | 保持显式的启动期默认拒绝(fail-closed)状态 | 候选已关闭 |
| CORS 加载失败 | 真实桶一旦丢失加载失败记录,就会与不存在的桶混淆,导致预签名请求回退到全局 CORS 配置 | 维护有界的失败桶集合,在成功加载、删除与刷新路径上清除记录;集合内的桶保持默认拒绝 | 候选已关闭 |
| CORS 恢复 | 按需调用 GetConfig 成功重载后,最初未清除加载失败标记 |
提交 84e1580a4 补齐单行状态清除,并增加定向并发竞态测试 |
候选已关闭 |
| 复制信任 | 多个处理函数仅凭客户端可控的内部请求头就放行特权分支 | 先完成鉴权,要求精确标记并具备 s3:ReplicateObject 或 s3:ReplicateDelete 权限,将决策写入私有上下文后再清洗请求头 |
候选已关闭 |
| 流式上传 | 清洗请求头时克隆出的请求对象起初未共享原请求的 Trailer 映射 |
保留 Trailer 映射,确保流式上传末尾到达的校验和可见 |
候选已关闭 |
| Snowball | 请求级的信任标记可能在归档解压条目之间串用 | 按归档条目独立推导信任状态,并在并发工作协程间保留请求默认值 | 候选已关闭 |
| SSE-C | 零字节读取与 GetObjectAttributes 可能跳过客户端密钥认证 |
强制要求成功解封客户端密钥;仅对已授权的副本请求保留独立豁免分支 | 候选已关闭 |
| 删除授权 | 指定版本删除仅检查普通删除权限,未要求 s3:DeleteObjectVersion |
统一单对象与批量删除的权限检查,复制删除继续检查 s3:ReplicateDelete,并保留鉴权与审计上下文 |
候选已关闭 |
| 管理授权 | 用户与组状态变更始终检查启用权限 | 按请求的目标状态检查对应的启用或禁用权限 | 候选已关闭 |
| Checksum | 分片上传与复制路径遗漏校验和字段、接受非法组合,或在压缩/加密后的字节流上计算校验和 | 补齐算法与类型校验、服务端分片校验和计算、联邦透传、AWS 错误码及 CopyObject 流变换顺序 |
候选已关闭 |
| 发布证据 | 全量验收记录最初对应的是后续仍有改动的中间提交 | 分别记录 ebac0ca73 的全量验收结果与最终候选提交的定向门禁 |
证据缺陷已关闭 |
定义候选的四条不变量
信任只在鉴权后推导一次
看似内部专用的请求头本质上仍是客户端输入。请求必须先以原始签名形态通过既有鉴权路径,随后处理函数才能综合以下四项条件推导复制特权:
- 唯一且精确匹配的复制标记;
- 非匿名的已认证身份;
- 目标资源上的
s3:ReplicateObject或s3:ReplicateDelete权限; - 仅限副本(replica-only)语义所要求的副本状态。
推导结果存入私有请求上下文;后续剥离内部请求头仅作为面向旧调用链的纵深防御,而非权限来源。
这一顺序至关重要:SigV4 签名可能覆盖这些请求头,若在鉴权前剥离,合法的复制请求就会因 SignatureDoesNotMatch 被拒绝。此外,清洗后的克隆请求必须与原请求共享 Trailer 映射,因为流式上传的校验和会在请求体读取完毕后通过 trailer 到达。
完整的接收端信任模型见鉴权前不做 I/O,Header 不产生权限。
共享记录只有一个写边界
桶策略、生命周期、服务端加密(SSE)、标签、配额、复制、Object Lock、版本控制与 CORS 在逻辑上各自独立,但在存储层同属一条桶元数据记录。按字段拆分的互斥锁无法保护整条记录的“读-改-写”周期。
修复方案没有引入重型的事务层,而是统一收敛到单一的元数据写锁:
该锁不涉及对象数据 I/O,生命周期严格限制在单次桶元数据操作内。元数据迁移与自愈同样会替换整条记录,因此也必须受该锁保护。站点复制的接收端仅合并发生变更的字段,防止远端较旧的快照覆盖本地其他配置。
区分 CORS 加载失败与桶不存在
CORS 热路径必须明确区分以下四种状态:
| 状态 | 处理结果 |
|---|---|
| 元数据子系统尚未初始化 | 不返回 CORS 响应头 |
| 已知存在的真实桶,但元数据加载失败 | 不返回 CORS 响应头 |
| 内存常驻桶且配有桶级 CORS 规则 | 按该桶的 CORS 规则求值 |
| 无常驻元数据,且无已知的加载失败记录 | 回退至服务端全局 CORS 配置 |
表中第二行是保留失败桶集合的原因。预签名 URL 由签名本身完成授权,可在不经过桶策略求值的情况下访问私有对象;此时桶级 CORS 配置就是浏览器跨源访问的唯一边界。如果丢弃加载失败状态并回退到宽松的全局 CORS 配置,这一隔离边界就会失效。
该集合仅在真实桶加载失败时记录,规模受实际桶数量约束,并通过 noteLoadFailure 与 clearLoadFailure 两个辅助函数维护。成功加载、删桶、陈旧桶清理、刷新、重置与并发加载均已补齐测试覆盖。
按解析后的 XML 语义归一化 Object Lock
任何合法且处于启用状态的 Object Lock 配置都要求开启版本控制。XML 空白字符、元素顺序以及是否包含默认保留规则(Default Retention)都不会改变这一语义。因此,状态归一化必须在解析 XML 之后执行,而不能通过与单一标准 XML 进行字节比对来判断。
开启 Object Lock 后的版本控制状态固定为纯粹的 Enabled;暂停状态(Suspended)与前缀排除扩展均与 Object Lock 不变量冲突,会在配置更新、读回和磁盘重载时自动剥离。
复杂度审计
发布前复审专门排查了过度设计、重复逻辑、缺乏威胁模型的防御性代码,以及陈旧的兼容机制。
因保护已复现故障而保留
- 单一元数据锁: 确定性的跨配置类型并发测试已复现更新丢失。
- CORS 删除墓碑(tombstone): 缺少墓碑记录时,站点复制无法区分“配置已删除”与“从未配置”。
- CORS 加载失败状态: 预签名 URL 提供了已通过认证但绕过桶策略的具体反例。
- 两级复制信任判定: 普通复制与副本密文/SSE 透传具有不同的请求特征与权限要求。
- 鉴权后清洗请求头: 在 SigV4 验签前剥离请求头会破坏合法的签名请求。
- 多存储池与
null版本边界测试: 单池常规路径无法覆盖它们捕获到的跨池状态选择缺陷。
删除或收窄的复杂度
- CORS 失败集合的修改统一收口到
noteLoadFailure与clearLoadFailure。 - 站点复制导入路径改为仅应用变更字段,不再整条覆盖可能过期的记录。
- 删除废弃的加密辅助函数、无调用的事件目标函数与死代码分支。
- 兼容性守卫不再逐一枚举所有导出的源码符号,改为保护实际对外暴露的路由及冻结的协议/配置表面。
- 移除旧的
wait_pipe静态检查豁免,并以gomodguard_v2替代已弃用的配置。 - 动态超时测试不再从并行测试包中调用全局
rand.Seed。 - 服务端依赖从临时
silo-go分叉切回经审查的上游兼容minio-go版本。
刻意没有引入
- 不引入通用的元数据事务框架;
- 不引入第二套 CORS 缓存或无界的负缓存;
- 不新增公开的“可信复制”请求头;
- 不设置让服务端发布受制于后续 Console 或文档站发布的跨仓库门禁;
- 不在候选版本中塞入半成品的条件删除契约;
- 不在缺少专项收敛测试的前提下重写继承的站点复制状态寄存器。
延期事项,以及为什么严重度不同
| 事项 | 分类 | 20260903 发布决定(及后续状态) |
|---|---|---|
| 条件删除 #10 | 继承的 S3 功能缺口;影响假定服务端会强制校验 If-Match / 单对象 ETag 的调用方 |
0903 发布说明中明确标注;单对象条件删除已随 20260916(#145)交付,批量 ETag 条件删除仍待实现 |
| 多站点配置删除 #77 | 桶策略、SSE、标签与配额的跨站点删除收敛缺陷;CORS 已使用独立的修复寄存器 | 0903 作为多站点删除配置时的部署注意事项;已随 20260916 修复 |
ListMultipartUploads #79 |
继承的列表格式一致性缺口 | 作为已知问题记录,不影响常规分片上传的数据完整性 |
联邦 CopyObject #99、#100 |
旧版联邦后端的校验和与内联小对象兼容缺口 | 0903 仅影响特定联邦特性;已随 20260916 修复 |
| ILM 迁移 PR #60 与通用 SSE 议题 #61 | 新功能需求 | 不属于本次发布的安全与稳定性范围 |
证据
完整验收树
全量本地验收基于提交 ebac0ca73bbf251b070bb6df4d8005015841f901:
- 完整的
cmd与internal测试套件通过; - 完整的
cmd并发竞态套件(-race)耗时 365.448 秒,全部通过; - 静态检查(lint):0 告警;
- 品牌重命名/兼容性守卫与生成文件检查通过;
govulncheck扫描无可达漏洞;- 六种
make verify部署拓扑验收:174 PASS / 0 FAIL。
前两次运行 make verify 在拉取 mcli 时因网络代理与环境准备中断,并非测试用例失败。最终通过的运行使用了本地固定校验和的 mcli 二进制,保留访问 GitHub 的出站代理、对 localhost 绕过代理,并将 GNU 工具链置于 PATH 首位。
验收后的候选
全量验收之后唯一的代码改动是 84e1580a4:在按需重载桶元数据成功后清除对应的 CORS 加载失败标记。随后的候选合并未改动业务代码,6e112d185 仅更新了 Helm Chart 发布元数据与文档。最终候选提交通过了以下门禁:
git diff --check;- CORS 与 Object Lock 定向
go test -race; - 品牌重命名与兼容性守卫(
rebrand-guard); - 生成文件一致性检查;
- 静态检查(lint)0 告警;
- Helm Chart 语法检查、默认与可选模板渲染、Chart 打包,以及七类资源的旧版升级身份守卫。
Go、No-Go 与发布门禁
代码结论:GO
两轮复审确认的代码缺陷在候选版本中均已闭合。各项修复直接作用于对应不变量所在的模块边界,保留的防御机制均有可复现的故障反例支撑。
生产发布门禁
在正式发布 RELEASE.2026-09-03T13-18-01Z 前,候选版本依次通过了七道发布门禁:
- 候选提交推送至远端并完成代码审查;
- 远端 CI 与测试发布流水线在最新提交上全部通过;
- 预发布标签精确指向经审查的 Chart 7.0.2、Server 0903 与 Client 0903 代码树;
- 构建产物、校验和、SBOM、构建证明(attestation)与签名 RPM 包全部通过校验;
- 正式发布与容器镜像流水线完成 classic 与 distroless 两种镜像的构建与推送;
- 匿名下载与镜像拉取验证通过;
- 基于正式标签更新发布说明并完成文档站部署。
20260903 部署注意事项
升级至 20260903 时需满足以下部署前提:
- 在同一维护窗口内完成分布式集群所有节点的同步升级;
- 在启用桶级 CORS 配置前,先升级站点复制组内的全部成员;
- 核对并更新涉及
s3:DeleteObjectVersion及用户/组状态启停权限分离的 IAM 策略。
重启与读回验证(2026-09-11,#116)
后续的 #116 专项验收(2026-09-11 执行)测量了集群在有界重启窗口内的写入确认与读回一致性,验证方法涵盖四个要点:
- 确认台账。 每次返回成功的 PUT 都写入带版本的唯一对象键,并在收到响应时立即记录
VersionId、字节数与 SHA-256。后续读回按精确的VersionId获取对象并同时校验三项指标,防止早期确认的写入被后续写入静默覆盖。 - 读回时机。 数据探针(canary)写入成功后,分别在 15 秒、30 秒和 60 秒经由所有对端节点周期性重读;最终检查同时覆盖单节点中断期间写入的对象及其重新加入集群后写入的对象。
- 禁用重试掩盖。 每次探针操作设置 60 秒硬超时(涵盖连接建立、请求发送、响应体读取与等待间隔),并关闭客户端 SDK 自动重试,防止恢复窗口内的短暂失败被客户端重试掩盖。
- 容器拓扑。 单机运行四个拥有独立网络标识的 Linux/arm64 容器,每节点挂载一块盘(EC 2+2);
tmpfs卷由占位容器在整个停机窗口内保持挂载;四个节点以 10 秒宽限期并行优雅停止,随后并行启动。
管理接口就绪不代表数据面就绪。 原始测试记录对比了 0806、0903 与当时的 main 修复构建。main 构建在重启后 2.461 秒通过管理接口检查,但随后又经过 14.489 秒才完成首次数据探针读写。在该启动窗口内,admin/health 探针无法反映数据面状态,滚动升级脚本必须验证实际的数据面读写,而不能依赖固定时长的等待。
后续各项修复的交付状态见组件与源码矩阵。
17 - 多池覆盖亲和:为何最终没有采纳 #245
2026 年 10 月 24 日决定:不合并本次测试的多池覆盖亲和补丁(PR #245)。 该方案解决了跨池覆盖遗留旧副本的空间占用问题,但无论完整策略还是仅限普通无版本覆盖的收窄策略,都未能在性能测试中达到可接受的权衡。两个实验分支(codex/healthy-pool-affinity 与 codex/unversioned-pool-affinity)已保留归档,服务端 main 分支保持原状。
Denis Babchuk 提出的思路是:覆盖已有对象时,优先写回已存放该对象的池。代码评审发现并修复了请求级写 quorum 判断缺陷,收紧了健康池判定规则,并通过了功能验证。但随后的性能实测改变了结论:完整策略使深版本历史的写入成本翻倍;第二版排除版本桶后,普通小对象覆盖写入仍有明显退化。
后续的多池删除修复(#257)已于 10 月 24 日独立合并:在保留按容量加权分配的前提下,先清理非权威池中的旧副本,再删除决定当前读取结果的权威副本,防止删除中途报错导致旧值暴露。
问题背景:跨池覆盖会在旧池留下过时副本
假设无版本对象 reports/latest.json 位于池 A,下一次 PUT 将新内容写入池 B。后续读取仍会选出池 B 中最新的逻辑对象,但池 A 中的旧物理副本会继续占用磁盘空间——普通覆盖写成功后,并不会跨池清理其他池里的旧副本。
基线代码在查找到可读的既有对象后,仍会再次进入按剩余容量加权的选池流程。视各池使用率不同,反复覆盖同一个 key 最终可能在两个池中都留下池级记录(xl.meta)。
这一行为源于如下历史演进:
- 上游 MinIO issue #21087 报告单池上传可能绕过最小剩余空间阈值;2025 年 7 月 19 日合入的 PR #21115(
83b2ad418b)为可读旧对象的覆盖写也强制执行了容量选池,从而丢掉了原有的原池亲和性。 - SILO issue #199 与 PR #207 修复了跨池当前逻辑对象的条件写校验,同时保留了按容量加权的分配策略。
- 池迁移通过独立机制防止旧内容覆盖新内容及删除标记丢失,详见多池对象一致性。
覆盖亲和的空间收益主要集中在多池部署下对同一批无版本 key 的高频覆盖。单池部署不存在跨池重复;新 key 仍需按容量跨池分配;版本桶则必须保留所有历史版本,集中到单个池并不会减少需保存的数据量。
原始 PR 的设计方案
提案复用了现有的对象查找和锁边界:若最新可读对象已存在,则在满足写 quorum 和硬空间阈值的前提下优先选择其所在池;新 key 继续按容量加权分配;当原对象所在的纠删码组无法接受写入时,先排除该池再从其余池中分配。
生产代码修改集中在 cmd/erasure-server-pool.go(新增 73 行、删除 3 行,附带 331 行放置测试),作用于进入共享选池路径的 PUT 和分片上传初始化。
选池过程涉及两条容量规则:
- 软分配预留(15% reserve):存在可用替代池时,分配器会过滤掉使用率超过约 85% 的池,优先填充较空的池。
- 硬空间检查(
hasSpaceFor):结合写入大小、单盘容量及diskFillFraction(0.99)执行硬性门槛检查。
当原池健康且有足够空间时,亲和策略会绕过软分配预留,同时保留硬空间检查。代价是在集群扩容后,既有热点 key 会继续留在较满的旧池中更新,而不会随覆盖写自动迁移到新池。
如果只是简单删掉“已有对象重新选池”的分支,就会丢失 83b2ad418b 引入的容量检查与故障回退。仅检查剩余空间同样不够:当某个纠删码组仍有足够在线盘读取旧对象、但在线盘数已不足以写入新对象时,选池必须提前识别;由于选池发生在消费上传流之前,一旦不可重放的请求体被消费,写入失败后无法无代价地换池重试。
正确性评审与实现改进
写 quorum 必须按本次请求计算
原始 helper 对所有写入一律按 STANDARD 存储类推算写 quorum。双池受控测试暴露了使用 REDUCED_REDUNDANCY(RRS)覆盖时的回归:
| 条件 | 测试设置 |
|---|---|
| 存储策略 | optimize=capacity;STANDARD=EC:4,REDUCED_REDUNDANCY=EC:2 |
| 已有对象 | 使用普通 STANDARD 奇偶校验创建 |
| 原对象所在组 | 16 盘中 13 盘在线,已用空间 90% |
| 另一池 | 16 盘全部在线,空间充足;RRS 探针写入成功 |
| 新请求 | 通过 PUT 或新分片上传初始化发起 RRS 覆盖写 |
由于校验片越少、数据片越多,RRS(EC:2)实际需要 14 块在线盘才能写入,而原始预检查按 EC:4 只要求 12 块写盘,误判原池可写,最终在写入阶段因盘数不足返回 InsufficientWriteQuorum(映射为 HTTP 503)。基线代码通过 85% reserve 过滤掉已用 90% 的原池,路由到健康的第二池并全部成功;原始 PR 在两个原池位置 × 两种入口的 4 个场景(重复 3 轮共 12 次尝试)中全部失败。
PUT 的内部 MaxParity 选项还会触发反方向的误判:实际允许使用 EC:8 的写入被按 EC:4 评估,导致不必要的跨池迁移。
仅优先选择完全健康的原池,并明确回退规则
改进后的实现将请求的存储类和 PUT MaxParity 一并传入选池逻辑,严格对齐写入路径的 availability / capacity 校验片计算规则(包括数据片与校验片相等时额外要求的写盘数),并保持分片初始化的既有 parity 语义:
| 原对象所在的目标纠删码组 | 处理方式 |
|---|---|
| 完全健康、满足本次请求的写要求且空间充足 | 优先留在原池 |
| 已降级,但仍可写且空间充足 | 走原有容量分配器,原池仍作为候选池参与筛选 |
| 已知不可写或空间不足 | 在 reserve 过滤前排除原池,再从其余池中选择 |
| 其他池均无空间,原组有空间但不足写 quorum | 交由原组返回真实的 InsufficientWriteQuorum,避免误报 StorageFull |
| 所有池均无可用空间 | 保留 StorageFull 错误 |
健康度与容量检查共享同一次组级 DiskInfo 快照:只有在线、查询成功且总容量为正的磁盘才计入可用写盘;DiskInfo 调用失败的磁盘一律按不健康处理。
回归测试同步覆盖了请求级 parity、已知坏盘、降级组、扫描次数、异构组大小、最新对象选取以及既有分片上传会话,并为分片上传回退测试配置了独立的初始对象(避免前置 PUT 先一步把最新副本迁走而削弱测试前置条件)。
第二版设计:仅对普通无版本覆盖启用
第一轮性能测试暴露出深版本历史的元数据重写放大后,第二分支(codex/unversioned-pool-affinity)将亲和范围收窄至普通无版本覆盖写:
| 请求或桶状态 | 收窄版行为 |
|---|---|
替换既有可读对象的普通无版本 PUT |
启用上述健康原池优先规则 |
| 覆盖普通无版本对象的新分片上传 | 仅在初始化进入选池时启用;保留已有上传绑定 |
| 已启用或已暂停版本控制 | 保持基线分配策略 |
指定显式版本 ID(含 null) |
不启用亲和 |
| 复制、复制锁协调或数据迁移 | 不启用亲和 |
| 内部元数据桶 | 不启用亲和 |
| 新 key 或单池部署 | 保持原有路径与容量行为 |
| 既有分片会话、普通 COPY 路由、最新删除标记、读 quorum 错误路由 | 保持各自原有路径 |
资格判定同时检查桶的版本控制配置与请求选项:启用了版本控制但配置了排除前缀的桶,或处于版本暂停(Suspended)状态、带有混合历史版本的桶,均继续使用基线分配器。
各阶段功能验证
| 阶段 | 验证结果 | 覆盖范围 |
|---|---|---|
| 原始 PR 初审 | base/head 对照复现正常亲和、STANDARD 回退、RRS 写入失败及 MaxParity 偏差 |
macOS 对象层单元测试 |
| 改进后的完整实现 | Go 全量测试:52 个包通过,1,823 个顶层测试(含子测试共 8,233 个)通过,12 个跳过;定向 -race:35 个顶层测试(含子测试 201 个)通过 |
隔离 APFS 测试卷上的完整亲和分支 |
完整版 mcli 验证 |
使用 mcli 20261018 验证普通覆盖读回、4 个不同内容的历史版本读取、分片覆盖及校验和 |
单机本地目录部署 |
| 收窄版实现 | Linux 定向测试:37 个顶层测试(含子测试 209 个)通过,覆盖放置、parity、健康/容量回退、版本状态排除、PUT/MPU 及条件写 |
放置与覆盖定向测试集 |
| 构建与静态检查 | Linux amd64 编译、make verifiers、lint(0 告警)与空白字符检查全部通过 |
仓库静态检查 |
定向回归测试筛选正则为:
性能测试环境与指标定义
两轮基准测试均在 m3 主机上执行(AMD Ryzen AI MAX+ 395,32 逻辑 CPU,61 GiB 内存,Linux 7.0.0-38,单块 CT2000P310SSD8 NVMe SSD,XFS 文件系统)。两个存储池各使用该 XFS 文件系统上的 16 个目录(共 32 个目录,单池单纠删码组,默认 EC 12+4)。
基线与候选分支均使用 Go 1.27.3(linux/amd64,CGO_ENABLED=0)编译。服务端绑定 CPU 4–11(GOMAXPROCS=8),客户端绑定 CPU 12–15(GOMAXPROCS=4),通过 minio-go v7.4.1 走本机回环 HTTP 压测。
- 吞吐(operations/s):每秒完成的对象操作数(分片上传每次操作含初始化、2 个分片上传及完成请求)。
- P50 / P99:取各轮运行 50 / 99 分位延迟的中位数。
- CPU 成本:每完成一次操作消耗的服务端 CPU 时间。
- 块 I/O:取自
/proc/PID/io的进程级读写字节数。 - 活动桶空间:对象数据文件、元数据与目录占用之和(不含临时目录)。
第一轮结果:完整策略导致深版本历史写入显著退化
第一轮对比完整候选分支(0aa870115d7d)与基线(789a2327ba8c),共执行 54 次测量运行(完成 243,718 次操作,0 失败)。基础矩阵每项运行 3 对 15 秒测试,新 key 与 4 KiB 覆盖追加 2 对 60 秒长测,单对象版本测试固定写入 4,000 个新版本,另在存量双副本数据上执行 4 轮 ABBA 二进制切换测试:
| 场景 | 基线 → 完整版,次/秒 | 变化 | P50,ms | P99,ms | 样本 |
|---|---|---|---|---|---|
| 双池 4 KiB 覆盖,256 key、16 并发 | 236.9 → 174.5 | −26.3% | 30.4 → 74.4 | 277.9 → 307.4 | 每版 2 × 60 秒 |
| 双池 1 MiB 覆盖,64 key、8 并发 | 140.9 → 132.2 | −6.2% | 34.2 → 41.3 | 169.3 → 176.2 | 3 × 15 秒 |
| 双池 16 MiB 分片覆盖,16 key、4 并发 | 25.8 → 26.4 | +2.1% | 144.0 → 141.7 | 265.3 → 272.7 | 3 × 15 秒 |
| 双池单个 4 KiB key 覆盖,1 并发 | 135.2 → 136.4 | +1.0% | 6.9 → 6.8 | 12.0 → 10.6 | 3 × 15 秒 |
| 双池 4 KiB 新 key,16 并发 | 290.7 → 296.8 | +2.1% | 46.9 → 48.7 | 170.2 → 165.9 | 2 × 60 秒 |
| 单池 4 KiB 覆盖,16 并发 | 166.2 → 159.2 | −4.2% | 83.9 → 89.4 | 296.8 → 293.2 | 3 × 15 秒 |
| 双池单个 4 KiB key,新增 4,000 版 | 76.6 → 53.0 | −30.8% | 11.4 → 17.8 | 47.7 → 59.5 | 3 次固定操作数 |
| 已有双份副本,切换程序 ABBA | 198.6 → 202.0 | +1.7% | — | 291.2 → 290.9 | 2 × 20 秒 |
普通 4 KiB 覆盖的两对 60 秒长测分别下降 30.7% 和 21.8%,此前三对 15 秒短测也全部下降;但每次操作的 CPU 时间反而从 5.85 ms 降至 5.33 ms,单次块写入保持在约 128 KiB。
单对象 4,000 版本测试下降 30.8% 的原因则非常直接:池内 xl.meta 保存了该池内所有版本的元数据(以及小对象的内联数据),每次新增版本都要重新序列化并整文件重写 xl.meta。基线按容量分配将 4,000 个版本大致均分到两个池(每池约 2,000 个版本),而亲和策略将全部 4,001 个版本集中写入同一个池,导致每次 PUT 重写的 xl.meta 体积翻倍:
- 单次写入 CPU 耗时:26.61 ms → 39.29 ms
- 单次写入块读取:12.20 MiB → 24.42 MiB
- 单次写入块写入:12.27 MiB → 24.49 MiB(4,000 次写入上传正文共约 15.6 MiB,累计块写入从 47.9 GiB 增至 95.7 GiB)
- 4,001 版本最终活动数据量:两版均为约 48.90 MiB
- 最后 1,000 次写入平均延迟:18.4 ms → 28.5 ms
集中历史版本既不能节省空间,又让更新成本随版本深度成倍放大,因此第二版直接排除了版本桶写入。
第二轮结果:排除了版本放大,但无版本小对象覆盖依然退化
第二轮对比收窄版(codex/unversioned-pool-affinity)与同一基线(789a2327ba8c)。测试前设定的准入线为:普通覆盖吞吐损失不超过 5%,P99 延迟增长不超过 10%,且 P50 与排除范围场景无实质退化。
四个场景每版各运行 5 次(AB/BA 交替,常规场景预热 5 秒、测量 60 秒,版本对照场景固定写入 4,000 次,每轮重建服务与数据目录,关闭性能分析器插桩):
| 场景 | 基线 → 收窄版,次/秒 | 变化 | P50,ms | P99,ms |
|---|---|---|---|---|
| 新建数据集,4 KiB 覆盖,256 key、16 并发 | 167.9 → 158.2 | −5.7% | 66.2 → 75.8 | 470.2 → 428.7 |
| 新建数据集,1 MiB 覆盖,64 key、8 并发 | 79.45 → 79.58 | +0.2% | 94.0 → 95.6 | 521.5 → 483.2 |
| 单个 4 KiB key 新增 4,000 版,对照项 | 48.5 → 59.6 | +22.8% | 12.7 → 12.4 | 180.9 → 135.4 |
| 4 KiB 新 key、16 并发,对照项 | 289.8 → 243.1 | −16.1% | 40.4 → 49.2 | 314.9 → 170.0 |
在核心的 4 KiB 普通覆盖场景中,五对运行的吞吐变化分别为 +11.9%、−14.9%、−1.4%、−10.3%、−12.8%(配对中位数变化 −10.3%,两版中位数比值 −5.7%,P50 上升 14.4%)。基线自身在 141.4–202.6 次/秒之间波动(变异系数 13.0%),收窄版为 146.3–176.6 次/秒,未能满足“吞吐损失 ≤ 5%”的准入线。
收窄范围确实消除了版本历史重写放大:单版本块写入为 12.272 → 12.268 MiB,块读取为 12.201 → 12.197 MiB,CPU 为 26.98 → 27.26 ms(版本对照项中五对均为后运行的二进制更快,而未改逻辑的新 key 对照项逐对波动达 −44.3% 至 +15.3%,反映出共享单盘测试环境的顺序波动)。
固定初始数据布局的恢复对照测试
新建数据集测试中,两种策略会随运行逐步写出不同的磁盘目录布局。为排除布局演化差异,追加的 8 轮夹具测试在每轮运行前恢复完全相同的初始数据快照——“每 key 单池副本(共 256 条记录)”或“每 key 双池副本(共 512 条记录)”——每次恢复后静置 30 秒,再预热 5 秒、测量 60 秒:
| 初态,256 个 key | 基线 → 收窄版,次/秒 | 变化 | P50,ms | P99,ms | 结束时池内记录数,基线 → 收窄版 |
|---|---|---|---|---|---|
| 每个 key 一份副本 | 188.0 → 160.1 | −14.9% | 51.9 → 68.0 | 521.0 → 466.4 | 512 → 256 |
| 每个 key 两份副本 | 185.7 → 140.1 | −24.5% | 37.6 → 79.7 | 460.0 → 629.9 | 512 → 512 |
单份副本初态的两对运行分别为 −0.9% 和 −23.5%;而双份副本初态的两对运行分别下降 −22.6% 和 −26.5%,P50 暴涨 +111.9%,P99 上升 +36.9%,且磁盘上原有的 512 份记录一份也不会减少。对于已经积累了跨池旧副本的存量集群,这意味承担显著的写入性能退化却无法立即回收任何空间。
根因定位与 I/O 路径实验
阶段计时显示,新增的健康检查每次仅耗时 0.014–0.016 ms,远小于对象查找(毫秒级)和纠删码写入(数十毫秒级)。第二轮 4 KiB 覆盖的单次 CPU 时间从 5.75 ms 降至 5.36 ms,吞吐却反而下降,说明瓶颈来自存储路径上的 I/O 与调度等待。
Go 执行跟踪(execution trace)显示系统调用等待集中在 RenameData → writeAllInternal → write 以及读取和 goroutine 调度等待。
随后我们做了一项局部 I/O 实验:将小元数据写入(约 791 字节,小于 4 KiB 对齐粒度,CopyAligned 会自动关闭 O_DIRECT)从 Linux O_DSYNC 路径改走 writeAllDirect(实际等价于缓冲写加独立 fdatasync)。“收窄亲和 + I/O 修改”五轮正式运行均值达到 179.4 次/秒(对比基线 167.9 次/秒,+6.9%,P99 −1.5%,逐对波动 −19.2% 至 +35.9%)。但在两对同时给基线和候选分支都加上该 I/O 修改的对照实验中,“基线 + I/O 修改”达到 212.5 次/秒,“收窄亲和 + I/O 修改”为 185.2 次/秒(中位数差值 −12.8%,逐对 +37.7% 与 −49.1%)。由于修改小元数据同步写路径会影响全局存储引擎,且开启后亲和分支依然落后于同条件基线,该构建覆盖补丁未被采纳。
收益与代价汇总
在双池新建数据集测试中,256 个普通 4 KiB key 结束时的池内记录数从 512 份降至 256 份,活动数据逻辑体积从 6.18 MiB 降至 3.09 MiB;1 MiB 场景记录数从 128 份降至 64 份,体积从 171.56 MiB 降至 85.78 MiB。
从集群稳态看,若逻辑数据中有比例为 $f$ 的对象会在双池间产生第二份副本,消除额外副本可节省的总体空间比例为 $f / (1 + f)$(例如当 $f = 10%$ 时约节省 9.1%)。单次请求的写入量则不会减半:两版在 4 KiB 覆盖下的单次进程块写入均为 128 KiB 左右。
| 维度 | 收益 | 代价或局限 |
|---|---|---|
| 容量空间 | 避免符合条件的无版本覆盖写产生新的跨池旧副本 | 不清理存量旧副本;故障回退时仍会产生跨池副本 |
| 写入性能 | 原池健康时可省去一次全池容量筛选 | 改变落盘分布与 I/O 争用;4 KiB 小对象覆盖吞吐退化(新建数据 −5.7%,双副本初态 −24.5%) |
| 扩容与热点 | 既有对象池位置固定 | 扩容后旧池继续承接热点 key 更新,新池分担的覆盖写流量减少 |
| 协议与格式兼容 | 不改 S3 协议、配置项、依赖或磁盘格式 | 池间容量分布与原有运维预期不同 |
| 可用性与正确性 | 请求级写 quorum 与坏盘检查避免误选不可写原池 | 预检查与实际写入之间仍存在状态变化窗口 |
| 维护复杂度 | 收窄至无版本覆盖后避免了版本 xl.meta 重写放大 |
需持续同步 parity、桶版本状态、内部写、reserve 规则及 PUT/MPU 选池入口 |
| 版本回滚 | 退回基线二进制无需转换磁盘格式 | 已产生的布局变化与跨池旧副本不会自动消除 |
最终决定与后续重启条件
- 完整亲和方案(
codex/healthy-pool-affinity):因集中版本历史导致xl.meta重写 I/O 翻倍、4,000 版本写入吞吐下降 30.8% 且无空间收益而否决。 - 无版本收窄方案(
codex/unversioned-pool-affinity):虽消除了版本历史放大,但在普通 4 KiB 覆盖上仍存在 −5.7%(配对中位数 −10.3%)的新数据吞吐退化,且在存量双副本恢复测试中出现 −24.5% 的吞吐退化(P50 +111.9%)而否决。
因此,我们决定保持主线分配器行为不变,归档两轮基准测试证据(第一轮 54 次运行 / 243,718 次操作;第二轮 53 次运行 / 495,745 次操作)。后续若重新评估该问题,应先采集生产环境的真实旧副本空间占比并在多块独立物理盘拓扑上压测,再权衡是采用后台旧副本清理还是显式可选的放置策略。
源码与证据索引
公开参考资料:PR #245、上游容量修复 PR #21115、多池对象一致性,以及基线存储实现 cmd/erasure-server-pool.go、cmd/xl-storage-format-v2.go 与 cmd/xl-storage.go。
| 制品 | 精确标识 |
|---|---|
| 初次独立评审的 base / PR head | 856f8f4c5580d1c5adacbd8ef2aedf3a4fbc507c / 3a9cdc731528e31bc4f9e61256025a1baae863e8 |
| 撰文时观察到的 rebase 后 PR head | 0a6cad318409451b52dfcef18efb476c3927b6f8 |
| 两轮 m3 共同基线 | 789a2327ba8c2283cda7192eb65f8374a7f919ba |
| 完整本地分支 | codex/healthy-pool-affinity(0aa870115d7d0904791104b25ad66f3430579de4) |
| 收窄本地分支 | codex/unversioned-pool-affinity(76db3626eb9068b29e4565e78ad22f7720012e65) |
| 收窄版性能二进制对应的生产语义 | bb4b987825985481e5047f94c60f560be85b9c19 |
第二轮 results/analysis.json SHA-256 |
68eec465749b625c691abcace90fc71f103b5826ccab4c40a120f39e29ded368 |
| 完整候选补丁 SHA-256 | af833ec4b7bbbc22fca8539a6da40f4fe5fa93d61febabbd5b08a63ae1ecbe1c |
18 - 副本元数据归一化:可信复制不得重新注入什么
随 SILO Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a)发布的 PR #194(修复提交 4fcdf37ce,合并提交 9f3037e941,采纳并保留 Mikhail Khadarenka 在 PR #187 中的实现),修复了可信复制接收端在恢复副本内部元数据时重新调用宽松请求头提取器、导致仅用于传输的 aws-chunked Content-Encoding 标记与 GHSA 已脱敏的用户元数据重新回流至落盘对象中的回归缺陷。
问题根因
普通 S3 PUT 路径在落盘前会对请求元数据执行归一化处理:从 Content-Encoding 中剥离仅用于流式传输分帧的 aws-chunked 标记,并删除 GHSA-76wf-9vgp-pj7w 缓解措施要求剔除的 X-Amz-Meta-X-Amz-Unencrypted-Content-Length 与 X-Amz-Meta-X-Amz-Unencrypted-Content-Md5 用户元数据键。
然而,当可信复制接收端为副本写入恢复内部复制元数据时,却以开启“允许复制(allowReplication)”标志的方式重新运行了同一个通用宽松提取器——把原始 HTTP 请求中的全部标准请求头与所有 X-Amz-Meta-* 用户元数据重新覆盖回已归一化的元数据映射表中。结果在可信副本写入路径上,服务端可能落盘并在后续 GET/HEAD 响应中返回:
Content-Encoding: aws-chunked(按 AWS SigV4 流式传输规范绝不能作为对象元数据持久化的纯传输编码),或未经拆分的aws-chunked,gzip整串(正确落盘值应为gzip);- 上述两个在普通写入中已被 GHSA 缓解措施剔除的
X-Amz-Meta-X-Amz-Unencrypted-Content-*用户元数据键; - 对于未携带独立 PAX 头的 Snowball 归档条目:意外继承外层归档请求的
Content-Type、Cache-Control、Expires与用户元数据。
底层对象数据字节本身并未受损,但持久化的 xl.meta 元数据被污染。该回归由 56fa63bfd(2026-04-15,针对 CVE-2026-34204 的复制请求头信任边界加固)引入——该提交的认证与信任边界保护本身完全正确并予以保留。
修复方案
提交 4fcdf37ce 仅修改 cmd/handler-utils.go,删除了布尔双模式辅助函数,将普通请求头提取与副本内部字段恢复彻底拆离:
- 普通提取器无条件跳过所有复制专用内部键;
- 新增专用副本提取器(
extractReplicationMetadata),仅遍历复制头到内部头的映射表,且仅恢复 6 个复制域专用字段:- SSE-C 密封密钥材料(
x-minio-internal-server-side-encryption-ssec-key), - SSE-C 密封算法(
x-minio-internal-server-side-encryption-ssec-seal-algorithm), - SSE-C 初始向量 IV(
x-minio-internal-server-side-encryption-ssec-iv), - 加密多段上传标记(
x-minio-internal-encrypted-multipart,空值标记按键存在性生效), - 实际逻辑对象大小(
x-minio-internal-actual-size), - SSE-C 校验和恒等映射;
- SSE-C 密封密钥材料(
- 副本提取器绝不重新读取标准受支持请求头(如
Content-Encoding、Content-Type等)或X-Amz-Meta-*用户元数据。
修复后各请求编码对应的落盘结果如下:
请求 Content-Encoding |
落盘存储的 Content-Encoding |
|---|---|
aws-chunked |
(无) |
aws-chunked,gzip |
gzip |
gzip |
gzip |
注:aws-chunked, gzip(逗号后带空格)仍存储带前导空格的 gzip,gzip, aws-chunked 仍存储整串;这两者属于普通 PUT 路径既有的解析现状,由测试按现状固定断言。
运维可见变化
- 可信 Snowball 归档条目: 可信 Snowball 条目不再继承外层归档请求的普通元数据。当条目不含 PAX 记录时,不再继承外层
Content-Type、Cache-Control、Expires与用户元数据;当含有 PAX 记录时,也不再继承条目自身未声明的外层字段。条目自身的minio.metadata.*依然生效,6 个复制域专用字段仍作用于已授权条目,归档的存储类型(Storage Class)仍正常继承。(仓库内置批量生产端调用PutObjectsSnowball时虽发送自动解压标记,但外层请求并不标记为可信副本,因此未受该继承路径影响。) - GHSA 脱敏键: 在副本元数据恢复时不再写回两个未加密长度/MD5 元数据键,与普通
PUT行为完全一致。 - 不变边界: 身份认证、权限门禁与复制信任语义保持不变;普通
PUT提取路径逐字节等价。
为什么升级服务端不会自动修复存量污染对象
升级至 SILO Server 20260916 会立即阻断新写入对象的元数据污染,但不会后台扫描或改写磁盘上的存量对象。运维中需注意两点影响:
- 权威源端仍被污染时的反复同步: 若权威源对象仍带有被污染的元数据(例如来自早期的双向复制回流),在与已归一化的目标副本比对时会持续检测到元数据差异,导致在自愈(heal)或重新同步(resync)中反复触发元数据复制。务必先修复权威源端的对象版本,再让下游副本收敛。
- 普通 S3 自
COPY的局限性: 普通 S3 向自身发起CopyObject并非通用的存量修复 API,因为它在版本化桶中会生成新版本或更新时间戳,而不会原地改写指定历史versionId的元数据。
只读副本元数据审计手册提供了可执行的存量清单扫描工具与分类规则,用于在改动前盘点受影响的对象版本。
存量元数据修复方案设计原则
针对审计手册识别出的历史受污染对象,制定修复流程时应遵循以下安全边界:
- 构建包含所有非当前历史版本的完整清单(不能仅扫描最新版本);
- 通过比对可信源版本或独立校验和来核验真实内容编码——绝不凭被污染的响应头猜测,也绝不因为头部写有
gzip就盲目执行解压重传; - 优先处理权威源端的精确版本,再让副本自动收敛;保留不可变清单与元数据备份,在小批量验证并演练回滚后再推广;
- 修复操作必须保持版本 ID 与当前版本关系、Object Lock 保留期与法务锁定、对象标签、复制状态及加密上下文不变;对缺乏受支持修复路径的对象保持原状,严禁直接手动编辑磁盘上的
xl.meta文件。
已知范围边界
- 浏览器
POST表单上传路径(cmd/bucket-handlers.go)直接调用底层提取器,原本即未对aws-chunked执行编码归一化;该路径不属于复制接收端,作为独立跟进项记录。
验证
回归测试(TestExtractReplicationMetadata*、TestAPIReplicaContentEncoding、TestAPISnowballReplicaContentEncoding 以及开启 -race 的复制信任与 SSE-C 往返测试)在真实单盘与 16 盘纠删码后端上覆盖了映射表、6 个复制恢复字段与普通路径等价性(共 64 个 HTTP 叶级用例:44 个对照通过,20 个缺陷用例在修复前失败、修复后通过,外加 16 个辅助函数单元测试)。
升级摘要见组件版本矩阵;姊妹修复见复制标签排序。相关设计记录:复制标签排序 · 副本元数据归一化 · 请求头超时防护 · 副本元数据审计手册。
19 - 为 SILO 提供多语言支持
Eduardo Andrade Machado 的 Console PR #83 提供了巴西葡萄牙语翻译。siyahkut 在 silo#277 中请求土耳其语。这两项贡献推动 Console 扩展为可配置的十语言界面。
**截至 2026-11-09 的状态:**十语言界面、审校后的译文、逐语言压缩、静态全量加载和长期缓存已合入 Console main,并随 Console 2.7.0 发布。最终复核保留了紧凑标签要求,并检查了相关控件在手机、平板和桌面宽度下的显示。源码合入、打标与正式产品发布仍是不同阶段。
决定
每种翻译语言生成一个预压缩 JavaScript 资源,全部词典静态导入,各自通过内容哈希独立缓存。 分包不意味着懒加载。应用加载完成后,切换语言同步完成,不再请求网络,离线也能切换。
九份翻译词典逐语言执行 gzip -9 后合计 555,105 字节,合并压缩则为 549,712 字节:分包只增加 5,393 字节,约 1%。这个体积对管理控制台可以接受。我们承担包括英文用户在内的首次下载成本,换取无需语言加载状态、超时、重试和迟到响应协调的简单实现;独立文件则让单种译文更新后能复用其他语言的缓存。
英文始终是默认语言,也是唯一的缺词回退语言。中文由第一方维护,必须完整。其他语言更新期间可以暂时回退英文。只翻译界面,服务端消息和日志内容原样展示。本次决策覆盖 Console,不扩展为服务端、命令行工具或整套文档的多语言工程。
为什么增加语言
管理控制台要求用户理解访问策略、保留设置、复制和危险操作。熟悉的语言有助于理解这些控件,也能让更多运维人员使用产品。葡萄牙语和土耳其语已经有具体的社区贡献或需求;统一注册表可以让后续语言沿同一条路径接入。
持续成本比增加一个菜单项更大。英文文案变更需要补译,存储术语需要保持一致,长文本和 RTL 文字影响版面。如果译文把保留或删除警告的意思翻反,危害比清晰的英文回退更大。词典条目齐全并不能证明语言质量合格。
因此,设计需要限制范围、区分维护承诺,并自动检查结构问题。安装和运行 SILO 不需要新增翻译服务、下载模型或访问外部运行时依赖。
已定决策
- 先合并 #83,再提交维护者调整,保留原作者署名。
- 统一使用下拉语言菜单,并提供 KBar 语言命令。
- 支持英语、中文、法语、德语、俄语、西班牙语、巴西葡萄牙语、日语、阿拉伯语、土耳其语。
- 默认英文,不探测浏览器语言,保留用户显式选择。
- 中文覆盖率阻断 CI,其他语言报告覆盖率。
- 全部词典启动时加载,构建产物按语言分包并预压缩,通过内容哈希与长期缓存独立复用。
- 阿拉伯语同时实现 RTL 布局,协议数据仍从左到右展示。
原有设计
英文原文就是词典 key。原中文词典分为 zh、zhHelp、zhScreens,合并优先级为外壳文案 > 功能页面 > 帮助。原先所有用户都会下载登录页和 Console 包中的中文词典。
保留英文原文 key,可以直接得到可读的回退文案,也无需仅为引入消息 ID 而重写数千处调用。代价是修改英文措辞就意味着迁移 key:审查时必须有意识地更新词典,不能把英文润色视为与翻译无关的改动。
代码中还有数字/日期格式、链接、间隔和语言按钮的专用分支。五处数量显示按英文单复数选择,不能覆盖俄语、阿拉伯语的复数规则。
PR #83
贡献包含 2,249 个词条和葡语专用的异步加载器,将双语切换改为英语 → 中文 → 葡语循环。翻译工作值得接纳,但选择器和加载方式需要成为通用能力,不能持续增加葡语专用分支。因此先合并 #83、保留贡献者原始提交,再调整注册表和界面。
原加载器在功能页面词典失败时,可能已注册公共词典却不通知已挂载组件。最终静态实现彻底消除了这类失败路径,同时删除 catalogRevision、首屏等待、重试状态和葡语专用加载器。贡献中的组件文案回归测试保留,改为验证同步切换与离线行为。
体积与打包
测量口径
以下数据来自 2026-11-09 的生产构建,基于完成译文及紧凑标签复核后的 Console 提交 b0371b6。未来文案变化后需要重新测量。十种可选语言对应九份翻译词典:英文直接使用源码 key,不需要重复词典。KB 按 1,000 字节、MB 按 1,000,000 字节计算。
九个生产词典资源压缩前合计 1,863,188 字节。生产流水线固定使用 fflate 0.8.3、level: 9、mtime: 0,得到 568,470 字节。优化后的完整嵌入 GUI 为 2,357,925 字节,相对合入葡语 PR 后的 1,905,708 字节增加 452,217 字节(23.7%)。这里统计的是嵌入 Web 资源,不是整个 Go 可执行文件。
登录首屏 JavaScript 合计 794.08 KB gzip,预算为首屏依赖图 850 KB、全部语言数据 630 KB、每个分包 350 KB。检查器会累加所有语言包,要求每个语言包都属于首屏静态依赖图,同时执行单包上限;不能靠拆成多个小文件绕过体积限制。
合成一包,还是每种语言一包
对比保持生产源码、压缩混淆方式和静态导入不变,只调整构建分组:全部词典一个组,或者每个语言目录一个组。每个语言组都包含 chrome、help、screens 三部分。然后分别用本机 gzip -n -9 命令和现有固定版本压缩器处理生成的 JavaScript。
| 打包方式 | gzip -n -9,字节 |
fflate level 9,字节 |
冷启动语言资源请求数 |
|---|---|---|---|
| 所有语言合并 | 549,712 | 562,448 | 1 |
| 每种语言独立,合计 | 555,105 | 568,470 | 9 |
| 分包额外成本 | 5,393(0.98%) | 6,022(1.07%) | 8 |
相同压缩级别不保证不同实现输出相同字节。系统命令用于对比,固定生产压缩器用于保证不同构建主机上的结果可复现。两种实现得到相同的打包结论,因此不需要为此更换压缩器。
| 语言 | gzip -n -9,字节 |
|---|---|
| 中文 | 58,319 |
| 法语 | 60,684 |
| 德语 | 60,717 |
| 俄语 | 70,315 |
| 西班牙语 | 59,296 |
| 巴西葡萄牙语 | 57,689 |
| 日语 | 63,159 |
| 阿拉伯语 | 64,095 |
| 土耳其语 | 60,831 |
合并文件并不会自动消除跨词典重复的所有英文 key。DEFLATE 只能引用前面 32 KiB 窗口内的文本,而单种语言的完整词典远大于这个窗口。这解释了为什么不能预设跨语言合并会带来巨大压缩收益;上表给出了实际构建的结果。参见 RFC 1951 第 2 节。
缓存收益与请求代价
另一次构建只修改了一句土耳其语 UI 译文。分包后,只有土耳其语语言资源的哈希变化,其他八个保持不变;合并时,整个语言包的哈希变化。若浏览器保留了有效缓存,这次修订后需要下载的语言数据按生产压缩器计算约为 63 KB,而不是 562 KB。入口模块也可能变化,因此这不是整个应用更新流量的比较。如果所有语言一起修改,独立复用的收益也会明显下降。
代价是冷启动多八个请求。全部语言仍在应用使用前下载和初始化;分包不会减少首次传输总量、词典内存分配或 JavaScript 解析工作。高延迟 HTTP/1.1 连接可能放大额外请求的成本。本轮测量证明的是字节体积与哈希稳定性,没有测出加载时间或内存占用的改善。
独立缓存还需要明确的服务端策略。早期合并语言包的实现设置了 Content-Encoding: gzip 和 Vary: Accept-Encoding,但没有明确的长期 Cache-Control。现已落实以下策略:
- 对确实存在、带内容哈希且与用户无关的 JavaScript 和 CSS 资源设置
Cache-Control: public, max-age=31536000, immutable。 - HTML 入口设置
Cache-Control: private, no-cache,重新验证以发现新资源名称。不存在的资源路径回退到 HTML 时不会继承长期缓存;API 的缓存策略继续由各自处理器控制。 - 构建时预压缩,服务时直接发送已有 gzip 内容。
.js.gz是采用 HTTP 内容编码的 JavaScript 资源,不是要求用户手动解包的归档文件。
这一策略遵循带版本资源的 HTTP 缓存原则。使用实际 Go 静态资源处理器的浏览器检查确认:首次访问加载九个生产语言文件,刷新时九个全部复用缓存;十种语言可离线切换,并保留未保存输入。该资源检查的 API 响应使用受控测试数据,不是整套 SILO 部署的验收测试。
为什么仍然全量加载
约 0.57 MB 的压缩词典对当前 Console 可以接受。随应用加载全部词典,可以消除切换语言时的另一组失败路径:无需协调部分词典到达、加载提示、首屏宽限时间、重试循环和过期响应。应用加载完成后也能离线切换语言。这不意味着首次加载前即可离线使用 Console,也不意味着存储操作不再依赖服务端。
成本依然存在:英文用户会下载可能永远不用的翻译,所有用户都要解析并保留这些词典。如果以后语言语料显著增长、首屏依赖图超过预算,或目标网络与设备的实测发现问题,再讨论选择性加载。当前仅为了更小的字节数,还不足以引入这套运行时复杂度。
设计
范围
翻译静态 UI 文案、帮助主题、控件无障碍名称、日期和数字格式、图表图例前缀。服务端错误、日志、审计、Trace、健康报告数据、对象名称、路径、配置标识符、策略 JSON 和 RSS 内容保持原样。文档站仍为中英双语:中文使用中文链接,其他语言回退英文链接。
语言注册表
src/i18n/locales.ts 管理 LOCALES 与 CATALOGS。元数据记录语言自称、BCP 47 标签、方向和维护级别。CATALOGS 的类型为 Record<Lang, Dictionary>,新增注册语言后,类型检查会要求补上词典映射。
这里的可配置是源码中的构建配置,不是管理后台设置,也不是远程语言包服务。组件从注册表读取菜单、格式和方向,避免继续增加语言专用的条件分支。例如:
| ID | 菜单名称 | 方向 | 级别 |
|---|---|---|---|
| en | English | LTR | source |
| zh | 简体中文 | LTR | core |
| fr | Français | LTR | maintained |
| de | Deutsch | LTR | maintained |
| ru | Русский | LTR | maintained |
| es | Español | LTR | maintained |
| pt-BR | Português (Brasil) | LTR | maintained |
| ja | 日本語 | LTR | maintained |
| ar | العربية | RTL | maintained |
| tr | Türkçe | LTR | maintained |
已有 zh 偏好不变。早期测试版的 pt 映射为 pt-BR;未知或未启用的值回退英文。未完成的语言可以通过 enabled: false 从菜单排除,但隐藏菜单项本身不会从构建中移除静态导入。
词典布局
每种语言在 src/i18n/locales/<id>/ 下保留 chrome.ts、help.ts、screens.ts。这三个文件用于组织翻译,不代表加载阶段。字面量 TypeScript 格式保留译者注释,支持静态校验。合并优先级仍为 chrome > screens > help。
加载
每种语言的三个部分全部静态导入。浏览器在应用启动时加载这些 JavaScript 依赖,渲染和后续语言切换不用等待另一轮语言请求。没有语言请求 API、Promise 缓存或启动计时器。共享下拉菜单也静态导入,因此应用首次加载完成后,第一次点击菜单即可离线选择语言。既有路由拆包,以及编辑器、图表、PDF 等大型组件的加载方式,与语言切换相互独立。
格式与链接
组件读取注册表中的格式化标签和站点语言字段。整句模板决定标点和间隔。MDS ControlLabelsProvider 提供 Actions 标题,删除 CSS 生成中文的做法。
语言菜单
登录、匿名页面和 Console 复用地球按钮与下拉菜单。每项用语言自称,并带对应 lang、dir;标记当前语言,键盘选择后将焦点还给按钮。KBar 提供同一组语言命令。
十种语言继续循环切换,会迫使用户经过多个看不懂的界面才能找到自己的语言。下拉菜单允许直接选择,也能随语言数量增长。使用语言自称,即使当前界面陌生,用户也能认出目标语言;无需用国旗代表语言。
复数
tn(count, one, other) 使用 Intl.PluralRules。多形式译文放在英文复数 key 下,包含该语言使用的全部类别。缺词时按英文规则选择英文原文,不将俄语或阿拉伯语规则应用到英文回退文案。
字体
中文和日语避免拉丁大写字距,阿拉伯语使用正常字距。非拉丁文字依靠系统回退字体,不增加字体下载。德语标题和控件允许断词。
紧凑措辞是界面要求。提高准确性时,不能把已经解决的长标签溢出问题带回来。例如,西语 Tiempo activo、法语 Temps actif 和葡语 Tempo ativo 保留 Uptime 的时长含义;纠删码健康标签可用 EC 缩写;生命周期过期标签仍需指明历史版本。应在真实指标卡和表单中审核这些文案,不能只看词典表格。
从右到左布局
间隔、对齐、边框和移动抽屉改用逻辑 CSS 属性。方向图标、标签页方向键和开关滑块随 RTL 改变。对象路径、桶名、ARN/JSON、策略编辑器和原始 Trace/日志数据保持 LTR。图表与物理视口坐标不做机械镜像。
维护分级与检查
中文完整覆盖是硬门槛。其他词典缺词时报告并回退英文,暂时的缺词不阻断无关发布,但仍需要持续审校和修正,所有词典也都必须通过结构校验。审校后的词典每种语言三部分合计 2,252 个词条,对应 2,235 个不同的 key;这是 key 覆盖统计,不是语言熟练程度证明。
两级翻译维护策略反映维护责任。核心团队承诺中文完整,其他语言通过贡献持续演进,不暗示团队有能力亲自保证每种语言的母语质量。英文是唯一回退语言,法语缺词时不会突然显示中文。
占位符错误、空译文、复数类别错误、重复或错分段 key、无效语言标签、注册表与目录不匹配都会导致校验失败。原有中文孤立词条和重复项基线是历史例外,不允许扩充以掩盖回归。
翻译生产
新增词典采用 AI 辅助草稿,再按术语、UI 上下文和危险操作说明校对。术语表覆盖核心存储与访问控制概念,审核重点包括否定关系、权限、不可逆操作、保留期限、占位符和协议标识符。完整覆盖不等于母语审校,AI 复核也不能证明已经达到这一标准。首轮审核与随后的紧凑标签复核已完成,较长帮助段落继续接受修正。
贡献指南说明了 i18n:report:导出缺词及中文参考、源码位置;以及 i18n:rename:在词典间迁移经过确认的英文 key 改动。翻译工具和服务不是构建或运行时依赖。
版面检查
真实语言在 350、768、1440 像素宽度检查登录页和密集的站点复制表单。额外覆盖键盘操作、偏好持久化、所有语言离线切换、未保存表单、RTL 抽屉/标签页,以及日志和路径原文不变。
同样的三种宽度也覆盖每种语言的真实指标行组件和批量生命周期对话框。检查拒绝标签与数值重叠、字段文字裁切或横向溢出,并保留紧凑指标布局,为后续译文调整守住版面约束。
开发专用 en-XA 将文案拉长约 40%,ar-XB 检查 RTL。两者不进入公开菜单或生产词典。词典校验、类型检查、全量前端单元测试和生产包预算共同补充浏览器验收。
考虑过的替代方案
| 方案 | 收益 | 代价与决定 |
|---|---|---|
| 只保留英文与中文 | 翻译维护负担最低 | 无法回应葡语贡献和土耳其语需求,不采用。 |
| 合并一包,全量加载 | 只有一个语言请求,gzip 总量最小 | 任一语言修改都会使所有翻译数据的缓存失效;是合理的简单基线,但分包只多约 1%。 |
| 按语言分包,全量加载 | 独立复用缓存,保留现有同步运行方式 | 冷启动多八个请求,首次词典工作量不减少;采用。 |
| 按语言分包,懒加载 | 英文可以不加载词典,其他用户只取需要的语言 | 增加部分加载、失败、重试与状态协调路径,暂缓,等实测证明有必要再引入。 |
| 引入完整翻译框架 | 更丰富的消息工具和生态集成 | 增加依赖与迁移工作,现有原文 key 查找、Intl、React/Redux 已满足需求。 |
按语言懒加载曾在评估中实现,维护者选择静态方案后将其移除。独立 gzip 文件仍然保留了以后调整加载方式的自然边界,不必现在就引入相关状态。
交付
PR #83 已保留原始历史合并,初版十语言实现与嵌入 GUI分别提交。最终交付包含审校译文与紧凑布局检查、逐语言资源与缓存策略,以及重新生成的嵌入 GUI。
最终源码通过 789 项前端单元测试、Go API 测试、类型检查、格式检查、未使用代码检查和生产包预算。词典修订既要进入 TypeScript 文件,也必须进入实际发布的 GUI。
验收覆盖缺词回退、占位符和复数、语言偏好持久化、首次加载后的离线切换、未保存表单、阿拉伯语布局、原始数据不变,以及只修改一种语言后的缓存复用。双语设计记录由伴生站点承载;源码合入、生成资源、文档发布和产品发布仍是不同交付事项。
20 - Multipart 提交失败可能让上传卡死
本记录说明在下一版本地验收中发现的一个缺陷。它在 69fcf50cd 上能复现,继承自上游 MinIO。目前还没有对应的 GitHub issue。
**状态:**已分析并复现。起草过一版修复,但对抗评审认为它对本版风险太大,予以否决。修复推迟到下一版,与存储操作的完成确认(fencing)一起做。下一版把它列为已知问题。
简短回答
CompleteMultipartUpload 没有达到写 quorum 时,这个上传可能变得无法使用,即使之后所有盘都恢复正常:
- 再次 Complete 返回 503;
- 用同一个 upload ID 重新上传分片返回 503;
- ListParts 列不出任何分片。
上传会一直保持这个状态,直到过期清理把它删掉,客户端只能新建上传、重新发送所有分片。如果 rename 成功的盘数超过校验盘数,已上传的数据甚至无法重建。还有一种变体:某块盘执行了 rename,但应答丢失,重试会返回 404 NoSuchUpload,客户端可能把它理解成“之前的 Complete 已经成功”。
这个缺陷不影响已提交的对象。受损的是进行中的上传,而且只在提交时恰好遇到盘故障时发生。
把分片移回、恢复上传元数据的小修复能让复现测试通过,但它会和迟到的存储操作、以及过期上传清理发生竞争。在这些竞争下,它可能发布一个没有数据的对象,或者把一块没有分片的盘算作写入成功。那会把一个可用性缺陷变成数据缺陷,所以不进本版。
**临时办法:**如果 Complete 返回 503,并且在集群恢复正常后仍然失败,就中止这个上传,重新上传对象。
定性
| 方面 | 评估 |
|---|---|
| 性质 | multipart 提交的失败路径丢弃了上传。继承自上游 MinIO,从 2020 年起就存在。 |
| 影响 | 提交时遇到盘故障的上传必须从头重传;在应答丢失的变体中,还可能看起来已完成,实际没有。 |
| 数据安全 | 这个缺陷不删除已提交的对象。rename 成功的那些盘上,进行中的上传数据会丢失。同一撤销步骤里还有一个更早就存在的缺口(T4),可能丢失与旧版本并存、被覆盖的 null 版本。 |
| 严重度 | S2:可用性和重试缺陷,需要 Complete 期间出现盘故障。 |
| 工作量 | 安全的修复属于中等:它依赖存储操作的 fencing。 |
何时发生
Complete 原地把上传 rename 成对象。以 4 块盘、EC 2+2(写 quorum 3)为例,下列情况会触发:
- rename 在 2 块盘上失败,在另外 2 块上成功(权限错误、盘满、盘故障);
- 一块盘离线,另一块盘上 rename 失败;
- 一块盘执行了 rename 但应答丢失,另一块盘上 rename 失败。
如果只有一块盘的 rename 失败,quorum 仍然达到,提交成功。如果失败的盘不止一块,但上传仍保留在足够多的盘上,那么第一次重试会返回 400 InvalidPart,只有重新上传分片才行。
根因
行号对应下一版的本地集成;69fcf50cd 上是同一条代码路径。
- 提交之前,Complete 在每块盘上删除每个已完成分片的
part.N.meta(cmd/erasure-multipart.go:1663-1678、:1725)。这时还不知道提交能否达到 quorum。 - 提交调用纠删层的
renameData,把上传目录 rename 成对象(:1734)。在每块盘上,xlStorage.RenameData:- 用合并后的对象
xl.meta覆盖上传的xl.meta(cmd/xl-storage.go:2900); - 把上传的数据目录(包含所有分片)移进对象(
:2926); - 把
xl.meta也移过去(:2965); - 删除上传目录(
:2989-2996)。
- 用合并后的对象
- 没达到写 quorum 时,撤销步骤在 rename 成功的盘上调用
DeleteVersion(UndoWrite)(cmd/erasure-object.go:1140-1167),把这个版本的数据目录,也就是上传的分片,移进回收站(cmd/xl-storage.go:1387-1412)。没有任何东西被移回.minio.sys/multipart。
结果是,上传只留在 rename 失败的那些盘上,而且那里保存的是合并后的对象元数据,不是上传自己的元数据。这些盘不够写 quorum,所以 PutObjectPart 和 Complete 返回 503;所有盘上都没有 part.N.meta,所以 ListParts 什么也列不出。
来源
- 上游 #9377(
4915433bd,2020 年)把原来“把上传 rename 回去”的撤销,换成了丢弃数据的版本删除。在此之前,失败的 Complete 可以重试。 - 上游 #15346(
f939d1c18,2022 年)引入了part.N.meta,以及在 rename 之前删除它。 - 把合并后的元数据写回源目录,来自
2d58a8d86(2020 年)。
方案
方案 A:撤销步骤把分片移回
只对 multipart 来源:在原有的 DeleteVersion(UndoWrite) 之前,先把数据目录 rename 回上传目录。这是必要的,但单靠它不够:分片元数据已经在所有盘上被删掉,上传元数据也已被移走或覆盖。
方案 B:等调用方决定之后再删上传目录
某块盘上 rename 成功时,上传目录已经空了,推迟删除省不下任何东西。而且这需要改存储 RPC,所有节点都要升级。不采用。
方案 C:用仍保留上传的盘重建上传
数据可能已经没了,分片 ETag 也丢了;而且少数盘上的残留,无法和一次已经成功的 Complete 或 Abort 留下的残余区分开,可能导致同一个上传被提交两次。不采用。
方案 D:把 part.N.meta 保留到提交之后
这会改变成功路径,崩溃后还会在对象目录里留下分片元数据;而且仍然需要方案 A 和元数据恢复。不采用。
方案 E:返回 NoSuchUpload,或者直接中止上传
这不是修复。S3 客户端重试 Complete 时收到 NoSuchUpload,会理解为“可能已经成功”。
起草的修复,以及为什么暂缓
起草的修复是方案 A 加上 Complete 中的恢复步骤:保存每块盘上上传的元数据;提交失败时,在每块盘上先用 CheckParts 检查分片是否齐全,再重写每个 part.N.meta,最后重写上传的 xl.meta。代码约 45 行,只改失败路径,不改存储 RPC 和格式,复现测试的所有用例都能通过。
对抗评审否决了它进入本版:
- **迟到的 rename 可能发布一个空对象。**远端 rename 可能先暂存对象元数据,在移动数据之前停住,而调用方此时已经看到了错误。恢复步骤把上传元数据写回去之后,迟到的 rename 继续执行,就会把它当作对象发布:大小为零,没有分片。迟到的撤销同样可能删掉一次成功的重试,因为它只按复用的版本 ID 匹配。
- **过期上传清理不取锁。**它可能在
CheckParts通过之后删掉一个已过期的上传。恢复步骤的写入随后会重建一个只有分片元数据的数据目录,重试时可能把这块没有分片的盘算作写入成功。 - 空上传会丢失零长度的分片文件,恢复步骤没有重建它们。
- **恢复步骤不理会锁丢失,还可能在持锁期间运行很久:**分布式锁丢失后它仍继续写;分片成千上万的上传意味着每块盘成千上万次持久化写入。
- **数据移走之后的明确失败,仍会让上传卡住:**一块盘如果在移动分片之后、提交元数据之前失败,它既不会被撤销,也不会被恢复。
前两条会把可用性缺陷变成数据缺陷。它们都来自同一个缺失的保证:调用方无法判断一个返回错误、或者已被放弃的存储操作到底有没有执行完。这个缺口已经针对所有写路径单独跟进。
完整的修复需要什么
- 在复用上传路径之前,先能对每块盘上的
RenameData和撤销操作做 fencing,或确认其结果; - 过期上传清理与恢复步骤互斥,并在写完分片元数据之后重新校验;数据目录在最后一次校验之后绝不能被重建;
- 空上传的零长度分片文件;
- 锁丢失时取消、总时间预算,以及有上限的并行写入;
- 恢复已明确失败、但数据已被移走的那些盘,或者明确收窄修复范围。
后续修复的代价与兼容性
起草的做法不改存储 RPC 和盘上格式,只有协调节点运行新逻辑。存储操作的 fencing 可能改变这一点,因为它需要盘按代际报告或拒绝操作。相关设计归入存储操作完成确认的单独跟进。
验证
一个确定性的单元测试在 4 块盘、EC 2+2 的纠删集上,上传 5 MiB 和 1 MiB 两个分片,制造盘故障后执行 Complete,再恢复这些盘,然后要求:ListParts 列出两个分片;直接重试和重新上传后的 Complete 都成功;内容一致;没有哪块盘持有对象元数据却缺少分片。在 69fcf50cd 和本地集成上,所有用例都失败:
| 场景 | 当前结果 |
|---|---|
| 2 块盘上的桶目录只读 | 卡住,503 |
| 2 块盘拒绝 rename | 卡住,503 |
| 一块盘离线,另一块拒绝 rename | 卡住,503 |
| 一块盘执行了 rename 但应答丢失,另一块拒绝 | 404 NoSuchUpload |
| 覆盖未开版本控制的对象,2 块盘拒绝 rename | 卡住,旧对象保留 |
| 空对象 | 卡住 |
进程级验收在 69fcf50cd 和下一版上,以同样的结果复现了桶目录只读和盘离线两种情况。
顺带发现的相关缺陷
- 已上传、但没有列入 Complete 清单的分片,会被移进对象的数据目录,成为孤立文件。上游从 2022 年起就存在。
- 当足够多的盘因为权限错误导致提交失败时,Complete 返回 403 而不是 503,客户端可能不会重试。
两者都列入下一版跟进。
评审记录
- 对抗评审第 1 轮:否决进入本版,理由见起草的修复,以及为什么暂缓。评审还纠正了本记录的几处说法:起草的修复并不充分,遗留竞争被低估了,“已提交的对象不会丢失”也说得太宽,因为撤销步骤里还有那个更早的缺口。
参考
21 - 局部视图不能把存在的对象报成不存在
本记录说明在下一版本地验收中发现的一个缺陷。下一版的本地集成和 69fcf50cd 上都能复现。目前还没有对应的 GitHub issue。
**状态:**修复已通过对抗评审(附若干小意见,均已采纳),并已在本地分支实现。尚未合并或发布。
简短回答
刚重启的节点,或者短暂与部分节点断开连接的节点,只能看到纠删集里的一部分盘。如果它能看到的盘上,对象元数据分属两个修订版本,而哪个版本都凑不够读 quorum,GET 和 HEAD 就会对一个存在的对象返回 404 NoSuchKey。
数据不会丢失,也不会被删除。但 S3 客户端、缓存和复制都把 404 当作确定结论:备份工具可能把这个 key 记成已删除,缓存可能存下这个否定答案,条件写入可能基于一个错误的“不存在”做决定。从这样的局部视图出发,正确的回答是 503 SlowDownRead,客户端会重试。
修复只改 cmd/erasure-object.go 一个文件,新增 37 行、改动 2 行:
- 只有纠删集里每块盘都应答了,元数据不一致才判为“不存在”;
- 版本合并找不到达到 quorum 的版本、且有盘没有应答时,持有版本的盘报告读 quorum 错误,而不是“不存在”。
在足够多的盘应答、且每块应答的盘上都没有这个 key 时,仍然返回 404。所有盘都应答的情况下,行为不变。在这种视图下执行的删除、复制和维护操作,现在会失败并重试,而不是把对象当作不存在;代价与兼容性列出了这些情况。
定性
| 方面 | 评估 |
|---|---|
| 性质 | 降级读取时的错误映射不对。继承自上游 MinIO。 |
| 影响 | 存在的对象被报成 404,直到节点重新连上所有盘(启动后最长约 16 秒),或 heal 让各盘副本一致。 |
| 数据安全 | 不删除任何数据。有盘未应答时,悬挂对象检查本来就拒绝动作。 |
| 严重度 | S1:客户端可见的错误“不存在”。概率低:需要部分写入和局部视图同时出现。 |
| 工作量 | 小:一个文件,不改存储 RPC,不改盘上格式,不改配置。 |
何时发生
两个条件必须同时成立:
- 一次写入在写 quorum 上提交,但没有写到所有盘,而且改动了元数据 quorum 会比较的字段。恢复、转储、恢复副本到期、覆盖未开版本控制的对象都属于这类。写入时离线的盘保留旧版本,直到 heal。
- 某个节点通过局部视图读取:它至少能连上一半的盘,但连上的盘分属两个版本,没有哪个版本达到读 quorum。8 块盘、EC 4+4 时,能看到 4 或 5 块盘的节点就可能遇到。
发现它的那次验收:4 个节点,每个节点 2 块盘,EC 4+4。节点 1 和节点 2 同时重启。经节点 1 发起的 t1 恢复只提交到 8 块盘中的 6 块,因为当时还连不上节点 2 的盘。节点 2 只能连上自己的两块盘(未恢复)和节点 4 的两块盘(已恢复)。经节点 2 的 GET 和 HEAD 返回 404。69fcf50cd 上也出现了同样的失败。
根因
行号对应下一版的本地集成;69fcf50cd 上是同一条代码路径。
- 启动时连不上的盘,在节点的纠删集里留空(
cmd/erasure-sets.go:420),不到 1 秒后重连一次,此后每 15 秒一次(:287-290、:349)。 getObjectFileInfo里,没有应答的盘保持errDiskOngoingReq(cmd/erasure-object.go:792, 820),quorum 统计忽略这个错误(:57)。- 版本合并和
objectQuorumFromMeta接受 8 块盘中的 4 个应答(cmd/erasure-object.go:659-661、cmd/erasure-metadata.go:560)。 findFileInfoInQuorum只对远端版本把转储字段算进哈希(cmd/erasure-metadata.go:346)。恢复副本不是远端版本,所以 2 个已恢复副本和 2 个未恢复副本分成两组,每组 2 个,低于读 quorum 4,返回InsufficientReadQuorum{RQInconsistentMeta}(:386)。getObjectFileInfo把这个错误转成errFileNotFound(cmd/erasure-object.go:1007-1016),API 返回 NoSuchKey。
未开版本控制的对象被覆盖时,还有第二条路径通向同样的 404:没有版本达到合并 quorum 时,pickLatestQuorumFilesInfo 把所有应答过的盘都标记为“不存在”,包括持有对象的盘(cmd/erasure-object.go:661-668)。
与 tier 无关。tier 连接被拒绝会映射成 XMinioBackendDown(HTTP 400),不会是 NoSuchKey;而且读取在到达 tier 之前就已经失败。
来源
RQInconsistentMeta转“不存在”来自上游 #20522(dc86b8d9d)。它的提交说明假定“所有盘都给出了足够的应答”,代码却没有检查。- 只对远端版本哈希转储字段来自上游 #19165(
c599c11e7)。 - 合并失败后把所有盘标记为“不存在”来自上游
d48002271。
方案
方案 A:只有所有盘都应答,才把元数据不一致转为“不存在”
补上缺失的前提:每个位置都必须是元数据,或者是明确的“没有这个文件、版本、卷,或元数据损坏”。这修复恢复和转储造成的分裂。新增 15 行、改动 2 行。
方案 B:局部视图下合并失败时,保留读 quorum 错误
合并找不到版本、且有盘没有应答时,持有版本的盘报告 errErasureReadQuorum,而不是 errFileNotFound。这修复未开版本控制对象的覆盖路径。新增 20 行。
方案 C:对所有转储版本都哈希转储字段
这会把已恢复和未恢复的副本合成一组,再由下标顺序决定返回哪一个。它不覆盖未开版本控制对象的覆盖,还会改变 #19165 之后的 heal 判断。不采用。
方案 D:让 /minio/health/cluster 反映本节点自己的视图
集群检查按各节点自报的本地盘计数,所以本节点还连不上部分节点时,它也可能报告“健康”。改它不能消除 404(任何局部视图都会触发),还会改变编排系统依赖的语义。对这个缺陷不采用。
方案 E:启动后更快地重连盘
只能缩小窗口。可以作为单独的改动。
修复方案
在 cmd/erasure-object.go 中同时采用方案 A 和 B:
- 新增辅助函数
allDrivesAnswered(errs):每个位置都是 nil,或errFileNotFound、errFileVersionNotFound、errFileCorrupt、errVolumeNotFound之一时为真。这些都是盘给出的明确答复。元数据损坏也算作答复,是因为它已不可用,而不是因为它证明对象从未存在。请求仍在进行、盘缺失或故障、盘未格式化、访问错误、传输错误、context 被取消,都不算答复; getObjectFileInfo中,RQInconsistentMeta的转换要求allDrivesAnswered;pickLatestQuorumFilesInfo中,合并以“不存在”失败、且不是所有盘都应答时,xl.meta中持有可读版本的盘得到errErasureReadQuorum。
为什么充分且必要
- **充分。**把局部视图变成 404 的两条路径,现在都要求所有盘都应答过。否则读取保留 quorum 错误,API 返回 503。
- **必要。**只有方案 A,未开版本控制对象的覆盖仍会 404(路径 B);只有方案 B,恢复和转储仍会 404(路径 A)。缺任何一半,复现测试都会失败。
- **最小。**不改存储 RPC、元数据哈希和健康检查语义,悬挂对象清理删除的东西不多也不少。真正不存在的 key,或所有盘都应答的对象,回答都不变。
代价与兼容性
- 代价:只在错误路径上多遍历一次错误数组,没有额外 I/O。
- S3:S3 承诺强读后写一致性,所以 NoSuchKey 必须意味着 key 不存在。503 SlowDown 是 SDK 会重试的临时错误码。
- 混合版本:改动只在处理请求的节点本地生效。滚动升级期间,旧节点在这种状态下仍返回 404,已升级节点返回 503,不需要协调。
- 有意的行为变化:某些盘离线时被删除的未开版本控制对象,如果从一个“旧副本和已删除副本恰好一样多”的视图读取,在节点重连或 heal 之前返回 503 而不是 404。从这个视图看,两种历史无法区分。
- 多池(依据代码推断):读 quorum 错误被视为“对象在这个池里”,所以某个节点处于局部视图时,PUT 不会再在另一个池里创建重复对象。
- 其他经过这种视图读取的操作,现在返回 503 并重试,而不是把对象当作不存在:
- 多池下删除一个副本以这种方式分裂的未开版本控制对象(
deleteObjectFromAllPools),以前可能走受保护的盲删路径; - CopyObject 读取源对象,以及标签和元数据更新;
- decommission 和 rebalance,会重试或报告这个对象,而不是把它当作缺失而跳过。
- 多池下删除一个副本以这种方式分裂的未开版本控制对象(
遗留限制
-
在写 quorum 上提交的带版本覆盖,通过这样的局部视图仍可能读到旧版本。这是上游既有行为,不在本次范围内。
-
永久损坏的盘。
errDiskNotFound算作没有应答的盘。因此在有一块永久坏盘的纠删集中,元数据分裂、且没有任何修订达到 quorum 的对象不再返回 404:- GET 和 HEAD 持续返回 503;
- 在开启版本控制或版本控制暂停的桶中,它的 DELETE 返回
InsufficientWriteQuorum,dangling 对象清理也因为缺盘而拒绝执行; - 在未开版本控制的桶中,它的 DELETE 仍可走盲删。
更换这块盘之后,纠删集的每块盘都会重新应答。但这本身并不能恢复这个对象:只有足够多的存活盘对它的元数据达成一致并持有它的数据时,heal 才能重建它。否则,一旦每块盘都应答,这次不一致的读取可能变成“不存在”。恢复步骤:更换这块盘,等盘的 heal 完成,然后读取该对象并列出它的版本加以确认;不要假定它已经恢复。
-
/minio/health/cluster可能在节点连上所有对端盘之前就报告健康。验收脚本应改为经每个节点探测一次写和读。
验证
一个确定性的单元测试构建 8 块盘、EC 4+4 的纠删集和内存 tier,在隐藏两块盘的情况下提交恢复、转储或覆盖,再通过只有 4 块盘的视图读取:
| 情况 | 修复前 | 修复后 |
|---|---|---|
| 恢复提交到 8 块盘中的 6 块,带版本和不带版本的桶 | 404 | 503 |
| 转储提交到 8 块盘中的 6 块 | 404 | 503 |
| 未开版本控制对象的覆盖提交到 8 块盘中的 6 块 | 404 | 503 |
| 不存在的 key,同样的视图 | 404 | 404 |
| 副本不一致,但所有盘都应答 | 404 | 404 |
恢复、转储和未开版本控制的覆盖三种情况,断言返回读 quorum 错误、HTTP 503。带版本的覆盖可以返回旧版本,但不能报告为不存在。撤掉修复后这三种情况都失败;只保留方案 A 时,未开版本控制的覆盖仍然失败。
修复后约 100 个相关测试通过,覆盖读 quorum、悬挂对象、恢复、列举和删除标记清除。
评审记录
- 对抗评审第 1 轮:批准,附若干小意见。评审逐一核对了判断条件对盘位上每种可能错误的处理、版本合并的所有调用方,以及悬挂对象清理。小意见已全部采纳:测试改为断言 503,而不只是“不是 404”;本记录补充列出了会失败而不是把对象当作不存在的降级删除、复制和维护操作,也不再把元数据损坏当作对象不存在的证据。
参考
22 - 两阶段原生恢复
本文覆盖 issue #284,以及 #281 第一版原生恢复防护带来的两处回归;那些防护记录在恢复副本:过期的是副本,不是对象中。本文基于 main 69fcf50cd 和下个版本的集成分支评估。这个设计取代 #281 防护中私有 upload 的部分,其余部分保留。
状态: 设计经六轮审查通过,实现经两轮审查通过,已在下个版本的集成分支上本地提交。尚未合并或发布。
简短结论
原生恢复(对已转储版本发起 POST ?restore)会从 tier 下载该版本,并写出一份本地副本。两个问题来自这次写入如何加锁,以及副本放在哪里:
- 读写要等待下载结束(#284)。 在有多个池的部署中,已发布的代码在整个下载期间持有对象写锁。#281 的第一版防护让单池也这样做,以免下载期间设置的 legal hold 被覆盖,并且把"对象锁被占用"当作"有恢复正在运行"。于是在恢复一个旧版本时,同一个 key 的所有 GET、HEAD 和写入,包括对其他版本的读取,都要等待下载结束。
- 失败的普通上传会卡住。 同一批防护把恢复数据放在一个用私有标记隐藏的分段上传里。普通上传的完成操作在暂存元数据之后失败时,这个标记就无法再区分两类上传。此后该普通上传对所有调用方都返回
InvalidUploadID,一小时内也无法中止。
下一个版本分两个阶段恢复:
- 不持对象锁暂存。 worker 把 tier 副本纠删编码写入该版本所在纠删集的 tmp 卷,写法与 heal 写入它重建的分片相同。该 key 上的其他请求不必等待。
- 短暂持有对象锁提交。 worker 重新读取该版本,确认它的尝试仍拥有该版本、暂存的内容仍是该版本的内容,然后逐盘发布副本。发布没有达到写 quorum 时,先在同一次持锁期间尝试收敛;这一轮没能理顺的,之后在重新取得的锁下重试,重试用尽或进程崩溃后仍可能留下未解决的状态。
判断是否有恢复正在运行的,改为每个版本一把的恢复锁,而不是对象锁。分段上传完成回到 main 上的代码,普通上传的行为与 #281 防护之前相同。
定性
| 方面 | 判断 |
|---|---|
| 性质 | 原生恢复的可用性缺陷,加上未发布的 #281 防护带来的两处回归。不丢数据。 |
| 受影响 | 所有部署上的原生恢复。已发布的代码中,多池恢复本来就在下载期间持有对象锁;单池持锁和卡住的普通上传只存在于未发布的集成分支上。 |
| 不受影响 | 不触发恢复的已转储对象读取;没有远端 tier 的部署。 |
| 严重程度 | 对可用性影响高:恢复一个旧版本,就可能在一次 tier 下载的时长内卡住对该 key 的所有访问。 |
| 发布紧急程度 | 随下个版本发布。它在私有 upload 设计发布之前取代该设计。 |
| 剩余工作 | 代码、两轮实现审查和带 kqueue,dev 标签的本地完整测试套件均已完成。还需要远端 CI,以及在分布式部署上的验收运行。 |
根因
两处回归都来自 #281 第一版防护的两个选择:
- 恢复复用了公开的分段上传命名空间。 恢复 upload 与普通 upload 的唯一区别,是
xl.meta中的一个键。完成操作会删除这个键,而失败的完成操作会在 upload 目录里留下合并后的对象元数据。发生这种失败之后两者无法再区分,只能隐藏所有已暂存的 upload,普通 upload 也在其中。 - 下载期间持有对象锁,而且这把锁代表 worker。 POST 根据对象锁是否被占用,推断是否有恢复在运行。因此在给 worker 另一种表明自己存活的方式之前,无法缩短这把锁的持有时间。
设计
先恢复锁,再对象锁
每个版本有一把恢复锁 R,它是系统桶下按桶名、对象名和版本 ID 命名的分布式锁。POST 先取 R,最多等待两秒,再取对象锁 O,持有到登记完它的尝试为止。新的尝试把 R 移交给 worker,worker 在整个运行期间持有它。除此之外没有任何路径取 R,所以不存在持有 O 时等待 R 的情况:顺序总是先 R 后 O。
R 只是存活提示,不保证排他。分布式锁的刷新失败按节点离线处理,所以在网络分区期间,第一个 worker 仍在运行时,另一个节点可能取得 R。因此正确性只依赖提交在 O 下所做的核验:被取代的 worker 会发现它的尝试已不再拥有该版本,于是什么也不写。它的暂存只属于它自己,不会干扰新的 worker。
在等待时间内取不到 R 的 POST,会不加锁地读取各盘:各盘显示该版本正在恢复时返回 409 RestoreAlreadyInProgress,没有显示或无法判断时返回可重试的错误。它从不启动第二个 worker。
在对象锁之外暂存
worker 在持有该尝试所指版本的池、以及对象哈希到的纠删集中暂存。它读取 tier 数据流,按版本各分段的大小切分,确认它们的总和等于对象大小,并按该版本自己的纠删布局把每个分段写到 tmp/<id>/<data dir>/part.N,使用与分段上传和 heal 相同的流式 bitrot 哈希。暂存期间不持有对象锁。
在所有分段的写入中逐盘跟踪:
- 某块盘在任一分段的写入、编码或关闭时出错,就被排除,之后不再为它创建写入器;
- 提交之前,对每块剩余的盘运行
CheckParts,每个分段都必须报告成功,所以 tmp 卷被移走、又被后续写入重建的盘也会被排除; - 剩余的盘少于写 quorum 时,暂存失败,该尝试清除自己的标记,客户端可以重新发送 POST。
不做内联:恢复副本总是使用新的数据目录。
在对象锁下提交
随后 worker 取 O,等待时间与普通操作一样有界。取不到时,它清除自己的标记并删除暂存。在 O 下,它重新解析池(必须仍是暂存所在的池),读取该版本的权威修订,然后核验:
| 核验 | 必须与暂存时一致的内容 | 不一致时 |
|---|---|---|
| 尝试 | 该尝试拥有这个版本。各盘的恢复字段不一致时,它的尝试必须是唯一正在进行的尝试。 | 停止,不写入;不触碰更新的尝试。 |
| 版本 | 版本 ID、不是删除标记、修改时间、ETag、大小。 | 停止;不复活已删除的版本,不覆盖新的 null 版本。 |
| tier 副本 | 转储已完成,tier、远端名称和远端版本都相同。 | 停止。 |
| 分段 | 分段编号与大小。 | 停止。 |
| 纠删布局 | 算法、数据块与校验块数、块大小、分布。 | 停止。 |
标签、retention、legal hold 和复制状态在下载期间可以变化。提交采用它在 O 下读到的修订中的这些字段,因此下载期间已确认生效的保护会保留。在按 ID 指定的版本之上出现更新的删除标记,不会让恢复停止;只有版本本身消失才会。
逐盘发布
提交在每块持有权威修订、且保留了暂存的盘上发布。它像 heal 一样直接调用该盘的 RenameData,从不经过纠删层的 rename:后者对被替换版本的回滚可能删掉新的数据目录,却留下指向它的元数据。每块盘写入的是提交在该盘上读到的版本,换上新的数据目录、已完成的恢复状态,以及在所有盘上相同的恢复字段。内联数据标记会被删除,因为更早的恢复可能把它留了下来。
副本达到写 quorum W 时提交成功:
- 被替换的数据目录只在返回了它的盘上删除,并且只有当这些盘返回的都是同一个目录时才删;否则留给废弃数据清理;
- 有盘没有拿到副本时,该版本以规范化的版本 ID(null 版本为
null)排入 MRF heal,由 heal 把这些盘修到已恢复的修订。
未达写 quorum 时的收敛
确认拿到副本的盘少于 W,或者某个结果不确定时,worker 在仍持有 O 时收敛该版本,之后再做有限次数的内存重试,每次重试都重新取 O。只有这个 worker 知道每块盘在提交前持有什么(Sᵢ)、提交在那里写了什么(Pᵢ),以及收敛会写什么(Vᵢ)。每一轮都先读取每块盘上的条目,要求每一条都等于其中之一:
- 出现任何其他内容就停止收敛:两次持锁之间有人改过该版本,例如密钥轮换、标签更新、新的尝试或 heal;不写入任何东西,并记录错误;
- Pᵢ 出现在 W 块或更多盘上,说明提交其实已经成功,例如响应丢失;该版本排入 heal,修复其余的盘;
- 其他情况下,worker 把 Vᵢ 写到每块持有该版本的盘上。Vᵢ 是不带任何恢复字段和内联标记、并使用旧数据目录的修订。此后该版本处于未恢复状态,新的 POST 可以再次恢复它。内容仍在 tier 上。
收敛不删除任何数据目录;不再被引用的目录留给废弃数据清理。重试用尽或某一轮停止时,worker 记录 restore publication did not converge,并附上桶、对象、版本、尝试和数据目录。
多池下的废弃数据清理
在把数据目录移入位置和改名 xl.meta 之间,某块盘上会短暂存在一个尚无元数据引用的目录。单池时,废弃数据清理本来就取同一锁域的对象锁。多池时,它只取各池所属集的锁,而这把锁与 O 并不互斥。现在多池时的废弃数据清理像 heal 一样取池层的对象锁,因此不会删掉提交已经移入位置的目录。每次 PUT 的 rename 也有同样的窗口,同一个改动一并关闭了它。
分段上传回到 main
分段上传完成中的恢复例外、私有 upload 标记、第一版设计的提交锁,以及 null 版本 Object Lock 检查中的恢复例外,都已移除。单池的完成操作重新先取 upload 锁、在提交之前才取对象锁,与 main 相同;多池时先取对象锁。暂存之后完成失败的普通 upload 的行为重新与 main 一致:重新上传分段后可以完成,也可以立即中止。Multipart 提交失败可能让上传卡死中描述的继承来的失败路径保持不变。
能力协议 2
旧服务器不取恢复锁,不在对象锁下核验尝试,提交方式也不同。因此只有集群中每台服务器都明确回答支持协议 2 时,才运行原生恢复:
- 处理 POST 的服务器询问每个对等节点,肯定的回答最多缓存十秒;只要有对等节点没有确认,就返回
503 ServiceUnavailable,不写入任何东西; - 排队中的 worker 在开始前再问一次,因为恢复排队期间某台服务器可能被换成了旧二进制;开关关闭时它清除自己的标记;
- 无法连上的对等节点会阻止原生恢复(
errNativeRestorePeerUnreachable,b8ebaffd2):503 ServiceUnavailable响应会单独指出不可达的服务器,与滚动升级协议不匹配(errNativeRestoreUnsupported)区分开,因为无法证明不可达的对等节点已经停止,它可能仍在对同一个版本运行旧的恢复。
两阶段恢复的设计文档写的是只有 POST 检查这个开关。代码在 worker 中也会检查,本文以代码为准。
为什么方案充分且必要
充分。 恢复只在提交时、在 O 下、通过上述核验之后才写入该版本,而只有拥有该版本的尝试能通过核验。下载期间,它不持有任何会让其他请求等待的东西。除剩余限制中的排除项外,发布的每种结果都落在三种状态之一:副本达到写 quorum;每块持有该版本的盘都是未恢复修订;或者记录日志后停止,不写入任何东西。
必要。
- 没有恢复锁,对象锁就仍要代表 worker,下载期间无法释放。
- 没有逐盘的合格性跟踪和
CheckParts,tmp 卷被重建的盘可能在缺少分片的情况下参与提交。 - 没有 O 下的核验,被取代的 worker、被替换的版本或已改变的布局都可能被发布。
- 不逐盘调用
RenameData,纠删层的回滚可能删掉被替换版本的新数据,而元数据仍指向它。 - 没有收敛,未达写 quorum 的发布会留下一个可能读不出来的版本。在 12 块盘、8 个数据分片加 4 个校验分片的集上,7 块盘发布成功就构成元数据读 quorum,但分片不足以重建数据。
- 废弃数据清理不取池层的锁,多池清理可能在元数据写入之前删掉已发布的数据目录。
代价与兼容性
- 普通请求。 GET、HEAD、PUT 和分段上传不取恢复锁,不做能力检查,也不多读任何东西。恢复在下载期间不再阻塞该 key 上的其他请求。多池时,scanner 的废弃数据检查改取池层的对象锁,而不是各池所属集的锁。
- 提交。 它在持有 O 期间重新读取版本元数据,并对每块盘调用一次
RenameData。这段时间随版本元数据的大小、分片数和盘数以及磁盘延迟增长,不随下载时间增长。它没有固定的毫秒上限。 - 恢复路径。 提交前对每块合格的盘做一次
CheckParts,恢复锁的刷新,以及在提交之前占用一份编码后副本的 tmp 空间。 - 重复的 POST。 对正在恢复的版本再次发送的 POST,最多等待两秒恢复锁,然后返回
409 RestoreAlreadyInProgress。 - 残留。 收敛或失败的 rename 留下的目录等待废弃数据清理;崩溃的 worker 留下的暂存等待过期 tmp 清理或重启。两者都不会阻塞之后的恢复。
- 格式。 不改变存储格式或公开 API。唯一新增的 RPC 是能力询问。版本元数据中的尝试键是内部键,旧二进制会保留它而不读取。
升级、回滚与降级
- 升级。 旧版本的服务器不会回答支持协议 2,所以在每台服务器都运行本版本之前,POST 返回
503。旧版本留下的、没有尝试 ID 的标记,由下一个拿到恢复锁的 POST 接管。对于各集使用不同主机的单池,锁域修复要求的排空重启仍然适用。 - 降级或回滚之前, 停止发起新的恢复,并等运行中的 worker 结束。旧二进制不认识恢复锁,开关也拦不住已经降级的服务器。
- 检查每台服务器的日志中是否有
restore publication did not converge,降级前逐一修复其中列出的版本。这条日志只是正面证据:进程在重试途中退出时不会留下记录。 - 现有工具无法证明不存在发布分裂。 heal 演练不能作为证明:它在检查可用分片是否足够之前就返回了,所以发现不了"已恢复的盘占多数、分片却不足以重建"的情况。只读的检查工具属于下文协调者崩溃的后续工作。
- 降级之后,残留的进行中标记在旧二进制上会返回
409,与上游相同。
剩余限制
- 协调者在发布途中崩溃。 运行 worker 的服务器在发布途中崩溃,或者收敛重试用尽时,该版本可能留下分裂:部分盘已恢复,部分盘未恢复。没有任何机制会自动收敛它。在人工修复或后续能够检测并收敛这种分裂的修复上线之前,该版本可能读不出来,POST 也可能失败。main 在任何写入途中崩溃时都有同样的风险。
- 迟到完成的存储调用。 调用方已经因超时或错误返回之后,服务端的存储 RPC 仍可能在执行,即使提交仍持有 O 也是如此,它可能在核验或收敛之后才落盘。所有写入路径都有这一限制;为存储 RPC 加 fencing 是单独的事项。少数盘上迟到落盘的副本,只有当未恢复修订达到 heal 对恢复字段要求的 quorum 时,才会被 heal 修回。
- 假定 tier 不可变。 提交相信远端名称和远端版本相同时字节也相同,与所有 tier 读取的前提一样。外部写入者覆盖未开版本控制的 tier 对象,不在保护范围内。
- 暂存丢失。 服务器重启会移走它的 tmp 卷,过期 tmp 清理会删除旧目录。下载期间受影响的盘会被排除在提交之外。只有剩余的合格盘少于写 quorum 时,暂存才会失败,这时可以重新发送 POST。协调服务器重启会结束它的尝试。
- 重启。 收敛和标记重置的重试在内存中运行。重启之后,版本保持进行中状态,直到之后的 POST 接管。
验证
两个问题的回归测试在之前的集成 head 上失败,其中分段上传的测试在 main 上也通过:
TestRestoreDownloadKeepsKeyAvailable:暂停旧版本的下载期间,对该 key 热版本的 GET、HEAD、PutObjectTags和 PUT 都按时完成,覆盖一个池和两个池、单段对象和分段对象;TestMultipartFailedCommitRetriesLikeMain:完成操作暂存元数据后失败,客户端重新上传分段后可以完成,或者中止。
故障注入测试覆盖整个协议:
TestRestorePublicationBelowQuorumReverts和TestRestorePublicationBelowWriteQuorumOnWideSet(8+4 上 12 块盘中 7 块发布成功会回退;把阈值放宽到 W-1 时该测试失败);TestRestorePublicationUncertainCommits(响应丢失)、TestRestoreConvergenceRetries和TestRestoreConvergenceStopsOnOtherWrites;TestRestoreRemovesOldDataDirOnlyWhenUnanimous、TestRestoreDropsStaleInlineMarker和TestRestorePublishedRevisionAcrossExpiry;TestRestoreStagingDropsRecreatedTmp:在 4 块盘的集上,暂存期间 tmp 卷被重建的一块盘被排除,恢复成功;有两块这样的盘时,什么也不发布;TestRestoreSecondPOSTWhileRunning、TestRestoreTargetDeletedDuringDownload和TestRestoreTierNotFoundClearsMarker;TestCheckAbandonedPartsTakesPoolsLock放置一个未被引用的数据目录,确认它在持有对象锁期间保留、之后被删除;没有池层锁时该测试失败;TestRestorePartialCommitQueuesNullVersionHeal:提交漏掉非当前null版本的一块盘时,以null排入 heal,并把它修回。
私有 upload 标记的 13 个测试,以及从客户端完成恢复 upload 的 TestNativeRestoreConcurrentCompletion,随那个设计一起删除。4 个测试按新语义改写,其中锁域的恢复测试现在卡住的是提交,而不是下载。本地完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件、恢复、分段上传、锁域和废弃数据测试的竞态选集、go vet 和 golangci-lint 都通过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 维护者审查初稿 | 要求修改:在现有的 tmp 卷而不是新的命名空间中暂存,并把分段上传回退到 main。 |
| 设计第 1 轮 | 要求修改。纠删层的回滚可能删掉被替换版本的新数据;被重建的 tmp 卷可能发布缺失的分片;分裂与纠删布局的核验不够明确;恢复锁只是存活提示;残留和 tier 前提需要写明。全部采纳。 |
| 设计第 2 轮 | 要求修改。多池下废弃数据清理与对象锁不互斥;回滚会删除迟到写入可能引用的目录;WriteMetadata 先截断再写;CheckParts 只是下限检查。发布改为逐盘 RenameData,回滚不再删除目录,清理改取池层的锁。 |
| 设计第 3 轮 | 要求修改。旧数据目录可能被同级版本共享;成功应以写 quorum 判定;已过期的内联恢复保留着它的标记。协调者崩溃后的恢复和迟到完成的存储调用,按决定明确排除,单独跟进。 |
| 设计第 4 轮 | 要求修改。重试可能覆盖更新的元数据写入或后继尝试;现在每一轮收敛都把每块盘与 Sᵢ、Pᵢ、Vᵢ 精确比对,出现其他内容就停止。迟到 RPC 的排除范围扩大到持锁期间落盘的调用。 |
| 设计第 5 轮 | 要求修改。发布一旦开始,任何失败都写 Vᵢ;heal 演练不能用来证明可以降级;少数盘上迟到的副本只有在恢复字段达到 quorum 时才能修回。 |
| 设计第 6 轮 | 通过,附一条小建议,已采纳:发布前的重试清除标记,发布后的重试只做收敛。 |
| 实现第 1、2 轮 | 第 1 轮发现三个问题,各自附测试修复:已发布修订在副本过期前后依赖时钟;tier 404 会让标记残留;废弃数据测试在没有修复时也能通过。第 2 轮通过;补充了一个 12 块盘 8+4 的写 quorum 阈值测试。 |
| 发布审查(2026-11-06) | 发现部分提交以 null 版本原始的空版本 ID 排入 MRF heal,而收敛路径用的是 null;heal 本来就把空 ID 读作 null,现在两条路径都排入 null。还发现能力门的注释仍在描述私有 upload,设计文档也写着只有 POST 检查开关。注释现在描述恢复锁、尝试栅栏和暂存提交,本文对开关的描述以代码为准。 |
| 审查后续跟进(2026-11-07) | 将 errNativeRestorePeerUnreachable 与 errNativeRestoreUnsupported 拆开(b8ebaffd2),对等节点不可达时,503 ServiceUnavailable 直接指出不可达的对等节点,而不是报告滚动升级。 |
参考
- Issue:#284
- 恢复副本:过期的是副本,不是对象(#281)
- 大型单池集群的锁域
- Multipart 提交失败可能让上传卡死:本设计保持与 main 相同的分段上传失败路径
- Heal 演练不能删除数据
- 上游多池恢复锁:minio/minio#20495
23 - Console 的桶配额:列表显示与数值校验
本文覆盖 silo-console#72(在 Web Console 中设置的配额在桶列表里显示为短横线)和 silo-console#73(创建桶表单的数值校验)。评估基于修复之前的 Console 源码 3d264aea9,以及修复提交 a85665814 至 5a34232e7。
状态: 已随 Console 2.6.0 发布。修复提交已合入 Console main(PR #74,合并提交 3d1dd56eb,2026-11-04),之后又有数值相关的后续修复(PR #79)。Console v2.6.0 已在 f8ab14669 发布,SILO main 已内嵌 v2.6.0(e24831f53);尚无已打标的 SILO Server 发布包含该内嵌更新。
简短结论
两份报告都属实,且都不影响已存储的数据或配额的执行。
- #72 是显示缺陷。 自 Console 开始写入新的
Size字段(2025-09-16)起,在 Web Console 中设置的配额以size存储,旧的quota字段为零。桶列表处理程序和列表界面只读quota,所以什么都不显示。服务器对配额的执行是正确的。 - #73 是输入校验缺陷,其中有一种情形会影响写入。
parseInt(getBytes(...))会把 868 Ei 及以上的任何数量变成 1 字节配额;服务器接受并执行它,该桶随后拒绝所有非空写入。其余情形是误拒绝或缺少检查:0.5 Gi被拒绝,切换单位不会重新校验,保留期有效值既不检查是否为整数,也没有限制在 32 位范围内。
两个缺陷都不是 SILO 引入的。#72 来自 SILO 所派生的社区 Console 分支:它的配额写入端在 2025-09-16 改变了,列表读取端没有跟着改。#73 是 2020 至 2022 年间 minio/console 的原有代码。SILO 在下文列出的所有内嵌 Console 的发布中都带着这两个缺陷,而且 Console v2.5.0 的列表改版保留了过时的读取方式。
修复很小:桶列表与 Summary 使用同一个配额读取函数,创建与编辑表单共用同一套换算与边界检查。
定性
| 方面 | 评估 |
|---|---|
| 性质 | #72:Console 写入端的表示方式改变,留下了一个没改的读取端。#73:早于派生点的输入校验问题。两者都是继承而来,不是 SILO 的回归。 |
| 受影响组件 | 仅 Web Console:api/user_buckets.go、桶列表界面,以及创建桶和编辑配额的表单。服务器、madmin-go 和 mcli 没有缺陷。 |
| #72 影响 | 仅显示。配额已被正确存储和执行,Summary 与 mcli 显示正确。列表显示短横线(Console v2.5.x)或不显示配额(v2.0.0 至 v2.4.2)。 |
| #73 影响 | 一种影响写入的情形:868 Ei 及以上会变成 1 字节硬配额,阻止任何非空对象的写入。8 Ei 至 867 Ei 超出有符号 64 位范围,会被 API 拒绝。其余情形是误拒绝或缺少检查。 |
| 严重度 | #72 低。#73 低到中:触发损害需要刻意输入至少 868 Ei,但后果是桶悄悄拒绝写入。无数据丢失,无安全影响。 |
| 受影响发布 | 见行为的来源。#72:社区 v1.9.0 起的 Console;SILO Server 20260314 至 20261026。#73:所有发布。 |
| 发布紧迫度 | 已随 Console 2.6.0 发布。不是数据安全的阻断项。 |
| 剩余工作量 | 内嵌 Console 的下一个 SILO Server 发布验证。 |
何时发生
#72。 创建一个桶,用 mcli quota set 设置配额,它写入旧的 quota 字段,列表可以显示。在 Console 中打开该桶,修改配额并保存。Console 写入 size,服务器存储的配置中 quota 为零。此时管理 API 对 110 GiB 配额返回 size=118111600640, quota=0。Summary 页面优先使用 size,显示 110 GiB;列表读取 quota,显示短横线。在创建表单中设置的配额同样如此。
#73。 打开启用配额的创建桶表单。
- 输入
1000并选择Ei。表单把它换算成字符串1.152921504606847e+21,parseInt在e处停止并返回1,请求携带amount: 1。 - 输入
0.5并选择Gi。parseInt("0.5")为0,所以表单把字段标记为无效,尽管输入模式允许两位小数。 - 输入
1000和Gi,然后把单位改为Ei。切换单位不会重新校验,先前的有效状态保留,无需再次输入就能走到情形 1。 - 启用保留期并输入
1.5或3000000000。输入通过valueAsNumber读取,唯一的检查是NaN或小于 1,而 API 模型是有符号 32 位整数。
已有桶的配额编辑器使用同样的 parseInt(getBytes(...)) 换算;保留期编辑器同样缺少整数与上界检查。
根因
#72。 在修复之前的源码中,桶列表响应由 getAccountBuckets(api/user_buckets.go 第 409 至 414 行)构造,它复制 bucket.Details.Quota.Quota,忽略 Size。列表界面随后只渲染 details.quota.quota:v2.5.x 中是 BucketsTable.tsx 第 387 至 390 行,更早的版本是 BucketListItem.tsx 第 144 行。而单个桶的配额处理函数 getBucketQuota(api/user_bucket_quota.go 第 118 至 125 行)已经优先使用正的 Size,否则回退到 Quota。对同一份配置,两个读取方使用了两套规则。
服务器没有改变表示方式。它按客户端写入的内容存储和返回配置,执行时先看 Size 再看 Quota(cmd/bucket-quota.go 中的 getBucketQuotaSize;上游 MinIO 的最后一个 master 也是同样的优先顺序)。madmin-go 的结构自 2023 年 8 月起就把 Quota 标为已弃用,并带有 Size。mcli quota set 仍然写旧的 Quota 字段,所以命令行设置的配额在列表中显示正常。
#73。 创建表单有四个相互独立的缺口:
addBucketThunks.ts第 80 行发送parseInt(getBytes(quotaSize, quotaUnit, true))。getBytes把数字转成字符串返回,而 JavaScript 对 1e21 及以上的数字使用指数形式。addBucketsSlice.ts第 122 行在parseInt(state.quotaSize) === 0时拒绝该字段。下一行的输入模式后来放宽到允许两位小数,parseInt检查却保留了下来。setQuotaUnit(第 133 行)只保存单位,不触及invalidFields。setRetentionValidity(第 170 至 183 行)和SetRetentionConfig.tsx(第 87 行)只检查NaN和< 1。
在服务器端,配额请求接受任何非负数量并存为 Size。存储的大小为 1 时,enforceQuotaHard 会拒绝所有一字节及以上的写入(uint64(size) >= quotaSize,cmd/bucket-quota.go)。
行为的来源
溯源依据 Console 的提交历史。上游项目 minio/console 如今已无法公开访问。SILO Console 所派生的社区延续项目 georgmangold/console 仍然可以访问。pgsty/silo-console 的 main 派生自它的提交 feff71e48(2026-04-16),这就是派生点。下列提交都在 pgsty/silo-console 的 main 上;PR 编号指提交原本所在的仓库。派生点之前的 Console 发布标签中,只能取得社区的 v1.7.7 及之后的标签,所以更早的提交不给出 minio/console 的首个发布标签。
#72:写入端改变后被遗留的读取端
| 要素 | 提交 | 来源 | 日期 | 首个发布 |
|---|---|---|---|---|
列表处理程序只复制 Quota |
64e38ca8d “List buckets re-design”(minio/console #1058),api/user_buckets.go |
上游 | 2021-09-20 | 标签无法取得;此后每个发布都有 |
列表界面只读 details.quota.quota |
10539929e “Added quota metric to buckets list & objects list”(minio/console #1595),BucketListItem.tsx |
上游 | 2022-02-16 | 标签无法取得;此后每个发布都有 |
Console 写入端改为 Size;Summary 读取端同步更新,列表读取端没有 |
6c54a55d5 “use Quota Size field instead of the deprecated Quota field”(georgmangold/console#28,Georg Mangold) | 派生点之前的社区 Console 分支 | 2025-09-16 | georgmangold/console v1.9.0(2025-11-16) |
| 列表改为表格;配额列沿用过时的读取方式,为零时显示短横线 | 2611a4d11 “modernize buckets, object browser, safe previews, and task center”(Feng Ruohang) | SILO | 2026-10-25 | Console v2.5.0(2026-10-25) |
缺陷需要倒数第二行:2025-09-16 之前 Console 写的是旧字段,列表能读到。6c54a55d5 就是表示方式的改变,它只更新了两个读取端中的一个。这不是服务器或 madmin-go 的改动:madmin-go 自 2023 年 8 月起就同时带有两个字段,服务器对它们的存储与执行也没有因此改变。SILO 没有引入这个缺陷。
SILO 改了什么。 从派生点到修复之间,没有 SILO 提交改动列表处理程序或配额写入端。SILO 的 Web 改版(23ae6e8e6,Console v2.0.0)和 2611a4d11 的列表重建重写了列表界面,并沿用了旧的读取方式。v2.0.0 至 v2.4.2 的列表只是不显示配额;v2.5.0 和 v2.5.1 的新表格列显示短横线,读起来像“没有配额”,使缺陷明显到被人报告。这个改动放大了可见症状,没有改变原因。
包含该缺陷的 Console 发布。 写入端的改变和过时的读取端同时存在于 georgmangold/console v1.9.0 与 v1.9.1,以及 SILO 的每个 Console 标签,v2.0.0 至 v2.5.1。SILO 的首个 Console 标签是 v2.0.0(2026-08-04)。写旧字段的 Console 发布,直到 minio/console v1.7.6 和社区 v1.8.1,都没有这个缺陷。
内嵌了此类 Console 的 SILO Server 发布(取自 Server go.mod 中 replace github.com/minio/console 一行):
| Server 发布 | 内嵌的 Console | 是否有缺陷 |
|---|---|---|
| 20251203、20260214 | minio/console v1.7.x,写旧字段 | 否 |
| 20260314、20260321、20260325、20260417、20260618 | georgmangold/console v1.9.1 | 是(列表不显示配额) |
| 20260804、20260806 | SILO Console v2.0.0、v2.1.x | 是(不显示) |
| 20260903 | Console v2.3.0 与 v2.4.0 之间的构建 | 是(不显示) |
| 20260916、20261015 | Console v2.4.1 | 是(不显示) |
| 20261018 | Console v2.4.2 | 是(不显示) |
| 20261025、20261026 | Console v2.5.0、v2.5.1 | 是(短横线) |
Server main 分支的 go.mod 现已指向 Console v2.6.0(e24831f53),但尚无已打标的 Server 发布包含该修复。Server 标签 20260304、20260323 和 20260330 内嵌的是写入端改变之前的 Console 构建,不受影响。
#73:minio/console 的原有代码
| 要素 | 提交 | 来源 | 日期 | 说明 |
|---|---|---|---|---|
创建请求中的 parseInt(getBytes(...)) |
28eb8784a “Set Bucket Quota on Creation”(minio/console #308) | 上游 | 2020-10-01 | getBytes 返回 Number.toString(10);从 1e21 起出现指数形式 |
字段检查 parseInt(quotaSize) === 0、切换单位不重新校验、保留期 NaN/< 1 检查 |
41e1b4a5d “Add Bucket slice refactor to reduce re-renders”(minio/console #2087) | 上游 | 2022-06-03 | 把表单逻辑移入 addBucketsSlice.ts;这些检查此前已存在于 AddBucket.tsx |
输入模式加入小数,parseInt 检查保留 |
7ecc1022b “Added decimal support to bucket quota selectors”(minio/console #2126) | 上游 | 2022-06-15 | 使 0.5 通过输入模式、却被 parseInt 检查拒绝;上游 “Release v0.19.0” 提交紧随其后 |
这些都在社区分支和 feff71e48 中。修复之前触及相同文件的 SILO 提交(23ae6e8e6、14b1c2de2、2611a4d11)改的是链接目标、翻译和命令预览,没有改动校验或换算。SILO 既没有引入也没有放大 #73。它存在于每个 Console 发布,因而存在于每个内嵌 Console 的 SILO Server 发布。
仅凭推理、未执行:main 一线的历史。2020 至 2022 年这些提交对应的 minio/console 首个发布标签无法取得,所以只给出提交主题和日期。
方案选项
选项 1:列表和详情共用一个配额读取函数
把“先 Size 后 Quota”的规则放进一个函数,列表处理程序和 getBucketQuota 都调用它。两个读取方始终一致,API 模式不变。
选项 2:让写入端同时发送两个字段
同时写 Quota 和 Size 能让旧读取端重新工作,但会为一个使用方重新引入已弃用字段,而其他工具写入的配额仍会让列表出错。否决。
选项 3:让界面也读取 size
列表响应模型只有 quota。让浏览器再读一个字段,会把规则复制到第三处。否决。
选项 4:用一个函数校验数量,到处使用
一个函数把文本数量和单位换算成字节数,并拒绝 1 到 2^63 - 1 之外的值。创建表单、配额编辑器和 thunk 都调用它,切换单位时重新校验。保留期增加整数和 1..2147483647 的检查。
选项 5:交给服务器拒绝
服务器接受任何非负的 Size,包括 1。这个错误值本身是合法配置,服务器无法区分它与有意设置。不能只靠它,保留作为负数的后备防线。
建议的修复
选项 1 加选项 4,在分支的六个提交中(都在 PR #74 内):
- a85665814 提取
bucketQuotaSize,并同时用于列表处理程序和getBucketQuota:正的Size优先,否则用Quota。处理程序的 JSON 测试覆盖现代、旧式、冲突、空、零和 nil 值。配额写入与模式不变。 - 35cf4aae7 让配额编辑器保留精确的字节值,未变化时跳过保存,换算前先校验,并在 API 端拒绝负数。
- fb2c9e71c 完整解析字节值并限制显示单位,大数字在显示时不再出现指数形式。
- d878dbf88 新增
addBucketValidation.ts,包含getQuotaBytes和isValidQuota。数量必须符合两位小数的模式,换算为大于零且小于2^63的有限整数,切换单位时重新校验。创建 thunk 在发送前再检查一次。 - 9fb0b0627 与 5a34232e7 要求保留期有效值在创建表单、其 thunk 和保留期编辑器中都是 1 到 2147483647 的整数。前者还让 API 拒绝小于等于零的保留期有效值。
分支上还有两个无关的显示提交(API 请求图表与 syscall 速率标签),不属于这两个问题。
PR #74 之后已在 main 上的后续修复。 它们改进同一处代码,不在该分支内:
- 2b18ff854(#75)用整数运算缩放小数配额,并拒绝浏览器写入 8 PiB 及以上的值,因此不会再发送不精确的值。
- 4da296098(#76)把其余
calculateBytes与显示路径改用同一个有界解析器。 - 9107a8281(#77、#78)在创建桶之前校验配额和保留期。此前
makeBucket容忍配额调用失败并返回成功,所以桶可能在没有所请求配额的情况下被创建。
为什么充分且必要
对 #72 充分。 列表处理程序和单桶处理程序现在调用同一个函数,所以两个字段的任何组合下它们都一致,包括 mcli 写入的旧式配额和 Console 写入的新式配额。界面读取处理程序返回的 quota 值,无需改动。
对 #73 充分。 所有把文本变成配额数量的路径现在都经过同一个有界换算:0.5 Gi 变成 536870912,868 Ei 及以上在变成 1 之前就被拒绝,切换单位会重新校验。保留期检查封住了两个表单中小数和超范围的情形。thunk 检查防止过期的表单状态。
必要。 没有共用的读取函数,列表响应的任何新使用方都会重犯同样的错误。没有在换算前校验,对数字字符串使用 parseInt 对任何大值都不安全。
刻意不包含。 以下内容不在本记录的修复之内:
- 任意 64 位精度:在分支上,小数数量经由 JavaScript 数字换算,所以
7 Ei这样的值会以 8070450532247929000 发送,而不是 8070450532247928832。这由 silo-console#75 跟踪,之后在main上通过拒绝此类写入处理; - 其余的
calculateBytes用法,由 silo-console#76 跟踪,之后在main上处理; - 三个复制带宽表单,换算模式相同,在 PR #74 中由单独的提交(facbf3bab)修复;
- 服务器:配额的存储、执行和管理 API 都不改。
成本与兼容性
- 不改 API 模式、服务器或
madmin-go。列表响应保留字段名quota,只是它携带的值现在是有效配额。 - 服务器上已有的配额,无论旧式还是新式,都不变。旧式配额照旧显示;原先被隐藏的新式配额现在显示。
- 以前被接受、现在被拒绝的输入是 868 Ei 及以上的数量(原本等于 1 字节)以及非整数或超范围的保留期。以前被拒绝、现在被接受的是小于一的小数,如
0.5 Gi。 - 已经用 1 字节配额创建的桶保持原样,见剩余限制。
- 回滚到旧版 Console 即恢复旧行为,无需迁移。
剩余限制
- 已被设成 1 字节配额的桶仍然被阻止写入。 用
mcli quota info ALIAS/BUCKET查找(大小为 1 字节即是迹象),然后设置期望的配额,或用mcli quota clear ALIAS/BUCKET清除。 - 尚未进入已打标的 Server 发布。 独立版 Console 2.6.0 已包含该修复,且 SILO
main已内嵌v2.6.0(e24831f53)。在下一个 SILO Server 发布推出之前,直到 20261026 的已打标 SILO Server 发布内嵌的 Console 界面仍有这两个缺陷。 - 大配额仍用
mcli。 在main上,浏览器拒绝写入 8 PiB(2^53 字节)及以上的配额,因为 JSON 数字只在此范围内是精确的(#75,由 2b18ff854 关闭)。分支顶端允许到 2^63,但经由 JavaScript 数字换算。 - 窄屏。 现有的响应式布局在 1200 px 以下隐藏桶列表的配额列;Summary 仍显示配额。
验证
在 Console 仓库中,对修复分支顶端 5a34232e7 实际执行(未在之后的 main 上重跑):
- Playwright unit 项目。
e2e/bucket-quota.unit.ts、e2e/bucket-retention.unit.ts、e2e/add-bucket-numeric.unit.ts和e2e/size-format.unit.ts:49 个测试全部通过。覆盖1000 Ei与8 Ei被拒绝、0.5 Gi写成 536870912 字节、切换单位会重新校验、未变化的配额不写入,以及保留期边界。 - Go 测试。
go test -mod=readonly ./api -run 'Quota|Retention|AccountBuckets|ListBuckets'通过(现代、旧式、冲突、空、零和 nil 配额的列表与详情响应)。 - 换算缺陷本身。 对修复前公式的一个小脚本给出:
1000 Ei为"1.152921504606847e+21",其parseInt为1;867 Ei是999582944494136300000,868 Ei是指数形式,所以 1 字节的情形从 868 Ei 开始。parseInt("0.5") === 0为真。 - 合并后的结果(来自报告,此处未重跑)。 issue 讨论报告合并后的改动在真实的 SILO、
mcli与 Console 环境中验证过(用mcli设置 100 GiB,在 Console 中改为 110 GiB,列表、详情与mcli一致为 118111600640 字节;创建时的 0.5 GiB 配额恰好存为 536870912 字节)。
仅凭代码推理、此处未执行:
- 服务器对 1 字节
Size的执行(cmd/bucket-quota.go); - API 对 8 Ei 至 867 Ei 的超范围数量的拒绝,以及 32 位模型对小数或超范围保留期的拒绝;
- 没有任何 Server 发布包含修复,依据
go.mod历史; - 派生点之前的历史,依据提交主题和日期。
参考
- Issue:silo-console#72、silo-console#73
- 修复:PR #74,合并提交 3d1dd56eb
- 写入端改动:georgmangold/console#28
main上的后续修复:silo-console#75(64 位精度,2b18ff854)、silo-console#76(calculateBytes,4da296098)、PR #79(创建时校验)- 发布页面:Console 2.6.0、Console 2.5.1、SILO 20261026
24 - Heal 演练不能删除数据
本文覆盖 Jasper Bernhardt(@dihmandrake)提交的 PR #280 中与演练(dry run)相关的部分,基于 main 69fcf50cd 评估。同一个 PR 还让深度扫描并发校验一个对象的各块盘。这项优化另行评审,不在本文范围内。该 PR 尚未运行 CI。
状态: 修复已通过审查,已在本地提交。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未合并或发布。
简短结论
缺陷属实,是 SILO 从上游 MinIO 继承来的。mc admin heal --dry-run 的说明是"只检查数据,不做修改"。在 main 上,只要一个对象版本的 xl.meta 没有读 quorum,演练 heal 仍会执行悬空对象清理:如果缺少元数据的盘全部回答"不存在"、且数量多于该版本的校验块数,deleteIfDangling 就会向每块可用的盘发出删除该版本的请求:仍持有该版本、且删除成功的每块盘都会失去它(连同数据目录),heal 随后报告对象不存在。运维人员为评估受损集群而运行演练,结果销毁的恰恰是他们正在调查的那些对象的最后几份元数据。
对象 heal 的修复是 PR #280 中五行的提前返回:演练中遇到元数据 quorum 失败时,heal 在清理之前返回 quorum 错误,以及一个根据观察到的盘状态构造的集合级结果。真实 heal 不受影响。它应进入下一个版本,与并行校验改动拆开交付,并保留贡献者署名。
我们的审计还发现了第二处演练写操作。PR #280 没有涉及它,本修复把它一并覆盖。 在多节点部署中,桶 heal 只向远端节点发送桶名和 remove 标志。没有演练标志,这些节点执行的就是真实的桶 heal:在缺少桶目录的盘上创建桶目录,或者在所有池中都找不到该桶时删除空的桶目录。它不删除对象,但演练不应改变任何东西。修复在对端消息中加入演练标志,并让远端处理函数遵守它,使远端节点走与本地节点相同的演练返回。改动是四行功能代码,加一个构造对端消息的小辅助函数,以及一个回归测试。
PR 中第二处与演练相关的改动,是在分片校验没有成功时,把盘报告为 unknown 而不是 ok。这是报告层面的改动,不是安全修复。如果只对演练生效,演练和真实 heal 会对同一块盘给出不同的报告。它可以留给一个同时修正两种模式的改动。
定性
| 维度 | 判断 |
|---|---|
| 性质 | 演练标志既没有传到 erasureObjects.healObject 中的悬空对象清理,也没有传到远端节点执行的桶 heal,两者都破坏了管理 heal 的安全承诺。继承自上游 MinIO;没有任何 SILO 提交引入或改动它们。 |
| 谁能触发 | 只有具备 heal 权限的管理员,通过设置了 DryRun 的管理 heal API 触发。例如对桶、前缀或对象,或带 --recursive 运行 mc admin heal --dry-run(隐藏参数)。全站演练还会遍历 .minio.sys/config,IAM 和服务器配置对象就在那里。 |
| 不受影响 | scanner、MRF、后台 heal 和磁盘 heal 从不设置 DryRun。SILO Console 没有调用 heal 的路径。真实 heal 的行为不变。 |
| 删除的内容 | 正在被 heal 的对象版本或删除标记。只要元数据 quorum 失败、且 isObjectDangling 判定该版本悬空,就会在每块可用的盘上尝试删除,每次成功的删除都会移除该版本(连同数据目录)。删除过程中的盘错误会写入审计,但不会返回。 |
| 其他改动 | 在多节点部署中,演练桶 heal 会让每个远端节点在自己的盘上创建缺失的桶目录;当所有池中都找不到该桶时,则删除空的桶目录。有内容的目录永远不会被删除。 |
| 严重程度 | 高。受影响的版本已经无法通过 S3 读取,真实 heal(以及用户桶中的 GET 或 HEAD)也会按同一规则清理它们。危害在于:运维人员的检查工具,做出了运维人员明确要求它不要做的不可逆删除。在任何人能决定如何恢复之前,最后几份元数据就已经没了。 |
| 发布紧急度 | 阻塞下一个版本。修复约十行代码,只改变演练行为,并且附带回归测试。 |
| 剩余工作量 | 拆分和审查已完成。剩下的是运行 CI,并在实际部署的多节点集群上验证一次演练桶 heal。 |
触发条件
需要同时满足三个条件:
- 带
DryRun的 heal 对某个对象版本进入healObject。heal 遍历会逐个版本分别 heal。 objectQuorumFromMeta失败:读错误归约后,有结果的盘不足一半;或者没有哪个校验块数(parity)得到足够多可读副本的一致报告。一个 parity 需要的一致副本数,等于它所对应的读 quorum,对普通对象就是数据块数。例如,一个 EC 3+1 的版本只有两份可读副本时,虽然有一半的盘作答,仍然会失败。isObjectDangling判定该版本悬空。此时还没有做任何分片检查,因此判定要求:- 至少有一份有效的
xl.meta; - 每块出错的盘都回答"文件不存在"或"版本不存在"。任何其他错误(盘离线、元数据损坏、桶目录缺失、超时)都会否决这个判定。heal 读取元数据时会一并读取小对象的数据,所以对于没有内联的小对象,即使
xl.meta还在,缺失的分片文件也会表现为"不存在"; - 对象版本:缺少元数据的盘数多于该版本的校验块数,也就是元数据副本少于数据块数。删除标记:超过一半的盘上没有它。
- 至少有一份有效的
举例来说,四块盘、EC 2+2。一个 1 MiB 的对象在三块盘上丢失了 xl.meta,这三块盘上的分片文件仍在。在 main 上,对该对象运行演练 heal,会把该版本从第四块盘(唯一还存有其元数据的地方)上删除,并报告"对象不存在"。
降级集群。 离线的盘回答 errDiskNotFound,这个错误会否决悬空判定。因此一块正常离线的盘会阻止清理,而不是导致清理;这一点我们已经验证过。判定只统计回答"不存在"的盘,并且无法分辨它们为什么这样回答。有些盘回答"不存在",但它们的回答并不权威:
- 一块已更换的盘:桶目录已经建好,但还没有收到这个对象;
- 一块丢失了
xl.meta、但分片文件仍在的盘。isObjectDangling的源码注释承认,这种情况原则上是可以恢复的。
而这正是运维人员会去运行演练的场景。这个规则对真实 heal 是否合适,是另一个继承下来的策略问题;演练根本不应该应用这个规则。
根因
healObject 把 opts.DryRun 读入 dryRun,并在任何修复写入之前返回,代码注释原文是:“After this point, only have to repair data on disk - so return if it is a dry-run”。处理元数据 quorum 失败的分支位于更前面,它调用 deleteIfDangling 时没有检查 dryRun。
deleteIfDangling 自己也无法检查。它接收的是 ObjectOptions,其中没有演练字段;它还被 GET、HEAD、CopyObject、PutObjectMetadata 和 PutObjectTags 路径共用,这些路径按设计就会清理悬空版本。它还直接从纠删集合获取盘,所以调用方无法限制它会碰到哪些盘。
healObject 中另一处 deleteIfDangling 调用,在处理无法修复对象的分支里。它位于演练返回之后,是安全的。
行为的来源
溯源
由上游 MinIO 引入,不是 SILO 引入的。 两个缺陷都来自上游提交,这些提交都是 SILO 分叉点(27742d469,2025-12-03)的祖先。它们存在于分叉前最后一个上游发布 RELEASE.2025-10-15T17-29-55Z、已归档的上游 master,以及从 RELEASE.2025-12-03T12-00-00Z 到 RELEASE.2026-10-26T00-00-00Z 的每一个 SILO 发布中。我们检查了每个 tag:healObject 中第一次 deleteIfDangling 调用位于演练返回之前,远端桶 heal 的消息不带演练标志。没有任何 SILO 提交引入、扩大或缩小这两个缺陷。
对象 heal。 上游曾经有这道保护。它的 purgeObjectDangling 只在 if !opts.DryRun && opts.Remove 时删除,来自 db2155551(#9159,2020-03-19,RELEASE.2020-03-19T21-49-00Z)。随后两处改动分阶段拆掉了它,第三处把结果留在了 healObject 中:
| 步骤 | 提交(PR) | 日期 | 首个上游发布 | 影响 |
|---|---|---|---|---|
去掉演练条件,保留 opts.Remove。 |
b7c5e45ff(#14053) |
2022-01-07 | RELEASE.2022-01-08T03-11-54Z |
演练加 --remove 会删除;单独的演练不会。 |
函数体改为调用没有 Remove 限制的 deleteIfDangling。 |
f19a414e0(#14273) |
2022-02-08 | RELEASE.2022-02-12T00-51-25Z |
单独的演练就会删除。 报告的行为从这个发布开始。 |
调用被内联进 healObject,位于演练返回之前。 |
136d41775(#15954) |
2022-10-27 | RELEASE.2022-10-29T06-21-33Z |
无。元数据仲裁失败的分支仍在演练返回之前。 |
| 该分支的错误返回被重新整理。 | 3549e583a(#19702) |
2024-05-09 | RELEASE.2024-05-10T01-41-38Z |
对判断条件无影响。当前这个分支的大部分行来自该提交。 |
桶 heal。 2024 年 1 月之前,按纠删集的桶 heal 会遵守 DryRun。节点级桶 heal 在 e31081d79(#18612,2024-01-10,RELEASE.2024-01-11T07-46-16Z)中取代了它。它的远端调用只发送桶名和 remove 标志,远端 handler 用这两个值构造 heal 选项。同一调用的本地实现会检查 DryRun,所以只有本地节点遵守。ff80cfd83(#18951,2024-02-02,RELEASE.2024-02-04T22-36-13Z)把调用迁到 grid,仍只带这两个值。上游 master 上这个缺口依然存在。
SILO 的改动。 分叉之后,SILO 对 heal 函数、cmd/peer-s3-client.go、cmd/peer-s3-server.go 以及 cmd/peer-rest-server.go 中的 heal handler 所做的改动:
| SILO 提交 | 日期 | SILO 发布 | 影响 |
|---|---|---|---|
0b1c21cb4、2a04d5f48 |
2026-10-25 | 仅 RELEASE.2026-10-25T00-00-00Z |
没有影响。 前者在 healObject 的元数据仲裁分支之后加入了版本代际仲裁检查,后者当天就把它移除了。它位于清除逻辑之后,既没有保护也没有扩大清除。 |
e59a3d938 |
2026-09-11 | RELEASE.2026-09-16T00-00-00Z 及之后 |
没有影响。 调用方已持有命名空间锁时,HealObject 沿用该锁的上下文。 |
9b11dc946 |
2026-09-03 | RELEASE.2026-09-03T13-18-01Z 及之后 |
没有影响。 只是替换了 errgroup 辅助包的导入路径。 |
7ce7fc8d1 |
2026-10-29 | 尚未发布 | 没有影响。 heal 遍历同时列出 free version,不含演练逻辑。 |
SILO 没有改动 deleteIfDangling、isObjectDangling、remotePeerS3Client.HealBucket 或 HealBucketHandler,也没有在这条路径上增加任何演练保护。PR #280 仍处于开放状态,带有对象 heal 的保护;桶 heal 的部分只是本记录提出的方案。
证据。 提交的祖先关系、首个发布、两个缺陷在每个 tag 上的存在,以及这些函数中没有 SILO 改动,都用 git 核对过。2022 年各步骤的行为(先是演练加 --remove,之后是单独的演练)来自对改动的阅读,没有在那些版本上运行。当前行为由验证一节中的测试复现。
方案比较
方案 1:在 healObject 中提前返回
这是 PR #280 的做法。当 objectQuorumFromMeta 失败且设置了 dryRun 时,healObject 用观察到的错误构造 defaultHealResult,连同 quorum 错误一起返回。改动只有一个函数中的五行,只影响演练,真实 heal 的行为与现在完全一致。它的局限是只保护这一个调用点:将来如果别处有演练路径调用 deleteIfDangling,需要自己的保护。
方案 2:把演练标志传入 deleteIfDangling
给 deleteIfDangling 增加一个参数或一个 ObjectOptions 字段,让它在演练中只做判定、不做删除。它还可以向报告返回"将会清理"的判定。代价是六个调用方都要改动,其中四个是对象读取和元数据更新路径,根本没有演练的概念。而且将来的调用方同样可能忘记传这个标志,就像可能忘记提前返回一样。为这点稳健性付出这么多改动并不划算;“将会清理"的报告应该归入演练报告的重新设计。
方案 3:集中式保护
让演练 heal 使用只读包装的盘,这样任何写入都会失败。这是唯一能从结构上防住将来失误的方案。但 StorageAPI 有几十个方法,deleteIfDangling 从纠删集合而不是从调用方获取盘,而桶 heal 的缺口位于对端 RPC 层,这样的包装也覆盖不到。作为发布修复,它的范围太大。
同一思路的廉价版本适用于测试。PR 的合成测试盘内嵌一个 nil 的 StorageAPI,任何测试未预期的写操作都会 panic。回归测试保留这种写法。
远端节点上的桶 heal
要阻止远端节点在演练中写入,有两种办法:
- 传递标志。 在对端消息中携带演练标志,并在远端处理函数中遵守它。这样远端节点会走与本地节点相同的提前返回。消息形态、处理函数和错误归约都不变;将来演练桶报告补全后,远端节点也能报告自己的状态。
- 跳过调用。 让
S3PeerSys.HealBucket在演练时不调用远端节点。这在滚动升级期间也能防住旧节点,但放弃了将来演练报告中远端的那一半,还要在分发逻辑中加一个演练特例。
我们选择传递标志。修复之前,每个节点的行为本来就等同于标志为 false。升级期间,混合版本的集群不会比现在更差;所有节点都运行修复后,缺口即被关闭。
关于报告改动
在演练的深度扫描中,如果一块盘的元数据读取成功,但分片校验因为缺失或损坏以外的原因没有成功,PR 会把它报告为 unknown。这些原因包括:检查过程中盘离线、卷缺失、超过截止时间、被取消、访问被拒绝。在 main 上,两种模式都把这块盘报告为 ok,修复决策也会忽略这些结果。
PR 只改了演练模式。它自己的测试对同一状态,期望演练给出 unknown、真实 heal 给出 ok。mc admin heal 在给对象着色、以及按在线盘数给对象分组时,只统计 ok 的盘。于是同一个对象在演练中会显得比随后的真实 heal 中更糟。
把未经校验的盘报告为 ok 确实是一个准确性问题:一次完整性审计可能对它根本没检查过的数据报告"干净”。但这个问题两种模式都有,而且它既不删除也不修改数据。应该同时修正两种模式,包括 heal 序列根据同样的盘状态推导出的"quorum loss"说明。
建议: 本次发布中,对象 heal 采用方案 1,桶 heal 采用传递标志;推迟报告改动。
修复方案
修复分两部分。第 1 部分是 PR #280 中贡献者的改动,第 2 部分来自维护者的审计。
第 1 部分:对象 heal 在悬空清理之前返回
代码。 原样采用 PR #280 在 healObject 中的第一处改动:
演练中,集合级结果把每块盘的读错误映射为盘状态,例如缺少 xl.meta 的盘为 missing。这次调用传入的是 FileInfo{},所以结果的大小为零,校验块数和数据块数取默认值。返回的错误就是 objectQuorumFromMeta 返回的错误,经过 toObjectErr 转换:多数盘回答"不存在"时为版本或对象不存在;多数副本损坏时为损坏错误;或者为读 quorum 不足。真实 heal 在报告 quorum 失败之前仍会丢弃各盘的错误,与现在一致。
这些信息并不都能到达管理报告。当每个池都回答"不存在"时(这是悬空的常见情形),erasureServerPools.HealObject 会用一个空条目替换各集合的结果,所以报告中只有"不存在"错误,没有逐盘状态,这与 main 在清理之后的表现相同。只有错误是其他类型时(例如元数据损坏或读 quorum 不足),观察到的盘状态才会出现在报告中。读 quorum 不足也涵盖一部分悬空版本:一个 EC 3+1 的版本有两份可读副本、两个"不存在"回答时会被判定为悬空,修复后它的演练报告读 quorum 不足,并带有全部四块盘的状态。
测试。 采用 PR 中不依赖并行校验的三个测试及其辅助代码:
TestNativeAuditDryRunPreservesFaults使用合成盘,覆盖元数据 quorum 失败、元数据损坏、分片缺失或损坏、内联数据损坏,以及没有 quorum 的删除标记;TestNativeAuditDryRunPreservesDanglingFiles使用真实盘:从四块盘中的三块删除xl.meta,检查演练不改动任何文件;TestNativeAuditNonDryRunStillDeletesDangling是对照组:不带DryRun的相同场景仍会走到清理。
三处小改动:
TestNativeAuditDryRunPreservesFaults把删除计数断言放到报告断言之前,这样在未修复的代码上,失败信息会直接指出删除;- 对每种 quorum 情形,它断言转换后的错误(版本不存在,或损坏错误),取代原来针对
errFileNotFound的检查——经过toObjectErr之后,那个检查不可能失败; - 合成盘去掉了
CheckParts桩,它只被 PR 中并行校验和报告相关的测试使用。
第 2 部分:远端节点在桶 heal 中遵守演练标志
代码。 在 peerS3BucketDeleted 旁边新增键 peerS3BucketDryRun = "dry-run"。remotePeerS3Client.HealBucket 通过一个小编码函数构造消息并加入这个键,HealBucketHandler 把它读入 madmin.HealOpts.DryRun:
设置了 DryRun 时,healBucketLocal 本来就会在任何 MakeVol 或 DeleteVol 之前返回,所以它不需要改动。RPC 处理函数、它的注册和返回类型也都保持不变。
测试。 TestPeerHealBucketDryRun 启动两个 grid 管理器,在其中一个上注册对端处理函数,再从另一个调用 remotePeerS3Client.HealBucket。这样请求会经过真实的客户端、grid 连接和处理函数,作用于一个四块盘的集合,其中一块盘上的桶目录已被删除。两次演练请求(一次带 Remove,一次不带)既不能重建缺失的目录,也不能删除空目录。随后两个对照组证明测试场景能走到这两种写操作:一条不带演练键的消息(旧节点发送的形态)会重建目录;一次带 Remove 的真实 heal 会删除空目录。该测试不使用任何新增标识符,因此能在 main 上编译,并在第一个演练断言处失败。
其他对端 heal 调用。 我们逐一检查过,没有一个存在同样的缺口:
- format heal 只从协调节点保存
format.json,并且只在它自己的演练检查之内保存。它会先用常规的存储初始化打开各块盘,而这一步发生在演练检查之前,见剩余限制。在当前代码中,管理 heal 序列不会把 format heal 入队; - 后台 heal 状态调用是只读的;
- 管理 heal 请求只有在凭 token 轮询已有序列时才会被代理到其他节点,从不用于启动序列。
交付
- 把演练改动和上述测试拆成独立的提交和 PR,与并行校验改动分开。后者需要单独评审(慢盘上的 I/O 并发和取消处理),也需要单独的 CI。
- 保留贡献者署名。生产代码改动与贡献者已签署 DCO 的提交完全一致,但测试是经过调整的子集,所以拆分后的提交是维护者提交,附带 Jasper Bernhardt 的
Co-authored-by标注,并引用 PR #280。 - PR #280 随后继续承载并行校验;如有需要,也可以承载一个同时覆盖两种模式的报告改动。
- PR 说明中写明,实现和测试由 AI 编写,贡献者无法独立确认其正确性。演练改动足够小,维护者已按下文所述独立验证;本文依据的是这些验证,而不是 PR 自身的说法。
- 第 2 部分作为单独的维护者提交,放在同一个 PR 中交付。它不含贡献者代码,贡献者署名保留在第 1 部分上。
为什么方案充分且必要
我们审计了管理演练 heal 可能修改数据的每一个位置:
| 路径 | 可能的写操作 | main 上 DryRun 时 |
修复后 |
|---|---|---|---|
healObject,元数据 quorum 失败 |
deleteIfDangling(对所有盘调用 DeleteVersion) |
会执行 | 不再执行 |
healObject,无法修复的对象 |
deleteIfDangling |
位于演练返回之后 | 不变 |
healObject,修复 |
向临时区写分片、RenameData、删除远端数据目录、清理临时区 |
位于演练返回之后 | 不变 |
HealObject,发现 bitrot 后的深度扫描重试 |
以相同选项再次运行 healObject |
同上 | 同样受修复保护 |
healObjectDir |
删除悬空目录;为缺失的目录执行 MakeVol |
删除需要 !dryRun && remove;MakeVol 位于演练返回之后 |
不变 |
HealObjects 遍历 |
CheckAbandonedParts |
需要 Remove && !DryRun,且 checkAbandonedParts 会再次检查 DryRun |
不变 |
全站 heal 中的 .minio.sys/config |
同一个 healObject 路径 |
会执行(读路径的清理跳过 .minio.sys,heal 不跳过) |
不再执行 |
桶 heal,本地节点(healBucketLocal) |
DeleteVol、MakeVol |
在任何写操作之前返回 | 不变 |
| 桶 heal,远端节点 | MakeVol、非强制 DeleteVol |
会执行:没有发送演练标志 | 不再执行:标志被发送并得到遵守(升级期间旧节点仍会忽略它) |
HealFormat |
写入 format.json 并替换盘;盘初始化可能创建内部目录,或迁移旧的 format.json |
保存位于 if !dryRun 之内,但初始化发生在这个检查之前。管理 heal 序列不会把它入队;当前代码中唯一的调用方是新盘监视器,且 dryRun 为 false |
不变 |
| 全站 heal,桶列举 | ListBuckets 会填入创建时间。对于元数据不在缓存中的桶,这会经由普通的对象读路径加载桶元数据,可能迁移旧版桶配置文件,并为 .metadata.bin 入队 MRF 修复 |
会执行,走的是服务器在启动时和定期元数据刷新时尝试的同一次加载 | 不变;见剩余限制 |
heal 的元数据读取(ReadVersion)、CheckParts、VerifyFile、heal 遍历 |
无 | 只读 | 不变 |
heal 路径本身不会向 MRF 入队;只有上面的桶元数据加载可能入队。被执行的清理在盘以外唯一的副作用,是一条 DeleteDanglingObject 审计记录,只在配置了审计日志时写入。
充分性。 两部分都到位、且所有节点都运行修复后,管理演练 heal 不再执行 heal 自身的写操作:对象 heal 在清理之前返回;桶 heal 在每个节点上(而不只是协调 heal 的那个节点)都在 MakeVol 和 DeleteVol 之前返回。heal 修复逻辑之外,还有两处继承下来的影响,列在剩余限制中:全站列举时的桶元数据加载,以及直接调用 format heal 时的盘初始化。
必要性。
- 在演练中,quorum 失败分支是唯一一处在演练返回之前执行的对象 heal 写操作。没有第 1 部分,清理就仍然可达。
- 远端桶 heal 是管理演练能走到的另一处 heal 写操作。没有第 2 部分,多节点部署中的每个远端节点在演练时仍会修复桶目录。
- 报告改动对安全不是必需的。
有意排除的范围:
checkObjectWithAllParts中的并行校验;unknown报告改动;- 真实 heal 和读路径的悬空清理策略。
代价与兼容性
- 不改变 API、配置、指标、存储格式或依赖。真实 heal 不变。
- 桶 heal 的对端消息新增一个可选键
dry-run。这个消息是字符串映射,grid 编解码会解出收到的每一个键,所以接收方不认识的键会被原样携带并忽略。我们验证过:带新键的消息能完整往返编解码,旧处理函数也能接受它。没有新增 RPC 处理函数,也没有处理函数的版本变更。 - 滚动升级:
- 新节点调用旧节点。 旧节点忽略这个键,执行真实的桶 heal,与现在完全相同。
- 旧节点调用新节点。 消息中没有这个键,新节点读到
false,执行真实的桶 heal,与现在完全相同。 - 只有在所有节点都运行修复之后,演练桶 heal 才不再改动桶目录;混合版本的集群任何时候都不会比现在更差。
- 演练结果的变化如下:
- 悬空版本不再被删除;
- 在集合级,各盘报告观察到的状态,而不是
ok。只有错误不是"不存在"时,管理报告中才能看到这些状态,因为当每个池都回答"不存在"时,池聚合会丢弃集合结果; - 返回的是经过转换的
objectQuorumFromMeta错误,而不是清理之后的结果; - 演练不再产生
DeleteDanglingObject审计记录; - 远端节点不再改动桶目录,演练桶报告中远端的部分现在也和本地部分一样是空的。
- 回滚即恢复旧行为,两个方向都不需要迁移。
剩余限制
- 混合版本集群。 在所有节点都运行修复之前,演练桶 heal 仍会让旧节点创建缺失的桶目录或删除空的桶目录。发布说明应写明:只有升级完成后,演练才不再改动桶目录。
- 全站演练会加载桶元数据。 它的桶列举会填入创建时间。对于元数据不在缓存中的桶,这是一次普通的元数据加载,与服务器在启动时和定期刷新时尝试的加载相同(每轮刷新在上一轮结束 15 分钟后开始):它可能把旧版桶配置文件迁移进
.metadata.bin,并为缺失的.metadata.bin副本入队 MRF 修复。它不触及对象数据。服务器自己的加载也会做出同样的改动,但迁移或修复何时完成没有保证。让演练列举跳过创建时间即可避免这次加载,这属于后续工作。 - format heal 在演练检查之前初始化各块盘。 打开盘时可能创建内部目录并迁移旧的
format.json。在当前代码中,管理 heal 序列不会把 format heal 入队,所以管理演练不会走到这里。要让 format 检查只读,需要只读的盘初始化。 - 演练桶 heal 不报告盘状态。
healBucketLocal在把观察到的状态附加到结果之前就返回。修复后,这在每个节点上都成立,而不只是本地节点,所以演练给出的桶摘要没有逐盘细节。在演练返回之前填入观察到的状态,属于报告的后续改动。 - 过去的演练无法撤销。 如果在本修复之前曾对受损对象运行过演练 heal,被它清理掉的版本已经不存在了。如果配置了审计日志,它们会以
DeleteDanglingObject记录出现,其 caller 标签指向 heal 代码。 - 演练不会提示"将会清理"。 修复后,一个悬空版本在管理报告中要么表现为一个没有盘状态的"不存在"错误(每个池都回答"不存在",池聚合丢弃了集合结果),要么表现为带有盘状态的读 quorum 不足错误。两种情况下,集合结果本身的大小都为零、校验块数为默认值。它不会被标注为真实 heal 将要删除的对象。“将会清理"的判定可以放进报告的后续改动。
- 未经校验的盘仍被报告为
ok,两种模式都是如此,直到报告的后续改动完成。 - 真实 heal 的悬空清理策略不变。 它统计的"不存在"回答,可能来自并不权威的盘。重新审视这个策略是另一项工作。
验证
所有运行都用 go test -run 只选相关测试,参数为 -p 2,标签为 kqueue,dev。下文的"main"指一个位于 69fcf50cd、只加入了新测试文件的一次性 worktree。
已执行:main 上的缺陷。
- 在合成盘上(四块中有三块回答"版本不存在”)运行演练
HealObject,会向每块盘发出DeleteVersion调用,对象版本和删除标记都是如此。 - 把其中一块盘改为离线后,真实 heal 一次也不调用,这证实了离线否决。
- 在真实盘上,演练 heal 删除了最后一份幸存的
xl.meta。 - 把只含桶名和 remove 标志的消息(即 main 的远端客户端为演练请求发送的形态)交给对端处理函数,它会重新创建缺失的桶目录;本地
healBucketLocal带DryRun时不会。PR #280 的改动对此没有影响。
已执行:提交的测试。
- 在 main 上:
TestNativeAuditDryRunPreservesFaults的元数据 quorum 和删除标记子测试在删除断言处失败;元数据损坏子测试在报告断言处失败:那里的清理被否决了,但 main 把每块盘都报告为ok;TestNativeAuditDryRunPreservesDanglingFiles失败:演练之后,持有最后一份xl.meta的盘上,对象目录已经不存在;TestPeerHealBucketDryRun能编译,并在第一个演练断言处失败:远端节点重建了缺失的桶目录;- 对照组通过:
TestNativeAuditNonDryRunStillDeletesDangling,以及分片缺失、分片损坏和内联数据损坏三个子测试。
- 应用修复后,以上测试全部通过,
-race -count=3也通过。 - 对
TestPeerHealBucketDryRun的变异检查:客户端编码函数漏掉这个键时测试失败,处理函数忽略这个键时测试也失败。 - 新测试与已有的
TestHealing*、TestHealObject*、TestHealCorrectQuorum、TestHealLastDataShard、TestHealEmptyDirectoryErasure、TestIsObjectDangling和TestFreeVersionHealObjects在同一进程中以-race -count=2一起运行,全部通过。已有的TestPeer*、TestErasureHeal和 grid 包测试在不启用 race 检测时也通过。TestErasureHeal在 race 检测下会在 ring buffer 中 panic,在 main 上同样如此,与本修复无关。 gofmt和go vet ./cmd都没有报告问题。
已执行:对抗性评审。
- 带上新键的消息能完整往返 grid 编解码,main 的处理函数接受并忽略它:重建缺失目录,并删除空目录。这就是"新节点调用旧节点"的情形。
- 同时存在于两个池中的对象仍会经过两个池进行 heal;健康的第二个池仍会在聚合中胜出。
- 每个对端都返回空的演练结果时,归约不会报错;扇出过程改写
Remove和Recreate,但保留DryRun。 - 桶元数据缓存为空时,全站演练会迁移一份旧版桶配置,并为缺失的
.metadata.bin副本入队 MRF 修复,main 和修复后都是如此。让列举跳过创建时间可以同时避免这两者。 - 直接调用的
HealFormat演练会重建缺失的.minio.sys/config目录,main 和修复后都是如此。 - 在管理层,一次悬空演练报告一个带"不存在"错误的空条目;在集合层,同一次调用报告三块
missing盘。一个有两份可读副本的 EC 3+1 版本报告读 quorum 不足,并带有四块盘的状态,而 main 会清理它。 - 全站演练序列会 heal
.minio.sys/config中的对象。修复后,一个悬空的配置对象会保留它最后一份xl.meta。
根据代码推断,未执行:
- 只有管理 heal API 会设置
DryRun; - main 的远端客户端只发送这两个字段;
- 没有其他对端 heal 调用会丢掉这个标志;
mc admin heal如何使用盘状态;- 上游历史。
未运行:
- 完整的
./cmd测试; - 远端 CI;
- 实际部署的多节点集群和混合版本升级。对端测试使用真实的 grid 连接,但两个管理器位于同一个测试进程中;
- 端到端的
mc admin heal --dry-run; - 并行校验相关的测试。
集成验证。 这个修复已摘取到下个版本的同一个集成分支上,位于 #278 和 #281 之后,与锁域、#259、#285、delete-all 和 Console 的修复放在一起。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 对抗性审查第 1 轮 | 要求修改。五个问题:全站演练的桶列举会加载未缓存的桶元数据,可能迁移旧版桶配置并入队 MRF 修复(原本就有);真实盘测试夹具会遗留后台 worker 和全局状态;直接调用的 format heal 在演练检查之前初始化各块盘(原本就有);每个池都回答"不存在"时,池聚合会丢弃集合级的盘状态(原本就有);测试中有一个错误断言不可能失败。另外对本文提出十一处更正。结果:列举的影响被认定不阻塞本修复,因为服务器在启动时和定期刷新时会尝试同一次加载;已在文中说明,并列出后续工作。夹具问题有异议:测试沿用了已有 heal 测试的夹具惯例,并且与这些测试一起进行的 race 运行全部通过。format 和聚合两个问题已在文中说明。该断言现在对每种情形检查转换后的错误。所有更正均已采纳。 |
| 对抗性审查第 2 轮 | 批准,附带小问题。审查方撤回了列举和夹具两个问题,不再作为这两部分的发布问题。两处措辞问题:部分悬空版本会报告带盘状态的读 quorum 不足;定期刷新没有完成时间上的保证。另有三处更正。均已采纳。 |
参考
- 贡献者 PR:#280
- 评估基线:69fcf50cd
- 上游历史:minio/minio#14053、minio/minio#14273、minio/minio#15954、minio/minio#18612
- 相关:对象自愈、
mc admin heal
25 - 大型单池集群的锁域
本文覆盖在审查恢复副本修复(恢复副本:过期的是副本,不是对象)时发现的锁域缺陷,评估基线为 main 上的 69fcf50cd。目前尚未提交 issue。
状态: 修复已通过审查,已在本地提交。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未合并或发布。
简短结论
锁域分裂是真实的,继承自上游 MinIO,并且只在一个池中的各纠删集并不使用相同主机时才存在。按自动布局,这意味着单池有 18 台或更多主机;不超过 17 台主机时,每个集都包含所有主机,锁组完全相同。
在这样的池里,DELETE、批量删除、CopyObject 以及 upload ID 守卫使用的对象锁,取自第一个纠删集的 locker。PUT、分片上传完成、标签/保留/法定保留/元数据更新、恢复(restore)、转储(transition)和修复(heal)使用的锁,取自键所哈希到的集的 locker。对于哈希到主机与第一个集不同的集的键,两把锁永远不会相遇,所以这些操作之间并不互斥。xlStorage 自身没有按键的串行化:RenameData、DeleteVersion、UpdateMetadata、WriteMetadata 都是读出 xl.meta、在内存中修改、再写回。
探针让每个集使用各自独立的锁表,在同一个键上让 PUT 与 DELETE、以及与仅元数据的 CopyObject 竞争,共享锁表 2100 次迭代,分裂锁表 2100 次迭代。共享锁表下没有任何异常。分裂锁表下:
- 在刻意收紧的竞争中,多数迭代出现已确认的版本或删除标记在该集部分磁盘上缺失;
- 已确认的
PUT只落在少于读仲裁数的磁盘上,甚至一块也没有; - 未版本化的对象只留在 16 块盘中的 1 到 5 块上;
- 仅元数据的复制在两个调用都返回成功后,写回了旧内容,或使对象不可读。
把单池的 DELETE 与 CopyObject 的锁路由到哈希集,同一探针中的全部异常消失。修复对所有原本使用第一个集的单池对象锁都这样做:各种形式的 DELETE、批量删除(每个集一把锁,各集并发删除)、COPY 的目标、SelectObjectContent、批量密钥轮换以及 upload ID 守卫。COPY 处理器先检查源名称,再在读取源对象之前一起获取源锁和目标锁。它不改变线上协议,但只有所有节点都运行它之后才能保护一个池,所以受影响的池需要排空后重启,而不是滚动升级(滚动升级)。
定性
| 方面 | 评估 |
|---|---|
| 性质 | 池层与集层之间的锁域不一致。自 RELEASE.2024-10-02T17-50-41Z 起继承自上游 MinIO,不是 SILO 的回归。SILO 增加了依赖该锁的功能(If-Match DELETE),并删除了一条警示注释,但从未改变过路由。 |
| 受影响部署 | 分布式、单池,且某些集使用的主机组与 0 号集不同的自动或显式布局。对 http://host{1...N}/disk{1...D},即 N 大于等于 18,与 D 无关。更小的集群只有在集大小小于主机数时才受影响(MINIO_ERASURE_SET_DRIVE_COUNT,或手工排序的参数列表)。 |
| 不受影响 | 单节点部署(所有集共享一张进程内锁表)。多池部署(写路径同样获取池级锁)。自动布局下不超过 17 台主机的池。 |
| 受影响操作 | DELETE 对 PUT、CopyObject、标签/保留/法定保留/元数据更新、restore、transition、heal、复制写入;ILM 过期对覆盖写;If-Match DELETE 对任何写者;批量删除;complete、abort、list-parts 的 upload ID 守卫对 PutObjectPart。 |
| 严重程度 | 影响高,触及面窄。同一键上的并发竞争可能丢失已确认的写入或删除标记,使对象只留在少数磁盘上,或使成功的元数据复制把数据回滚。不影响其他键,也不存在跨租户暴露。 |
| 可达性 | 竞争需要两个写者在几毫秒的提交窗口内操作同一个键。普通 S3 客户端很少这样做,但 ILM 过期遇到刚发生的覆盖写、复制、重试风暴,以及对同一个键反复覆盖又删除的客户端(队列、锁、临时文件)都可能触发。强制时很容易复现,正常流量中少见。在 18 台主机的集群中,大约一半的键(其集的主机组与 0 号集不同)暴露。 |
| 发布紧迫度 | 计划在下一个版本修复,而非热修复。改动局限于池层和 COPY 处理器;受影响的池以排空后重启的方式升级(滚动升级)。 |
| 剩余工作量 | 修复与测试已编写。剩余:远端 CI,以及在分布式池上的验收运行。 |
锁组如何构建
每个纠删集有自己的 locker 列表:该集端点涉及的每个不同主机一个 locker(newErasureSets)。分布式锁需要该列表的严格多数,主机上的锁服务器对同一资源名一次只授予一个持有者。两个针对同一键的请求,只有在询问相同主机时才会互斥。两个列表相同,则互斥;完全不相交,则各自拿到自己的多数,两个请求都被授予;部分重叠时,只要两个仲裁数能放进并集,即 qA + qB <= |A ∪ B|,仍然可以同时被授予。
端点展开时最左侧的省略号变化最快,所以在 http://host{1...N}/disk{1...D} 中,第 i 个端点的主机是 i mod N。集是 S 个连续端点的切片,S 是 N*D 的、不超过 16 且使集数最少的最大约数。用真实的 GetAllSets 检查得到:
| 布局 | 集大小 S | 锁组 |
|---|---|---|
| N = 2 到 17,D 为 1 到 32 的任意值 | S >= N | 每个集都含所有主机,锁组相同。 |
| N = 18,任意 D | 9 | 0 号集:主机 1-9。1 号集:主机 10-18。互不相交。 |
| N = 20 | 10 | 两个互不相交的一半,在各集之间交替。 |
| N = 24 | 12 | 两个互不相交的一半。 |
| N = 32 | 16 | 主机 1-16 与 17-32,交替。一半的键暴露。 |
| N = 21 | 7 | 三个互不相交的三分之一。 |
| N = 25 | 5 | 五个互不相交的组。五分之四的键暴露。 |
| 单节点,任意 D | 任意 | 所有集共享一张锁表。 |
扫描覆盖了 N 从 2 到 64;N 不超过 17 时 D 取到 32,其上 D 取 {1, 2, 3, 4, 6, 8, 12, 16}。N 大于等于 18 的每个有效布局锁组都不同,且在每一个中两个仲裁都可以同时被授予。当集大小被强制设为小于 N 时,更小的 N 也是如此。配置文件布局以同样的方式把磁盘轮流分配到各节点。
操作表
单池,main 上 69fcf50cd。“0 号集”指 erasureServerPools.NewNSLock(serverPools[0].sets[0]);“哈希集”指 getHashedSet(object) 的 erasureObjects.NewNSLock。
| 操作 | 使用的锁 | 锁域 |
|---|---|---|
PutObject(池层在任何池锁之前直接返回 serverPools[0].PutObject;putObject 在集层加锁) |
er.NewNSLock |
哈希集 |
带条件或带副本调和的 PutObject |
er.NewNSLock |
哈希集 |
CompleteMultipartUpload,对象提交 |
er.NewNSLock(object) |
哈希集 |
CompleteMultipartUpload,upload ID 守卫 |
z.NewNSLock(object/uploadID) |
0 号集 |
AbortMultipartUpload、GetMultipartInfo、ListObjectParts 守卫 |
z.NewNSLock(object/uploadID) |
0 号集 |
PutObjectPart(分片锁、upload ID 读锁) |
er.NewNSLock |
哈希集 |
DeleteObject(池层总是加锁;集层不加) |
z.NewNSLock |
0 号集 |
DeleteObjects 批量(对所有名称一把锁) |
z.NewNSLock |
0 号集 |
CopyObject,仅元数据与重写(池锁覆盖带 NoLock 的回退 PutObject) |
z.NewNSLock |
0 号集 |
PutObjectTags、PutObjectMetadata(保留、法定保留)、DeleteObjectTags |
er.NewNSLock |
哈希集 |
RestoreTransitionedObject、TransitionObject |
er.NewNSLock(restore 复用带锁的 PutObject、分片、CompleteMultipartUpload、CopyObject) |
哈希集 |
Heal(healObjectInPool 仅在 !SinglePool() 时取池锁) |
er.NewNSLock |
哈希集 |
目标端的复制写入(PutObject、元数据、复制) |
同上述操作;另有经 objectAPI.NewNSLock 的 /[replicate]/<key> |
哈希集;串行器在 0 号集 |
复制的 DELETE |
z.NewNSLock |
0 号集 |
ILM 过期(包括精确 key 的整对象删除;带 Expiration 的 DeleteObject;规则在锁内重新评估) |
z.NewNSLock |
0 号集 |
批量密钥轮换(NewNSLock,然后带 NoLock 的 CopyObject) |
z.NewNSLock |
0 号集 |
SelectObjectContent(读锁) |
z.NewNSLock |
0 号集 |
GetObject、HeadObject |
er.NewNSLock 读锁 |
哈希集 |
列举恢复(listingRLock,SILO) |
0 号集,然后源集 | 两者 |
在多池部署中,PutObject、CompleteMultipartUpload、标签与元数据更新、transition、restore、heal 也会取池锁并向下传递 NoLock,所以所有写入都与 DELETE 共用 0 号集。分裂的根源是单池捷径 if z.SinglePool() { return z.serverPools[0].X }:它让写者跳过池锁,但没有让 DELETE 与 CopyObject 跳过。
根因
两个选择叠加在一起。
- 池层自己为
DELETE与CopyObject加锁,并且总是通过第一个集。erasureServerPools.NewNSLock返回serverPools[0].NewNSLock,erasureSets.NewNSLock返回sets[0].NewNSLock。当每个写入也都取池层锁时(多池就是这样),这是正确的。erasureObjects.DeleteObject自己不加锁。 - 单池的写者跳过池锁,使用它所哈希到的集的锁。 该集的 locker 由它自己端点的主机构成。
当两个锁组是同一份主机列表时,这两个选择描述的是同一个锁域。当它们不同时,同一键上的 DELETE 与 PUT 持有的锁互不相遇。
xlStorage 不会修复这一点。RenameData 读取目标 xl.meta,加入版本,写到暂存路径,再重命名。DeleteVersion 读取,移除版本,把数据目录移入回收站,再写回或删除。UpdateMetadata 与 WriteMetadata 读取、修改、写入。除调用方持有的命名空间锁之外,没有任何东西在同一个键、同一块盘上串行化它们。上层还有各自的先检查后行动:DeleteObject 依据未加锁的读取决定写标记还是清除,悬空对象清理会清除一个在提交中途看起来不完整的版本,仅元数据的 CopyObject 会写回它先前读到的 FileInfo 快照。
行为的来源
上游起源,分几步形成。 下列提交都是上游最后一个 master 7aac2a2c5 的祖先,且上游 master 今天的代码与此相同。
| 成分 | 提交 | 日期 | 首个上游版本 |
|---|---|---|---|
| 每个集独立的 locker 组 | e9b2bf00a,minio/minio#8492 | 2019-11-13 | RELEASE.2019-12-17T23-16-33Z |
| 每个集内每台主机一个 locker | 4550ac6ff,minio/minio#11052 | 2020-12-10 | RELEASE.2020-12-12T08-39-07Z |
批量删除通过 getHashedSet("") 加锁(批量与单键的分裂由此开始) |
ab7d3cd50,minio/minio#8974 | 2020-02-21 | RELEASE.2020-02-27T00-23-05Z |
CopyObject 取池锁 |
200caab82,minio/minio#13280 | 2021-09-22 | RELEASE.2021-09-23T04-46-24Z |
DeleteObject 的锁从集层移到池层 |
d350b666f,minio/minio#15521 | 2022-08-18 | RELEASE.2022-08-22T23-53-06Z |
| 池锁总是落在 0 号池 | 6c746843a,minio/minio#20356 | 2024-08-30 | RELEASE.2024-09-09T16-59-28Z |
| 集锁总是落在 0 号集:造成单键分裂 | afea40cc0 | 2024-09-27 | RELEASE.2024-10-02T17-50-41Z |
直到 RELEASE.2024-09-13T20-26-02Z,只给一个名称时 erasureSets.NewNSLock 返回 getHashedSet(objects[0]).NewNSLock。因此单池的池锁落在哈希集,与 PutObject 同域,单键 DELETE 与 CopyObject 是一致的。只有多名称锁(批量删除)使用另一个集 getHashedSet("")。afea40cc0(Harshavardhana,2024-09-27)把它换成了 sets[0],使多对象删除能与授予其他调用者的锁竞争。当每个写者都取池层锁时(多池如此),这是对的;对于写者在集层加锁的单池则是错的。该提交标题带有“(#93)”,这不是公开 minio/minio 仓库中的编号,所以没有可链接的上游 pull request。
首批受影响版本。 上游:单键 DELETE 与 CopyObject 自 RELEASE.2024-10-02T17-50-41Z;仅批量删除更早。SILO:afea40cc0 与 d350b666f 是首个 SILO 版本 RELEASE.2025-12-03T12-00-00Z 的祖先,也是此后直至 RELEASE.2026-10-26T00-00-00Z 每个版本的祖先。每个 SILO 版本都带有这一分裂。
改变了风险的 SILO 提交。
| 提交 | 日期 | 首个 SILO 版本 | 影响 |
|---|---|---|---|
40bee4b7b If-Match DELETE(issue #10,PR #145) |
2026-09-06 | RELEASE.2026-09-16T00-00-00Z |
放大。条件检查与删除现在依赖池锁与写者之间的原子性。提交写明了限制(“单个纠删集(默认部署)”),但在分裂的集群中,写者可在检查与删除之间替换对象,过期的 If-Match 仍可能删掉较新的对象。 |
| 7a060cab1 基于访问的 ILM | 2026-08-15 | 与其回退 9b76a2167(2026-09-15)一起包含在 RELEASE.2026-09-16T00-00-00Z 中 |
对分裂无影响。它的回滚代码直接写明了这一分裂(“顶层命名空间锁取自 0 号池 / 0 号集……客户端 PutObject 锁哈希集……locker 按集划分”),说明维护者八月就已知晓。 |
| e59a3d938 多池串行化(PR #178) | 2026-09-11 | RELEASE.2026-09-16T00-00-00Z |
没有改变单池锁域。它为多池写者加了池锁,并删除了 DeleteObject 中说该锁“仅在单个集内”与写路径共享命名空间的注释。警示没了,它所描述的行为没有变。 |
| 87c1c6b3d 列举恢复(issue #218,PR #239) | 2026-10-17 | RELEASE.2026-10-18T00-00-00Z |
缩小,仅针对一种读者。列举恢复按固定顺序取 0 号集与源集的读锁,以同时覆盖两个锁域。 |
d2eb0ade9、cd1f6cec3、dcc2f20ba |
2026-10-25 与 26 | RELEASE.2026-10-25T00-00-00Z(前两个)、RELEASE.2026-10-26T00-00-00Z |
移动了 PutObject 取集锁的位置,又移回。锁域不变。 |
1748f62c7、60c7179dd、7ce7fc8d1、5e7d60308 |
2026-09 与 10 | RELEASE.2026-10-18、RELEASE.2026-10-18、未发布(2026-10-29)、RELEASE.2026-09-16 |
仅多池与数据迁移路径,对单池无变化。 |
公开设计记录 Restored Copy Expiry 已把“大型单池部署中的锁域”列为继承的、范围之外的问题,Listing Recovery Lock Cost 描述了列举的绕行方案。
简而言之。 缺陷不是 SILO 引入的。造成单键分裂的提交是上游 2024-09-27 的 afea40cc0,首发于 RELEASE.2024-10-02T17-50-41Z。SILO 在 RELEASE.2025-12-03T12-00-00Z 中继承了它,在其上建立了功能(40bee4b7b),在注释里保留警示直到 e59a3d938,此后只在列举恢复中做了绕行。
探针结果
探针在 main 的一次性 worktree 中运行。它构建一个由 32 块本地盘组成的池,分成两个各 16 块盘的集,并选取哈希到 1 号集的键。于是 DELETE 与 CopyObject 通过 0 号集加锁,PUT 通过 1 号集加锁。生产本地配置下两个集共享一张锁表,这模拟各集共享主机的分布式集群。分裂配置给每个集独立的锁表,沿用现有 TestNativeRestoreConcurrentCompletion 的技术,模拟完全不相交的锁组。每次迭代先写入种子,然后在屏障后同时启动 PUT 与另一个操作,第二个操作带最多 2 毫秒的随机延迟(另一次运行:最多 20 毫秒),两个调用返回后立即读取每块盘上的 xl.meta。机器当时很忙(负载均值 32),使用 -p 2。绝对比率不代表生产环境的比率。
窗口最多 2 毫秒,每行 300 次迭代:
| 竞争 | 共享锁表 | 分裂锁表 |
|---|---|---|
版本化 PUT 对 DELETE |
0 次异常 | 188 次迭代中已确认的版本或标记在部分磁盘上缺失;70 次中已确认的 PUT 落在少于读仲裁数(16 中的 8)的磁盘上;127 次 DELETE 对本来存在的键返回“未找到” |
未版本化 PUT 对 DELETE |
0 | 84 次迭代结束时 xl.meta 只存在于 16 块盘中的一部分(例如 1、3、4 或 5 块),而不是全有或全无;94 次 DELETE 错误 |
未版本化 PUT 对仅元数据 CopyObject |
0 | 30 次迭代在两个调用都成功后读回旧内容;155 次读到“未找到”;48 次复制错误 |
窗口最多 20 毫秒,每行 400 次迭代:分裂行降到 34 次有缺失项、10 次磁盘状态混杂,以及 3 次旧内容加 24 次对象不可读;共享行保持为零。竞争是真实的,但对时序敏感,符合预期。
分裂锁表下一次版本化竞争后的典型逐盘状态(16 块盘):11 块盘同时有种子、新版本和标记;3 块盘只有种子和标记(新版本丢失);2 块盘只有种子和新版本(标记丢失)。另一次迭代中,PUT 已被确认,其版本在 16 块盘上一块都没有,而 DELETE 返回“未找到”。最后这条路径没有追踪到具体行;它与删除路径在提交中途读取对象、并清除它当作悬空版本的内容的情形一致。
使用只在探针中生效的改动,把 DELETE 与 CopyObject 路由到哈希集(单池),同样三种竞争各 300 次迭代,在共享与分裂两种配置下,每一行异常数均为零。
探针没有使用 dsync、网络或部分重叠的锁组;没有测试批量删除、upload ID 守卫、restore,也没有测试混合版本集群。修复的测试覆盖了除网络和混合二进制以外的全部这些情形;见验证。
方案比较
方案 1:把单池的对象锁路由到哈希集(采用)
凡是池层原本在第一个集上加锁的单键对象名,都改为在对象所哈希到的集上加锁,也就是它的写者加锁的地方,并且只在那里加锁。池层的一个辅助函数返回这把锁(NewObjectNSLock):单池时返回 serverPools[0].getHashedSet(object) 的锁,否则不变地返回 z.NewNSLock。它也加入了 ObjectLayer 接口,供 SelectObjectContent 与批量密钥轮换使用。upload ID 守卫按其对象选集,锁名保持 object/uploadID,与 PutObjectPart 一致。批量删除每个集取一把锁,各集并发删除,最多同时 16 个;每个集的分组只持有自己的锁。COPY 处理器在打开源对象之前,一起获取源锁和目标锁。通用的 NewNSLock 为非对象名称保留一个稳定的锁域。
单键操作的锁 RPC 数量不变;一把锁询问的是一个集的主机,而不是 0 号集的主机,这也分散了负载。多池部署的锁域不变;各集共享主机的单池,锁询问的主机也与以前相同。
方案 2:让 NewNSLock 把单名称路由到哈希集
这在一处恢复 afea40cc0 之前的行为,一次修好所有调用方。多名称锁仍需决定(上游当初正是为此修改);多池的池锁会从 0 号池的 0 号集移到 0 号池的哈希集,改变所有多池锁的锁域;object/uploadID 这类复合名称会按整个名称哈希,而不是按其对象。风险超出了缺陷本身的范围。否决。
方案 3:所有对象锁使用同一个锁组
对每个对象锁使用池中全部主机,或 0 号集的主机,包括集层的锁。互斥可以成立,但每把锁的锁组会增大到 N 台主机,或把所有锁集中在 0 号集的主机上。它还改变了写者,所以下面的混合版本问题也适用于 PUT。否决。
方案 4:单池在集层加锁
让 erasureObjects.DeleteObject 与 CopyObject 像 2022 年之前那样自己加锁。池层的先检查后删除逻辑(标记还是清除、If-Match、ILM 重新评估)将在加锁之前运行,这些功能依赖的原子性就丢失了。否决。
方案 5:写文档并给出建议
声明 18 台或更多主机的单池有此限制,并建议池不超过 17 台主机,集群增长时增加新池。这没有任何代价,但让已经运行这种池的运维者继续承受缺陷,而他们无法重构一个池。不能替代修复,因此否决;发布说明仍然包含下面的升级步骤。
方案 6:两把锁都取(双重加锁)
保留 DELETE、COPY 和 upload 守卫在 0 号集上的锁,再加上哈希集的锁,始终按这个顺序,使滚动升级期间新旧节点仍然互斥。设计的对抗性审查用探针否决了它,理由有两个:
- 重叠的锁组会自我阻塞。 两个锁组共享主机时(显式布局和一部分自动布局都会这样),第一把锁占用的仲裁可能使同名第二把锁剩下的空闲主机不够。等价的锁组会直接阻塞。要做到安全,需要一个覆盖两个锁组并集的原子锁,而 SILO 没有。
- 批量删除会与分片上传完成形成环。 持有覆盖一个批次全部名称的 0 号集锁、再去取哈希集的锁,会与这样的完成操作成环:它持有哈希集中的对象锁,等待 0 号集中的 upload 守卫;因为
parent/uploadID也是批次可以列出的合法对象键。源和目标都在批次中的旧节点 COPY 也会形成类似的环。
建议
在下一个版本采用方案 1。修复实现的就是它。
-
路由。 单池中,下列锁从 0 号集移到对象自己的集:
DeleteObject的每个分支:普通删除、If-Match、ILM 过期(包括精确 key 的整对象删除)、数据迁移,以及复制删除;CopyObject的目标锁;SelectObjectContent的读锁;- 批量密钥轮换的锁;
CompleteMultipartUpload、AbortMultipartUpload、GetMultipartInfo与ListObjectParts的 upload ID 守卫。
名称按存储保存的方式规范化(目录对象为
name__XLDIR__),用于路由和锁名。只取一把锁:不会同时持有 0 号集的锁。真正的前缀删除(强制删除)对前缀名称保留 0 号集的锁,这把锁从来就不覆盖前缀之下的对象。多池部署不变。通用的NewNSLock对桶.lck、桶metadata.lock、IAM 修订锁、format 与 leader 协调,以及/[replicate]/<key>串行器保持不变。 -
批量删除。 单池把
DeleteObjects的名称按所属集分组,各组并发执行,最多同时 16 组,与改动之前一次批量删除中各集的删除方式相同。每组通过一次多名称锁请求获取本集覆盖本组全部名称的锁,删除自己的子批次,然后释放锁。每组只持有本集的锁,持有期间不等待其他锁,因此各组不会互相卡住。每组的锁都在请求的上下文中获取,绝不在已释放锁的上下文中获取。拿不到锁的组整体失败,其他组照常执行。请求被取消之后才开始的组直接失败,不做任何存储操作。结果保持原有位置;校验错误、重复名称、版本与复制身份、以及同一键先清除后加标记的顺序都与以前相同。复制、事件和 ILM 审计仍由调用方在合并后的结果上处理。多池路径不变。单节点的本地锁获取多个名称时逐个进行,等待后面的名称时继续持有前面的名称。因此,所有集共用一张本地锁表的单节点,保留今天的单把批量锁。其他本地锁表按尝试获取,每次尝试整体有界,失败时释放已持有的全部名称。分布式锁对整个名称列表要么整体授予,要么整体拒绝。
-
COPY。 处理器在加任何锁之前先检查源名称,用的是改动之前最先执行的那项检查;只有目标名称合法时才锁目标。非法目标仍在复制自身的目标检查中失败,排在源不存在(404)或源前置条件不满足(412)之后。处理器在打开源对象之前算出它的锁:源对象在其所属集上的读锁,以及本地目标在其所属集上的写锁。在同一张锁表内,请求按 locker 看到的资源(清理后的桶名和对象名)合并,所以复制到自身只取一把写锁。这些锁以事务方式获取:按锁域、再按桶和对象排出一个顺序;第一把之后的每把锁只做有界等待。在持有前面的锁时,如果这次有界等待超时,复制按相反顺序释放已持有的锁,用新的实例单独等待那把锁直到截止时间,拿到后立即释放,再从第一把锁重新开始。其他失败的尝试同样按相反顺序释放已持有的全部锁。每次新的尝试都在随机短暂停顿之后进行,并使用新的锁实例和新的上下文;所有尝试共用一个截止时间。之所以要用新的实例,是因为锁实例标识它的持有者,而分布式解锁会在后台重试:前一次尝试的解锁如果迟到重试,不能释放下一次尝试取得的锁。随后在这些锁之下读取源对象,包括前置条件和内联数据。Object Lock 保留检查与分层清理器对目标的查询,以及内部的
CopyObject和PutObject,都不再重复加锁。锁下的所有工作都在这些锁的上下文中执行,任何一把锁的租约丢失都会中止复制,包括其中的查询。复制写完后即释放锁,然后才可能有同步复制去读取目标。 -
listingRLock保留它的两把读锁(先 0 号集,再源集),以及有界等待与清理。改动之后,源集就是所有单池写者加锁的地方;在早期版本的节点仍可能运行期间,0 号集的锁继续保留,只有在强制的版本边界之后才能去掉。 -
注释。 e59a3d938 从
DeleteObject删除的警示已恢复,并按新的路由和混合版本限制重写。声称只在单个纠删集内具有原子性的 If-Match 段落已更正。 -
发布说明。 说明哪些部署受影响、下面的升级步骤,以及增加新池只改变之后写入的加锁方式。
锁顺序
改动之后,每个单池对象锁都在该对象写者的锁域中,每个名称只有一把。现有嵌套保持不变:先取 /[replicate]/<key> 串行器,再在其内取对象锁,反过来不会发生。单池的分片上传完成先取 upload 锁,在提交之前才取对象锁,二者都在对象所属的集中,与 main 相同;多池时先取对象锁、再取 upload 锁。原生 restore 不再经过分片上传完成:它先取 restore 锁,再取对象锁(两阶段原生恢复)。批量删除的每个分组只持有本集的锁。
COPY 是唯一先后获取两个对象的锁的操作;批量删除对每个集的名称只发出一次多名称锁请求。COPY 对锁排序,所以两个复制之间不会形成循环等待;它等待第二把锁的时间有界,超时即释放第一把,所以按不同顺序加锁的操作(例如早期版本节点上的复制)只会延迟它,不会让它永远阻塞。之后它单独等待被争用的那把锁:复制在等待被争用的源对象时不持有任何锁,目标对象的读写照常进行。改动之前,处理器先打开源对象(这会持有它的读锁),之后才去取目标锁。因此,只要两把锁在同一个锁域,两个在非内联对象之间反向进行的复制就已经会互相等待直到锁超时:单节点、各集共享主机的池,以及多池部署都是如此。测试在基线上复现了这一点。如果只把目标锁移到它自己的集,跨集的键之间也会出现同样的环;审查用探针复现了这一点。有序获取把两者都消除了。
滚动升级
锁名称与锁 RPC 不变;变化的只是一把锁询问的主机。只有所有节点都运行修复后,修复才能保护一个池。
- 谁受影响。 某个集使用的主机与 0 号集不同的单池部署。即主机数为 18 台或更多的所有自动布局,以及集大小小于主机数的显式布局。例如,
http://host{1...18}/disk{1...4}自动选择集大小 9,得到不相交的两半;显式指定集大小 12,则各集的主机窗口部分重叠。各集都共享主机的池以及多池部署,锁询问的主机与以前相同,可以照常升级。 - 混合版本。 新旧节点同时服务一个受影响的池时,旧节点的
DELETE或 COPY 在 0 号集加锁,新节点的在哈希集加锁,因此两者在同一键上不再互斥,而以前它们是互斥的。旧节点的DELETE对任何PUT的暴露与今天相同。方向相反的新节点 COPY 与旧节点 COPY 还可能互相等待,直到新节点的有界等待释放它的第一把锁;旧节点之间的复制保持原有行为。 - 步骤。 受影响的池用排空后重启的方式升级:暂停新的请求;排空或停止进行中的操作和后台操作(复制、ILM、批处理作业、修复);在任何新进程开始服务之前停止所有旧进程;然后恢复。回滚也照此进行。仅仅同时重启所有节点是不够的,如果新进程启动时旧进程仍在服务。
- 增加新池 只改变之后经过池锁的写入的加锁方式。它不能拆分已有的池,也不能修复已经丢失的数据。
- 代价。 COPY 可能需要不止一次获取,并可能重试。批量删除对涉及的每个集各有一次锁往返,而不是总共一次,但各集并发执行,最多同时 16 个,与改动之前相同。
剩余限制
- 测试在单个进程中运行。它们在共享的测试磁盘上,用独立的本地锁表和进程内 dsync locker(不相交以及部分重叠)来模拟分布式池的锁组。没有运行 18 台或更多主机的真实集群,也没有混合二进制;上面的混合版本行为来自设计探针和代码。
- 竞争探针中的比率来自在繁忙机器上刻意收紧的竞争。它们证明竞争可能发生以及结果是什么,而不是它在生产环境中多久发生一次。
- 单节点的本地锁仍然像以前一样逐个获取批量删除的名称。一个批次如果同时列出某个对象和与其他操作的锁名同名的名称(例如
parent/uploadID/1),可能等待该操作直到锁超时。分布式池不受影响。 - 源对象一直被写锁占用时,COPY 最多等到它的操作超时,等待期间不持有任何锁。
- 在受影响的布局中,如果一个集失去了锁仲裁,该集上的
DELETE现在会等到删除操作超时(1 到 5 分钟),然后返回503 RequestTimeout。按旧的路由,这样的DELETE在 0 号集上取锁,只要 0 号集健康,就会立即以InsufficientWriteQuorum失败;但当时 0 号集是所有DELETE和 COPY 的单点故障。 mcli admin top locks --json把 COPY 和批量删除的锁的source显示为erasure-server-pool-locks.go中的getNSLocks,而不是持有锁的处理器。这一点来自阅读代码。- 原生 restore 不再把不加锁读取的快照交给
CopyObject。它在不持有对象锁的情况下暂存副本,提交时在锁下重新检查该版本(两阶段原生恢复)。 - 已经造成的损害(对象只留在少数磁盘上)由 heal 像处理任何部分写入一样处理。heal 取的是哈希集的锁,修复之后它也是
DELETE的锁。 - 探针中“已确认的
PUT在所有磁盘上都没有”的路径与悬空对象清理一致,但未追踪到行。 - 我们没有测量把对象锁分散到各集的主机、而不是都放在第一个集上的负载效应。
验证
在修复所基于的提交上,于一次性工作树中执行:
- 回归测试把一个测试池拆成两个集,它们共享磁盘,但像分布式集一样加锁,共四种模式:一张共享的本地锁表(如同单节点,作对照)、每个集一张本地锁表、没有共同 locker 的进程内 dsync,以及三个 locker 中共享一个、每个集仅靠自己就能凑够仲裁的进程内 dsync。一个操作被卡在存储提交之中,另一个必须等待。覆盖的情形有:
PUT对DELETE、If-MatchDELETE(随后必须因条件不满足而失败)、批量DELETE、仅元数据和重写数据的CopyObject;DELETE与仅元数据 COPY 对标签更新;restore 对DELETE与 COPY;PutObjectPart对 complete 与 abort,get-info 与 list-parts 作为不应等待的对照;分片上传完成对批量删除,批次中有无 upload ID 别名两种;批量删除对分片锁名的别名;批量分组的失败与取消;COPY 处理器对其目标的PUT,以及复制到自身时必须读取PUT提交的版本;经由处理器的反向复制;处理器在启用 Object Lock 且配置了分层的桶中对目标的查询;SelectObjectContent;批量密钥轮换。一个差分测试执行带标记、清除、重复、非法名称、目录对象和不存在键的版本化批量删除,并把分组结果与单把批量锁的结果比较。 - 在基线上只应用这些测试时,所有互斥情形在拆分模式下都失败,因为第二个操作在第一个持有提交时照常执行。复制到自身在共享锁表下也失败:它把加锁之前读到的版本的元数据写到了新数据之上。批量分组情形失败,因为基线没有在写者的锁域中加锁。反向复制在两把锁共享一个锁域时(共享锁表,以及两个池)在基线上失败:每个复制持有自己源对象的读锁,等待对方的,直到请求结束。其余对照在两棵树上都通过。
- 单元测试覆盖辅助函数:对象锁与 upload 锁的路由;事务式获取(部分获取会被释放、总超时、取消、租约丢失会结束上下文并以新上下文重试,以及回复丢失的解锁在后台迟到重试时不能释放下一次尝试的锁);覆盖多个名称的一次本地尝试的上限;有界的本地分组获取;以及租约丢失时 COPY 处理器对目标的查询随之结束。消融实验确认,这些测试在去掉对应修复后都会失败:查询再次加锁、分组以一次无界获取进行、锁实例在多次尝试间复用、按名称而非整次尝试设上限,以及查询运行在请求的上下文中。
- 有一个已有测试在对象层的
CopyObject内部注入保留或法定保留的修改,位置在处理器的 Object Lock 检查与写入之间。现在处理器在目标的写锁下做这项检查,这个窗口已不存在,而注入的写入会等待其调用方自己持有的锁。该测试现在在处理器获取锁之前注入修改,这是并发写者能到达的最晚时点;它预期的拒绝结果和对象保持不变都与以前相同。一个新的回归测试覆盖这个窗口本身:它让一个 COPY 停在写入之中,要求对目标的保留或法定保留更新等待该复制完成后再成功,覆盖普通复制与复制到自身,四种模式以及两个池。在基线上,该更新在每种拓扑下都落在检查与写入之间,随后后端自己的复查拒绝了这次复制。 - 2026-11-06 的发布审查发现了这个修复的三处回归(见审查记录)。它们的修复加入了下列测试,去掉对应的改动部分时每一个都会失败:
TestLockDomainCopyRejectsAliasNames、TestLockDomainCopyErrorPrecedence与TestLockObjectsMergesByResource:别名形式的源名和目标名在加任何锁之前就以400拒绝,所有锁布局和双池都是如此;非法目标保持404与412的优先顺序;别名与它的规范名称合并,在共享锁表中别的集的别名也是如此;TestGetNSLocksWaitsHoldingNothing、TestGetNSLocksIdleWaitCancel、TestGetNSLocksIdleWaitDeadline与TestLockDomainCopyDoesNotStarveDestination:在整个等待期间,排在被争用锁之前的锁始终可供他人获取;取消或截止时间结束等待时不持有任何锁;复制等待源对象期间,对其目标的HEAD能够成功;TestGetNSLocksNonAtomicKeepsBoundedRetry是对照;TestDeleteObjectsInSetsRunsGroupsConcurrently与TestDeleteObjectsInSetsQueuedGroupsCancel:两个集的磁盘调用相互重叠;取消之后,排队的组不做磁盘调用,已在执行的组保留自己的结果。
gofmt与go vet ./cmd无问题。新测试在竞态检测器下通过;./cmd中已有的锁、复制、删除、分片上传、select、列举、生命周期、退役与再平衡测试通过。
合并前仍需运行:远端 CI,以及在各集使用不同主机的分布式单池上的验收运行。
集成验证。 这个修复已摘取到下个版本的同一个集成分支上,位于 #278 和 #281 之后,与整对象删除、Heal 演练、#259、#285 和 Console 的修复放在一起。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结论 |
|---|---|
| 设计审查,第 1 轮 | 同意但需修改。upload 守卫按其对象选集并保留锁名;按存储方式规范化名称;区分真正的前缀删除(强制删除)与针对单个对象的精确 key 删除;明确批量删除的失败语义。两个阻断问题:COPY 处理器在哈希集中持有源读锁的同时去取 0 号集的目标锁,只移动目标会在反向复制之间形成环(已复现);“滚动升级期间不会更差”的说法不成立,因为旧的 DELETE 与 COPY 以前在 0 号集中互斥(已复现)。 |
| 设计审查,第 2 轮 | 提出双重加锁(方案 6),以保持新旧节点互斥。审查用探针否决:重叠的锁组会阻塞同名的第二把锁;持有 0 号集批量锁时再取哈希集锁,会通过 parent/uploadID 别名与分片上传完成成环。COPY 必须在打开源对象之前取锁,对源读取传入 NoLock,并对锁排序。 |
| 设计审查,第 3 轮 | 放弃双重加锁;同意单锁域路由加排空升级。修改:本地锁后端逐个获取名称,所以单节点保留其单把批量锁,其他本地锁表使用有界尝试;不同集的资源仍可能共享主机;COPY 的每个嵌套调用(包括目标查询)都传入 NoLock;COPY 使用单一截止时间、新的上下文并处理租约丢失;为升级与回滚给出真正的排空步骤;补充测试。前提更正:host{1...18}/disk{1...4} 自动选择集大小 9,显式指定 12 才得到重叠窗口。 |
| 对抗性审查,补丁第 1 轮 | 要求修改;四项发现全部接受。重试复用了同一个锁实例,而实例标识其持有者,所以一次回复丢失的解锁在后台重试时,可能释放下一次尝试取得的锁(已复现);现在每次尝试都使用新的实例。目标查询运行在请求的上下文中,在租约丢失后仍会继续;现在锁下的工作都运行在锁的上下文中。对覆盖多个名称的本地锁的一次尝试,会对每个名称各等待一次上限,从而超出截止时间;现在整次尝试共用一个上限。一个测试假定分布式解锁立即完成;现在它会等待锁被释放。 |
| 对抗性审查,补丁第 2 轮 | 通过;没有新的发现。四项修复都得到验证,包括竞态检测器下的运行、补充探针以及 restore 集成测试。本轮中本文更正了三处表述:COPY 是唯一先后获取两个对象的锁的操作(批量删除对一个集的名称只发出一次多名称锁请求);按不同顺序加锁的操作是早期版本节点上的复制,而不是分片复制;这样的复制只会让新的复制等到新节点的有界等待释放其第一把锁为止,而不是等到锁超时。 |
| 对抗性审查,补丁第 3 轮(仅本文) | 通过,附小建议。更正后的三处表述和审查记录与代码一致。已采纳的小建议:“一次获取”改为“一次多名称锁请求”,因为本地后端逐个获取多个名称;状态行在修复提交之后成立,提交在本轮之后完成。 |
| 对抗性审查,补丁第 4 轮(一个已有测试) | 通过,附小建议。把改写后的保留测试的注入点移到获取锁之前是合理的,但仅凭它无法发现 COPY 在检查与写入之间释放了锁的回归。已采纳:新增回归测试,要求保留或法定保留更新等待一个停在写入之中的 COPY。 |
| 发布审查(2026-11-06) | 发现这个修复的三处回归:源名是目标别名的复制(例如把 a//x 复制到 a/x)对同一资源加了两次锁,自己等自己直到操作超时;等待被争用源对象的复制在大部分时间里持有目标的写锁;批量删除逐个执行各集的分组。分别用源名检查与按资源合并、不持有任何锁的等待,以及并发分组修复。两轮实现审查补齐了测试缺口:无竞态的超时与锁实例、共享锁表中其他集的别名,以及先探测写者再探测读者。 |
参考
- afea40cc0:集锁总是落在 0 号集(上游,2024-09-27)。
- d350b666f 与 minio/minio#15521:
DeleteObject的锁移到池层。 - 40bee4b7b、e59a3d938、87c1c6b3d:触及此约定的 SILO 提交。
- Restored Copy Expiry 与 Listing Recovery Lock Cost:提到此限制的公开记录。
26 - 多池生命周期过期:按整个对象决策
本文覆盖 issue #278 和 Denis Babchuk 提交的 PR #279,基于 main 69fcf50cd 评估。修复将通过维护者集成 PR 合入:贡献者提交原样保留,并附加下文所述的维护者后续修正。恢复副本过期的修复(#281)在同一个集成 PR 中交付,另有单独记录。
状态: 修复的第一版通过了两轮维护者审查。随后对抗性审查要求修改,即修复方案中的第 6–9 部分。实现这些修改的补丁经过四轮审查后获得批准。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。远端 CI 和多池验收尚未完成。未合并,未发布。
简短结论
报告属实,也是下一个版本发布前最严重的开放缺陷。部署有多个池时,同一个 key 的版本经常分散在不同池中。生命周期按某一个池里的版本做判断,但由此发起的过期操作会在所有池中删除或隐藏这个 key。于是:
- 一个版本在本池是最新的、但不是整个对象的最新版本,会被当成当前版本;
- 一个在本池里单独存在的删除标记,会被当成"没有更早版本"的标记。
结果是静默的数据损坏:新的删除标记盖住了当前版本,较新的版本随整个 key 一起被删,已删除的对象重新变得可读。decommission 和 rebalance 做的是同样的判断,并且仍然报告 Complete。
修复保留现有的所有生命周期判断点,只在它们的汇合处加一道检查:多池部署中,ILM 过期真正删除任何东西之前,由池层在对象锁内,把该 key 在所有池中的版本合并成一个列表,重新求值一次规则。对整个对象不成立的过期会被拒绝;版本历史或锁保护无法权威确定的过期,同样会被拒绝。decommission 和 rebalance 会迁移这样的版本,而不是把它交给过期处理。在单池上,版本和删除标记的过期跳过合并读取;整对象过期则在锁内检查每一个版本(见整对象 ILM 过期)。
定性
| 维度 | 判断 |
|---|---|
| 性质 | 生命周期求值中的数据正确性缺陷;继承自上游设计,不是已发布 SILO 版本引入的回归。 |
| 受影响部署 | 多于一个池;桶开启或暂停了版本控制;并配置了 Expiration、ExpiredObjectAllVersions、ExpiredObjectDeleteMarker 或 DelMarkerExpiration 规则。 |
| 不受影响 | 单池部署;所有版本都在同一个池中的 key;恢复副本过期(由 #281 跟踪)。 |
| 严重程度 | 致命。版本被删除或隐藏而没有任何错误,唯一的痕迹是 ILM 审计条目和桶通知。 |
| 可达性 | 普通 S3 写入即可触发。在报告者的复现中,SILO 自身的放置策略让三分之一到一半的 key 的版本分布在两个池中。 |
| 发布紧急度 | 阻塞下一个版本。main 上已经合入 #276,它让迁移过期在 decommission 或 rebalance 遍历中立即执行。 |
| 剩余工作量 | 代码、审查和多池验收运行都已完成,还需要远端 CI。 |
触发条件
在正常运行中,一个 key 就会在两个池里都有版本:
- 已存在 key 的新版本写入的池按可用空间加权随机选出,可能不是已经持有该 key 的池;
- 同一个 key 的两次首次写入并发,落在不同的池;
- decommission 或 rebalance 在复制了部分版本后中断。
此时,只要生命周期规则的判断依赖该 key 的其他版本,就会出错。报告者在 main 上的矩阵展示了每一种失败形态:
| 规则 | 扫描前的布局(新的在前) | main 上的结果 |
|---|---|---|
Expiration Days=1 |
p1 [v1],p2 [v2] |
新的删除标记盖住了当前版本 v2。 |
Expiration 加 ExpiredObjectAllVersions |
p1 [v1],p2 [v2] |
v1 被当作当前版本,整个 key(包括 v2)被删除。 |
ExpiredObjectDeleteMarker |
p1 [dm],p2 [v1] |
删除标记被移除,已删除的对象重新读出 v1。 |
DelMarkerExpiration |
一个池中是删除标记,另一个池中有更新的版本 | 标记过期时连同更新的版本一起删除(由 Go 测试覆盖;普通 S3 写入不会产生这种布局)。 |
同一轮运行中,所有版本都在同一个池里的 key 表现正确。
根因
生命周期求值需要每个版本的三项信息:
- 是否是最新版本(
IsLatest); - 对象共有多少个版本(
NumVersions); - 后继版本的写入时间(
SuccessorModTime)。
每个发起过期的地方都只用自己看得到的版本来计算这三项:
- scanner 和磁盘 heal 读取一个纠删集合的
xl.meta; - 带
versionId的 GET 和 HEAD 读取持有该版本的池; - decommission 和 rebalance 遍历一个池。
执行过期的地方却不一样。applyExpiryOnNonTransitionedObjects 和 expireTransitionedObject 通过池层删除:整对象动作会在每个池中删除该对象,新的删除标记会在每个池中隐藏该 key。对单个版本或新建删除标记,锁内的集合级复核也帮不上忙,因为它求值的是承接这次删除的那个集合里的版本;如果正是这个集合做了错误判断,复核只会确认它。除跨池判断之外,整对象过期另有单独记录(见整对象 ILM 过期)。
一句话:判断是局部的,动作却是全局的。
行为的来源
溯源
来自上游,不是 SILO 引入的。 这个缺陷有两个上游成分:生命周期判断基于单个集合看得到的版本,动作却作用于整个 key;以及一次放置策略的改动,让 key 的版本分布在多个池中成为常态。从 RELEASE.2025-12-03T12-00-00Z 到 RELEASE.2026-10-26T00-00-00Z 的全部 18 个 SILO 版本都同时包含这两个成分。SILO 在这条路径上的改动,要么没有触及判断,要么尚未发布;有影响的几项列在下面。
成分 1:本地判断,全局动作。 每个发起方都用自己读到的版本求值。下表中的提交都在上游,也在每一个 SILO 版本中。
| 发起方 | 提交 | PR | 日期 | 首个上游版本 |
|---|---|---|---|---|
扫描器按对象求值生命周期(scannerItem.applyActions) |
43d6e3a(Klaus Post) | #9579 | 2020-06-12 | RELEASE.2020-06-14T18-32-17Z |
| 扫描器改用完整求值器,输入是单个集合的版本列表 | 01447d2 | #21096 | 2025-04-02 | RELEASE.2025-04-03T14-56-28Z |
| GET 和 HEAD 让读到的版本过期 | 65aa2bc(Anis Elleuch) | #11296 | 2021-02-01 | RELEASE.2021-02-01T22-56-52Z |
| decommission 跳过并让 ILM 已过期的版本过期 | 236ef03(Harshavardhana) | #15300 | 2022-07-14 | RELEASE.2022-07-15T03-44-22Z |
| rebalance,同一过滤逻辑 | 4523da6(Krishnan Parthasarathi) | #15483 | 2022-10-25 | RELEASE.2022-10-29T06-21-33Z |
| 驱动器 heal 跳过已过期的版本(直到下一个版本,见第 12 部分) | 109a9e3(Harshavardhana) | #18569 | 2023-12-01 | RELEASE.2023-12-02T10-51-33Z |
动作是消费方。对象锁内的集合级复查,即 erasureObjects.DeleteObject 中的 opts.Expiration.Expire,来自 35f2552(#13091,Harshavardhana,2021-08-27,RELEASE.2021-08-31T05-46-54Z),它读取的是执行删除的那一个集合的版本。上游 master 的代码相同。
成分 2:让分裂 key 变得常见的放置策略。 83b2ad4(jiuker,minio/minio#21115,2025-07-19)在 getPoolIdx(现名 getWritePoolIdx)的条件中加入了 || pinfo.Err == nil。在此之前,只有尚不存在的 key 才按空闲空间选池,已存在 key 的新版本留在持有该 key 的池中。在此之后,已存在的 key 也按空闲空间选池,新版本可能落在另一个池。包含它的首个上游版本是 RELEASE.2025-07-23T15-54-02Z;RELEASE.2025-07-18T21-56-31Z 不包含。全部 18 个 SILO 版本都包含它,上游 master 至今也保留。该提交的标题讨论的是单个池的空闲阈值,对已存在 key 的影响看起来是副作用,而不是有意的设计。按空闲空间选池是加权随机抽取,而不是“空闲空间最多的池”。
SILO 在这条路径上改了什么。
| SILO 改动 | 日期 | 是否发布 | 对缺陷的影响 |
|---|---|---|---|
| e59a3d9 串行化并对账多池对象更新 | 2026-09-11 | 自 RELEASE.2026-09-16T00-00-00Z 起 |
收窄了一个触发条件:PUT 现在在同一把池层锁下查找并写入 key,两次首次写入不会再同时认为 key 不存在。放置策略和生命周期求值没有变化,因此分裂 key 仍会产生。 |
f54c048 在 getWritePoolIdx 中加入写恢复标志,2a04d5f 又把它去掉 |
2026-10-25 | 两者都在 RELEASE.2026-10-25T00-00-00Z |
无。已发布代码中的放置条件就是上游的条件。 |
| 7a060ca 按访问频率在池间迁移热对象,及其回退 9b76a21 | 2026-08-15、2026-09-15 | 仅同时出现在 RELEASE.2026-09-16T00-00-00Z |
无。该功能从未在没有被移除的情况下发布。 |
| 60c7179、1748f62 及其他迁移安全修复 | 2026-10-18 | 自 RELEASE.2026-10-18T00-00-00Z 起 |
无。RELEASE.2026-10-26T00-00-00Z 中 decommission、rebalance、heal 的 filterLifecycle 闭包与上游 master 完全一致,扫描器的求值也一致。 |
| #266 decommission 和 rebalance 期间迁移 ILM 已过期的当前版本(19910230、a995921) | 2026-10-29 | 仅在 main,位于 RELEASE.2026-10-26T00-00-00Z 之后 |
收窄。版本控制开启的桶里,当前版本过期只会加一个删除标记,所以遍历现在迁移该版本,而不是跳过它。 |
| #276 迁移期间的 ILM 清理(0a546c7) | 2026-11-02 | 仅在 main | 在时机上放大。它判断的仍是同一份按池的副本,但在源被淘汰之前,在遍历内立即执行过期,包括整对象动作。在已发布版本中,遍历跳过该版本,并把同样的判断交给过期工作线程排队。 |
a803027 把 DelMarkerExpiration 计为活动规则 |
2026-11-03 | 仅在 main | 暴露面放大。扫描器原先跳过只有 DelMarkerExpiration 规则的桶;在 main 上它会对这类桶求值,从而触发上面的第四种失败形态。 |
| 整对象精确 key 修复(整对象 ILM 过期) | 2026-11-05 | 仅在集成分支 | 整对象过期的配套修复:按精确 key 而不是前缀删除,在单池和多池上都在锁内检查每个版本,并清理分层版本的远端对象。 |
所以,已发布的 SILO 版本所包含的缺陷就是上游的缺陷,没有变化。#266 和 #276 不在任何发布标签中。#276 没有产生新的错误判断,它让已有的错误判断立即执行,这就是本修复成为下一个版本发布门槛的原因。
针对本问题的维护者修复的第一版自身有两个缺陷:通过过时的少数派元数据修订绕过法律保留,以及重复副本的待清除状态丢失。评审在发布之前发现了它们,两者都从未合入或发布。
日期、PR 编号和发布归属来自 git 历史。关于代码流程的说法来自阅读代码。
方案比较
方案 1:当 key 跨池时禁用生命周期或跳过过期
这样做安全,但错在另一个方向:合法的规则会无限期失效,而运维人员得不到任何"保留策略已经不再执行"的信号。
方案 2:让一个 key 的所有版本都留在同一个池
池亲和可以减少新产生的跨池 key,但修复不了已有的跨池 key、中断的迁移和并发的首次写入。SILO 已经评估过覆盖写的池亲和方案,因为小对象覆盖写性能回退而没有采用(见 多池覆盖亲和)。池亲和可以作为未来的缓解手段,但不是正确性修复。
方案 3:让每个发起方都按合并后的版本求值
每次 scanner 访问、heal、读取和迁移步骤,都要从每个池读取该 key。这个代价会落在"对象没有任何东西需要过期"这一最常见的情况上,而且每个发起方在判断与删除之间仍然会和写入发生竞争。
方案 4:在执行端、对象锁内复核
这是选定的方案,也是 PR #279 的思路。发起方保留廉价的本地判断;只有即将真正执行的删除才需要付出一次合并读取,而且这次读取和删除在同一把对象写锁内进行,检查与执行之间版本列表不会变化。
不采纳的扩展
全局版本索引、跨请求缓存或新的生命周期状态机,都会引入持久状态和新的故障模式,而修复这个缺陷并不需要它们。
修复方案
修复共十二部分:
- 第 1、2 部分是 PR #279 的原始内容。
- 第 3–5 部分来自前两轮维护者审查。
- 第 6–9 部分来自对此版本的对抗性审查。那轮审查复现了前面几部分引入的两个缺陷,以及它们没有关闭的两条更早的路径。
- 第 10–12 部分来自 2026-11-06 的发布审查。
-
在执行端按合并版本求值(PR #279 第 1 个提交)。
lifecycleEventsAllPools从每个池中该 key 所在的纠删集合读取版本,忽略分层 free version;按从新到旧合并,在两个池中都有副本的同一版本只计一次;然后用完整的生命周期求值器求值,并按整个对象计算IsLatest、NumVersions和SuccessorModTime。expiryApplies把结果对应到所请求的删除:- 整对象删除,要求最新版本带有整对象动作;
- 新建删除标记,要求最新版本到期;
- 删除指定版本,要求该版本本身带有删除动作。
检查有四种结果:
- 批准;
- 版本存在但规则不成立时,返回
errLifecycleNotApplicable; - 目标不存在时,返回
ObjectNotFound或VersionNotFound; - 无法权威地读出版本历史时(第 6 部分),返回
InsufficientWriteQuorum。
其他错误照常向上传递。scanner 会在后续轮次重试被拒绝的过期。与"不存在"不同,拒绝结果永远不会确认一次待处理的复制 purge;迁移在源版本仍然存在时被拒绝,也不会退役这个版本。
erasureServerPools.DeleteObject对多池上的 ILM 删除在对象锁内执行检查,然后把请求标记为CheckedAllPools。DeleteObjects对每一批 ILM 版本清理执行同样的检查,每个 key 只做一次。 -
迁移规则未覆盖的版本(PR #279 第 2 个提交)。 decommission 和 rebalance 只在
migrationExpiryApplies按所有池确认后,才保留按池做出的过期判断;否则该版本像其他版本一样被迁移,之后由 scanner 在持有该 key 的池中应用规则。没有这一部分,遍历中的过期会被拒绝,decommission 会在被退役的池里还留着该版本时停下。 -
分层非当前版本批量清理中的全池核准传递。 新增的集合级
Expire标志导致分层的NewerNoncurrentVersions批量清理在集合内被拒绝,而批量适配层又把这个拒绝当成成功上报。现在deleteObjectsReconciled只对带显式版本 ID 的 ILM 条目传递CheckedAllPools,这正好是池层已经检查过的那些条目。 -
读路径触发的过期,按实际读到的版本复核。 本地求值认为版本到期时,GET 和 HEAD 会返回
NoSuchKey并把过期入队。在多池部署中,对本地的删除候选,读路径现在会按合并列表再求值一次,并按实际读到的版本身份匹配事件:- 别处一个更新的版本到期,不能成为隐藏一个仍然有效的旧版本的理由;
- 复核失败或未确认过期时,读取照常成功,也不入队;
recheckReadExpiry会在expiry_incomplete_total中按原因(history_unavailable或not_applicable)统计被跳过的过期,并写入result=skipped的 ILM trace(04d7d9197); - 本地结果为"无动作"时,不产生任何额外读取。
-
被拒绝的过期不再发送删除通知。 以
errLifecycleNotApplicable被拒绝的批量条目,不再发送s3:ObjectRemoved:Delete通知,也不再写 ILM 过期审计。第 9 部分把这一点扩展到所有未生效的结果。 -
要么得到权威的版本历史,要么不做决定。
-
采纳规则。 一个版本的某个修订,由元数据头加上规范化后元数据的 sha256 摘要来标识:
- 摘要使用无歧义的编码:每张表单独成段,记录条目数,各条目按键排序并带长度前缀。
- 按设计就因盘而异的字段不参与计算,包括纠删索引和旧格式对象的分片校验和。
- 只靠 4 字节的元数据头签名并不够,因为两种不同的锁状态可能得到相同的签名。
只有当集合中至少
max(n/2 + 1, 该版本自身的元数据读 quorum)块盘持有这个修订时,objectVersions才采纳它;n是集合配置的盘数。合并历史之前,会先剔除每块盘上未被采纳的修订,避免一块过时的盘挤掉多数派。 -
拒绝条件。 以下任一情况都以
InsufficientWriteQuorum拒绝:- 任一块盘上能看到的某个有效版本,没有被采纳的修订;
- 集合中一半或更多的盘读不到。
-
适用范围。 这些检查在每一个提前返回之前都会执行。处理每个版本之间都会检查取消,一旦取消就拒绝删除。
-
关闭的漏洞。 此前,一块错过了 legal hold 更新的盘,就可能提供合并所用的修订;而一半的盘读不到时,一个达不到 quorum 的新版本会从历史中消失,于是整对象过期把它删掉。
-
-
合并过程中不会丢失的锁保护。
-
跨池。 锁字段按与生命周期求值器相同的键优先级读取:先读小写键,再读规范大小写的键。同一版本在不同池中的副本,如果在 legal hold 或 retention 上不一致,只有当两者都带有效、非零的修订时间戳,且其中一个严格更新时,才采用更新的那个;否则拒绝该对象。
修改时间不同的副本是不同的写入,例如暂停版本控制的桶中两次写入的
null版本。由最新的那次写入决定;如果更早的那次写入仍受保护,则拒绝该对象。 -
集合内。 对单个版本的 ILM 删除,集合在锁内重新读取目标,并在以下两种情况拒绝:
- 有待处理的复制 purge;
- 只把已批准状态以严格更新的有效时间戳证明为过时的那些锁字段替换掉之后,版本仍然受保护(retention、legal hold 或时钟不可用)。
批量删除依靠第 6 部分和跨池规则。
-
-
复制状态不一致的重复副本被拒绝。 对同一版本在两个池中的副本,会在解析后比较复制总状态、purge 总状态和按目标的状态表,任何语义上的差异都会拒绝该对象。此前,一个副本上待处理的 purge 会在合并视图中消失,该版本可能在 purge 复制完成之前就过期。
-
只有真正完成或确认已不存在的删除才会被通知。 只有过期删掉了它针对的对象、或确认它原本就不存在时,才发送
ObjectRemoved通知;任何拒绝或失败都不会发送。在批量路径上,删除的 ILM 过期审计也是如此。没有删掉任何东西的过期留下什么记录,取决于原因:- 失败(quorum 失败或其他错误)会带
ilm-incomplete-reason标签写入审计,以result=failed和原因写入 trace,写入日志(同一对象重复出现的相同失败,由去重日志抑制,直到它每小时重置),并计数; - 拒绝(在锁内重新判断时规则已不适用,或 Object Lock 保护该版本)会以
result=refused、原因和错误写入 trace 并计数,但不写审计:分裂的 key 在每次扫描时都会被拒绝。
scanner 和已转储版本的过期,现在按删除实际做了什么来命名一次完成的过期:没有指定版本 ID、结果是删除标记的过期,创建了这个标记;其他过期删除了它针对的版本,删除标记也包括在内。此前它们从排队的对象取身份,因此版本桶中最新已转储版本的过期,会把它保留的版本报告为已删除。复制重试的处理不变。
- 失败(quorum 失败或其他错误)会带
-
被拒绝和失败的过期会被计数。
expiry_incomplete_total按reason统计每一次没有删掉所针对对象的 ILM 过期尝试:not_applicable(规则已不适用)、locked(Object Lock)、history_unavailable(无法权威读取历史)和error。指标 v2 导出为minio_node_ilm_expiry_incomplete_total,指标 v3 在/ilm下导出为minio_ilm_expiry_incomplete_total。桶和对象都不是标签,已经不存在的版本不计数。 -
获批的版本过期会删除所有副本。 一个版本可能在两个池中都有副本,例如 decommission 或 rebalance 中断之后。池层用所有池的版本核准的 ILM 版本过期,现在会删除每一份副本,先删次要副本、最后删主副本,遇到第一个失败就停止,所以分层版本的远端对象只会在所有副本都删掉之后才删除。每个集合仍守护池层核准的那些保护。此前只删除按最新 key 路由选中的那一份副本:另一份副本仍可读,分层版本的远端对象在那份副本仍引用它时就被删除,持有它的池在 decommission 时完成检查失败。批量非当前版本清理本来就会删除所有副本。
-
驱动器 heal 会修复它排入过期的版本。 驱动器 heal 按单个池里的版本求值生命周期。对它认为已到期的版本,它排入过期并跳过 heal。而过期会在对象锁内重新判断,可能被拒绝,于是这样的版本在被更换的盘上一直保持降低的冗余,直到拒绝结束为止;scanner 只 heal 没有到期动作的版本,也不会修复它。现在驱动器 heal 排入过期,同时修复该版本;随后被过期删除的版本只多花一次 heal。heal 与过期通过写者锁域中的对象锁互斥,这由单池锁域修复提供。
同一版本在两个池中的副本合并时,各自独立排序的 retention、legal hold 和标签修订都会保留,并遵循第 7 部分的严格排序。
为什么方案充分且必要
对其覆盖的路径是充分的。 多池部署中,所有针对单个版本或删除标记的 ILM 动作,都经由 erasureServerPools.DeleteObject 或 erasureServerPools.DeleteObjects 到达存储层,包括 scanner、磁盘 heal、读路径触发的过期、过期 worker 和迁移遍历。合并检查就放在这里:
- 它与串行化该 key 写入的是同一把对象写锁;
- 它使用的是 scanner 用的求值器;
- 有了第 6–8 部分,只要版本历史或保护状态无法确定,它就拒绝。
在这些路径上,错误的本地判断会被拒绝,而不会被执行。迁移在决定"不迁移某个版本"之前,也会调用同一检查。
由配套修复覆盖。
- 整对象过期(
ExpiredObjectAllVersions和DelMarkerExpiration),除第 1 部分的跨池判断之外,由整对象 ILM 过期覆盖。
必要性。 每一部分都关闭一条否则会继续存在的路径:
- 是否到期取决于其他池中的版本,因此判断需要一个完整的跨池视图,并且要和删除串行化。对象锁内的池层执行端,正是这个视图和这把锁交汇的地方。
- 没有迁移部分,decommission 会在被退役的池里还留着被拒绝的版本时停下。
- 没有在批量清理中传递全池核准,分层非当前版本清理会在源端保留了版本的情况下被上报为已完成。
- 没有读路径修改,GET 和 HEAD 仍会对有效版本返回
NoSuchKey。 - 没有第 6 部分:
- 一块过时的盘就能解除 legal hold;
- 不完整的历史能批准删除一个已提交的版本。
- 没有第 7 部分:
- 两个池之间时间戳相同的冲突,可能在批量路径上丢掉 hold;
- 待处理的 purge 可能被忽略。
- 没有第 8 部分,重复副本上待处理的 purge 会被忽略。
- 没有第 5、9 部分,被拒绝的过期会被当作删除发布出去。没有第 10 部分,运维人员无法看出本应删除的版本为何仍然存在。
- 没有第 11 部分,获批的版本过期会在另一个池中留下可读的副本,还可能删掉该副本仍引用的远端对象。
- 没有第 12 部分,过期被拒绝的版本在换盘之后会一直保持降低的冗余。
有意排除的范围:
- 在单池上,版本和删除标记的过期跳过合并读取。它们只有一处变化:ILM 拒绝现在返回
errLifecycleNotApplicable,而不是"不存在"。scanner 一类的调用方对两者处理相同;迁移永远不会把拒绝当作不存在。 - 恢复副本过期取决于恢复日期,而不是其他版本,由 #281 处理。
- 未开启版本控制时的单副本删除保留集合自身的复核,因为那里的每个副本都是独立的写入。
代价与兼容性
- 接口。 没有新的公开 schema、路由、配置、存储格式或依赖;
ExpirationOptions只增加内存中的字段。可见的行为变化有三处:- 过期会被拒绝;
- GET 和 HEAD 会返回以前回答
NoSuchKey的版本; - 单池返回上述拒绝错误。
- I/O 与 CPU。 多池部署中,每次 ILM 删除都在已持有的锁内,从每个池中该 key 所在集合的每块盘读取完整的版本历史,并对每块盘上的每个版本解码、计算哈希。decommission 和 rebalance 遍历在预检查时每个 key 只读取一次历史,并基于这次读取判断该 key 的每个到期版本;每次过期在删除之前仍会在锁内再读一次历史。批量删除每次调用、每个 key 做一次。取消是协作式的:在版本之间、合并前后检查。合并本身不能被取消,因此没有耗时上限。这里有意不设版本数上限,因为设上限会恰好卡住生命周期最需要清理的长历史 key。
- 合并成本。 合并开销随历史长度超线性增长。用本修复之前的合并方式,在内存中、16 路盘数据下,1,000 个版本约 2.4 毫秒,8,000 个版本约 230 毫秒。这些数字测的是修复前的合并,不是最终实现。
- 哪些过期会执行。 对整个对象不成立、或版本历史无法确定的过期不再执行。有一种情况现在会过期而以前不会:带对象大小过滤的规则,作用于一个当前版本所在池与"第一个持有该 key 的池"不同的 key。
- 回滚。 回滚即恢复旧行为,两个方向都不需要迁移。
剩余限制
-
已经造成的损坏不会被修复。 旧行为已经添加的删除标记会保留。它们是普通的删除标记,核对 ILM 审计日志或桶通知后,可以用
DELETE ?versionId=移除。已经被删除的版本,本修复无法找回。 -
拒绝可能无限期持续。 以下情况会一直拒绝过期,直到 heal 修复元数据为止;反复扫描本身不会修复它们:
- 严格的历史规则;
- 过时的受保护副本;
- 残留记录;
- 复制状态不一致。
一半或更多的盘读不到的降级集合,也会让其中 key 的过期停下。
-
每个池都必须给出历史。 多池部署中,该 key 在每个池中所在的集合都必须给出 key 的历史。某个池离线,或者它为该 key 所在集合有一半或更多的盘读不到时,即使这个池并不持有该 key 的任何副本,该 key 的过期也会停下。生命周期复核失败不会让本来能成功的 GET 或 HEAD 失败,但它们仍须满足正常的读 quorum。这些拒绝按
history_unavailable计数。这是有意为之:无法应答的池无法证明 key 不在那里。 -
长历史仍然每个被删版本读一次。 遍历在预检查时每个 key 只读取一次历史,但每次过期都会在锁内再读取并合并一次,因此有大量到期版本的 key,每删除一个版本都要读一次历史。
-
在两个池中都有副本的非版本化 key 可能一直不过期。 较旧的副本不满足规则的大小或标签过滤、而较新的副本满足时,过期会停在较旧的副本上,较新的副本不会过期。已发布版本中,较新的副本会被删除、较旧的副本暴露出来,这更糟。这种布局很少见。
-
不保证生命周期最终收敛。 一个版本可能对整个对象是非当前版本,但在它自己的池里是最新的。按池运行的 scanner 可能一直不会提出它,因此在版本合并到同一个池之前(例如 decommission 或 rebalance 之后),一些已到期的版本可能一直保留。
-
读取不会隐藏所有到期版本。 最新版本的整对象规则已经到期时,对一个更早版本的 GET 或 HEAD 仍可能返回该版本。
-
放置策略不变。 已存在 key 的新版本仍按空闲空间选池,跨池 key 仍会继续出现。SILO 是否应优先选择 key 所在的池,是另一个关于放置和性能的问题。
验证
第 1–5 部分,在集成分支 head 上:
TestPoolsLifecycleSplitObject和TestPoolsMigrationLifecycleWholeObject(来自 PR #279)覆盖 scanner、decommission 和 rebalance。前者中的 HEAD 用例只模拟了求值和入队;真实的 handler 由下面的读取测试覆盖。TestPoolsLifecycleTieredNoncurrentBatch、TestPoolsLifecycleTieredBatchNotReplicated(原名TestPoolsLifecycleTieredBatchReplication,现在检查批量路径不排队任何复制)、TestPoolsLifecycleTieredBatchIdentity和TestPoolsLifecycleBatchRejectionNotAnnounced覆盖批量路径及其复制、通知副作用。TestPoolsLifecycleDuplicateLockMetadata、TestPoolsLifecycleDuplicateTagMetadata和TestPoolsLifecycleBatchRejectionReplication覆盖副本合并和被拒绝的清理。TestPoolsLifecycleReadHTTP、TestPoolsLifecycleReadAuthorizationAndClose、TestPoolsLifecycleReadBoundaries和TestPoolsLifecycleReadProxy经过真实的 GET 和 HEAD handler,包括鉴权顺序和 reader 释放。- 早先的维护者审查轮次记录过完整的
./cmd测试、定向 race 选择,以及一次变异测试(38 个变异中 37 个被测试捕获)。对抗性审查轮次没有重新运行这些。
对抗性审查。 审查在集成分支 head 上用可执行探针完成,维护者重新运行了全部探针,结果相同。
- 五个探针失败:
- 少数派过时锁修订让受 hold 保护的版本被删除;
- 不完整的历史批准了对新版本的整对象删除;
- 重复副本上待处理的 purge 被丢掉;
- quorum 拒绝被当作删除发布;
- 单池的拒绝错误发生了变化。
- 另外两个是测量: 读取覆盖范围和合并成本。
- 回归测试。 第 6–9 部分把相关探针转为回归测试,并另外加入以下用例:
- 8/8 冲突修订;
- 降低校验位的部分版本;
- 读不到的盘数的边界;
- 残留的 free version;
- 跨池时间戳相同的保护冲突(单个和批量路径);
- 带删除标记元数据的待处理 purge;
- 顺序不同但等价的按目标状态表;
- 混合批量的副作用。
- 对照用例。 正常降级的集合,以及跨池严格更新的解除操作,仍必须正常过期。
补丁审查。 获批的补丁新增 26 个测试,它们在之前的 head 上失败、加上补丁后通过,race 检测无问题。生命周期、迁移、恢复和过期相关的测试选集共 88 个测试,全部通过,无失败。审查者自己写的 11 个探针在最终补丁上全部通过。
端到端运行。 2026-11-05,在一台带两个池(每池四块盘)的原生服务器上,分别对 69fcf50cd 和修复运行了报告者的 repro.sh。在 69fcf50cd 上,scanner 步骤隐藏了 9 个当前版本,丢失了 8 个版本,并使 8 个已删除的 key 复活;decommission 步骤报告 Complete,同时丢失了 6 个版本、复活了 3 个 key。使用修复后,两个步骤中的每个 key 要么保持不变,要么在其规则到期时被完整过期,scanner 仍然删除了全部 32 个到期的 key。docs/distributed/decom.sh 在修复上通过:它扩容集群,对存有多版本和分层对象的池执行 decommission,再移除该池,之后 s3-check-md5 报告剩余的每个对象版本都完好。
发布审查(2026-11-06)。 第 10–12 部分和更省的预检查附带以下测试,去掉对应改动时每一个都会失败:
TestILMExpiryNotDoneClassification、TestDeleteObjectVersionsReportsRefusalAndFailure、TestExpiryEventsNameTheResult和TestILMExpiryIncompleteMetrics:每种错误的原因;一次包含被拒绝、失败和已删除条目的批量清理,对前两者计数并写 trace,只对失败写审计,只对删除发送通知;事件说明创建了标记还是删除了版本;两个版本的指标都按原因导出计数器;TestPoolsLifecycleVersionExpiryDeletesEveryCopy:两个池中都有副本时,热版本和分层版本的过期都会删除两份副本,远端对象只删除一次;次要副本删不掉时过期失败,两份副本和远端对象都保留;对持有第二份副本的池执行 decommission 能够完成;TestHealDriveHealsVersionWithRefusedExpiry、TestHealDriveThenExpiryRemoves和TestHealDriveThenExpiryAddsMarker,TestHealDriveDoesNotRestoreStaleMinority作为对照;TestPoolsMigrationExpiryPrecheckReadsHistoryOnce:对一个有 63 个到期非当前版本的 key 执行 decommission 和 rebalance,只读取一次它的历史,结果不变。
合并前仍需完成: 在最终 head 上运行远端 CI。
多节点锁域,以及长时间运行的 scanner 和迁移行为,尚未验证。
集成验证。 这个修复是下个版本集成分支的基础。#281 以及锁域、整对象删除、Heal 演练、#259、#285 和 Console 的修复都叠在它上面,所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 维护者第 1、2 轮 | 发现批量清理中全池核准的传递缺口、读路径隐藏有效版本、被拒绝的过期被当作删除发布,作为第 3–5 部分修复。 |
| 对抗性审查 | 要求修改。两个问题由第 1–5 部分引入:过时修订导致的 legal hold 绕过、重复副本上丢失的待处理 purge,作为第 6–8 部分修复。两个问题更早就存在:不完整历史下的整对象删除、其他拒绝也被发布,分别作为第 6、9 部分修复。整对象过期在整对象 ILM 过期中修复。单池错误码的变化已在上文说明。 |
| 设计第 2、3 轮 | 有条件同意。修订需要严格多数加上该版本自身的读 quorum;认定过时需要严格更新的有效时间戳;待处理的 purge 一票否决;跨池保护冲突在批准前就拒绝。 |
| 补丁审查第 4–7 轮 | 第 4 轮:发现 4 字节的元数据头签名不能用来标识修订。第 5 轮:发现另外三个问题,即只读小写的锁键(原本就有)、摘要缺少表边界、旧格式的分片校验和在不同盘上永远不一致。第 6 轮:发现旧格式的嵌套表哈希时没有排序。以上问题都已修复并补了回归测试。第 7 轮:在约定范围内没有剩余问题,批准。 |
| 发布审查(2026-11-06) | 发现被拒绝和失败的过期几乎不留痕迹,scanner 把拒绝记成删除(第 9、10 部分);获批的版本过期只删掉两个池中副本的一份(第 11 部分);驱动器 heal 跳过它排入过期的版本,过期一旦被拒绝,这些版本就保持降低的冗余(第 12 部分);遍历在预检查时对每个到期版本都读一次 key 的历史;无法给出历史的池会停止它并不持有的 key 的过期;非版本化的分裂 key 可能一直不过期。最后两点作为限制写明。 |
参考
- Issue:#278
- 贡献者 PR:#279
- 配套整对象过期修复:整对象 ILM 过期
- 相关的恢复副本过期:#281、#282
- 迁移过期改动:#276,以及 ILM 过期与存储池迁移顺序
- 上游放置策略改动:minio/minio#21115
- 未采用的放置方案:多池覆盖亲和
27 - 恢复副本:过期的是副本,不是对象
本文覆盖 issue #281 和 Denis Babchuk 提交的 PR #282,基于 main 69fcf50cd 评估。修复与多池生命周期修复(多池生命周期过期,#278)在同一个维护者集成 PR 中交付:贡献者提交原样保留,并附加下文所述的防护。本修复还依赖单池锁域修复,必须与它一起或在它之后合并。
状态: 修复已通过审查,已在本地提交。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未合并或发布。
简短结论
报告属实。执行 POST ?restore 后,已分层的版本会在本地获得一份临时副本,保留到 x-amz-restore 中的到期日期。到期后,生命周期应当只删除这份副本:版本本身保持分层状态,之后重新从 tier 读取。
对非当前版本,这一点原本就正确。对当前版本,清理却因为没有传版本 ID,被当成了对整个对象的删除:
- 开启版本控制的桶会在对象上方多出一个新的删除标记,GET 和 HEAD 返回
NoSuchKey; - 暂停版本控制的桶会把删除作用到下面的
null版本上,删掉这个版本的数据; - 没有
null版本时,删除静默失败,恢复副本永远留在磁盘上。
到期后一次普通的 GET 或 HEAD 就足以触发。
核心修复就是 PR #282 提出的做法:清理始终指向恢复副本所属的精确版本。审查又发现,清理和恢复还有其他途径会碰到错误的版本,因此修复还附带四组改动:
- 一处版本控制状态的修正;
- 一道存储层防护:只有已完成分层的版本才能交出恢复缓存;
- 一组原生恢复修复:让恢复本身不会新建、覆盖它正在回迁的版本,也不会在这个版本上死锁;
- 一套原生恢复的归属协议,来自对抗性审查。每次恢复尝试拥有它正在回迁的版本;恢复在对象锁之外暂存副本,只在提交时短暂持有对象锁(两阶段原生恢复);所有恢复决策都基于多数磁盘一致认可的元数据。
定性
| 维度 | 判断 |
|---|---|
| 性质 | 恢复副本过期中的数据正确性缺陷;继承自上游(MinIO RELEASE.2025-07-23T15-54-02Z 与 master 中代码相同),不是已发布 SILO 版本引入的回归。 |
| 受影响 | 开启版本控制的桶中被恢复的当前版本(包括开启版本控制前写入的 null 当前版本);暂停版本控制的桶中,带版本 ID 且被恢复的当前版本。 |
| 不受影响 | 被恢复的非当前版本;暂停版本控制时被恢复的 null 当前版本;未开启版本控制的桶。 |
| 严重程度 | 致命。对象被删除标记隐藏;如果桶还配置了 NoncurrentVersionExpiration 规则,这条规则之后会把被隐藏的版本连同 tier 对象一起永久删除。暂停版本控制的桶中,更早的 null 版本的数据会被直接删除。 |
| 可达性 | 任何分层对象的恢复,在恢复到期后都会经由 scanner、GET、HEAD、列表、decommission 或磁盘 heal 触发。 |
| 发布紧急度 | 阻塞下一个版本。核心修复改动小;原生恢复的防护改动较大,已实现并通过审查。 |
| 剩余工作量 | 代码、审查、与锁域修复的集成、一次针对真实远端 tier 的恢复验证和一次升级验证都已完成,还需要远端 CI。 |
触发条件
恢复会把到期日期设为至少 Days(最少一天)之后的 UTC 零点,因此缺陷会在恢复一天或更久之后出现。报告者的测试把日期挪到过去,然后在一个池和两个池上分别运行每一条生命周期路径:
| 布局 | main 上的结果 |
|---|---|
| 开启版本控制,被恢复的当前版本 | 删除标记隐藏了对象;恢复副本一直留着,直到后续某一轮把该版本当作非当前版本处理。 |
开启版本控制,被恢复的 null 当前版本 |
同样多出删除标记。 |
暂停版本控制,被恢复的当前版本下面有 null 版本 |
删除作用到 null 版本并删掉其数据;恢复副本和已过期的 x-amz-restore 都还在。 |
暂停版本控制,被恢复的当前版本下面没有 null 版本 |
DeleteObject 返回 VersionNotFound,被静默丢弃且不打日志;副本一直留着,每一轮都重试。 |
在 main 上,贡献者测试的 56 个用例中有 32 个失败,所有路径、两种池数量都是如此。
根因
对最新版本的恢复到期,Lifecycle.Eval 返回 DeleteRestoredAction;对其他版本,返回 DeleteRestoredVersionAction。expireTransitionedObject 只在 DeleteVersioned() 接受的动作上设置版本 ID,而这个集合包含 DeleteRestoredVersionAction,不包含 DeleteRestoredAction。
因此,对当前版本,DeleteObject 收不到版本 ID,把请求当成了对整个对象的删除。本该把删除限制在恢复副本上的 ExpireRestored 标志,也就没有可作用的版本:
- 开启版本控制时,普通路径会写入一个新的删除标记;
- 暂停版本控制时,请求指向
null版本,xlMetaV2.DeleteVersion找到它,并返回它的数据目录供删除。
行为的来源
溯源
来自上游,是一次回归;不是 SILO 引入的。 这个缺陷由 ee158e1(minio/minio#19093,Krishnan Parthasarathi,2024-02-22)引入,首次发布于 RELEASE.2024-02-24T17-11-14Z。恢复副本分支并不是一直缺少版本 ID。SILO 在分叉时继承了这段代码,从 RELEASE.2025-12-03T12-00-00Z 到 RELEASE.2026-10-26T00-00-00Z 的全部 18 个 SILO 版本都包含它。没有任何 SILO 提交改动过这个分支。
| 步骤 | 提交 | PR | 日期 | 首个上游版本 |
|---|---|---|---|---|
两个动作类型 DeleteRestoredAction 和 DeleteRestoredVersionAction,随 ILM 转储和恢复一同出现 |
1ebf6f1(Poorna Krishnamoorthy) | #10565 | 2020-11-12 | RELEASE.2020-11-25T22-36-25Z |
expireTransitionedObject 对每个动作都传入 lcOpts.VersionID,包括恢复副本 |
2ff655a(Poorna Krishnamoorthy) | #10945 | 2020-11-25 | RELEASE.2020-11-25T22-36-25Z |
DeleteRestored() 与 DeleteVersioned() 辅助方法;DeleteVersioned() 只包含 DeleteRestoredVersionAction |
b984bf8(Harshavardhana) | #16757 | 2023-03-09 | RELEASE.2023-03-09T23-16-13Z |
回归点: 只有 if lcEvent.Action.DeleteVersioned() 时才设置版本 ID |
ee158e1 | #19093 | 2024-02-22 | RELEASE.2024-02-24T17-11-14Z |
在 #19093 之前,每个恢复副本都用它所属对象的版本 ID 定位,所以带版本 ID 的恢复当前版本能被正确清理。#19093 的提交说明称,它修复的是已转储对象的当前版本被直接删除、而不是加删除标记的问题。为此它用 DeleteVersioned() 这一判断取代了版本 ID 的传递,而非转储对象的过期路径已经在使用这个判断,其中不含 DeleteRestoredAction。恢复副本分支与它在同一个函数里,作为副作用丢掉了版本 ID。恢复的 null 当前版本到达过期逻辑时 ID 为空,而空 ID 无论在 #19093 之前还是之后,都从未被正确定位。
SILO 在该分支周围改了什么。
| SILO 改动 | 日期 | 是否发布 | 对缺陷的影响 |
|---|---|---|---|
| 8ed5c90 过期只加删除标记时保留分层数据(#253) | 2026-10-23 | 自 RELEASE.2026-10-25T00-00-00Z 起 |
无。它在恢复副本分支之后设置暂停状态和保留远端的判断。上游从未在这个函数中设置暂停状态,所以该分支的这个缺口是继承来的,这次改动也没有补上。 |
| 0a546c7 迁移期间的 ILM 清理(#276) | 2026-11-02 | 仅在 main | 无。分支之后的“Recheck the lifecycle decision and create the hot-tier tombstone before touching the remote tier”就是这个提交加入的。恢复副本分支在到达这段代码之前就已返回,这段代码只对其他动作执行。 |
| 7a060ca 及其回退 9b76a21 | 2026-08-15、2026-09-15 | 仅同时出现在 RELEASE.2026-09-16T00-00-00Z |
无。它们没有改动该分支。 |
从 DeleteRestored() 起的九行代码在全部 18 个 SILO 版本和上游 master 中完全一致,DeleteVersioned() 也是。因此本修复改动的是 SILO 从未修改过的代码。
一并修复的原生恢复缺陷。 恢复相关代码同样是继承来的。池层和集合层的 RestoreTransitionedObject 以及 updateRestoreMetadata,在全部 18 个 SILO 版本中都与上游 master 一致,没有任何 SILO 提交触及它们。
| 缺陷 | 来源 | 提交 | 日期 | 首个上游版本 |
|---|---|---|---|---|
| 多池恢复等待自己持有的锁 | 上游。池层先取对象锁并设置 NoLock,而恢复为自己的写入新建了选项,没有带上它。在此之前,池层对恢复不持有任何锁。 |
6186d11(#20495,Harshavardhana) | 2024-09-29 | RELEASE.2024-10-02T17-50-41Z |
首次读取失败时写出新的空 null 版本 |
上游。失败路径把零值 ObjectInfo 传给元数据更新,后者用空版本 ID 重写最新版本。 |
c829e3a(#12090,Krishnan Parthasarathi)及 cbfdf97(Harshavardhana) | 2021-04-19、2021-04-23 | RELEASE.2021-05-11T23-27-41Z |
| 按最新写入位置路由版本 | 上游。恢复的池查找从来没有带上请求的版本。#12090 就是这样写的;76b21de(#14012,2022-01-10,RELEASE.2022-01-25T19-56-04Z)的池查找把它变成显式行为;c4d0c49(#17451)只给标签和元数据写入开了例外,恢复不在其中。 |
c829e3a(#12090) | 2021-04-19 | RELEASE.2021-05-11T23-27-41Z |
| 单池恢复不持有对象锁 | 上游。单池快捷路径在取任何锁之前就返回。恢复的初稿只在最后的元数据更新处加锁,cbfdf97 把它去掉了,因此没有任何版本从第一次读取起就持锁。#20495 只给多池部署加了锁。 | c829e3a、cbfdf97 | 2021-04-23 | RELEASE.2021-05-11T23-27-41Z |
多池死锁出现在 2024 年 9 月,比过期回归晚七个月。另外三个缺陷从 2021 年 5 月恢复功能的首个版本起就存在。本修复中其余的恢复修复(POST 处理器的版本身份、编码对象和排队的恢复)依赖同样未被改动的函数,没有逐一追溯到具体提交。对抗性审查发现的较早的恢复缺陷也是如此;审查记录把它们与修复本身引入的问题区分开。
日期、PR 编号和发布归属来自 git 历史。原生恢复缺陷的影响以“验证”一节所述测试的结果为准。关于空版本 ID,以及各个原生恢复缺陷来源的说法,来自阅读代码。
方案比较
方案 1:传入精确版本(PR #282)
在恢复副本分支中总是传入版本 ID,空 ID 映射为 null。这样当前版本就和非当前版本走同一条已经正确的路径。这是触及根因的最小改动,予以采纳。
方案 2:把两个生命周期动作合并为一个
让求值器对所有版本都返回 DeleteRestoredVersionAction,在调用点效果相同,但会改变审计、追踪和生命周期包其他调用方看到的动作名称,却没有带来额外的安全性。不采纳。
方案 3:专用的"删除恢复缓存"存储调用
单独的存储操作能从根本上避免把清理缓存和删除混为一谈,但需要新增存储 RPC,滚动升级时还要处理不认识它的节点。这是合理的长期方向,不适合作为发布修复。
方案 4:只在存储层加防护
存储层防护可以拒绝删除不属于恢复缓存的数据,却拦不住对象删除路径:删除标记在任何缓存逻辑之前就已写入。存储层防护作为第二道防线是必要的,但单独使用并不充分。
修复方案
修复分五组。第一组是 PR #282 的原始内容;第 2 到 4 组是前两轮维护者审查后补充的修正;第 5 组来自对抗性审查。
- 指向精确版本(PR #282)。
expireTransitionedObject在恢复副本分支中总是传入版本 ID,空 ID 以null发送。原因是 ListObjects 的max-keys=1捷径读取最新版本时不带桶的版本控制状态,所以即使在开启版本控制的桶中,null版本也可能带着空 ID 到达这里。 - 保留版本控制状态。 桶的暂停状态现在在恢复副本分支返回之前设置,而不是之后。缺少这一改动时,暂停版本控制的桶中被恢复的非当前
null版本,其清理到达存储层时不带这个状态。这种布局给贡献者测试增加了 8 个用例,没有这处改动时它们全部失败。 - 只清理被证明是恢复缓存的东西。
- 清理需要新鲜的元数据。集合无法建立读 quorum 时,不会退回到普通未版本化 DELETE 可能使用的盲删路径。
- 排队中的
null版本清理,如果现在找到的是一个替代它的删除标记,就返回VersionNotFound,而不会对它动手。 - 恢复或清理过程中的读取失败,不再触发对正在回迁的版本做 dangling 对象删除。
- 在
xlMetaV2.DeleteVersion中,恢复副本清理会在任何其他修改之前先检查存储的版本:只有分层已完成的对象版本才拥有恢复缓存,其他情况下这次调用不做任何事。检查依据的是存储的状态,而不是请求声称的状态。旧格式xl.json对象不可能处于已完成分层的状态,因此原样保留。
- 让原生恢复可以放心依赖。 过期修正之后,恢复本身就成了剩下的损坏途径。审查针对 main 发现了以下每一项并在测试中复现,现已全部修复:
- 版本身份。 后台恢复保留桶的版本控制上下文。此前恢复一个
null版本,可能会创建一个指向同一远端对象的新版本 ID;此后任一版本的过期都可能删掉共享的远端对象(这一后果由代码路径推导得出,未做端到端复现)。 - 首次读取失败。 首次读取目标版本失败时,恢复直接返回,不写入任何东西。此前请求一个不存在的版本,会创建一个新的、内容为空的
null版本。 - 多池死锁。 恢复不再在池层已经持有的对象锁之内运行。它在不持有对象锁的情况下暂存副本,提交和写入状态时才自己短暂获取这把锁。此前多池恢复会等待自己持有的锁,直到超时。
- 版本路由。 按请求的版本选择池,而不是按最新版本的写入位置。此前恢复池 0 中的旧版本、而最新版本在池 1 时,会返回
VersionNotFound。 - 编码对象。 回迁会保留 ETag、加密分段对象用来派生密钥的原始分段编号、压缩分段的逻辑大小以及分段索引;也能处理旧版本写入的、使用旧分段大小的对象。
- Object Lock 完整性。 恢复不再写回下载前读取的快照。提交时在对象写锁下重新读取该版本,并采用这次读到的修订中的 Object Lock、标签和复制状态,因此下载期间已确认生效的 legal hold 或 COMPLIANCE 保留期延长都会保留。此前单池恢复不持任何锁,开始时读取的快照会覆盖这类修改。
- 排队中的恢复。 每个版本有自己的恢复锁,恢复先取它、再取对象锁,而且只有短暂的提交才持有对象锁。因此同一个 key 另一个版本的恢复不必等待这次下载。提交在有界等待内拿不到对象锁时,会清除自己的标记并删除暂存的副本。此前,恢复在一次锁超时后放弃,会让该版本永久停留在
ongoing-request="true",之后每次 POST 都返回RestoreAlreadyInProgress。
- 版本身份。 后台恢复保留桶的版本控制上下文。此前恢复一个
- 给每次原生恢复确定归属(对抗性审查)。 第 1 到 4 组到位后,对抗性审查仍发现三类缺口:恢复可被其他调用方触及;恢复可能依据版本元数据的少数派视图行事;旧的尝试可能作用于更新的尝试。下面每一项都关闭了审查在测试中复现的问题。
- 尝试身份。 每个启动回迁的 POST 都会在版本的内部元数据中写入一个尝试 ID,并同时记录 POST 解析出的身份:版本 ID、ETag、大小和修改时间。后台 worker、它的暂存和提交都带着这个尝试。尝试不再拥有该版本时,worker 停止,不提交、也不重置任何东西。之后的 POST 只有拿到该版本的恢复锁时才会接管进行中的恢复,而运行中的 worker 一直持有这把锁。否则它最多等待两秒,然后在磁盘显示该版本正在恢复时返回
409 RestoreAlreadyInProgress,无法判断时返回可重试的错误,而不是等待下载结束。 - 捕获的身份。 worker 使用 POST 解析出的版本控制状态和显式版本 ID 写入,
null目标始终是null。此前,在恢复排队期间开启版本控制,会让恢复副本获得一个指向同一远端对象的新版本 ID。同一 ID 下替换进来的null对象被视为不同的目标。 - 恢复失败的处理。 worker 在发布任何内容之前失败时,会以有限次数的重试,把自己的尝试重置为可重试状态,并删除暂存的副本。发布到了部分磁盘、但没有达到写 quorum 时,由执行发布的 worker 在提交的同一次持锁中收敛,之后再做有限次数的内存重试(两阶段原生恢复)。
- 暂存提交(取代私有 upload)。 最初的设计把恢复数据放在一个私有的分段上传里,用标记对公开分段调用隐藏,并在提交锁下暂存它的完成。它会让暂存之后完成失败的普通上传卡住,还让该 key 的读写等待下载结束。下一个版本改为在对象锁之外把副本暂存在 tmp 卷中,再在一次短暂的持锁中逐盘发布;见两阶段原生恢复。
- 权威修订。 POST、worker 和重置都会从每块磁盘读取精确版本,并使用至少
max(n/2+1, 读 quorum)块磁盘持有的元数据修订。修订的识别方式与多池修复相同:版本头加上规范化元数据的摘要。- 它们只修改该修订中的恢复字段,并且只写回持有该修订的磁盘。此前,一次状态写入可能把某块过期磁盘的元数据复制到所有磁盘上,已确认的 legal hold 因此丢失。
- 写入失败时,各磁盘会恢复为原来的修订。
- 当磁盘之间只有恢复字段不同时,去掉这些字段后的修订仍能识别该版本。之后的 POST 或该尝试自己的重置可以让磁盘重新一致。只有当某个尝试是唯一进行中的尝试时,它的重置才会生效。
- 没有这样的修订时,请求失败,不写入任何东西。
- 过期和 heal 使用同一视图。 恢复副本过期的新鲜复核需要自己的理由:一个恢复副本过期动作、一个有效且已过的恢复期限、已完成的分层,以及没有进行中的恢复。此前,另一个到期的生命周期动作可能删掉已续期的缓存。复核依据上述权威修订做决定,磁盘对恢复状态有分歧时拒绝。下线迁移遇到过期恢复副本返回
errLifecycleRecheckRefused时,仍会把该已分层版本、其层元数据和恢复期限迁移到目标池(5cb91e7d7)。heal 只有在多数磁盘持有相同的恢复字段时,才会把它们复制到落后的磁盘上;否则等待。 - 有界等待。 POST 的首次读取不取锁,它的锁等待都是有界的。
- 滚动升级。 旧节点不遵守恢复锁、尝试栅栏和暂存提交。处理 POST 的节点现在通过内部能力 RPC 询问每个对等节点,该 RPC 给出原生恢复的协议号 2。在每个对等节点都回答支持之前,POST 返回
503 ServiceUnavailable,不写入任何东西,并在错误信息中将不可达对等节点与运行旧版本的对等节点区分开并指明该节点(b8ebaffd2)。排队中的 worker 在开始前会再问一次,有对等节点没有回答时清除自己的标记。 - 锁域。 对抗性审查复现了:在各纠删集合不共享主机的分布式单池部署中,
DELETE和CopyObject会与恢复竞争。这个继承来的锁域分裂由单池锁域修复关闭,本组依赖它,这里不再重复。
- 尝试身份。 每个启动回迁的 POST 都会在版本的内部元数据中写入一个尝试 ID,并同时记录 POST 解析出的身份:版本 ID、ETag、大小和修改时间。后台 worker、它的暂存和提交都带着这个尝试。尝试不再拥有该版本时,worker 停止,不提交、也不重置任何东西。之后的 POST 只有拿到该版本的恢复锁时才会接管进行中的恢复,而运行中的 worker 一直持有这把锁。否则它最多等待两秒,然后在磁盘显示该版本正在恢复时返回
为什么方案充分且必要
充分性。 所有让恢复副本过期的路径都汇入 expireTransitionedObject,现在都带着精确版本。这些路径包括 scanner、GET 和 HEAD、列表、decommission 和磁盘 heal;集合层和存储层随后只对这个版本操作。三层防护互为补充:
- 版本 ID 选定目标;
- 新鲜的元数据确认目标及其到期时间;
- 存储的分层状态证明这份数据是缓存。
即使目标选错,只要它不是已完成分层的版本,就不会丢失数据。原生恢复的修复保证:被清理的版本正是当初被恢复的那个版本,并且它的保护元数据完好。
第 5 组让这些修复在并发和部分失败下依然成立:
- 同一时间只有一个尝试拥有一个版本,只有这个所有者能提交恢复或重置它。
- 副本暂存在公开调用方无法触及的位置,只在对象锁下、通过两阶段提交的核验之后才发布;只要还有节点不支持这个协议,开关就让恢复保持关闭。
- POST、worker、过期和 heal 都依据同一个权威修订做决定;没有权威修订时,它们都按失败关闭处理。
提交协议本身的论证见两阶段原生恢复。
必要性:
- 没有精确版本,报告的缺陷依然存在。
- 没有版本控制状态的修正,暂停版本控制的桶仍有一种布局会失败。
- 没有元数据和存储层防护,过期或路由错误的清理仍可能删掉一个未分层版本的唯一数据。
- 没有原生恢复的修复,修好过期之后,恢复会成为造成同类损坏的路径:新的版本别名、空版本、丢失的 legal hold,或永远"进行中"的版本。
- 没有第 5 组,对抗性审查复现了以下问题:
- 一块过期磁盘的元数据在所有磁盘上覆盖已确认的 legal hold;
- 旧的尝试重置了更新的尝试;
- 过期或 heal 依据少数派的恢复状态行事。
这些改动必须一起交付:在清理正确之前先解除多池恢复死锁,会让有害的清理在更多部署上变得可达。
防护不承诺的范围。 存储层防护只保证未分层的版本不会交出自己的数据。如果选错的目标本身也是已完成分层的版本,防护会放行,最坏结果是丢失该版本的恢复缓存,而不是它的数据。正确选定目标仍由版本 ID 和元数据复核负责。
代价与兼容性
- 不改变公开 API、配置、指标或依赖。新增一个内部元数据键:版本上的尝试;新增一个内部对等 RPC,回答节点支持哪个原生恢复协议。旧版本会忽略这个键。
- 当前版本的恢复副本现在只会单独被删除。此前因清理失败而残留的恢复副本,会在下一轮被删除。
- 不再等待下载。 该 key 的 GET、HEAD 和写入不再等待恢复的下载,只有短暂的提交才持有对象锁。#284 由两阶段恢复修复(两阶段原生恢复)。提交的持锁时间随版本元数据和磁盘数增长,不随下载时间增长。
POST ?restore的行为变化。- 在已升级节点上,只要有对等节点运行旧版本或无法连接,它就返回
503 ServiceUnavailable,不写入任何东西。旧节点仍使用旧恢复路径;混合版本期间应暂停发起新的恢复请求。 - 磁盘对版本元数据没有一致意见时,它失败且不写入。
- 该版本的恢复正在进行时,它在几秒内返回
409 RestoreAlreadyInProgress。 - 除对象状态无效之外的错误,按常规 S3 映射返回,而不再全部变成
InvalidObjectState。
- 在已升级节点上,只要有对等节点运行旧版本或无法连接,它就返回
- 磁盘对恢复字段有分歧、且没有多数磁盘持有相同字段时,该版本的 heal 会等待。
- 回滚即恢复旧行为,也包括这个缺陷。回滚之前,按两阶段原生恢复中的降级规则操作。
剩余限制
- 已经造成的损坏不会被修复。 旧清理创建的删除标记会保留。它们是普通的删除标记,在通过 ILM 审计日志或桶通知确认它来自恢复副本过期后,可以用
DELETE ?versionId=移除。已从null版本删掉的数据,以及已被非当前版本规则删除的隐藏版本,本修复无法找回。 - 大规模单池部署中的锁域。 在分布式单池部署中,如果各纠删集合不在同一组主机上,DELETE 和 CopyObject 使用的锁域与 PUT、元数据更新和恢复不同,恢复期间的 DELETE 或复制因此可能与它竞争。这个继承来的分裂影响范围不止恢复,由单池锁域修复关闭;本修复不能脱离它单独发布。
- 滚动升级。 开关只能阻止已升级节点上的新恢复;由旧节点处理的 POST 仍走旧路径。升级和降级规则见两阶段原生恢复。
- 重启。 重置失败尝试的重试在内存中进行。重启后,版本保持进行中状态,直到之后的 POST 接管。
- 磁盘分歧。 磁盘对恢复字段有分歧、且没有多数磁盘持有相同字段时,该版本的 heal 和过期会等待。足够多的磁盘响应后,之后的 POST 或该尝试自己的重置会让磁盘重新一致。如果磁盘在其他元数据上也有分歧,以至于去掉恢复字段后仍没有修订达到 quorum,恢复和 heal 都不会改动该版本;它需要之后一次在所有磁盘上重写元数据的写入,例如标签更新。这种双重故障情形由代码推导得出,未经测试。
- 继承且未改变的行为。 临时文件清理可能删除任何 upload 中传输时间超过过期 upload 时限的进行中分段。磁盘在修改时间和 ETag 上一致、但其他字段不一致时,普通 GET 和 HEAD 仍可能返回某块过期磁盘的元数据。恢复决策不再依赖这次读取。
验证
第 1 到 4 组,在集成分支 head 上。
TestExpireRestoredCopy覆盖 8 种布局 × 4 条生命周期路径(scanner、HEAD、列表,以及 decommission 和磁盘 heal 的同步调用)× 一个池和两个池,共 64 个用例:贡献者的 56 个,加上暂停版本控制下非当前null布局的 8 个。它们在 main 上失败,修复后通过。TestExpireRestoredRequiresFreshMetadata、TestExpireRestoredRenewedOrMissing、TestExpireRestoredStorageBoundary、TestExpireRestoredKeepsReplacementDeleteMarker、TestExpireRestoredMetadataReadKeepsMinorityVersion和TestExpireRestoredStorageRejectsOtherTypes覆盖各项防护。TestNativeRestore*系列与TestMultipartCompletionLockAlias覆盖原生恢复修复:多池、版本路由、首次失败、POST 版本身份、恢复期间的 Object Lock 更新、排队恢复、读取安全,以及编码对象和旧格式对象。- 在维护者审查轮次所审的 head 上,完整的
./cmd测试和定向 race 选择均通过。
第 5 组。 补丁曾新增 36 个测试,布局相关时分别在一个池和两个池上运行。私有 upload 和提交暂存的 13 个测试随那个设计一起删除,第 4 组中从客户端完成恢复 upload 的 TestNativeRestoreConcurrentCompletion 也一并删除;两阶段恢复的测试见两阶段原生恢复。其余 23 个保留:
- 状态写入与尝试:
TestRestoreStatusUsesQuorumGeneration和TestRestoreAttemptGenerationFencing;TestRestoreGenerationSplitNeedsQuorum、TestRestorePOSTStatusWriteSplit和TestRestorePOSTStatusWritePartialFailure;TestRestorePOSTKeepsConcurrentProtection和TestRestorePOSTRacesCacheExpiry;TestRestorePOSTRejectsReplacedTarget、TestRestoreKeepsCapturedNullIdentity和TestRestoreReplacementNullObject;TestRestoreSupersededAttempt、TestRestoreRecoversStrandedMarker和TestRestoreRestartTakeover;TestRestorePOSTOngoingBound、TestRestoreDuplicatePoolCacheStates和TestRestorePOSTControls。
- 过期与 heal:
TestExpireRestoredNeedsOwnReason、TestExpireRestoredUsesQuorumGeneration和TestRestoreHealNeedsRestoreQuorum。 - 滚动升级:
TestNativeRestoreCapabilityGrid在真实的测试 grid 上运行,并包含一个旧式对等节点。TestNativeRestoreCapabilityCache、TestNativeRestoreRollingUpgradeGate和TestNativeRestoreCapabilityRevalidates覆盖缓存、重连和取消。
设计轮次产生的回归测试在之前的集成 head 上失败,在补丁上通过。补丁审查产生的每个回归测试,在只禁用它对应的修复时失败,启用时通过;对照用例在两种情况下都通过。
恢复、生命周期、分段上传、分层、过期、过期 upload 和 heal 的测试选择通过:./cmd 中共 260 个测试。lifecycle 和 grid 包通过。开启 race 检测的 63 个恢复与清理测试没有报告数据竞争。gofmt 和 go vet 均干净。
对抗性审查自己的探针在最终补丁上运行,46 个中 33 个通过。其余 13 个都有解释:
- 3 个需要锁域修复。在集成分支上,它们表明这些竞争已经不会发生:COPY 不再覆盖恢复的版本,DELETE 会等待恢复结束,公开的 abort 也找不到被隐藏的 upload;
- 1 个命中继承来的临时分段清理;
- 9 个断言的结果被商定的设计有意改变,例如把 heal 的推迟当作致命错误、一个已经无法发生的竞争、现在需要等锁的清理,以及一块留给 heal 处理而不是被重写的过期磁盘。
真实 tier 恢复。 2026-11-05,用第二台 SILO 服务器作为一个启用版本控制、SSE-S3(静态 KMS 密钥)和压缩的桶的远端 tier。一个当前版本、一个非当前版本、一个 74 MB 的分片对象和一个不可压缩对象先转储到 tier,再用 POST ?restore 恢复。使用修复后,四次恢复全部完成;停掉 tier 之后,每个恢复的版本仍能逐字节读回,说明数据来自恢复副本。在 69fcf50cd 上,加密且压缩的分片对象恢复失败,报 Part size for 1 should be at least 5MB,停掉 tier 后该对象无法读取;其余三个恢复成功。恢复副本的过期没有包含在这次运行中,因为最短的恢复期是一天;上面的测试覆盖了它。
从上一个版本升级。 同一天,在一台主机上运行一个四服务器集群,版本为 RELEASE.2026-10-26T00-00-00Z,配置了远端 tier,并有已转储的对象。服务器两台一组地换成修复版本,因为正在启动的服务器会等待另一台服务器运行相同的二进制。在两台旧服务器和两台新服务器同时提供服务期间,通过每台服务器执行的 PUT、GET、列举和 DELETE 都成功,通过每台新服务器发出的 POST ?restore 都返回 503 ServiceUnavailable。最后一台服务器替换完成后,恢复成功完成,停掉 tier 后恢复的对象能正确读回,升级前写入的 20 个对象全部一致。
合并前仍需完成: 在最终 head 上运行远端 CI。
跨主机的锁域、跨主机的混合版本集群、长达一小时的存储停顿以及节点间时钟偏差尚未验证。
集成验证。 这个修复在下个版本的集成分支上紧接 #278,锁域、Heal 演练、#259、#285、delete-all 和 Console 的修复叠在它上面。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 维护者第 1、2 轮 | 发现版本控制状态缺口、缺失的存储层防护和原生恢复缺陷;作为第 2 到 4 组修复。 |
| 对抗性审查,设计第 1、2 轮 | 要求修改,发现七个问题。一个由第 4 组引入:客户端可以在 upload 的某个分段通过校验后替换它,恢复随后把这些字节提交到受 COMPLIANCE 保护的版本上。六个是较早就存在的: - POST 覆盖较新的保护元数据; - 恢复排队期间开启版本控制,会给 null 版本造出别名;- 失败的恢复永久停留在进行中; - 过期 upload 清理删掉旧对象上新启动的恢复; - 另一个生命周期动作可能让已续期的缓存过期; - 失败的分段恢复泄漏其 upload。 第 2 轮商定了第 5 组的设计。 |
| 补丁审查,第 3 轮 | 要求修改。由补丁引入的:旧节点忽略私有标记、标记检查不是失败关闭、尝试之间没有隔离。较早就存在的:状态写入可能扩散一块过期磁盘的元数据、POST 可能作用于替换进来的 null 版本、POST 等待上限不起作用。锁域分裂交由单独的修复处理。 |
| 补丁审查,第 4 到 10 轮 | 第 4 轮发现四个问题:POST 写入失败使恢复处理无法继续、提交暂存暴露了 upload、能力检查存在竞争、过期的首次读取绕过了等待上限。第 5 轮又发现五个:旧的重置可能清除更新的尝试、分歧视图让清理删掉运行中的 upload、暂存宽限期删掉未暂存的副本、活动读取失败时恢复了长时间等待、缓存的能力回答忽略取消。第 6 轮发现过期可能跟随少数派的已过期头部。第 7 轮发现 heal 可能把少数派的恢复状态提升为多数,以及正在提交的 upload 可能永远留存。第 8 轮发现中止会删除年龄未知的残留,以及过期清理可能删掉运行中的提交。第 9 轮发现,过期清理如果恰好在提交暂存之前读取了一个普通 upload,仍可能把它删掉(既有问题)。第 10 轮发现,清理的重命名在磁盘期限到达后可能比它持有的锁活得更久。全部已修复并配有回归测试。 |
| 补丁审查,第 11 轮 | 批准,附一条文档意见:在挂起的清理重命名之后等待的分段完成会持有对象锁。已记入那个设计的剩余限制。 |
| 被取代(2026-11-06) | 私有 upload 与提交暂存被两阶段恢复取代,后者同时修复了 #284;见两阶段原生恢复。属于它们的限制(失败的普通提交、挂起的清理重命名、长时间恢复期间的可用性)随之消失。 |
参考
28 - 生命周期"删除全部版本"只能作用于一个 key
本文记录的缺陷是在审查 #278 的修复(多池生命周期过期)时发现的。评估基于 main 69fcf50cd,以及 #278 和 #281 的维护者集成分支;该分支尚未公开。
状态: 修复已通过审查,并已合入 main。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未进入已打标的 SILO 发布。
简短结论
缺陷属实,并阻塞下一个版本。有三种操作会删除一个 key 的"全部版本":
- 生命周期扩展
ExpiredObjectAllVersions; - 生命周期动作
DelMarkerExpiration; purge.retainVersions: 0的批量过期,而这正是默认值。
三者都是在磁盘上递归删除该 key 的目录。对象 a/b 存放在对象 a 的目录里面,所以清除 a 时,a/b、a/b/c 以及所有以 a/ 开头的 key 都会被一起删除。这些 key 在每个纠删集合、每个池中都会被删,无论它们有多新,也无论是否有任何规则选中它们。这次清除只为 a 写一条 ILM 审计记录、发一条桶通知,其他 key 没有任何记录。
同一条路径还有两个问题。第一,它跳过了本应在对象锁内确认过期是否仍然成立的复核,所以 scanner 做出判断之后才写入的版本也会被删掉。第二,它直接丢掉分层版本的元数据,却不留下分层 free version,这些版本的远端对象永远不会被清理。
修复把递归删除替换为:按版本 ID 清除该 key 自身的版本。清除在删除操作已经持有的对象锁内进行,并在锁内读取的版本上重新做一次判断。随后逐个删除这些版本,最旧的先删,最新的最后删,使用磁盘的版本删除;这一操作只在对象目录为空时才删除目录。本版本中,带有已分层版本的 key 会被拒绝,所有内容保持不变。本修改建立在 #278 修复之上(两者修改同一个函数),也建立在单池锁域修复之上。
定性
| 维度 | 判断 |
|---|---|
| 性质 | “删除全部版本"实现中的数据丢失缺陷。继承自上游 MinIO,存在于每个 SILO 版本中,包括 RELEASE.2026-10-26T00-00-00Z。不是回归。 |
| 受影响部署 | 所有纠删码部署:一个或多个池、一个或多个纠删集合。开启、暂停或未开启版本控制的桶都受影响。 |
| 受影响操作 | 带 ExpiredObjectAllVersions 的 Expiration(mc ilm rule add --expire-all-object-versions);DelMarkerExpiration;清除时不保留任何版本的批量过期规则;decommission 和 rebalance 在迁移中应用上述规则时。 |
| 受影响布局 | 被该操作删除的 key K,同时存在其他以 K/ 开头的 key。这些 key 不需要匹配规则,也不需要已经过期,可以位于其他纠删集合或池中。 |
| 不受影响 | 目录对象(K/),它的元数据与其子对象分开存放。Kx、K-1 这类兄弟 key。启用了对象锁的桶上的生命周期:这两类规则在配置时被拒绝,在求值时被跳过。其他生命周期动作,它们每次只作用于一个版本。强制删除(x-minio-force-delete),它按约定就是前缀删除。 |
| 严重程度 | 致命。没有被任何规则选中的在用对象被永久删除,而且没有任何审计记录、通知或 trace 提到它们。 |
| 可达性 | 普通配置加普通的 key 布局即可触发:一个 key 同时是其他 key 的"文件夹”,例如 report 和 report/page-1。按默认设置编写的批量过期任务也会触发。 |
| 发布紧急度 | 阻塞下一个版本。 |
| 剩余工作量 | 已实现,并通过对抗性审查;尚需在单池锁域修复之后集成、远端 CI 与发布。 |
触发条件
有三个调用方会向池层请求删除全部版本:
- 过期 worker,即
applyExpiryOnNonTransitionedObjects。它执行 scanner 入队的过期,也执行 GET、HEAD 和列举操作在遇到到期对象时入队的过期。 - 迁移过期,即
applyMigrationExpiry。自 #276 起,decommission 和 rebalance 在遍历中直接调用 worker 的这个函数。 - 批量过期,即
batchObjsForDelete,用于清除时不保留任何版本的规则。
我们在 main 上逐一做了探测。每种布局中,旧 key 已到期,其他 key 都是新的:
| 探测 | 布局 | main 上的结果 |
|---|---|---|
scanner 与过期 worker,ExpiredObjectAllVersions,一个池、两个纠删集合 |
ea/parent(两个版本);ea/parent/child 在同一集合;ea/parent/deep/grandchild 在另一集合;ea/parentx |
parent、child、grandchild 被删;ea/parentx 保留 |
| 磁盘 scanner 自身的目录遍历,再执行 worker | 同样的布局,位于一个集合 | 结果相同 |
| 两个池 | ea/parent 在第一个池;ea/parent/child 和 ea/parentx 在第二个池 |
parent 和 child 被删;ea/parentx 保留 |
DelMarkerExpiration |
最新版本为旧删除标记的 dm/parent;dm/parent/child |
两者都被删 |
| 未开启版本控制的桶 | ea/parent、ea/parent/child |
两者都被删 |
| 目录对象 | ea/dir/、ea/dir/kid |
ea/dir/ 被删;ea/dir/kid 保留 |
批量过期,规则 name: bx/parent,默认清除 |
bx/parent、bx/parent/child、bx/parentx |
parent 和 child 被删;bx/parentx 保留 |
带 ExpiredObjectAllVersions 的 decommission 和 rebalance |
obj-01、obj-01/child、obj-01x |
obj-01 和 obj-01/child 从两个池中消失;obj-01x 被迁移 |
在 #278 的集成分支上,这些探测的结果相同。那个修改并不能缓解本缺陷。
根因
这次删除经过三层,每一层都把它扩大了一些。
- 调用方请求前缀删除。 对于
Action.DeleteAll(),applyExpiryOnNonTransitionedObjects设置DeletePrefix和DeletePrefixObject。批量过期设置同样两个标志。 - 池层丢掉其他选项。
erasureServerPools.DeleteObject对所有DeletePrefix请求都用deletePrefix处理。该函数以新建的ObjectOptions{DeletePrefix: true}调用每个池,因此丢掉了两样东西:Expiration.Expire。上游正是为这个动作在erasureObjects.DeleteObject中加的复核,因此永远不会执行。DeletePrefixObject。erasureSets.DeleteObject因此把删除发给每个池的每个纠删集合,并丢弃它们返回的错误。
- 磁盘删除整个目录。
erasureObjects.deletePrefix在每块盘上以Recursive和Immediate调用Delete。在磁盘上,对象a是目录a/,里面有它的xl.meta和数据目录;对象a/b是这个目录里面的a/b/xl.meta。递归删除把整棵目录树移进磁盘的回收区,并立即清除。
目录对象之所以幸免,只是因为 a/ 被存成 a__XLDIR__,位于 a/ 旁边,而不是里面。
同一条路径还有三个副作用:
- 没有复核。 scanner 的判断可能是几分钟甚至几小时之前做出的。在单池部署上,判断之后才写入的版本会被一起删除,我们复现了这一点。审查 #278 修复时还发现了一个相关情形,我们做了推理但没有探测:在元数据不完整时做出的判断,例如一半磁盘不可读、其余磁盘中又有一块缺少某个新版本,会删除一个已经提交的版本。
- 分层泄漏。 整体删除
xl.meta,会删掉每个已分层版本的记录,包括先前的删除为分层清理留下的 free version。它们的远端对象仍留在分层存储中,却再也没有任何东西引用它们。我们对该 key 及其子对象都复现了这一点。 - 错误被忽略。 跨纠删集合的扇出删除,即使某个集合删除失败,也会报告成功。
行为的来源
来源追溯
这个缺陷来自上游 MinIO。SILO 在分叉时继承了它,并没有引入它。 分叉点是 27742d469(2025-12-03)。缺陷路径上的函数与上游 master(7aac2a2c5,2026-02-12)完全一致:erasureServerPools.deletePrefix、erasureSets.deletePrefix、erasureSets.DeleteObject、erasureObjects.deletePrefix 和 batchObjsForDelete 与上游相同,erasureServerPools.DeleteObject 和 erasureObjects.DeleteObject 中的 DeletePrefix 分支也相同。生命周期调用方对"删除全部版本"仍使用上游的选项。
缺陷由四个上游要素组合而成。生命周期规则第一次可能删除其他 key 的版本是 RELEASE.2023-06-29T05-12-28Z。第一个带有该缺陷的 SILO 版本是 RELEASE.2025-12-03T12-00-00Z,也就是第一个 SILO 标签。从它到 RELEASE.2026-10-26T00-00-00Z 的全部 18 个 SILO 版本都包含它。
上游要素。
| 要素 | 提交 | 日期和作者 | 首个上游版本 |
|---|---|---|---|
| 在每个纠删集合的每块盘上递归删除 key 的目录,经由一个自己重建选项的池层函数。为强制删除而加入,那里删除整个前缀正是目的。 | 7722b91e1、minio/minio#12504 | 2021-06-16,Anis Elleuch | RELEASE.2021-06-17T00-10-46Z |
第一个生命周期调用方:ExpiredObjectAllVersions 设置 DeletePrefix。同一提交还把对象锁移到了前缀分支之前。 |
aae684641、minio/minio#17521 | 2023-06-28,Harshavardhana | RELEASE.2023-06-29T05-12-28Z |
批量过期:不保留任何版本的清除使用 DeletePrefix 删除。最初没有设置 DeletePrefixObject,因此会扇出到所有集合。 |
a50f26b7f、minio/minio#17946 | 2023-12-02,Krishnan Parthasarathi | RELEASE.2023-12-02T10-51-33Z |
DelMarkerExpiration,走同一条路径。 |
7926401cb、minio/minio#19481 | 2024-04-30,Krishnan Parthasarathi | RELEASE.2024-05-01T01-11-10Z |
上游中碰过同一批函数、但没有改变其效果的修改:
- 1a884cd8e(minio/minio#13242,2021-09-17,
RELEASE.2021-09-18T18-09-59Z)让跨纠删集合的扇出并发执行,并让它丢弃错误。 - b49fc33cb(minio/minio#15148,2022-07-11,
RELEASE.2022-07-13T23-29-44Z)让强制删除立即清除磁盘的回收区。 - 88a89213f(minio/minio#19231,2024-03-09,
RELEASE.2024-03-10T02-53-48Z)让这种清除不再阻塞。 - 6a1558081(minio/minio#19685,2024-05-06,
RELEASE.2024-05-07T06-41-25Z)让单个集合内的磁盘删除返回写仲裁错误。跨集合的扇出仍然丢弃这些错误。
上游中没有收窄这条路径的修改。 三个修改都没有生效,因为池层会重建选项:
- eb55034df(minio/minio#17827,2023-08-09,
RELEASE.2023-08-09T23-30-22Z)加入了DeletePrefixObject。它是为配置删除避免扇出的优化,并不保护其他 key。 - c61dd16a1(minio/minio#19365,2024-03-27,
RELEASE.2024-03-30T09-41-56Z)在扫描器和批量过期中设置了这个标志,同样是"为避免扇出调用"。 - 289223b6d(minio/minio#19359,2024-03-27,
RELEASE.2024-03-30T09-41-56Z)在erasureObjects.DeleteObject中加入了Expiration.Expire下的复核。三个修改里只有它以安全为目的,而池层的调用永远走不到它。
SILO 版本。 标签 RELEASE.2025-12-03T12-00-00Z 到 RELEASE.2026-10-26T00-00-00Z(共 18 个)都以上游的形式包含池层和集合层的前缀函数、扫描器与批量过期的调用点,以及磁盘层的删除。
碰过这条路径的 SILO 提交。 没有一个改变了前缀删除本身。它们对缺陷的影响:
| 提交 | 日期和作者 | 发布于 | 影响 |
|---|---|---|---|
| 40bee4b7b、e59a3d938 | 2026-09-06 和 2026-09-11,Feng Ruohang | RELEASE.2026-09-16T00-00-00Z |
无。在 erasureServerPools.DeleteObject 的 DeletePrefix 一带,它们只改了对象锁上方的注释。其余改动(条件删除、跨池串行化)都在该分支之后。 |
| 1cf529ce8、eb4f5e5b3、60c7179dd、a30270d9b、3c82748e1、a2073d251 | 2026-09-15 至 2026-10-24,Feng Ruohang 和 Denis Babchuk | RELEASE.2026-09-16T00-00-00Z 至 RELEASE.2026-10-25T00-00-00Z |
无。它们改的是 erasureServerPools.DeleteObject 和批量删除的其他部分(跨池版本删除的协调、数据迁移、删除标记的放置、分层引用)。DeletePrefix 分支在这些代码之前就已返回。 |
| 1748f62c7 | 2026-10-18,Feng Ruohang | RELEASE.2026-10-18T00-00-00Z |
收窄了一个调用方。下线和再平衡原先像上游一样,用前缀删除把已搬走的 key 从源池移除;现在改为按版本 ID 移除已搬走的版本。该提交声明的目的是并发安全。 |
| 7a060cab1、374de0fa3、9b76a2167 | 2026-08-15、2026-09-08、2026-09-15 | 未发布 | 一个按访问频率搬迁对象的功能曾加入一个对精确 key 使用前缀删除的调用方,随后改为按版本寻址的删除,最后被回退。RELEASE.2026-09-16T00-00-00Z 标签不含其中任何内容。 |
| 0a546c7a7,随 #276 合并于 0fc5a0b52 | 2026-11-02,Feng Ruohang | 未发布(RELEASE.2026-10-26T00-00-00Z 之后的 main) |
扩大了暴露面。下线和再平衡现在把"删除全部版本"的动作交给 applyExpiryOnNonTransitionedObjects,等于给缺陷路径增加了一个调用方。同一提交没有改动删除全部版本的选项。在它之前的 19910230a 和 a995921e5 在迁移期间只执行针对单个版本的动作,没有扩大。 |
因此,这个缺陷不是任何版本中的回归。唯一让它更容易被触发的 SILO 改动在 main 上,尚未发布。
由 git 历史确认、未实际运行: 引入提交、它们的日期和各自首个上游版本,上面所列函数与上游 master 逐字节相同,以及每个 SILO 版本的内容。没有把每个要素提交对应的版本重新构建并运行。
背景。 上游 master 仍是同样的代码。在 SILO 中,#276 让迁移遍历立即执行这类过期。
方案比较
方案 A:在磁盘层做"精确对象"删除
新增一个磁盘操作:删除 xl.meta 及其引用的数据目录,然后仅在对象目录为空时删除该目录。
- 它能保护子对象。
- 它会删除磁盘上持有的所有版本,包括判断之后写入的版本,以及 quorum 视图中缺失的版本。它仍然需要复核;而且除非接收一个版本列表,否则无法只删除已判定的版本。真到了那一步,它就是现有的
DeleteVersions调用。 - 为了避免分层泄漏,磁盘层必须自己写 free version,这和版本删除路径已经做的事重复。
- 它需要一个新的存储调用。在现有
Delete调用上加新标志是不安全的:旧节点解码DeleteOptions时会跳过未知字段,滚动升级期间它们会在自己的磁盘上执行递归删除。
不采纳:存储层代码更多,有升级风险,而且仍然需要方案 B 的逻辑。
方案 B:按 ID 清除该 key 自身的版本
这是推荐的方案。
- 子对象。 版本删除路径(
xlStorage.deleteVersions)会改写或删除xl.meta,并把每个被删版本的数据目录移入回收区。它只在对象目录为空时才删除该目录,所以其他 key 不受影响。我们用现有批量路径做了原型:删除了一个 key 的全部四个版本,其中有一个删除标记和一个分层版本,两个纠删集合中的子对象都完好无损。 - 分层版本。
xlMetaV2.DeleteVersion会为每个已分层的版本加一个 free version。原型留下了一个,之后由 scanner 的 free version 清理删除远端对象(见 Tier free version 是内部清理记录)。 - 对象锁。 生命周期在启用对象锁的桶上会在配置时拒绝这两类规则,并在求值时跳过它们,清除不改变这一点。由于清除按版本 ID 删除,它可以在这一处逐版本执行保留期和 legal hold 检查,原语会检查将被删除的每个物理修订。批量作业提交者的授权由调用方另行检查。
- 复制。 “删除全部"仍然是本地清除:删除版本时不带复制状态,不创建删除标记,也不入队任何复制,与现在相同。
- Quorum 与部分历史。
- 清除只删除它判定过的版本。quorum 视图中缺失的版本会保留,由 heal 去协调。
- 写 quorum 失败时,key 保持原样,下一轮扫描会重试。
- 当一个很长的版本列表分成多次调用时,先删最旧的版本,最新版本最后删,所以部分完成的清除不会改变当前版本,也不会暴露更旧的版本。
- 多池。 同一个版本列表发给每个池中该 key 所在的纠删集合。不持有某个版本的池会回答"不存在”,批量路径对物理删除接受这个回答。扇出到所有纠删集合的做法不再需要。
- 代价。 每个池中一个集合的每块盘,做一次
xl.meta的读-改-写,每个版本再做一次数据目录重命名,都在对象锁内完成。历史很长时分成多次调用。 - 滚动升级。 清除使用现有的
DeleteVersions调用,没有新的传输字段,也不改存储格式。 - #278 的检查。 合并检查已经在锁内读取所有池中的版本并对规则求值。清除可以直接使用这个列表,而不只是一个"是/否"的结果。
设计审查修改了这个方案的三处,结果见修复方案。批量路径会重新排列一个 key 的版本,而且池与池之间没有屏障:在一次两池探测中,较旧版本的删除失败了,它却删掉了最新的删除标记,随后的读取返回了那个较旧版本。所以清除改为一次删除一个版本。仅靠 free version 在这里不能保证分层 key 的安全:free version 会把远端对象交给分层清理,而审查发现,另一个池中的副本,或一次没有达到 quorum 的删除,仍可能引用那个对象。所以带有已分层版本的 key 一律拒绝。此外,重复副本和 null 写入会重复使用同一个版本 ID,所以清除在删除任何东西之前,先记录每个池中的每个副本。
方案 C:K/ 下还有其他 key 时拒绝执行
在清除前列举一条 K/,发现其他 key 就跳过。
- 存在竞争:列举之后才写入
K/下的 key 仍会被删,因为K上的锁并不串行化对K/child的写入。 - 规则对这类 key 永远不再生效,而且没有任何提示。
- 每次清除都要付出一次跨所有纠删集合的列举。
- 分层泄漏和缺失的复核依然存在。
不采纳。
方案 D:保留前缀删除,但把 Expire 和 DeletePrefixObject 传下去
这样能恢复复核:带上这些选项后,集合级删除拒绝了判断之后写入的版本。但它保护不了子对象:与该 key 位于同一纠删集合的子对象仍会被删,位于其他集合的子对象则幸存。丢失与否将取决于哈希,分层泄漏也依然存在。
不作为修复方案。它的复核部分纳入方案 B。
修复方案
以下是经过两轮设计审查确定、并在已审查的补丁中实现的设计。
- 专用选项。 过期 worker、迁移过期和批量过期通过一个新的内存选项
ObjectOptions.DeleteAllVersions请求清除,不再设置DeletePrefix和DeletePrefixObject。DeletePrefix保持原有含义,留给强制删除,以及确实要删除前缀的内部调用方。该选项与DeletePrefix、版本 ID、数据迁移或任何会创建删除标记的选项同时出现时会被拒绝。池层以下的每一层也都拒绝它,所以路由错误的请求不会变成另一种删除。 - 在锁内基于历史做判断。
erasureServerPools.DeleteObject拿到对象锁后,立即把请求交给清除,在 #278 检查之前,单池部署也一样。- 生命周期:原样使用
lifecycleEventsAllPools,在该 key 于所有池中的版本上对规则求值。只有最新版本仍带有"删除全部"事件时才执行清除。 - 批量过期没有规则可以求值。最新版本必须仍是遍历时匹配到的那个版本,按版本 ID(空 ID 视为
null)和修改时间比较。否则该 key 报告为未过期,也不再重试。 - 对每个池的权威历史再读一次,记录每个被认可的版本在哪里:每个副本,包括同一 ID 的较旧写入,例如在多个池中写入的
null版本。这次读取只能确认认可的结果,绝不增加版本。只要有任何差异,或磁盘无法给出完整历史,就返回InsufficientWriteQuorum,不删除任何东西。没有版本的 key 视为成功,不删除任何东西。
- 生命周期:原样使用
- 删除任何东西之前检查每一次写入。
- 清除在每个池中该 key 所属集合的每块盘上读取
xl.meta,包括 quorum 视图中没有副本的池。少数磁盘可能持有与历史所认可的不同的版本修订,所以每块盘上每个被认可版本的每个修订都要检查。无法读取的磁盘可能藏着这样的修订,而 heal 之后会删除清除留在它上面的内容,所以只要有一块盘无法读取,整个 key 就被拒绝。之后清除只在它读取过的那些磁盘句柄上删除;之后因重连而换上的句柄不会被使用。 - 只要任一版本的任一修订已分层,整个 key 就被拒绝。见分层 key。
- 清除对它将要删除的每一次写入执行保留期和 legal hold 检查。只要有一个修订受保护,整个 key 就被拒绝。
- 清除在每个池中该 key 所属集合的每块盘上读取
- 按版本 ID 依序删除。
- 一次一个版本,最旧的先删。在持有副本的每个池中,于该 key 所在的纠删集合、在检查过的磁盘上使用磁盘的版本删除(
DeleteVersion)。这个调用改写xl.meta,把该版本的数据目录移入回收区,只在对象目录为空时才删除目录。删除不写删除标记、不写复制状态,也不产生 free version。 - 对每个版本,先删较旧的写入,最后删读取会选中的那个副本。
- 最新版本最后、单独删除,并且只在每个较旧版本都已从每个池中删除之后。所以中途停止的清除既不会改变读取结果,也不会暴露更旧的版本。
- 遇到第一个错误就停止。一次删除如果达到了写 quorum、但在部分磁盘上失败,会在仍持有锁时对这些磁盘重试有限次数;剩下的交给 heal。
- 每次调用的预算是 1000 个版本和一分钟,包括其中的读取和检查,在每个版本之前检查。更长的历史会在最新版本之前停下,由下一次过期继续,届时重新读取历史、重新做判断。
- 一次一个版本,最旧的先删。在持有副本的每个池中,于该 key 所在的纠删集合、在检查过的磁盘上使用磁盘的版本删除(
- 报告删除了什么。 清除统计它删除的逻辑版本数:包括删除标记,不包括重复副本和 free version;并返回认可的最新版本。ILM 审计记录在标签
ilm-versions-removed中带上这个数。开始删除之后失败或停止的清除记为部分完成(标签ilm-partial),带上已删除的数量和错误,从下一个版本起还带上标签ilm-incomplete-reason;即使只删除了副本、没有删完任何一个版本也是如此;它以result=partial写入 trace,不发送删除通知。因为该 key 没有可删除的内容而什么都没删除的清除,不发送删除通知,只写一条普通 trace。在删除任何东西之前就被拒绝的生命周期清除(有分层版本、规则已不适用、Object Lock)会计数,并以result=refused、原因和错误写入 trace,不写审计。在删除任何东西之前就失败的(历史无法读取或其他错误)还会写审计并记录日志。expiry_incomplete_total中的原因有tier_unsupported、budget(取得进展后在预算处停止)、changed(批量作业匹配到的最新版本已改变)和history_unavailable,以及与其他过期共用的not_applicable、locked和error;见多池生命周期过期。批量过期按清除的结果统计对象和删除标记;未能删除某个删除标记的作业报告为失败。
分层 key
本版本不改变带有已分层版本的 key 的"删除全部"行为。
- 过期 worker 仍把最新版本已分层的"删除全部"交给
expireTransitionedObject。它写一个删除标记,把所有版本和远端对象都留在标记下面,之后的扫描也不会再删除它们。 - 最新版本在本地、但较旧版本已分层的 key 会被拒绝,不删除任何东西。从下一个版本起,持有这种分层版本的池,其 scanner 不再把清除排入队列:每个盘的 scanner 每一轮把这次跳过计为一次
tier_unsupported_skipped。只有其他池持有的分层版本,这个 scanner 并不知道,所以仍由清除来判断,并以tier_unsupported拒绝该 key,同时写入日志;同一 key 重复出现的相同拒绝由去重日志抑制,直到它每小时重置。批量过期把该 key 计入keysRefused(状态complete-with-skips,07b89b98f),本次运行内不再重试。从下一个版本起,下线或再平衡在 tier 证据排除整 key 过期时,会正常迁移存活版本及其 tier 元数据。迁移第一个版本之前,先收集所有池和磁盘上的 tier 证据,防止获胜的本地副本隐藏已分层的重复副本或少数盘修订。最终仍检查物理残留,不能把仍有数据的池报告为完成。
分层安全的清除,不能在任何副本仍引用某个远端对象时(包括一次删除没有达到 quorum 之后)把它交给分层清理。这需要持久的清理记录和锁内的证明,属于单独的修改。
协调
- #278 修复。 本修改建立在 #278 的提交之上,原样使用它的辅助函数:用
lifecycleEventsAllPools做判断,用objectVersions读取各池的历史。清除在合并检查之前执行,所以既不需要CheckedAllPools,也不需要单池豁免。 - 单池锁域修复。 在单池上,一个 key 的写入在它自己的纠删集合中加锁,而
DELETE在第一个集合中加锁,见大型单池集群的锁域。清除在DELETE的锁内做判断并删除,所以只有在那个修复合入之后,它才与该 key 的所有写入互斥。那个修复先合入,本修改在它之上 rebase。清除本身不增加任何加锁。
为什么方案充分且必要
充分性。 所有"删除全部"都会到达 erasureServerPools.DeleteObject,包括过期 worker 及其所有入队方(scanner、GET、HEAD、列举)、迁移过期和批量过期。有了专用选项,它们都不会再走到递归删除:
- 版本删除只触及该 key 自身的
xl.meta和数据目录。子对象有自己的xl.meta,对象目录只在为空时才删除。 - 复核在串行化该 key 写入的同一把锁内进行,使用与 scanner 相同的求值器,所以不再成立的判断不会删除任何东西。
- 只删除认可的列表,可以保留 quorum 视图没有看到的版本。
- 最新版本最后删除,所以在每个较旧版本都删除之前,读取结果保持不变。
- 拒绝带有已分层版本的 key,所以每个远端对象都仍被引用。
必要性。 每一部分都关闭一条否则会继续存在的路径:
- 没有精确清除,子对象会被删除;方案 D 表明,即使只在一个集合内做前缀删除,也会丢失子对象。
- 单池部署上没有复核,判断之后写入的版本会被删除。
- 不把删除限制在认可的列表内,quorum 视图中缺失的版本会被删除。
- 没有删除顺序和最新版本之前的屏障,一个池中的删除失败会暴露更旧的版本。
- 没有分层拒绝,远端对象会泄漏,或者分层清理会删除仍被某个副本使用的远端对象。
- 没有批量过期和迁移的改道,这些调用方仍走前缀路径。
- 没有专用选项,设置旧标志的调用方就会再次走到前缀路径。
DeletePrefixObject在这条路径上多年来一直没有生效,却没有人注意到。从下一个版本起,池层还会把同时带DeletePrefix和Expiration.Expire的请求以InvalidArgument拒绝,所以生命周期过期绝不会删除前缀。强制删除(不带过期的DeletePrefix)不变。
有意排除的范围:
- 强制删除和 speedtest 清理保留前缀删除,这是它们的约定。
- 内部配置删除(
deleteConfig、delServerConfigHistory)在系统桶上走同样的递归路径。要发生冲突,需要一个配置名以/延续另一条记录的文件名,例如一个 IAM 用户的名字以另一个用户的名字加/identity.json/开头。我们在配置层确认了这个机制,但没有检查管理 API 是否接受这样的名字。严重程度低,可以在后续修改中让这些删除也改用同一个精确清除。
代价与兼容性
- 不改变 API、配置、存储格式或传输格式。新增的选项只存在于内存中。
- 每次"删除全部",清除在每个池中读取该 key 所在集合的
xl.meta三次:用于判断、用于记录位置,以及检查每块盘。之后对每个版本,持有副本的每个集合的每块盘做一次xl.meta读-改-写和一次数据目录移动。现在的删除则是在每个集合的每块盘上做一次递归删除。大集群的工作量会减少;历史很长的 key 工作量更大,持有对象锁的时间也更长,但每次调用最多持续一个预算。 - 回收区改为后台清除而不是立即清除,空间回收会稍晚一些。
- 判断已不成立的"删除全部"现在不做任何事,单池也一样;scanner 会在后续轮次重试。
- 滚动升级是安全的,因为只使用现有的存储调用。但只有当每个执行过期的节点都运行了修复,保护才完整:执行过期的节点决定如何删除,所以混合版本集群中的旧节点仍会做递归删除。在所有节点都升级之前,请保留临时缓解措施。
- 用量与配额。 scanner 现在按实际大小统计处于"删除全部"之下的 key 的每个版本,无论是否已排入队列,而不再当作已经删除。用量以及硬配额,要在清除成功之后、对该 key 的子树重新扫描一次才会下降;这可能要等到 scanner 的目录扫描间隔,而不是一轮。关于版本过多和版本过大的告警,也会计入 scanner 没有排入队列的 key 的版本。
- 回滚即恢复旧行为,两个方向都不需要迁移。
剩余限制
- 已经发生的丢失不会被修复。 递归删除会立即清除回收区,磁盘上实际上无法撤销。
- 已成孤儿的远端对象会保留。 分层存储中已经成为孤儿的远端对象仍然留在那里。它们与 key 的对应关系记录在已被删除的元数据中,所以只能手工清理。
- 分层 key 不会被清除。 见分层 key。
- “删除全部"要等每块盘都可用。 只要该 key 所在纠删集合中有一块盘离线、无法读取该 key,或其上该 key 的
xl.meta无法解码,清除就以history_unavailable拒绝该 key,由 scanner 在之后的轮次重试。调用方看到的仍是写 quorum 错误(503)。这是在删除任何东西之前检查每一个修订的代价。 - CRC 损坏必须先成功 heal,再执行清除。 下一个版本把
xl.meta校验和失败归类为存储损坏,存储 RPC 的错误转换也保留这一类别。当某块盘无法解码导致磁盘检查被拒绝,或在历史读取阶段有一半磁盘无法解码(7a13780d8)时,被拒绝的清除会为可解码磁盘上观察到的每个版本排入 MRF heal;null 版本使用明确的nullID。普通和深度对象 heal 可以从足够的存活副本重建损坏元数据,后续清除再重试。dry-run 不改写元数据;没有元数据 quorum 时,heal 保留原文件,清除继续拒绝。其他无法解码的元数据错误不保证能通过同一路径修复。 - 磁盘句柄指向一块盘,而不是一个后端。 清除在它检查过的磁盘句柄上删除,但每次存储调用都会在承载该盘的节点上重新解析后端。清除运行期间替换某块盘的后端一开始是空的,只会通过该 key 的写入或 heal 得到它,而两者都要先拿到清除持有的对象锁。以
nolock启动的管理端 heal 会跳过这把锁;那个选项是单独的问题。 - 对象 key 之下的 key 不会被列出。 列举不会进入一个对象 key 的目录,所以只要
K存在,K/child就不出现在列举结果、批量过期的遍历,以及下线和再平衡的遍历中。这比本修复更早就存在。修复之后,下线或再平衡执行的K的"删除全部"会保留K/child,但遍历已经越过了它。第一轮会把它报告为遗留对象,这与没有任何生命周期规则时相同(已执行);之后的一轮会迁移它。 - 数据目录被递归删除。 每一次版本删除(不只是"删除全部”)都会把该版本的数据目录连同其内容一起移入回收区。一个名为
K/<data-dir>/...的 key,若<data-dir>恰是K某个版本的内部数据目录 ID,就会随该版本一起被删除。S3 API 不返回这个 ID,构造这样的名字需要内部元数据。这属于存储层的后续工作。 - 旧格式对象不在覆盖范围内。 在最早期纠删格式的磁盘上,仍以
xl.json存储的对象,每次删除版本都是递归删除,不只是"删除全部"时。这一点来自代码推理,没有探测。 - 批量过期的复核很有限。 批量过期只能确认最新版本仍是它匹配到的那个版本。
- 混合版本。 见代价与兼容性:执行过期的旧节点仍会递归删除。
修复发布前的运维指引
确认是否暴露。
-
生命周期:运行
mc ilm rule export ALIAS/BUCKET,查找已启用、且包含"ExpiredObjectAllVersions": true或DelMarkerExpiration的规则。 -
批量过期:运行
mc batch list ALIAS --type expire和mc batch describe ALIAS JOB-ID,查找没有设置purge.retainVersions、或设置为retainVersions: 0的规则。 -
key:在这些桶中(或在规则的前缀下),列出所有 key,包括各个版本和删除标记,然后打印出那些是其他 key 路径前缀的 key:
输出
a -> a/b表示对a执行"删除全部"时也会删除a/b。右侧以/结尾的是目录对象,它不受影响。
在所有节点都运行修复版本之前的缓解措施。
-
用
mc ilm rule edit --id RULE-ID --disable ALIAS/BUCKET停用受影响的规则,或从规则中删除对应元素。 -
仍然需要这类策略时,改用按版本执行的规则:
- 开启版本控制的桶,用
ExpirationDays,加上NoncurrentVersionExpirationNoncurrentDays和ExpiredObjectDeleteMarker; - 未开启版本控制的桶,只用
ExpirationDays。
这些规则按版本删除,不走递归路径(代码推理),只是全部删完所需的时间更长。在多池部署上,它们在 #278 的修复发布前仍受那个缺陷影响。
- 开启版本控制的桶,用
-
前缀或标签过滤只有在排除父 key 时才有用。只要过滤仍然选中
a,就仍会删除a/b,因为子对象不需要匹配规则。 -
不要在存在这类 key 的桶上运行默认清除的批量过期任务,正在运行的用
mc batch cancel取消。retainVersions为 1 或更多时按版本删除(推理),但会保留相应数量的版本。
排查已经发生的丢失。
- 如果配置了审计日志,查找 API 名为
ILMExpiry、且标签ilm-action为DeleteAllVersionsAction或DelMarkerDeleteAllVersionsAction的记录。桶通知s3:ObjectRemoved:DeleteAllVersions和s3:LifecycleDelMarkerExpiration:Delete包含相同的信息。每条记录只提到父 key 和规则;被连带删除的 key 没有任何审计记录、通知或 trace。 - 批量过期的任务状态和批量 trace,只统计、列出规则匹配到的 key。
- 对这样找到的每个父 key
K,比较事件发生前K/下的 key 与现在的情况。之前的列表可以来自清单、备份、应用自身的记录或复制目标。一个已经不存在、又没有自己删除记录的 key,很可能就是被本缺陷删除的。 - “删除全部"不会被复制,所以桶复制的目标可能仍保有这些 key,除非目标端运行了同样的规则。站点复制的对端共享生命周期配置并自行执行,因此可能也丢失了同样的 key(推理)。
验证
修复,已执行。 在修复上,以及在一个位于 9f7b49369、只加入新测试的一次性 worktree 上,用 -p 2 运行有针对性的 go test -run 选择。下面每个回归测试在该基线上失败、在修复上通过。新测试在 race 检测下通过。
- 生命周期"删除全部"之后,位于该 key 自身纠删集合、另一个集合以及更深层级的子对象都保留了全部版本,
ExpiredObjectAllVersions和DelMarkerExpiration都是如此。位于另一个池的子对象(该 key 在两个池中都有副本),以及批量过期清除也一样;批量过期的计数来自清除结果。 - 下线和再平衡删除到期的 key,并保留它的新子对象。
- 位于目录对象
ea/dir/目录中的ea/dir__XLDIR__/odd,在清除ea/dir/后仍保留。 - 在单池上,scanner 判断之后写入的版本会保留。批量清除匹配到的版本已不是最新版本时,不删除也不计数。
- 在单池上,一半磁盘无法读取时,对这样的历史执行过期不删除任何东西。
- 一个在两个池中都写入过的
null版本,较新的写入无法删除时,记为部分完成、已删除 0 个版本,因为已经删除了一个副本。 - 批量清除删除了较旧版本、但最新的删除标记删除失败时,该标记计为失败,作业也报告为失败。
- 两个池、三个版本:中间版本删除失败时,最旧的版本已删除,最新版本保留且仍是读取结果,审计 trace 标记为部分完成、已删除一个版本,并且不发送删除通知。故障消失后,下一次过期完成清除,并为两个版本发送一条通知。
- 两个版本的预算让一个四版本的清除停在最新版本之前,报告部分进度,下一次过期完成它。
- 最新版本在本地而较旧版本已分层的 key,以及批量清除中最新版本已分层的 key,都被拒绝,所有版本和远端对象都保留;批量过期把它们计入
keysRefused(complete-with-skips)。 - 受保留期或 legal hold 保护的写入会使整个 key 被拒绝,包括另一个池中受保护的较旧
null写入。 - 十六块盘中只有一块盘上的修订已分层或带 legal hold,整个 key 也会被拒绝,尽管被认可的修订两者都不是。一块无法读取的盘上带 legal hold 的修订,以及一块没有任何保护但无法读取的盘,也都会使 key 被拒绝。
- 检查之后替换了已检查句柄的新磁盘句柄不会收到任何删除。
- 预算为 10 毫秒且元数据读取很慢时,清除在第一次删除之前就停止。
- 一个在两个池中都写入过的
null版本:较新的写入无法删除时,较旧的写入已删除,读取仍返回较新的写入。一个版本在两个池中的两个副本,计为一个被删除的版本。 - 之前删除分层版本时留下的 free version 会保留。
- 该选项与每个冲突选项同时出现时会被拒绝,池层以下各层也拒绝它。不存在的 key 视为成功,不删除任何东西。
- 在两边都通过的对照:没有子对象的 key 执行"删除全部"后所有版本都被删除;
ea/plainx和ea/plain-1保留;目录对象ea/dir/被删除而ea/dir/kid保留;强制删除仍删除整个前缀;最新版本已分层时仍得到一个删除标记;在两个池上,过时的判断仍被拒绝。 ./cmd中现有的生命周期、过期、批量、ILM、删除、下线和再平衡测试,以及./internal/bucket/lifecycle,在修复上通过。
缺陷,已执行。 所有运行都使用位于 main 69fcf50cd 的一次性 worktree,以及 -p 2 的有针对性 go test -run 选择,没有使用 race 检测。
- 直接调用。 用 scanner 的选项对
eav-parent调用DeleteObject执行"删除全部”,会删除eav-parent/child,保留eav-parentx。 - scanner 与过期 worker。
- 通过
scannerItem.applyActions和真实过期 worker 应用生命周期,得到上表中的结果:一个池两个纠删集合、DelMarkerExpiration、未开启版本控制的桶,以及目录对象。 - 磁盘 scanner 自身的目录遍历(
NSScanner)为父对象入队"删除全部",worker 随后删除了它的子对象。
- 通过
- 其他调用方。
- 两个池:第二个池中的子对象被删除。
- 通过
BatchJobExpire.Start启动的批量过期作业,删除了其规则所指定 key 的子对象。 - 下线和再平衡把到期的 key 和它的新子对象从两个池中都删除了。
- 副作用。
- 该 key 及其子对象的分层版本留下远端对象,没有元数据,也没有 free version。之前删除留下的 free version 也被清掉。
- 单池上,scanner 判断之后写入的版本被删除。直接用
Expire和DeletePrefixObject调用集合级删除时,它会被拒绝。 - ILM trace 只有一条过期记录,只针对父对象。
- 对象层接受
ea/dir__XLDIR__/odd,它随目录对象ea/dir/一起被删除。 - 保存两条配置记录,一条位于另一条的路径之下;删除外层记录时,内层记录也被删除。
- 最新版本已分层。 “删除全部"写一个删除标记,保留两个版本和远端对象,下一轮扫描不再入队任何操作。
- 方案 D。 恢复选项后的集合级前缀删除,删除了与该 key 同一集合的子对象,保留了另一个集合中的子对象。
- 方案 B 原型。 通过现有批量路径按 ID 删除该 key 的版本,所有子对象都保留,并为分层版本留下一个 free version。
- #278 集成分支。 单池、两池、迁移以及单池过时判断的探测丢失了同样的 key。两池的过时判断在那里被拒绝。
来自代码推理,未执行:
- 缺陷路径上的审计记录和通知只针对父对象:它们与产生那唯一一条 trace 的调用点相同;
- GET、HEAD 和列举通过同一个 worker 入队"删除全部”;
- 跨纠删集合的扇出会丢弃各集合的错误;
- 旧节点会跳过未知的
DeleteOptions字段; - 逐版本的生命周期规则,以及
retainVersions为 1 或更大的批量清除,不会走到递归路径; - 复制目标保留了被删除的 key;
- #278 审查中发现的元数据不完整的变体;
- 旧格式
xl.json的行为; - 回收区的立即清除。
未运行:
- 针对运行中服务器的 S3 级复现;
- 多节点集群或混合版本升级;
- 完整的
./cmd测试集和远端 CI; - 本修复与单池锁域修复合在一起的运行。
集成验证。 这个修复已摘取到下个版本的同一个集成分支上,位于 #278 和 #281 之后,与锁域、Heal 演练、#259、#285 和 Console 的修复放在一起。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 设计第 1、2 轮 | 同意并要求修改。批量路径会重排版本,池与池之间也没有屏障,所以清除改为一次删除一个版本,最旧的先删,最新的最后且单独删除。仅靠 free version 不能保证分层 key 的安全,所以带有已分层版本的 key 一律拒绝,分层对象的"删除全部"推迟处理。为每个副本记录位置,对每次写入执行保留期和 legal hold 检查,在锁内有限次重试,每次调用有预算,报告只统计确认的删除。单池上的写入互斥交给先合入的锁域修复。每次版本删除都会递归删除数据目录,这一点留给存储层的后续工作。 |
| 补丁审查第 1 轮 | 要求修改。五个问题:少数磁盘上已分层或带 legal hold 的修订没有被检查;删除了副本却没有删完任何一个版本的清除没有记为部分完成;时间预算没有包括读取和检查;批量作业的完成状态忽略了删除失败的删除标记(这是更早就存在的判断,被新的计数暴露出来);一个测试夹具缺少删除标记计数。全部采纳,并加了回归测试。 |
| 补丁审查第 2 轮 | 要求修改。三个问题:无法读取的磁盘可能藏着受保护的修订,之后会被 heal 删除;磁盘选择被应用到一组新取得的句柄上;清除在已经知道达不到写 quorum 时仍会开始删除。全部采纳:无法读取的磁盘现在会使 key 被拒绝,清除只在它检查过的那些句柄上删除。 |
| 补丁审查第 3 轮 | 要求修改。一个问题:保留下来的句柄并不能锁定承载该盘的节点上的后端。有异议:替换后的后端一开始是空的,只会通过在清除持有的对象锁下进行的写入或 heal 得到该 key;而要绑定后端的具体实例,需要改变传输格式。 |
| 补丁审查第 4 轮 | 批准,附带小问题。审查方撤回了第 3 轮的问题。两处小问题:把磁盘选择描述为磁盘句柄,并写明替换与加锁的前提;点名管理端 heal 的 nolock 选项是例外。均已采纳,分别写入代码注释和剩余限制。 |
参考
29 - Peer 重载后的审计 Webhook
本文覆盖 Henrik Feldt 报告的 issue #259,基于 main 69fcf50cd 评估。本修复完成了维护者此前未提交的工作:补上 Kafka 调用点,在目标从未启动时把排队记录交给替代者,并增加 Kafka 测试覆盖。修复已在本地提交,尚未提交 pull request。
状态: 修复已通过审查,已在本地提交。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未合并或发布。
简短结论
报告属实,一个定向测试即可复现。管理员在运行时修改 audit_webhook 时,接收请求的服务器用进程上下文应用这次修改;其他每台服务器在 peer 重载处理函数里应用它,使用的是一个 30 秒的上下文,处理函数一返回就被取消。HTTP Webhook 目标把它唯一的队列 worker 绑定在初始化时传入的上下文上;配置了 queue_dir 时,磁盘重放也绑定在这个上下文上。因此,除一台服务器外,其他每台服务器上的新目标在创建后立刻失效。记录在内存中排队,直到达到 queue_size,此后的每条记录都以 log buffer full 被丢弃。没有任何机制会重新启动 worker。可靠的恢复方式是重启受影响的服务器,因为启动时用进程上下文构建目标。如果之后的一次修改恰好由受影响的服务器处理,这台服务器会恢复,但其他服务器上的目标会再次停止。
同样的缺陷还影响 logger_webhook(无论是否配置 queue_dir),以及配置了 queue_dir 的 audit_kafka。其他动态子系统都没有把长期运行的工作绑定到重载上下文上。
修复分三部分:
- 用进程上下文构建目标。
applyDynamicConfigForSubSys在其三处日志目标调用点使用进程上下文(GlobalContext)构建日志目标。启动、处理请求的服务器和 peer 节点都通过这一个函数应用配置。日志输出仍然使用重载上下文。 - 先打开队列存储,再启动磁盘队列 worker。 HTTP 磁盘队列 worker 只在队列存储成功打开之后才启动。现在的代码先启动 worker;如果存储打不开,worker 会在没有存储的情况下继续运行,并在收到第一条记录时让服务器崩溃。这在启动时和处理请求的服务器上已经会发生。没有这一部分,第一部分会把这个崩溃扩散到每一台服务器。
- 排队记录跟随替代目标。 有了第 2 部分,队列无法打开的目标没有 worker,它的记录在内存中等待。之后的重载替换这个目标时,
Cancel现在会在共享交接缓冲区容量允许的范围内,把这些记录交给替代目标,而不是丢弃。
定性
| 维度 | 判断 |
|---|---|
| 性质 | peer 配置重载中的上下文生命周期缺陷:长期存在的日志目标被绑定在一个随请求结束的上下文上。继承自上游,不是已发布 SILO 版本引入的回归。 |
| 受影响部署 | 多于一台服务器,并且在运行时通过 mc admin config set 或 mc admin config reset 修改了 audit_webhook 或 logger_webhook(无论是否配置 queue_dir),或修改了配置了 queue_dir 的 audit_kafka。同时更新 logger_webhook 的 subnet 代理修改也走同一条路径。 |
| 不受影响 | 单服务器部署;启动时就已存在、运行时从未修改的目标;处理该管理请求的服务器;未配置 queue_dir 的 audit_kafka;其他所有动态子系统(见逐项排查)。 |
| 严重程度 | 高。除一台服务器外,其他所有服务器上的审计记录都会被丢弃,直到这些服务器重启,或者自己处理了之后的一次修改。管理命令报告成功,S3 请求也正常完成;丢弃只体现在服务器日志和日志目标指标中,因此这个缺口往往在审计复核中才被发现,而不是在日常运维中。审计 Webhook 若是合规控制的一部分,这段时间的审计记录就不完整,而且无法重建。 |
| 可达性 | 一条常规管理命令即可触发。修改其中一个目标会重建该子系统中所有启用的目标,因此在其他服务器上它们会全部停止,而不只是被修改的那一个。 |
| 发布紧急度 | 阻塞下一个版本。审计丢失不会报告给管理端或 S3 客户端,并一直持续,直到受影响的服务器重启,或自己处理之后的一次修改;而改动很小,不触及 S3 数据路径。 |
| 剩余工作量 | 代码、审查和一次集群验收都已完成,还需要远程 CI。 |
触发条件
mc admin config set ALIAS audit_webhook:NAME enable=on endpoint=…到达服务器 A。A 校验并保存配置,通过applyDynamic应用修改,后者以GlobalContext调用applyDynamicConfigForSubSys。随后 A 通知每个 peer 重载该子系统(SignalConfigReload)。- 在每个 peer 上,
SignalServiceHandler处理serviceReloadDynamic:读取已保存的配置,在context.WithTimeout(context.Background(), 30*time.Second)下调用applyDynamicConfigForSubSys,并延迟执行cancel。 updateHTTPTargets为该子系统中每个启用的 Webhook 创建新目标,用这个上下文初始化每一个,然后替换目标列表,并取消旧目标。- 处理函数返回,上下文被取消,新目标的主 worker 从
startQueueProcessor返回。额外的 worker 共享同一个上下文,也会被要求停止,而且没有任何机制会启动新的 worker。 - 该服务器上的审计日志仍在调用
Send。Send把记录放入目标的通道,直到达到queue_size(默认 100000);此后返回log buffer full,服务器日志输出Unable to send audit event(s) to the target `NAME`: log buffer full。
报告者的节点指标展示了这一模式。三台服务器中,config set 在服务器 0 上执行,以下数据取自 7.5 小时之后:
| 服务器 | target_queue_length |
failed_messages |
total_messages |
|---|---|---|---|
| 0(处理该请求) | 0 | 4 | 2,097,356 |
| 1 | 100000 | 674,917 | 774,917 |
| 2 | 100000 | 712,215 | 812,215 |
在这份报告中,服务器 1 和 2 上的 total_messages 减去 failed_messages 正好等于队列长度,这与这两台服务器没有发送出任何记录的情况一致。
三种变体的表现如下:
- 配置了
queue_dir的 HTTP Webhook。 把记录写入磁盘队列的就是同一个 worker,磁盘重放也随同一个上下文停止。新记录永远到不了磁盘,而是像上面那样填满内存队列。修改之前已在磁盘上的记录会保留,重启后在端点可达时重放;内存队列中的记录在服务器重启时丢失。 - 配置了
queue_dir的 Kafka。Send直接写入磁盘队列,但initQueueStore启动的重放 goroutine 随上下文停止。记录在磁盘上堆积到queue_size,之后Send以the maximum store limit reached失败。重启后,一旦 broker 可达,重放会发送磁盘上的记录。Kafka 目标的队列长度指标只统计内存通道,而这种模式不使用内存通道;它的消息计数也只在重放发送时变化。因此这些指标保持不变。 - 未配置
queue_dir的 Kafka。 不受影响。连接 broker 时不使用这个上下文,发送循环也不监听它。
根因
日志目标 Init 的 ctx 参数承担了两个职责:它是初始化调用的上下文,同时也是目标中 goroutine 的生命周期。initMemoryStore 和 initDiskStore 用 context.WithCancel(ctx) 派生 worker 上下文,并保留取消函数供 Cancel 使用;startQueueProcessor 在这个上下文结束时返回。因此,一个目标在 Cancel 被调用或调用方上下文结束时停止工作,以先发生者为准。两者都只是请求关闭;关闭不能及时完成的情形见剩余限制。
在启动时(lookupConfigs)和处理请求的服务器上,调用方上下文是 GlobalContext,目标一直存活到被替换或服务器关闭,这正是预期的生命周期。在 peer 上,调用方上下文在 RPC 处理函数返回时就结束了,也就是目标创建后的几毫秒。
这个 30 秒的上限在这里什么都没有保护。在 applyDynamicConfigForSubSys 中,这个上下文只用于日志调用、配置查找(同样只用于日志)和目标的 Init。HTTP 的 Init 不做任何网络 I/O:它启动一个 goroutine,配置了 queue_dir 时再打开一个本地目录。Kafka 目标在 init() 中连接 broker,而 init() 不接收上下文,由客户端自己的拨号和元数据超时约束。
子系统逐项排查
config.SubSystemsDynamic 中的每个子系统都走同一条重载路径。对每个子系统要问的是:applyDynamicConfigForSubSys 是否创建了在调用返回后仍持有这个上下文的 goroutine 或对象。
| 子系统 | 重载时做什么 | 结论 |
|---|---|---|
logger_webhook |
logger.UpdateHTTPWebhooks(ctx, …) → Init(ctx):队列 worker,配置 queue_dir 时还有磁盘重放 |
受影响,无论是否配置 queue_dir |
audit_webhook |
logger.UpdateAuditWebhooks(ctx, …),同一路径 |
受影响,无论是否配置 queue_dir |
audit_kafka |
logger.UpdateAuditKafkaTargets(ctx, …) → Init(ctx) → initQueueStore:以 ctx.Done() 启动磁盘重放 |
配置 queue_dir 时受影响;未配置时发送循环不使用该上下文 |
api |
globalAPIConfig.init、setRemoteInstanceTransport |
不受影响:只设置值和一个 transport,不涉及上下文 |
ilm |
UpdateWorkers、ResizeWorkers |
不受影响:这些 worker 使用 transition 和 expiry 状态自身的上下文 |
scanner |
原子写入、scannerSleeper.Update |
不受影响 |
subnet |
Update、ApplyEnv |
本身不受影响;修改代理时可能连带重新应用 logger_webhook |
compression、heal、batch、storage_class、drive、browser、callhome |
在锁内替换一个配置值 | 不受影响 |
在 main 上,通知目标和 Lambda 目标都不是动态子系统。
issue 中有两处描述比实际范围宽。peer 处理函数中针对整份配置的分支(applyDynamicConfig)使用同一个上下文,但目前没有任何管理路径会发送不带子系统的重载。mc admin config import 只保存配置,需要重启生效,不会通知 peer。
行为的来源
溯源
由上游 MinIO 引入,不是 SILO 引入的。 每一个成因都是上游提交,并且都是 SILO 分叉点(27742d469,2025-12-03)的祖先;SILO 没有任何提交改动过相关函数。同样的代码存在于分叉前最后一个上游发布 RELEASE.2025-10-15T17-29-55Z、已归档的上游 master,以及从 RELEASE.2025-12-03T12-00-00Z 到 RELEASE.2026-10-26T00-00-00Z 的每一个 SILO 发布中。我们逐个 tag 比对了代码:worker 先于存储启动的顺序、绑定上下文的重放、30 秒上下文,在所有 tag 上都相同。
这个缺陷需要两个条件同时成立:随处理函数结束而结束的重载上下文,以及生命周期与 Init 所收上下文一致的日志目标 goroutine。它们来自不同的提交:
| 成因 | 提交(PR) | 日期 | 首个上游发布 |
|---|---|---|---|
| peer 重载在请求上下文中应用子系统,该上下文随处理函数返回而结束。 | a89612549(#11018);按子系统的形式 040ac5cad(#14928) |
2020-12-04;2022-05-16 | RELEASE.2020-12-10T01-54-29Z;RELEASE.2022-05-19T18-20-59Z |
grid 改造后生命周期不变:serviceReloadDynamic 创建 30 秒上下文并延迟 cancel。 |
e06168596(#19004) |
2024-02-19 | RELEASE.2024-02-24T17-11-14Z |
| 日志与审计 Webhook、审计 Kafka 变为可在运行时重载。 | 28f188e3e(#14289)、3934700a0(#14390) |
2022-02-17、2022-02-24 | RELEASE.2022-02-17T23-22-26Z、RELEASE.2022-02-24T22-12-01Z |
Init 开始接收上下文。HTTP 目标和 Kafka 的磁盘重放(initQueueStore)随它停止,HTTP worker 用它发送。 |
57acacd5a(#17121) |
2023-05-09 | RELEASE.2023-05-18T00-05-36Z |
HTTP 队列 worker 在 Init 上下文结束时返回。 |
1fc4203c1(#19275) |
2024-03-25 | RELEASE.2024-03-26T22-10-45Z |
| 磁盘队列 worker 在队列存储打开之前启动。 | ba54b39c0(#19854) |
2024-06-01 | RELEASE.2024-06-04T19-20-08Z |
每一步改变了什么:
- 在 #17121 之前,这个缺陷不会发生。
Init和 HTTP worker 都不接收上下文,peer 的上下文无法停止目标,尽管当时重载已经运行在请求上下文中。 - 从 #17121(
RELEASE.2023-05-18T00-05-36Z)起,重载上下文能传到目标。设置了queue_dir时,HTTP 目标和 Kafka 的磁盘重放会在处理函数返回时停止,这就是本记录中带queue_dir的 Kafka 情形。没有queue_dir的 HTTP worker 用同一个上下文发送,发送会以context canceled失败,而不是进入队列。 - 从 #19275(
RELEASE.2024-03-26T22-10-45Z)起,HTTP worker 在上下文结束时返回。这就是报告中的现象:记录在内存中等待,直到queue_size,之后以log buffer full被丢弃。 - #19854(
RELEASE.2024-06-04T19-20-08Z) 修复的是 #19275 重构按其自身说明引入的一次崩溃。它把队列存储的打开移入initDiskStore,但 worker 的启动仍在它之前。这个顺序就是修复方案中描述的崩溃。在 peer 上它大多不可见,因为 worker 通常在第一条记录到达之前就已随重载上下文退出。 - #19004 没有改变生命周期。 原来的请求上下文同样在处理函数返回时结束。如根因一节所述,30 秒的限制没有保护任何东西。
SILO 的改动。 没有任何 SILO 提交改动过 SignalServiceHandler 中的 peer 重载分支、initDiskStore、HTTP worker、Kafka 的 initQueueStore,或 applyDynamicConfigForSubSys 中的日志分支。靠近这些代码的 SILO 提交,没有引入、扩大或缩小这个缺陷:
9b11dc946(2026-09-03)在这些文件中把minio/pkg的导入路径换成了silo-pkg。011b0df5f(2026-10-23)在 broker 客户端初始化中设置了默认的 Kafka 协议版本,该处不接收上下文。5cb900bfa和079ebb192(2026-09-07 与 2026-09-08)让系统目标的注册具备幂等性并串行化。只有控制台日志使用这条路径,Webhook 重载不经过它。77bdc4c0c、7a060cab1、9b76a2167改动的是applyDynamicConfigForSubSys中其他子系统的分支(callhome,以及后来被撤销的 ILM 访问分层 worker)。
证据。 每个成因在各 tag 上是否存在,以及这些函数中没有 SILO 改动,都是在 git 历史中核对的。#17121 到 #19275 之间的行为来自对代码的阅读,我们没有运行那些旧版本。当前行为由验证一节所述的测试复现。
相关:仍处于开放状态的 PR #249 为通知 Webhook 增加了热重载,出于同样的原因用 GlobalContext 构建目标。IAM 策略重载记录讨论的是相反的情形:请求内的工作必须跟随请求上下文。
方案比较
方案 A:在日志调用点使用进程上下文
这是选定的方案。applyDynamicConfigForSubSys 向三处日志目标更新传入 GlobalContext,日志输出继续使用调用方的上下文。所有入口都通过这个函数应用配置,所以一处修改就覆盖了启动、处理请求的服务器和 peer。生命周期的决定放在创建长期对象的地方,与调用方传入什么无关。PR #249 对通知目标采用的也是这种做法。
方案 B:在日志包内部解除绑定
updateHTTPTargets 或 Init 可以用 context.WithoutCancel 包装上下文,从而保护所有调用方。但这也会让目标脱离 GlobalContext:关闭服务器时取消 GlobalContext,目标将不再随之停止。这改变了启动时构建的目标的关闭行为,而这与本缺陷无关。它还会留下一个取消信号被悄悄忽略的 ctx 参数。如果要由日志包掌管生命周期,应当去掉这个参数(方案 D)。
方案 C:在 peer 处理函数中使用进程上下文
serviceReloadDynamic 可以传入 GlobalContext,只为真正有界的工作保留 30 秒上限。目前并没有这样的工作(见根因),所以这样同样能修复缺陷。但它修的是调用方,而不是创建长期对象的那个函数。任何其他使用短上下文的调用方,例如测试或将来的处理函数,都会再次掉进同一个陷阱。这是可接受的备选,但不是首选。
方案 D:由目标自己掌管生命周期
把 Init 拆成两步:在调用方上下文中完成的初始化,以及一个生命周期归目标所有、只随 Cancel 结束的启动。这是长期正确的形态,但它会修改 logger.Target 接口和所有实现(HTTP、Kafka、console、测试 logger)。修复本缺陷并不需要它,因此留作后续候选。
决策背后的问题
- 是否有某个
Init依赖这 30 秒上限? 没有。HTTP 的Init不做网络 I/O,Kafka 连接 broker 时不使用这个上下文。2024 年 3 月重构之前,HTTP 初始化会用这个上下文探测端点;现在已经不这样做了。 - 反复重载会不会泄漏 goroutine? 不会因为这次修改而泄漏。每次重载时,
updateHTTPTargets让每个旧目标指向它的替代者(AssignMigrateTarget),调整共享的遗留记录缓冲区大小(CreateOrAdjustGlobalBuffer),启动新目标,然后在后台对旧目标调用Cancel。Cancel把目标标记为已关闭,此后的Send会转给替代者(如果有的话);它取消目标自己的上下文,请求 worker 和磁盘重放停止;等待 worker 把遗留记录交给共享缓冲区;把通道中仍有的记录交出去(修复的第 3 部分);最后关闭通道。旧目标的关闭一直由Cancel掌管,而不是由调用方上下文掌管,所以改用GlobalContext不会让每次重载多出 goroutine。审查中的一个探测在两种队列模式下,对空闲目标各连续执行 100 次重载再禁用,goroutine 数量回到初始值;这不涵盖有积压或磁盘写入失败时的关闭。在 main 上,peer 上旧目标的 worker 早已退出,它退出后堆积的记录会在通道关闭时被丢弃,这就是通过同一台服务器再执行一次config set也救不回 peer 的原因。与本修复无关、早已存在的例外,见剩余限制。
修复方案
- 用进程上下文构建日志目标。 在
applyDynamicConfigForSubSys中,向logger.UpdateHTTPWebhooks、logger.UpdateAuditWebhooks和logger.UpdateAuditKafkaTargets传入GlobalContext,错误报告继续使用调用方的上下文。此前的候选修复覆盖了两处 HTTP 调用点;Kafka 调用点在配置queue_dir时有同样的缺陷,补上它之后修复才完整。 - 先打开队列存储,再启动磁盘队列 worker。 现在的
initDiskStore先启动主 worker,再打开队列存储。如果打开失败,例如queue_dir无法创建或不是目录,Init会返回错误,但目标仍被注册,它的 worker 也仍在没有存储的情况下运行。收到第一条记录时,worker 对不存在的存储调用PutMultiple,服务器随即 panic。目前这会发生在启动时和处理请求的服务器上,因为那里的上下文一直存活。在 peer 上,worker 通常先随重载上下文退出,崩溃因此大多被掩盖;但如果 worker 在重载上下文结束前取到了一条记录,仍然会崩溃。因此,单独应用第 1 部分,会把静默丢弃变成每一台服务器上的崩溃。修复先打开存储:失败时取消派生的上下文并返回,不启动 worker;成功时先设置存储,再启动 worker 和重放。这同时消除了一个数据竞争:当先前的目标在共享缓冲区中留有记录时,worker 可能在Init设置存储字段的同时读取它。失败的Init也不再遗留 worker。 - 把排队记录交给替代目标。 有了第 2 部分,队列存储打开失败的目标没有 worker。发给它的记录在其通道中等待,最多
queue_size条。没有任何机制会重试初始化:运维人员修复queue_dir后再次应用配置,这次重载会构建一个替代目标。只有 worker 会把记录交给替代目标,因此Cancel原本会关闭通道并丢弃这些记录。现在Cancel会把通道中仍有的记录移入替代目标读取的共享缓冲区:在等待 worker 的wg.Wait()调用返回之后、关闭通道之前进行,期间持有通道锁和缓冲区映射锁,发送时从不等待空位。如果共享缓冲区已满,其余记录被丢弃,与 worker 自身的交接一致。它也覆盖了在 worker 最后一次排空之后才到达的记录。单个失败目标被一个正常目标替换时,它的记录会全部交出;多个失败目标在短时间内相继被替换时,缓冲区仍可能装不下(见剩余限制)。 - 回归测试。
TestDynamicWebhookOutlivesConfigContext覆盖audit_webhook和logger_webhook,每个都包括内存队列、磁盘队列和一个无法打开的磁盘队列;还覆盖配置与未配置queue_dir的audit_kafka。每种情况都在短上下文中通过applyDynamicConfigForSubSys应用子系统,像 peer 处理函数那样取消上下文,然后发送一条记录并要求送达。Kafka 的情况使用 Kafka 客户端库自带的模拟 broker,要求在重载上下文结束之后仍有生产请求到达;未配置queue_dir的 Kafka 不受影响,因此那一项作为对照。在无法打开的情况下,存储不可用期间记录必须保持在队列中;随后测试修复目录并再次应用配置,替代目标必须送达这条记录。每种情况在去掉日志与审计环境变量的子进程中运行,使全局目标注册表不会在各情况之间互相影响。
为什么方案充分且必要
充分性。 运行中的服务器里,每个日志 Webhook 或 Kafka 目标都由 applyDynamicConfigForSubSys 构建:启动时经由 lookupConfigs 和 applyDynamicConfig,在处理请求的服务器上经由 applyDynamic,在 peer 上经由 serviceReloadDynamic。这三个更新函数的其他调用方只有测试。有了第 1 部分,每条路径都用进程上下文构建目标,peer 上目标的生命周期与处理请求的服务器相同。逐项排查没有发现其他子系统把长期工作绑定到重载上下文上。旧目标仍由掌管其关闭的 Cancel 停止,限制见剩余限制。
必要性。 每一部分都关闭一条否则会继续存在的路径:
- 三处调用点都会从这个上下文启动 goroutine:HTTP 队列 worker、HTTP 磁盘重放和 Kafka 磁盘重放。漏掉任何一处,对应的变体在 peer 上仍然失效。
- 没有第 2 部分,无法打开的
queue_dir会让任何一台由 worker 收到记录的服务器崩溃。有了第 1 部分,每台服务器上的 worker 都存活,因此每一台服务器都会在收到第一条审计或日志记录时崩溃,而不再大多只是处理请求的那台。 - 没有第 3 部分,修复这样的
queue_dir并再次应用配置时,失败目标已接受的所有记录都会被丢弃。 - 没有测试,就没有什么能阻止 peer 路径再次与处理请求的服务器产生偏差。
有意排除的范围:
- peer 处理函数保留 30 秒上下文,它对所覆盖的日志输出仍然是正确的。
- 不在日志包中使用
context.WithoutCancel,也不修改logger.Target接口(方案 B 和 D)。 - 早已存在、但不会造成本症状的行为留给单独的 issue,列在剩余限制中。
代价与兼容性
- 不改变 API、配置、指标、存储格式或依赖。
- peer 上目标的生命周期与处理该管理请求的服务器相同。因此,下面列出的早已存在的替换与关闭限制现在同样适用于 peer。
- 一处可见变化:无法打开的
queue_dir不再在启动时或处理请求的服务器上导致崩溃。目标仍被注册,错误会被记录(Unable to update audit webhook targets: …);记录在内存中排队到queue_size,之后以log buffer full丢弃,与任何无法发送的目标一样。目录修复并再次应用配置后,替代目标会在共享交接缓冲区容量允许的范围内接手这些等待中的记录。 - 回滚即恢复旧行为,两个方向都不需要迁移任何状态。
运维指引
在修复发布之前:
- 运行时修改后,重启其他服务器。 执行任何涉及
audit_webhook、logger_webhook或配置了queue_dir的audit_kafka的mc admin config set或mc admin config reset之后,重启除处理该命令的服务器以外的每一台服务器。配置了日志 Webhook 时,subnet代理修改也算在内。如果负载均衡器让你无法确定是哪台服务器处理了命令,可以在修改配置时把mcli别名指向某一台服务器自己的地址,或者逐台重启所有服务器。 - 未修改的目标不受影响。 启动时就已存在的目标,无论来自环境变量还是已保存的配置,都用进程上下文构建,在下一次运行时修改之前不受影响。
- 已丢失的记录无法找回。 以
log buffer full丢弃的记录无法恢复。仍在内存队列中的记录会在服务器重启时丢失。配置了queue_dir时,修改之前已在磁盘上的记录会在重启后、端点可达时重放。
检测这种状态时,要在每台服务器上分别读取节点指标:
- 该目标的
minio_audit_target_queue_length(target_id形如audit_http_0,日志 Webhook 为sys_http_0)在部分服务器上逐渐升向queue_size,而在处理请求的那台服务器上接近于零。 minio_audit_failed_messages持续上升,minio_audit_total_messages减去minio_audit_failed_messages通常停在队列长度上不再变化。- 服务器日志出现
Unable to send audit event(s) to the target `NAME`: log buffer full。 - 对配置了
queue_dir的 Kafka,上述指标保持不变;应改为观察目标队列目录下的文件数量,以及the maximum store limit reached消息。
在受影响的服务器上,新目标的 Webhook 在线指标通常也一直为 0。端点宕机时它同样为 0,所以只能用来佐证其他信号。
剩余限制
以下限制都早已存在,并非由重载上下文造成。它们今天已存在于处理请求的服务器和启动时构建的目标上;有了修复,peer 的表现相同。每一项都需要单独的修改。
- 已丢失的记录不会被找回。 修复防止的是由重载上下文造成的丢失。它不会找回已丢弃的记录,也不修复下面其他交接与关闭环节的丢失。
- 已满的磁盘队列仍可能阻塞旧目标的关闭。 配置了
queue_dir、使用默认的max_retry=0,并且替换目标时磁盘队列已满,旧 worker 会不断重试磁盘写入,而不检查自己的上下文;只要写入一直失败,它的Cancel就不会结束。重试等待应当响应上下文,而且届时还必须把未完成的批次交出去。 - worker 已取出但尚未送达的记录会在替换时丢失。 未配置
queue_dir时,worker 会把记录编码进当前批次。如果目标在这个批次发送之前被替换,例如端点宕机期间,这个批次既不会送达,也不会被交出去。 - 替换期间新旧磁盘队列互相重叠。 两个目标打开同一个目录,各自维护索引。替代目标打开目录之后旧目标再写入的记录,对替代目标的重放不可见;如果旧目标的重放在发送它之前停止,这条记录会留在磁盘上,直到目录被再次打开(例如下次重启);两个重放也可能把同一条记录发送两次。Kafka 目标在
queue_dir下使用固定的目录名,因此queue_dir相同的两个 Kafka 目标会共用它。 - worker 可能在紧随其后的
Cancel之后才启动。 worker 只有在运行起来之后才登记到 wait group,所以紧接在Init之后的Cancel可能在 worker 出现之前就结束等待。worker 对通道字段的读取以及之后对 worker 启动时间的更新同样没有同步。在 main 上,race 检测器同样会报告这些问题。 - 共享交接缓冲区可能装不下。 它的大小在创建替代目标时确定,依据是被替换目标的队列和缓冲区中已有的记录;更早被替换、尚未完成交接的目标的记录不计在内。多个失败目标在短时间内相继被替换时,审查中的一个探测在 200 条排队记录中送达了 116 条;去掉
Cancel中的交接后一条也没有送达。 - Kafka 的关闭没有释放所有客户端。 未配置
queue_dir时,Init创建一个客户端,第一次发送又创建第二个,第一个从未关闭。配置了queue_dir时,重放中仍在进行的初始化可能在Cancel返回之后才发布客户端。 - 未配置
queue_dir的 Kafka 目标仍可能在没有发送循环的情况下启动。 如果初始化目标时 broker 不可达,Init会在启动发送循环之前返回,目标会一直填充通道,直到log buffer full。这与上下文无关,同样留给单独的 issue。
验证
在 main 69fcf50cd 的一次性工作树中实际执行:
- 应用修复后,
go test ./cmd -run '^TestDynamicWebhookOutlivesConfigContext$'的全部八种情况通过,在 race 检测器下同样通过。完整的./cmd测试、./internal/logger/...和./internal/store/...通过,后两者在 race 检测器下同样通过。go vet ./cmd没有报告。 - 只应用测试、撤销生产代码改动后,七种情况失败。内存队列和磁盘队列的情况报告重载返回后 Webhook 停止工作;配置
queue_dir的 Kafka 从未发出生产请求;无法打开队列的情况要么在目标被替换时丢失记录,要么以空指针解引用崩溃,位置在startQueueProcessor中的PutMultiple调用,worker 由initDiskStore启动,具体是哪一种取决于 worker 是否在重载上下文结束前取到了记录。未配置queue_dir的 Kafka 在两棵代码树上都通过,作为对照。 - 消融实验,每次只去掉一部分、保留其余修复:去掉 Kafka 调用点,只有配置
queue_dir的 Kafka 情况失败;去掉先打开存储再启动 worker 的顺序,两个无法打开队列的情况都在PutMultiple处崩溃;去掉Cancel中的交接,两个无法打开队列的情况在再次应用配置时丢失记录。 - 对抗性审查补充了探测。共享缓冲区中已有 32 条记录时,磁盘目标在 race 检测器下完成初始化且没有报告,而 main 在
PutMultiple处 panic。对空闲目标执行 100 次重载后再禁用,goroutine 数量不变。Kafka 在 broker 不可达时,即使上下文只有 10 毫秒,Init也耗时约 10 秒,说明重载上下文在那里没有约束任何东西。在 main 和修复后的代码上,探测复现了四项剩余限制:磁盘队列已满时关闭被阻塞、未发送批次丢失、磁盘队列互相重叠,以及 worker 启动竞争。Kafka 客户端关闭问题通过阅读代码和一次受控交错确认。
通过阅读代码确认、未实际执行:
- 配置
queue_dir时 Kafka 指标的表现、子系统逐项排查,以及历史沿革。
集群验收。 2026-11-05,在一台主机上运行四个服务器进程,每个进程两块盘,使用内存队列。通过第一台服务器在运行时两次设置 audit_webhook,然后向每台服务器各发送三个 HEAD 请求。在 69fcf50cd 上,只有第一台服务器的三条记录到达,其他三台服务器上的 minio_audit_target_queue_length 都停在 3。使用修复后,全部十二条记录都到达,每台服务器上的队列长度都是 0。这次运行没有覆盖磁盘队列和 audit_kafka:同一台主机上的服务器在一个集群级设置下无法使用各自独立的 queue_dir 路径,也没有可用的 broker。上面的测试覆盖了这两种情况。
合并前仍需完成:远程 CI。
集成验证。 这个修复已摘取到下个版本的同一个集成分支上,位于 #278 和 #281 之后,与锁域、Heal 演练、#285、delete-all 和 Console 的修复放在一起。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集(Ubuntu CI 竞态工作流现已包含 TestDynamicWebhookOutlivesConfigContext)和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 对抗性审查第 1 轮 | 带小问题通过;没有发现改动引入的生产缺陷。审查报告了六个早已存在的缺陷。修复了其中一个:从未启动的目标中排队的记录,会在重载替换该目标时被丢弃(第 3 部分;测试也改为通过第二次应用配置来恢复)。其余五个列在剩余限制中。测试子进程现在去掉了日志与审计环境变量。本文按审查意见做了更正:重启不是唯一的恢复方式;丢弃在日志和指标中可见;peer 上的崩溃大多被掩盖但并非总是如此;关于关闭和送达的说法加了限定。 |
| 对抗性审查第 2 轮 | 带小问题通过,覆盖 Cancel 中的交接和改写后的测试。交接把单个被替换目标的每条记录恰好送达一次,在缓冲区并发调整大小时保住了记录,并经受住了重复和并发的 Cancel 调用;端点可用和宕机两种情况下的普通重载没有出现新的丢失或重复。记录了一处剩余丢失:失败目标在短时间内相继被替换时,共享缓冲区可能耗尽(见剩余限制)。测试现在只清理最后一个目标。另外做了进一步的措辞更正。 |
| 对抗性审查第 3 轮 | 带小问题通过。确认了测试清理和更正后的措辞,另有两处表述改得更准确。 |
参考
- Issue:#259
- 相关的通知 Webhook 热重载:PR #249
- 上游历史:minio/minio#19004、minio/minio#19275、minio/minio#19854
- 相关的上下文设计:IAM 策略重载:锁粒度、请求取消与超时预算隔离
- 配置:将服务日志或审计日志发布到外部服务
30 - 失败写入与幽灵空前缀
本文覆盖 issue #285 和 Denis Babchuk 提交的 PR #286,基于 main 69fcf50cd 与 PR head ed05d982a 评估。
状态: 修复已通过审查,已在本地提交。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未合并或发布。修复保留 PR #286 的提交不变,并增加一个维护者提交。
简短结论
报告属实。调用方已经放弃并删除了暂存数据之后,某块磁盘仍可能执行 xlStorage.RenameData。此时数据目录的重命名会失败,但为这次重命名创建的对象目录会留下来,而且是空的。只要它的父目录下还有其他对象,它就不可见;一旦这些对象被删除,父目录就再也删不掉,带分隔符的列举会返回一个下面什么都没有的公共前缀。
没有任何对象数据丢失、被改动或被暴露。损害是一个永久存在的列举异常:
- 读取每个列出前缀的工具会因此失败,例如 Iceberg
remove_orphan_files; - heal 不会移除它;
- 不知道空对象目录的名称时,通过 S3 唯一的清理方式,是对该前缀做一次破坏性的强制删除。
PR #286 是正确的修复。数据目录重命名失败时,它会在对象目录为空的情况下一并删除对象目录,再逐级删除变空的父目录。末尾的斜杠把第一次删除限制在空目录上,因此已有对象、其他数据目录或旧格式数据在这一步都不会被触及。
我们在采纳时补充三处:
- 同一函数的提交步骤,也就是
xl.meta的重命名,有自己的失败分支。对于没有数据目录的写入(例如内联对象和零字节对象),这个分支会留下同样的空目录,因此加上同样的一行清理。 - 向上逐级删除父目录时,只删除空目录。原来它会删除每个父路径上的任何东西,而并发写入可能把这个路径换成一个已提交对象的
xl.meta。对抗性审查表明,新的清理使这种情况在每块磁盘上都可能发生。 - 如果某次写入的重命名失败,是因为并发清理删除了其目标路径上的某级目录,而它自己的源仍然存在,它会重新创建这些目录并重试,最多每级路径一次。直接元数据写入(
WriteMetadata与UpdateMetadata)在首次版本或删除标记元数据写入期间如果遇到并发RenameData空目录清理删除了父目录,同样会对openFileAll与renameAll最多按每级路径重试一次(479a49b28)。
修复不会主动清扫已经留在磁盘上的目录。之后对同一个 key 写入再删除,就会移除其中一个;如何发现和移除它们,见剩余限制。
定性
| 维度 | 判断 |
|---|---|
| 性质 | 写入失败路径上的清理缺陷;继承自上游 MinIO,不是已发布 SILO 版本引入的回归。 |
| 受影响部署 | 纠删码部署,实际上主要是分布式部署:远端磁盘在调用方放弃之后才完成 RenameData,例如节点缓慢或过载、磁盘超时、客户端断开。 |
| 受影响写入 | 该磁盘上还没有 xl.meta 的 key 的写入,且数据位于数据目录中:超过内联阈值的对象,以及分段上传完成。没有数据目录的写入会经由提交分支进入同样的状态,但时间窗口更窄。 |
| 不受影响 | 成功的写入;已有的对象和版本;对该磁盘上已有 xl.meta 的 key 的覆盖写。 |
| 严重程度 | 中等。没有数据丢失或损坏。列举会无限期返回空前缀,遍历前缀的工具会失败,而在仍有对象的前缀下,这类目录会持续累积。 |
| 可达性 | 依赖时序。报告者在一个注入了网络延迟的 4 节点集群上,用约 5000 次被客户端中断的写入也没能现场复现,但在 12 盘和 16 盘集合的生产环境中发现了它。测试可以确定性地构造这个状态。 |
| 发布紧急度 | 不属于数据安全层面的发布阻塞项,但计划随下一个版本发布:生产用户已经遇到(Iceberg 孤儿文件清理)。清理和重试只在重命名失败时执行;只删除目录的向上清理也作用于普通删除,删除的目录与之前相同,只有仅含 macOS .DS_Store 文件的父目录除外。 |
| 剩余工作量 | 修复及其测试已在本地完成并通过审查。剩下远端 CI、合并和发布。 |
触发条件
在一块磁盘上的顺序如下:
- 一次超过内联阈值的
PutObject,或一次CompleteMultipartUpload,进入提交阶段,在纠删集合的每块磁盘上调用RenameData。 - 调用节点不再等待某块远端磁盘:客户端断开、请求被取消,或磁盘调用超时。远端磁盘并不知情。
RenameDataHandler用context.Background()执行RenameData,grid 单请求 handler 也完全没有 context。远端的磁盘包装层xlStorageDiskIDCheck.RenameData用磁盘超时(默认 30 秒)约束这次调用,但到期时只是返回,正在进行的文件系统调用仍会继续。 - 没有写仲裁,调用方判定写入失败。
renameData撤销已经报告成功的磁盘;PutObject中延迟执行的deleteAll会在每块磁盘上删除.minio.sys/tmp下的暂存对象。对分段上传而言,完成失败后的AbortMultipartUpload会删除上传目录。 - 迟到的
RenameData执行到数据重命名。renameAll先创建目标路径的各级父目录(reliableRename→reliableMkdirAll),也就创建了<bucket>/<prefix>/<object>/。随后重命名以“不存在”失败,而清理只删除数据目录这个从未存在过的路径。 - 之后,
<prefix>/下的其他对象都被删除。每次删除都会移除自己的对象目录,并在父目录处停下,因为父目录里还有那个空的<object>/。
当足够多的磁盘上留有这个空目录时,main 上的列举结果如下(取自测试,只留下了 day=1/b=9/put/):
| 请求 | main 上的结果 |
|---|---|
ListObjectsV2,delimiter=/,不带前缀 |
前缀 day=1/ |
ListObjectsV2,delimiter=/,前缀 day=1/ |
前缀 day=1/b=9/ |
ListObjectsV2,delimiter=/,前缀 day=1/b=9/ |
无结果 |
递归 ListObjectsV2、ListObjectVersions、HEAD day=1/b=9/put |
无结果、无结果、不存在 |
只要集合中至少一半的磁盘上有这个目录,前缀就会出现,这就是默认的 strict 列举仲裁。在 16 盘集合中,目录留在 8 块盘上时会被列出,留在 7 块盘上时不会。
根因
两种行为叠加在一起。
清理假设失败的重命名最多只创建了数据目录。 deleteFile(dstVolumeDir, dstDataPath, false, false) 会在数据目录存在且为空时删除它;只有删除成功之后,才会向上逐级删除变空的父目录。源路径不存在时,数据目录从未被创建,删除报告“不存在”,deleteFile 提前返回,而 reliableMkdirAll 片刻之前创建的对象目录根本不在考虑范围内。
列举遍历会隐藏空目录,但不会隐藏包含空目录的目录。 没有 xl.meta 的目录只有在非空时才会作为前缀上报。空的 <object>/ 会被跳过;它的父目录不为空,于是被上报,它之上的每一级祖先目录也一样。删除其他对象无法解决这个问题:每次删除都会向上走到这个父目录,发现它不为空,然后停下。
也没有其他机制会清理它。heal(包括由数据 scanner 触发的 heal)只会删除“持有它的磁盘少于缺少它的磁盘”的目录;scanner 本身也不会从磁盘上删除目录。
行为的来源
溯源
由上游 MinIO 引入,不是 SILO 引入的。 残留目录比本想防止它的那段清理更早出现。下面每个成因都是上游提交,并且都是 SILO 分叉点(27742d469,2025-12-03)的祖先。这段清理代码在报告者看到问题的 MinIO RELEASE.2025-07-23T15-54-02Z、上游最后一个发布 RELEASE.2025-10-15T17-29-55Z、已归档的上游 master、main,以及从 RELEASE.2025-12-03T12-00-00Z 到 RELEASE.2026-10-26T00-00-00Z 的每一个 SILO 发布中完全一致,我们逐个 tag 比对了相关行。SILO 曾在一个发布中改变过迟到写入的时间窗口,没有任何 SILO 提交改动过这段清理。
| 成因 | 提交(PR) | 日期 | 首个上游发布 |
|---|---|---|---|
| 重命名在改名之前先创建目标的父目录。 | 12f67d47f(#5376) |
2018-01-13 | RELEASE.2018-01-18T20-33-21Z |
RenameData 用这种重命名移动数据目录和 xl.meta。失败时没有任何清理,因此它创建的目录会留下。 |
4915433bd(#9377) |
2020-06-12 | RELEASE.2020-07-11T06-07-16Z |
加入失败清理:对数据目录路径调用 deleteFile,提交分支中则对 xl.meta 路径调用。deleteFile 对不存在的路径在向上清理之前就返回,先是返回“不存在”,从这个提交起返回 nil。 |
dd2831c1a(#12452) |
2021-06-07 | RELEASE.2021-06-07T21-40-51Z |
提交分支的清理目标从 xl.meta 路径改为数据目录路径;原先直接写 xl.meta、没有数据目录 ID 的写入,现在也走暂存文件的重命名。 |
c7f7c4738(#18801) |
2024-02-20 | RELEASE.2024-02-24T17-11-14Z |
存储 RPC handler 用 context.Background() 执行磁盘变更(grid)。此前 handler 传入的是请求上下文。 |
51aa59a73(#18461) |
2023-11-20 | RELEASE.2023-12-02T10-51-33Z |
磁盘包装层用截止时间限制 RenameData,到期即返回,工作继续:先是 deadline worker,后来是 WithDeadline,直到现在的形态。 |
e7b60c4d6(#17701)、4a6c97463(#18861)、9693c382a(#19548) |
2023-07-25、2024-01-24、2024-04-23 | RELEASE.2023-08-04T17-40-21Z、RELEASE.2024-01-28T22-35-53Z、RELEASE.2024-04-28T17-53-50Z |
这些部分如何组合:
- 目录泄漏早于清理。 从第一版
RenameData起,重命名就会创建对象目录,失败时没有任何东西把它删掉。#12452 加入了清理,但源路径缺失时它针对的路径并不存在,deleteFile又在考虑任何父目录之前就返回。它从来没有能力删除这个目录。 - #18801 没有改变结果。 在它之前,提交分支清理的是
xl.meta路径,重命名失败后该路径并不存在。在它之后,该分支清理的是数据目录路径:内联写入时它不存在,数据重命名已成功时它是一个非空目录。两种情况都不会删除空的对象目录。该分支仍会创建这个目录,所以提交步骤会留下同样的残留,如本记录所述。 - 迟到的写入来自时间相关的成因。 磁盘包装层的截止时间(2023 至 2024 年)和 handler 的
context.Background()(#18461)使RenameData可以在调用方放弃、暂存数据被删除之后继续运行。当时 handler 的上下文并不控制RenameData的任何步骤,所以 #18461 并没有创造这个窗口。后来加入的取消检查(#19627,2024-04-28)在远端磁盘上看不到调用方的取消,只会在远端磁盘自己的截止时间触发,这个截止时间由该磁盘的包装层加上。 - 议题中对清理代码的描述是准确的。 它与
RELEASE.2025-07-23T15-54-02Z一致,且这段代码自 2024 年 4 月的 #19548 以来没有变化。
SILO 的改动(都在分叉之后;没有一个改动 deleteFile、RenameDataHandler 或两处清理调用):
| SILO 提交 | 日期 | SILO 发布 | 对残留目录的影响 |
|---|---|---|---|
f480086b6(PR #232) |
2026-10-12 | RELEASE.2026-10-18T00-00-00Z 及之后 |
没有影响。 errCommitMetaMissing 在清理调用之后,把暂存 xl.meta 缺失重新归类为写仲裁失败(503),让客户端重试。目录的残留与之前相同。 |
cd1f6cec3、5ad1f344b |
2026-10-25 | 仅 RELEASE.2026-10-25T00-00-00Z |
缩小了迟到写入的窗口。 调用方在回滚、删除暂存文件和释放锁之前,会等待每个已受理的 RenameData(磁盘包装层用 WithDeadlineAndWait,远端调用不可取消、也没有默认超时)。清理代码没有变化,所以仍然失败的写入会以同样方式留下残留。 |
ea7c794a9、8a4097309、652a1c09d |
2026-10-25 | 仅 RELEASE.2026-10-25T00-00-00Z |
没有影响。 按路径的变更锁、独立的回滚备份目录,以及保护未完成提交不被废弃数据清理删除。 |
dcc2f20ba |
2026-10-26 | RELEASE.2026-10-26T00-00-00Z 与 main |
恢复了继承来的窗口。 它撤回了上述等待在途操作的改动,以保持既有的超时行为,见存储超时与回滚边界。 |
a36fd8fff、0f9199e0c |
2026-08-02、2026-10-18 | RELEASE.2026-08-04T00-00-00Z、RELEASE.2026-10-18T00-00-00Z |
没有影响。 前者在 deadline worker 内部恢复 panic,后者及时删除系统桶下为空的分片上传父目录。两者都不涉及目标对象目录。 |
没有任何 SILO 提交引入或扩大了这个残留。唯一降低暴露的 SILO 改动只发布了一次,一天后就被撤回,因此 RELEASE.2026-10-26T00-00-00Z 的暴露程度与上游相同。迟到的磁盘操作仍是一条有文档记录的边界,本问题就是它的一种可见后果。
证据。 清理代码块在各 tag 上是否一致、每个提交的祖先关系和首个发布,都用 git 核对过(已执行)。20261025 的缩小效果以及 #18801 的影响,来自对改动的阅读(推理)。报告者也无法在线上复现迟到写入;验证一节中的确定性测试直接构造了这个状态。
方案比较
方案 1:在重命名失败路径上删除空目录
这是 PR #286 的思路,也是选定的方案。创建这些目录的函数在同一次调用中、在同一块磁盘上删除它们,而且只删除空目录。成功路径上没有任何代价,也不需要新增状态。
方案 2:让远端 RenameData 响应调用方的取消
这能缩小窗口但无法关闭它,代价却大得多。grid 单请求 handler 拿不到 context;grid 服务端知道请求的截止时间,但只用它来丢弃迟到的响应。要传播取消,需要修改 grid 请求协议,或为这个调用换用另一种 handler 类型。取消也无法中止正在进行的文件系统调用:如果取消恰好在创建目录和重命名之间到达,目录仍会留下,方案 1 依然必要。SILO 之前等待已受理存储 I/O 的工作,已为保持既有超时行为而撤回(见行为的来源)。
方案 3:让 heal 或 scanner 删除空的对象目录
这是唯一无需用户操作、就能清扫旧版本遗留目录或崩溃遗留目录的方案,但本次不采用。heal 有意只删除少数磁盘持有的目录。在后台删除多数磁盘都持有的空目录,会与每一次首次写入在“创建目录到重命名”之间发生竞争,而不只是与迟到的写入竞争,除非它持有对象锁并设置时间阈值。这是一种新的、具有破坏性的后台行为,需要单独设计。它仍可作为清理存量遗留的后续工作。
方案 4:让列举忽略下面没有任何对象的目录
遍历只知道一个目录是否为空。要证明一棵子树里没有对象,就得在带分隔符的列举中,为每个目录条目读取整棵子树。这些目录也仍然留在磁盘上,父目录仍然删不掉。不采用。
方案 5:在创建目标之前检查暂存数据
这会给每次写入增加一次系统调用,而且仍有竞争:暂存数据可能在检查之后、重命名之前消失。不采用。
方案 6:让 reliableRename 在重试时重建父目录
这能让并发写入者在其目录被清理删除后仍然成功(见下文的竞争分析)。reliableRename 本身不做这项改动:renameAll 有许多调用方,这一改动恰好会在源路径不存在的情况下重新创建目标父目录,其中包括那些没有任何清理的调用方。
改为在 RenameData 中采用一个收窄的形式:只有当目标路径上的某级目录消失、而源仍然存在时,它的两次重命名才会重试并重建目录。源不存在(即迟到写入的情形)不重试;如果源在某次重试期间消失,随后的清理会处理。
修复方案
-
数据重命名失败分支(PR #286)。 在现有的数据目录清理之后,
RenameData调用这个路径就是对象目录。不带递归时,
deleteFile只尝试删除这个目录本身;带末尾斜杠时,非空目录会返回“不存在”,调用方忽略这个结果,因此任何持有数据的东西都不会被删除。删除成功后,deleteFile向上逐级删除变空的父目录,到桶目录为止。PR 增加了两个测试。
TestRenameDataStagedDataGoneLeavesNoPrefix在 16 盘集合上,用 8 MiB 对象的PutObject和CompleteMultipartUpload构造出报告中的状态,然后检查列举结果和磁盘内容。TestRenameDataStagedDataGoneKeepsExistingObject检查覆盖写失败后旧对象仍然可读。 -
提交失败分支(维护者补充)。
RenameData末尾对xl.meta的重命名以同样的方式创建对象目录,它的失败分支也有同样的清理缺口。对于没有数据目录的写入,如果暂存的xl.meta在写入之后、提交之前消失,就会留下一个空的对象目录,无论是否应用 PR #286。这个分支也加上了同样的调用。TestXLStorageRenameDataCommitMetaMissingRemovesEmptyDirs复用现有TestXLStorageRenameDataCommitMetaMissing中的missingCommitMetaContext钩子,它恰好在这一点删除暂存的xl.meta。测试检查零字节写入和内联写入既不留下对象目录,也不留下空父目录;持有其他对象的父目录保留;在提交失败之前已经移入的数据目录保持原位。 -
只删除目录的向上清理(维护者补充,由对抗性审查发现)。 删除成功后,
deleteFile会向上逐级删除父目录。原来这一步调用删除时不带末尾斜杠,因此也会删除文件。在一次向上清理的两步之间,另一次清理可能删掉这条链上剩下的目录,随后某个写入者可能提交一个对象,其xl.meta恰好位于第一次清理下一步要删除的路径上:对 keyparent/xl.meta/child来说,这个对象就是parent。于是清理会在发生这种交错的每块磁盘上删掉已提交的元数据。现在向上清理传入的父路径带末尾斜杠,只删除空目录,遇到其他任何东西都会停下。另一次清理既可能是新加的清理(这使得普通的失败写入就能触发这种交错),也可能是普通删除,所以这个隐患在修复之前就已存在。TestXLStorageWalkUpKeepsCommittedObject分别通过DeleteVersion和两个清理分支复现了它。 -
并发清理之后重试(维护者补充,由对抗性审查发现)。 新的清理可能在另一个写入创建目标目录之后、重命名之前删除这个目录。原来那次写入即使源完好也会失败,发生在哪些磁盘上就在哪些磁盘上失败,按分支和操作不同,报
IncompleteBody(400)、NoSuchKey(404)或写仲裁错误。现在RenameData的两次重命名在某次尝试的目录创建或重命名报告“不存在”、而源仍然存在时重试。直接元数据写入(WriteMetadata与UpdateMetadata)在首次版本或删除标记元数据写入期间如果遇到并发RenameData空目录清理删除了父目录,同样会对openFileAll与renameAll最多按每级路径重试一次(479a49b28,TestXLStorageMetadataWriteSurvivesRenameDataCleanup)。判断依据是这次尝试本身的错误,在它被映射为存储错误之前,因为另一个写入(例如嵌套 key 的写入)可能在判断之前重建这些目录。每次重试都会重新执行基准目录以下的目录创建;缓存基准目录的限制见剩余限制。一次清理向上逐级删除,路径上的每个目录最多删除一次,而每次失败的尝试至少需要多一次删除,所以写入最多按其基准目录以下的路径层数重试,任何单次清理都无法把重试耗尽。TestRenameDataSurvivesConcurrentEmptyDirCleanup在PutObject(8 MiB 和内联)或CompleteMultipartUpload刚创建目录之后,在全部 16 块磁盘上运行一次同一 key 的迟到失败RenameData,检查迟到调用删除了目录,而写入仍然成功。TestXLStorageRenameDataRetriesWhileCleanupWalksUp对a/b/object逐个删除object/、b/和a/,每一步都紧跟在写入的下一次目录创建到达该目录之后,使这次清理先后挫败第一次重命名和下一次尝试中的两次目录创建;写入仍必须成功。第三个用例让a/b/object/child的写入在一次失败的目录创建之后立即重建这些目录。
PR #286 的测试辅助函数 stagedDataGoneDisks 现在在集合磁盘列表所用的锁下替换列表背后的磁盘,而不是替换那个后台任务不加锁读取的回调。
这两个重命名分支是 RenameData 为新 key 创建对象目录的仅有位置。第 3、4 项不改变目录在哪里创建,它们让清理对并发写入是安全的。
为什么方案充分且必要
充分性。 报告中的目录是在失败的 renameAll 内部创建的。清理紧随其后,在同一块磁盘、同一次调用中执行,从对象目录开始向上,恰好删除那些为空的目录。加上提交分支的补充之后,没有数据目录的写入也同样成立。分段上传完成使用同一个 RenameData,PR 的测试覆盖了它。
对数据安全。 清理中的每一次删除,无论是第一次还是向上清理的每一步,都只删除空目录。任何使目录非空的内容都会让它保留下来:已有版本的 xl.meta、其他数据目录、旧格式数据,或同名路径下嵌套的对象。父路径上的文件(例如在此期间提交到那里的对象的 xl.meta)会让向上清理停下。把新清理改成递归删除的变体,会让 TestRenameDataStagedDataGoneKeepsExistingObject 和提交分支测试的 data_directory 用例失败;向上清理去掉末尾斜杠,会让 TestXLStorageWalkUpKeepsCommittedObject 失败,因此测试能发现这两种破坏性改动。
同一个 key 的并发写入。 迟到的 RenameData 在其调用方释放对象锁之后才执行,因此同一个 key 的另一个写入者可能同时到达这块磁盘,典型的是客户端的重试。如果该 key 在这块磁盘上没有 xl.meta,清理可能恰好在另一个写入者创建目录之后、重命名之前删除这个目录以及变空的父目录。一次失败写入的迟到调用会到达集合中的每块磁盘,所以这可能发生在任意数量的磁盘上。此时另一个写入者的重命名或目录创建失败,但它的源仍然存在,于是它重新创建目录并重试,最多每级路径一次。一次清理对每个目录最多删除一次,单靠它无法耗尽重试。只有当多次清理在所有重试中不断删除它的目录时,写入才会在这块磁盘上失败。这时纠删层把这块磁盘计为本次写入失败:
- 其他磁盘满足写仲裁时,写入成功,并尝试为这块磁盘排入一次 MRF heal;
- 不满足写仲裁时,写入整体失败,已经提交的磁盘会尽力回滚,客户端需要重试;
- 已提交的对象不会被删除:持有该 key
xl.meta的磁盘上,对象目录不为空,而向上清理遇到文件就会停下。
没有这个修复时,这种交错不会导致失败,所以修复引入了一种新的失败形态,重试把它收得很窄。但这一类问题并不新:在 main 上,比锁活得更久的操作已经可能改动单块磁盘,例如迟到提交或迟到回滚,SILO 对这条边界有文档记录。删除对象最后一个版本时,已经会用同一个 deleteFile 删除对象目录及其空父目录,而该存储 RPC 同样以 context.Background() 执行。其他 key 的写入者,会在清理恰好于其父目录创建之后、其自身目录创建之前删除该父目录时受到影响。这时 reliableMkdirAll 会从更高一级的基准目录重试一次,这和今天面对删除路径时完全一样;如果某个 key 自己的目录正是被删除的父目录之一,上面的重试会覆盖它。
其他清理分支。 除提交分支外,RenameData 还有针对旧格式数据的清理(legacyPreserved)。这个分支只在目标位置已有旧格式内容时执行,因此对象目录不为空;它自己的清理会递归删除旧格式数据目录,并且删除成功后已经会向上清理。它不需要改动。第一次 renameAll 之前的各个提前返回,都不会为新 key 创建对象目录。
必要性。 没有这项清理,就没有任何机制会删除这个目录:deleteFile 提前返回,遍历把它隐藏起来,heal 认为多数磁盘都持有的目录是有效的。这些目录会在每个仍有对象的前缀下持续累积,并在那些对象被删除时显现出来。
有意排除的范围。 已经留在磁盘上的目录不会被主动清扫(见剩余限制),迟到的 RenameData 本身也不会被阻止。
代价与兼容性
- 不改变 API、配置、存储格式、指标、路由或依赖。
- 成功的重命名不增加任何文件系统调用。某次尝试的目录创建或重命名报告“不存在”时,磁盘检查源是否仍然存在,存在则重试。重命名最终失败时,磁盘会对对象目录多做一次删除尝试,然后依次尝试每一级父目录,直到某次删除失败或到达基准目录为止;基准目录本身不会被删除。
- 只要这些目录为空且可以删除,失败的首次写入就不再在磁盘上留下对象目录。没有任何东西会读取空的对象目录,因此就这种情形而言,可见的行为变化在列举上。同一 key 的并发写入也有变化:它们现在可能看到自己的目录被删除,然后重试,见充分性。
- 只删除目录的向上清理作用于
deleteFile的所有调用方,包括对象删除和版本删除。它删除的空目录与之前相同,只是不再删除父路径上的文件。在 macOS 上,只含 Finder.DS_Store文件的父目录不再被向上清理删除。 - 回滚即恢复旧行为,两个方向都不需要迁移。
剩余限制
- 存量遗留不会被清理。 旧版本创建的目录不会被主动删除。在仍有对象的前缀下,它们保持不可见,直到那里的最后一个对象被删除才显现,所以升级之后仍可能出现新的空前缀。之后对同一个 key 写入再删除,会移除其中一个,下面的步骤也可以。
- 崩溃会留下同样的状态。 如果进程或节点恰好在创建目录和重命名之间停止,清理不会执行。
- 路径只创建了一部分。 如果创建路径中途失败(例如磁盘已满),已经创建的父目录会留下。清理从对象目录开始,而此时对象目录并不存在。
- 数据重命名之后的孤立数据目录。 如果数据目录已经移入,而调用在
xl.meta就位之前停止,数据目录会留在对象目录中,而没有xl.meta。这发生在xl.meta的重命名失败时,或两次重命名之间的取消检查触发时(例如到了磁盘自己的截止时间)。这一点保持不变;代码有意不在这一步做递归删除。如果真的在一半磁盘上发生,它也会表现为一个前缀,下面的步骤可以清除它。 - 多次并发清理。 重试能覆盖一块磁盘上的任意单次清理,即使另一个写入在其间重建了目录也是如此。如果多次清理在所有重试中不断删除某次写入的目录,这次写入仍会在这块磁盘上失败。当同样的失败发生在至少写仲裁数量的磁盘上时,客户端看到的错误取决于失败的步骤:目录创建失败时为
AccessDenied(403);数据重命名失败时,PutObject为IncompleteBody(400),CompleteMultipartUpload为NoSuchKey(404);xl.meta重命名失败时为SlowDownWrite(503)。否则,失去仲裁的写入报告写仲裁错误(503)。这些映射与 main 相同;在 main 上,删除空父目录的并发删除已经可能耗尽目录创建的那一次重试。 - 缓存的基准目录。 覆盖写时,两次重命名把对象目录作为目录创建的基准,因为调用开始时它持有
xl.meta。在 Unix 上,如果它在此期间被别的操作删除,重试不会重建它;Windows 不使用这个基准目录。新的清理做不到这一点,因为该目录持有xl.meta;这一限制与 main 相同。 - 迟到的
RenameData仍会执行。 它仍可能在.minio.sys/tmp下或分段上传目录中重新创建暂存的xl.meta;测试显示,无论是否修复,每块磁盘上都会出现。这些路径属于内部路径,不会通过 S3 列出,现有的过期上传清理会在stale_uploads_expiry(默认 24 小时)之后删除它们。对于内联对象,迟到的调用仍可能在调用方报告失败之后,在这块磁盘上提交元数据。这属于存储超时与回滚边界中描述的迟到提交边界。
清理已有的空前缀
- 确认前缀为空:
mcli ls --recursive --versions ALIAS/BUCKET/PREFIX/和mcli ls --recursive --incomplete ALIAS/BUCKET/PREFIX/都没有输出。 - 确认已经没有客户端向该前缀写入。对 Iceberg 这类表格式来说,就是已经不再写入的分区。
- 用
mcli rm --force --purge ALIAS/BUCKET/PREFIX/删除列出的最深一级空前缀。服务端会在每个池、每个纠删集合的每块磁盘上递归删除该前缀,然后删除变空的父目录。在测试中,清除day=1/b=9/时,day=1/也在全部 16 块磁盘上被删除。
强制删除是破坏性的。它会删除该前缀下的全部内容,包括所有版本,不留删除标记,且无法撤销。只对已经按第 1、2 步确认为空且无人使用的前缀执行。
知道空对象目录的名称时(例如 day=1/b=9/put/ 中的 put),有一种非破坏性的替代办法。列举不会显示这个名称;可以从磁盘上读到,也可能从失败请求的日志或应用记录中得知。在这个确切的 key 上写入一个零字节对象,再把它删除;在启用版本控制的桶中,按版本 ID 删除。和删除其他对象一样,这次删除会移除对象目录以及每个变空的父目录。如果新对象会被施加默认保留期,这个办法不适用。
启用了 Object Lock 的桶会拒绝强制删除。除非知道叶子名称且不会施加保留期,否则对这些桶没有受支持的 S3 途径可以删除这样的前缀。这些目录不含数据,留在原处是安全的,但读取每个列出前缀的工具必须能容忍它们。不要手工修改磁盘内容。
验证
使用 Go 1.27.3(darwin/arm64),在一次性工作树中,针对 main 69fcf50cd、PR head 和修复版本实际执行:
-
回归测试在没有修复时失败、修复后通过。 在 main 上只加入测试文件时:
TestRenameDataStagedDataGoneLeavesNoPrefix失败:根目录列举返回day=1/,day=1/返回day=1/b=9/,day=1在 16 块磁盘中的 16 块上仍然存在;- 提交分支测试在零字节写入和内联写入上失败,它们留下了对象目录;
TestXLStorageWalkUpKeepsCommittedObject的三个用例都失败:经由DeleteVersion时已提交的对象parent丢失,经由两个RenameData分支时空目录没有被删除;TestRenameDataSurvivesConcurrentEmptyDirCleanup失败,因为迟到调用在全部 16 块磁盘上留下了对象目录;TestXLStorageRenameDataRetriesWhileCleanupWalksUp失败,因为写入扛不过第一次删除。
只应用 PR head 时,提交分支测试仍然失败,
TestXLStorageWalkUpKeepsCommittedObject丢失已提交的对象,TestRenameDataSurvivesConcurrentEmptyDirCleanup的三次写入全部失败,分别报IncompleteBody、NoSuchKey和写仲裁错误。应用修复后,以上测试全部通过,-race下也通过。在 16 块磁盘的每一块上,对a/b/object运行一次迟到的RenameData,并在其向上清理的每次删除处暂停,使它在写入的第一次重命名和下一次尝试的两次目录创建期间依次删除object/、b/和a/:当写入只重试一次时,PutObject和CompleteMultipartUpload都以AccessDenied失败。再加上一个a/b/object/child的写入在一次失败的目录创建之后立即重建目录时,如果重试判断是在尝试之后检查目标路径,也会出现同样的失败。应用修复后,两种调度下两次写入都成功。 -
守护测试能发现破坏性的变体。 数据分支的递归清理会让
TestRenameDataStagedDataGoneKeepsExistingObject失败,提交分支的递归清理会让data_directory用例失败。向上清理去掉末尾斜杠会让TestXLStorageWalkUpKeepsCommittedObject失败。去掉重试会让TestRenameDataSurvivesConcurrentEmptyDirCleanup失败;只允许重试一次会让TestXLStorageRenameDataRetriesWhileCleanupWalksUp以 access denied 失败;在尝试之后检查目标路径来判断是否重试,会让它的嵌套写入用例以同样方式失败。对照测试在 main 上和修复后都通过:TestRenameDataStagedDataGoneKeepsExistingObject、TestXLStorageRenameDataCommitMetaMissing、TestRenameDataCommitMetaMissing,以及data_directory用例。 -
测试辅助函数。 在
-race下,集合磁盘列表的一个并发读取者与 PR 的stagedDataGoneDisks会报数据竞争,与修订后的辅助函数则不会。 -
列举仲裁。 空目录留在 16 块盘中的 7 块上时,不列出任何前缀;留在 8 块和 16 块上时会列出。所有情况下,递归列举、
ListObjectVersions和HEAD都找不到任何内容。 -
清理步骤。 在对象层,强制删除选项在全部 16 块磁盘上删除了幽灵前缀
day=1/b=9/这一整条目录链;同一选项作用于另一个前缀时,也删除了其下的真实对象。在未启用版本控制的桶中,对叶子 key 做一次零字节PutObject再DeleteObject,不用强制删除就移除了这条目录链。 -
附带观察。 1 KiB 的内联
PutObject即使在RenameData之前删除了暂存目录也会成功,因为它的数据随请求一起传输。失败的调用之后,无论是否修复,暂存的xl.meta都会在全部 16 块磁盘的.minio.sys/tmp和分段上传目录中被重新创建;ListMultipartUploads不报告任何上传。数据重命名之后的取消会留下没有xl.meta的数据目录,在 main 上和修复后都一样。 -
相邻测试。 应用修复后,
go vet ./cmd通过;由^Test(XLStorage|XlStorage|Erasure|DeleteObject|DeleteFile|DeleteVersion|RenameData|PutObject|CompleteMultipart|ObjectComplete|Heal|ListObjects|Walk|Metacache|StorageTimeout|StorageRollback|ReduceWriteQuorumErrs|Multipart|ObjectNewMultipart|NewMultipart|Lifecycle|Decommission|Rebalance|Disk|Posix|Abort|CopyObject|GetObject|Rename|Reliable|OsMkdir|MkdirAll)选出的 137 个测试(含新增测试)全部通过。PR #286 的 CI 检查在其 head 上全部通过。
仅由代码推断、未实际执行:
- 远端时序窗口本身,没有多节点的现场复现;
- 写入因多次并发清理而失去一块磁盘之后,最终的 heal;
- 旧格式分支、路径部分创建、崩溃情形,以及内联对象的迟到提交;
- 过期上传清理对重新创建的暂存元数据的删除;
- 启用版本控制的桶中的清理替代办法、
mcli rm --force --purge会发送强制删除请求头,以及 Object Lock 桶的拒绝行为; - Linux 和 Windows 文件系统上的末尾斜杠语义;测试在 macOS 上运行,两个调度目录创建的测试在 Windows 上跳过,因为那里的目录创建没有跟踪;
- 上游历史。
本记录没有运行完整的 ./cmd 测试。
集成验证。 这个修复已摘取到下个版本的同一个集成分支上,位于 #278 和 #281 之后,与锁域、Heal 演练、#259、delete-all 和 Console 的修复放在一起。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 对抗性审查第 1 轮 | 要求修改。删除之后的向上清理可能删除父路径上的文件,因此对于 parent/xl.meta/child 这样的 key,新的清理或普通删除都可能删掉在此期间提交的对象的 xl.meta;以只删除目录的向上清理修复(第 3 项),这个问题在 main 上经由删除也能触发。清理可能删除另一个写入刚创建的目录,使那次写入以 400 或 404 失败;以重试修复(第 4 项)。PR 的测试辅助函数与磁盘列表的后台读取者存在竞争;已修复。记录做了十处更正。 |
| 第 2 轮 | 要求修改。每块磁盘一次迟到调用,在写入重试期间依次删除各级父目录,就耗尽了唯一的一次重试并返回 403。现在重试最多每级路径一次。没有把耗尽后的失败改判为 503,剩余问题记录在剩余限制中。记录做了七处更正。 |
| 第 3 轮 | 要求修改。嵌套 key 的写入在一次失败的尝试之后重建目录,使重试判断看不到这次失败;现在判断依据是这次尝试本身的错误。两个调度目录创建的测试在 Windows 上跳过。概述与清理指引保持一致。 |
| 第 4 轮 | 附带小修改批准:关于缓存基准目录和清理过程的三处措辞更正,已应用。 |
参考
31 - 存储池迁移完整性审查:#261、#263 与 #265
本文审查 #261、#263、#265 及其对应的修复 #262、#264、#266,并记录维护者后续修复 #276。这是设计与审查记录,不是发布说明。本文已更新到 2026-11-02 的 #276 合入后 main。#264、#266、#276 已合入,#262 已被取代并关闭。
先看结论
| 问题 | 是否成立 | 对应 PR 的判断 | 建议处理 |
|---|---|---|---|
| #261 | 成立。可读的混合 EC 对象可能从迁移列表消失,旧池删除后丢失。 | 窄范围修复已通过维护者 PR #267 合入;#262 已被取代。 | 已由 #267 合入,issue 已完成关闭。 |
| #263 | 成立。内部 tier 清理记录被生命周期、列表和迁移路径当成真实版本。 | #264 处理了完整边界,同时保留 healing 所需的可见性。 | 已合入,issue 已完成关闭。 |
| #265 | 成立。ILM 与迁移对“版本是否还存在、谁负责删除”的判断不一致。 | #266 修复了贡献者报告的本地顺序问题;维护者后续 PR #276 补齐 tier 清理、生命周期代数和整对象完成合同。#276 同时加入的生命周期删除复制在下一个版本发布前移除,见 2026-11-06 更新。 | 为承认贡献先合入 #266,再立即合入维护者的完整性修复 #276。 |
实际顺序是:先合入 mixed-EC 修复,再合并 #264,合并 #266 以保留贡献,然后立即合入 #276。前三步和维护者后续修复现在都已进入 main;#276 补齐了原始 PR 无法单独安全承担的外部清理合同。
验证了什么
三个问题都能在修复前的 main 上用聚焦测试和 issue 中提供的原生服务端场景复现。#264 与 #267 合入后先完成了基础验证;#266 与 #276 合入后的代码又通过了迁移与复制聚焦测试、go vet ./cmd、make build、仓库 CI 的 lint 与生成文件检查,以及完整的 go test ./cmd -count=1。本文更新后会再次运行伴生站检查。
关键的行为证据是:
- 在 main 上,#261 的混合 EC 迁移测试返回空结果,但 latest-only GET 与 ListObjectsV2 已经能找到可读对象;加上 #262 后同一组测试通过。
- 在 main 上,#263 测试显示 free entry 被列为最新删除标记、让可读对象从 ListObjectsV2 消失、让生命周期选择错误版本,并在 decommission 时被搬成普通删除标记;完整 #264 测试在 main 失败,在 PR 上通过。
- 在 main 上,#265 测试会把当前版本留在源池,或留下已经搬走版本的重复副本;延迟 expiry queue 的 decommission/rebalance 测试在 #266 上通过。
这些证据足以证明问题和本地迁移修复成立。但它们还不能证明 #266 的直接源端 purge 保留了桶复制语义、远端 tier 清理语义、生命周期配置变化语义,或为整对象过期提供了安全的完成栅栏,这些都是单独的验收条件。
三个问题为什么会同时出现
三者都违反了同一条规则:存储池 worker 必须使用与 GET 相同的逻辑对象视图,并且在确认安全目的地或明确删除之前,必须保留对象。
问题被隐藏,是因为不同 worker 对同一份 xl.meta 使用了不同投影:
- latest-only 读取可以跨混合 EC 布局选择可读多数,但 all-versions 遍历仍走更严格的旧路径。
- GET 已经跳过 tier free version,但列表、生命周期和迁移代码仍然看见它。
- ILM 知道某些 action 最终会删除数据,decommission/rebalance 却把“已经排队的删除”当成了“已经完成的删除”。
因此正确的修复是补齐这些边界,而不是重写整个存储池迁移或新增元数据格式。
受影响的发布范围与公告
这三个问题应按存储完整性公告处理。最新已发布 tag RELEASE.2026-10-26 早于这些修复;修复现在已经通过 #264、#267、#266 和 #276 进入 main,用户需要升级到包含适用修复的版本。
两类问题的代码窗口不同:
- free version 的列表和迁移行为来自 2024-06-07 的上游提交 29a25a538。包含该改动的版本都可能带有潜在问题,但真正产生用户可见影响,还需要 tiered version 在 free record 尚未清理时被生命周期、列表或迁移路径观察到。
- mixed-EC 的不一致在 2026-10-16 的 e90cb2067 之后尤其明显:latest-only 读取已经能恢复可读多数,但 all-version 迁移遍历仍可能漏掉对象。已发布的 20261018、20261025 和 20261026 都包含这段行为;#267 修复了它。
这不是“所有部署或所有对象都已经损坏”,而是需要多个条件同时满足的问题。free version 单独存在是正常的待清理状态;混合 EC 元数据单独存在也是可用性优先降级写入产生的可恢复状态。真正的损坏需要在这些状态存在时再发生第二个操作:
| 状态与操作 | 结果 | 恢复位置 |
|---|---|---|
| free version 短暂存在,scanner 及时完成 | 没有用户可见损坏,记录和远端对象都会被清理。 | 升级后按正常 scanner 运行;不要手工改元数据。 |
| free version 在清理前被生命周期或普通列表看到 | 可能错误判断 noncurrent、隐藏 key 或显示误导性的 delete marker。 | #264 会修正后续扫描;已经误删的真实版本需要备份或其他权威副本。 |
| 旧版 decommission/rebalance 搬运了 free version | 可能生成普通 delete marker,并丢失远端引用。 | 无法自动识别该 marker;需要比较源端副本、复制目标、备份和审计记录。 |
| 旧版 decommission/rebalance 扫描到混合 EC 元数据 | 可读对象可能被漏掉;旧池删除后对象可能丢失。 | 源池仍在时用修复版重新迁移并执行 heal;源池已删除时只能从副本或备份恢复。 |
应该立即发布操作公告。公告应告诉使用受影响版本的操作员暂停 pool decommission 和 rebalance,尤其是在磁盘降级、tier 清理未完成或生命周期规则生效时;保留旧池和备份;升级到包含相关修复的版本;迁移后核对 GET、ListObjectsV2、ListObjectVersions 和对象数量。公告要明确这是复合条件触发的潜在问题,并单独说明 rebalance 对 transitioned version 的限制。除非项目把存储完整性问题统一归入安全漏洞,否则不需要 CVE 风格的安全标签;高优先级存储完整性 release note 和运维公告已经足够。
在发布“全部解除风险”的说明之前,还需要完成 patch release 和历史数据审计 runbook。当前 main 已经包含这三个问题的完整 decommission 修复;只发布包含 #264 与 #267 的版本,仍不足以让所有带生命周期的迁移场景安全。
合并与后续计划
- 让已经合入的 #264、#267、#266 和 #276 一起进入下一个 patch release,覆盖普通对象视图、mixed-EC 迁移列表、生命周期 action 语义和外部清理合同。
- 保留“版本桶当前 Expiration”“只删除一个版本的 purge”“删除整个对象”三类 action 的区分,不能退化成“一律跳过所有 ILM action”。
- 保留以下回归测试:配置了复制时生命周期删除不得排队任何复制,以及 transitioned version 清理、生命周期配置变化、整对象过期和可恢复 decommission。
- 随 patch release 发布存储完整性公告,并为受影响版本上执行过池迁移的安装提供历史数据审计 runbook。
- 单独跟踪 rebalance 对 transitioned version 的支持;#276 有意没有扩大这个范围。
三篇专题记录如下:
剩余边界
这些修复不会自动修复仍然离线的磁盘,不会恢复旧版本中已经完成错误 decommission 后丢失的数据,也无法区分旧版本错误产生的普通删除标记与客户端创建的删除标记。当 tier 不可用时,#264 可能让 decommission 失败;这是保住仍可能存在的清理记录所需的安全行为。rebalance 仍会有意跳过 transitioned version,这是独立于本次 decommission 修复的范围决策。迁移期间生命周期配置变化采用 fail-closed 语义,旧扫描结果必须在新配置下重试。
32 - 请求头截止时间:绝对头部上限与滚动的正文空闲超时
本文记录 Server HTTP 读取截止时间的修复设计,该修复通过 PR #196 合入主干(提交 055030ea5),随 Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a)发布。
排查源于慢速 HTTP DoS 扫描器报告(#183),并通过两组实验验证:一是直接 TCP 对照实验(请求头限制设为 100 ms、实际分批发送耗时 400 ms,标准 Go 服务拒绝该请求而旧版服务返回 204);二是单盘进程探针验证(通过命令行参数与环境变量两种入口配置后,服务端均拒绝 400 ms 慢速请求头,正常请求不受影响)。
一条连接上的两类超时
- 请求头绝对截止时间。
ReadHeaderTimeout限制从开始读取请求头到头部解析完成的总时长,慢速逐字节发送无法顺延该上限。在 HTTP/1 keep-alive 连接上,Go 先按IdleTimeout等待下一个请求的首字节,随后开启新的请求头截止时间。ReadHeaderTimeout同时参与独立的 TLS 握手超时计算。 - 正文滚动空闲超时。 请求头解析完成、连接进入活跃阶段后,切换回滚动超时:每次成功读取都会顺延截止时间,仅当两次读取之间的停顿超过配置的空闲超时(idle timeout)时才关闭连接;持续传输数据的长上传或长下载不会因头部截止时间中断。
修复前,第一类超时在实际运行中并未生效:连接层包装器在每次部分读取前都将 socket 截止时间重置为 now + idle + 250 ms,直接覆盖了 net/http 设置的绝对截止时间。慢速客户端只需在每个空闲窗口内发送一个字节,即可无限期占用连接。
两个独立缺陷
- 连接层包装器覆盖了绝对截止时间。
DeadlineConn包装器的读取路径在每次部分读取前重置 socket 截止时间,令 Gonet/http服务端设置的读头截止时间失效。直接 TCP 对照实验单独验证了这一点:将请求头上限设为 100 ms、空闲窗口设为 2 s 时,耗时 400 ms 才发完的请求头仍被接受。 - 命令行配置未传入服务器上下文。 CLI 定义并解析了
--read-header-timeout与MINIO_READ_HEADER_TIMEOUT(含默认值),但服务器上下文构建器只复制了IdleTimeout而漏掉了ReadHeaderTimeout,导致运行中的服务器拿到的值始终为0。仅修复缺陷 1 时,真实服务进程依然接受慢速请求头;第二处修复是在IdleTimeout赋值旁补齐ReadHeaderTimeout传递。
该问题长期未被发现,是因为 --read-header-timeout 的默认值(30 s)与 IdleTimeout 默认值相同;当该参数未传入服务器时,net/http 回退使用的读超时恰好也是同一个 30 s,使默认配置下的表面行为与预期一致。
配置
- 命令行参数:
--read-header-timeout(Hidden: true,不在常规 CLI 帮助中显示) - 环境变量:
MINIO_READ_HEADER_TIMEOUT - 默认值: 30 s(与 idle timeout 默认值一致)
- 两项超时均无 YAML 配置项,仅在启动时按“命令行参数 > 环境变量 > 默认值”的优先级绑定一次。
| 取值 | 效果 |
|---|---|
header > 0 |
作为 HTTP/1 请求头阶段的绝对时长上限,并参与 Go 的 TLS 握手读取超时计算(含 HTTP/2 握手) |
header = 0(显式设置) |
回退至 Go 标准库规则:使用读超时(即 idle timeout);CLI 默认值为 30 s |
header < 0 |
关闭请求头专用上限;正值的读/写超时仍约束 TLS 握手读取,正值 IdleTimeout 仍约束 keep-alive 空闲等待 |
调小 idle、未设置 header |
请求头阶段仍使用自身的 30 s 默认值——这是唯一比直觉预期宽松的参数组合,但仍具备确定的绝对上限 |
将参数设为负值会关闭请求头绝对上限,使连接重新面临慢速请求头占用风险,不建议在生产环境使用。因超过截止时间而被截断的未完成请求头通常会直接触发连接关闭,而非返回 HTTP 错误状态码。该问题由 @AEGEGE 在 #183 中报告,初始修复 PR #195 经整理后随 PR #196 合入。
各协议的超时行为
- HTTP/1:请求头读取与 keep-alive 等待使用绝对截止时间,请求正文保持滚动空闲超时。连接状态钩子与调用方已有的钩子组合执行,互不覆盖。
- TLS:握手读取阶段取正值 header 截止时间与既有读/写超时的较小值,握手完成后重新计时新的请求头上限;握手写入阶段保持滚动超时语义。
- HTTP/2:保持不变。协商为
h2时跳过连接阶段切换,沿用h2原生的单流(per-stream)绝对读超时,ReadHeaderTimeout不注入h2配置。 - 内部节点通信:Linux 节点间连接独立维护滚动超时语义;由 grid hijack 接管的连接在进入上述阶段切换前即解包还原为底层裸 TCP 连接。
被否决的方案
- 全局钳制所有未来截止时间。 Go 1.27 在部分路径上会设置整请求截止时间;当读超时等于 idle timeout 时,全局钳制会把整个 HTTP/1 请求(请求头加正文)的总时长硬性限制在该窗口内,导致大文件上传中途断开。
- 移除读超时并将零值解释为滚动空闲超时。 在
net/http中,零值截止时间代表后台读取与 hijack 连接“永不超时”;改写这一语义会破坏长耗时 handler,同时使h2失去单流超时保护。 - 包装请求正文读取器或使用
http.ResponseController。 需要完整处理 chunked 编码、连接排空(drain)、EOF 状态记账及h2特例,改动面远超修复请求头超时所需。 - 在连接状态钩子中重算标准库的截止时间。 这会复制随 Go 版本演进而变化的
net/http内部算术逻辑;直接记录并保留标准库下发的目标截止时间更加稳健。 - 通过比较参数大小自动推导超时模式。 三个相关超时参数的默认值均为 30 s,在生产默认配置下无法通过“是否小于空闲窗口”区分所处阶段,该方式仅在人为调小参数的测试配置下有效。
验证与限制
单元测试覆盖了连接包装器跨越三个连续刷新周期的行为(验证绝对截止时间不随读取向后顺延)、HTTP/1 keep-alive / TLS / 纯 HTTP/2 协商下的阶段切换,以及真实 CLI 上下文中的命令行参数与环境变量绑定;进程探针验证了配置 100 ms 请求头上限的运行实例能够拒绝耗时 400 ms 的慢速请求头。边界说明:TLS 握手写入阶段仍采用滚动超时;业务 handler 的纯 CPU 或存储等待时间不受连接读取截止时间约束;请求头绝对上限不加额外宽限,而滚动空闲超时保留约 250 ms 的刷新宽限期。
升级注意事项(调小请求头超时会同步收窄 TLS 握手读取窗口)见组件版本矩阵。
33 - 条件 DELETE:一个条件对应一个逻辑对象
单对象条件删除(DeleteObject 上的 If-Match)由 PR #145 引入,随后 PR #178 修复了多池串行化与跨池清理。这两项改动均已随 Server 20260916 发布。
八月围绕 PR #12 的早期方案曾设想更宽的范围——包括批量 DeleteObjects 拒绝保护、额外的 s3:GetObject 读取授权检查,以及“仅比较当前版本”规则——但实际合入的实现并未采纳这些扩展。本文记录 f99ed829b 中已交付的契约,并阐述为什么前置条件判断必须位于单池存储层之上。
已实现的契约
| 请求 | 已交付行为 |
|---|---|
单个 DeleteObject,非空 If-Match: <ETag> |
在外层删除锁内比较客户端可见 ETag;不匹配时先返回 412 PreconditionFailed,不执行任何删除 |
If-Match: * |
要求选中的对象表示存在且不是删除标记(Delete Marker) |
显式 versionId |
比较所指定的版本,而非无关的当前版本 |
未携带 If-Match 或请求头值为空 |
不安装条件回调,按普通删除处理 |
批量 DeleteObjects 的逐项 <ETag> |
ObjectToDelete 结构体无 ETag 字段,该 XML 元素不提供删除保护 |
内部递归 x-minio-force-delete |
前缀删除先于单对象条件路径返回,不能用作条件删除 |
条件判断不会额外增加 s3:GetObject 授权检查。普通删除授权仍然生效,包括显式版本对应的 s3:DeleteObjectVersion、Object Lock 保护检查,以及独立的可信复制路径。
为什么条件判断属于跨池协调层
在多存储池部署中,不同存储池可能保留同一对象在不同时期的副本。请求前置条件针对的是本次操作选中的单一逻辑对象。如果让每个存储池独立判断条件并直接修改磁盘状态,就可能先在一个池中删掉副本,随后在另一个池中返回 412 PreconditionFailed;或者删掉最新副本后,反而让旧池中的陈旧副本重新暴露为当前版本。
因此,多池协调层(erasureServerPools.DeleteObject)会先获取共享命名空间写锁,收集各池状态,对选中的逻辑表示只执行一次条件判断,随后清空向下传递的回调函数,并跨池协调删除清理。并发写入与元数据更新共用同一把外层命名空间锁。
早期设计中的反例
假设存储池 1 保留旧副本(ETag 为 A),存储池 2 保留当前最新副本(ETag 为 B):
If-Match: A(返回412却发生部分删除): 若逐池独立判断,存储池 1 会先匹配并删除副本A,随后在存储池 2 因与B不匹配而返回412 PreconditionFailed,导致被拒绝的请求依然修改了底层存储。If-Match: B(成功删除后旧副本复活): 若逐池独立判断,存储池 2 删除了最新副本B,而存储池 1 因不匹配或跳过而保留了旧副本A,导致陈旧数据重新成为客户端可见的当前对象。
此外,在逐池并发工作协程中直接调用 HTTP 条件回调,还可能引发对同一个 http.ResponseWriter 的并发写入竞争。在跨池协调层集中消费一次请求条件,能够同时消除这两类隐患。
失败边界
跨池协调路径在判断条件前会先读取各池状态,并向上报告读仲裁失败,而不会把不可达的存储池误判为空池。不过,物理清理失败仍可能发生在部分底层修改之后:单次请求失败并不等价于分布式多池回滚事务。存储层报错后,调用方仍需通过重试与状态核对来确认最终结果;详见多池对象一致性。
选择与判断
只判断一次
erasureServerPools.DeleteObject 持有外层命名空间锁。多池部署调用 deleteObjectReconciled;单池部署先读取选中表示,并在删除标记快捷返回分支之前调用 CheckPrecondFn。下层存储引擎不再针对每份物理副本重复解释条件。
通配符与删除标记
DELETE 专用前置条件辅助函数将 If-Match: * 解释为“目标必须存在有效的非删除标记对象表示”;删除标记不能满足 *。缺失对象键或缺失指定版本会进入常规的未找到路径,不会被伪造成空 ETag 匹配。
授权
处理器依据实际生效的目标版本检查删除权限(s3:DeleteObject 或 s3:DeleteObjectVersion),不会在 If-Match 携带具体 ETag 时额外要求 s3:GetObject 权限。
SSE-C ETag
条件删除直接比较元数据中已保存的客户端可见 ETag 投影,不会读取或解密对象数据载荷。SSE-C 读取密钥认证与对象删除属于相互独立的契约。
显式版本
当请求提供显式 versionId 时,系统会选中该特定版本执行 ETag 比对与删除。因此,即使当前最新版本拥有不同的 ETag,只要所指定的历史版本 ETag 匹配,该历史版本仍可被正常删除。这与早期方案中“只允许比较当前版本”的设想不同。
尚未支持的边界
空 If-Match 请求头不会安装条件。内部递归前缀删除扩展(x-minio-force-delete)会绕过单对象条件路径。ObjectToDelete 不会解析批量 DeleteObjects XML 中的 <ETag> 元素,也没有整请求级别的 NotImplemented 拒绝保护。需要“比较后删除”语义的调用方必须使用带非空 If-Match 请求头的单对象 DeleteObject 接口。
被拒绝的替代方案与范围边界
- 仅修改底层共享 ETag 比较函数: 改动字符串比较辅助函数无法解决多池胜出版本选择,也无法阻止在条件检查前先修改其他池的数据。
- 逐池执行回调后再汇总错误: 当某个池已经物理删除了副本后,即使外层汇总返回
412也无法撤销已发生的删除。 - 引入新的分布式事务框架: 在既有外层命名空间锁内消费一次条件回调已足以满足单对象
DeleteObject;批量条件删除与 IAM 策略键跨越了其他接口边界,应作为独立工作推进。
源码证据
核心实现位于 cmd/object-handlers.go、cmd/erasure-server-pool.go 与 cmd/api-datatypes.go,回归测试覆盖了 ETag 不匹配、通配符与删除标记、显式 versionId、读仲裁失败以及多池状态协调。
后续工作
批量条件删除
在 DeleteObjects 中支持逐项 <ETag> 条件需要解析该 XML 字段、在对应外层锁内逐一判断每个逻辑对象、兼容 Quiet 模式,并在逐项响应结果中报告条件失败。在此之前,批量请求中的 <ETag> 会被忽略,切勿将其用作并发控制手段。
策略强制键
IAM 策略包目前尚未定义 s3:if-match 条件键。支持通过策略强制要求客户端提供 If-Match 需要先在 silo-pkg 中新增该条件键、在服务端处理器中填充请求条件上下文,并补充策略评估测试。
设计权衡
单对象条件 DELETE 直接复用了既有的多池对象选择与外层命名空间锁边界,无需引入新的事务层。完整的批量条件删除与 IAM 策略条件键涉及更多 API 与外部包边界,仍作为独立特性管理。核心不变量非常明确:当受支持的单对象 If-Match 条件不成立时,必须在任何存储池删除数据之前,针对选中的逻辑表示一次性完成判定。
34 - 只预览文本,绝不执行:SILO Console 文本预览设计
SILO Console 支持预览图片、PDF、音频和视频,但在 v2.1.1 中却无法直接查看日常运维最常检查的小型日志、纯文本、JSON 与 XML 对象。即使对象保存了准确的 Content-Type,前端也会在选择预览渲染器之前将其判定为不支持。
恢复旧版浏览器原生 <iframe> 预览虽然简单,却是错误的修法。存储桶中的对象内容完全由上传者控制;一旦将其作为同源 HTML 或 XML 文档加载,一个便利的预览功能就会退化为同源脚本执行边界。
随 Console 2.2.0 交付的设计(跟踪议题 #17)确立了一条严格的安全不变量:
SILO Console 仅将符合条件的对象作为有界 UTF-8 纯文本预览,绝不让浏览器将其中的标记、MIME 类型或文件内容解析为可执行文档。
核心决策
Console 新增独立的 text 预览类型与 PreviewText 组件,并遵循十项契约规则:
- 完整保留现有的
image、pdf、audio与video媒体分类逻辑。 - 仅当既有媒体分类器返回
none时,才进入文本回退(fallback)判定。 - 允许四种目标扩展名(
.log、.txt、.json、.xml)或四种精确被动文本 MIME 类型(text/plain、application/json、application/xml、text/xml)。 - 通过常规鉴权下载路径读取对象字节,不传递
preview=true。 - 在应用层强制执行 1 MiB(
1,048,576字节)读取硬上限。 - 仅执行严格 UTF-8 解码(
fatal: true),并拒绝包含 NUL 字节的内容。 - 在可滚动的
<pre>容器中仅渲染单一 React 文本节点。 - 绝不使用
<iframe>、HTML 解析器、XML 解析器或任何 HTML 注入 API。 - 要么展示完整对象,要么完全不展示;绝不渲染被截断的 JSON 或 XML 半截片段。
- 当文件超出大小上限、编码非法或无法加载时,始终保留 Download(下载)出口。
本设计不新增 S3 操作或后端预览路由,也不扩大后端的 inline MIME 白名单。交付过程中同步收敛了 Console 下载端点的字节范围语义:零字节对象的 Range 请求返回空 200 OK,不可满足的范围请求返回 416 Range Not Satisfiable 及 Content-Range: bytes */N,且对象 JSON 元数据始终输出 size 字段(详见 Console 2.2.0)。
Console 2.1.1 的原有行为
在 SILO Console v2.1.1 中,前端预览联合类型仅包含:
其扩展名映射表只包含多媒体格式,未收录 .log、.txt、.json 或 .xml;MIME 分类器同样忽略 text/plain、application/json、application/xml 与 text/xml。运行时验证显示前端分类与后端响应头之间存在如下分裂:
| 对象 | 前端分类结果 | Console 下载响应头 |
|---|---|---|
.log / text/plain |
none |
inline,X-Frame-Options: SAMEORIGIN |
.txt / text/plain |
none |
inline,X-Frame-Options: SAMEORIGIN |
.json / application/json |
“Preview unavailable” | inline,X-Frame-Options: SAMEORIGIN |
.xml / application/xml |
none |
attachment,X-Frame-Options: DENY |
此外,对象详情抽屉在判断 Preview 按钮是否禁用时使用了错误的布尔逻辑连接词:有读取权限的用户可以对不支持预览的对象点击 Preview 并看到不可用提示,而在另一些权限组合下界面会放行实际上会被服务端拒绝的操作。
更关键的是,预览容器组件末尾仍残留着一条通用的同源 <iframe> 回退分支。由于封闭的五元联合类型无法落入该分支,v2.1.1 并未构成可利用的文本预览 XSS;但如果简单地把 text 加入联合类型并任其落入旧的 <iframe> 分支,就会直接激活同源文档执行风险。
根因分析
该缺陷源于三个独立演进层之间的契约漂移:
分类契约漂移
浏览器前端依据文件名与对象的 Content-Type 元数据判断预览资格,但封闭的联合类型中根本没有 text 分支。再准确的元数据也无法选中一个不存在的渲染器。
响应策略漂移
Console 后端独立判断下载响应是否允许 inline:后端仍将纯文本与 JSON 视为安全的被动内联类型,同时将 XML 与 HTML 强制设为 attachment。这一服务端策略并未反映在前端分类器中。
渲染器漂移
在可达预览类型收缩为纯媒体类型后,旧版通用 <iframe> 回退分支仍残留在组件树中。修复方案必须引入专用的惰性文本渲染器并彻底删除不可达的 <iframe> 分支,而不能把 MIME 元数据当作安全边界。
为什么拒绝同源 Iframe 预览
X-Frame-Options: SAMEORIGIN 不是沙箱。它只限制哪些父级源可以嵌入该响应,并不限制同源 frame 内部的代码能执行什么操作。
一旦上传者可控的 HTML、XHTML、SVG 或活动 XML 被作为内联同源文档加载进 <iframe>,其中的脚本就会直接在 Console 的源(Origin)下运行。即便 HttpOnly 会话 Cookie 能阻止脚本直接读取 document.cookie,同源脚本仍然可以携带当前会话凭据向 Console 后端发起任意状态修改请求。
X-Content-Type-Options: nosniff、内容安全策略(CSP)与 Content-Disposition 都是有价值的纵深防御,但核心安全不变量必须建立在数据流本身之上:
产品契约
本功能是一个只读源码文本查看器,既不是网页文档渲染器,也不是在线代码编辑器。
用户可以:
- 从存储桶对象列表或对象详情抽屉打开符合条件的小型文本对象;
- 在现有预览弹窗中查看保留空白与缩进的原始文本;
- 使用浏览器原生选择与复制功能;
- 清晰区分预览受阻是因为文件过大、非 UTF-8/二进制编码、权限不足、对象已被替换还是网络错误;
- 随时点击下载获取原始字节。
系统绝不会通过静默转换误导用户:
- 绝不对 JSON 执行解析重排(否则会篡改超出双精度范围的大整数、折叠重复键或改变空白格式);
- 绝不将超限的 XML 或 JSON 静默截断为结构残缺的前缀片段;
- 绝不用
U+FFFD替换字符掩盖非法的 UTF-8 字节序列。
目标与非目标
目标
- 无需下载到本地即可直接预览小型
.log、.txt、.json与.xml对象。 - 无论扩展名、
Content-Type或载荷内容如何,始终保持对象内容完全惰性(inert)。 - 将保留的响应字节与渲染文本严格限制在 1 MiB(
1,048,576字节)以内。 - 原样保留存储的 UTF-8 文本,不做静默重排。
- 统一对象列表与详情抽屉的预览资格及权限判断逻辑。
- 同时支持当前对象版本与显式选中的历史
versionId。 - 保持匿名访问(
X-Anonymous: 1)与子路径托管(subpath hosting)行为不变。
非目标
- HTML、XHTML 或 Markdown 渲染;
- XML 解析、XSLT 转换、外部实体解析或 Schema 校验;
- JSON 自动美化缩进(
JSON.parse/JSON.stringify); - 在线编辑、语法高亮、行号栏、代码折叠或 ANSI 转义序列渲染;
- 针对超过 1 MiB 大文件的首尾截取或截断预览;
- 针对 GBK、UTF-16、Latin-1 等非 UTF-8 编码的有损解码或自动编码探测。
当 notes.md 等文件的精确 MIME 类型为 text/plain 时,它可以作为原始 UTF-8 纯文本预览,但不会触发 Markdown 渲染。
准入资格契约
准入资格采用两阶段判定,以确保既有媒体预览行为零回归。
第一阶段:保留既有媒体判定
首先原样运行现有的 image、pdf、audio 与 video 分类器。只要返回值不是 none,即直接采用该媒体类型。
第二阶段:应用文本回退判定
仅当第一阶段返回 none 时:
- 显式拒绝最终扩展名为
.html、.htm与.xhtml的对象。 - 将最终扩展名(忽略大小写)与
.log、.txt、.json、.xml进行匹配。 - 规范化
Content-Type:剥离参数(如; charset=...)、去除首尾空白并转为小写。 - 将规范化后的 MIME 类型与
text/plain、application/json、application/xml、text/xml进行精确匹配。
只要命中允许的扩展名(且未被规则 1 排除)或命中允许的精确 MIME 类型,即判定为 text:
| 文件名与 MIME | 判定结果 | 原因 |
|---|---|---|
report.txt + image/png |
image |
既有媒体分类优先 |
report.json + application/pdf |
pdf |
既有媒体分类优先 |
server.LOG + application/octet-stream |
text |
命中允许的扩展名(大小写不敏感) |
无扩展名 + application/json; charset=utf-8 |
text |
命中规范化后的精确 MIME |
page.html + text/plain |
none |
活动文档扩展名被显式排除 |
page.txt + text/html |
text |
扩展名准入;HTML 源码仅作为惰性纯文本展示 |
notes.md + text/plain |
text |
精确 MIME 准入原始纯文本 |
image.svg + image/svg+xml |
image |
走既有图片预览路径 |
资源上限契约
二进制读取硬上限定义为:
恰好 1,048,576 字节(1 MiB)的对象允许预览;1,048,577 字节则作为超限拒绝。
已知对象大小
- 若选中版本的已知大小超过
1,048,576字节,打开弹窗时直接进入 Too Large(文件过大)状态,不发起正文下载请求。点击 Retry(重试)会使用有界Range请求重新探测,以应对列表元数据过期的情况。 - 已知大小为
0字节的对象仍会发起有界请求以验证权限与对象存在性,收到空200 OK后展示明确的 File is empty(文件为空)状态。 - 缺失大小元数据(
undefined)必须保留为未知状态,不能通过布尔回退强制转换为0,并进入有界流式读取路径。
有界请求与哨兵字节
对于已知不超过 1 MiB 或大小未知的对象,客户端发送:
请求 1,048,576 + 1 字节是为了把第 1,048,577 个字节作为超限哨兵(sentinel byte)。客户端执行以下流程:
- 响应到达后立即检查
Content-Range与Content-Length响应头。 - 通过
ReadableStream流式读取正文,绝不直接调用response.text()或构造无界Blob。 - 内存中最多保留
1,048,577字节。 - 一旦读到第
1,048,577个哨兵字节(包括反向代理忽略Range并返回完整200 OK的情况),立即中止流读取。 - 仅当流正常结束且证明完整对象不超过
1,048,576字节时,才执行解码与渲染。
请求标识与取消生命周期
每个预览请求由 bucket + object name + versionId 唯一标识,并保留同源凭据、Console 子路径前缀、version_id 查询参数以及匿名模式下的 X-Anonymous: 1 请求头。关闭弹窗、切换对象或版本、切换存储桶或组件卸载时,会同时触发 AbortController 中止信号与代次令牌(generation token)失效检查,防止已读完但较晚完成解码的旧响应覆盖新对象的预览。主动中止的请求静默清理,不弹出错误提示。
编码与保真契约
文本解码仅支持严格模式的 UTF-8:
- 自动剥离前导 UTF-8 BOM(
EF BB BF),不在界面上渲染零宽字符; - 原样保留多字节 Unicode 字符、emoji、制表符、
LF与CRLF换行; - 遇到非法 UTF-8 字节序列时直接抛出解码错误并进入 Invalid UTF-8 / Binary 状态,绝不插入
U+FFFD替换字符; - 解码结果中一旦包含
NUL(\0)字符,立即作为二进制内容拒绝。
安全渲染器与界面状态
成功状态仅在 <pre>{content}</pre> 中渲染单一 React 文本节点,使用等宽字体、保留空白字符、默认禁用自动换行(white-space: pre,以保持对齐日志的可读性,并避免超长单行 JSON 引发昂贵的浏览器排版计算)、双向滚动条以及原生键盘焦点支持。预览路由中残留的通用 <iframe> 回退分支被彻底移除。
界面状态与权限控制
在对象列表与对象详情抽屉中,当且仅当同时满足以下条件时才启用 Preview 按钮:
预览弹窗明确区分 Loading(加载中)、Success(成功展示源码与下载按钮)、Empty(0 字节空文件提示)、Too Large(超过 1 MiB,展示对象大小、上限说明、Download 与有界 Retry)、Invalid UTF-8 / Binary(编码非法或二进制说明与 Download)、Forbidden(403)、Not Found(404) 以及 Network / Server Error。任何 HTTP 错误响应体都绝不会被解码展示为预览内容。
功能与安全需求
功能需求
- FR1–FR3: 保留现有媒体与 PDF 分类优先级,按两阶段扩展名/MIME 矩阵判定文本预览资格,并将不超过 1 MiB 的完整对象渲染为严格 UTF-8 源码。
- FR4–FR6: 超限对象不渲染半截内容,空文件展示独立状态,且元数据、大小与正文请求严格绑定同一
versionId。 - FR7–FR9: 保持匿名访问、Console 子路径、列表与详情页一致的按钮准入判定,以及既有下载/分享行为不变。
安全需求
- SR1–SR4: 对象字节仅通过
<pre>文本节点进入 DOM(不含任何 HTML/XML/SVG/Markdown 解析器),最多保留1,048,577字节,并在关闭弹窗或切换对象时使旧请求失效。 - SR5–SR8: 拒绝非法 UTF-8 与
NUL字节,不在 Redux、本地存储、日志或错误体中留存预览文本,依赖服务端 S3 鉴权,且不放宽 CSP 或后端safeMimeTypes。
实现范围
- 重构预览分类器,保留既有媒体判定并将
text作为显式回退分支。 - 新增
PreviewText组件,内置流式字节上限、严格 UTF-8 解码、AbortController取消与显式状态机。 - 移除不可达的通用
<iframe>回退分支,统一对象列表与详情抽屉的 Preview 启用条件,并保留undefined未知大小状态。
被拒绝的替代方案
继续禁用文本预览
被拒原因:查看小型日志、状态文件与 JSON/XML 配置是高频日常运维操作,每次检查都强制下载到本地会造成不必要的操作摩擦。
复用旧版同源 Iframe
被拒原因:在同源 <iframe> 中加载上传者可控的内容会把可变的 MIME 元数据变成脚本执行边界,且无法限制浏览器内存占用。
新增专用后端预览 API
被拒原因:现有鉴权下载端点已经完整执行了 GetObject / GetObjectVersion 权限检查并支持 HTTP Range 请求(bytes=0-1048576)。另起炉灶只会复制权限与版本逻辑,而不会建立新的数据访问边界。
截断展示大文件的前 1 MiB
被拒原因:展示 50 MiB JSON 或 XML 文件的前 1 MiB 会呈现结构残缺的半截文档,且需要额外处理多字节 UTF-8 截断边界。“要么完整展示、要么不展示”使预览语义保持严密。
使用替换字符有损解码非法 UTF-8
被拒原因:静默插入 U+FFFD 会污染运维人员从预览框中复制出的文本内容。传统单字节编码或 UTF-16 文件应通过 Download 下载后使用专用工具查看。
自动解析并格式化 JSON
被拒原因:JSON.parse 再 JSON.stringify 会静默篡改超出 Number.MAX_SAFE_INTEGER 的 64 位整数、折叠重复键并改变原始空白格式。
引入 Monaco 等重型代码编辑器
被拒原因:对于有界 1 MiB 的只读预览而言,Web Worker、语法分析器与体积开销得不偿失;原生 <pre> 单文本节点更加轻量且易于安全审计。
验收矩阵与测试计划
分类矩阵测试
锁定两阶段扩展名与 MIME 判定矩阵的每一行、大小写不敏感、参数剥离、.html/.htm/.xhtml 排除以及媒体类型优先规则。
资源边界测试
覆盖 0 字节、1 字节、1,048,576 字节、1,048,577 字节、已知超限跳过首次请求、有界 Retry、206 Partial Content、服务端忽略 Range 返回 200 OK 以及关闭弹窗时的流取消。
编码与保真测试
覆盖 UTF-8 中文与 emoji、BOM 剥离、非法 UTF-8 序列、内嵌 NUL 字节、JSON 不安全大整数、重复键、原始空白以及 XML DOCTYPE/CDATA/stylesheet 指令。
安全载荷测试
验证包含 <script>、内联事件属性、<iframe>、SVG 事件处理器、XML 外部实体(XXE)与 XSLT 的载荷逐字出现在 <pre>.textContent 中,不创建任何子 DOM 节点,不触发任何脚本或外部网络请求。
权限与版本竞态测试
确认 401/403/404/416/5xx 响应体绝不进入预览面板,且旧请求的迟到响应无法覆盖新选中的对象或 versionId。
真实浏览器回归
在真实 SILO + Console 实例上通过 Playwright 验证中英文路由、亮色与暗色主题、桌面与窄屏视口,以及媒体、PDF、下载、分享与多版本工作流。
交付与完成门禁
该特性已在 pgsty/silo-console v2.2.0(PR #30)中发布,并随 SILO Server 20260903 内嵌交付,关闭 pgsty/silo#17。
利弊权衡
本设计选择明确的准入范围而非通用浏览器查看器、选择完整小文件而非截断片段、选择原文保真而非自动格式化、选择严格 UTF-8 而非静默有损解码、选择单一惰性文本节点而非重型代码编辑器。超过 1 MiB 的大型日志与非 UTF-8 编码文件仍需点击 Download 下载查看——这一刻意的边界取舍,使得 Console 文本预览足够精简以便安全审计,也足够坚固以供生产环境信赖。
设计评审记录
本方案在合入 silo-console#30 前完成了产品范围、前端安全与 CSP 隔离以及 S3 Range/多版本语义三轮评审。
35 - 数据库通知统一连接串:#53 的兼容性边界
随 SILO Server 20260903 发布的提交 f1ba68358 修复了 SILO #53,明确了 PostgreSQL 与 MySQL 桶通知目标的兼容性边界、旧配置迁移行为与启动期错误处理契约。
最终决策
SILO 保留 PostgreSQL 与 MySQL 通知目标(notification target),但每种数据库仅支持单一的规范配置键:
- PostgreSQL 必须提供完整的
connection_string; - MySQL 必须提供完整的
dsn_string。
2020 年之前的五字段形式——host、port、username、password、database——继续作为当前 KV 配置系统不支持的格式。SILO 既不重新注册这些键,也不在旧 JSON 配置迁移时自动将它们拼接合成为 DSN。
旧配置迁移契约按三类输入状态处理:
| 旧 target 状态 | 迁移结果 |
|---|---|
未启用(Enable == false) |
忽略,不生成任何 KV 配置项。 |
已启用,且含有非空 connection_string 或 dsn_string |
仅迁移规范连接串和其他已注册的 target 设置。 |
| 已启用,但仅有离散连接字段 | 在新配置生效前拒绝迁移并中止服务器启动;错误信息明确指出子系统与 target 名称及修复方法,且绝不打印任何凭据内容。 |
背景
SILO 从上游继承了两代数据库通知配置方式。在引入 KV 配置体系之前,旧版 JSON 配置既可以保存完整连接串,也可以使用五个离散字段:
当前 KV 配置体系则仅暴露驱动原生的连接串键:
上游早在 RELEASE.2020-04-10T03-34-42Z 中就已废弃这五个离散字段,并要求运维人员迁移到 connection_string 或 dsn_string。SILO 当前的命令行帮助表、环境变量文档、示例以及 Console 界面也均将完整连接串作为唯一正式接口。
问题本质
在 f1ba68358 之前,旧配置迁移辅助函数 SetNotifyPostgres 与 SetNotifyMySQL 会把两种形式同时写入新 KV 配置。即使旧 JSON target 中已经配置了完整的 connection_string 或 dsn_string,迁移器仍会一并写出全部五个离散键(通常为空字符串)。
新解析器随后会拒绝这些键,因为 DefaultPostgresKVS 与 DefaultMySQLKVS 均未注册它们,而 CheckValidKeys 仅检查键名是否存在,不区分值是否为空。因此新旧两种来源格式在迁移后都会失败:
通知子系统的初始化逻辑进一步放大了这一故障。FetchEnabledTargets 对所有通知子系统采用快速失败(fail-fast)策略:第一个校验失败的子系统会直接返回错误和空的 target 列表。上层调用方仅记录一行错误日志便继续启动对象存储服务,导致同一集群上原本健康的 Webhook、Kafka、NATS 等其他通知目标也全部被连带禁用。
仅仅让两个迁移函数返回错误还不足以彻底修复该行为。错误虽会沿 readConfigWithoutMigrate 与 initConfig 向上传播,但 initConfigSubsystem 原先会将不可重试的配置错误降级记录为 "some features may be missing" 日志并返回成功。随后服务端会在未设置 globalServerConfig 的状态下继续启动——不仅桶通知全部失效,区域(region)、存储类型、压缩、身份认证及其他持久化配置也可能全部缺失。反之,若将该错误标记为可重试,服务端又会在静态配置错误上陷入死循环。因此,必须将数据库迁移失败定义为不可重试的类型化致命错误(LegacyDatabaseTargetError),一路传递至启动边界并立即终止进程。
修复前的降级行为尤为危险:由于普通 S3 对象读写依然正常,运维人员看到的是表面健康的 S3 服务,而所有已配置的事件通知管道均已静默中断。此外,未注册的 password 键缺少敏感字段元数据,可能被明文复制进健康检查或诊断包中;而正式注册的 connection_string 与 dsn_string 则均按敏感值进行脱敏处理。
为什么第一版修复被回滚
20260804 中的第一版尝试(SN-2026-019)曾在 DefaultPostgresKVS 与 DefaultMySQLKVS 中重新注册了这五个离散键,并让解析器读取它们。虽然这使迁移后的配置能够通过 CheckValidKeys,但却直接破坏了文档正式支持的完整连接串路径。
共享的 mcli admin config set 分词器通过扫描已注册的键名来识别字段边界,并不具备完整的引号语法感知能力。一旦 port 成为 notify_postgres 的已注册顶层键,下面这条合法的引号内连接串就会被误识别为包含第二个顶层 KV 字段:
分词器会在引号内部的 port= 处直接切分,将 connection_string 截断为 "host=db,再把剩余部分交给整型的 port 解析器,最终报出 invalid port 错误。
在当前共享分词器语法下,注册 host、port、password 等常见词会导致连接串内部参数语法与顶层 KV 语法发生直接冲突。因此第一版注册方案被彻底回滚,改为严格执行单一连接串边界。
产品判断
数据库通知目标无需额外部署外部消息总线即可在数据库中维护对象命名空间视图或访问审计流水,对于小型部署及已运行 PostgreSQL / MySQL 的环境依然具有实用价值。
相比之下,五字段连接参数模型的表达能力十分受限,无法表达常见的数据库驱动选项:TLS 模式与证书路径、连接超时、应用名称、Unix Socket、PostgreSQL 多主机容灾以及 MySQL 驱动参数。同时支持两种格式还会制造优先级、配置合并、密码脱敏与测试矩阵的歧义。由 SILO 负责通知投递语义、由数据库驱动负责原生连接串语法,才是最清晰的抽象边界。
目标
- 将
connection_string与dsn_string确立为数据库通知唯一受支持的在线配置键。 - 允许已包含非空规范连接串的旧 JSON target 无损跨过迁移边界。
- 在仅含离散字段的已启用旧 target 生成半成品或非法 KV 配置之前明确拒绝。
- 将 #53“服务看似健康、全部通知静默失效”的运行时故障模式,替换为必须先修正才能启动的显式启动期失败(
LegacyDatabaseTargetError)。 - 确保迁移错误、日志、健康报告与诊断包均不会泄露数据库密码。
- 从未注册配置键写入审计(
knownUnregisteredWrites)中彻底移除 Postgres/MySQL 的十条例外。 - 在发布说明与迁移文档中明确兼容性边界与运维修复步骤。
非目标
- 在当前 KV 接口中同时支持 DSN 与数据库离散字段;
- 在旧配置迁移时自动从离散字段拼接合成 DSN;
- 重写共享 KV 分词器;
- 在本补丁中改变
FetchEnabledTargets的跨子系统 fail-fast 语义; - 静默跳过已启用的非法数据库 target 并以残缺的通知覆盖继续运行;
- 移除 PostgreSQL 或 MySQL 通知功能;
- 删除用于解码旧 JSON 并识别不受支持离散输入的旧结构体字段;
- 修改除 PostgreSQL 和 MySQL 之外其他八个旧通知 setter 的既有错误处理方式。
功能需求
当前配置
notify_postgres仅接受connection_string;notify_mysql仅接受dsn_string。- 五个离散键(
host、port、username、password、database)保持未注册状态,并被当前配置命令拒绝。 - 完整连接串继续支持数据库驱动原生语法,包括引号内出现
host=、port=、user=、password=、database=等子串的情形。 - 旧环境变量
MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE及其 MySQL 对应变量保持未接线和不受支持状态。
旧配置迁移
- 当旧 target 未启用(
Enable == false)时,SetNotifyPostgres与SetNotifyMySQL直接返回nil,不生成任何 KV target。 - 对已启用的 target,
SetNotifyPostgres要求非空ConnectionString且只写出已注册的 Postgres 键;若规范连接串与离散字段同时存在,则以规范连接串为准并丢弃所有离散值。 SetNotifyMySQL对DSN执行相同规则。- 两个迁移函数均不得写出
host、port、username、password或database。 - 当已启用 target 缺少规范连接串时,返回类型化的
LegacyDatabaseTargetError,指明子系统与 target 名称但不包含任何字段值:
cmd/config-migrate.go检查并向上传播两个数据库迁移函数的错误,拒绝启用或持久化半迁移配置。initConfigSubsystem将LegacyDatabaseTargetError作为不可重试的致命错误直接返回给serverMain。
操作者修复路径
由于旧 JSON 配置只有在迁移成功后才会持久化为新 KV 配置,任何仍在读取旧格式 JSON 来源文件的部署在每次启动时都会重新进入迁移逻辑。遇到该启动错误的运维人员可按以下四条路径之一处理:
- 切换到 SILO 前转换: 在兼容的过渡版本上将旧离散字段替换为
connection_string或dsn_string,验证通知正常后再升级至 SILO; - 先禁用再重建: 禁用或删除旧数据库 target,完成服务端升级后,使用规范连接串重新创建 target;
- 全新安装: 直接使用
connection_string或dsn_string创建 target,不涉及旧配置迁移; - 仍在读取旧 JSON 的存量部署: 先备份来源配置,在升级至 SILO Server 20260903 及更新版本前转换、禁用或移除该数据库 target。
可用性权衡
本设计有意将不受支持的旧数据库通知配置从“降级启动”改为“启动硬失败”。虽然一台此前仍能处理对象读写(但通知已全部静默死亡)的服务器在升级后会拒绝启动,但对象存储表面健康、已配置的事件出口却全部消失,会导致静默且不可逆的下游事件丢失。用明确、可操作的启动前置检查替代隐患重重的静默降级更加安全。
安全要求
LegacyDatabaseTargetError绝不格式化输出旧参数结构体或其中任何字段值。- 回归测试注入哨兵密码(sentinel password),并断言返回错误和捕获日志中均不包含该哨兵值。
- 迁移输出仅包含已注册的敏感连接串键(
connection_string与dsn_string),不生成独立的password键。 - 若受影响部署曾在修复前导出并对外提供过诊断包,建议轮换对应的数据库密码。
评估与否决的备选方案
注册并解析五个离散字段
优点: 保留 2020 年前的旧 JSON 字段形式,并复用现存结构体字段。
否决原因: 注册 host、port、password 等常见词会被共享分词器识别为顶层键,直接截断引号内包含 port= 等子串的合法 connection_string。
迁移时自动拼接生成规范连接串
优点: 自动兼容仅配置了离散字段的早期安装。
否决原因: 需要为早在 2020 年即已废弃的输入格式永久承担 PostgreSQL 转义引用、MySQL DSN 编码、Unix Socket、IPv6 地址括号及驱动默认值的代码与测试维护负担。
仅跳过不支持的数据库 target
优点: 让对象存储服务与其他通知 target 继续运行。
否决原因: 静默丢弃用户已启用的事件通知出口会造成不可见的事件丢失。
修改全局 FetchEnabledTargets 的 fail-fast 行为
优点: 隔离不同通知子系统之间的故障半径。
否决原因: 既无法修复数据库迁移函数本身,也无法关闭未脱敏 password 键的暴露路径,且会改变全系统的通知启动错误语义。
彻底删除数据库通知 target
优点: 移除全部数据库通知专用代码维护面。
否决原因: PostgreSQL 与 MySQL 通知目标本身功能自洽且实用,缺陷仅在于过时的五字段配置形式。
实现范围
服务端提交 f1ba68358 仅修改 internal/config/notify/legacy.go、cmd/config-migrate.go 与 cmd/server-main.go 等 6 个文件:
- 两个数据库旧配置 setter 仅输出规范已注册键,并在已启用 target 缺少规范连接串时返回不携带字段值的
LegacyDatabaseTargetError。 cmd/config-migrate.go捕获并传播这两个数据库迁移错误。cmd/server-main.go中的initConfigSubsystem将LegacyDatabaseTargetError作为不可重试的致命错误直接返回,由serverMain通过logger.FatalIf终止启动。- 从
knownUnregisteredWrites中移除全部 10 条 Postgres/MySQL 例外。
验收标准
实现通过了以下全部 12 项验收标准:
- 含完整
connection_string的旧 PostgreSQL target 可正常迁移、通过CheckValidKeys,并由GetNotifyPostgres原样返回。 - 含完整
dsn_string的旧 MySQL target 完成同等验证。 - 两类已启用的纯离散字段 target 均在初始化前失败,返回包含子系统与 target 名称的
LegacyDatabaseTargetError并中止启动。 - 缺失连接串和畸形连接串的错误均不包含哨兵主机名、用户名、密码、数据库名或 DSN 值。
- 未启用的离散旧 target 不生成配置项,也不阻塞启动。
- 迁移后的 KVS 不包含任何十个离散键(包括空值形式)。
- 旧 target 同时包含规范连接串与冲突离散值时,仅迁移规范连接串,输出中不含任何离散哨兵值。
- 使用真实
DefaultPostgresKVS和DefaultMySQLKVS键集的SetKVS回归测试,能够正确解析引号内包含port=、host=、password=的完整连接串。 - 同时包含健康 Webhook、Kafka、NATS target 与非法旧数据库 target 的配置在
readConfigWithoutMigrate阶段即返回错误,不持久化、也不激活任何半成品配置。 initConfigSubsystem直接返回类型化迁移错误,既不记录后继续,也不进入重试循环。knownUnregisteredWrites不再包含 Postgres/MySQL 例外。- 全部聚焦与常规测试套件通过:
实现结果与发布边界
提交 f1ba68358 已随 SILO Server 20260903 正式发布。跨仓库核对确认 pgsty/mc、pgsty/silo-pkg 与 pgsty/silo-console 均无需代码修改:客户端原样转发配置文本,silo-pkg 不包含通知配置 schema,而 Console 已将表单序列化为规范的 connection_string 或 dsn_string。
发布与兼容性声明
SILO 数据库通知要求 PostgreSQL 使用 connection_string、MySQL 使用 dsn_string。2020 年前的离散 host/port/username/password/database 形式不会被迁移;在升级至 SILO Server 20260903 及更新版本前,请先转换、禁用或移除这类旧数据库 target。
设计审查校准记录
初稿经设计审查校准后,补齐了穿过 initConfigSubsystem 至 ExitFunc(1) 的致命错误传播链路、覆盖了仍在读取旧 JSON 文件的存量部署、明确了规范连接串相对于残留离散字段的优先级,并通过了完整 cmd 测试套件与 -race 验证。
36 - 混合 EC 布局与迁移列表
本文记录 issue #261 与 PR #262 的审查结论,本文已按 #276 后的 main 0fc5a0b5 更新。实际修复通过维护者 PR #267 合入,#262 是后来被取代的重复 PR。
先说结论
问题成立。一次成功的降级覆写可能让一个无版本对象同时存在两种 EC 布局:在线磁盘上有更新的副本,离线磁盘上保留旧副本。GET 与 ListObjectsV2 能选出更新的可读多数;decommission 与 rebalance 使用 all-versions 遍历,此时元数据合并可能以旧布局为参考,只统计旧布局。旧布局低于 quorum 后,对象就从迁移列表中消失。迁移报告成功,旧池被移除后对象丢失。
PR #262 的修复方向正确。它只放宽已有 mixed-EC recount 的一个过窄条件,仍把 recount 限制在“每个非空流都只有一个普通 null version”的安全范围,并增加 quorum、流顺序、迁移和排除场景测试。该修复已经通过 #267 合入,#267 还增加了完整的池移除回归测试。
什么条件会触发
需要同时满足以下条件:
- 对象存储在纠删码集合中。
- 写入或覆写发生时有一个或多个磁盘不可用。
- 可用性优先的写入在在线磁盘上使用了不同的 parity 或 layout。
- 离线磁盘仍保留旧元数据。
- 在 decommission 或 rebalance 扫描前,修复没有完成。
只要某个 EC 组达到 quorum,对象仍然可读。问题是否出现还取决于旧、新元数据流到达的顺序,所以不同磁盘组合下表现会有差异,但底层状态本身是确定的。
什么时候引入
混合布局是可用性优先降级写入的自然结果。SILO 相关的列表修复在 2026-10-16 的 e90cb2067 中引入,用于处理更广泛的列表一致性问题。该修复只为 latest-only 读取打开了 recount;all-versions 的迁移调用仍使用旧选择路径。
于是出现了分裂契约:
| 调用方 | 请求模式 | main 上的行为 |
|---|---|---|
| GET 与 ListObjectsV2 | latest-only | 对符合条件的混合 EC 组重新计数,选出可读 quorum。 |
| ListObjectVersions、decommission、rebalance 及部分 Walk 调用 | all versions | 普通合并失败后停止,可读多数被少数旧布局遮住。 |
所以这个缺陷来自 2026-10-16 修复的范围没有覆盖完整调用链。底层降级元数据状态可以更早存在;真正暴露数据丢失路径的是只有一类调用方学会了恢复。
根因,用简单的话说
假设有 16 个磁盘:4 个磁盘仍保存旧的 12+4 元数据,12 个磁盘保存新的 8+8 元数据。两组都描述同一个 null version,但 EC header 不同。
合并逻辑按顺序查看元数据流。看到差异后,它可能以当前 EC 作为参考,只统计匹配该 EC 的条目。旧组只有 4 个,达不到 8 的 quorum,于是返回空结果。latest-only 读取已有的第二次 recount 会发现新组有 12 个,能够达到 quorum。
迁移路径传入的 requestedVersions 是 0,而 mixed-EC fallback 原来被 requestedVersions 等于 1 的条件保护。于是同一份元数据对 GET 是“可读对象”,对 decommission 却是“没有对象”。
这个保护条件不能简单删除所有安全边界。不能把任意 EC 组、版本历史、UUID 冲突、删除标记、free version,或 legacy/partial header 混在一起。安全范围是:每个非空流只有一个普通 null version,并且有现代 EC header。此时列最新版本与列全部版本实际上是在问同一个逻辑对象。
影响
操作员在移除存储池前看到的都是正常的:
- GET 返回新数据。
- ListObjectsV2 返回对象。
- decommission 和末尾检查都看不到对象。
- rebalance 把对象留在源池。
- 无版本或 suspended bucket 的 ListObjectVersions 也可能漏掉对象。
- 旧池移除后,GET 和两个列表都找不到对象。
问题严重之处在于迁移状态会错误地报告成功。旧池一旦删除,通常无法再重试恢复。
备选方案
方案一:迁移前必须先成功 heal
这可以作为运维缓解,但不是完整修复。磁盘可能一直不可用,MRF 可能放弃;旧元数据虽然有效,GET 也不一定会排队 heal。如果把所有迁移都阻塞到布局统一,池退役就会依赖无关的磁盘恢复。
方案二:让迁移调用方统一使用 latest-only 列表
这会把列表策略复制到 decommission 和 rebalance 中,并可能改变它们处理版本历史的方式,还会留下 ListObjectVersions 与 Walk 的不一致。
方案三:合并所有 EC 组或降低 quorum
不安全。两个都低于 quorum 的组不能因为对象名相同就合票;降低 quorum 可能选出无法重建的数据。
方案四:扩展现有的窄范围 recount
这是最小的安全改动。recount 只在普通选择失败后运行,只对非 strict 读取生效,并且只处理每个流一个普通 null version。PR #262 正是这个方案。
PR 判断
PR #262 去掉 mixed-EC 条件对 latest-only 模式的依赖,并增加:
- requestedVersions 为 1 与 0 时相同的选择断言;
- strict、UUID、delete marker、free version、history、legacy、partial EC 等排除场景;
- decommission 与 rebalance 的迁移回归测试;
- 将新回归测试加入 race workflow。
聚焦基线测试在 main 上会在可读混合 EC 场景失败,在 PR 上通过。新增测试也保留了重要的负面断言:低于 quorum 的组不能被错误合并。该补丁不改变 API、配置、磁盘格式、指标或依赖。
合入后的实现符合原建议:这个窄范围 recount 充分解决 #261,同时没有引入可能掩盖不安全元数据状态的广泛修复机制。原 issue 已标记完成,#262 已因被 #267 取代而关闭。
剩余边界
补丁只负责选出可读组,不负责修复旧组。离线磁盘恢复后仍应执行 heal。它也无法恢复旧版本中已经完成错误 decommission 后丢失的对象。其他畸形或混合版本历史仍按保守方式处理,这是刻意保留的安全边界。
验证参考
37 - 多池删除:失败时保住最新副本
2026 年 10 月 24 日决定:合并修正后的 PR #257(已随 Server 20261026 发布)。 提交 aafe4f8886 修复了 issue #256 报告的多池删除缺陷,并补齐请求开始之后的执行期故障保护。核心不变量是:先完成其他池的旧副本清理,再删除决定当前读取结果的权威副本。
Denis Babchuk 报告了该问题并提交了初始补丁。代码评审复现了故障,发现批量删除在通过在线盘数预检查后仍存在执行期故障窗口,随后补充分阶段执行、逐项响应身份保持、部分删除重试以及共享分层对象保护。
背景与前置工作
SILO 采用按剩余容量加权的选池策略。覆盖写入无版本对象时,新对象可能写入池 B,而池 A 中仍保留旧物理副本。读取路径会跨池选择最新的逻辑对象,因此旧副本平时不可见;但一旦后续删除中途遭遇故障,旧副本就可能重新暴露。
此前合入的多池一致性工作与本次修改的关系如下:
| 已有工作 | 保护范围 | 与本次修改的关系 |
|---|---|---|
| #178 | 条件删除和指定版本操作的共享对象锁与跨池协调 | 提供本次复用的锁与协调基础 |
| #199,由 #207 修复 | 条件 PUT 针对跨池当前逻辑对象判断条件 |
保留按容量加权的分配策略 |
| #248,随 Server 20261018 合入 | 跨池迁移不得用旧载荷覆盖新副本,也不得丢失删除标记 | 保护 rebalance 与 decommission 迁移路径 |
| #245,已关闭未合并 | 提议覆盖写优先留在原池 | 两轮实验均未达到性能目标;本次修复不依赖覆盖亲和 |
既有协调机制尚未覆盖普通无条件删除与批量删除。共享锁与迁移设计详见多池对象一致性,保留原分配器的评估过程详见多池覆盖亲和。
故障现象:删除失败后,已被覆盖的旧值重新可见
假设初始状态如下:
若两个池并行执行删除,池 B 可能先完成删除,池 A 随后返回 I/O 错误。此时单对象 DELETE 虽然向客户端返回失败,但最新写入的 "latest" 已被物理移除,下一次 GET 会读回已被覆盖的 "old"。
Issue #256 报告了四类具体表现:
| 操作 | 原有故障行为 |
|---|---|
普通无版本 DELETE |
删除最新副本后返回失败,旧池中的旧值重新可见 |
批量 DeleteObjects |
某池删除成功掩盖了另一池的删除失败 |
| 批量显式版本删除 | 指定版本仍残留在另一池,却向客户端返回成功 |
版本化单个 DELETE |
在不满足写 quorum 的持有池尝试创建删除标记,未切换到另一可读可写的持有池 |
运行作者提交的 11 个回归子测试:基线 main 有 10 项失败,初始 PR 全部通过。不过,代码评审进一步发现批量删除在通过事前检查后仍存在执行期故障窗口。
为什么事前检查不够
初始补丁在批量删除开始前检查目标纠删集是否有足够的在线磁盘,能够提前拦截已知的离线 quorum 缺失,但无法应对预检查通过后在元数据读写或磁盘删除阶段才发生的 I/O 错误。
评审构造了 45 个故障注入场景:预检查时所有磁盘均在线,先放行最新副本所在池完成删除,再在旧副本所在池的 DeleteVersions 内注入故障(覆盖模拟 EIO、模拟存储断连与真实文件权限错误,遍历两池和三池下权威副本的每个位置,以及无版本、显式 null 与 UUID 版本对象):
- 基线
main(000e653038):在全部 45 个场景中吞掉了逐项删除错误。 - 初始 PR(
a30270d9ba):能返回错误,但在全部 30 个无版本与显式null场景中删掉了最新副本,导致旧值暴露;15 个 UUID 场景因同版本副本内容相同且仍有副本保留,未丢失最新值。
评审同时发现了另一处响应构造缺陷:提前失败的 DeletedObject 遗漏了 ReplicationState,而 HTTP handler 需要依赖该字段还原原始请求项身份。当签名批量请求包含两个受阻的复制版本清除项时,响应会错误地拆成一个错误项和一个空的删除成功项。仅保留 key 和版本 ID 不足以保证错误项准确对应。
删除一致性契约
修复在既有共享对象写锁下,逐项维护以下执行顺序:
- 解析本次操作指定的对象或版本,确定其权威副本所在池。
- 先完成所有非权威池的旧副本清理(阶段 0),再尝试删除权威副本(阶段 1)。
- 若非权威池清理失败,立即向对应的请求项返回错误,且不触碰该项的权威副本。
- 同一批次中互不相关的其他对象正常完成删除。
各边界行为说明如下:
- 非跨项事务回滚:若某对象在三个池中都有副本,阶段 0 可能先删掉其中一份旧副本,随后在清理第二份旧副本时失败。此时阶段 1 不会执行,健康的权威副本仍然完好;同一批次中已成功的其他对象不会回滚。
- 权威副本自身的执行期错误:当阶段 0 全部成功、进入阶段 1 删除权威副本时,若底层磁盘在阶段 1 部分写入后报错,调用方应在存储恢复后检查对象状态并重试。两阶段顺序解决的核心问题是绝不在已知其他池清理失败的情况下提前删掉权威副本。
- 显式历史版本删除:显式指定
versionId删除某个历史版本或删除标记后,更早的保留版本成为当前版本,这属于正常的 S3 版本控制语义。
最终实现
单个对象删除
普通无版本删除按先旧后新的顺序依次清理各池副本,遇到错误立即停止。条件删除与显式版本删除继续走已有的 deleteObjectReconciled。若选中的无版本对象已完成分层(tier)迁移,同样进入 deleteObjectReconciled,防止本地清理破坏共享的远端分层引用。
普通单对象创建删除标记时,若多个池持有该对象,协调层会跳过已知不满足删除 quorum 的池,优先选择通过检查的可读持有池写入删除标记。
批量数据删除分成两个阶段
新增的 deleteObjectsReconciled 直接复用既有命名空间写锁和 objectPoolInfos 解析器,不引入新的锁域。
对象元数据查询并发上限为 32。每项查询读取所有池中的指定版本并按 ModTime 排序;时间戳完全相同时以较小的池索引决胜,与读取路径保持一致。不可读元数据直接作为错误处理,绝不视为“该池无此对象”。
每个阶段内部仍跨池并行执行,继续调用现有的纠删集与磁盘层批量接口。由于同一批次内不同对象的权威副本可能分布在不同存储池,固定全局池顺序无法满足要求,必须按每项各自的权威池划分阶段 0 与阶段 1。
阶段 0 同样包含查询时报告对象不存在的非权威池:读 quorum 看到“不存在”并不能证明某块暂时离线的磁盘上没有残留旧物理副本。若该池无法完成删除,对应请求项会保守返回错误。
所有结果从一开始就完整保留原始 key、版本 ID 和 ReplicationState,过滤与重新分批均通过显式下标映射还原请求顺序。在线磁盘预检查继续用于提前拒绝已知的 quorum 缺失,运行期安全性则由两阶段顺序保障。
版本配置与删除标记顺序
批量入口沿用底层版本判断规则,区分物理删除与删除标记创建:
| 请求 | 执行动作 |
|---|---|
显式 versionId=null 或 UUID |
物理删除指定版本;多池批量请求执行两阶段清理 |
| 无版本 ID,普通无版本 key | 两阶段物理删除对象数据 |
| 无版本 ID,已启用版本控制 | 创建删除标记,保留历史版本 |
| 无版本 ID,已暂停版本控制 | 创建或替换 null 删除标记 |
| 配置了前缀级版本规则 | 对当前 key 计算 PrefixEnabledFn,并结合暂停状态判断 |
物理删除先于删除标记创建执行,从而保证同一批次内先显式清除 null 版本、再为同一 key 创建新标记时的先后顺序。纯标记批次继续走既有路径(降级场景下的已知边界见下文兼容性、重试与运行边界)。
部分磁盘清理后的重试
当某池仅部分磁盘完成了版本删除、其余磁盘失败时,客户端发起重试会遇到第二类问题:一部分磁盘返回“删除成功”,已删过该版本的磁盘则返回“文件或版本不存在”。若将两类响应分开计票,两边都可能达不到 quorum。
erasureObjects.DeleteObjects 的修正仅针对纯物理删除将成功移除与 errFileNotFound / errFileVersionNotFound 合并计入删除 quorum(未知 I/O 错误不计入)。对于创建删除标记或更新复制状态的操作,仍要求实际写入成功达到 quorum,避免把“目标不存在”误判为新标记或元数据状态已落盘。
单版本与批量删除路径(含此处的删除标记写入)的 quorum 为 floor(N/2) + 1(N 为纠删集磁盘数)。
共享分层对象
不同池的物理副本可能指向同一个远端分层对象(包括某份副本已临时恢复到本地的情况)。清理非权威本地副本时,只要保留的权威副本仍依赖该远端对象,就不能将其排入远端垃圾回收。
因此,批量请求中包含已完成分层迁移副本的项会转入既有的逐对象协调路径,完整保留 SkipFreeVersion、复制状态与前缀版本选项,并按对象串行执行(同时避免同一批次内的重复项并发改写元数据)。普通本地对象继续走两阶段批量路径。新增回归测试覆盖了共享与独立远端引用、已恢复副本、权威副本删除前失败以及显式 SkipFreeVersion 请求。
最终版本的验证
各演进提交与验证角色如下:
| 版本 | 角色 |
|---|---|
000e653038 |
对照基线 main(不含 #245 覆盖亲和) |
a30270d9ba |
初始 PR head,复现预检查后的执行期故障窗口 |
3c82748e14 |
补充分阶段批量清理、结果身份保持与部分删除重试 |
a2073d2514 |
最终 PR head,补齐共享分层对象保护 |
aafe4f8886 |
合并后的 main(源码树与 a2073d2514 一致) |
入库的故障测试在全盘在线状态下进入 DeleteVersions 并注入错误,同时断言返回正确的逐项错误且权威副本的删除尝试次数为零,并逐一验证故障期间的 GET、恢复后的 GET 与版本列表、重试成功及无关项正常删除:
| 覆盖范围 | 合并源码中的测试入口 |
|---|---|
预检查后 I/O 故障,两池/三池、全部权威位置、无版本/null/UUID |
TestPoolsBulkDeleteKeepsLatestOnIOFailure |
| 带复制状态的签名 HTTP 响应身份 | TestPoolsBulkDeleteReplicationIdentity |
| 混合 null 清除/标记、部分清理与重试、不同权威池 | 批量顺序与重试测试 |
相同时间戳、不可读元数据、共享锁/并发 PUT、对象层取消 |
选择与并发测试 |
| 物理删除 quorum 与标记/复制写入的区别 | TestBulkDeleteQuorumEvidence |
| 共享分层引用和已恢复对象 | TestPoolsRetiringCopyPreservesSharedTierObject |
| 原始单删、批量错误、已知 quorum 缺失与标记选池回归 | 原贡献的回归测试 |
新增回归测试在初始 PR 代码上可稳定复现失败;最终合入的删除、分层与 quorum 套件连续通过三轮 -race 测试,合并提交后再次通过复测。补充检查覆盖了单池对照组、前缀版本配置、目录与重复 key、Object Lock、版本清除及迁移回归。
最终 PR head 的 Go CI 与合并后 main 的 Go CI 均通过完整 cmd / internal 测试、race 测试、静态检查及 Linux、macOS、Windows 的 amd64 / arm64 交叉编译;DCO 与 VulnCheck 同步通过。
回归测试命令:
实机 mcli 验证使用单进程两池(每池四个目录盘):先用基线版本写入数据,重启切换至修复版验证读取、批量删除、UUID 版本删除、启用/暂停版本控制下的删除标记与新写入,最后切回旧服务端重新打开同一数据目录,升级与回退均正常。
性能:实测删除代价
性能基准直接对比基线 000e653038 与最终实现 a2073d2514(与合并后的 main 源码一致)。
测量方法
- 硬件环境:Apple M5 Max(18 物理核心、128 GiB 内存),单块 8 TB Apple SSD(APFS,测试时空间使用率约 95%,所有逻辑目录盘共享该物理 SSD)。
- 配置参数:每池 4 个目录盘(EC 2+2),4 KiB 对象,8 个并发 worker,
GOMAXPROCS=8,对象层直接调用(不含 HTTP、鉴权与跨节点 RPC)。 - 测试矩阵:5 种“池数/副本数”布局(此处的“副本”指一个逻辑对象在不同池中的物理副本数,而非纠删码分片),分别测试单对象
DELETE(每轮 1,024 次请求)与每批 64 项的批量删除(每轮 64 次请求、共 4,096 个对象)。 - 采样方式:每种布局和操作执行 3 对交替运行(基线→修复版、修复版→基线、基线→修复版),每轮重建相同初始布局(准备与清理不计入耗时)。共采集 60 个运行样本、30,720 次单删请求、1,920 次批删请求,累计删除 153,600 个逻辑对象,零请求错误。
结果与波动
吞吐量单位为“逻辑对象/秒”,表中给出三轮中位数及实测范围;配对变化为三对交替样本百分比变化的中位数。
| 池数/副本;操作 | 基线中位数 [范围] | 修复版中位数 [范围] | 配对变化 |
|---|---|---|---|
1 / 1;DELETE |
1,286 [1,193–1,292] | 1,347 [1,269–1,371] | +6.1% |
| 1 / 1;bulk 64 | 1,786 [1,739–1,809] | 1,818 [1,680–1,982] | +4.5% |
2 / 1;DELETE |
1,350 [1,272–1,363] | 1,304 [1,270–1,327] | −1.7% |
| 2 / 1;bulk 64 | 1,477 [1,468–1,740] | 1,453 [1,149–1,604] | −7.8% |
2 / 2;DELETE |
560 [546–641] | 534 [469–534] | −16.3% |
| 2 / 2;bulk 64 | 606 [528–695] | 620 [546–707] | +2.2% |
3 / 1;DELETE |
988 [722–994] | 1,053 [974–1,130] | +13.6% |
| 3 / 1;bulk 64 | 1,338 [1,269–1,383] | 1,120 [1,055–1,163] | −16.9% |
3 / 3;DELETE |
364 [323–371] | 354 [335–364] | −2.0% |
| 3 / 3;bulk 64 | 404 [367–450] | 365 [313–378] | −14.5% |
请求级延迟(单位:毫秒,bulk 64 为整批 64 个对象的单次请求耗时,方向为基线→修复版):
| 池数/副本;操作 | P50,ms | P99,ms |
|---|---|---|
1 / 1;DELETE |
5.69 → 5.50 | 15.06 → 14.37 |
| 1 / 1;bulk 64 | 277.28 → 280.00 | 406.23 → 385.23 |
2 / 1;DELETE |
5.46 → 5.47 | 14.09 → 14.88 |
| 2 / 1;bulk 64 | 317.10 → 346.26 | 635.16 → 463.78 |
2 / 2;DELETE |
12.91 → 13.76 | 35.86 → 45.54 |
| 2 / 2;bulk 64 | 823.19 → 752.78 | 1,315.81 → 1,297.48 |
3 / 1;DELETE |
7.23 → 6.42 | 22.09 → 20.20 |
| 3 / 1;bulk 64 | 361.73 → 444.34 | 669.84 → 732.27 |
3 / 3;DELETE |
20.35 → 21.00 | 50.75 → 48.92 |
| 3 / 3;bulk 64 | 1,236.42 → 1,392.63 | 1,697.38 → 2,079.27 |
P99 采用 nearest-rank 算法(每轮批量请求共 64 次,P99 接近单轮最大值)。单池控制组的调度路径未变,无性能回退;三池批量删除吞吐量下降约 15–17%,三池三副本的 P99 延迟增加约 22.5%;两池两副本的单对象删除吞吐下降 16.3%,同布局批量删除则在波动范围内。
代价来自哪里
单对象删除需要按先旧后新的顺序串行清理各池副本;批量删除则增加了跨池元数据解析以及阶段 0 到阶段 1 的先后等待(阶段内部仍保持跨池并行与磁盘批处理)。对于批次大小 $B$、池数 $P$、每纠删集磁盘数 $N$,元数据解析需读取 $B \times P$ 个池级对象状态,在线盘预检查至多检查 $B \times P \times N$ 个磁盘状态。由于共享 key 写锁贯穿上述流程,额外耗时也会相应延后针对同一 key 的并发写入。为守住删除失败不丢最新副本的一致性底线,这一多池删除开销是必要且可接受的。
兼容性、重试与运行边界
- 错误码与客户端重试:单对象删除的写 quorum 失败通常返回 HTTP 503;批量
DeleteObjects则在 HTTP 200 响应体内逐项返回SlowDownWrite等错误码。实测 S3 SDK 在MaxRetries=4下收到包含逐项SlowDownWrite的 HTTP 200 响应时不会自动重发整批请求,mcli rm则会直接报告失败项。调用方必须解析批量响应中的错误列表,在存储恢复后携带原versionId重试失败项。 - 并发与请求取消:既有共享对象写锁将多池删除与跨池写入串行化。HTTP 批量删除 handler 使用脱离客户端连接的后台 context,客户端中途断连不会中止已开始的批次。
- 暂停版本桶的降级批量标记边界:补充测试记录了一处在基线
main中既有的边界行为:在两池和三池布局下,仅含 UUID 历史版本的暂停版本桶在磁盘降级期间执行批量删除标记创建,待磁盘恢复后显式清除null标记仍可能返回 quorum 错误(一次运行暴露出保留的 UUID 历史版本,另一次运行保留了删除标记)。本次修复未改动纯标记批量路径的这一既有行为。 - Object Lock 与条件删除:Object Lock 与保留期权限检查保持不变;批量 XML
DeleteObjects的 ETag 条件仍不受支持,详见条件 DELETE。 - 升级与回退:本次修改不涉及 S3 参数、RPC 协议、磁盘格式、外部依赖、选池分配规则或配置开关变更。混合版本滚动升级期间,尚未升级的协调节点仍按旧逻辑处理其接收的删除请求;回退旧版本不会恢复此前已丢失的副本。
备选方案取舍
| 备选方案 | 不采纳原因 |
|---|---|
| 仅汇总各池错误 | 只能修复“删除失败却报成功”,无法挽回已被提前删掉的最新权威副本 |
| 仅做在线磁盘预检查 | 无法拦截预检查通过后在元数据读写或删除执行阶段才发生的磁盘 I/O 错误 |
| 按固定全局池顺序删除 | 同一批次内不同对象的权威副本可能位于不同存储池,固定池顺序无法兼顾 |
| 所有批量删除全部改为逐对象串行 | 不必要地放弃了普通本地对象的纠删集与磁盘层批处理吞吐;当前仅对含分层引用的对象走逐对象协调路径 |
| 依赖覆盖亲和(#245) | 覆盖亲和会带来常规小对象写入回退,且仍需处理存量跨池副本、迁移副本与中断清理,无法替代删除阶段的顺序保护 |
| 引入跨池回滚事务或配置开关 | 逐项两阶段清理已能保住权威副本,无需引入复杂的分布式回滚状态机或额外开关 |
38 - Go 1.27 TLS 默认值与 OIDC Discovery 故障诊断
SILO 工具链升级至 Go 1.27 后,Server TLS 修复 48e184652(“fix(tls): honor Go key exchange defaults across transports”,随 Server 20260916 发布)移除了 8 处 Server TLS 配置点上的显式椭圆曲线覆盖。本文记录 Go 1.27 在 TLS 层的变更细节、issue #154 排查总结的分阶段诊断方法,以及身份提供方(IdP)在启动阶段不可达时的健康检查端点行为。
关于发布后复测的深入机制分析——为什么 GODEBUG=tlsmlkem=0 只能关闭混合密钥交换而无法隐去新增的 12 字节 ML-DSA 签名算法扩展,以及应对 TLS 深度检测中间设备的完整配置方案——参见《写死 TLS 参数与握手兼容性》。
Go 1.27 在 TLS 层的变化
- 显式配置的
CurvePreferences会覆盖 ML-KEM 兼容开关。GODEBUG=tlsmlkem=0会从默认曲线集合中移除全部 ML-KEM 混合密钥交换方案;tlssecpmlkem=0则仅移除 Go 1.26 新增的 P-256/P-384 混合方案并保留X25519MLKEM768。但在 Go 1.27 中,只要应用显式设置了CurvePreferences,标准库就会原样保留该列表中的 ML-KEM 项,不再受上述GODEBUG开关控制。此前 SILO Server 有 8 处 TLS 配置显式设置了包含X25519MLKEM768的曲线列表;本次修复移除了这 8 处赋值并废弃相关辅助函数,使这些配置点直接沿用 Go 标准库默认曲线,恢复GODEBUG兼容开关的效果。跨仓库核查确认silo-pkg、mcli与 Console 客户端原本即使用标准库默认值;Console 的 HTTPS 服务端监听器则保留其独立的仅 P-256 策略。 ClientHello新增 ML-DSA 签名算法标识(0x0904–0x0906)。ML-DSA 属于签名算法而非密钥交换算法:关闭混合密钥交换不会移除ClientHello中的 ML-DSA 声明,因不识别 ML-DSA 标识而拦截握手的网关无法通过 ML-KEM 相关开关绕过。ClientHello报文体积增大。 基于同一套源码与依赖实测:Go 1.26.5 默认握手报文为 1497 字节;Go 1.27.1 默认握手报文为 1509 字节;配置tlsmlkem=0时,Go 1.26.5 配合旧显式曲线列表会生成不含 ML-KEM 的 275 字节ClientHello,而在 Go 1.27.1 下配合旧显式列表仍会生成含 ML-KEM 的 1509 字节报文。因此,仅升级编译器版本就会直接改变出站 TLS 握手载荷。- macOS 根证书信任库行为随主模块
go指令切换。 新启动的进程优先读取SSL_CERT_FILE/SSL_CERT_DIR还是系统 Keychain,取决于x509sslcertoverrideplatformGODEBUG 的默认值,而该默认值由顶层构建主模块的go指令决定:声明go 1.26的主模块在 macOS 上忽略这两个环境变量(始终优先使用系统 Keychain),声明go 1.27的主模块则会遵循这两个变量(不受底层依赖库模块的go版本影响)。在 macOS 上运维时需注意:一旦设置了其中任一环境变量,进程就会用指定文件或目录整体替换系统 Keychain 信任库;若变量指向过期或不完整的 CA 证书包,原本可通过 Keychain 验证的证书链就会失败,清除该环境变量即可恢复使用 Keychain。 - 未变更的 TLS 与平台基线。 TLS 协议版本、密码套件、证书链与主机名校验、代理环境变量解析及 HTTP/2 协商规则均保持不变。标准库响应体排空上限(256 KiB / 50 ms)等其余 Go 1.27 变更经审计均不影响 SILO。此外,Go 1.27 编译产物要求 macOS 13 或更高版本;Server、Console 与
mc的模块依赖图均要求 Go ≥ 1.27.1,不支持回退至旧版工具链编译。
为什么撤回了仅针对 OIDC 的局部补丁
排查初期曾拟定一个最小改动方案:仅在 OIDC discovery 使用的 HTTP transport 上清空 CurvePreferences。该方案最终被否决。原因在于同一个底层构造函数同时服务于身份插件、事件通知与 Lambda 连通性探测、审计 webhook 以及 S3 云存储后端分层——若只修改两处 OIDC 调用点,其余出站 TLS 客户端仍会停留在带硬编码曲线列表的状态。最终合入的提交统一移除了全部 8 个 Server 配置点的显式曲线覆盖,使兼容开关在所有出站与入站配置点一致生效,同时保持严格的证书校验,不引入协议降级或静默回退。
按连接阶段诊断 Discovery 故障
服务端 OIDC 启动链路依次为:进程启动 → 身份子系统初始化 → 请求 .well-known/openid-configuration(discovery)→ 请求 jwks_uri 拉取公钥集 → IAM 存储就绪 → Console 初始化。Console 界面上的 OIDC 配置校验同样复用这一服务端 transport。链路中任一环节失败都会导致 IAM 处于离线状态;即使 discovery 文档拉取成功,只要后续 JWKS 请求返回 503 或超时,IAM 依然无法就绪。
排查时可按连接中断所处的阶段快速定界:
- 发送 TLS
ClientHello后立即收到 TCP RST(tls_start后连接重置):重点排查入口网关或中间设备对ClientHello的处理——包括代理CONNECT策略、TLS 终止器,以及基于握手报文长度或扩展字段匹配的过滤规则。Go 1.27 的曲线与签名算法变化均集中在这一阶段。 - TLS 握手完成后收到重置或拒绝(
wrote_request后中断):说明 TLS 握手本身已通过,应排查 HTTP 层策略——例如 WAF 规则、User-Agent 白名单(服务端默认 UA 已随品牌重命名由MinIO改为Silo)以及七层路由配置。此时调整证书或密钥交换参数不会生效。 - x509 证书校验错误:核对对端实际返回的证书链、请求携带的 SNI,以及当前进程实际加载的根证书信任库(macOS 环境重点检查
SSL_CERT_FILE/SSL_CERT_DIR,见上文)。 - 诊断命令必须在与故障进程相同的网络命名空间与出口路径下执行——新启动的临时容器默认不共享故障容器的网络环境,应优先保证源 IP 与代理配置一致。
反映 IAM 真实状态的健康检查端点
当 IAM 处于离线状态时,/minio/health/live 与 /minio/health/ready 依然返回 HTTP 200,因为常规存活与就绪探针不检查身份子系统。真正检查身份系统初始化状态的端点是 /minio/health/cluster:当 IAM 未就绪时,该端点返回 503 Service Unavailable,并在响应头中附带 X-Minio-Server-Status: iam-offline。需要感知 IdP 连通性故障的监控告警应探测 /minio/health/cluster,并配合一次带认证的业务探针。
身份子系统具备自动恢复能力:初始化失败后会按 0–3 秒的随机间隔后台重试,一旦上游 IdP 恢复可达,IAM 无需重启进程即可自动上线(本地实测恢复耗时在亚秒级至 1.4 秒之间)。但后台重试无法绕过持续存在的握手兼容问题——被网关拒绝的 ClientHello 在每次重试时仍会被拒绝。
Discovery 与 JWKS 客户端的传输层参数
OIDC discovery 与 JWKS 客户端使用独立构造的 http.Transport,其关键参数如下:禁用 HTTP/2(不发送 ALPN h2,固定使用 HTTP/1.1);代理配置仅读取 HTTPS_PROXY 与 NO_PROXY(优先读取大写变量,忽略 ALL_PROXY);DNS 缓存刷新周期在 Kubernetes/Docker 环境下默认为 30 秒,其余环境默认为 10 分钟(可通过 DNS cache TTL 配置项覆盖);多 IP 拨号按解析顺序依次尝试而不做随机打乱;单次 TCP 拨号超时为 5 秒,TLS 握手超时为 10 秒,等待响应头超时为 1 分钟。需要注意,单次 discovery 或 JWKS 请求本身未设置整体请求超时(Request Timeout)——如果上游 IdP 持续缓慢响应,启动阶段的初始化会一直等待。
来源与验证说明
本文整理自 issue #154 的故障排查记录与九月份 Go 1.27 工具链全栈评审。文中所述的各项机制均通过合成实验(抓包比对 ClientHello、独立进程根证书探针及本地测试夹具复现)验证,并与 Go 1.27 官方发布说明及标准库 crypto/tls、crypto/x509 源码核对一致。
39 - Tier free version 是内部清理记录
本文记录 issue #263 与 PR #264 的审查结论,本文已按 #276 后的 main 0fc5a0b5 更新;#264 的合入提交记录为 0cfca29c。这个 PR 的三个提交是一组相互配合的修复,不是三个互不相关的 workaround。
先说结论
问题成立。free version 是 SILO 在删除 tier 远端对象期间留在 xl.meta 中的内部清理记录。它带有 delete-marker 类型和被删除版本的时间,但它不是用户版本。GET 已经忽略它,若干批处理路径却没有。
于是出现四类问题:
- 生命周期把 free entry 下方的真实版本当成 noncurrent,可能删错版本;
- ListObjectVersions 在一个 GET 可读的对象上方显示最新删除标记;
- ListObjectsV2 隐藏这个 key;
- decommission 与 rebalance 把 free record 当成真实删除标记搬走,旧池删除后对象变得不可读。
PR #264 同时修复生命周期视图、latest marker 判断、普通列表和池清理,并保留 healing 所需的全量视图。该 PR 已经合入,是继续审查 #266 的必要基础。
free version 到底是什么
tier 对象有两部分:
- hot pool 中的元数据;
- 配置的远端 tier 中的对象。
删除数据版本时,SILO 可能暂时无法删除远端对象。于是写入一个 free version,记录远端对象名、远端版本、tier 和删除时间。scanner 后续先删除远端对象,再删除这条本地记录。
客户端按 version ID 删除 tier 版本可能产生它;覆写 tiered null version 可能产生它;ILM 远端删除失败时也可能产生它。这个记录对 healing 和 tier 清理有用,但不属于 S3 版本历史。
引入时间与历史
free-version 机制由上游 a1df23051 引入,时间是 2021-06-30,用来跟踪 tier 内容删除。普通读路径长期以来已经在计算 IsLatest 和 successor time 时跳过 free entry。
之后,上游在 29a25a538 中把 free version 放进版本列表,并显示为删除标记,时间为 2024-06-07。原意是为了可观测性,让使用版本列表的操作员能看到 xl.meta 的所有条目。但这样就把内部清理记录暴露给了 S3 API,也让其他批处理调用方依赖“是否请求 free entry”。
因此这不是一个新引入的存储格式回归,而是同一表示被当成了两种东西:
- GET 把它当内部清理状态。
- scanner、list、Walk、decommission、rebalance 把它当对象版本。
根因,用简单的话说
假设对象有真实版本 v1、v2,最新的 tier 版本 v3 被删除。元数据可能近似如下:
free entry 带着 v3 的时间,所以排在 v2 之上。GET 跳过第一条并返回 v2。scanner 和版本列表没有跳过它:
- v2 被认为有 free entry 作为 successor,所以变成 noncurrent;
- free entry 被编码成删除标记;
- 迁移把这个标记搬到新池。
旧池移除后,新池中的标记遮住了 v2。远端对象引用也离开了 tier 清理路径。
影响
这是数据正确性与 API 正确性同时出问题:
- NoncurrentVersionExpiration 可能删除实际最新的真实版本。
- NewerNoncurrentVersions 可能保留错误数量的版本。
- ListObjectVersions 显示 DM 为 latest,但 GET 对下方真实版本返回 200。
- ListObjectsV2 可能漏掉一个可读 key。
- decommission 可能在把 free record 变成永久删除标记后报告完成。
- 如果远端对象尚未删除,搬走 free record 可能让 tier 数据失去清理引用。
- tier 不可用时,静默完成 decommission 是不安全的。
这会在正常的版本化 tier 工作流中发生:删除 tier 版本后,在 scanner 完成清理前启动迁移即可触发。
备选方案
只修 scanner
从非 free 版本重新计算 IsLatest 和 successor time,可以阻止最危险的误过期,但不能修复 S3 列表和迁移。decommission 仍可能产生删除标记。
只修列表与迁移
这会让 API 和迁移看起来正确,但 scanner 仍可能在 free entry 存在时删除错误版本,仍然不完整。
到处都保留 free entry
这能保留 #19878 的上游可观测性行为,但会把内部清理记录暴露为公共删除标记,让 GET、生命周期、列表和迁移互相矛盾。SILO 的维护目标是维护栈的逻辑对象行为,内部 tier 账本不应定义 S3 历史。
删除时立即清理 free entry
远端 tier 暂时不可用时会失败。free record 正是远端清理仍未完成的持久提醒,过早删除可能让远端数据永久失去引用。
建立两个明确视图
这是最小的完整设计:
- 普通列表、生命周期计算、Walk、decommission、rebalance 只看真实版本;
- healing 和 tier cleanup 可以请求全量条目;
- decommission 先删除 free entry 指向的远端对象,再删本地记录;tier 无法访问时失败并支持重试。
PR #264 实现了这个方案。
PR 判断
#264 的三个提交顺序是有逻辑的:
- getFileInfoVersions 在分离 free entry 后重新计算 IsLatest、SuccessorModTime 和 LatestModTime。
- latest-delete-marker 判断跳过 free entry。
- 普通 FileInfoVersions 排除 free entry;healing 使用显式的全量 helper;分页跳过只有 free entry 的 key;decommission 执行 tier cleanup 并使失败可重试。
新增测试在 main 上除有意保留的 healing guard 外都会失败,在 PR 上通过。覆盖了生命周期选择、ListObjectsV2、ListObjectVersions 分页、Walk、healing、decommission、rebalance 和 tier 不可用场景。
用户可见的变化是明确的:ListObjectVersions 不再把 free record 暴露成删除标记。这与 2024 上游行为存在兼容差异,但它恢复了对象的一致逻辑视图,阻止内部清理状态变成持久公共墓碑。SILO 的上游兼容目标是尽最大努力,维护栈本身才是发布门禁。
原审查建议采纳 #264 的整体方案,该决策已经落实到 main。它必须整体合入;只拿第一个或第二个提交,会把独立的正确性漏洞留到下一个批处理路径。
运维边界
decommission 遇到 free entry 时可能需要访问 tier。远端对象已经不存在时可以忽略,因为本地记录已经过时;tier 故障则不同:free record 必须保留,decommission 应失败,等待重试完成清理。这个行为比 main 更容易显露故障,但避免了悄悄丢失清理义务。
如果 free version 仍指向某个 tier,而该 tier 已经从配置中删除,这同样不是 tier 故障。free version 是其远端对象的唯一记录,因此会被保留;在以相同名称重新配置该 tier(mcli ilm tier add)之前,它所在存储池的 decommission 无法完成。从下一个版本开始,decommission 会先搬迁其他所有桶,然后以失败结束,错误信息会指出该 free version、它的远端对象和 tier;重试时从剩下的桶继续。在此之前,decommission 会停在第一个这样的桶上,之后的所有桶都留在该池中。
从下一个版本开始,远端清理与本地 free 记录的删除还会和 scanner 共用该对象的写者锁;本地删除只有在磁盘句柄、本地版本 ID、tier、远端对象和远端版本都与已删除远端对象的那条记录完全一致时,才会移除这条 free 记录。远端删除在独立于调用方、带自身超时的上下文中执行,因此即使 decommission 被取消,只要远端删除已经成功,本地记录仍会被移除。同样,当 decommission 遇到一个恢复副本移除被拒绝的分层版本时(例如对象受锁保护或规则已不再适用),它会连同 tier 元数据和恢复截止时间一起迁移该版本,而不是报错或丢失 tier 链接(5cb91e7d7)。
从下一个版本开始,mcli admin decommission cancel 在返回之前还会先把取消状态持久写入磁盘,因此正处在搬迁完桶、保存集群配置阶段的 decommission 会以 Canceled 结束,而不是 Complete。
这个修复无法识别或恢复旧版本错误 decommission 已经产生的普通删除标记,因为这些标记已经没有 free-version 标志。受影响对象需要从备份或其他权威副本恢复。
验证参考
40 - 鉴权前不做 I/O,Header 不授予权限
本文记录通过 PR #101(提交 938603458 至 04b097fd9)合入 SILO、编号为 SN-2026-008 并随 Server 20260903 发布的 CORS 热路径与复制请求信任边界修复。
概览
两个表面上互不相关的缺陷,实际上源于同一个架构层面的设计失误:
- 任何携带
Origin请求头的 HTTP 请求都会促使最外层 CORS 中间件把 URL 第一段路径当成存储桶名称,并在身份认证之前同步从磁盘加载该桶的元数据。 - 任何携带
X-Minio-Source-Replication-Request请求头的请求,仅凭该请求头存在就会让下游处理器将其视为内部复制请求。
两者的共同根因在于:不可信的请求语法在跨越授权边界之前,就已经获得了高开销或高特权的内部语义。
本次修复确立了两条核心安全不变量:
对于 CORS,最外层中间件现在只读取已驻留在内存中的存储桶元数据。对于复制,处理器先对原始签名请求完成身份认证,再校验对应的复制权限(s3:ReplicateObject 或 s3:ReplicateDelete),随后将私有的信任结论写入请求上下文(context.Context)。不可信的内部请求头仅在 SigV4 验签完成后才被剥离。下游选项构造器、加密路径、Object Lock 检查、事件通知与元数据持久化统一读取上下文中的信任结论,而不再检查原始请求头。
缺陷 A:鉴权前的 CORS 元数据放大攻击
corsHandler 包裹了整个服务端路由器。任何携带 Origin 请求头的请求都会在进入 S3 签名认证、请求合法性检查和常规 API 并发限流器之前先经过该中间件。
在旧实现中,按桶 CORS 检查直接调用了通用的存储桶元数据加载器:
当磁盘上不存在 .metadata.bin 时,加载器会继续探测 10 个历史遗留配置路径;全部未命中后,它不会返回 NoSuchBucket,而是构造并返回一条合法的空 BucketMetadata 记录,随后由通用读取器插入内存映射表 metadataMap。
因此,未认证客户端只需不断变换语法合法但并不存在的存储桶名称,每个新名称都会触发双重放大效应:
- 在进入 API 限流器之前触发未受限的纠删码/存储层元数据读取(1 次
.metadata.bin加 10 次旧路径探测); - 导致内存中的
metadataMap持续膨胀(分布式部署每 15 分钟才清理一次过期项,而单节点部署不启动后台刷新循环,伪造条目会一直驻留到进程重启)。
缺陷 B:标记请求头被直接当作权限权威
SILO 与兼容 MinIO 的复制客户端在跨站复制时通过内部请求头传递源端状态,其中最核心的标记头是:
在本次修复前,多条代码路径只要看到该请求头存在(或其字符串值为 "true"),就会直接认定当前请求来自受信任的复制对端,影响范围遍及多个子系统:
- 仅具备普通
s3:GetObject权限的调用方在对 SSE-C 对象发起GetObject、HeadObject或GetObjectAttributes时,可利用该标记触发NoDecryption分支,无需提供客户端密钥即可直接读取原始密文; - 客户端提供的源 ETag 与修改时间(
MTime)可以覆盖服务端生成的值; - 源端标签、对象锁保留期与法律保留(Legal Hold)时间戳可渗入“最后写入者胜出”(LWW)冲突比较;
- 对象锁解析器通过检查原始标记头即可放行已经过期的
RetainUntilDate保留时间; - 调用方可自行指定删除标记(Delete Marker)的
VersionID与修改时间; - 成功的对象创建与访问通知事件可被静默抑制;
- 分片上传完成(
CompleteMultipartUpload)阶段可注入实际对象大小与加密校验和元数据; - 普通
PutObject、CopyObject、Snowball/PAX 归档解压或浏览器POST表单策略上传只要附带X-Amz-Replication-Status: REPLICA,就能将该状态直接持久化到对象元数据中。
此前的 CVE-2026-34204 修复已经阻止了普通 PutObject 与 CopyObject 导入会导致对象不可读的复制 SSE 元数据,但当时尚未为所有读取复制标记、源时间戳、事件状态、对象锁豁免与分片完成元数据的消费者建立统一的授权后信任源。
复制信任模型设计
精确标记解析与两级信任模型
仅当 X-Minio-Source-Replication-Request 恰好出现一次且其值严格等于小写 "true" 时,解析器才视其为有效标记;重复请求头、大小写混合或任何其他字符串一律视为不可信。
完成身份认证后,处理器派生出两个层次的信任决策:
| 信任级别 | 准入要求 | 允许启用的内部语义 |
|---|---|---|
trusted |
原始请求已通过非匿名身份认证 + 精确标记头 + 对目标资源具备 s3:ReplicateObject(或 s3:ReplicateDelete)权限 |
保留源端 ETag/MTime 及子系统时间戳;传递分片实际大小与加密校验和;抑制重复事件通知与二次复制;固定复制删除的目标版本与存储池 |
replicaTrusted |
满足 trusted,且当前请求携带原始状态 REPLICA(或分片上传初始化时已持久化 REPLICA 状态) |
持久化 REPLICA 状态;导入复制 SSE 密封密钥;启用 SSE-C 免解密原始密文读取路径(NoDecryption);启用副本专用的对象锁豁免规则 |
之所以必须区分 trusted 与 replicaTrusted,是由真实线缆协议决定的:合法的分片上传(UploadPart)、分片完成(CompleteMultipartUpload)以及批量复制(Batch Replication)请求只会携带 X-Minio-Source-Replication-Request: true,而不会在每个子请求上重复附带 X-Amz-Replication-Status: REPLICA。
接收端按以下矩阵处理入站请求:
| 入站请求形态 | 接收端行为 |
|---|---|
| 无标记头 | 按普通 S3 请求处理 |
| 携带标记头,但缺少复制权限 | 剥离并忽略内部复制请求头,按普通 S3 语义继续执行 |
携带 X-Amz-Replication-Status: REPLICA,但缺少复制权限 |
直接返回 403 AccessDenied |
精确标记头 + 具备复制权限,无 REPLICA 状态头 |
仅授予 trusted |
精确标记头 + 具备复制权限 + REPLICA 状态头 |
同时授予 trusted 与 replicaTrusted |
对未获授权却声称 REPLICA 的请求显式返回 403 AccessDenied,可以防止配置错误的副本写入被静默降级为普通新对象写入并触发反向复制循环。
先验签原始请求,再清洗内部请求头
由于 AWS SigV4 会将 x-amz-* 与 x-minio-* 请求头纳入签名计算,若在验签前删除内部请求头,就会破坏规范化请求(Canonical Request)并导致合法签名报 SignatureDoesNotMatch。因此处理顺序必须严格固定:
剥离请求头后的克隆请求会与原请求共享底层 http.Request.Trailer 映射,确保普通客户端的 aws-chunked 流式校验和上传不受影响。Snowball/PAX 批量归档路径则按归档条目独立隔离信任状态(ff44527a3),同时在后台工作协程之间保留合法的请求默认属性(ab3ae99ca)。
将副本状态移出通用用户元数据白名单
X-Amz-Replication-Status 已从通用的受支持请求元数据提取列表中移除。普通 PutObject、CopyObject、分片上传初始化、Snowball 归档提取以及浏览器 POST 表单策略均无法再通过提交同名字段来持久化 REPLICA 状态;服务端仅在通过验证的 replicaTrusted 分支中显式写入 REPLICA。
对象锁显式接收信任决策
对象锁解析包不再直接读取 X-Minio-Source-Replication-Request 请求头,而是由外层处理器根据 replicaTrusted 状态显式传入 allowPastRetainDate 参数;外层处理器在判断入站副本是否允许覆盖既有合规模式(Compliance)或法律保留版本时,同样使用同一个 replicaTrusted 决策。
真实复制线缆协议矩阵
| 操作类型 | 标记头 | 当前请求是否携带 REPLICA |
接收端信任决策 |
|---|---|---|---|
常规复制 PutObject |
是 | 是 | replicaTrusted |
复制 CreateMultipartUpload |
是 | 是 | 持久化受信任的分片上传 REPLICA 来源状态 |
复制 UploadPart |
是 | 否 | trusted;仅当已保存的分片上传状态为 REPLICA 时提升为 replicaTrusted |
复制 CompleteMultipartUpload |
是 | 否 | trusted;保留源端 ETag/MTime、实际大小与加密校验和 |
CopyObject 元数据复制 |
是 | 是 | replicaTrusted |
复制 DeleteObject |
是 | 是 | replicaTrusted(需具备 s3:ReplicateDelete 与 s3:DeleteObject,且 s3:DeleteObjectVersion 未被显式拒绝) |
批量复制 PUT / Complete |
是 | 否 | trusted(目标端凭据必须包含 s3:ReplicateObject) |
| 代理转发 / 就绪检查 / 有效性探针 | 独立探针头 | 否 | 保留探针头;有效性探针在规则前缀下校验复制权限(c9ad74673、5db7be4ee) |
CORS 纯内存驻留状态机
最外层 CORS 中间件现在调用专用的纯内存驻留(resident-only)访问器,仅获取一次内存读锁,绝不触发任何存储层 I/O:
| 存储桶元数据内存状态 | CORS 决策 | 存储层 I/O |
|---|---|---|
| 已驻留,且包含合法的桶级 CORS 配置 | 应用桶级 CORS 规则(后台刷新失败时保留上一次成功加载的配置) | 无 |
| 已驻留,但未配置桶级 CORS | 回退使用全局 CORS 配置 | 无 |
| 已驻留,但持久化的 CORS XML 损坏 | 失败关闭(Fail closed:不输出 CORS 响应头并记录一次日志) | 无 |
| 未驻留,且启动期初始加载尚未完成 | 失败关闭(Fail closed) | 无 |
| 启动完成后未驻留:磁盘上真实存在但元数据加载失败的桶 | 失败关闭(Fail closed) | 无 |
启动完成后未驻留:不存在、保留名、非法名或 .minio.sys 命名空间 |
回退使用全局 CORS 配置 | 无 |
启动加载期与已知加载失败状态均采用失败关闭(fail closed)策略:由于预签名 URL(Presigned URL)本身已在查询字符串中携带认证签名,存储桶自定义的 CORS 配置是浏览器端唯一能够约束其调用源(Origin)的边界;若在桶元数据加载失败时回退到宽松的全局 CORS 策略,泄露的预签名 URL 就可能被任意网页源跨域调用。
被拒绝的替代方案
| 被拒绝的替代方案 | 拒绝原因 |
|---|---|
| 在调用旧 CORS 加载器前先校验桶名语法 | 攻击者仍可构造无穷多个语法合法但并不存在的桶名,依然会触发鉴权前磁盘 I/O |
加载 CORS 前先调用 GetBucketInfo |
只是把 11 次元数据探测替换成每个伪造桶名至少 1 次不受限的后端存储探测 |
| 为不存在的桶增加带 TTL 的负缓存 | 只能限制单条缓存存活时间,无法限制攻击者构造海量不同桶名的基数(Cardinality)与初始 I/O 放大 |
| 在 SigV4 验签前直接剥离复制请求头 | 破坏 SigV4 规范化请求计算,导致合法请求报 SignatureDoesNotMatch |
| 拒绝所有携带内部复制标记头的请求 | 会使原本应被忽略的多余请求头引发大面积客户端报错,并打断合法的仅标记(marker-only)复制调用 |
要求所有受信任复制请求都携带 REPLICA |
破坏复制 UploadPart、CompleteMultipartUpload 与批量复制的真实线缆兼容性 |
在 ObjectOptions 中保存布尔值,但让事件与对象锁继续读请求头 |
留下两个可能互相矛盾的信任源 |
实现分层与验证矩阵
整体实现划分为七个清晰边界:
- 独立的请求信任模块负责精确解析标记头、评估复制 IAM 权限、将私有信任结构体写入
req.Context(),并生成验签后的清洗版请求克隆; putOpts、copyDstOpts、getOpts与delOpts构造器仅在调用方传入受信任状态时才解析源端复制字段;DecryptObjectInfo、通知事件参数构造、分片上传完成、删除选项与对象锁统一消费同一份上下文决策;- 各 S3 处理器在现有身份认证通过后立即计算复制信任结论;
- 分片上传(
PutObjectPart)将当前请求的trusted状态与分片上传初始化时持久化的REPLICA来源状态结合判定; - 通用用户元数据提取器不再接受
X-Amz-Replication-Status; corsHandler使用独立的纯内存驻留访问器,绝不调用缓存未命中即读盘的通用加载器。
验证矩阵
自动化回归测试与真实双站点 TLS 黑盒复制测试共同验证了以下场景:
- 数百个不同的合法未创建桶名在预检(preflight)与实际 CORS 请求下,磁盘元数据读取次数为零且
metadataMap大小不增长; - 最小权限的 SSE-C
GetObject、HeadObject与GetObjectAttributes调用方在缺失、大小写错误或未授权复制标记下均无法绕过密钥检查; - 仅携带标记头的批量风格
PutObject只有在具备s3:ReplicateObject权限时才保留源端 ETag 与MTime; - 未获授权的
REPLICAPutObject与DeleteObject稳定返回403 AccessDenied; - 浏览器
POST表单策略无法伪造REPLICA状态; - 对象锁过去保留时间在无
replicaTrusted时被拒绝、在受信任副本写入时正常放行; - 两个启用 TLS 的 SILO 节点通过真实站点复制同步 SSE-C 4 KiB 单对象、SSE-C 12 MiB 三分片对象、SSE-C
CopyObject以及复制删除标记,源端与目标端的 ETag、大小、VersionID、SSE-C 密钥 MD5 及解密后 SHA-256 完全一致。
兼容性与后续工作
- 普通客户端: 请求无需任何修改;不可信的内部复制请求头会被安全忽略,不再获得特权语义。
- 批量复制(Batch Replication): 目标端凭据必须包含
s3:ReplicateObject权限(详见批量复制配置文档);缺少该权限时,接收端会将仅带标记头的写入当作普通写入处理,不再保留源端 ETag 与MTime。 - 滚动升级: 线缆协议与磁盘格式完全不变。新节点立即执行纯内存 CORS 查询与复制信任边界。
后续演进
- 复制执行可靠性(2026-09-09): 复制可靠性:删除完成、MRF 可见性与重同步取消记录了 PR #162 对 #153、#152 与 #137 的修复,处理受信任复制请求进入执行流水线后的状态收敛问题。
- 限频诊断日志: 当携带复制标记头的请求缺少复制权限并回退为普通写入时,接收端会输出限频告警日志,帮助运维人员快速排查缺少
s3:ReplicateObject权限的批量复制任务,避免将其误判为底层 ETag/MTime 不一致。
结论
该修复于 0079723d3(PR #159)合入主分支并随 Server 20260916 发布,通过纯内存驻留 CORS 查询与鉴权后统一计算的复制信任边界,彻底消除了未认证桶元数据磁盘探测与未受信任复制请求头注入。
41 - ILM 过期与存储池迁移顺序
本文记录 issue #265、贡献者修复 PR #266 与维护者后续修复 #276,并说明已经合入的 #264 与 #267 是相关前置修复。本文已于 2026-11-02 按 #276 合入后的 main 更新。#266 的 decommission 提交是 19910230a,rebalance 提交是 a995921e5。
更新(2026-11-06)。 生命周期删除不复制,S3 如此,SILO 的每个发布版本也是如此。#276 曾在 main 上短暂地让它们复制:scanner、transitioned version 的 expiry 和批量 noncurrent purge 会评估删除复制决策,并为目标端排队删除标记或 version purge。这正是仍未合入的 PR #240 的内容。下一个版本在发布前移除这一部分。#276 的其余部分保留:先删 hot-tier 再删远端、free-version 重试记录、生命周期变化栅栏,以及迁移完成检查。通过 S3 API 发出的删除仍按配置复制。decommission 在退役已过期版本之前,不再等待复制目标。下文已据此更正。
先说结论
问题成立。在版本桶中,当前版本到期时 Expiration 会创建删除标记,并把数据版本保留为 noncurrent。decommission 与 rebalance 却把所有 lifecycle DeleteAction 都当成“该版本已经被删除”,于是跳过了复制。
结果有两种:
- decommission 可能报告 Complete,旧池移除后当前版本丢失;也可能因为 expiry worker 的时序竞争而失败。
- rebalance 可能已经复制了一部分版本,却把源端副本永久留下。
PR #266 让迁移代码区分这些 action。为了保留贡献者正确的本地修复,我们先合入了它;维护者后续 PR #276 再让 ILM 选中的删除复用既有 expiry 合同,并补上必要的安全栅栏。两者合在一起已经覆盖 decommission 与普通 hot-tier 生命周期迁移。rebalance 仍有意跳过 transitioned version,这是独立的范围边界。
#276 之前,本地算法对普通 hot-tier 版本的搬迁是正确的,但还缺少三条安全边界:
- transitioned version 可能失去唯一的远端清理记录;
- 必须明确迁移扫描与清理之间生命周期配置发生变化时的规则;
- 删除整个对象的 action 可能在 decommission 报告 Complete 后仍把当前版本留在源池。
这些边界与原始的本地迁移问题是分开的;#276 用小范围边界调整和回归测试补齐了它们,不需要重写整个存储池迁移。
时间线与来源
上游首次引入“decommission 跳过生命周期过期对象”的提交是 236ef03db,时间为 2022-07-14。池级 rebalance 在 4523da654 中引入,时间为 2022-10-25,也采用了类似的生命周期跳过思路。之后上游在 3637aad36 中调整跳过项计数,在 9af6c6cee 中让 rebalance 与 decommission 对齐,分别发生在 2023-05-11 和 2023-06-21。
SILO 在 1748f62c7 中明显收紧了迁移清理,时间为 2026-10-18:从宽泛的前缀清理改为只删除经过源端与目标端检查的具体版本。这样能更好地防止并发删除,但也意味着“被 ILM 跳过的版本”不能再因为前缀删除而顺带消失。
所以问题是旧的 lifecycle skip 语义与新的逐版本安全检查发生交互。两部分单独看都合理,合在一起却对版本状态得出了不同结论。
什么条件会触发
最常见的触发方式是规则在两次 scanner 之间到期,或在迁移前刚刚新增/修改规则:
- 版本桶中有 v1、v2 和当前 v3。
- Expiration 规则已经对 v3 到期。
- expiry worker 尚未执行,迁移 walker 先评估了规则。
- evaluator 返回 DeleteAction。
对版本桶来说,DeleteAction 的含义是“在当前版本上方新增删除标记”,不是“立刻删除 v3”。同一次迁移也可能看到真正只删除一个版本的 DeleteVersionAction,例如 NoncurrentVersionExpiration。这两个 action 不能使用相同的迁移处理。
expiry queue 越慢,风险越明显。当前版本可能被跳过,而且目的池没有副本;旧池删除后它就丢了。处于 COMPLIANCE 保留期的版本也会暴露,因为删除标记仍然可以创建,而保留的数据版本本应继续存在。transitioned current version 也是 decommission 的特殊情况。
根因,用简单的话说
旧代码使用一个布尔过滤器:
这个布尔值抹掉了几个不同语义:
- 版本桶上的 DeleteAction:增加标记,保留当前数据版本;
- DeleteVersionAction:删除一个 noncurrent 版本;
- DeleteAllVersionsAction:删除整个对象历史;
- delete-restored action:删除恢复副本,而 tier 元数据有另一套规则。
于是 decommission 把跳过项计为完成,末尾检查也忽略它;rebalance 则不计跳过项,所以“所有版本已搬迁”的条件永远无法满足,源副本会留下。
新的 removeMovedVersions 又增加了一个必要规则:只有当目标副本仍然保留读取结果时,才能删除源版本。这个规则对已经搬迁的版本是正确的,但不能原样用于“ILM 会删除、且不会产生目标副本”的版本。如果跳过版本留在源池,其他新版本搬走后,它可能成为源池中的最新版本;expiry 重新评估时反而得到“不需要删除”。
为处理这个情况加入的直接 purge 还有自己的边界。它用版本 ID 和 SkipFreeVersion 调用源池的 DeleteObject,这是一项存储清理操作,不是普通的 S3 删除路径。它不会自动执行复制决策、写入 VersionPurgePending,也不会删除远端 tier 对象。
维护者后续修复如何补齐这些边界
#266 中加入的 direct purge 本质上是本地存储清理。单独看,它不会自动执行复制决策、写入 VersionPurgePending,也不会删除远端 tier 对象。#276 保留 #266 的 action 区分,同时让迁移遵守普通 ILM expiry 已经拥有的合同。
复制不是生命周期删除的义务
生命周期删除不复制:删除复制规则作用于通过 S3 API 发出的删除,每个目标端各自执行自己的生命周期配置。#276 合入时还会为 ILM 选中的删除排队复制;这一部分在下一个版本发布前移除(见上文更新)。配置了复制时,迁移期间的生命周期删除现在不会为目标端排队任何任务,源端副本也不必等待目标端即可退役。
#276 仍让 ILM 选中的版本删除在源端清理前先走普通 expiry 路径,是为了下面两项合同:transitioned version 保留远端 tier 清理及其重试记录,完成栅栏能看到 expiry 删除了什么。这是最小的安全边界,因为它复用了已有 expiry 路径,只在迁移与 expiry 之间补了一次必要的交接,不需要把这些规则复制到 removeMovedVersions 中,也不会让延迟的 expiry task 因为源端已经消失而得到 VersionNotFound。
transitioned version 必须保留清理义务
transitioned version 同时需要清理 hot-tier 元数据和远端 tier 对象。如果远端删除失败,hot-tier 中必须留下持久的 free version,让后续 scanner 可以重试。#276 先处理 hot-tier 状态,并在远端删除失败时保留可重试的 free-version 记录;decommission 只有在普通版本和 free version 都清理干净后才报告完成。当 transitioned version 带有已过期的恢复副本、且本地恢复副本删除被拒绝时(例如磁盘暂时不可用或恢复副本删除报错),decommission 会将该版本搬迁到目的池(5cb91e7d7),而不是把它留在源池或在源池退役时丢失。
这保持了 #264 的双视图模型:free version 继续是内部清理记录,普通生命周期和迁移视图仍然排除它。#266 中 rebalance 仍然跳过 transitioned version;让 rebalance 搬迁它们应作为单独的范围决策处理。
生命周期配置变化采用 fail-closed 语义
迁移遍历开始时记录生命周期配置更新时间。源端清理执行不可逆决定前会重新比较这个值;如果配置发生变化,迁移停止并保留源端数据,等待按新规则重试。
这是最小的安全语义:它不会静默执行过期的删除判断,代价只是操作员在迁移期间修改生命周期配置时多执行一次迁移。
整对象过期需要可观察的完成栅栏
DeleteAllVersionsAction 与 DelMarkerDeleteAllVersionsAction 必须在旧池退役前删除整个历史。#276 在源端清理前执行整对象 expiry,并确认普通版本与 free version 都已经消失,再让 decommission 报告完成。这样不会出现旧池仍独占当前版本、而旧版本已经搬到新池的状态。
这个栅栏使用已有的源端状态检查,而不是引入新的异步事务。它保留当前 expiry worker 和重试模型,同时让 Complete 真正表示源池已经不再拥有该对象。
影响
操作员可能看到:
- decommission 报告 Complete,旧池移除后当前版本消失;
- decommission 报 PreConditionFailed,需要第二次运行;
- rebalance 报告有进展,却把源副本留下;
- object lock 保护的版本或 tier 中的版本从源端迁移路径丢失;
- transitioned version 被直接 purge 后,远端 tier 数据变成孤儿;
- 已经进入迁移的源端清理忽略生命周期规则变化;
- 后续 scanner 也不会删除这些重复副本。
即使迁移前 GET 正常,这仍是迁移正确性问题,因为丢失发生在旧池退役的边界。
备选方案
只要 lifecycle 到期就阻塞迁移
安全但不可用。scanner 的正常延迟会变成迁移故障,规则变更后还需要正确重扫。
迁移前同步执行 lifecycle
可以降低竞争,但不能定义 expiry queue、tier 或目的地不可用时怎么办,还会增加迁移延迟,并且仍需让迁移 walker 正确解释生成的元数据。
跳过所有 ILM action,再删除源端整个前缀
接近旧的宽泛清理逻辑,但可能在目的池尚未创建删除标记前就删除版本桶当前对象,也绕过 SILO 特意加入的逐版本并发保护。
区分 action 语义,只清理安全的内容
这是最小的必要设计:
- Expiration 只产生标记时,搬迁当前版本;
- ILM 真正删除一个版本时可以跳过,但允许源端清理不要求目的池有副本;
- 删除整个对象历史的 action 保留现有语义,等 expiry 在所有池中执行;
- rebalance 把跳过项计为已处理,从而可以清理已搬迁的源副本。
PR #266 实现了这个设计。
当删除带有外部副作用时先搬迁版本
tier 清理让直接源端 purge 不再只是本地元数据操作。先搬迁版本,再让普通 ILM worker 执行删除,可以复用已有的 tier 合同。它可能会短时间保留一份额外副本,但不需要在迁移清理中重新实现这项合同。
PR 判断
#266 是对原始本地问题正确的贡献修复:
- 不再把版本桶当前 Expiration 产生的删除标记,当成数据版本已经被删除;
- 区分只删除一个版本与删除整个对象;
- 让 decommission 和 rebalance 正确计算 ILM 真正会删除的版本;
- 保留新版迁移清理加入的逐版本源端与目标端检查。
我们先合入 #266 以承认这项贡献。维护者后续 PR #276 再补上必要的组合边界:
- ILM 选中的版本删除在源端清理前先走普通 expiry 路径;
- 远端 tier 删除失败时保留可重试的 transitioned-version 清理记录;
- 生命周期配置变化会使旧遍历结果失效;
- 整对象 action 在 decommission 报告成功前完成并验证;
- decommission 与 rebalance 使用独立的生命周期审计来源;
- 回归测试覆盖延迟 expiry、tier 清理、配置变化、整对象过期和可恢复 decommission。
合并后的结果对报告的 decommission 问题充分且必要,也是合理的最小改动:action 语义留在迁移 worker 中,tier 清理复用已有路径,完成判断使用系统已有的持久化元数据检查。没有引入新的元数据格式,也没有重写存储池迁移。
代价主要是少量额外元数据操作,以及某些情况下多一次迁移重试或短时间保留额外副本。相比静默丢版本或产生远端 tier 孤儿,这些代价是必要且可接受的。
这个修复有意不扩展 rebalance 对 transitioned version 的支持。该问题需要单独的 tier 所有权测试和范围决策,不应在 #276 中顺手扩大。
合并顺序与冲突
最终顺序是有意安排的:#264 先建立真实版本视图,#266 保留贡献者的 action 区分,#276 再在两者之上补齐维护者负责的 tier、生命周期代数和完成检查。#266 的原始提交仍保留在合并历史中。
组合测试覆盖 free version 位于生命周期过期版本上方、被选中删除的 transitioned version、生命周期配置变化、整对象过期,以及可恢复 decommission。配置了复制时,测试确认生命周期删除不会为目标端排队任何任务。
剩余边界
rebalance 仍会有意跳过 transitioned version。这个修复也不会自动修复仍然离线的磁盘、恢复旧版本错误 decommission 已经丢失的数据,或清理已经失去 free-version 记录的旧孤儿 tier 对象。这些是独立的运维或范围决策。迁移期间生命周期配置变化现在采用 fail-closed 语义,旧扫描结果必须按新配置重试。
验证参考
42 - 一个端点,两种权限:彻底分离用户与组状态
本文记录 上游议题 minio/minio#21478、SILO PR #73(提交 58735ee38 / 2e2377d1c)以及 set-group-status 组状态权限善后提交(229fe2b3c)的鉴权设计与验证记录。上述修复已随 Server 20260903 发布。
太长不看(TL;DR)
SILO 同时定义了 admin:EnableUser 与 admin:DisableUser 两项策略权限,但共用的 set-user-status 处理函数过去无论目标状态是什么,都固定检查 admin:EnableUser。结果,仅授予 admin:DisableUser 的策略反而无法禁用账户;要想执行禁用,就必须额外授予 admin:EnableUser,直接打破了这两项权限的最小权限边界。
修复方案在鉴权前根据请求的目标状态映射出唯一对应的权限动作:
| 请求状态 | 必须具备的权限 |
|---|---|
enabled |
admin:EnableUser |
disabled |
admin:DisableUser |
| 非法或未知值 | admin:EnableUser,保留原有的“先鉴权、后校验”默认边界 |
随后处理函数仅调用一次 validateAdminReq。四向 IAM 集成测试同时验证了两条正向放行路径与两条交叉拒绝路径。这一设计不再允许“仅含 admin:EnableUser 的策略也能禁用用户”的历史行为,使两项权限各自严格对应单向操作。
相同的规则也同步应用于组状态端点:
| 请求的组状态 | 必须具备的权限 |
|---|---|
enabled |
admin:EnableGroup |
disabled |
admin:DisableGroup |
| 非法或未知值 | admin:EnableGroup,保留原有的“先鉴权、后校验”默认边界 |
修复前,仅持有 admin:EnableGroup 的调用方可以禁用组,而仅持有 admin:DisableGroup 的调用方在执行禁用时却会收到 AccessDenied。组状态修复同样采用“单一选择函数、单次鉴权调用”的结构。
被报告的问题
管理 API 使用同一个路由处理启用与禁用两种状态变更:
修复前,处理函数在读取目标状态参数之前就硬编码检查了启用权限:
后续调用的 SetUserStatus 虽然能正确处理 enabled 或 disabled,但前置鉴权已经将两种操作一律视为启用。admin:DisableUser 虽存在于策略语法与公开文档中,却无法独立授权该端点。
议题 #21478 给出了典型的生产场景:安全运维人员在应急响应期间需要一份“只能禁用异常账户、不能恢复账户”的受限策略。然而仅配置 admin:DisableUser 时请求会返回 AccessDenied;补上 admin:EnableUser 后禁用才得以执行,但调用方也同时获得了本应受控的账户恢复权限:
为什么两个 action 必须代表两种能力
账户状态变更具有明确的方向性。禁用操作通常下放给应急响应值班人员、反欺诈系统或合规自动化流程;而重新启用会恢复访问权限,往往需要更高层级的审批。
因此,两项权限必须严格正交:
| 调用方策略 | 禁用目标 | 启用目标 |
|---|---|---|
仅 admin:DisableUser |
允许 | 拒绝 |
仅 admin:EnableUser |
拒绝 | 允许 |
| 两者都有 | 允许 | 允许 |
| 两者都没有 | 拒绝 | 拒绝 |
内置的 consoleAdmin 策略授予 admin:*,完整管理员仍可执行双向操作;受影响的仅是曾依赖旧缺陷的自定义受限策略。公开的 PBAC 参考文档同样为 admin:EnableUser 与 admin:DisableUser 记录了这一契约。
设计目标与非目标
设计目标
- 让现有的两项管理权限严格按各自名称生效;
- 在启用与禁用两个方向上同时满足最小权限原则;
- 每个请求只执行一次鉴权判定,最多写出一次鉴权错误响应;
- 保持路由、查询参数、响应格式、禁止修改自身状态保护、IAM 存储调用与站点复制钩子不变;
- 用自动化测试锁定四向权限矩阵,防止两项权限再次被合并或错位。
非目标
- 将端点拆分为独立的启用路由与禁用路由;
- 新增合并权限名称或修改 IAM 策略语法;
- 修改用户状态的持久化格式或站点复制机制;
- 重新设计 Console 前端权限模型。
讨论过但没有采用的方案
两种状态继续只检查 admin:EnableUser
这会保留缺陷原状,使 admin:DisableUser 持续失效并迫使策略过度授权。
任一状态都同时要求两个 action
这会让两项权限失去独立意义,无法下放“仅允许禁用”的最小权限。
Enable 鉴权失败后,再尝试 Disable 鉴权
上游 PR #21482 对禁用请求采用了串联调用方案:先以 EnableUser 调用 validateAdminReq,若返回 nil 再以 DisableUser 调用第二次。
然而 validateAdminReq 有一项关键副作用:当它返回 nil 对象层时,已经向 http.ResponseWriter 写出了错误响应。对仅持有 admin:DisableUser 的调用方而言,第一次调用已经向客户端提交了 HTTP 403,第二次调用随后通过鉴权并继续修改账户状态。鉴权回退分支绝不能在错误响应已经写出后继续修改状态。
禁用请求接受 Enable 或 Disable 任一个 action
validateAdminReq 支持传入可变参数(variadic)权限列表,只要其中任一权限匹配即放行。若想兼容旧行为,只需向单次 validateAdminReq 调用同时传入两项权限,即可在放行 admin:DisableUser 的同时,保留“仅含 admin:EnableUser 也能禁用账户”的历史行为。
SILO 否决了该方案,因为“仅持启用权限即可禁用账户”正是需要修复的越权缺陷。需要管理完整账户生命周期的角色应当显式配置两项权限。
先校验 status,再进行鉴权
若在鉴权前先拦截非法 status 参数,就会改变错误响应优先级:原本未获授权的调用方在鉴权前就能探测参数格式校验结果。
因此,对于非法或未知的 status 值,选择函数继续返回默认的 admin:EnableUser;仅当 status 精确为 disabled 时才选择 admin:DisableUser。通过鉴权后,仍由既有的 IAM 逻辑拒绝非法状态值。
最终实现
实现新增了一个无副作用的纯选择函数:
处理函数先读取路由变量,选定所需权限,然后仅执行一次鉴权:
鉴权通过后的业务逻辑完全保持不变:
- 调用方仍不能启用或禁用自身账户;
globalIAMSys.SetUserStatus继续校验并持久化目标状态;- 站点复制继续同步状态与更新时间;
- 响应格式与审计日志仍走原有路径。
选择函数仅依赖请求传入的目标状态字面量,不在鉴权前读取目标用户当前状态,也不依赖目标账户是否存在,从而保持鉴权判定确定且无前置存储读取。
为什么这个修复是安全的
该实现满足五条安全不变量:
- 每个合法的目标状态严格映射到唯一一项管理权限;
validateAdminReq仅调用一次,鉴权失败后立即返回,绝不执行状态修改;- 状态修改调用仅在目标状态对应的权限通过校验后可达;
- 非法状态值保留原有的启用权限鉴权门槛,鉴权通过后仍由既有校验分支拒绝;
- 存储格式、站点复制、网络协议与客户端 SDK 接口均无变化。
测试设计
纯 action 映射
单元测试覆盖选择函数的三类输入:
| 输入 | 期望权限 |
|---|---|
enabled |
EnableUser |
disabled |
DisableUser |
| 非法值 | 保持默认的 EnableUser |
四向 IAM 鉴权矩阵
集成测试创建彼此独立的管理员账户与受限策略,通过真实管理 API 验证完整四向矩阵:
- 仅持有禁用权限的客户端可以成功禁用目标用户;
- 同一客户端尝试启用目标用户时收到
AccessDenied; - 仅持有启用权限的客户端可以成功启用目标用户;
- 同一客户端尝试禁用目标用户时收到
AccessDenied。
两个交叉拒绝断言确保任一策略都不会意外放行反方向操作。测试在既有 IAM 服务端测试套件中运行,完整覆盖请求签名、策略绑定、处理函数鉴权、状态持久化与管理客户端错误解析,并在执行结束后清理所有临时用户与策略。
修复与验证记录
本地验证命令:
带 DCO 签名的提交 58735ee38 推送至 PR #73 后,全部八项远端 CI 检查(DCO 签名、代码格式/编译/go vet、静态检查与生成文件、cmd/ 单元测试、internal/ 单元测试、并发竞态与 S3 Select、交叉编译、漏洞扫描)均一次通过,并以 2e2377d1c 合入主干。
最小权限策略示例
只能禁用的操作员
持有该策略的调用方可以查看并禁用其他用户,但不能重新启用账户。
只能启用的操作员
持有该策略的调用方可以查看并启用其他用户,但不能禁用账户。负责完整账户生命周期管理的角色应同时授予这两项权限。
组状态权限善后
组状态管理端点与用户状态端点结构完全对称:
该端点同样定义了 admin:EnableGroup 与 admin:DisableGroup,但原有处理函数也在读取 status 参数前固定检查了 EnableGroup,导致两方向的最小权限同时错位。
善后提交(229fe2b3c)新增了与 setUserStatusAdminAction 同构的 setGroupStatusAdminAction:
配套集成测试创建了仅持 EnableGroup、仅持 DisableGroup 的独立管理员账户及目标用户组,验证:
- 仅持
DisableGroup可以禁用目标组; - 仅持
DisableGroup不能启用目标组; - 仅持
EnableGroup可以启用目标组; - 仅持
EnableGroup不能禁用目标组。
非法 status 参数仍先按默认启用权限完成鉴权,再由既有逻辑返回参数校验错误,不会在鉴权前暴露参数校验差异。请求通过后的站点复制钩子保持不变,被拒绝的请求不会触发复制。
兼容性与迁移
客户端与 API 无需任何改动:路由地址、查询参数、状态字符串、响应格式与管理客户端方法完全保持不变。
自定义受限管理员策略需按以下规则核对:
- 仅负责禁用用户的角色需授予
admin:DisableUser; - 仅负责启用用户的角色需授予
admin:EnableUser; - 需要执行双向操作的角色必须同时授予这两项权限;
consoleAdmin及其他包含admin:*的策略不受影响;- 过去仅配置
admin:EnableUser的自定义策略将无法再禁用用户;若业务确需双向操作,请补充admin:DisableUser。
组管理角色遵循完全对称的规则:
- 仅负责禁用组的角色需授予
admin:DisableGroup; - 仅负责启用组的角色需授予
admin:EnableGroup; - 需要执行双向操作的角色必须同时授予这两项权限;
- 仅配置
admin:EnableGroup的旧策略将无法再禁用组。
上游处置与交付
上游议题 #21478 与 PR #21482 保留在已归档为只读的 minio/minio 仓库中作为历史记录。
交付状态
| 里程碑 | 用户状态修复 | 组状态善后 |
|---|---|---|
| 带 DCO 签名的提交 | 58735ee38(合并为 2e2377d1c) |
229fe2b3c |
| 发布版本 | Server 20260903 | Server 20260903 |
结论
启用与禁用用户或组具有截然相反的安全风险方向。处理函数根据请求的目标状态选择对应的策略权限,并在修改状态前执行单次鉴权,使权限检查与策略定义严格对齐。
本文记录的启停权限修复与后续 admin:ChangeMyPassword 权限拆分相互独立;跨九月版本升级时请一并参考密码迁移指南。
43 - IAM Admin API 授权边界修复
Server 20261018 包含了 PR #242(基于基线 00a375c4e 的修复提交 2616d43fb,合并提交 4e55fc7db)以及批量自查询后续修复 995439260,用于修复 SN-2026-017。本文记录问题范围、Handler 级授权契约、空策略与 claims 归属处理、损坏密钥清理语义、性能开销、被否决的候选方案以及回归验证结果。
问题与范围
此前部分 Admin Handler 在授权拒绝后仍以“目标属于调用者”为由放行,列出自身服务账号时甚至可能直接跳过授权检查。这些路径会绕过用户或组策略的显式 Deny、内联会话策略限制以及外部授权插件的拒绝结果。
凭据创建和更新还存在另一类越权路径:获准执行相应 Admin Action 的受限派生凭据,可以创建继承父账号更大权限的新密钥,或通过更新操作移除服务账号原有的限制;拥有广泛 Admin 权限的非 root 管理员也能操作 root 的派生凭据。在内置授权模式下,仅凭单个 Action 许可不足以放行这些操作。
本次修复覆盖服务账号与访问密钥的查询、清理、创建和更新 Handler(含批量密钥查询和 LDAP 创建入口),同时保持策略引擎全局语义、现有 Admin 路由与 Action、请求响应格式及 IAM 存储格式不变,无需调整依赖版本。
文中的内置授权指未配置外部授权插件的模式,包括由本地 IAM 评估 LDAP/OIDC 身份权限的场景。相关策略模型见访问管理与外部授权。
授权契约
传给 IAM 的操作主体始终是已认证的调用者 AccessKey。比对归属关系时,若存在 ParentUser 则取父账号,否则取 AccessKey;归属归一化不会在策略评估阶段将派生凭据替换为父账号身份。
自查询和自清理在调用授权前设置 DenyOnly: true:普通用户无需额外 Allow 策略即可执行既有的自管理操作,但显式 Deny 依然生效;派生凭据继续执行完整 IAM 评估,其有效会话策略必须明确允许目标操作;外部插件的拒绝结果始终生效。任何 Handler 都不会再因目标属于调用者而覆盖拒绝决定。
| Handler | 现有 Admin Action | 自管理语义与附加边界 |
|---|---|---|
InfoServiceAccount |
admin:ListServiceAccounts |
比对有效用户与服务账号存储的父账号,授权通过后返回详情。 |
ListServiceAccounts |
admin:ListServiceAccounts |
空用户参数、调用者 AccessKey、调用者父账号均视为自身别名;每次请求均执行授权,并列出父账号名下的密钥。 |
ListAccessKeysBulk |
admin:ListServiceAccounts |
自身别名统一按父身份归一化并复用同一自管理授权辅助函数;跨用户查询和 all=true 保持原有权限要求。 |
InfoAccessKey |
admin:ListServiceAccounts |
仅当目标存在、属于派生凭据且父账号相同时才视为自身;不存在的目标不触发自管理分支。 |
DeleteServiceAccount |
admin:RemoveServiceAccount |
自清理先执行授权再检查 root 边界;目标 token 损坏时直接读取存储的身份元数据。 |
RevokeTokens |
admin:RemoveServiceAccount |
省略用户参数或解析后等于调用者父账号时视为自身;先执行授权并保护 root 目标。 |
AddServiceAccount、AddServiceAccountLDAP |
admin:CreateServiceAccount |
完成既有自身或跨用户 IAM 授权后,拒绝受有效会话策略限制的调用者及无权管理 root 的调用者。 |
UpdateServiceAccount |
admin:UpdateServiceAccount |
完成既有 Action 授权后,拒绝受有效会话策略限制的调用者及无权管理 root 的调用者;不引入自更新特权。 |
即使会话策略允许创建或更新 Action,受该策略限制的调用者也不能执行这些操作。更新守卫同样拦截仅修改名称等元数据的请求,因为现有更新实现会在请求未提供新策略时清除原有内联策略。
三个局部辅助函数
生产代码改动集中在 cmd/admin-handlers-users.go 以及 cmd/admin-handlers-idp-ldap.go 的 claims 复制循环,通过三个局部辅助函数收敛边界,IAMSys.IsAllowed 保持不变。
hasActiveSessionPolicy 复用现有会话策略评估函数,按 IAM 派发顺序先检查临时凭据,再检查服务账号。设置了过期时间的服务账号同样满足 IsTemp(),会走 STS 评估路径。因此,带有 inherited-policy 标记且含过期时间的历史凭据仍可能携带有效的内联限制;若先检查继承标记,会误放行此类凭据去创建或更新权限更大的密钥。非临时服务账号则沿用现有服务账号评估器对继承标记与空策略的处理逻辑。
canManageRootCredentials 作为内置授权通过后的附加守卫,仅放行 root 自身或不受会话策略限制的 root 派生凭据(排除内部站点复制服务账号)。签名验证阶段的 owner 标记在原始会话策略 claim 存在时就会被清除,即便服务账号评估器判定该策略为空。直接检查实际限制可保留历史上使用 null、{} 或 {"Statement":null} 创建的 root 服务账号的管理能力。
isAllowedServiceAccountAction 保留普通用户在解绑最后一个策略后既有的自查询与自清理能力。仅当调用满足内置授权、非 owner、非派生凭据且设置 DenyOnly 时,该函数直接解析用户与组策略并评估合并结果(允许合并结果为空,显式 Deny 仍生效);其余调用继续走 IAMSys.IsAllowed,从而避免修改全局“无绑定策略”行为而波及无关的 S3 与 Admin 操作。
创建、更新、删除和吊销操作上的 root 守卫仅在内置授权下生效。配置外部授权插件时,包括 root 目标在内的所有请求均交由插件裁决,此前被自身分支跳过的请求现在同样经过插件检查。
空策略与 claims 的归属
代码复核还发现创建路径上一处可用性缺陷:将父服务账号的空内联策略复制给新的继承型子密钥时,会残留旧的内部策略 claim。一旦新密钥设置了过期时间,STS 评估器会因空策略缺少 Version 字段而拒绝访问,导致密钥创建成功却无法使用。
在创建请求中显式传入语义空策略并同时设置过期时间,也会触发相同故障。两类问题统一在 Admin 签发入口处理:
- 两个创建 Handler 在复制调用者 claims 时跳过
exp、policy.SessionPolicyName、sessionPolicyNameExtracted和iamPolicyClaimNameSA()。新密钥使用自身的过期时间和请求策略,其余身份信息(含 LDAP/OIDC 上下文)原样保留。 commonAddServiceAccount仅当解析后的策略满足Version == "" && Statements == nil && ID == ""时将其归一化为继承策略,与现有服务账号评估器的空策略定义保持一致;同时清空本地策略选项和返回请求中的 Policy 字段,确保站点复制发出的创建消息与本地落盘密钥一致。
| 新密钥请求中的策略 | 结果 |
|---|---|
省略、null、{}、{"Statement":null} |
继承父账号权限(含设置过期时间的密钥)。 |
| 仅允许特定 Action 的策略 | 保留所请求的限制,未授权操作继续拒绝。 |
{"Version":"2012-10-17"} 或 {"Statement":[]} |
不归一化为继承策略;带过期时间的回归用例继续拒绝访问。 |
归一化判断条件使用 Statements == nil 而非 len(Statements) == 0,以区分 Console 特有的空策略占位形态与显式传入空语句列表的策略。
IAMSys.NewServiceAccount 同时供 IAM 导入与站点复制调用。若在该公共构造函数中清洗 claims,会抹掉历史过期凭据仅保存在 claims 中的有效限制。因此公共构造函数与历史记录重放逻辑保持不变,仅在 Admin 签发入口规范化新凭据。
删除与错误语义
DeleteServiceAccount 直接通过 globalIAMSys.store.GetUser 读取存储的身份元数据,校验服务账号类型并保留存储的父账号,无需解码目标 session token。底层删除操作使用同一查找原语,既避开了 token 解析,又绕开了公开用户查询对禁用或过期凭据的过滤,同时保留 store 原有的父账号吊销检查。
具备权限的管理员可正常清理 token 损坏、已禁用或已过期的非 root 服务账号,而非 root 管理员无法利用 token 损坏掩盖目标的 root 归属。保留下来的父账号也保证了站点复制钩子能准确判断是否同步删除消息。
无权删除他人密钥、被 root 守卫拦截或目标服务账号不存在时,统一返回 ErrAdminServiceAccountNotFound(HTTP 404);自身操作被策略拒绝时返回 ErrAccessDenied(HTTP 403)。其余端点保持原有的查询与报错顺序。
兼容性与边界
行为收紧集中在三处:自管理操作必须遵守显式拒绝策略、受会话限制的凭据不得创建或更新密钥、内置授权下保护 root 凭据免受非 root 修改。依赖旧旁路逻辑的自动化任务需改用具备相应权限且不受会话策略限制的身份。普通用户(含解绑策略后)原有的自查询与自清理能力不变,历史 root 空策略服务账号的管理能力不变。
新建空策略密钥的元数据统一保存为 inherited-policy,不再写入内嵌空策略文档。这消除了带有效期密钥在两条评估路径上的行为分歧;策略内部保留字段也不再从调用者复制给新密钥,仅反映新凭据自身的属性。
2026-10-18 的后续提交 995439260 将 ListAccessKeysBulk 接入同一自管理授权辅助函数。无绑定策略的普通用户可正常自查询,显式 Deny、派生凭据会话限制与跨用户授权照常生效。省略 users 参数,或唯一 users 参数为调用者自身 AccessKey/父账号时,统一查询解析后的父账号密钥列表;all=true 仍需 admin:ListUsers 权限。回归测试会解密响应负载并校验普通用户、服务账号与 STS 别名下的身份内容。
此前已生成且无法使用的过期历史凭据不会自动改写,应由父账号或管理员重新签发新密钥并在验证后删除旧密钥。切勿通过批量清除导入或复制凭据的策略 claims 来做迁移,以免破坏存量凭据上的有效会话限制。
关于分布式吊销顺序与并发管理变更边界,参见持久化 IAM 撤销。
成本与被否决的候选方案
PR #242 在两个生产 Handler 文件中净增 59 行代码,10 月 18 日的批量查询修复直接复用同一辅助函数。改动未引入新缓存、后台任务、锁协议或存储迁移,也未触及 S3 数据路径;claims 过滤在既有复制循环中完成,空策略归一化直接复用创建请求已解析的策略对象。
Admin 创建与更新检查可能需要额外解析一次调用者的内联策略(服务账号策略上限为 4 KiB),复用现有评估器避免维护第二套“有效策略”判定逻辑。原先跳过授权的部分自查询请求现在会执行必要的策略评估,通常直接命中 IAM 内存缓存;仅当合并策略加载器发现文档缺失时才会访问底层存储。在启用外部授权插件的部署中,补齐的授权检查会向插件发起一次请求,耗时取决于外部插件服务。
在 Darwin/arm64(Apple M5 Max,Go 1.27.2,三次 200 ms 采样)上的基准测试结果如下:
- 普通或继承型会话检查:约 11–16 ns,0 次内存分配;
- 空服务账号策略检查:约 0.30–0.32 µs;
- 约 3 KiB 的内联策略检查:约 10 µs;
- 缓存命中的普通用户自管理授权:与现有 IAM 路径同处亚微秒量级(800 B / op,15 allocs / op)。
上述开销无需在 Admin 路径上额外引入专用缓存或策略表示。
设计复核评估并否决了以下五种方案:
- 全局修改
IAMSys.IsAllowed:会改变全局“无绑定策略”或自归属语义,波及无关的 S3 与 Admin 操作。 - 仅凭原始会话策略 claim 键存在即判定受限:历史上使用
null、{}或{"Statement":null}创建的 root 服务账号同样带有原始 claim 键,按存在性拦截会破坏其管理能力。 - 在
IAMSys.NewServiceAccount公共构造函数中统一剔除策略字段:该函数同时被 IAM 导入和站点复制调用,在公共构造函数中清洗 claims 会丢失历史过期凭据仅保存在 claims 内的有效限制。 - 为会话受限凭据实现通用委派策略包含关系求解:会话受限凭据的设计定位是受限叶节点凭据而非子签发者,直接禁止有效会话策略持有者创建或更新子密钥即可用 59 行代码闭合越权路径。
- 一律拒绝损坏密钥的清理:会导致管理员无法利用存储的身份元数据清理 token 已损坏、已禁用或已过期的非 root 服务账号。
回归证据
提交 2616d43fbc3400a02cc1e6f9299e26ae8b2e71a6 与后续提交 9954392605827b213877c45262c741e638598dd8 通过了以下测试套件:
| 检查 | 结果 |
|---|---|
完整 TestIAM* / TestSTS*(连接真实 etcd、LDAP、两个 OIDC Provider 和外部授权插件) |
57 个顶层测试;含子测试共 380 条通过记录;0 失败、0 跳过。 |
TestIAMAdmin* 回归测试(使用签名请求及加密 Admin 请求体) |
通过。 |
TestIAMAdmin* 与 peer 删除/重载测试的定向 -race 检查 |
通过。 |
| 认证、身份配置与策略配置相关包测试 | 通过。 |
make verifiers 与 make build |
lint、生成文件、品牌与兼容性检查通过,成功构建 silo 二进制文件。 |
PR #242 云端 CI 检查 |
DCO、漏洞扫描、格式/构建/Vet、lint/生成文件、race、交叉编译、internal 与 cmd 测试共 8 项全部通过。 |
cmd/admin-handlers-users-authz_test.go 覆盖了 root 空策略、无绑定策略用户自管理、父用户/组/会话/插件拒绝、带过期时间的历史 inherited 凭据、非 root 操作 root 目标、禁用或过期的损坏密钥清理、删除复制、自身别名,以及空策略子密钥签发的正反向访问验证。LDAP 测试套件额外通过 AddServiceAccountLDAP 签发带过期时间的子密钥并验证 S3 访问。补充边界测试验证了损坏 root 密钥的清理拦截,以及五个自管理 Handler 对 STS 显式与隐式拒绝的处理。
验证过程中同时修正了两处既有测试夹具问题:peer 重载测试在构造对象存储夹具前显式清理残留的 etcd 客户端状态;IAM 导入测试改为从 policy.DefaultPolicies 动态获取默认策略名。
复现验证命令:
交付与升级说明
- 发行版本:Server 20261018(PR
#242,提交2616d43fb、4e55fc7db与995439260)。 - 安全通告:SN-2026-017。
- 请升级所有接收 Admin 流量的服务端节点,避免混合版本部署将请求路由至未修补的 Handler。本次变更不涉及磁盘格式变更,无需批量重写存量凭据。
44 - 配置环境文件不是 Shell 脚本
本文定义 MINIO_CONFIG_ENV_FILE 的启动解析契约,并记录提交 2aea7fe9c(已随 Server 20260903 发布)中的兼容性修复。该文件是 SILO 的专用输入格式;为方便运维而支持可选的 export 前缀,并不意味着它是一段 POSIX shell 脚本。
概览
SILO 支持在启动时从指定文件加载环境变量:
解析器接受如下形式的键值赋值:
最后两行展示了为什么 POSIX shell 标识符校验过于狭窄。多目标(multi-target)配置会在下划线后原样拼接目标名称。配置子系统并不要求目标名称符合 shell 变量命名规则:包含 -、.、:、数字或可打印 Unicode 字符的目标名称均能被正常发现并精确解析。
此前的一项加固改动曾使用 [A-Za-z_][A-Za-z0-9_]* 校验环境文件中的每一个键名,导致 my-hook 被判定为非法并在服务重启时拒绝启动,尽管旧版加载器与配置目标模型均支持该名称。修复后的解析器改为校验 SILO 与操作系统环境真正依赖的不变量:
- 键名非空、为合法 UTF-8,且仅由可见的非空白字符组成;
- 键名中禁止包含
=与 NUL; - 值中禁止包含 NUL;
- 非法输入仅报告文件路径与行号,绝不在日志中输出变量值;
- 整个文件全部解析通过后,才会开始向进程环境写入变量。
为什么这是一次真实的回归缺陷
环境文件加载器在解析完成后直接调用 os.Setenv。操作系统环境块本质上是一组 KEY=VALUE 字符串列表,而非 shell 变量命名空间。Shell 赋值语法之所以更严格,仅仅是因为 shell 需要在其命令语言中对变量引用进行分词与展开。
SILO 的命名配置目标采用如下拼接格式:
例如:
目标发现(target discovery)按固定的 MINIO_<SUBSYSTEM>_<PARAMETER>_ 前缀枚举环境变量,并将剩余后缀原样作为目标名称;目标读取时同样使用该原样后缀重建环境变量名,不会将其转为大写或替换连字符。因此,在环境文件解析器中拒绝 - 会直接打断一条原本完整可用的“发现—解析”链路,却并没有保护任何 shell 执行路径——因为没有任何 shell 会去解析或执行 MINIO_CONFIG_ENV_FILE。
这一故障在运维层面尤为尖锐:MINIO_CONFIG_ENV_FILE 仅在进程启动时读取。服务器可能依赖内存中的旧环境变量持续正常运行,却在二进制升级或配置重载后的下一次重启时突然无法启动。面对畸形输入应当快速失败(fail fast),但文件解析器绝不能擅自发明比配置子系统更狭窄的目标语法。
文件语法
行与注释
- 忽略空行与仅含空白字符的行;
- 忽略第一个非空白字符为
#的整行注释; - 若行首存在后跟空白字符的独立
export关键字,在解析键名前先将其剥离; exportFOO=value的键名保持为exportFOO,不会被误判为export前缀;- 第一个
=用于分隔键与值,值中出现的后续=字符全部原样保留。
环境文件不是 shell 脚本,不执行 $VAR 变量展开、命令替换、反斜杠转义处理或行内 # 注释截断。
键名
键名两侧的空白字符会先被去除。处理后的键名必须满足:
- 非空且为合法 UTF-8;
- 仅包含 Unicode 图形字符(
unicode.IsGraphic); - 不包含空白字符、
=、NUL、控制字符或不可见的 Unicode 格式字符。
这一规则在完整保留操作系统兼容变量名与多目标后缀的同时,拒绝了视觉上为空或结构含糊的键名。以数字或标点符号开头的键名可通过语法解析;SILO 仍只读取自身配置与运行时组件实际使用的精确名称。
值与引号
未加引号的值会自动去除首尾空白。若需要保留有意义的首尾空格,请使用匹配的单引号或双引号包裹完整值:
解析器最多剥离一对匹配的外层引号,不解释引号内部的反斜杠转义。NUL 字节在任何情况下均被拒绝,因为操作系统底层环境项以 NUL 结尾的 C 字符串表示。
失败与保密契约
任何语法错误都会立即终止服务启动。诊断消息包含文件路径、从 1 开始的行号以及非法键名或错误类别,但绝不输出变量值,从而确保畸形行上的密码或密钥不会被写入启动日志。
解析过程遵循全有或全无(all-or-nothing)原则:任一行出现语法错误都会丢弃全部解析结果,只有整个文件校验通过后才会开始调用 os.Setenv。若操作系统拒绝某个已通过语法校验的赋值,SILO 同样会终止启动并指明键名与文件路径,绝不以“只加载了一半”的环境对外提供服务。
由于 MINIO_CONFIG_ENV_FILE 包含 root 凭据、KMS 密钥与 webhook 令牌等高权限机密,运维人员仍必须通过严格的文件系统属主与权限(如 0600)保护该文件。
回归测试矩阵
单元测试与配置子系统测试覆盖了以下场景:
=两侧的空格与制表符;- 使用单引号或双引号保留首尾空格的值;
- 独立
export前缀(包括其后紧跟 Unicode 空白字符); - 以
_、数字或标点符号开头的键名; - 包含
-、.、:与可打印 Unicode 字符的命名目标; - 通过配置子系统端到端发现并解析命名目标;
- 空键名、键名内部空白、NUL 字节以及零宽空格等不可见格式字符;
- 包含 NUL 字节的值;
- URL、查询字符串与 Base64 令牌中包含多个
=的值; - 严格脱敏变量值的文件路径与行号诊断;
- 语法错误时的全有或全无原子返回保障。
兼容性与 #65 热重启陷阱
常规环境配置无需任何迁移:标准环境变量名行为不变,包含空格的 = 赋值更加可预测,带连字符或点号的命名目标恢复正常工作。
以下四项外部可见行为是明确设计的契约:
- 非法或含不可见字符的键名会在启动时立即报错,不再静默忽略;
- 未加引号的值会自动去除首尾空白,需要保留首尾空格时必须加引号;
- 畸形输入会以带行号且脱敏的诊断信息阻止启动;
- 合法的标点命名目标不再仅仅因为 POSIX
sh无法直接通过NAME=value语法赋值而被拒绝。
对原始 #65 的调查还揭示了一个隐蔽的热重启陷阱:旧版解析器按 = 切分时未去除键名尾随空白,会将 KEY = new 保存为带尾随空格的键名 KEY 。在使用 systemd 冷启动时,由于 systemd 会自行解析 EnvironmentFile= 并去除 = 两侧空格,该缺陷往往被掩盖;但当管理员通过管理 API 触发原地热重启(mcli admin service restart)时,re-exec 出的新进程会从父进程继承 KEY=old,而环境文件中新解析出的 KEY 无法覆盖 KEY,导致服务虽重启成功却继续使用过期的 root 凭据、KMS 配置或 IdP 设置。Server 20260903 修复键名空白裁剪后,KEY = new 在冷启动与热重启下均会正常覆盖 KEY;若环境文件中曾包含带空格的 = 赋值,升级前请核对其配置值是否符合预期。
结论
通过在 silo-pkg/v3(1a0d788)中收紧 config.ReadEnvFile 词法规则并在服务端(a643483be)升级依赖,MINIO_CONFIG_ENV_FILE 在冷启动与原地热重启下具备完全一致的解析行为,同时保持对标准 Shell 环境文件的完整向后兼容。
45 - 两把 SSE-C 密钥,一份 CopyObject 响应
本文记录提交 e73436c99(已随 Server 20260903 发布)中的 CopyObject SSE-C 校验和响应修复。该修复仅作用于目标对象成功提交后的 CopyObject XML 与 HTTP 响应生成阶段;已存储的对象字节、校验和元数据、加密格式、源对象解密、联邦复制与站点复制均保持不变。
概览
一次 SSE-C CopyObject 请求可以同时携带两套相互独立的客户端加密密钥:
| 角色 | 请求头 | 用途 |
|---|---|---|
| 源对象 | X-Amz-Copy-Source-Server-Side-Encryption-Customer-* |
解密源对象及其已存储的元数据 |
| 目标对象 | X-Amz-Server-Side-Encryption-Customer-* |
加密并在提交后解释目标对象及其元数据 |
SILO 在写入阶段能够使用目标密钥正确加密目标对象并密封其校验和元数据。但在目标对象提交完成后,CopyObjectResult XML 生成器与通用的 PUT 成功响应头辅助函数都直接收到了完整的原始 CopyObject 请求头。由于校验和元数据解密函数 decryptChecksums 在检测到 X-Amz-Copy-Source-Server-Side-Encryption-Customer-* 请求头存在时会优先使用该请求头——这一优先级在读取源对象阶段是完全正确的——导致它在解释已提交的目标对象元数据时误用了源密钥。
当源密钥为 A、目标密钥为 B 时,旧流程表现为:
落盘的目标对象与加密校验和元数据本身完全正确,缺陷仅出现在一次性的成功响应中。修复方案通过克隆请求头并剥离三个 copy-source SSE-C 请求头来构造目标响应视图,使用目标密钥 B 对目标校验和元数据只执行一次解密,并将得到的映射表同时复用于 CopyObjectResult XML 响应体与 HTTP 响应头。
可观察故障
当目标对象包含校验和且源、目标使用不同的 SSE-C 密钥上下文时,即可复现该故障:
在修复前:
CopyObject返回 HTTP 200;- 使用密钥
B对目标对象执行GetObject可读取正确的明文正文; - 使用密钥
B对目标对象执行HeadObject可正常解密并返回x-amz-checksum-crc32与x-amz-checksum-type; - 但
CopyObject当次返回的 XML 响应体与 HTTP 响应头中却缺失了ChecksumCRC32/x-amz-checksum-crc32与ChecksumType/x-amz-checksum-type。
同样的密钥遮蔽问题也会出现在同对象原地换钥(即将对象复制到自身并更换为新 SSE-C 密钥 B)场景中:尽管目标元数据已用新密钥 B 重新密封,请求中仍保留着代表旧源对象的 copy-source 密钥 A,导致换钥响应丢失校验和字段。
为什么不能修改全局解密器
decryptChecksums 优先读取 copy-source SSE-C 请求头本身并非缺陷。在 CopyObject 的提交前阶段,服务端需要先检查源对象的校验和元数据,以决定是直接沿用源算法、将分片组合校验和重算为整对象校验和,还是为无校验和源对象补充默认的 CRC64NVME 校验和。对于 SSE-C 源对象,这份源元数据由源密钥保护,必须使用 X-Amz-Copy-Source-Server-Side-Encryption-Customer-* 请求头才能解密。
如果简单地将 decryptChecksums 的全局优先级反转为“目标 SSE-C 请求头优先”,虽然能修复提交后的响应,却会破坏提交前的源校验和解析。正确的安全边界必须按时间阶段与操作对象严格区分:
实现设计
目标响应请求头视图
在目标对象提交成功后,处理器克隆请求头映射并精确删除以下三个源端 SSE-C 请求头:
X-Amz-Copy-Source-Server-Side-Encryption-Customer-AlgorithmX-Amz-Copy-Source-Server-Side-Encryption-Customer-KeyX-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-MD5
常规的目标端 X-Amz-Server-Side-Encryption-Customer-* 请求头保持不变。SSE-S3 与 SSE-KMS 目标元数据不依赖客户端提供的密钥请求头,继续沿用原有路径。
解密一次,投影两次
修复前,CopyObject 在构造 CopyObjectResult XML 时调用一次 decryptChecksums,随后在 setPutObjHeaders 写入成功响应头时又调用一次。对于 SSE-S3 或 SSE-KMS 对象,这会导致重复执行主密钥或 KMS 解封操作。
修复后的流程只执行一次目标校验和解密,并将结果投影到两处输出:
通用的 setPutObjHeaders 包装函数仍继续服务于 PutObject、CompleteMultipartUpload 与 DeleteObject;CopyObject 则直接调用接收已解密校验和映射表的内部辅助函数,确保 ETag、VersionID、删除标记、生命周期预测与校验和响应头共享同一套逻辑。
回归测试矩阵
加密与 API 测试套件覆盖了以下组合:
- 明文源对象复制到 SSE-C 目标对象;
- 压缩与未压缩的 SSE-C 目标对象;
- SSE-C 源密钥
A复制到 SSE-C 目标密钥B; - 同时校验
CopyObjectResultXML 与 HTTP 响应头中的校验和摘要及ChecksumType; - 验证持久化目标校验和可用密钥
B解密且与目标正文匹配; - 同对象从密钥
A到密钥B的原地 SSE-C 换钥及换钥后的校验和响应; SSE-S3源与目标组合在各纠删码后端上的表现。
兼容性与运维影响
CopyObject成功响应: 当已提交的目标对象包含校验和时,此前在跨密钥 SSE-C 复制中缺失的校验和值与ChecksumType现已在 XML 响应体和 HTTP 响应头中完整返回。- 已存储对象: 无需重写数据、无需修改元数据或加密格式,也无需数据迁移;存量对象的持久化校验和原本就是正确的。
- 性能开销: 每次
CopyObject响应的元数据校验和解密次数从两次减为一次,不增加任何对象数据读取或哈希计算开销。 - 滚动升级与回滚: 滚动升级窗口内,新节点会返回完整校验和字段,旧节点可能省略该字段,但两端写入的对象数据完全互操作。
结论
在构造 CopyObjectResult XML 响应前对 ObjectInfo 执行元数据解密(21870fa2e),使 SSE-C 与 KMS 加密对象的 CopyObject 响应恢复返回已持久化的校验和字段,且不改变底层存储布局与加密语义。
46 - ListMultipartUploads 行为契约与设计权衡
本文记录 ListMultipartUploads 在完整工程生命周期中的设计与演进:Server 20261018 当前采用的 MinIO 兼容契约、16 盘纠删集容量压测后于 10 月 18 日执行的部分回滚、10 月 17 日无条件磁盘扫描尝试(#243,合入提交 7e9454456)、9 月 16 日可选 strict 模式(#198),以及 8 月 30 日的原始根因分析。
Server 20261018 当前行为契约
Server 20261018 在 main 分支上恢复了传统的 MinIO 兼容公共 ListMultipartUploads 契约(提交 4733e1423,随后由 0f9199e0c 补齐空父目录清理修复):
- 不带
prefix时,ListMultipartUploads返回当前节点内存分片缓存(multipartUploadsCache)中跟踪的上传记录。 - 带非空
prefix时,ListMultipartUploads将prefix视为精确对象键(sha256(bucket/object)),直接检查磁盘上该对象对应的分片上传目录。 delimiter、key-marker、upload-id-marker与encoding-type仍由 HTTP 处理器解析并在需要时校验(如encoding-type=url)及回显到 XML 响应中,但候选集选取遵循 MinIO 兼容的“内存缓存 / 精确对象键”路径,不再对.minio.sys/multipart执行全盘扫描。UploadID、GetMultipartInfo、PutObjectPart、ListObjectParts、CompleteMultipartUpload与AbortMultipartUpload在进程重启后仍可凭Bucket + Object + UploadID正常工作,因为活跃上传持久化在磁盘.minio.sys/multipart/<sha256(bucket/object)>/<uploadID>下。MINIO_API_MULTIPART_LISTING与api multipart_listing保留为兼容性空操作(no-op),旧脚本或已保存配置无需修改即可继续启动。GET /minio/admin/v3/multipart-preflight保持可用,固定返回mode: "legacy"且不兼容上传数为0。
为什么在 2026-10-18 回滚默认磁盘扫描
在 2026-10-17(#243,提交 4ed86dfe6)至 2026-10-18(提交 4733e1423)之间,main 曾短暂将所有公共 ListMultipartUploads 请求统一切换为对 .minio.sys/multipart 的无索引磁盘扫描。针对 16 盘纠删集的容量分析与基准测试表明:将该磁盘扫描设为无条件默认路径,会引入比原有列表限制严重得多的可用性故障模式。
为什么进程重启不会删除未完成的分片上传
未完成的分片上传持久化在磁盘路径 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID>/xl.meta 与 part.* 下。服务进程重启只会清空内存中的 multipartUploadsCache,不会删除磁盘上的未完成上传。因此,客户端持有的 UploadID 在重启后依然有效,直至完成合并、显式中止(Abort),或被后台过期清理任务回收(默认过期时间 7 天,巡检周期 12 小时)。
由于未完成上传会跨重启和客户端重试不断累积,任何遍历磁盘 .minio.sys/multipart 的操作,其开销都取决于纠删集当前存留的未完成上传总数,而非仅取决于本次进程启动后新建的上传数。
100,000 目录项保护阈值在 16 盘纠删集上的失效机制
10 月 17 日的磁盘扫描实现在整个纠删集所有磁盘合计的粒度上施加了 maxMultipartWalkEntries = 100000 上限,将每块磁盘上的 <sha256(bucket/object)> 目录项与 <uploadID> 目录项一并计入预算。
对于包含 $N$ 块磁盘、跨 $H$ 个不同对象键共有 $U$ 个活跃上传的纠删集,一次未命中缓存的桶级 ListMultipartUploads 扫描需要遍历的目录项总数为:
当每个上传对应不同对象键($H = U$)时:
- 在 4 盘纠删集上,$4 \times (10{,}000 + 10{,}000) = 80{,}000$ 个目录项,恰好落在
100,000阈值以内。 - 在 16 盘纠删集上,$16 \times (3{,}126 + 3{,}126) = 100{,}032$ 个目录项——整个纠删集(所有桶合计)只要累积到 3,126 个活跃上传,就会突破
100,000上限,导致所有公共列表请求直接返回 HTTP503 SlowDown。 - 即便所有上传都集中在同一个对象键($H = 1$)下,16 盘纠删集在单键累积到 6,250 个上传时也会达到 $16 \times (1 + 6{,}250) = 100{,}016$ 个目录项,使精确对象键查询(
prefix=<exact-object-key>)同样因503 SlowDown失败。
演进脉络与问题定责
| 变更阶段 | 日期 / 提交 | 具体改动 | 工程评估 |
|---|---|---|---|
引入可选 strict 模式(#198) |
2026-09-16 / 4cbb074cc |
在 MINIO_API_MULTIPART_LISTING=strict 后提供磁盘扫描列表,默认保持 legacy |
提供了安全的按需启用边界,但最初缺少目录遍历硬上限 |
安全加固(SN-2026-009..012) |
2026-09-16 / 143f6970d |
增加 checkUploadID、maxMultipartWalkEntries = 100000、503 SlowDown 映射及 readQuorum 过滤 |
为磁盘扫描补齐了必要的 DoS 保护,但按全部 $N$ 块盘的原始目录项总数计费 |
无条件统一为磁盘扫描(#243) |
2026-10-17 / 4ed86dfe6 |
移除 legacy 分支,将所有公共 ListMultipartUploads 强制路由到磁盘扫描 |
步子迈得过大:仅凭 4 盘单键基准测试就将无索引磁盘扫描设为必经路径,在 16 盘纠删集上于 3,126 个上传处触发 503 SlowDown |
部分回滚方案权衡对比
| 备选方案 | 公共 S3 列表语义完整度 | 16 盘纠删集容量表现 | 存储格式与后台 I/O 影响 | 决策 |
|---|---|---|---|---|
保留 #243 无条件磁盘扫描 |
在 16 盘 3,125 个上传以内支持完整前缀/分隔符/游标分页 | 在全集累积 3,126 个不同键上传时立刻触发 503 SlowDown |
每次未缓存列表产生 16 × (1 + H) 次 ListDir RPC |
淘汰 |
调高或移除 maxMultipartWalkEntries |
超过 3,126 个上传后仍能继续扫描 | 重新打开无索引 .minio.sys/multipart 上的无界 $O(N \times U)$ 磁盘遍历与内存分配风险 |
并发 GET /bucket?uploads 下延迟与文件描述符/RPC 放大严重 |
淘汰 |
| 构建持久化按桶分片索引 | 无需全盘扫描 sha256(bucket/object) 即可支持完整 S3 列表 |
开销仅与匹配前缀的上传数成正比 | 需要全新的分布式元数据索引、自愈协议与崩溃恢复契约 | 延后(需独立立项设计索引) |
对 #198、143f6970d 与 #243 执行完整 git revert |
恢复 MinIO 兼容列表行为 | 无 100,000 目录项悬崖 |
会连带丢弃 checkUploadID 路径穿越防护、Abort 缓存/节点通知清理以及 xl.meta 身份元数据 |
淘汰 |
外科手术式部分回滚(4733e1423 + 0f9199e0c) |
恢复 MinIO 兼容公共列表(空前缀走缓存、非空前缀走精确键查盘) | 消除跨桶 100,000 目录项悬崖;精确键查盘每块盘仅访问 1 个哈希目录 |
完整保留 xl.meta 身份字段、checkUploadID、Abort 清理与空哈希父目录回收 |
采纳 |
回滚内容与保留的修复项
- 在公共列表路径上回滚的部分(
4733e1423):- 公共
ListMultipartUploads不再遍历.minio.sys/multipart下的全部<sha256(bucket/object)>目录。 - 空
prefix直接读取内存multipartUploadsCache;非空prefix直接计算bucket/prefix的 SHA-256 哈希,仅检查该对象对应的单个<sha256(bucket/object)>目录。
- 公共
- 保留下来的安全加固与存储修复(
4733e1423+0f9199e0c):NewMultipartUpload仍在xl.meta中写入x-minio-internal-multipart-v1-bucket与x-minio-internal-multipart-v1-object,使落盘上传具备自描述能力,便于诊断及未来构建索引。- 所有分片操作继续强制执行
checkUploadID校验(SN-2026-009)。 AbortMultipartUpload继续同步驱逐本地缓存并通知集群对端节点。cleanupStaleUploads继续在后台清理磁盘上的过期上传,且提交0f9199e0c确保当.minio.sys/multipart/<sha256(bucket/object)>下最后一个<uploadID>因过期清理、合并完成或显式中止而被删除时,空的<sha256(bucket/object)>父目录也会一并删除,防止空哈希目录长期堆积。
排序成本与发现成本的本质冲突
在 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID> 布局中,目录名是 bucket/object 的 SHA-256 摘要,既打散了对象键的字典序,也抹去了桶边界。在没有二级索引的前提下,要按 S3 字典序回答任意前缀查询(如 prefix=trips/),存储引擎必须先打开纠删集内所有桶、所有活跃上传下的 xl.meta 才能还原 (Bucket, Object) 并排序分页。相比之下,精确对象键查询(prefix=trips/2026/a.mp4)只需计算一次 SHA-256 并读取每块盘上的单个目录(将 ListDir 次数从 $O(N \times H)$ 降为 $O(N)$)。
提交 4733e1423 上的容量验证基准
使用 go test -run=^$ -bench=BenchmarkMultipartPublicListingCapacity -benchmem -benchtime=1x ./cmd 实测结果如下:
测试场景(BenchmarkMultipartPublicListingCapacity) |
回滚前(4ed86dfe6 磁盘扫描) |
回滚后(4733e1423 MinIO 兼容路径) |
延迟(4733e1423) |
内存分配 / 次数(4733e1423) |
|---|---|---|---|---|
4_drives_10000_distinct_keys/empty_prefix_cached |
200 OK(在磁盘上遍历 80,000 个目录项) |
200 OK(从内存缓存返回 1,000 条上传) |
1.12 ms |
257,104 B / 14 allocs |
16_drives_3126_distinct_keys/empty_prefix_cached |
503 SlowDown(100,032 项 > 100,000 上限) |
200 OK(从内存缓存返回 1,000 条上传) |
0.36 ms |
90,208 B / 14 allocs |
16_drives_3126_distinct_keys/exact_key_after_cache_clear |
503 SlowDown(遍历全部 3,126 个哈希目录) |
200 OK(清空缓存后从磁盘返回 1 条上传) |
0.78 ms |
30,024 B / 389 allocs |
16_drives_10000_uploads_one_key/exact_key_lookup |
503 SlowDown(单键在 6,250 个上传处超限) |
200 OK(从磁盘返回 1,000 条上传) |
383.85 ms |
13,721,672 B / 236,704 allocs |
关联文档记录
| 文档 | 说明 |
|---|---|
| S3 API 兼容性 — 分片上传 API 差异说明 | Server 20261018 中 ListMultipartUploads 的权威用户侧兼容性说明 |
| SILO 20260916 发布注记 | 记录已发布的 legacy 默认、strict 可选、UploadID 校验和取消边界 |
历史记录:10 月 17 日统一磁盘扫描尝试(#243)
2026-10-17 合并的 PR #243(提交 4ed86dfe6)曾短暂移除 legacy/strict 模式开关,将所有 ListMultipartUploads 请求统一路由到 #198 引入的磁盘扫描实现。尽管该默认路由在次日由 4733e1423 回滚,下文仍完整保留 #243 的架构决策记录,以便查阅 .minio.sys/multipart 的落盘格式、法定人数判定规则与并发中止竞态分析。
10 月 17 日架构决策记录
10 月 17 日实验的目标契约
10 月 17 日的统一尝试旨在不引入外部元数据数据库的前提下,让 ListMultipartUploads 同时满足以下三条不变式:
- 跨重启持久可见:
ListMultipartUploads直接从磁盘.minio.sys/multipart读取活跃上传,而非仅依赖内存multipartUploadsCache。 - 完整的 S3 过滤与分页语义:将
prefix作为对象键前缀匹配,支持delimiter折叠CommonPrefixes,并按(Key, Initiated, UploadID)排序及执行key-marker/upload-id-marker分页。 - 自描述上传元数据:
NewMultipartUpload在每个上传的xl.meta内写入桶名与对象键,使<sha256(bucket/object)>目录下的上传可反向还原出(Bucket, Object)。
当时尝试统一两种模式的背景
在 #198 之前,调用方根据 prefix 为空、为目录前缀或为完整对象键,会观察到截然不同的行为:
| 请求示例 | 传统 MinIO / legacy 行为 |
S3 标准 / 磁盘扫描行为 |
|---|---|---|
GET /photos?uploads(CreateMultipartUpload 后立即调用) |
返回当前节点内存缓存中记录的上传 | 返回磁盘上达到读法定人数的活跃上传 |
GET /photos?uploads(服务进程重启后) |
返回空列表(重启清空了 multipartUploadsCache) |
仍能从 .minio.sys/multipart 返回未完成的活跃上传 |
GET /photos?uploads&prefix=2026/(目录前缀) |
除非存在字面名为 2026/ 的对象(sha256("photos/2026/")),否则返回空列表 |
返回对象键以 2026/ 开头的所有活跃上传 |
GET /photos?uploads&prefix=2026/trip.mov(精确对象键) |
直接查磁盘 .minio.sys/multipart/<sha256("photos/2026/trip.mov")>(跨重启可见) |
返回对象键以 2026/trip.mov 开头的活跃上传 |
Issue #198(2026-09-16)与 #213 最初通过 MINIO_API_MULTIPART_LISTING=legacy|strict 同时保留两种模式,避免升级前遗留的旧上传静默不可见。PR #243(2026-10-17)在补充惰性回填逻辑后,尝试收敛这一配置分叉。
#243 评估过的备选方案
| 候选方案 | 跨重启可见性 | 前缀与游标支持 | 额外索引维护成本 | 当时取舍与后续复盘结论 |
|---|---|---|---|---|
1. 维持 legacy + strict 双模式 |
仅 strict 支持 |
仅 strict 支持 |
无 | #243 为消除双模式而放弃;4733e1423 重新回到兼容路径 |
| 2. 仅使用内存缓存 | 不支持 | 仅在缓存存活期内支持 | 无 | 淘汰:重启后丢失全部列表可见性 |
3. 扫描 .minio.sys/multipart + xl.meta 身份字段(#243) |
支持 | 支持 | 无 | #243 选中;因 $O(N \times (H+U))$ 遍历在 16 盘 3,126 个上传处触发 100k 上限而于 4733e1423 回滚 |
| 4. 每个上传附加旁路 JSON 文件 | 支持 | 支持 | 每次创建/中止多一次文件读写 | 淘汰:元数据文件 I/O 翻倍 |
| 5. 按桶维护磁盘分片索引树 | 支持 | 支持(仅与匹配前缀成正比) | 需配套分布式索引自愈与清理协议 | 延后:大规模纠删集唯一可行的长期路径,但需独立设计崩溃恢复与自愈 |
| 6. 外部 KV / SQLite 索引 | 支持 | 支持 | 在纠删码体系外引入外部状态 | 淘汰:违背纠删集自包含架构原则 |
磁盘存储布局与 xl.meta 身份字段
每个活跃分片上传在磁盘上的存储路径为:
由于 <sha256(bucket/object)> 是单向哈希摘要,仅凭目录路径无法反推出桶名或对象键。提交 4cbb074cc 扩展了 cmd/erasure-multipart.go 中的 newUploadedObjectMetadata,在 xl.meta 内写入两个内部元数据键(在 20261018 中继续保留):
x-minio-internal-multipart-v1-bucket:明文桶名x-minio-internal-multipart-v1-object:明文对象键
针对上游 MessagePack xl.meta 读取器的兼容性验证(对照提交 3be10fcc1a44f6620ded0bd303461f9d688cca23):MetaSys 在 xl.meta 中是标准的 map[string][]byte 映射,旧版本读取器会自动忽略未知的 x-minio-internal-* 键,不会触发反序列化错误。
磁盘扫描路径中的发现与法定人数规则
当 #198 与 #243 执行磁盘扫描(listMultipartUploadsScan)时,每个纠删集按以下五步工作:
- 在所有在线磁盘上列出
.minio.sys/multipart下的<sha256(bucket/object)>目录,要求每个哈希目录至少出现在readQuorum = floor(N/2) + 1块磁盘上。 - 列出每个满足法定人数的哈希目录下的
<uploadID>子目录,使用checkUploadID校验每个uploadID并要求满足readQuorum。 - 对每个候选
uploadID从在线磁盘读取xl.meta,要求至少readQuorum块磁盘对(Bucket, Object)身份达成一致。 - 若升级前遗留的旧上传缺少
x-minio-internal-multipart-v1-*键,则在精确前缀或缓存命中提供候选键时验证哈希并惰性回填xl.meta。 - 按
Bucket、Prefix、Delimiter、KeyMarker与UploadIDMarker过滤,按(Key, Initiated, UploadID)排序并跨存储池归并。
目录遍历成本与 4 盘基准测试(#243)
在包含 $N$ 块磁盘、跨所有桶共有 $H$ 个活跃 <sha256(bucket/object)> 目录和 $U$ 个活跃 <uploadID> 目录的纠删集上:
- 每次未缓存扫描检查的目录项总数:$N \times (H + U)$
- 每次未缓存扫描发起的
ListDir调用总数:$N \times (1 + H)$
在 #243 提交时,BenchmarkMultipartListingCapacity 仅在 4 盘单纠删集夹具($N = 4$)上针对单个对象键下的 10,000 个上传($H = 1, U = 10{,}000$)进行了测试,总目录项为 $4 \times (1 + 10{,}000) = 40{,}004$:
4 盘测试夹具(4ed86dfe6 上的 BenchmarkMultipartListingCapacity) |
活跃上传数 | 耗时 | 内存分配量 | 分配次数 |
|---|---|---|---|---|
| 单对象键($H = 1, U = 10{,}000, N = 4$) | 10,000 |
101.6 ms |
3.58 MB |
61,210 |
这一 4 盘单键基准测试掩盖了次日在 16 盘纠删集上暴露的两个乘数因子:$N = 16$ 块磁盘($4\times$ 倍率)与不同对象键 $H = U$(目录项 $2\times$ 倍率、ListDir 次数 $U\times$ 倍率),两者叠加使 3,126 个上传即突破 maxMultipartWalkEntries = 100000。
16 盘 EC:8 纠删集上的 Abort 与延迟创建竞态分析
在 #243 评审期间,我们还分析了在固定 readQuorum = floor(N/2) + 1(例如 16 盘 EC:8 纠删集上为 9)下,AbortMultipartUpload 是否可能因延迟写入而出现“死而复生”的上传:
- 假设
NewMultipartUpload先在8块快盘上完成了xl.meta写入,另有7块盘的写入仍在飞行途中阻塞,1块盘离线。 - 此时并发到达的
AbortMultipartUpload删除了前8块快盘上的副本并返回成功。 - 随后那
7个延迟的NewMultipartUpload写入落盘,且离线的1块盘恢复或自愈后补齐副本——此时磁盘上重新凑齐了8份可读副本(达到EC:8对象读取法定人数),使客户端已中止的上传重新变为可操作状态。
尽管 4733e1423 保留的 AbortMultipartUpload 缓存驱逐与对端节点通知能够保持内存缓存一致,但要在磁盘层面彻底消除延迟创建复活,仍需引入墓碑(tombstone)或按上传维度的代际锁。
10 月 17 日代码改动汇总(后续已部分回滚)
cmd/object-api-interface.go、cmd/server-main.go、cmd/config-current.go:将api multipart_listing设为兼容性空操作(4ed86dfe6;在4733e1423中继续保留为空操作)。cmd/erasure-multipart.go:增加精确键解析时的xl.meta惰性回填,并将全部公共列表路由到磁盘扫描(4ed86dfe6;公共扫描路由已在4733e1423回滚,xl.meta写入元数据与0f9199e0c空父目录清理予以保留)。
10 月 17 日测试证据
go test ./cmd -run 'TestMultipartUploadListing|TestMultipartPreflight|TestMultipartListingConfig'在4ed86dfe6上全部通过。BenchmarkMultipartListingCapacity在 4 盘单键夹具上耗时101.6 ms,但未能覆盖 16 盘多键拓扑下的阈值悬崖。
#243 的后续工程教训
未来任何试图提供“跨重启持久且支持任意前缀”的 ListMultipartUploads 方案,必须按 (bucket, object, uploadID) 建立索引,严禁在公共列表路径上跨纠删集所有磁盘扫描 .minio.sys/multipart/<sha256(bucket/object)> 目录,且必须在 16 盘不同对象键($H = U$)拓扑下完成容量基准验证。
历史记录:9 月 16 日可选 strict 模式(#198)
Issue #198(提交 4cbb074cc,并由 143f6970d 与 0ff5b37fc 加固)在 Server 20260916 中交付了最初的按需启用实现:
- 新增
MINIO_API_MULTIPART_LISTING=legacy|strict(默认legacy)及mcli admin config set/reset ALIAS api multipart_listing。 - 新增
GET /minio/admin/v3/multipart-preflight,供运维人员在开启strict前检查磁盘.minio.sys/multipart中是否存在缺少x-minio-internal-multipart-v1-bucket与x-minio-internal-multipart-v1-object的历史上传。 - 在
strict模式下实现按(Key, Initiated, encoded UploadID)的 S3 字典序排列与floor(N/2) + 1磁盘发现法定人数。 - 9 月 16 日 Docker Desktop 环境实测记录:跨
4个容器的10,000个活跃上传在虚拟化容器文件系统上因逐盘读取xl.meta耗时18.7 s(并发度10下p95 = 2.68 s)。
原始根因分析(2026-08-30)
问题概述
在上游 MinIO 的纠删码存储引擎(erasureObjects)中,CreateMultipartUpload 将每个分片上传持久化在磁盘路径 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID> 下,同时将该上传写入按桶组织的内存缓存(multipartUploadsCache)。由于磁盘目录名对 bucket/object 做了 SHA-256 哈希,存储层在已知精确 bucket 和 object 时可以直接定位到对应目录;但在不打开每个活跃上传目录的前提下,无法直接从磁盘按桶或按对象键前缀列出上传。
8 月 30 日的原始建议
8 月 30 日的调研建议分两阶段改进 ListMultipartUploads:第一步先在 NewMultipartUpload 时向 xl.meta 写入 (bucket, object) 身份元数据并提供按需启用的磁盘扫描模式(#198);第二步再评估是否将磁盘扫描或专用按桶索引设为默认行为。
证据与信息来源
AWS S3 规范要求
AWS S3 ListMultipartUploads 规范要求支持 prefix 与 delimiter 过滤,按 Key 与 Initiated 时间升序排列(时间相同时按 UploadId 排序),并通过 key-marker 与 upload-id-marker 实现分页续传。
仓库 Issue #198
Issue #198 报告:带目录前缀(如 prefix=dir/)调用 ListMultipartUploads 时,即使刚刚执行完 CreateMultipartUpload 也会返回空列表;而不带前缀的 ListMultipartUploads 在服务进程重启后同样返回空列表,尽管使用之前保存的 UploadId 继续调用 UploadPart 和 CompleteMultipartUpload 完全正常。
上游 MinIO 历史演进
- MinIO PR
#5248:将早期镜像桶目录结构的分片上传树替换为.minio.sys/multipart/<sha256(bucket/object)>/<uploadID>,以规避 POSIX 文件系统上的超长路径限制与空父目录清理缺陷。 - MinIO PR
#20407:引入内存multipartUploadsCache,使空prefix的ListMultipartUploads无需扫盘即可返回近期创建的上传;同时将非空prefix直接作为精确对象键传给listMultipartUploads(bucket, prefix, ...)。 - MinIO Issue
#20989:在docs/minio-limits.md中正式记录了这一上游限制:ListMultipartUploads仅支持空前缀(读内存)或将prefix设为完整对象键。
pgsty/silo 代码走查
cmd/bucket-handlers.go(ListMultipartUploadsHandler):解析了prefix、keyMarker、uploadIDMarker、delimiter与maxUploads,校验encoding-type后传给objectAPI.ListMultipartUploads。cmd/erasure-sets.go(erasureSets.ListMultipartUploads):按prefix == ""分支——为空时读取multipartUploadsCache.List(bucket);非空时对pathJoin(bucket, prefix)计算哈希并调用单哈希目录上的set.listMultipartUploads。cmd/erasure-multipart.go(erasureObjects.listMultipartUploads):仅列出.minio.sys/multipart/<sha256(bucket/object)>下的<uploadID>子目录,忽略了delimiter、keyMarker与uploadIDMarker。cmd/erasure-server-pool.go(erasureServerPools.ListMultipartUploads):在prefix != ""时顺序遍历各存储池,并在首个返回非空结果的存储池处提前返回。
设计评审结论
初步代码评审确认该问题属于存储布局层面的架构取舍,而非简单的参数解析遗漏:HTTP 处理器完整解析了所有 S3 查询参数,但底层存储缺少从 bucket + key-prefix 到 <sha256(bucket/object)> 的索引映射。
#198 之前的历史行为(即 20261018 恢复的默认行为)
空 prefix 路径
当 prefix == "" 时:
erasureSets.ListMultipartUploads直接读取目标桶的内存multipartUploadsCache。- 进程重启前创建的上传不在内存缓存中,因此不会出现在返回结果里,尽管磁盘
.minio.sys/multipart/<sha256(bucket/object)>/<uploadID>目录依然完好。 - 分布式节点间的缓存同步采用尽力而为机制(
updateMultipartUploadInfo)。
非空 prefix 路径
当 prefix != "" 时:
erasureSets.ListMultipartUploads将prefix视为完整对象名(object = prefix),计算bucket/object的 SHA-256 哈希以定位目标纠删集,并读取磁盘上的.minio.sys/multipart/<sha256(bucket/object)>。- 若调用方传入的是目录前缀(如
prefix=photos/2026/),服务端会对字面字符串"bucket/photos/2026/"计算哈希,因不存在该精确名称的对象而返回空列表。 - 若调用方传入精确对象键(如
prefix=photos/2026/video.mp4),服务端会定位到"bucket/photos/2026/video.mp4"对应的哈希目录,从磁盘读取匹配的<uploadID>子目录,即使经历过服务重启也能返回该对象下的全部活跃上传。
多存储池提前返回机制
在 erasureServerPools.ListMultipartUploads 中,非空 prefix 的提前返回对“精确对象键”是自洽的,因为 sha256(bucket/object) 确定性地映射到唯一存储池中的唯一纠删集;而若要支持任意前缀列表,则必须归并所有存储池的结果。
为什么缺少候选键时无法自动回填历史上传
由于 20260916 之前的 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID>/xl.meta 既未记录桶名也未记录对象键,磁盘上遗留的历史上传目录仅包含 bucket/object 的 SHA-256 摘要。在既不知道候选 bucket/object 明文(无法正向计算哈希比对)、xl.meta 中又没有 x-minio-internal-multipart-v1-bucket 与 x-minio-internal-multipart-v1-object 的情况下,服务端无法逆向还原出 (Bucket, Object)。
结构性复杂度根源
| 层次 | 隐含假设 | 直接后果 |
|---|---|---|
S3 HTTP API(ListMultipartUploadsHandler) |
prefix 是对象键前缀;delimiter 折叠公共前缀;游标在有序键集合上分页 |
要求底层按桶提供有序的 (Key, Initiated, UploadID) 候选集 |
磁盘布局(.minio.sys/multipart/<sha256(bucket/object)>) |
调用方总是提供精确的 bucket 与 object(如 PutObjectPart、Complete、Abort) |
精确键查找为 $O(1)$,但既无按桶分区,也无字典序索引 |
内存缓存(multipartUploadsCache) |
仅跟踪本次进程启动以来观察到的分片上传 | 空 prefix 按桶列出速度快,但进程重启即清空 |
运维与兼容性影响
协议兼容性影响
使用目录 prefix 调用 ListMultipartUploads 的 SDK 与备份工具(例如 aws s3api list-multipart-uploads --bucket B --prefix dir/)在 prefix 不等于完整对象键时会收到空列表。
存储与孤儿上传清理影响
依赖重启后或按目录前缀调用 ListMultipartUploads 来发现并中止残留上传的外部工具,无法通过列表接口看到这些上传;但 Silo 内置的后台过期清理任务(cleanupStaleUploads)直接按修改时间遍历 .minio.sys/multipart,不依赖 ListMultipartUploads 即可自动回收过期分片。
运维可用性权衡
正如 2026-10-18 所验证的,强制 ListMultipartUploads 在每次未命中缓存的公共请求上遍历 .minio.sys/multipart,会把一个列表过滤限制变成 16 盘纠删集在 3,126 个活跃上传处的 503 SlowDown 可用性悬崖。保持 MinIO 兼容路径能够保护集群级 ListMultipartUploads 的稳定性,同时继续支持精确对象键查盘与后台过期回收。
严重度分级
- 数据完整性风险:无(
PutObjectPart、CompleteMultipartUpload与AbortMultipartUpload均按精确Bucket + Object + UploadID寻址)。 - S3 API 兼容性差异:已在 S3 API 兼容性参考中明确列出。
设计备选方案对比
方案 0:维持现状且不补充说明
会让调用方在 prefix=dir/ 返回空列表时难以排查。已淘汰,改为明确记录兼容性契约并保留 xl.meta 自描述元数据。
方案 1:保持 MinIO 兼容行为并明确记录契约(20261018 最终选择)
空前缀列表读取内存缓存,非空 prefix 读取单对象键磁盘目录,同时保留 xl.meta 身份字段并补齐空哈希父目录清理(0f9199e0c)。在 16 盘扫描回归验证后于 20261018 选定。
方案 2:基于 xl.meta 身份字段直接扫描磁盘 .minio.sys/multipart
在 #198 中作为可选 strict 模式实现,并在 #243 中短暂设为默认。在小规模上传下可用,但开销随纠删集内全桶上传数呈 $O(N \times (H + U))$ 增长,在 16 盘纠删集上于 3,126 个不同键上传处触及 100,000 遍历上限。
方案 3:构建按桶组织的专用分片上传索引
在 .minio.sys/multipart 旁维护按桶隔离、按前缀有序的元数据索引。可避免跨桶扫描 SHA-256 目录,但需要在每次 CreateMultipartUpload、CompleteMultipartUpload、AbortMultipartUpload 与过期清理时同步维护并自愈第二套分布式元数据结构。
方案 4:仅在内存缓存层扩展前缀与分隔符过滤
仅对内存 multipartUploadsCache 执行完整的 S3 prefix、delimiter 与游标过滤,不扫盘。速度快且开销有界,但重启前创建的上传在被精确键操作触达前依然不可见。
磁盘扫描参考架构(于 #198 实现,公共路径已于 4733e1423 回滚)
1. 先行冻结契约
在改动存储层前明确定义 (Key, Initiated, UploadID) 排序规则与游标续传边界。
2. 写入路径(xl.meta 身份写入)
在 NewMultipartUpload 中向 xl.meta 写入 x-minio-internal-multipart-v1-bucket 与 x-minio-internal-multipart-v1-object(20261018 已保留)。
3. 读取路径
从磁盘读取分片元数据时要求跨磁盘达到 readQuorum 一致。
4. 语义过滤层
在单纠删集发现层之上统一执行 Prefix、Delimiter、KeyMarker、UploadIDMarker 与 MaxUploads 过滤。
5. 精确键辅助函数
为内部调用方与精确键查询保留 $O(1)$ 的 sha256(bucket/object) 查盘辅助函数。
6. 缓存同步
在 AbortMultipartUpload 与 CompleteMultipartUpload 时跨对端节点同步驱逐 multipartUploadsCache 条目(20261018 已保留)。
7. 迁移与预检
提供 /minio/admin/v3/multipart-preflight 端点以检查磁盘分片目录状态(20261018 保留为兼容端点)。
8. 降级与挂起存储池处理
在多存储池列表时安全跳过挂起(suspended)的存储池,避免单池挂起导致整体请求失败。
#198 原型验证清单
- 验证
MetaSysMessagePack 字段与既有xl.meta读取器的前向/后向兼容性。 - 测量跨 $N$ 块磁盘的
ListDir与readAllI/O 放大倍数。 - 验证
cleanupStaleUploads在清理最后一个上传后同步删除空的<sha256(bucket/object)>父目录(已于0f9199e0c修复)。
验证门禁
单元测试
覆盖前缀、分隔符、游标分页以及 checkUploadID 路径穿越拒绝测试。
处理器与端到端测试
验证 ListMultipartUploadsHandler 响应 XML、URL 编码以及跨重启的缓存与精确键查盘行为。
故障与容量基准测试
在 4 盘与 16 盘纠删集拓扑下运行高达 10,000 个活跃上传的 BenchmarkMultipartPublicListingCapacity。
交付门禁
在 S3 API 兼容性参考中同步记录公共 ListMultipartUploads 契约,并保持所有历史锚点完整可用。
当前工程结论
在 Server 20261018 中,以 MinIO 兼容公共 ListMultipartUploads 路径(4733e1423)配合空父目录清理修复(0f9199e0c)作为生产默认实现。不要在公共列表路径上重新开启无索引的 .minio.sys/multipart 全盘扫描;如果未来版本确实需要跨重启持久的任意前缀列表,必须先设计按桶隔离的分片索引并在 16 盘纠删集上完成容量基准验证。
47 - 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录
Shooks(@Dansyuqri)贡献的 PR #57 修复了 #47,并已随 Server 20260903 发布。当对象保存了附加 S3 校验和时,CompleteMultipartUpload 的 XML 响应现在会一并返回已存储的 ChecksumType 元素。
缺陷与范围
对 @cbornet 报告的 #31 的调查拆出了两个独立缺陷。CRC32 分片上传完成阶段的数据计算路径已由提交 c8590413f 与 3e14733f1 修复。但在分片上传成功完成后,虽然底层对象元数据保存了校验和类型且 HeadObject 与 GetObject 均可正常返回该字段,CompleteMultipartUpload 的 XML 响应却遗漏了 ChecksumType。这导致 S3 SDK 调用方在完成响应中收到了有效的校验和摘要,却拿不到对应的 ChecksumType。
这一遗漏虽然不会损坏已存储的对象数据,却使客户端无法仅凭完成响应区分整对象校验和(FULL_OBJECT)与分片组合校验和(COMPOSITE)。对于同一种校验和算法,两种模式生成的 Base64 摘要长度完全相同,下游校验方无法仅凭摘要字符串判断应当复现哪一种计算方式。
响应契约
| 已存储的校验和状态 | CompleteMultipartUpload 响应 |
|---|---|
| 整对象校验和 | ChecksumType=FULL_OBJECT 及对应算法摘要元素 |
| 分片组合校验和 | ChecksumType=COMPOSITE 及对应算法摘要元素 |
| 无附加校验和 | 不输出 ChecksumType 元素,也不伪造校验和 |
S3 CompleteMultipartUpload API 规范定义了 FULL_OBJECT 与 COMPOSITE 两种校验和类型。对象 ETag 是独立的响应字段,不能替代附加 S3 校验和元数据。
实现与证据
生产代码在 CompleteMultipartUploadResponse 结构体中新增带 xml:"ChecksumType,omitempty" 标签的 ChecksumType string 字段,并在解码已存储的校验和元数据后将 cs[xhttp.AmzChecksumType] 填入响应。响应生成器直接复用 HeadObject 与 GetObject 所读取的同一份已存储状态,既不会重新扫描对象内容,也不会根据分片数量猜测校验和类型。
合并提交(a96116b12)包含了针对整对象、分片组合与无校验和三种完成响应的单元测试。
兼容性与相邻改动
忽略未知 XML 元素的客户端保持完全兼容,而检查 ChecksumType 的 SDK(如 silo-go bc3b35f97)则能在分片完成响应中直接获取已存储的类型值。本次修复未引入新算法、未修改磁盘元数据格式,也无需迁移存量对象。
另外两项相邻的分片校验和修复同样随 Server 20260903 发布:UploadPart 校验和推断修复与 CompleteMultipartUpload 校验和错误映射(其中 CRC64NVME + COMPOSITE 会被显式拒绝,不再静默规范化)。
48 - 总量未知时,进度条应该说什么
在 SILO Console v2.1.1 中下载文件夹时,Downloads / Uploads(下载/上传)抽屉会显示 NaN%。尽管后端的流式 ZIP 归档仍在正常传输、底层存储对象也完好无损,但进度条却从“总量未知”错误地跳入了确定进度(determinate)渲染状态——导致运维人员误以为下载已卡死或失败,进而反复点击 Download。
该修复已随 Console 2.2.0(提交 16960f7ab,跟踪议题 #62)发布,并自 Server 20260903(edc8be6ed)起内嵌于服务端。其核心准则只有一条:
只有当下载任务拥有一个有限、正数且适用于当前响应字节流的总量时,才能进入确定百分比(determinate)模式;否则必须保持不确定(indeterminate)动画状态,直至完成、失败或取消。
(后续发布的 Console 2.4.1 进一步将文件夹与多选下载演进为文件流写入与浏览器原生下载交接,不再将完整 ZIP 缓存在浏览器内存 Blob 中;详见文末后续演进。)
可观察故障
在 SILO Console v2.1.1 中:
- 在某个公共前缀(如
folder/)下上传若干对象; - 停留在父级存储桶列表页,勾选
folder/并点击 Download; - 在传输完成前打开 Downloads / Uploads 面板;
- 当前下载行显示
NaN%,而底层 ZIP 流仍在继续传输。
根因分析
界面上出现的 NaN% 是 S3 前缀元数据、服务端流式 ZIP 响应与前端 XHR 进度算术三层契约错位共同作用的结果。
公共前缀没有对象大小
S3 中的“文件夹”只是逻辑上的公共前缀(Common Prefix),并非实际存储的目录对象。在列表模型中,前缀以 / 结尾并携带逻辑大小 size = 0(Console 列表将其渲染为 -,表示大小不适用)。由于自动生成的 API 模型为 size 字段标记了 omitempty,逻辑零值在列表 JSON 中会被省略。而单选下载 thunk 直接将 object.size 原样传给下载辅助函数,导致运行时传入的 fileSize 为 undefined(或人工构造前缀记录中的 0)。这两者都不能作为除法运算的有效分母。
流式 ZIP 没有预先可知的网络传输长度
当服务端收到以 / 结尾的下载请求时,会递归列出该前缀下的所有对象,并将 zip.Writer 直接连接到 io.Pipe:一边读取源对象、一边执行 Deflate 压缩、一边将生成的字节直接刷入 HTTP 响应流。
这种边压缩边流式输出的设计十分合理:服务端无需在内存或临时磁盘中缓存完整 ZIP 归档即可立即发出首字节。但它的必然推论是:在发送 HTTP 响应头的那一刻,最终压缩后的归档总字节数尚不存在,因此响应只包含 Content-Type: application/zip 与文件名,不包含 Content-Length。源对象的未压缩大小之和也不能替代 Content-Length:源大小是未压缩的逻辑字节,而浏览器 ProgressEvent.loaded 统计的是经过 Deflate 压缩并附带 ZIP 帧头后的网络传输字节,两者的量纲完全不同。
进度事件缺乏有效分母时的算术异常
前端 XHR progress 监听器此前无条件执行以下计算:
对于文件夹前缀或零字节对象,分母 fileSize 为 undefined 或 0,JavaScript 会计算出 NaN(loaded / undefined 或 0 / 0)或 Infinity(正数除以 0):
产品契约
进度展示需要严格遵守四条不变量:
以“分母是否合法有效”而非 objectPath.endsWith("/") 来定义契约,能够用同一套逻辑同时覆盖文件夹前缀、零字节对象、缺失大小元数据以及任何未来的分块流式响应,无需为特定对象类型编写脆弱的特判分支。
目标与非目标
目标
- 文件夹下载在任何时刻都绝不显示
NaN%、Infinity%或伪造的百分比。 - 总量未知的传输任务复用现有的不确定进度(indeterminate)动画。
- 已知大小的普通非空文件保持原有的确定百分比进度行为不变。
- 完成、失败与取消三种终态均必须退出不确定进度动画。
- 零字节文件下载过程中绝不产生非有限百分比,并在完成时正常到达
100%。 - 严禁任何非有限数(
NaN、Infinity)或越界百分比写入 Redux 状态树。
非目标
- 不为了凑出
Content-Length而在服务端预先缓冲整个 ZIP 归档; - 不把未压缩源对象大小之和当作压缩后网络传输进度的分母;
- 不在本次缺陷修复中重构整个 Object Manager 的 Redux 状态结构。
技术决策
最小生产修复由五个紧密配合的部分组成:
D1. 仅在拥有合法总量时计算百分比
将进度算术抽离为独立于 DOM 与 Redux 副作用的纯函数(downloadProgress.ts):
- 若存在有限正数
objectSize,优先使用它以保持普通文件下载进度计算完全不变; - 若
objectSize不可用(0或undefined),但浏览器声明响应长度可计算(event.lengthComputable === true)且提供了有限正数event.total,则将其作为回退分母; - 其他情况下统一返回
null,表示当前不存在可诚实计算的百分比。
D2. 总量未知时保持 Indeterminate 状态
修改 XHR progress 事件监听器,仅当 calculateDownloadPercent 返回非空数值时才派发 progressCallback:
由于下载条目初始化时默认 waitingForFile = true——且 ObjectHandled 组件已经将该状态渲染为 <ProgressBar variant="indeterminate" />——当 percent === null 时跳过派发即可让任务行自然保持在不确定进度模式,直到终态动作到达。
D3. 确保取消操作正确进入终态
此前完成(complete)与失败(fail)reducer 都会清除 waitingForFile,唯独取消(cancelObjectInList)遗漏了这一转换。在 cancelObjectInList 中补齐 item.waitingForFile = false,可防止处于不确定进度模式的文件夹下载在用户点击取消后仍继续播放活动动画。
同时在 XHR 边界增加事件顺序保护:调用 req.abort() 时会先触发 readystatechange (DONE, status = 0),随后才触发 onabort 事件。令通用 DONE 分支在 status === 0 时直接返回、交由专门的 onabort 或 onerror 处理器接管,可避免取消操作被抢先误标为下载失败。
D4. 归一化被省略的零字节大小
在单选下载 thunk 中传入 object.size || 0,与多选下载入口对齐。这样可在完成回调检查 Blob.size === fileSize 之前恢复 API 模型因 omitempty 而省略的逻辑零值,确保零字节对象在收到 HTTP 200 空响应时顺利过渡到 100% 完成状态。
D5. 保持服务端流式 ZIP 不变
Go 后端的文件夹下载处理器继续通过 io.Pipe 流式生成 Deflate 压缩 ZIP 并省略 Content-Length,无需改动任何服务端 API 或归档格式。
进度状态机
| 状态 | waitingForFile |
percentage |
终态标志 | 界面渲染 |
|---|---|---|---|---|
| 排队中 / 尚未收到有效进度 | true |
0 |
无 | 不确定进度条(indeterminate) |
| 总量未知的传输 | true |
0 |
无 | 不确定进度条(indeterminate) |
| 总量已知的传输 | false |
0..100 |
无 | 确定百分比进度条 |
| 已完成(Completed) | false |
100 |
done = true |
成功(100%) |
| 已失败(Failed) | false |
上次有效值 | failed = true, done = true |
错误状态 |
| 已取消(Cancelled) | false |
0 |
cancelled = true, done = true |
已取消状态 |
完备性证明
普通非空文件
objectSize > 0 提供有限正数分母,辅助函数返回 [0, 100] 范围内的整数,updateProgress 将 waitingForFile 置为 false 进入确定百分比模式,下载完成时置为 100%。
流式文件夹 ZIP
objectSize 归一化为 0,同时 lengthComputable = false 且 event.total = 0。辅助函数返回 null,不派发进度更新,任务行保持不确定动画;流式传输完成后置为 waitingForFile = false、percentage = 100 与 done = true。
未来带真实长度的响应
若反向代理或未来的归档端点提供了可信的响应总长度(lengthComputable = true 且 event.total > 0),同一套代码会自动回退使用 event.total 显示真实百分比,无需再次修改前端。
零字节文件
被省略的列表大小归一化为 0;传输期间若触发进度事件则返回 null(绝不计算 0 / 0);收到空响应后,零字节 Blob.size 与归一化后的期望大小相等,直接跃迁至 100% 完成状态。
失败与取消
失败与取消均会将 waitingForFile 置为 false 并将 done 置为 true,确保没有任何已终止的任务行继续显示活动动画。
被拒绝的替代方案
在服务端缓冲完整 ZIP 以获取 Content-Length
在服务端内存或临时磁盘中生成完整归档后再发送,虽然能提供精确的 Content-Length,但会显著推迟首字节时间(TTFB)、加剧内存与磁盘压力并恶化并发下载表现。绝不能为了一个前端进度展示缺陷而放弃流式传输。
将前缀下所有对象的未压缩大小相加
源对象大小之和是未压缩的逻辑字节,而 event.loaded 是经过 Deflate 压缩后的网络字节加 ZIP 帧头。由于分子与分母单位不同,进度条会随压缩率剧烈波动,甚至停留在远低于 100% 的位置或提前达到 100%。
将非法进度强制转换为 0%
在长达数 GB 的文件夹下载过程中一直显示确定模式的 0%,会让用户误以为一个字节都没有传过来、连接已经卡死。“总量未知”必须诚实地表达为未知。
仅对 / 结尾的路径做特判
只检查 objectPath.endsWith("/") 会遗漏真实的零字节对象、缺失元数据的响应以及其他不含 Content-Length 的分块传输响应。正确的判断边界是“分母是否可计算”,而非路径后缀。
将文件夹直接交给旧版 BrowserDownload
Console 2.2.0 时期的旧版 BrowserDownload 辅助函数会在点击隐藏 <a> 标签后立即触发完成回调,在首字节到达前就把任务标为 100% 完成,无法感知后续 HTTP 错误或支持取消。(真正的浏览器原生流式交接已在后续 Console 2.4.1 中独立设计与实现。)
仅在 ProgressBar 组件内部过滤 NaN
仅在展示组件内屏蔽 NaN 会任由非法数值写入 Redux 状态树,且无法阻止 waitingForFile 被错误清零。主防线必须设在进度事件进入应用状态的入口处。
立即将 Redux 状态扩展为 percentage: number | null
在保留 waitingForFile、done、failed 与 cancelled 四个布尔字段的同时再引入 null,只会制造更多自相矛盾的状态组合。既然现有 waitingForFile = true 已经准确建模并渲染了不确定进度状态,直接复用它的兼容性风险最小。
需求与验收条件
功能需求
- FR1–FR3:
calculateDownloadPercent仅返回null或[0, 100]范围内的有限整数;总量未知的文件夹下载保持不确定模式(waitingForFile = true);已知总量的普通对象下载保持确定百分比进度。 - FR4–FR5: 归一化被省略的零字节大小使空文件正常完成至
100%,并在完成、失败与取消时统一将waitingForFile置为false。
非功能需求
- 保持服务端流式 ZIP(
io.Pipe无全量缓冲)、S3/Console API 路由以及 Redux 状态类型不变。
验收条件
- 在文件夹、普通文件、零字节文件与中途取消等全部下载场景中,下载面板均不出现
NaN%或Infinity%。
验证矩阵
纯函数计算矩阵
| 场景 | loaded |
objectSize |
lengthComputable |
event.total |
期望返回值 |
|---|---|---|---|---|---|
| 普通文件下载过半 | 50 |
100 |
false |
0 |
50 |
公共前缀(folder/) |
1024 |
0 |
false |
0 |
null |
初始 0 / 0 |
0 |
0 |
false |
0 |
null |
| 响应总长度回退 | 50 |
0 |
true |
200 |
25 |
| 响应总长度为零 | 0 |
0 |
true |
0 |
null |
| 已加载字节超过总量 | 150 |
100 |
true |
100 |
100 |
非法对象大小 NaN |
10 |
NaN |
false |
0 |
null |
省略的对象大小 undefined |
10 |
undefined |
false |
0 |
null |
非有限响应总量 Infinity |
10 |
0 |
true |
Infinity |
null |
负数 loaded |
-1 |
100 |
true |
100 |
null |
状态转换测试
单元测试直接验证:新下载任务以 waitingForFile = true 启动;无有效百分比派发时保持不确定模式;收到有效进度后更新有限百分比并将 waitingForFile 置为 false;完成、失败与取消均将 waitingForFile 置为 false。
真实浏览器端到端回归
Playwright 测试在真实 SILO + Console 实例上利用 Chromium CDP 网络限速保留传输观察窗口,验证文件夹下载期间进度抽屉显示不确定动画且不含 NaN% 或 Infinity%,验证中途取消能正确进入 Cancelled 终态,并验证不限速下载的 ZIP 归档完整性、普通非空文件进度以及零字节文件下载。
实现范围
- 新增
downloadProgress.ts纯计算模块; - 修改
Objects/utils.ts:仅当计算结果非空时调用progressCallback,并让status === 0的终止事件交由onabort/onerror处理; - 在单选下载 thunk 中将
object.size || 0归一化; - 在
cancelObjectInListreducer 中将item.waitingForFile置为false; - 在
playwright.config.ts中配置纯单元测试与 Chromium 限速端到端回归测试。
交付与回滚
该修复已随 Console v2.2.0(silo-console#29)发布,并内嵌于 SILO Server 20260903,关闭 pgsty/silo#62。不涉及任何数据或 API 迁移。
完成定义
-
calculateDownloadPercent仅返回null或[0, 100]范围内的有限整数; - 总量未知的文件夹下载展示不确定进度,普通文件与零字节文件正常展示确定进度;
- 完成、失败与取消均退出不确定进度模式(
waitingForFile = false); - 随 Console
2.2.0与 Server20260903正式发布。
后续演进任务
评审期间识别出的五项演进方向——消除前端整包 Blob 内存缓冲并直接流式交接给浏览器、进度终态可辨识联合类型、服务端流式 ZIP 错误中断传播、共享进度条组件的非有限值兜底守卫,以及 Blob JSON 错误响应解码——已分别在后续 Console 版本中推进落地。
后续演进:Console 2.4.1 的浏览器原生流式交接
在合入上述进度修复的同时,Console 2.2.0 还将对象列表中的 size 改为始终输出的 JSON 字段,并加固了服务端 ZIP 错误传播:不可读条目不再被静默跳过,响应头发出前的错误返回 HTTP 500,响应开始后的错误则通过中断流暴露给客户端。
随后发布的 Console 2.4.1 进一步消除了整包 XHR Blob 内存缓冲:单文件夹与单对象下载直接交接给浏览器原生下载管理器,多选打包下载则通过文件系统流式写入器(FileSystemWritableFileStream)或原生流交接落盘。在这一新架构下,Console 下载面板中的完成状态代表“已成功交接给浏览器下载管理器”,后续字节传输进度与取消操作均由浏览器原生下载管理器接管。
49 - 列举不能丢掉仍有仲裁的 null 版本
本文记录 2026-09-16 调查的对象列举漏项问题:未开启版本控制的存储桶在并发覆盖写下返回 HTTP 200 的列表,却漏掉了同一端点仍能正常 GET 的对象键。下文说明已确认的合并缺陷机制、提交 8d06424b1(随 Server 20260916 发布)的针对性修复与验证数据,以及在 #218 中跟踪、随后在 Server 20261018 中由 PR #239 关闭的滚动重启漏项边界(详见 LIST 恢复设计)。
缺陷机制
对象列举会合并被询问各块磁盘返回的版本流。当各盘状态不一致时,mergeXLV2Versions 选出一个最新版本并统计同意该版本的磁盘流数量。上游 2022 年的一处改动(PR #14125)让这一计票过程容忍自愈(Healing)留下的签名差异——这一容忍本身是必要的,但它的版本选择与计票方式对输入切片顺序敏感。当每块盘各持有一个普通 null 版本、而版本更新的少数盘恰好排在切片末尾时,旧一代版本的计票会被重置。原本实际达到读仲裁(Quorum)的旧一代版本因此被丢弃,合并函数返回空切片,解析器进而将该对象键当作不存在跳过。由于 HTTP 请求整体仍返回 200 OK,客户端会看到一份看似完整、实则漏掉有效键的列表;而对该键直接发起 GET 仍能成功,因为单对象读路径独立解析读仲裁。
修复前的代码在 9 种磁盘排列顺序下以此方式复现失败(具备完整的签名 LIST 报文证据),而完全相同的磁盘内容在其余排列顺序下则列举正常。
修复方案(8d06424b1)
提交 8d06424b1 新增的二次计票仅在原选择逻辑未能达到仲裁时触发,且严格限定于能够精确判定的输入场景:在裁剪前的原始输入上,每个非空磁盘流恰好持有一个普通 null 版本、不包含 free version,且所有磁盘流的纠删码参数完全一致。满足上述条件时,算法按元数据头部重新分组,唯有达到原始有效仲裁数的组才会返回。混合版本历史、显式版本 ID、删除标记、free version 与混合纠删布局均保持原有行为;严格模式、签名比对、平票决胜与代表条目选择规则保持不变,共享该合并函数的其他调用方对所有原本成功的输入均得到完全相同的结果。
8d06424b1 的验证数据:
- 9 个旧顺序反例与 7 个新顺序反例全部通过;5,620 个差分测试输入保持契约结果一致。
- 完整签名 HTTP 列举、对象读取、常规与
-race并发检查,以及扫描器(Scanner)、自愈(Healing)与迁移(Migration)等下游调用方测试全部通过(含 6 个顶层测试与 23 个嵌套子用例)。 - 在 4 节点 16 盘集群上叠加并发覆盖写,连续 20,000 次稳态成功
LIST零漏项。
滚动重启边界(#218,已由 20261018 的 #239 闭环)
在同一套 4 节点 16 盘集群上,使用 20260916 构建对四个节点执行滚动重启并叠加并发覆盖写时,27,966 次成功返回的 LIST 请求中有 2,624 次漏掉了 1 至 4 个对象键。这些被漏掉的键实际可读:8,192 次同端点 GET/HEAD 核查中有 8,186 次返回 200 OK 且内容为最后一次确认的写入。最典型样本发生在最后一个节点健康检查恢复正常 17.6 秒之后,所漏掉的键最后一次成功 PUT 已在 11.5 秒前确认完成且其后无新写入。原始 XML、HTTP 请求关联与响应体哈希均经独立复核,排除了分页与客户端解析因素。
这一滚动重启漏项发生在进入 mergeXLV2Versions 之前或周边的列举管线中——20260916 构建中仍存在将瞬态读取或锁竞争失败静默转为“键不存在”的分支:
listPathRaw获取命名空间读锁(NSLock)在高并发写竞争下达到 1 秒硬超时后直接失败或跳过;- 单块远端磁盘在流式列举中途断连,导致解析器拿到的有效条目少于仲裁数或缺少缓存元数据;
- 部分解析回调(Partial-Resolution Callback)在失效 reader 数量未超过整集容错阈值时静默丢弃单个条目。
Issue #218 专门跟踪了上述滚动重启调查,随后在 Server 20261018 的 PR #239 中通过移除 listPathRaw 的 1 秒读锁硬超时并引入有界 metacache 流恢复机制彻底解决。完整根因分析、修复设计与基准测试见节点重启与 NSLock 锁竞争下的 LIST 恢复设计。
50 - ListObjects 快捷路径不能把不存在的桶伪装成空桶
随 SILO Server 20260903 发布的 PR #37(PR 提交 e9c5340be,合并提交 49c8aeac4,关闭 SILO #32),修复了 ListObjects、ListObjectsV2 与 ListObjectVersions 中三条提前返回(early-return)快捷路径在目标桶不存在时错误返回 HTTP 200 OK 空列表、而非 HTTP 404 NoSuchBucket 的 S3 兼容性回归。
太长不看(TL;DR)
对不存在的桶执行普通 ListObjects、ListObjectsV2 或 ListObjectVersions 请求时,扫描存储层会触发 BucketNotFound;但以下三类输入会在访问存储前提前返回:
marker(或start-after)不以prefix开头;max-keys=0;prefix以/开头,包括 #32 中boto3使用的Prefix="/"。
这三个分支原先直接返回 io.EOF,上层调用方将其解释为“列表正常结束”,导致客户端收到空的 HTTP 200,而非 S3 标准规定的 HTTP 404 NoSuchBucket。同一个不存在的桶,仅因过滤参数不同就从资源不存在错误变成成功响应,既破坏了 S3 兼容性,也阻塞了从回归前版本升级的用户。
修复该问题不能把昂贵的桶存在性检查重新加回每一次列表请求。选定方案仅将这三处裸 io.EOF 替换为调用辅助函数 listPathShortcutEOF:该函数调用一次 GetBucketInfo,当桶不存在或集群无法确认时返回真实错误,当桶存在时仍返回 io.EOF。正常列表热路径完全不变,额外的节点与磁盘扇出开销仅由原本会在访问存储前直接退出的快捷请求承担。
这是什么问题
同一个 API 出现两套桶存在性语义
#32 给出的最小复现是在不存在的桶上调用:
AWS S3 抛出 NoSuchBucket,而 SILO 却返回一个成功的空列表。差异源于对象层 listPath 的控制流:
触发提前返回的不仅限于 / 前缀:
| 快捷条件 | 为什么结果必为空 | 修复前的缺陷 |
|---|---|---|
marker 不以 prefix 开头 |
当前实现不扫描该不相交区间 | 未确认桶是否存在即返回 io.EOF |
max-keys=0 |
调用方明确要求返回零个键 | 将“请求零条结果”错误等同于“桶资源有效” |
prefix 以 / 开头 |
SILO 的扁平键空间不会生成以 / 开头的对象名 |
过滤条件在检查桶身份之前发生短路 |
对于已存在的桶,这三个分支直接返回空列表是合理的快路径优化;但对于不存在的桶,同一个 io.EOF 会掩盖应当优先返回的资源错误。
追溯上游 #18917 引入的回归起点
报告者确认 RELEASE.2024-01-29T03-56-32Z 行为正确,而从 RELEASE.2024-01-31T20-20-33Z 开始出现回归。追溯提交历史可以定位到上游变更 minio/minio#18917(80ca12008):该提交从通用参数检查中移除了 GetBucketInfo,改为依赖后续实际的 Put、List 与 Multipart 存储操作自行暴露不存在的桶。
这一优化假设在请求真正进入存储层时成立,却遗漏了“提前返回路径根本不会到达存储层”的边界。#32 并不需要全面回滚 #18917 的优化,只需补齐被绕过的三处控制流出口。
为什么要修复
S3 契约明确要求返回 NoSuchBucket
AWS ListObjects 与 ListObjectsV2 均将 NoSuchBucket 定义为 HTTP 404(指定桶不存在)。prefix、marker、start-after 与 max-keys 仅用于筛选桶内对象,不能让不存在的桶变成一次成功请求。同时,ListObjectVersions 共享同一个对象层列表引擎,在三处快捷入口上统一桶存在性语义可避免 V1、V2 与版本列表行为分化。
错误的空列表会改变客户端控制流决策
空的 HTTP 200 与 HTTP 404 在客户端逻辑中代表截然不同的分支:
- HTTP 404 会指示部署脚本、SDK 或测试代码先创建桶、修正配置或终止流程;
- 空的 HTTP 200 则声明桶已存在,只是当前没有匹配的对象;
- 使用 SILO 作为本地 S3 替身的集成测试会在本地假性通过,部署到 AWS S3 时却直接报错。
修复面极窄且易于验证
该缺陷完全集中在 cmd/metacache-server-pool.go 的三个相邻提前返回分支中,不涉及对象数据、元数据格式、排序、分页令牌编码、IAM 权限或 XML 结构。
为什么不能简单恢复全局检查
上游在 #18917 中移除通用 GetBucketInfo 并非随意清理:每次 Put、List 与 Multipart 操作前先检查桶存在性会在所有节点间产生 RPC 扇出;即便经过向量化合并,当集群规模超过 100 个节点后,该开销依然十分显著。
在 SILO 当前实现中,erasureServerPools.GetBucketInfo 会调用 S3PeerSys.GetBucketInfo,并发向所有节点发起请求并按存储池聚合法定人数,每个节点还需检查本地桶状态。因此两个极端方案均不可取:
- 完全不检查: 继续在不存在的桶上返回错误的空 HTTP 200;
- 每次
List前都检查: 恢复了404语义,却撤销了大型集群上的关键性能优化。
将 GetBucketInfo 严格限制在“不会访问存储、因而无法自然发现缺桶”的三条快捷分支上,即可在不影响正常列表热路径的前提下修复该兼容性缺陷。
具体修复方案
仅替换三处裸 io.EOF 返回
在 cmd/metacache-server-pool.go 中,三个快捷分支原先均执行:
现统一改为调用 listPathShortcutEOF:
该辅助函数定义如下:
其行为分为三类:
- 桶存在: 返回
io.EOF,保留原有的空列表返回行为; - 桶不存在: 返回
BucketNotFound,交由既有错误映射生成 HTTP 404NoSuchBucket; - 集群状态无法确认: 原样向上传播仲裁失败、节点离线、超时或上下文取消错误,不再伪造空成功响应。
正常的 listMerged、metacache 扫描、排序、分页与响应生成路径完全不变。
为什么辅助函数必须放在 listPath
将检查紧贴 listPath 的快捷出口放置基于三点原因:
- 只有
listPath知道当前请求即将绕过所有底层存储访问; - 若上移到通用 HTTP 参数校验层,会让所有正常列表请求承担额外扇出开销;
- 若下移到存储扫描层则完全无效,因为这三个分支根本不会进入扫描层。
为什么不引入桶存在性缓存
虽然增加 bucket-existence cache 可以降低快捷路径的扇出开销,但会立即引入桶创建、删除、跨站点复制、故障恢复与 TTL 过期窗口期的一致性失效问题。为了三个低频快捷分支引入第二套状态源,其复杂度与陈旧数据风险远高于收益。若未来生产遥测表明大型集群存在高频 max-keys=0 或 / 前缀探测流量,再基于真实观测数据评估限流或专用元数据快路径。
测试与评审证据
对象层契约测试
对象层测试在单盘与多盘纠删码配置下,对以下四类输入逐一验证:
- 以
/开头的prefix(Prefix="/"与Prefix="/sub"); max-keys=0;- 不以
prefix开头的marker; - 普通
prefix(作为由底层存储自然返回BucketNotFound的对照基准)。
每组测试均覆盖 ListObjects、ListObjectsV2 与 ListObjectVersions,并使用类型化的 isErrBucketNotFound 断言。
HTTP 协议层契约测试
Handler 测试使用真实签名请求验证三个公开列表 API:
| API | 请求形态 | 断言 |
|---|---|---|
ListObjects |
GET /missing-bucket?prefix=/ |
HTTP 404,XML 错误码为 NoSuchBucket |
ListObjectsV2 |
GET /missing-bucket?list-type=2&prefix=/ |
HTTP 404,XML 错误码为 NoSuchBucket |
ListObjectVersions |
GET /missing-bucket?versions&prefix=/ |
HTTP 404,XML 错误码为 NoSuchBucket |
本地与 CI 质量门禁
修复通过了完整本地验证(go test ./cmd -count=1 耗时 116.2 秒、10 个新增对象层与 HTTP 回归子场景、定向 go test -race、CGO_ENABLED=0 go build ./...、go vet ./... 以及独立设计与代码审查)。在 GitHub Actions 上,带 DCO 签名的 PR 提交 e9c5340be 通过了 DCO、VulnCheck 及 Go CI 全部 6 个构建测试任务;合并提交 49c8aeac4 随后也独立通过了 VulnCheck 与 Go CI。
衍生风险与边界情况
快捷路径请求现在会产生集群扇出
这是本修复唯一刻意接受的运行时成本:针对已存在桶的三类快捷请求过去仅相当于一次本地分支判断,现在需要调用 GetBucketInfo。本机微基准测试对比如下:
| 路径 | 本机观测耗时与分配 |
|---|---|
| 修复前快捷路径 | 约 0.55 μs,7 allocs |
| 修复后单盘快捷路径 | 约 7.8–8.1 μs,45–47 allocs |
| 修复后 32 盘快捷路径 | 约 70–81 μs,977 allocs |
| 32 盘正常列表请求 | 约 0.95 ms |
在真实多节点部署中,GetBucketInfo 还包含跨节点网络往返与法定人数聚合延迟。这也正是为何绝不能将该检查扩展到正常列表热路径。若存在配置错误的客户端高频轮询 max-keys=0、/ 前缀或不相交 marker,可通过 S3 追踪或监控指标识别并按需限流。
降级集群会暴露真实的仲裁错误
过去当节点离线或桶仲裁不足时,快捷路径由于根本不接触集群状态,仍会返回空的 HTTP 200。修复后,这类请求在集群降级无法确认桶是否存在时会返回仲裁、超时或服务不可用错误,避免了在无法确认资源状态时谎称“存在有效空桶”。
桶创建与删除并发仍不具备线性化快照
GetBucketInfo 与返回空列表是两个先后动作:桶可能在检查通过后立即被删除,或在形成缺桶错误后立即被创建。这与其他所有“先校验资源存在性、再执行操作”的 S3 API 属于同一并发模型。
依赖旧错误行为的客户端会收到 404
此前误把缺桶上的空 HTTP 200 当成正常状态的客户端在升级后会进入 404 错误分支。这一行为变化恢复了 S3 标准契约以及 2024 年 1 月回归之前的历史行为。
刻意留在范围外的两个相邻边界
设计审查记录了两个不阻塞合并的低优先级边界:
- 桶删除后的陈旧分页令牌: 在恢复 metacache 分页续传(continuation)时,
c.fileNotFound分支仍直接返回裸io.EOF。如果桶被删除后客户端携带陈旧或手工构造的 continuation token 请求,理论上仍可能收到空 HTTP 200。若在此处加入GetBucketInfo会影响所有正常分页续传请求的性能,需单独设计。 - Handler 层的
NotImplemented校验优先级: V1 与版本列表的某些marker/prefix组合会在 HTTP handler 参数校验阶段先返回NotImplemented,尚未到达对象层(V2 的start-after则会进入对象层listPath)。本补丁修复的是存储快捷路径掩盖缺桶错误,不改变畸形参数校验与资源错误之间的既有优先级。
评估并否决的替代方案
保持上游现状
优点是零性能变化且减少与上游分支的差异;缺点是继续违反 S3 契约、保留已知回归,并导致 SILO 在作为 S3 集成测试环境时给出错误信号。否决。
恢复通用 checkBucketExist
虽然能一次覆盖所有路径,但会把跨节点扇出重新加回每一个 Put、List 与 Multipart 操作,直接撤销 #18917 的大型集群优化。成本与收益严重失衡,否决。
只修复 Prefix="/"
仅修补 / 前缀虽能通过 #32 的单条 boto3 复现,但会遗留 max-keys=0 与不相交 marker 两个同源缺陷。三个分支在 listPath 中紧邻且语义完全相同,用同一个辅助函数收敛更完整。否决。
增加桶存在性缓存
缓存虽能降低快捷路径延迟,但需要处理创建、删除、复制、自愈与 TTL 窗口期的陈旧状态。当前并无遥测数据表明这三类快捷请求具有足够高的流量来支撑一套新缓存,否决。
复杂度与成本收益评估
| 维度 | 评价 | 说明 |
|---|---|---|
| 生产代码复杂度 | 低 | 仅修改三处调用点并新增一个 7 行辅助函数;无新状态、依赖或磁盘格式 |
| 测试复杂度 | 低到中 | 同时覆盖 V1、V2、版本列表、三类快捷条件、正常前缀对照与 HTTP 404 映射 |
| 正常路径风险 | 极低 | listMerged 正常列表热路径无任何变更 |
| 快捷路径运行时成本 | 仅快捷路径上升 | 从本地分支返回变为集群级 GetBucketInfo 查询 |
| 兼容性收益 | 高 | 恢复 HTTP 404 NoSuchBucket、回归前升级兼容性及 S3 测试保真度 |
| 运维复杂度 | 无 | 无需数据迁移、配置项、特性开关或缓存维护 |
验收决策
补强后的 PR #37(保留 Jason Lin 在 e9c5340be 上的共同作者署名)已合并为 49c8aeac4,包含类型化错误断言、V1/V2/版本列表对象层覆盖及 HTTP 404 NoSuchBucket 验证,并随 SILO Server 20260903 正式发布。
总结
本问题的根源在于列表引擎把 io.EOF 同时用于表达两件不同的事:“已存在桶的查询结果为空”以及“从未确认桶是否存在的提前过滤退出”。上游为优化大型集群性能而移除通用存在性检查,依赖的是“后续真实存储操作会自然暴露缺桶错误”这一前提。选定方案仅在三个绕过存储的出口调用 GetBucketInfo,在完整保留正常列表热路径性能的同时,恢复了这一前提与 S3 NoSuchBucket 协议契约。
51 - 只读 Checksum 审计与可靠的 CLI 输出契约
本文记录 MCLI 只读 checksum 校验工作流,以及发布复核阶段发现的 non-TTY 输出缺陷 pgsty/mc#5 的设计与实现。该命令经由 pgsty/mc 的 pull request #8 与 #13 合入主干,并随 mcli 20260903 正式发布(已内置于 Server 20260903 容器镜像)。
核心概览
历史版本的 CopyObject 实现可能在压缩或加密后的底层存储字节上计算附加校验和(additional checksum),而非在 S3 GET 返回给客户端的逻辑对象字节上计算。mcli checksum verify 会筛选候选对象,独立地将逻辑对象流送入已记录的校验和算法,并把每个候选分类为 MATCH、MISMATCH、NO_CHECKSUM、WOULD_VERIFY(dry run)、十种 UNKNOWN_* 分类之一,或三种 SKIPPED_* 结果之一。
首版实现在交互式终端中工作正常,但当 stdout 被重定向到文件或管道时却没有任何输出(#5)。原因在于 MCLI 为在非终端环境中禁用进度条 UI,会自动将内部状态 globalQuiet 标记为启用;新命令误把这项内部状态当成了“用户要求隐藏审计结果”。PR #13 将语义输出与进度条抑制解耦,在不改变全局 quiet 行为、也不在 CI 中重新打开进度条的前提下恢复了非终端输出。
命令与范围
第一版支持标记为 FULL_OBJECT 的 CRC32、CRC32C、CRC64NVME、SHA1 与 SHA256 校验和。检查目标可以是单个对象、精确 VersionID、前缀下的当前对象、所有历史版本,或由 JSON Lines manifest 给出的精确条目。命令同时支持 SSE-C 密钥映射、时间与大小过滤、--dry-run 成本预估、有界并发 worker(--max-workers)、全局下载限速、--json 输出以及可选的 --report JSON Lines 报告。
该命令严格只读:不校验分段复合校验和(COMPOSITE),不从 ETag 推断校验和类型,不直接读取底层 xl.meta,也不修改任何对象元数据。服务端点必须随校验和一并返回类型头(x-amz-checksum-type);对于不返回该头的端点,所有带校验和的对象都会被归类为 UNKNOWN_CHECKSUM_TYPE,绝不猜测类型。
当前不支持 --rewind、分层恢复、服务端批处理、断点续查或修复。
只读数据路径
对每个候选对象,mcli checksum verify 执行以下五步:
- 启用 checksum mode 发送
HEAD请求,保留所有受支持的校验和摘要与ChecksumType。 - 对不支持或含糊的元数据状态直接归入
UNKNOWN_*,不做猜测。 - 将
GET返回的逻辑字节流送入有界内存哈希器,不把对象正文写入本地磁盘。 - 对不可变历史版本使用
VersionID固定目标;对可变的未版本化或null版本对象使用If-Match,并在流式读取完成后再次HEAD核验以防并发替换。 - 将独立计算的摘要与已存摘要逐一比较。
S3 传输边界仅允许 LIST、HEAD 与 GET;若 mock endpoint 收到任何写方法,自动化测试立即失败。
专用 S3 helper 通过 StatObject 保留所有校验和与类型,再用 Core.GetObject 读取逻辑字节,不启用 SDK 自动校验器。这样无需改变共享 ClientContent,也不会改变 stat、复制、镜像或 JSON 输出的既有契约。
结果与退出码契约
每个候选对象只产生一个稳定的分类结果:
| 结果 | 含义 |
|---|---|
MATCH |
所有受支持的已存校验和均匹配 GET 返回的逻辑对象字节 |
MISMATCH |
至少一个已存校验和与逻辑对象字节不一致 |
NO_CHECKSUM |
对象没有附加校验和,不读取对象正文 |
WOULD_VERIFY |
--dry-run 发现可校验的 FULL_OBJECT 校验和 |
UNKNOWN_* |
MCLI 无法给出可靠校验结论(如 COMPOSITE 或缺失 ChecksumType) |
SKIPPED_* |
过滤条件排除了该对象(SKIPPED_TOO_LARGE、时间过滤或删除标记) |
最终汇总(summary)携带 objects、verified 计数、每种结果状态的计数以及 incomplete 标志。verified 等于 MATCH 加 MISMATCH——只有这两种结果真正把对象正文流过了哈希器。枚举了大量对象却实际核验为零的运行,会在汇总中清晰体现。
--fail-on 支持 mismatch、unknown、no-checksum、any 与 none:
- 默认值
any在出现MISMATCH、UNKNOWN_*或审计不完整时返回退出码1。其中SKIPPED_TOO_LARGE会使any返回退出码1(因为大小上限使审计不完整),而时间过滤与删除标记跳过不会触发失败。 no-checksum包含any的全部失败条件,并在任一对象缺少校验和或verified == 0时返回1。--dry-run不执行--fail-on失败判定。- 命令行参数、认证、对象枚举与报告写入失败属于命令级错误,直接返回非零退出码。
输出与自动化契约
逐对象记录和最终 summary 都是命令的语义输出:
- 除非调用方显式指定
--quiet、-q或MC_QUIET=true,TTY 与 non-TTY 下的 stdout 都必须收到全部对象记录和最终 summary。 - non-TTY 下的
--json每行输出一个紧凑 JSON 值(JSON Lines);TTY 下的--json保留 MCLI 既有的缩进美化格式。 - 全局参数(
--json、--quiet)在根命令mcli、checksum与verify三层位置均生效。 --report独立于 stdout:即使显式--quiet让 stdout 静默,--report仍会完整写入对象记录和最终 summary 的 JSON Lines。- 输出通道不会改变
--fail-on的退出码判定。
这一区分非常关键:MCLI 历史上的 globalQuiet 混合了两个来源——用户显式传入的 quiet 参数,以及拿不到终端尺寸时自动启用、用于关闭进度条 UI 的 non-TTY 状态。如果直接修改全局 globalQuiet,会让 cp、get、put、mirror 等命令在 CI 日志中重新输出进度条。
因此 PR #13 的修复仅作用于 checksum 命令内部:它沿完整 CLI context 链查找显式 quiet/JSON 参数(绕开 CLI 库 GlobalBool 只检查最近一层父级 flag set 的限制),并在 verify action 内部恢复被嵌套 Before 钩子重置的 JSON Lines 模式。
报告、密钥安全与运行成本
报告文件(--report)在 POSIX 系统上以 0600 权限新建,Windows 使用账户的文件系统 ACL。若目标路径已存在则报错退出;报告中只保存对象元数据与校验结果,绝不包含对象正文或 SSE-C 客户密钥。输入清单(--manifest)同样只记录 bucket、key 与可选 versionId。
由于校验过程会完整下载每个受支持对象的逻辑正文,运维人员应组合使用 --dry-run、--max-size、时间过滤、--max-workers 与全局下载限速控制网络与存储负载。NO_CHECKSUM 与 UNKNOWN_* 计数在汇总中独立展示,绝不与 MATCH 混同。
Mismatch 能证明什么
MISMATCH 证明:校验时刻 HEAD 返回的附加校验和,与同一时刻 GET 返回的逻辑对象字节不一致。单凭 MISMATCH 本身,不能直接断定对象一定由某个历史压缩复制缺陷生成,也不能替代与外部权威数据源的比对。
排查出 MISMATCH 时,不要原地覆写底层校验和元数据。应先通过只读审计完成分类;对确认受影响且有业务消费的对象,优先写入新对象键或新版本,用 mcli checksum verify 验证新对象返回 MATCH 后再切换下游消费方。UNKNOWN_* 对象不得纳入自动化修复脚本。
验证记录与输出 Schema
验收矩阵覆盖 TTY 人类可读与 JSON 输出、non-TTY 管道、普通文件重定向、app/parent/leaf 三层 JSON 与 quiet 参数、环境变量 MC_QUIET=true、quiet 模式下的 --report、报告写入失败、MISMATCH/UNKNOWN_* 退出码,以及针对真实 SILO 服务端的 MATCH、MISMATCH 与 COMPOSITE 对象实测(详见 mc checksum verify 命令参考)。
JSON 消费方应校验 schemaVersion: 1,并通过 type: object 与 type: summary 区分逐对象记录和最终汇总。--max-workers 默认为 4(范围 1–64),用于限制并发对象校验协程数。
测试还覆盖多校验和任一不匹配、固定与 null 版本、正常完成时每个候选只输出一次,以及 worker 取消后正常关闭结果流。InvalidObjectState 优先归类为需要恢复的存储状态,不能误作通用 403;SSE-C 错误分类不依赖 URL 或对象名文本。
52 - 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性
本文记录 SILO #46(合并提交 7fea6d5a5,随 Server 20260903 发布,后续延伸修复见 #63 与 #64)的设计与实现:如何围绕 S3 分片上传的可选校验和请求头,同时理顺分片合并(completion)语义、复制响应、压缩与加密数据流以及兼容性基线守卫。
太长不看(TL;DR)
分片上传(Multipart Upload,简称 MPU)会将大对象切分为多个分片(part)分别上传。AWS S3 允许客户端在上传每个分片时附带校验和(checksum),但将其定义为可选请求头。SILO 过去却将其作为必填项强制校验:普通 UploadPart 请求一旦省略校验和就会返回 400 错误,而本身不包含请求体的 UploadPartCopy 则必然失败。
修复后,若客户端提供了分片校验和,SILO 继续严格校验;若客户端未提供,SILO 则在读取原始明文流的同一遍过程中自行计算校验和并写入分片元数据。计算发生在压缩与加密变换之前,无需二次读取磁盘,也不改变磁盘存储格式。
最终决策
当分片上传在 CreateMultipartUpload 阶段声明了校验和算法后,SILO 遵循以下处理规则:
- 若客户端在请求中提供了分片级校验和,服务端继续按原逻辑校验;校验值不匹配或算法不一致时直接报错拒绝,绝不用服务端计算结果掩盖客户端错误。
- 若客户端省略了分片级校验和,服务端使用该 MPU 记录的算法,在压缩与加密之前的逻辑明文字节流上单遍计算校验和并持久化到分片元数据。
- 普通
UploadPart仅在客户端请求携带了校验和时才在响应头中回显;服务端自行补算的校验和不在UploadPart响应头中返回。 UploadPartCopy没有客户端请求体校验和,统一由服务端计算并在CopyPartResultXML 响应体中返回对应字段。ListParts返回已持久化的各分片校验和。FULL_OBJECT模式的合并操作继续从各分片校验和线性推导完整对象校验和;COMPOSITE模式的合并操作继续要求客户端提交各分片的校验和(客户端可通过ListParts查询获取)。- 校验和计算在首次读取数据流时同步完成,合并阶段绝不重新读取整个对象。
我们如何发现问题
该缺陷在排查另一个分片上传校验和议题 #31 时暴露。
#31 处理的是 CompleteMultipartUpload:当校验和类型为 FULL_OBJECT 时,客户端只需提交分片编号、ETag 及可选的完整对象校验和,无需在合并 XML 中逐一列出所有分片的校验和。在向上游回溯数据流时,我们发现 erasureObjects.PutObjectPart 在写入任何分片前有一处硬性拦截:
只要 MPU 在创建时声明了校验和算法,后续每个 UploadPart 请求都必须携带匹配的 x-amz-checksum-* 请求头,否则直接返回:
API 探针在单盘与纠删码后端上均复现了该错误。
进一步核查 CopyObjectPartHandler 后,这一兼容性问题直接升级为 P0 级缺陷:UploadPartCopy 请求根本没有可供客户端计算校验和的请求体。处理函数从源对象读取字节并构造内部读取器,最终同样调用 PutObjectPart。客户端既无法通过补充请求头绕过,也无法通过调整 SDK 配置规避,导致所有开启校验和的 MPU 都无法使用 UploadPartCopy。
AWS 契约到底是什么
AWS S3 接口文档对三类相关操作的输入与输出有明确界定:
- AWS UploadPart API 将各算法对应的校验和请求头定义为可选的数据完整性检查,并明确规定:仅当请求中提供了校验和时,响应头才会返回对应的校验和字段。
- AWS UploadPartCopy API 则规定:若创建 MPU 时指定了校验和算法,
CopyPartResult响应中将包含目标分片的校验和。由于复制请求不含数据体,该值必然由服务端计算生成。 - AWS ListParts API 用于查询未完成 MPU 中各已上传分片的校验和。
算法与校验和类型的支持矩阵如下:
| 算法 | FULL_OBJECT |
COMPOSITE |
|---|---|---|
| CRC64NVME | 支持 | 不支持 |
| CRC32 / CRC32C | 支持 | 支持 |
| SHA1 / SHA256 | 不支持 | 支持 |
FULL_OBJECT 仅支持可线性合并的 CRC 算法;而 SHA1 与 SHA256 虽不支持线性合并,仍需在每个分片中记录正确的摘要(digest),才能在 COMPOSITE 模式下完成合并校验。
在实际使用中,尽管新版 AWS SDK 默认倾向于为支持校验和的请求自动计算摘要,但用户一旦配置 request_checksum_calculation = when_required,或通过底层 API 在发起 MPU 时指定算法、而在上传分片时省略校验和请求头,就会在旧版 SILO 上触发 400 错误。
为什么不能只删除强制检查
最直观的改法是直接删掉 PutObjectPart 中的校验条件,放行不带校验和的分片写入。但这只会把报错推迟到 CompleteMultipartUpload 阶段。
SILO 在合并分片时不会重新读取并拼接所有分片的数据字节,而是直接读取每个 part.N.meta 中的 ObjectPartInfo.Checksums:
- 若分片元数据缺少对应算法的校验和,立即返回
InvalidPart; FULL_OBJECT模式调用Checksum.AddPart,按各分片字节长度线性合并 CRC;COMPOSITE模式将各分片摘要的原始字节依次拼接,再对拼接结果计算一次对象级哈希。
因此,存储层依赖的核心不变量是:
如果只删除入口检查而不补齐分片元数据,就会导致 UploadPart 表面返回成功、ListParts 缺失校验和字段、UploadPartCopy 响应缺值,最终在合并阶段因 InvalidPart 失败。
我们研究过的方案
| 方案 | 优点 | 致命问题 | 结论 |
|---|---|---|---|
| 仅删除入口强制检查 | 改动最少 | 分片元数据缺少校验和,合并阶段必然报错 | 否决 |
仅对 FULL_OBJECT 放宽限制 |
可兼容部分默认 CRC 客户端 | COMPOSITE 与 SHA1/SHA256 依然报错,无法完整修复 #46 |
否决 |
| 合并阶段重新读取所有分片计算 | 上传阶段无需保存摘要 | 引入 $O(\text{对象大小})$ 的额外磁盘与网络读取,且无法修复 ListParts 与复制响应 |
否决 |
普通 UploadPart 始终返回服务端计算值 |
易于旧版联邦转发 | 违反 AWS“仅在请求提供时回显”的响应规范 | 否决 |
| 照搬 AIStor 实现 | 有商业版先例可循 | 仅支持可线性合并的 CRC,且哈希器挂载位置在压缩/加密路径下存在读取变换后字节的风险 | 否决 |
| 在逻辑明文流上单遍计算并持久化 | 完全符合 S3 规范、无二次 I/O、同时覆盖 CRC 与 SHA | 需在内部显式分离明文校验和读取器与底层存储读取器 | 采用 |
商业版 AIStor 给了什么线索
我们对当时最新的 MinIO AIStor RELEASE.2026-08-07T18-34-35Z 二进制执行了 Go pclntab 符号还原与 ARM64 反汇编分析。静态分析显示,AIStor 已实现了以下机制:
- 当客户端未提供校验和时挂载服务端哈希计算器;
- 将计算结果写入分片元数据;
- 在
CopyPartResult结构体中新增校验和字段。
但 AIStor 的回退计算仅对满足 CanMerge() 的 CRC 算法(CRC32、CRC32C、CRC64NVME)生效,SHA1 与 SHA256 的 COMPOSITE 上传仍会触发 checksum missing 错误。更重要的是,AIStor 在对象层直接将哈希计算器挂载到当前的 r.Reader 上;而在开启服务端压缩或加密的路径中,传入对象层的 r.Reader 已经是经过压缩或加密变换后的存储流。因此,SILO 需要在更上游的明文边界挂载计算器,并同时覆盖 SHA1 与 SHA256。
设计审查如何推翻第一版设计
初版方案曾计划将所有逻辑收口到 erasureObjects.PutObjectPart:由对象层读取 MPU 元数据,发现客户端未提供校验和时,直接在传入的读取器上挂载服务端哈希计算器。
然而代码审查发现,这一方案在启用服务端压缩时会产生并发竞态与数据截断。newS2CompressReader 并非惰性包装器,其构造函数在调用瞬间就会启动后台压缩协程:
底层 S2 编码器还会并发预读多个数据块。HTTP 处理函数在创建压缩读取器之后,还需依次执行选项解析、加密封装,最后才进入对象层 PutObjectPart。等到 PutObjectPart 尝试挂载哈希计算器时,底层明文流往往已经被后台压缩协程消费了数 MiB:
- 较大分片计算出的校验和会丢失开头已读走的数据块;
- 较小分片可能在挂载前就已被全部读完(到达 EOF),导致无法生成任何校验和;
- 在后台协程并发调用
Read的同时修改ServerSideHasher字段,还会触发直接的数据竞争(data race)。
这一发现决定了最终的职责划分:
- HTTP 处理函数负责在启动任何立即消费数据的流变换(如并发压缩或加密封装)之前,在逻辑明文流上挂载哈希计算器;
- 对象层负责复核算法配置、验证校验和计算结果已就绪,并将其原子写入分片元数据。
最终实现
独立的逻辑 checksum reader
PutObjReader 原本包含两个读取器字段:
Reader:最终交付给底层存储的字节流(可能已经过压缩或加密);rawReader:供 ETag 计算等既有逻辑使用的原始读取器。
在压缩路径下,rawReader 也并非始终直接读取明文字节,有时仅通过 etag.Tagger 接口透传 ETag。为避免破坏既有 ETag 语义,我们在 PutObjReader 中新增了未导出字段:
checksumReader 始终绑定未经变换的 S3 逻辑分片明文流。后续调用 WithEncryption 包装存储流 Reader 时,会原样保留 checksumReader。
同时,PutObjReader 新增了未导出的访问方法:
- 获取最终生效的校验和类型(客户端提供或服务端补算);
- 若客户端提供了校验和,优先返回客户端校验结果;
- 若客户端未提供,则返回读取到达 EOF 时由服务端哈希器生成的计算结果。
将该字段与方法保持为包内未导出,既避免了扩大公共 Go API 表面,也为后续 #63 修复普通 CopyObject 预留了同一套底层机制。
在 transform 之前准备 hasher
辅助函数 prepareMultipartChecksumReader 负责读取 MPU 元数据中记录的算法与校验和类型:
- 若 MPU 未声明校验和算法,直接返回;
- 若客户端已提供校验和,比对其基础算法是否与 MPU 声明一致;
- 若算法不匹配,按原逻辑返回
InvalidArgument; - 若客户端未提供校验和,则在明文
hash.Reader上挂载对应算法的服务端哈希计算器。
对于普通 UploadPart:
- 压缩路径在完成请求校验和解析(
actualReader.AddChecksum)之后、调用newS2CompressReader启动并发压缩之前完成挂载; - 非压缩路径在请求校验和解析之后、构造加密读取器之前完成挂载。
对于 UploadPartCopy:
- 开启校验和的 MPU 先在源对象的目标读取区间上构造内层明文
hash.Reader; - 字节范围复制(range copy)仅对所选区间内的明文字节计算校验和;
- 内层读取器挂载就绪后,再依次包裹压缩与目标端加密流。
对象层仍然是最终权威
在 HTTP 处理函数层提前挂载哈希器并未削弱对象层的守卫职责。erasureObjects.PutObjectPart 依然执行完整校验:
- 重新解析 MPU 元数据中要求的校验和算法;
- 验证传入读取器的有效校验和类型存在且与 MPU 算法一致;
- 在纠删码编码写入完成后提取校验和结果映射;
- 若 MPU 启用了校验和但未能取到计算结果,立即记录内部错误并中止提交;
- 将校验和连同 ETag、分片大小与编号一并写入
part.N.meta,最后原子重命名提交该分片。
因此,任何绕过 HTTP 处理函数的内部调用方若未按要求准备合法校验和,仍会被对象层拦截,不会写入缺少校验和元数据的损坏分片。
CopyPart 响应
CopyObjectPartResponse 结构体新增了当前支持的五种校验和字段:
所有字段均带有 omitempty 标签,未启用校验和的 MPU 仍输出与原先完全一致的 XML。普通 UploadPart 继续仅通过 TransferChecksumHeader 回显客户端传入的校验和请求头,服务端自动补算的结果不会写入响应头。
为什么这个修改能解决问题
修复后的分片上传数据流如下:
这一管线同时满足四项核心要求:
- 协议兼容: 客户端省略可选的分片校验和请求头时,上传与复制均能正常完成。
- 完整性校验不降级: 客户端提供校验和时仍执行端到端比对,错误校验值不会被服务端重算结果覆盖。
- 字节语义准确: 校验和始终针对 S3 逻辑明文字节计算,不受底层压缩或加密变换影响。
- 单遍流式处理: 哈希计算随原有的单遍读取同步完成,无需在内存中缓存完整分片,也无需在合并阶段二次读取磁盘。
此外,hash.Reader 仅在读取到达 EOF 时生成最终的 ServerSideChecksumResult。在压缩路径下,压缩管道(pipe)的关闭操作天然同步了后台压缩协程与底层存储读取;对象层在纠删码写入返回后读取校验和结果时,计算已确定完成。定向 -race 测试验证了这一并发同步边界。
兼容性基线守卫处理
CopyObjectPartResponse 新增的五个字段属于导出 Go 符号。SILO 的 buildscripts/rebrand-guard 工具会自动扫描导入路径、环境变量、HTTP 头、路由、存储标记及导出符号,并与 buildscripts/rebrand-guard/compat-baseline.json 进行双向精确集合比对;任何未登记的新增导出符号都会阻断 CI。
将 #46 新增的五个字段写入基线后,守卫脚本仍报告两项未登记符号:
排查确认,这两个符号来自 main 分支更早合入的数据库通知修复提交 f1ba68358:cmd 启动流程需要通过 errors.As 匹配该导出错误类型,但原提交漏更新了 compat-baseline.json,导致后续基于该分支的所有 PR 都会在兼容性守卫检查处失败。
我们将这两项通知错误符号与 #46 的五个响应字段一并补入基线,最终基线差异为精确的 7 项新增、0 项删除:
验证证据
新增回归测试套件共包含 76 个子测试,完整覆盖以下矩阵:
- CRC32、CRC32C 与 CRC64NVME 的
FULL_OBJECT模式; - CRC32、SHA1 与 SHA256 的
COMPOSITE模式; - 客户端提供正确校验和、错误算法与错误校验值;
- 验证服务端补算值不出现在普通
UploadPart响应头中; - 验证
UploadPartCopy响应体与ListParts正确返回服务端计算值; - 5 MiB + 1 KiB 双分片的真实对象线性合并;
- 零字节分片与同分片编号覆盖上传;
- 指定字节区间的
UploadPartCopy(验证 SHA256 仅覆盖所选区间); - 单盘与 16 盘纠删码两种存储后端;
- 普通桶、版本控制、压缩、加密,以及“压缩 + 加密”组合模式;
- 显式 SSE-C 与 SSE-S3 服务端加密。
本地验证命令:
成本、风险与滚动升级取舍
当客户端省略分片校验和时,服务端需在读取数据流时额外执行一次哈希计算。CRC 系列算法的 CPU 开销极小,SHA1/SHA256 的 CPU 开销相对较高,但均在既有的数据读取流上单遍完成,不增加全分片内存缓冲,也不产生额外磁盘 I/O。
在集群滚动升级窗口内,新旧节点对省略了校验和的分片上传请求会表现出不同行为:已升级节点正常接收,未升级节点仍返回 400 错误。由于磁盘上 ObjectPartInfo.Checksums 的存储格式并未改变,已落盘数据完全向后兼容;待集群所有节点完成升级后,客户端可见行为即全面收敛。
为什么拆出两个独立后续
代码审查期间还识别出两项同源但边界独立的问题,分别拆分为独立议题跟进:
#63:CopyObject + Compression
普通 CopyObject 在启用服务端校验和与服务端压缩时,哈希计算器同样可能被挂载到压缩变换之后的字节流上。该问题与 #46 共享根因及新增的 checksumReader 机制,但涉及不同的 API 入口与测试矩阵,因此在 #63 中复用同一明文读取器契约独立修复。
#64:Legacy Federation
旧式 etcd 联邦代理会将 UploadPartCopy 转换为发往远端集群的普通 UploadPart 请求。由于普通 UploadPart 遵循 AWS 规范不在响应头中返回服务端补算的校验和,代理节点无法直接从远端 UploadPart 响应头中拿到填充 CopyPartResult 所需的校验和。#64 在不破坏外部 UploadPart 响应合规性的前提下,单独处理联邦代理路径的校验和获取。
这两项后续修复均遵循本次确立的流式处理契约:
所有由服务端计算的 S3 校验和都必须绑定在逻辑明文流上,在启动任何并发或流式变换之前完成挂载,并由对象层在落盘前复核与持久化。
工程要点总结
- 客户端可选输入不代表服务端可省略内部状态。 当协议允许客户端省略分片校验和时,服务端必须在写入阶段自行补算,以满足合并阶段的元数据不变量。
- 区分请求处理与响应回显规则。 普通
UploadPart可在内部补算校验和,但按 S3 规范不得在响应头中回显该值;而UploadPartCopy则必须在响应体中返回服务端计算值。 - 流式管线的挂载位置取决于字节语义与并发时机。 哈希计算器必须挂载在未经压缩和加密的逻辑明文流上,且必须在
newS2CompressReader启动并发预读协程之前完成安装。 - 外部实现反汇编仅作参考,不能替代完整路径验证。 AIStor 二进制分析验证了服务端补算的方向,但也暴露了仅覆盖 CRC 以及在对象层挂载流读取器的局限。
- 通过符号基线守卫显式管理导出表面。
compat-baseline.json的双向精确比对确保每次导出符号变更都有明确的提交归属。 - 共享底层不变量,按独立边界拆分交付。 #63 与 #64 复用本文建立的
checksumReader契约,但各自保持独立的测试矩阵与回归边界。
53 - BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
#48 与 #50 的修复已随 Server 20260903 发布,流式校验和后续修复(PR #143)已随 Server 20260916 发布。本文取代八月早期提案中“暂时保留 CRC64NVME 静默规范化”的结论。
20261018:消息文字与提交失败
Server 20261018 包含 #225、#229 与 #238 的消息格式修复:Content-MD5 大小写对齐 S3 规范、PutObject 与 UploadPart 校验失败时显示已知算法名,以及移除 CompleteMultipartUpload 错误消息中重复的 "checksum" 字样。未知算法仍保留通用描述,各操作对应的 S3 错误码与校验条件保持不变。下文提及的“保持 UploadPart 错误映射器不变”对应八月修复范围;十月版本更新了其消息文本生成。客户端应依据 HTTP 状态码与 S3 错误码判断失败原因,而非解析完整的英文消息字符串。
此外,#232 将最终提交阶段暂存 xl.meta 丢失归类为 503 SlowDownWrite。该状态与下表中客户端提供错误校验和导致的 HTTP 400 不同,也不改变请求体不完整时的原有处理逻辑。详见 S3 写入提交错误参考。
为什么必须在完成阶段执行校验
CreateMultipartUpload 在初始化上传时确定校验和算法与对象校验和类型,每次 UploadPart 调用分别记录对应分片的校验和。CompleteMultipartUpload 必须依据已保存的上传契约,同时校验最终对象摘要与任何显式声明的校验和类型断言:请求既不能因为基础算法相同而在完成阶段将 COMPOSITE 篡改为 FULL_OBJECT,也不能通过省略最终摘要来绕过不匹配的类型断言。
修复方案严格区分“省略校验和类型”与“显式声明校验和类型”,仅规范化内部表示标志;同时为分片完成路径引入操作专用错误类型,保持 UploadPart 及全局校验和不匹配错误映射器的原有行为不变。
当前错误契约
下表列出的所有校验失败均返回 HTTP 400,且不会提交新的已完成对象。
| 请求条件 | S3 错误码 |
|---|---|
整对象(FULL_OBJECT)或组合对象(COMPOSITE)摘要不匹配 |
BadDigest |
| 显式声明的合法类型与初始化类型不一致(包括仅提供类型的断言) | BadDigest |
| 未知或小写的校验和类型 token | InvalidArgument |
| 完成阶段提供的校验和算法与初始化算法不一致 | InvalidArgument |
| 缺少组合校验和所需的分片校验和 | InvalidRequest(指明算法与分片编号) |
初始化时将 CRC64NVME 与 COMPOSITE 组合 |
InvalidArgument |
完成时同时提供 CRC64NVME 摘要与 COMPOSITE 类型(在解析阶段被拒绝) |
InvalidArgument |
对 CRC64NVME FULL_OBJECT 上传在完成时仅断言 COMPOSITE 类型 |
BadDigest |
UploadPart 期间客户端提供的分片校验和错误 |
XAmzContentChecksumMismatch |
对于 FULL_OBJECT 上传,完成请求 XML 中可以省略分片级校验和,但凡是显式提供的分片校验和仍会与已存储的分片元数据逐一比对。与初始化类型一致的纯类型断言允许通过;省略可选的校验和类型请求头不会隐式断言 COMPOSITE。将 SHA1 或 SHA256 与 FULL_OBJECT 组合的请求会在算法与类型解析阶段直接被拒绝,不会进入已存储类型比对阶段。
CRC64NVME 决策与源码证据
早期评审曾考虑在收集更多 SDK 兼容性证据前暂缓处理 #50(此前代码会将 CRC64NVME + COMPOSITE 静默规范化为 FULL_OBJECT)。然而静默规范化会掩盖非法的客户端请求,违背快速失败原则。PR #93 在请求头与 trailer 解析路径中直接拒绝 CRC64NVME + COMPOSITE 组合,PR #96 则移除了 CompleteMultipartUpload 阶段的静默规范化。由于完成请求既可能在请求头解析阶段失败(同时携带 CRC64NVME 摘要与 COMPOSITE 类型),也可能在已存储类型比对阶段失败(对已初始化为 CRC64NVME FULL_OBJECT 的上传仅发送裸 COMPOSITE 类型请求头),上表分别记录了对应的两种错误码(InvalidArgument 与 BadDigest)。
最初的完成阶段错误映射由 PR #74 引入,显式类型断言后续修复(7e079ff05)经由 PR #85 合入。cmd/erasure-multipart-fullobject_test.go 中的处理器测试覆盖了整对象与组合摘要不匹配、纯类型断言、非法 token 以及 CRC64NVME 的两个拒绝阶段。
独立的流式校验和后续修复
PR #143 修复了 @cbornet 报告的 #107,专门处理 AWS Java SDK v2 使用的混合请求模式:aws-chunked 流式请求在 x-amz-trailer 中声明了校验和请求头,但实际通过普通 HTTP 请求头直接发送该校验和值。该修复同时拒绝格式非法的请求头校验和值,防止因解析失败而静默跳过校验。此项修复已随 Server 20260916 发布,与分片完成阶段的错误映射相互独立。
兼容性与剩余边界
检查 S3 错误码的客户端在完成阶段遇到摘要或类型不匹配时会收到 BadDigest,遇到缺失组合分片校验和时会收到 InvalidRequest。不带校验和的常规分片上传与 ETag 语义保持不变,无需迁移磁盘元数据。相邻的分片上传校验契约详见 CompleteMultipartUpload ChecksumType 响应契约、UploadPart 校验和设计 与 重复分片编号安全公告。
目前仍保留三项较窄的边界行为:
- 若
CreateMultipartUpload初始化时未记录任何校验和算法,但在完成阶段提供了最终校验和摘要,请求会以BadDigest拒绝。 - 组合校验和的分片数量不匹配与摘要值不匹配共用同一条
BadDigest错误描述。 - 附加在
FULL_OBJECT校验和请求头末尾的-N分片数后缀会被忽略,不作为分片数断言进行校验。
54 - 旧版元数据与扫描响应复用时的残留字段修复
本文记录 PR #233(验证提交 86c5a92eef3d,合并提交 d94b3b3c9251,随 Server 20261018 发布)的设计与验证。该补丁修复了旧版 xl.meta 版本头部与远程命名空间扫描响应在复用结构体时带入残留字段的问题。
问题概述
两处独立代码路径在复用 Go 结构体时未完整重置其逻辑状态,导致新输入中未出现的字段沿用了上一轮留下的旧值。修复直接落在明确知晓当前输入字段布局的解码器与响应构造处:
| 路径 | 触发条件 | 修复前状态 | 修复后状态 |
|---|---|---|---|
| 旧版 V2 版本头部 | 将旧版头部解码至 EC 字段非零的复用结构体 | EcN 归零,EcM 残留旧值 |
两个缺失的 EC 字段均归零 |
| 旧版 V1 版本头部 | 复用带有签名或 EC 字段的结构体 | V1 布局中不存在的字段继续残留 | Signature、EcN、EcM 均归零 |
| 扫描进度响应 | 复用上一轮携带最终缓存的响应信封 | 同时包含新 Update 与旧 Final |
仅包含 Update |
| 扫描最终响应 | 复用上一轮携带进度更新的响应信封 | 同时包含旧 Update 与新 Final |
仅包含 Final |
旧版 xl.meta 版本头部
xl.meta 内部的紧凑版本头部(version header)拥有独立的版本号,与外层 xl.meta 文件格式版本及 SILO 发行版本号相互独立。当前版本头部为 V3,包含纠删码分片参数 EcN 与 EcM;旧版 V2 头部仅有 5 个字段,不含这两个 EC 字段;V1 解码器对应更早的四字段布局,既无签名也无 EC 字段(源码注释标明 V1 布局从未正式发布,此处属于对保留解码路径的防御性清理)。
此前 V2 的字节切片解码入口 UnmarshalMsg 与流式解码入口 DecodeMsg 均写成了:
由于对 z.EcN 重复赋值,z.EcM 从未被清零。当调用方传入新分配的零值结构体时该缺陷不会暴露;一旦复用此前存有 EcN=4, EcM=8 的结构体,解码 V2 头部后便会得到 EcN=0, EcM=8。修复将其更正为 z.EcN, z.EcM = 0, 0,并在 V1 解码入口同步清空缺失的 Signature 字段。当前 V3 头部则正常保留线上编码中的 EC 字段。
在实际运行中,loadIndexed 会直接复用版本头部切片的底层数组空间,因此仅在新建结构体时初始化无法覆盖该路径;合入的回归测试专门针对这一切片复用路径进行了验证。具体改动见旧版解码器实现与索引头部加载及比对逻辑。
若不清零残留字段,同一份二进制头部的解码结果会随复用结构体原先的内容而变化。由于头部相等比对与法定票数(quorum)统计均包含这些字段,不同的残留值会将本应一致的头部拆入不同分组(即便 hasEC() 本身要求 EcN 与 EcM 同时非零才判定为有效 EC 布局)。
命名空间扫描响应
远程磁盘扫描通过 grid RPC 流持续发送 nsScannerResp 响应信封。按协议契约,每条消息只能在两个指针字段中二选一:Update 携带单次进度更新 dataUsageEntry,或 Final 携带扫描完成后的汇总缓存 dataUsageCache。服务端处理器的对象池会跨扫描周期复用 nsScannerResp 实例,但不会在归还时自动清空字段。
此前 NSScannerHandler 仅对当次发送所需的字段赋值,未显式清空另一个指针。当后续扫描复用到同一个信封对象时,两个指针字段可能同时非空并被一并序列化发送。而客户端接收循环对两个字段分别独立判断:Update != nil 时转发进度更新,Final != nil 时记录最终缓存。其结果是,本应只发最终汇总的响应可能夹带上一轮残留的进度更新进入用量聚合,而中间进度消息也可能夹带上一轮扫描遗留的最终缓存。
尽管扫描结束前的最后一条合法 Final 消息会覆盖客户端保存的最终缓存指针,但中间消息破坏了字段互斥不变式并在节点间传输了陈旧数据。修复在服务端构造响应时显式清空另一侧指针:
该修复作用于跨节点远程磁盘扫描响应(本地磁盘扫描直接调用内部接口,不经过该 RPC 响应信封)。相关代码见服务端扫描处理器、客户端接收逻辑与存储桶用量聚合。
为什么不在通用 RPC 对象池中调用 Recycle
原始 PR 最初尝试在通用流式 RPC 处理器的 PutResponse 中调用 Recycler.Recycle(),随后再将同一响应对象放回处理器自身的 sync.Pool。这一做法混淆了“转移底层资源所有权”与“重置结构体字段”两种语义。
以 grid.MSS 为例,其 Recycle() 方法会将内部底层 map 归还给专门的 map 池,并将外层结构体的 map 字段置为 nil。如果紧接着把外层包装对象又放回响应池,下一次 NewResponse 从池中取出该对象时就会跳过构造函数,直接向调用方返回一个无法写入的 nil map。我们编写的补充复现用例在修复前主分支上正常通过,而在引入该通用池改动后会触发失败。
因此,维护者补丁撤回了通用 PutResponse 改动以及新加的 nsScannerResp.Recycle 方法,仅保留扫描处理器构造响应时的显式字段赋值。最终合入的 internal/grid/handlers.go 与 cmd/storage-rest-common.go 相对合并前主分支保持零改动,完整保留了 grid 类型定义中的 Recycler 资源所有权契约。
验证证据
确定性回归测试
合入代码包含两项回归测试:TestXLMetaV2HeaderLegacyUnmarshalResetsEc 与 TestNSScannerHandlerClearsStaleResponses。
版本头部测试覆盖了 V2 字节切片解码、V2 流式解码、loadIndexed 切片复用、V1 残留字段清零以及当前 V3 格式的 EC 字段保留。扫描器测试连续三轮调用真实 NSScannerHandler,在消息经序列化与反序列化后逐条校验两类响应的互斥性及各轮扫描数据归属。
值得强调的是,测试中通过池构造函数新分配的每个响应对象都会预先填入非空的旧 Update 与 Final 指针。这是因为 Go 的 sync.Pool 随时可能回收池中缓存的对象;如果测试仅依赖“上一轮归还的脏对象一定会在下一轮被取出”,在垃圾回收介入时就会退化为分配全新零值对象,导致测试在未触发缺陷的情况下假性通过。直接在构造函数中预置脏字段保证了测试的确定性:该用例在修复前主分支(0d3ba5d4cbfd)上稳定失败,在应用修复后稳定通过。
分布式集群扫描实测
端到端验证在本地回环网络上启动了两个服务进程、共八个数据目录的分布式实例,创建 12 个存储桶,每桶先写入两个不同大小的对象,随后在每个桶内执行删除一个对象、覆盖一个对象、新增一个对象的组合操作。
| 检查点 | 对象总数 | 逻辑总字节数 | 验证结果 |
|---|---|---|---|
| 首轮写入后 | 24 | 64,740 | 连续三次扫描刷新后,各桶及集群汇总用量均完全匹配 |
| 删除、覆盖与新增后 | 24 | 111,960 | 连续三次扫描刷新后,各桶及集群汇总用量均完全匹配 |
| 对象回读校验 | 24 | 111,960 | 全部 24 个对象的字节长度与 SHA-256 摘要完全一致 |
两个节点均产生了真实的存储桶扫描记录,其中非协调节点通过远程 RPC 流累计执行了 119 次 ScanBucketDrive 操作。
远端 CI 检查
PR 分支提交 86c5a92eef3d 通过了全部 8 项 CI 检查:DCO、6 项 Go CI 任务以及 VulnCheck。合入主分支后,主干上的 6 项 Go CI 任务与 VulnCheck 亦全部独立通过。
兼容性与运维指引
本次修复仅修正既有解码器与扫描响应构造处的字段赋值,不涉及存储格式、RPC 协议字段、依赖版本、IAM 策略或配置开关变更,无需重写存量对象或手动清理用量缓存。
- 将集群节点升级至 Server 20261018 或更高版本。 节点间线协议完全兼容,可按常规流程滚动升级;跨组件版本配套要求参见组件版本矩阵。
- 等待后台扫描自然收敛。 可通过
mcli admin scanner status ALIAS -n 1查看扫描进度,待完整扫描周期结束后核对各存储桶的对象计数与逻辑容量。 - 无需人工干预底层元数据。 切勿手动编辑磁盘上的
xl.meta、删除内部缓存对象或批量重写数据;常规后台扫描周期会自动刷新派生的用量统计。
致谢与交付记录
感谢 Julien Laurenceau 提交初始修复与测试用例。维护者在此基础上收窄了对象池改动范围,保持了 grid.Recycler 的资源所有权契约,并补充了确定性脏分配回归测试;原作者提交记录与 DCO 签名完整保留在合并历史中。
55 - LIST 漏项修复:仅对存疑对象补读确认并收敛锁等待开销
随 SILO Server 20261018 发布的 PR #239(合并提交 fbcb51197,关闭 #218 并保留 mr javad seydi 在 #221 中的原始签署提交)以及 10 月 18 日的目录与公共前缀跟进修复(3dd53eb81 与 2080b3d03),解决了并发读写期间 ListObjects 与 ListObjectsV2 静默漏报对象的问题。本设计承接九月的 null 版本仲裁修复,并替代了早期“直接调用普通 GET 元数据路径”的恢复方案。
成功的目录列举可能漏掉真实存在的对象
假设桶中存在 reports/a.csv,客户端可以通过 GET 正常下载,但 LIST 请求却返回 HTTP 200 OK 且结果中不包含该文件名。备份与资产清点工具通常将成功的列表响应视为权威输入,从而误报文件缺失;若同步工具开启了“删除源列表不存在的目标端对象”,甚至会误删目标端的有效副本。相比明确返回错误,一个返回 HTTP 200 但漏项的列表往往会造成更严重的下游破坏。
LIST 会收集不同磁盘在不同时刻看到的目录与元数据记录。在覆盖写、删除、滚动重启或跨池迁移期间,部分磁盘返回旧记录,部分返回新记录,还有部分暂时没有记录。达成列举法定人数(quorum)要求足够多的磁盘提供彼此相容的证据;不同代际或不同纠删码布局的记录不能简单将票数相加。此前,当某条目录记录无法形成法定人数结论时,旧路径会直接跳过该记录,而整个 LIST 请求依然返回成功。
本修复针对 ListObjects 与 ListObjectsV2(例如 GET /bucket?list-type=2&prefix=reports/),与 #79 的 ListMultipartUploads 分属不同接口。修复不新增 prefix=true、环境变量或任何客户端参数。
选定的恢复设计
列举流水线首先执行原有的目录合并逻辑,已达成仲裁的条目直接沿快路径返回。仅当条目属于只列举最新版本、存在至少一条可解码对象记录、且“可解码记录数加失败读取流数”达到原始列举阈值但仍未决时,才进入恢复流程。失败的读取流仅作为触发进一步确认的依据,绝不计作对象存在或满足读仲裁的证据。低于入口阈值的输入以及完整版本历史列举(ListObjectVersions)保持原有行为。
符合恢复条件的未决对象名会被传递至存储池汇总层(pool merger),按完整同名组统一确认。汇总层先获取该对象名的读锁以阻止并发同名修改打断确认过程,随后重新读取当前磁盘元数据。确认结果分为三类:
- 确认存在: 按对象实际的纠删码布局和原有读仲裁规则选出当前版本,并保留删除标记直至跨池合并阶段再行过滤旧副本。
- 确认已删除: 来源存储组(source set)的每一个配置盘位都必须在本次原始读取中返回
FileNotFound,且汇总层还须核对每个存储池当前应存放该对象名的目标存储组(target set)均无存活对象后,才能从列表结果中省略该名称。 - 仍无法确认: 返回既有的失败响应(例如 HTTP 503
SlowDownRead),确保客户端不会将不确定的状态误认为完整清单。离线盘、损坏元数据、缺失卷与解码失败均不得当作“已删除”处理。
流式分页仍可在全桶扫描结束前先返回已满的页面。客户端必须读取所有后续分页;若任一分页失败,此前已收到的页面均不能视作完整桶清单。
异步缓存清理同样保留原始请求身份:提交 2a4db6a4f 在启动后台清理任务前捕获缓存节点并按值传递列举选项,防止前台重置缓存 ID 时与后台清理任务发生竞态或向 RPC 发送空 ID。缓存格式与分页令牌编码均保持不变。
为何确认来源存储组不存在后还要检查其他存储池?假设在双池集群中,池 A 的旧目录扫描包含 a.csv,而池 B 先前的目录扫描为空;随后跨池迁移将对象复制到池 B 并删除了池 A 的副本。如果仅因池 A 当前为空就省略该名称,就会漏掉池 B 中的当前对象。因此在确认来源存储组严格为空后,汇总层会查询各池当前的目标存储组,并用最新结果替换整个同名旧结果组。同一次恢复中,每个不同的存储组最多补读一次。
正常并发 DELETE 是另一个典型反例:假设 16 个目录输入中有 6 份旧记录、6 份新记录、4 份无记录,目录扫描本身无法达成仲裁;但此时 DELETE 已返回 204、GET 已返回 404,且当前 16 块磁盘均明确返回 FileNotFound。#239 的早期版本曾将这种健康的删除后状态也判定为未决并返回 503。最终方案通过核对全盘原生 FileNotFound 准确放行正常删除,同时对真正的磁盘或解码故障保持失败关闭。
对象缺失不得吞掉同名目录
10 月 18 日合入的 3dd53eb81 修复了同名组中对象已确认不存在、但同名目录条目依然有效的场景:仅移除缺失的对象,保留目录(公共前缀)。后续 2080b3d03 覆盖了待恢复对象、普通对象与目录混合出现时的排序处理:重复目录折叠为一个公共前缀,仍存活的对象保持对象结果,避免将“确认对象不存在”误扩大为“整组目录不存在”。
内存元数据表示
本修复不新增任何持久化文件、xl.meta 字段、磁盘索引或迁移步骤,仅在内存 metacache 条目结构体中增加一个指向来源存储组的指针:
“待确认”状态仅在进程内部通道中流转,由池汇总层消费。两个缓存编码入口均显式拒绝尚未确认的条目,listingSet 绝不会写入分页缓存或发送给客户端,因此磁盘布局与网络编码完全不变。
补读使用只读元数据辅助函数,复用既有的版本选择与读仲裁规则,避免触发完整 GET 路径中的机会性修复(healing)或残留清理副作用。此外还包含一项窄范围的内存重计数逻辑:仅当原始选择失败、且最新 null 版本具有已知纠删码布局时,按相容的纠删码布局分组重新对照原始阈值统计票数,既不降低仲裁阈值,也不混加不同布局的票数。
各子系统具体改动
| 位置 | 改动及原因 |
|---|---|
| 目录解析与跨池汇总 | 将待确认名称传递至完整同名结果组并在汇总层统一补读,防止单池私自裁决导致漏项或旧副本复活。 |
| 元数据读取 | 区分原生的“全盘均不存在(FileNotFound)”与读取/解码故障;复用版本与读仲裁规则,避免引入 GET 的写副作用。 |
| 对象锁 | 多池场景沿用池级锁;单池多存储组场景按固定顺序获取首组与目标组读锁,同时覆盖 DELETE/COPY 与 PUT 的锁域;相同锁域只获取一次。 |
| 分页缓存 | 先发布列举的终态错误再结束缓存,使 HTTP 错误响应无需等待失败的远端缓存更新;远端更新失败时保留本地原缓存 ID 与错误。 |
| 读取待生成缓存块 | 读取失败后及时检查生产端状态,每次重试仅轮转探测一块在线盘,探测成功后再发起完整仲裁读取;正常读取不增加额外状态 RPC。 |
| 取消与锁竞争 | 允许上下文取消打断锁重试等待;仅对可明确识别的 LIST 恢复锁冲突提供一次 2 毫秒短等待与重检,降低短暂读锁触发长退避的概率。 |
| 回归测试与 CI | 在 -race 下覆盖真实删除、跨池迁移时序、双存储组锁路由、分页终止、失败 RPC、请求取消及迟到锁授予。 |
底层目录遍历回调保留原有函数签名。由于恢复错误统一由汇总层传播,扫描器、自愈、再平衡(rebalance)与退役(decommission)调用方无需修改接口。
当单池恢复需要跨两个不同锁域加锁时,持有第一把读锁等待第二把读锁期间可能会短暂延迟首组路由上的 DELETE 或 COPY 请求。恢复流程会在编码结果、发送通道与发起缓存 RPC 之前释放锁,避免慢客户端拖长持锁时间。若第二次加锁失败或请求取消,会立即释放第一把锁。
评估与否决的替代方案
| 候选方案 | 优点 | 否决原因 |
|---|---|---|
| 保持现状 | 零新增运行时开销 | 仍可能在 HTTP 200 响应中静默漏项,导致备份与同步工具误判。 |
| 遇到分歧一律返回 503 | 补丁最小,无需补读元数据 | 一次补读即可确认的对象也会报错;正常并发删除后也会反复返回 503。 |
| 对每个列举对象都加锁并补读 | 规则简单统一 | 将磁盘读取和锁开销强加给所有 LIST 请求,严重拖慢大桶列举与并发写入。 |
| 仅在来源池确认,或不加锁确认 | 减少锁获取或跨池查询次数 | 并发覆盖写、删除与跨池迁移仍可打断确认过程,实测反例证明依然会漏项。 |
| 全局调低所有写请求的重试间隔 | 可降低部分锁竞争长尾 | 影响面扩大到全局写路径,显著增加集群锁 RPC 流量与竞争。 |
| 对符合条件的分歧按需确认,配合局部短等待(已选定) | 正常路径保持轻量;可恢复时返回准确结果,不可确认时明确报错 | 分歧路径需承担实际补读与锁等待开销,实现与验证复杂度高于直接报错。 |
运行时开销分析
| 触发条件 | 额外代价 |
|---|---|
| 正常列举条目 | 在 64 位构建中,单个内存条目从 56 字节增至 64 字节(新增一个 8 字节指针)及少量分支判断;无额外对象读锁或磁盘补读。 |
| 单个名称进入恢复 | 对待确认来源存储组并发补读磁盘元数据;单池跨锁域需获取两次读锁,多池使用一次池级锁。 |
| 来源存储组明确不存在 | 进一步查询每个池的当前目标存储组(复用已读结果),读取上限为来源组与目标组的去重并集,而非扫描全桶。 |
| 大量名称同时需要恢复 | 同名组按目录字典序串行确认,高比例分歧会拉长整次扫描耗时。 |
| 恢复读锁与写锁冲突 | 每次识别出的恢复锁冲突 RPC 最多在互斥锁外名义等待 2 毫秒后重检一次,每个本地锁管理器共享最多 64 个并发等待名额(每次等待约增加 264 字节、4 次分配)。 |
| 锁获取失败后的普通退避 | 为支持上下文取消,将休眠改为计时器与 select,仅在初次取锁失败需要退避时增加少量分配;一次加锁成功的快路径无此开销。 |
以内存中同时驻留 1,000 个条目计算,新增指针字段本身约占用 8 KiB。单个对象名的恢复使用 5 秒上下文超时预算,取消后若有部分或迟到的锁授予也会立即清理。
验证证据与基准测试边界
本地 make test 通过全部 1,773 项顶层测试,且针对目录恢复、分页缓存、锁路由、分布式与本地锁取消及迟到授予的专项 -race 测试全部通过。在提交 87c1c6b3d 上,8 项 PR 检查全部通过(包括 Go CI、DCO、漏洞检查 与快照构建流水线)。
针对主干 7e9454456 的独立 10 轮配对基准测试(2 轮相同主干 A/A 对照 + 4 组交替顺序的主干/候选配对;每轮预热 20 秒、测量 120 秒,采用 8 进程、2 存储池、单块 NVMe 上的 32 个逻辑盘目录与回环网络小对象固定速率负载)共完成 22,410 次完整扫描,期间零漏项、零重复、零内容损坏、健康状态下零 HTTP 错误:
| 指标 | 候选 / 主干配对 p95 变化 | 95% 置信区间 |
|---|---|---|
完整 LIST 页面 |
+0.82% | −3.53% ~ +5.36% |
同名 DELETE |
−0.38% | −1.80% ~ +1.05% |
不同名 DELETE |
−0.55% | −4.74% ~ +3.84% |
次要指标配对变化如下:完整分页扫描 p95 约 +0.47%,PUT p95 约 +2.82%,GET p95 约 −0.51%,每页上限 7 条的中间续页 p95 约 +12.60%,同名 DELETE p99 约 +18.95%(候选版本出现一次 327.248 毫秒的同名 DELETE 峰值,而额外 A/A 基线对照中也曾出现 148.186 毫秒样本)。8 进程总 CPU 配对变化约 −0.07%,RSS 合计变化约 +0.11%。由于完整 LIST 页面 p95 的 95% 置信区间上界为 +5.36%(略超严格的 5% 门禁线),且两轮相同主干的不同名 DELETE p95 自身存在约 12% 波动,严格的“不超过 5% 退化”统计门禁记录为待定(inconclusive),而点估计表明主要稳态指标变化极小。
同一候选二进制还完成了 8 进程逐一优雅重启的滚动验证:负载期间完成 5,235 次完整扫描(测量窗口内 4,754 次),零漏项、零重复、零内容损坏;139 次失败 HTTP 请求全部落在对应端点的停机至就绪窗口内;最终 80 个对象状态、8 个端点在分页上限 1/7/1000 下的分页检查、416 次预期存在的 GET 与 512 次预期不存在的 GET 均与最后确认的写入或删除一致。
兼容性
S3 请求参数、分页令牌编码、磁盘存储格式、配置项与客户端调用方式完全保持不变。无需重写存量对象或启用新配置开关。进入恢复流程但因故障仍无法确认的条目会使 LIST 返回既有错误码(如 503 SlowDownRead),以此替代原先漏项却返回 HTTP 200 的行为。降级服务端无需转换数据。
56 - 对象锁保留期越权与版本覆写漏洞修复设计(SN-2026-015 / SN-2026-016)
本文档完整记录针对外部报告与内部关联排查发现的两处对象锁(Object Lock / WORM)安全缺陷——**SN-2026-015(PutObjectRetention 绕过请求头越权与条件策略失灵)**与 SN-2026-016(版本寻址写入覆写绕过及写路径锁缺口)——的事实核验、Git 历史溯源、修复设计、关键路径影响评估与候选方案的权衡取舍。完整修复(#234、#235、#241)已随 Server 20261018 正式发布。
关联公开安全公告:
1. 问题确认与外部报告核验
1.1 两条独立外部报告来源
本次修复覆盖两条独立报告链及一次内部同源排查:
- 首报(F2 主路径):由外部安全研究者 szopi00 通过 GitHub Security Advisory 私密报告(GHSA-67mv-7hc9-wj88),指出持有
s3:PutObjectRetention但无s3:BypassGovernanceRetention的主体可通过发送原始x-amz-bypass-governance-retention: true请求头缩短未到期的GOVERNANCE保留期。 - 独立外部报告(F1 / F2 / F3):另一份外部报告独立复现了三类攻击向量:未经授权将
GOVERNANCE改为多年COMPLIANCE、绕过基于请求体条件键的s3:PutObjectRetentionDeny 策略,以及无 bypass 授权缩短未到期的GOVERNANCE保留期。 - 内部同源关联排查(
SN-2026-016/ F4 与目录对象null版本覆写):在收到上述报告后,我们对“拥有写权限但无 bypass 权限的调用者还能通过哪些入口破坏受锁版本”进行了全量写路径审计,定位到非可信复制请求携带?versionId=原地覆写历史版本、目录键dir/隐式null版本覆写以及已持久化目标版本的分段上传完成三条同源缺口。
1.2 独立报告逐项核验结论
以下核验与代码行号针对修复前的 bcb5fc956 快照:
| 邮件报告论断 | 核验结果 | 代码证据与定性说明 |
|---|---|---|
危害 1(未经授权设置 COMPLIANCE 保留期):无 s3:BypassGovernanceRetention(甚至无 s3:PutObjectRetention 或被条件 Deny)的攻击者可将 GOVERNANCE 改为 COMPLIANCE 或直接写入超长 COMPLIANCE |
完全属实 | cmd/bucket-object-lock.go 第 205 行仅检查原始请求头 !byPassSet;cmd/auth-handler.go 第 773–781 行在目标模式为 COMPLIANCE 时跳过 IAM 求值,保持 byPassSet == true,并于 if byPassSet || retSet 直接返回 ErrNone。一旦写成 COMPLIANCE,即使 S3 root 管理员也无法通过 S3 API 在到期前删除 |
危害 2(削弱保留期 / 勒索软件擦除前置):无 s3:BypassGovernanceRetention 的凭据可携带请求头缩短未到期 GOVERNANCE 保留期 |
完全属实 | 请求体模式为 GOVERNANCE 时,虽然 isPutRetentionAllowed 把 byPassSet 更新为 false,但紧接着求值 retSet(s3:PutObjectRetention)为 true,末尾 byPassSet || retSet 依然放行 |
SILO 与上游 MinIO 在 DeleteObjectVersion 上的爆炸半径差异:SILO 限制了无 s3:DeleteObjectVersion 时的历史版本删除;上游 MinIO 将 s3:DeleteObjectVersion 设为 DenyOnly: true,仅凭 s3:DeleteObject 即可等锁到期后删空历史版本 |
完全属实 | SILO 在提交 75a6734e4(2026-09-02)引入 deleteObjectAction 显式要求 s3:DeleteObjectVersion;而上游 MinIO cmd/auth-handler.go 至今对指定 versionId 的删除使用 DenyOnly: true |
受影响范围与代码行号:缺陷位于服务端 cmd/ 而非 silo-pkg;SILO RELEASE.2026-09-03T13-18-01Z(9b11dc94)、RELEASE.2026-09-16T00-00-00Z、修复前的 SILO main(bcb5fc956)及当时核验的上游 minio/minio 快照均受影响 |
完全属实 | silo-pkg 的 IAM 策略引擎对 Deny 求值完全正确;问题纯粹出在 cmd/object-handlers.go、cmd/bucket-object-lock.go 与 cmd/auth-handler.go 的调用编排与布尔短路逻辑 |
复现脚本中无法用无条件 Deny s3:PutObjectRetention 规避:若对应用账号无条件 Deny s3:PutObjectRetention,则连向配置了默认保留期(Default Retention)的桶执行普通 PutObject 也会失败 |
完全属实 | 普通 PutObject 在相关对象锁检查路径中依赖 s3:PutObjectRetention,无条件 Deny 会阻断正常业务写入,因此生产策略只能使用带条件键的 Deny——而这正是被 F3 绕过的场景 |
2. 缺陷全景与 Git 历史溯源
2.1 SN-2026-015:PutObjectRetention 鉴权短路与绕过漏洞(F1 / F2 / F3)
在修复前的实现中,PUT /{bucket}/{object}?retention 存在两处结构性错位:
- 处理器入口只做签名认证,不做策略鉴权:
PutObjectRetentionHandler调用authenticateRequest(ctx, r, policy.PutObjectRetentionAction),该函数只验证签名/令牌有效性,不查询 IAM 策略; - 存量锁绕过检查与 IAM 策略裁决混杂且布尔方向写反:在对象写锁回调
enforceRetentionBypassForPut及辅助函数isPutRetentionAllowed中,服务端直接信任了客户端请求头x-amz-bypass-governance-retention: true,并用byPassSet || retSet将“绕过权限”与“写保留期基础权限”做逻辑或(||)短路。
由此产生三类具体漏洞(F1–F3)及一处清除保留期时的剩余天数计算缺陷:
| 编号 | 攻击者拥有的权限 | 触发方式与安全后果 |
|---|---|---|
| F1 | 仅通过身份认证、没有任何对象锁权限(无 s3:PutObjectRetention、无 s3:BypassGovernanceRetention) |
携带 x-amz-bypass-governance-retention: true 发送:① 空 <Retention/> 直接清零未到期的 GOVERNANCE 保留期;② <Mode>COMPLIANCE</Mode> 将任意对象(含已有 GOVERNANCE 锁的对象)直接设为到期前不可解除的 COMPLIANCE 保留期 |
| F2 | 持有 s3:PutObjectRetention、但没有 s3:BypassGovernanceRetention(两份报告均覆盖) |
携带 x-amz-bypass-governance-retention: true 将未到期的 GOVERNANCE 保留期缩短至几秒后,或原地改为 COMPLIANCE |
| F3 | 持有 s3:PutObjectRetention(受条件 Deny 限制)或持有 s3:BypassGovernanceRetention |
① 目标模式为 COMPLIANCE 或空 <Retention/> 时,携带 bypass 请求头直接跳过 isPutRetentionAllowed 中的策略求值,完全绕过挂在 s3:PutObjectRetention 上的条件 Deny(独立报告症状 2);② 持有 s3:BypassGovernanceRetention 时,在 GOVERNANCE 模式下同样因 byPassSet || retSet 绕过 s3:PutObjectRetention 条件 Deny |
| 附带算术缺陷 | 持有受 s3:object-lock-remaining-retention-days 条件限制的 s3:PutObjectRetention |
发送空 <Retention/> 清除保留期时,RetainUntilDate 为零值 0001-01-01,旧代码 math.Abs(objRetention.RetainUntilDate.Sub(t).Hours()) 因 time.Duration 292 年上限截断后取绝对值算出 106,752 天,导致“禁止剩余保留期小于 N 天”的 NumericLessThan 策略被清除操作直接绕过 |
精确 Git 提交溯源
- 2019-11 至 2020-03 的正确初始设计:
- 2020-04 引入核心缺陷(上游 #9259,
43a3778b4,2020-04-06,Harshavardhana):- 为支持来自 XML 请求体的三个锁条件键(
s3:object-lock-mode、s3:object-lock-retain-until-date、s3:object-lock-remaining-retention-days),该提交删掉了 handler 入口的checkRequestAuthType,将鉴权下沉到enforceRetentionBypassForPut内部的新函数isPutRetentionAllowed,一举引入三处错误:enforceRetentionBypassForPut直接读取原始请求头byPassSet := IsObjectLockGovernanceBypassSet(r.Header),并用if !byPassSet包裹防缩短守卫;isPutRetentionAllowed仅在if retMode == objectlock.RetGovernance && byPassSet时才向 IAM 求值s3:BypassGovernanceRetention,导致retMode == COMPLIANCE时byPassSet保持原始请求头的true不变;- 函数末尾写成
if byPassSet || retSet { return ErrNone },造成||短路。
- 为支持来自 XML 请求体的三个锁条件键(
- 2020-05 扩大 F1 爆炸半径(上游 #9677,
bede525dc,2020-05-22,Ashish Kumar Sinha):- 允许发送空
<Retention/>(Mode == "")清除GOVERNANCE锁,同样落在retMode != RetGovernance分支,使零权限攻击者不但能锁死COMPLIANCE,还能直接清零GOVERNANCE锁,同时引入了零值日期math.Abs算出106,752天的算术缺陷。
- 允许发送空
- 2025-02 临时遮蔽 F1 与 2025-04 机械回归:
- 上游 #20929(
437dd4e32,2025-02-12,Ramon de Klein)发现零权限用户可调用PutObjectRetention,于是在 handler 入口加回不带条件键的checkRequestAuthType(ctx, r, policy.PutObjectRetentionAction, bucket, object),临时挡住了 F1(但破坏了带锁条件键的Allow策略,且完全未修 F2、F3)。 - 仅 50 天后,上游 #21103(
8c7097528,2025-04-03,Harshavardhana)在重构流式签名验证时,把入口的checkRequestAuthType机械替换为只验签名、不查策略的authenticateRequest,却保留了上方的旧注释// Check permissions to perform this object retention operation,导致 F1 重新彻底暴露。SILO 分叉自该提交之后,完整继承了 F1–F3。
- 上游 #20929(
2.2 SN-2026-016:版本寻址写入覆写绕过及写路径锁缺口(F4 与同源路径)
标准 S3 协议中,公开对象写入操作(PutObject、CopyObject、CreateMultipartUpload、PostPolicy)只允许服务端生成新版本号(或在未开启/暂停版本控制时写入 null 版本),绝不允许客户端指定目标 versionId 去原地改写既有历史版本。然而现有实现存在三处同源写路径缺口:
| 路径编号 | 触发方式 | 安全后果 |
|---|---|---|
F4(显式目标 versionId 写入) |
普通 s3:PutObject 主体在 PutObject、CopyObject、CreateMultipartUpload、PostPolicy 或 Snowball 归档解压(PAX minio.versionId)中传入既有版本的 versionId(UUID 或 null) |
存储层按指定 versionId 原地覆盖该版本的数据载荷与元数据。对于处于未到期 GOVERNANCE 保留期的版本,其数据与保留期被直接替换或抹除 |
同源缺口 A(目录键 dir/ 隐式 null 版本覆写与慢速请求体竞态) |
对启用了对象锁的桶中任意以 / 结尾的目录键发起普通 PUT /{bucket}/dir/(不带 ?versionId=) |
① putOpts 将目录键隐式映射为 opts.VersionID = "null" 原地覆写,而 checkPutObjectLockAllowed 通过 getOpts 读到的 opts.VersionID 为空,跳过存量锁检查,导致普通 PUT dir/ 可原地覆盖并抹除目录 null 版本的 GOVERNANCE、COMPLIANCE 保留期与 LegalHoldOn;② 即便修补了 HTTP 预检,由于预检发生在读取 HTTP 请求体之前,写入者仍可通过预检后暂停发送请求体字节流,待管理员对 dir/ 加上 COMPLIANCE 锁后再发完请求体,在存储层覆盖已加锁的 null 版本;③ 预检阶段 checkPutObjectLockAllowed 仅在 getObjectInfoFn 返回 err == nil 时检查锁状态,遇到读仲裁失败等错误时直接跳过检查放行写入 |
| 同源缺口 B(已持久化目标版本的分段上传完成) | 分段上传在 NewMultipartUpload 时将目标 fi.VersionID 持久化到 .minio.sys/multipart/.../uploadID/xl.meta,CompleteMultipartUpload 通过独立的 completeMultipartOpts 提交该版本 |
升级前已创建的遗留分段上传任务、由复制主体发起的任务、或旧上传状态恢复后与已复制落盘的同 UUID 版本发生碰撞的任务,其目标版本号保存在 uploadID/xl.meta 中且不经过 putOpts;若完成阶段不在对象写锁下对 fi.VersionID 执行存量版本查重,仅有 s3:PutObject 的调用者即可在目标版本被加上 COMPLIANCE 锁后完成上传并覆写该版本 |
精确 Git 提交溯源
- 2020-05 对象锁检查遗漏
GOVERNANCE(上游 #9513,9b3b04ece,2020-05-04):cmd/bucket-object-lock.go的checkPutObjectLockAllowed在if opts.VersionID != "" && !replicaTrusted分支中只拦截了未到期的COMPLIANCE与LegalHoldOn,漏掉了未到期的GOVERNANCE,且仅在getObjectInfoFn返回err == nil时才进入检查。 - 2020-09 为桶复制开放目标
versionId却未校验复制信任(上游 #10598,56d1b227c,2020-09-30,Poorna Krishnamoorthy):在cmd/object-api-options.go的putOpts中读取?versionId=并赋给opts.VersionID,但从未检查trustedReplication,导致任何拥有s3:PutObject的客户端都能直接寻址覆写既有版本。 - 2023-05 目录对象强制使用
null版本(上游 #17132,1d0211d39,2023-05-04,Harshavardhana):在putOpts和delOpts中对isDirObject(object)强制设置opts.VersionID = nullVersionID,但未同步更新checkPutObjectLockAllowed与存储提交阶段,留下目录对象隐式null版本锁覆写缺口。
3. 最终修复方案:要修改哪些代码、怎么改
最终修复方案由**核心方案 A(SN-2026-015)与入口 + 存储写锁双重防线 + 预检 Fail-Closed(SN-2026-016)**两部分组成,共涉及 cmd/ 下 11 个生产文件:
| 所属问题 | 修改文件 | 核心改动内容 |
|---|---|---|
| SN-2026-015 (核心方案 A,3 个文件) |
cmd/object-handlers.go |
在 PutObjectRetentionHandler 解析请求体 XML 后、读取任何桶/对象状态前,立即调用 isRetentionActionAllowed 无条件校验 s3:PutObjectRetention |
cmd/bucket-object-lock.go |
新增 isRetentionActionAllowed(修正零值日期为 "0" 天、超远日期用秒差向上取整);重写 enforceRetentionBypassForPut,仅在削弱未到期存量 GOVERNANCE 锁时同时要求 bypass 请求头与 s3:BypassGovernanceRetention 授权 |
|
cmd/auth-handler.go |
删除存在 byPassSet || retSet 短路漏洞的 isPutRetentionAllowed 函数(净删 45 行) |
|
| SN-2026-016 (入口与写锁双重防线,8 个文件) |
cmd/object-api-options.go |
在所有 HTTP 写入选项的唯一汇合点 putOpts 中,对 vid != "" && !trustedReplication 一律返回 errAuthentication(403 AccessDenied) |
cmd/batch-handlers.go |
在 BatchJobReplicateV1.writeAsArchive 中为外层 SnowballOptions 补齐 ReplicationRequest: true 标记,使服务端 batch replicate 归档复制正常通过外层复制信任校验 |
|
cmd/bucket-object-lock.go |
① 在预检 checkPutObjectLockAllowed 中补齐 isDirObject(object) 的 nullVersionID 解析,将未到期 GOVERNANCE 纳入不可覆写条件,并在 getObjectInfoFn 返回非“不存在”错误时立即 Fail-Closed;② 新增 checkObjectLockedForOverwrite 供存储写锁内复检 null 版本锁状态(同样 Fail-Closed) |
|
cmd/bucket-handlers.go |
在 PostPolicyBucketHandler 中对目录对象(isDirObject(object))补充调用 checkPutObjectLockAllowed |
|
cmd/object-multipart-handlers.go |
在 CompleteMultipartUploadHandler 调用 checkPutObjectLockAllowed 时传入实际的 trustedReplication 状态 |
|
cmd/erasure-object.go |
在 erasureObjects.putObject 与 erasureObjects.CopyObject 持有目标对象命名空间写锁(NewNSLock)期间,对目标版本为 "" 或 nullVersionID 的非复制写入调用 checkObjectLockedForOverwrite |
|
cmd/erasure-multipart.go |
在 CompleteMultipartUpload 持有目标对象写锁期间,对 null 版本调用 checkObjectLockedForOverwrite,对非空 UUID 版本执行 getObjectInfo(多池下为 replicaObjectInfo)存量版本查重 |
|
cmd/erasure-server-pool.gocmd/erasure-sets.go |
在多池模式下为公开写入与分段上传完成注入跨池 z.replicaObjectInfo,并在 CopyObject 中透传版本与复制协调选项 |
3.1 SN-2026-015 修复代码详解(核心方案 A)
(1)cmd/object-handlers.go:PutObjectRetentionHandler 入口无条件基础鉴权
在 authenticateRequest 验签并调用 ParseObjectRetention(r.Body) 解析完请求体后,在读取任何桶信息(GetBucketInfo)、桶锁配置(globalBucketObjectLockSys.Get)或对象版本(getOpts)之前,立即调用 isRetentionActionAllowed 校验 s3:PutObjectRetention:
(2)cmd/bucket-object-lock.go:isRetentionActionAllowed 与 enforceRetentionBypassForPut
抽出统一的 isRetentionActionAllowed 辅助函数,从解析后的请求体构造 object-lock-mode、object-lock-retain-until-date、object-lock-remaining-retention-days 三个条件键并调用 globalIAMSys.IsAllowed:
- 当请求为清除保留期(空
<Retention/>,RetainUntilDate为零值0001-01-01)或非未来时间时,将object-lock-remaining-retention-days精确求值为"0"(修复旧代码因time.Duration截断与math.Abs算出106,752天的漏洞,且始终保留该条件键以确保NumericLessThan的Deny策略能够命中清除请求); - 当日期为未来时间时,改用 Unix 秒数差与纳秒差向上取整计算天数,避免
Time.Sub在约 292 年后发生time.Duration饱和。
同时,将对象写锁内的 enforceRetentionBypassForPut 收敛为纯粹基于存量版本状态的规则:
(3)cmd/auth-handler.go:删除 isPutRetentionAllowed
直接删除 cmd/auth-handler.go 中 45 行的 isPutRetentionAllowed 函数,消除 byPassSet || retSet 短路逻辑。
3.2 SN-2026-016 修复代码详解(入口守卫 + 存储写锁复检 + 预检 Fail-Closed)
为什么 SN-2026-016 必须在 HTTP 入口守卫之外保留存储写锁内的复检?原因在于以下四类场景无法仅靠入口或来源标记拦截:
- 慢速请求体导致的
PUT dir/覆写竞态(TestPlanReviewSlowDirectoryPut):PutObjectHandler的预检checkPutObjectLockAllowed运行在流式读取 HTTP 请求体r.Body之前,而存储写锁er.NewNSLock在请求体写完临时文件后才获取。仅持有s3:PutObject的写入者可以先发起PUT /bucket/dir/通过预检,随后暂停发送请求体字节流,等管理员对dir/设置COMPLIANCE保留期并收到200 OK响应后,再继续发完请求体。因此必须在对象写锁内复检null版本锁状态。 - 升级遗留或复制发起的已持久化
uploadId完成请求(TestPlanReviewLegacyMPUCompliance):putOpts只在NewMultipartUpload创建上传任务时运行;而CompleteMultipartUpload使用completeMultipartOpts,目标版本号直接读取自.minio.sys/multipart/.../uploadID/xl.meta中持久化的fi.VersionID。对于升级前已创建的遗留分段上传任务(或由复制凭据初始化的任务),必须在完成阶段的对象写锁下校验目标版本。 - 旧上传状态恢复后与已复制落盘的
COMPLIANCE版本碰撞(TestRecoveredMultipartUploadAfterReplica):即便分段上传在初始化时由服务端生成了新 UUID(甚至带有服务端来源标记),在旧上传状态快照恢复且存活副本已将该完成版本及COMPLIANCE锁重新同步回本地后,暂存目录中的fi.VersionID与已落盘的受锁版本具有相同 UUID。来源标记只能证明 UUID 生成时的来源,不能证明完成上传时该版本仍不存在。因此必须在CompleteMultipartUpload的对象写锁内直接查重目标fi.VersionID是否已存在。 - 预检与写锁复检的读取失败必须 Fail-Closed(
TestPlanReviewPreflightReadFailure):只有明确确认目标版本不存在(ObjectNotFound、VersionNotFound或删除标记MethodNotAllowed)时才能按“无锁”放行;当桶锁配置读取失败或底层存储发生读仲裁错误(如InsufficientReadQuorum)时,预检与写锁复检都必须拒绝写入。
完整改动分为以下四步:
(1)在 putOpts 唯一汇合点拦截非可信复制的 versionId(cmd/object-api-options.go)
所有 HTTP 写入选项构造(PutObject、CopyObject、NewMultipartUpload、PostPolicy、PutObjectExtract)最终都汇聚到 putOpts。在解析 vid 前增加 3 行守卫:
(2)补齐 Batch Snowball 归档复制发送端的外层复制标记(cmd/batch-handlers.go)
SILO 服务端自身的 batch replicate 在源与目标均为 SILO/MinIO 时会通过 writeAsArchive 发送 Snowball 归档,并在每个 tar PAX 条目中携带 minio.versionId。接收端 PutObjectExtractHandler 要求外层 HTTP 请求头具备 X-Minio-Source-Replication-Request: true(markerExact)才会对条目授予 entryTrusted。因此在 writeAsArchive 中为外层 PutObjectOptions 补上复制请求标记:
(3)在 HTTP 预检与存储对象写锁内双重拦截 null 版本锁覆写,且读取失败时一律 Fail-Closed(cmd/bucket-object-lock.go、cmd/erasure-object.go、cmd/erasure-server-pool.go、cmd/erasure-sets.go)
- HTTP 预检阶段(
checkPutObjectLockAllowed):使目录对象(isDirObject(object))与putOpts/delOpts一致地将空版本解析为nullVersionID;在!replicaTrusted时将未到期的GOVERNANCE与COMPLIANCE、LegalHoldOn一并视为不可覆写;并且当getObjectInfoFn返回非ObjectNotFound/VersionNotFound/MethodNotAllowed的错误时立即返回错误(Fail-Closed):
- 存储提交写锁阶段(
checkObjectLockedForOverwrite):在er.putObject、er.CopyObject、er.CompleteMultipartUpload持有目标对象命名空间写锁(NewNSLock)期间,对所有目标版本为""或nullVersionID的非复制写入调用checkObjectLockedForOverwrite(多池模式下通过z.replicaObjectInfo汇总跨池锁状态)。桶锁配置读取失败或对象读取失败时同样拒绝写入,只有确认目标null版本不存在或未被锁定才放行:
(4)在 CompleteMultipartUpload 对象写锁下按持久化 fi.VersionID 精确区分 null 版本与 UUID 版本覆写(cmd/erasure-multipart.go)
分段上传在初始化与完成之间可能跨越桶版本控制状态的切换,或涉及升级遗留任务、跨主体完成及旧上传状态恢复。因此在对象写锁(NewNSLock)内直接依据 uploadid/xl.meta 中持久化的 fi.VersionID 裁决非复制完成请求(!opts.ReplicationRequest && !opts.ReplicaLockReconcile && !opts.DataMovement):
- 若
fi.VersionID == "" || fi.VersionID == nullVersionID(写入null版本,含版本控制开启前发起的正常在途上传及目录对象上传):调用checkObjectLockedForOverwrite,在未触发对象锁时正常放行完成; - 若
fi.VersionID为非空 UUID:无论当前桶处于Enabled还是Suspended,通过getObjectInfo(多池经replicaObjectInfo)检查该 UUID 版本是否已存在于磁盘(含普通版本与删除标记);若已存在则直接返回errAuthentication(403 AccessDenied),读取失败则按 Fail-Closed 返回对应错误,且不清理未提交的分片文件。
4. 关键路径与性能影响分析
| 操作路径 | 修改后的执行路径与开销说明 |
|---|---|
GetObject / HeadObject / ListObjects / DeleteObject |
完全不经过任何修改点,读路径与删除路径无任何变更 |
普通桶(未开启对象锁)的 PutObject / CopyObject |
putOpts 增加一次内存空串判断 vid != "";写锁内 checkObjectLockedForOverwrite 读取进程内 globalBucketObjectLockSys.Get(bucket) 缓存发现 !retentionCfg.LockEnabled 后立即返回,无额外磁盘 I/O 或跨池请求 |
对象锁桶中的常规对象 PutObject / CopyObject(非 / 结尾) |
对象锁桶强制开启版本控制,常规单次对象写入生成的 fi.VersionID 为非空 UUID,不满足 fi.VersionID == "" || fi.VersionID == nullVersionID,既不在预检也不在写锁内触发额外的 getObjectInfo 磁盘读取 |
对象锁桶中的目录对象写入(PUT dir/)或暂停版本控制下的 null 版本覆写 |
在预检与对象写锁内读取 null 版本元数据进行锁校验,并在读取失败时立即拒绝写入,防止慢速请求体竞态与读仲裁故障下的锁覆写 |
版本化桶中的分段上传完成(CompleteMultipartUpload) |
在 CompleteMultipartUpload 最终提交写锁内执行一次 getObjectInfo(多池下为 replicaObjectInfo)核验 fi.VersionID 是否已存在。该检查仅在分段上传最终合并提交时发生一次(NewMultipartUpload 与各 UploadPart 均不受影响);针对常规新建上传跳过该查询的优化需另行设计能抵御快照恢复重放的机制并独立验证,不在本次安全修复中强行并入 |
PutObjectRetention 合法延长保留期与未授权请求 |
基础权限 s3:PutObjectRetention 前移至读取桶/对象状态之前:未授权请求在入口即被内存态 IAM 求值拒绝(消除原有的磁盘读取与写锁争用);合法延长保留期请求在对象写锁内判定 !weakens 后立即返回,无需在写锁内重复执行 IAM 求值 |
5. 核心安全不变量与升级兼容性说明
5.1 三条核心安全不变量
- 不变量一:基础权限与锁条件键在读取任何状态前无条件成立(消除 F1、F3、清锁天数绕过与存在性探测)
PutObjectRetention支持的三个对象锁条件键全部派生自请求体 XML 与当前时间,不依赖桶或对象的任何存量元数据。因此在ParseObjectRetention之后立即调用isRetentionActionAllowed(policy.PutObjectRetentionAction, ...),既是最早可求值点,也是完全求值点。- 删除
byPassSet || retSet并将清除保留期(空<Retention/>)的剩余天数精确求值为"0"后,任何缺少s3:PutObjectRetention授权、或命中条件Deny/ 不满足条件Allow的请求,无论是否携带 bypass 请求头、也无论请求何种模式,一律在入口被403 AccessDenied拦截。
- 不变量二:Bypass 仅由“存量锁未到期且被削弱”触发,且意图与授权必须同时具备(消除 F2)
enforceRetentionBypassForPut在对象写锁保护下检查已存储的ret.Mode与ret.RetainUntilDate。若存量无锁、已到期、或请求未削弱存量锁(!weakens),无需 bypass;一旦weakens == true,存量COMPLIANCE无条件返回400 ObjectLocked,存量GOVERNANCE必须同时满足请求头(IsObjectLockGovernanceBypassSet)与 IAM 授权(isRetentionActionAllowed(BypassGovernanceRetentionAction, ...))。
- 不变量三:公开数据写入在入口与提交写锁下均不能替换既有 UUID 版本或受锁
null版本,且读取失败一律 Fail-Closed(消除 F4 与同源写路径缺口)- 入口层:
putOpts拒绝一切非可信复制指定的目标versionId;checkPutObjectLockAllowed拦截对受锁目录null版本的写入并在读取错误时立即拒绝放行。 - 提交写锁层:在与实际重命名提交相同的对象命名空间写锁(
NewNSLock)下,对null版本覆写再次执行 Fail-Closed 的checkObjectLockedForOverwrite(闭合慢速请求体 TOCTOU 窗口),对分段上传的非空fi.VersionID执行存量版本查重(闭合升级遗留任务、跨主体完成与旧上传状态恢复重放窗口)。
- 入口层:
PutObjectRetention 完整判定表
retOK 表示带请求中的锁条件键时 s3:PutObjectRetention 被允许,bypassOK 表示同一组条件键下 s3:BypassGovernanceRetention 被允许:
| 目标版本存量状态 | 请求变更类型 | Bypass 请求头 | retOK |
bypassOK |
修复前行为 | 修复后行为 |
|---|---|---|---|---|---|---|
| 任意 | 任意 | 任意 | 否 | 任意 | 常为 200(F1、F3)或 404/400 | 入口 403 AccessDenied |
未到期 GOVERNANCE |
延长保留期 | 任意 | 是 | 任意 | 200 | 200 OK |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 无 | 是 | 任意 | 400 InvalidRequest(ObjectLocked) |
400 InvalidRequest(ObjectLocked) |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 有 | 是 | 否 | 200(F2 漏洞) | 403 AccessDenied |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 有 | 是 | 是 | 200 | 200 OK |
未到期 COMPLIANCE |
缩短 / 清除 / 改模式 | 任意 | 是 | 任意 | 400 InvalidRequest(ObjectLocked) |
400 InvalidRequest(ObjectLocked) |
| 无保留期或已到期 | 任意 | 任意 | 是 | 任意 | 200 | 200 OK |
写路径不变量覆盖表
| 写入路径 | 选项汇合点与写锁防线 | 修复后的保证 |
|---|---|---|
PutObject(常规 PUT / 桶复制 / 站点复制 / 单对象 Batch 复制) |
验签后经 evaluateReplicationTrust 写入上下文,再调用 putOptsFromReq -> putOpts;null 版本在写锁下执行 checkObjectLockedForOverwrite |
公开客户端(含 root)带 ?versionId= 一律在 putOpts 返回 403;可信复制正常保留源版本 ID |
CopyObject(含元数据复制) |
源版本走 srcOpts,目标版本经 copyDstOpts -> putOpts;null 版本在写锁下执行 checkObjectLockedForOverwrite |
公开复制源版本正常生成新版本;公开指定目标 ?versionId= 返回 403;可信复制正常同步目标版本 |
PostPolicy(浏览器表单上传) |
经 putOptsFromReq -> putOpts;目录对象预检与写锁内双重执行锁检查 |
携带目标 versionId 一律返回 403;受锁目录对象不可被表单覆写 |
PutObjectExtract(Snowball / Batch 归档复制) |
外层校验 markerExact,逐条目校验 s3:ReplicateObject 得出 entryTrusted 再传入 putOpts;writeAsArchive 补齐外层标记 |
未获复制信任的归档条目若带 minio.versionId 一律返回 403;服务端 batch replicate 归档路径正常保留版本 ID |
NewMultipartUpload / UploadPart / UploadPartCopy |
初始化与加密分片走 putOptsFromReq -> putOpts;UploadPartCopy 强制清除复制信任 |
非可信复制初始化分段上传无法指定目标 versionId(返回 403) |
CompleteMultipartUpload |
走 completeMultipartOpts,并在对象写锁下按 fi.VersionID 执行 null 版本锁检查或 UUID 存量版本查重 |
版本控制开启前发起的正常在途上传可正常完成;针对既有 UUID 版本(含升级遗留任务、指定版本任务与恢复重放任务)的非复制完成请求一律返回 403 |
目录对象 PUT / Copy(/{bucket}/dir/) |
预检 checkPutObjectLockAllowed 与写锁内 checkObjectLockedForOverwrite 双重 Fail-Closed 校验 |
一旦目录 null 版本存在未到期的 GOVERNANCE、COMPLIANCE 或 LegalHoldOn(含预检后慢速传输期间并发提交的锁),非复制覆写返回 400 InvalidRequest(ObjectLocked);读取失败时在预检与写锁均拒绝写入 |
5.2 升级与兼容性注意事项
常规的新版本创建、普通对象读写与已获授权的保留期延长保持原有行为。以下收紧的边界行为需在升级与发布说明中提示运维人员:
- 仅授予
s3:BypassGovernanceRetention而无s3:PutObjectRetention的策略不再放行保留期写入(SN-2026-015)- 旧行为:因
byPassSet || retSet漏洞,仅有s3:BypassGovernanceRetention的主体带上请求头也能成功调用PutObjectRetention。 - 新行为:返回
403 AccessDenied。按 AWS S3 规范,s3:BypassGovernanceRetention是削弱治理锁时的附加权限,写保留期始终需要s3:PutObjectRetention。
- 旧行为:因
- 未授权
PutObjectRetention请求在入口统一返回403,不再返回404或400(SN-2026-015)- 旧行为:桶不存在时返回
404 NoSuchBucket,桶未开启对象锁时返回400 InvalidBucketObjectLockConfiguration。 - 新行为:在读取桶状态前完成鉴权,未授权主体一律收到
403 AccessDenied。
- 旧行为:桶不存在时返回
- 清除保留期(空
<Retention/>)时s3:object-lock-remaining-retention-days求值为"0"(SN-2026-015)- 旧行为:零值日期经
time.Duration截断与math.Abs计算后得到106,752天,导致基于s3:object-lock-remaining-retention-days的最短保留天数策略在清除保留期时失效。 - 新行为:清除请求将该条件键求值为
"0",受最短保留天数策略约束的主体无法通过直接清除保留期绕过最小天数限制。
- 旧行为:零值日期经
- 非复制请求(包括
root)不能再通过?versionId=原地覆写对象版本(SN-2026-016)- 旧行为:任何拥有
s3:PutObject的调用者(含root)带?versionId=<id>发送PUT、CopyObject或NewMultipartUpload会原地改写该版本。 - 新行为:不带可信复制标记的请求携带目标
versionId时一律在putOpts返回403 AccessDenied。标准 S3 协议本就不存在公开的按版本号覆写接口。
- 旧行为:任何拥有
- 向 SILO 目标执行
batch replicate时,目标端凭据必须持有s3:ReplicateObject(SN-2026-016)- 旧行为:若跨集群
batch replicate配置的目标端凭据只有s3:PutObject而无s3:ReplicateObject,目标端虽然剥离了内部复制头,但putOpts仍接受了 URL 中的?versionId=,从而静默保留了源版本号。 - 新行为:保留源版本号必须通过目标端的复制信任校验(即目标凭据需具备
s3:ReplicateObject),否则目标端返回403 AccessDenied,与桶级复制(Bucket Replication)的权限要求一致。
- 旧行为:若跨集群
- 对象锁桶中受锁目录对象(
dir/)在锁到期前不可被重复PUT覆写(SN-2026-016)- 旧行为:目录对象强制共用
null版本且跳过了存量锁检查,重复PUT dir/会静默覆盖并重置其保留期。 - 新行为:当对象锁桶设置了默认保留期、或有人对
dir/(versionId=null)设置了保留期/法务保留时,在该锁到期前再次写入dir/会返回400 InvalidRequest(ObjectLocked)。
- 旧行为:目录对象强制共用
- 独立跟踪的相邻议题(不并入本次补丁)
PostRestoreObjectHandler(归档对象恢复)先读快照再调用CopyObject回写完整元数据映射的并发竞态属于归档恢复路径的独立问题,另行立项处理。
6. 候选方案权衡评估与最终取舍
在设计评审期间,我们针对写路径防护评估并实际构造测试检验了以下四套候选方案:
| 评估维度 | 候选一:仅保留 HTTP 入口与预检守卫 (已否决) |
候选二:凭分段上传“服务端生成版本标记”跳过写锁 UUID 查重 (已否决) |
候选三:fe3935476 完整写锁复检(无预检 Fail-Closed) |
最终落地方案:fe3935476 完整写锁复检 + 预检 Fail-Closed |
|---|---|---|---|---|
SN-2026-015 保留期鉴权(F1 / F2 / F3 / 清锁 0 天计算) |
通过 | 通过 | 通过 | 通过 |
慢速请求体 PUT dir/ 竞态覆写 COMPLIANCE(TestPlanReviewSlowDirectoryPut) |
失败(P1):暂停请求体即可覆写已提交的 COMPLIANCE 锁 |
通过 | 通过 | 通过 |
升级遗留或复制发起的 uploadId 覆写(TestPlanReviewLegacyMPUCompliance) |
失败(P1):CompleteMultipartUpload 不经 putOpts,直接覆写 COMPLIANCE 版本 |
通过 | 通过 | 通过 |
旧上传状态恢复后与已复制落盘的同 UUID COMPLIANCE 版本碰撞(TestRecoveredMultipartUploadAfterReplica) |
失败(P1) | 失败(P1):生成标记只能证明初始化时的来源,不能证明完成时该版本仍不存在 | 通过(写锁内无条件按 fi.VersionID 查重) |
通过(写锁内无条件按 fi.VersionID 查重) |
预检阶段读仲裁失败时的行为(TestPlanReviewPreflightReadFailure) |
失败(P1):预检忽略读错误并覆写对象 | 通过 | 写锁内拦截,但预检忽略读错误并继续接收请求体 | 通过(预检即刻 Fail-Closed 拒绝,不进入存储写入) |
| 分段上传完成阶段查询开销 | 无额外查询 | 新上传跳过查询(但引入恢复重放漏洞) | 每次完成在写锁内执行一次 getObjectInfo |
保持写锁内 getObjectInfo 严格查重;性能优化留待后续单独设计验证 |
最终结论
采用**fe3935476 完整写锁防线 + 预检 Fail-Closed 加固**作为最终落地实现,并将慢速目录写入(TestPlanReviewSlowDirectoryPut)、预检读故障(TestPlanReviewPreflightReadFailure)、遗留分段上传(TestPlanReviewLegacyMPUCompliance)及恢复后分段上传碰撞(TestRecoveredMultipartUploadAfterReplica)四组复现用例全部纳入 cmd/object-retention-overwrite_test.go 回归测试集。