STS 临时凭证在自动化中的正确用法:一次 20 并发的刷新与 8 小时的时区差

举报
yd_263929376 发表于 2026/10/31 13:48:33 2026/10/31
【摘要】 STS 临时凭证在自动化中的正确用法:一次 20 并发的刷新与 8 小时的时区差临时凭证不是"在配置里多填一个 token 字段",它把一个长期密钥换成了三件套加一个到期时刻。后者会推翻脚本里所有"配置一次、跑一整年"的假设。我在一个批量运维脚本上踩了四类坑,数据都留在下面。 三元组要落在三个不同的位置本机 KooCLI 的 ~/.hcloud/config.json:{ "mode":...

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,五分钟后自己恢复"。

检查清单

  1. token 是否随每个业务请求发出,而不只在换凭证时出现
  2. expires_at 是否按带时区的绝对时间解析
  3. 刷新阈值是否用服务端时间锚定,而不是本地时钟
  4. 刷新是否单飞,跨进程是否有文件锁
  5. 401 是否只重试一次
  6. 日志脱敏是否是正则整体替换,而不是截断
  7. 404 是否被误当成可重试错误

临时凭证真正的难点不在"怎么拿到",而在"什么时候该换、换的时候有几个人同时在换"。把刷新做成一个以服务端时间为准、带锁的单例,剩下的故障基本都出在日志和重试策略上。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。