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 撤销并发执行验证。
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 秒)的正向慢查询测试,才能防止超时范围扩大造成的回归。
相关设计文档: