STS 临时凭证在自动化中的正确用法:一次 20 并发的刷新与 8 小时的时区差
STS 临时凭证在自动化中的正确用法:一次 20 并发的刷新与 8 小时的时区差
临时凭证不是"在配置里多填一个 token 字段",它把一个长期密钥换成了三件套加一个到期时刻。后者会推翻脚本里所有"配置一次、跑一整年"的假设。我在一个批量运维脚本上踩了四类坑,数据都留在下面。
三元组要落在三个不同的位置
本机 KooCLI 的 ~/.hcloud/config.json:
{
"mode": "AKSK",
"accessKeyId": "Oe8GhH2isSj5...(48 字符密文,已截断)",
"secretAccessKey": "Ouhkh3mhjBv1+...(76 字符密文,已截断)",
"securityToken": "",
"region": "cn-north-4",
"connectTimeout": 5,
"readTimeout": 10,
"retryCount": 0
}
securityToken 长度为 0,这是永久 AK 模式。切到临时凭证时,最常见的错误是只替换了 AK/SK,没填 token——服务端用一个不存在的会话去验签,返回 401 APIG.0301,message 是 Incorrect IAM authentication information: verify aksk signature fail。看起来像"AK 写错了",实际是漏了第三个字段。真正麻烦的是它长得像权限问题,于是有人去改 IAM 策略,越改越远。
令牌要跟着每一个业务请求走,不是只在换凭证时出现:
def hw_call(method, url, cred, body=b""):
headers = {
"X-Security-Token": cred["securitytoken"], # 漏这行 = 401
"Content-Type": "application/json",
}
signer.sign(headers, cred["access"], cred["secret"]) # 签名必须在塞完 token 之后
return session.request(method, url, headers=headers, data=body)
顺序有讲究:先塞 token 再签名,签名器才看得到它。反过来写,请求能发出去,但签名对不上。
expires_at 不能用"签发时间 + duration"推算
STS v5 真实返回:
{"credential": {
"access": "STUB...",
"secret": "...",
"securitytoken": "gQAAAA...",
"expires_at": "2026-10-31T12:35:57.000000Z"
}}
本机 Python 3.13 实测三种解析写法:
datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ")
# ValueError: time data '2026-10-31T12:35:57.000000Z' does not match format '%Y-%m-%dT%H:%M:%SZ'
datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ")
# OK,但 tzinfo=None —— 得到一个"没有时区"的时间
datetime.now(timezone.utc) - 上面那个
# TypeError: can't subtract offset-naive and offset-aware datetimes
第二条不报错才最危险。如果顺手写成 datetime.now()(本地时间)相减,脚本不崩,但结论错了。本机是 UTC+8,实测偏差 -28800 秒:脚本认为凭证在 8 小时前就已经过期,于是每次调用都去刷一次 STS。日志里会看到刷新频率高得毫无道理,却没有任何报错。
fromisoformat 在 3.11 之后能直接吃尾部的 Z 并带回 UTC 时区,3.11 之前会抛 Invalid isoformat string。跨机器跑的脚本,别赌解释器版本。
用 duration 反推到期时间同样不可靠。CLI 自带的接口元数据里,duration_seconds 的取值范围是 [900, 43200]、默认 3600,还附了一条约束:携带 X-Security-Token 头再取凭证时,该值不能超过 3600 秒。链式委托时传 43200,拿回来的可能仍是 3600。有效期以 expires_at 为准,duration_seconds 只是你提出的请求。
提前刷新:300 秒是个会随时钟漂移的常量
令牌真实有效期 600 秒,刷新阈值 300 秒,每 60 秒调用一次共 20 次,人为给本地时钟加偏移,结果:
| 本地时钟偏移 | 刷新次数 | 401 次数 |
|---|---|---|
| −900 s | 0 | 10 |
| −600 s | 1 | 6 |
| −300 s | 1 | 1 |
| 0 | 3 | 0 |
| +300 s | 19 | 0 |
| +900 s | 20 | 0 |
慢 15 分钟:一次都不刷新,20 次调用里 10 次 401。慢 5 分钟:刚好在过期瞬间撞一次 401。快 5 分钟:20 次调用刷 19 次 STS,把换凭证的配额白白打满。
修法不是把阈值调大,而是别拿本地时间当绝对基准。取一次成功响应的 Date 头做锚点:
from email.utils import parsedate_to_datetime
def clock_offset(resp):
server = parsedate_to_datetime(resp.headers["Date"])
return (server - datetime.now(timezone.utc)).total_seconds()
def remaining(cred, offset):
exp = datetime.fromisoformat(cred["expires_at"]) # 3.11+ 自带 UTC
return (exp - (datetime.now(timezone.utc) + timedelta(seconds=offset))).total_seconds()
另一个可用的自检点:401 的 message 会回显服务端认定的到期时刻(token expires, expires_at: xxx)。如果这个值和本地缓存里存的不是同一个,说明刷新的结果根本没写回缓存——多进程场景里这条几乎每次都能命中。
并发刷新:20 个线程打出 20 次 STS
桩服务每次 STS 延迟 350ms,20 个线程同时发现令牌过期:
| 实现 | STS 调用 | 总墙钟 | p50 | 最慢 |
|---|---|---|---|---|
| 检查与刷新都不加锁 | 20 | 1062 ms | 544 ms | 1059 ms |
| 锁内二次检查(单飞) | 1 | 355 ms | 350 ms | 353 ms |
_lock = threading.Lock()
def get_cred():
with _lock: # 锁必须包住"判断 + 刷新"整段
if _cache["exp"] - time.time() > 60:
return _cache["cred"]
c = assume_agency() # 只有第一个线程会真的发请求
_cache["cred"] = c
_cache["exp"] = datetime.fromisoformat(c["expires_at"]).timestamp()
return _cache["cred"]
锁只包判断、不包刷新,等于没加锁——20 个线程全部通过检查,然后依次去发请求,结果和上表第一行一样。跨进程还要文件锁,否则 4 个 worker 就是 4 次刷新。
同一个坑的另一种形态:把"取凭证"塞进每次 API 调用的前置步骤。200 次调用 = 200 次 STS,按 350ms 算,纯等待多出 70 秒,还没算限流。凭证是进程级资源,不是请求级资源。
887 字符的令牌进日志,占掉 93%
按 curl -v 的风格构造一条请求日志:令牌 887 字符,日志文件 954 字节,令牌占 93%。三种处理方式实测:
- 原样写入:全文命中 1 次
- 只截前 20 字符:仍命中 1 次。截断不是脱敏,20 个字符足够定位到具体会话
- 正则整体替换:命中 0 次
REDACT = re.compile(r"(X-Security-Token:\s*)\S+", re.I)
safe_line = REDACT.sub(r"\1***", line)
落盘权限更容易想当然。os.chmod(p, 0o600) 之后,os.stat(p).st_mode & 0o777 在本机 Windows 上读到的是 0o666。Windows 的 chmod 只切只读位,ACL 一动不动。真要隔离得用 icacls 去掉继承,或者干脆不落盘:进程内存持有,退出时删除,崩溃后重新申请一次。
对比一下 KooCLI 自己的做法:config 里 AK/SK 是 48 / 76 字符的密文,authEncrypt 为 true,密钥组件拆到 cipher.json 的三个目录里。自己写脚本至少别把明文 token 写进 .env 再提交上去。
STS5.1106 是 404,不是 403
本机 2026-10-21 15:08:35 的真实日志:
ERROR [STS][AssumeAgency/v1] - URL: https://sts.cn-north-4.myhuaweicloud.com/v5/agencies/assume,
status code is 404.
{
"error_code": "STS5.1106",
"error_msg": "the agency 'iam::b1272fa5b561415eb88dacc1f0ecb4be:agency:SELF_VERIFY' cannot be found"
}
三个信息点:
一是状态码。403 是"找到了但不许",404 是"这个 URN 指向的委托不存在"。404 重试一万次也不会变成成功,但很多脚本把 5xx 和 4xx 一起塞进重试分支。
二是报错回显了完整 URN。委托 URN 的形状是 iam::<账号ID>:agency:<委托名>,第二段(region)留空。写成 iam:cn-north-4:<账号>:agency:<名字>、或者干脆漏掉账号段,都会落到 404。手写 URN 时,第 3 段和你当前账号对不上,就是这一条。
三是紧接着的兜底动作也没兜住:CLI 去错误中心查 .../v2/errors?keyword=STS5.1106,324 ms 后返回 No solutions in response body。自动化里别指望错误中心替你解释。
重试策略值得单独写一条:401/403 不重试,刷新令牌后重试一次,再失败就退出。官方文档明确写了同一个 AK/SK 连续鉴权失败 5 次以上会被锁定 5 分钟——一个 retry=10 的循环能把自己的密钥锁死,之后所有正常调用一起挂。这类事故的现场特征是"昨天还好好的,今天全部 401,五分钟后自己恢复"。
检查清单
- token 是否随每个业务请求发出,而不只在换凭证时出现
expires_at是否按带时区的绝对时间解析- 刷新阈值是否用服务端时间锚定,而不是本地时钟
- 刷新是否单飞,跨进程是否有文件锁
- 401 是否只重试一次
- 日志脱敏是否是正则整体替换,而不是截断
- 404 是否被误当成可重试错误
临时凭证真正的难点不在"怎么拿到",而在"什么时候该换、换的时候有几个人同时在换"。把刷新做成一个以服务端时间为准、带锁的单例,剩下的故障基本都出在日志和重试策略上。
- 点赞
- 收藏
- 关注作者
评论(0)