节点临时存储 ephemeral-storage 压力在整点前驱逐了令牌刷新任务 Pod。替代 Pod 虽然被创建,但错过了 00:00:05 的整点任务,且系统没有启动补偿、短周期重试或 Token 缺失自愈。共享 component_access_token 随后缺失,使多个商家小程序登录卡在"授权中"。到 01:00:05 下一轮任务成功后,系统才自动恢复。
脱敏与证据说明
本文已隐去真实集群地址、节点 IP、商家与门店标识、小程序 AppID、日志索引与内部服务细节。时间线、Pod 事件、失败统计和根因机制保持不变。逻辑数据模型使用示例名,不代表线上真实表名。
1. 背景与问题
老系统承载多商家小程序的会员登录与开放平台授权。小程序登录并不是单纯的本地账号校验,它依赖开放平台公共令牌,再根据不同商家的授权关系换取会话。
事发时用户点击"确认登录"后,页面长时间停留在"授权中",没有展示可理解的错误。现场先发现两个商家异常,但服务端日志表明影响面实际更大。
事故影响
| 指标 | 结果 |
|---|---|
| 异常窗口 | 00:00:15 至 00:59:50(北京时间) |
| 已识别失败 | 337 次小程序登录/授权失败 |
| 服务端范围 | 8 个会员服务 Pod,涉及 34 个脱敏 AppID |
| 用户体感 | 页面持续显示"授权中",无明确失败原因 |
| 恢复方式 | 01:00:05 下一轮定时刷新成功,01:01:22 观测到首次成功登录 |
2. 排查目标与约束
- 确认是小程序端、网络、会员服务、Redis,还是开放平台异常。
- 将用户截图时间与 Kubernetes 事件、定时任务和业务日志对齐。
- 不因"现在已恢复"而停止排查,还原自愈前的完整故障链。
- 不重启正常业务 Pod,不删除正在使用的镜像,不扩大故障面。
- 区分"已证实的证据"与"基于现象的推断"。
3. 系统架构

开放平台公共令牌不属于某一个商家,而是多个小程序共享的平台级依赖。任务服务定时从开放平台获取新令牌,写入 Redis;会员服务的多个 Pod 在登录时共享读取该令牌。这种设计减少了平台请求次数,却也使一个 Token 缓存键成为多租户登录链路的共享单点。
4. 核心设计与正常执行流程
- 任务服务按
5 0 0/1 * * ?每小时的第 5 秒执行刷新。 - 调用开放平台接口获得新 Token 与有效期。
- 将 Token 写入 Redis 共享键,并设置 TTL。
- 会员服务根据脱敏 AppID 找到授权关系,从 Redis 读取平台 Token,完成登录会话换取。
原设计的脆弱点
定时任务是"在某个精确时刻尝试一次",不是"持续保证 Token 可用"。只要唯一的执行时刻恰好遇到 Pod 重建,就会丢失整整一个小时的刷新机会。
5. 逻辑数据模型与 ERD

| 逻辑实体 | 职责 | 关联键与一致性 |
|---|---|---|
| merchant | 商家主体 | merchant_id 为主键,与授权关系一对多 |
| miniapp_authorization | 保存商家与小程序的授权关系 | auth_id 为主键,app_id 是跨系统业务键,merchant_id 是外键/业务关联键 |
| platform_token_cache | 平台级共享令牌与过期时间 | 通过 token_type 定位;多个 AppID 共享,不应将它误认为商家级 Token |
| token_refresh_record | 记录每个计划时刻的开始、成功或失败 | 建议对 scheduled_at 建立唯一约束,方便识别"根本没执行" |
| authorization_request_log | 记录脱敏 AppID、Pod、Trace ID 与失败原因 | 可以把"用户看到的授权卡住"对齐到后端共享依赖 |
推荐写入顺序: 先记录本次刷新为 STARTED,再请求开放平台;成功后使用单条原子命令替换 Redis 中的 Token 和 TTL,最后将刷新记录更新为 SUCCESS。若外部请求失败,旧 Token 尚在安全有效期时不应被删除。
6. 数据流与异常时序

这次问题不是外部平台整体不可用,而是内部刷新链路在关键时刻中断。登录流量仍然正常进入多个会员服务 Pod,因此表面看起来"服务没有宕机";但每个 Pod 都依赖同一个缺失的共享 Token,最终形成横向放大。
7. 故障时间线

| 时间 | 事件 | 技术含义 |
|---|---|---|
| 23:55:59 | 旧任务 Pod 被驱逐 | Kubernetes 事件明确记录节点 ephemeral-storage 不足 |
| 23:55:59 | 替代 Pod 创建 | 调度层已开始恢复,但应用还未启动 |
| 00:00:05 | 应执行整点 Token 刷新 | 调度器未就绪,任务漏执行 |
| 00:00:15 | 首次登录失败 | 与定时点相差 10 秒,共享 Token 已无法使用 |
| 00:00:42 | 替代容器启动 | 启动不会自动补执已错过的 cron 时刻 |
| 00:02:50 | Pod Ready | 任务服务恢复可用,但 Token 仍然缺失 |
| 00:59:50 | 最后一次已识别失败 | 故障持续接近一小时 |
| 01:00:05 | 下一轮刷新成功 | 共享 Token 恢复 |
| 01:01:22 | 首次成功登录 | 证明 Token 刷新与业务恢复具有直接时序相关性 |
8. Troubleshooting:Symptoms → Investigation → Root Cause → Resolution → Verification
8.1 Symptoms(症状)
- 用户进入小程序登录页,持续显示"授权中"。
- 现场最初只确认两个商家,但后端日志覆盖 34 个 AppID。
- 没有持续宕机或手工发布,系统约一小时后自动恢复。
- 页面"没有报错"不等于服务端没有错误;服务端确实记录了 Token 缺失相关失败。
8.2 Investigation(排查过程)
- 将截图时间锁定到 00:20--00:40,从会员服务日志中反查授权失败。
- 扩展到 00:00--01:10,发现失败在 00:00:15 开始,01:00 后消失。
- 统计同类关键词,确认 8 个会员服务 Pod 都出现相同依赖缺失,排除单 Pod 问题。
- 反查 Token 刷新服务日志,发现 00:00:05 没有刷新记录,01:00:05 恢复成功。
- 查询 Kubernetes 事件和 Pod 生命周期,找到 23:55:59 的
Evicted事件,原因为节点ephemeral-storage不足。 - 对齐替代 Pod 容器启动时间 00:00:42 和 Ready 时间 00:02:50,确认它确实错过了 00:00:05。
8.3 Root Cause(根因)
**触发因素:**Kubernetes 节点出现临时存储压力,kubelet 为保护节点将任务 Pod 驱逐。
**直接原因:**任务 Pod 在 00:00:05 不可用,令牌刷新任务未执行。
**系统根因:**令牌保活被设计成单次整点 cron,缺少 misfire 处理、启动补偿、有效期巡检与错过任务告警。因此一次短暂的 Pod 不可用被放大为近一小时的多租户登录故障。
8.4 Resolution(现场处置)
- Kubernetes 自动创建替代 Pod,服务于 00:02:50 Ready。
- 01:00:05 的下一轮刷新成功,共享 Token 恢复,登录链路自愈。
- 事后对 4 台工作节点执行
crictl rmi --prune,仅尝试删除未被任何容器引用的镜像。 - 四台节点命令均成功,但镜像数分别保持 27、25、31、28,说明当时没有可安全回收的闲置镜像。
- 本次调试创建的临时 Pod 全部删除,未重启业务 Pod。
为什么"清理镜像"不是最终修复
回收后根盘使用率仍约为 76%--85%,inode 使用率仅 7%--9%。无用镜像数为 0,因此后续应调查容器日志、容器可写层、containerd 内容库、kubelet 目录和 systemd journal,而不是继续盲目删镜像。
8.5 Verification(验证)
- 4 台工作节点均为
Ready=True、DiskPressure=False。 - 53 个当前运行中业务 Pod 全部 Ready。
- 01:00:05 刷新成功后,01:01:22 观测到成功登录,故障时序闭环。
- 集群中仍有 6,102 个历史 Evicted Pod 对象,其中 873 个与
ephemeral-storage相关。这些终止对象不等于当前业务不健康,但暴露出长期管理问题。
9. 5 Whys 根因追踪
| 层次 | 为什么 | 答案 |
|---|---|---|
| Why 1 | 为什么小程序登录一直授权中? | 会员服务无法获得可用的平台公共 Token。 |
| Why 2 | 为什么 Token 不可用? | 00:00:05 的刷新任务未执行,缓存中没有可继续使用的值。 |
| Why 3 | 为什么定时任务未执行? | 承载任务的 Pod 在整点前因临时存储压力被驱逐,替代容器到 00:00:42 才启动。 |
| Why 4 | 为什么 Pod 恢复后没有立即自愈? | 调度机制只依赖整点 cron,没有启动补刷、misfire 恢复或按 Token 剩余有效期巡检。 |
| Why 5 | 为什么故障持续近一小时才被感知? | 没有对刷新任务缺席、Token TTL 过低、授权失败率和前端长时间 loading 建立联动告警。 |
10. 根因分类
- **根本原因:**令牌维护是时间点触发型任务,而不是面向最终状态的保活机制。
- 技术触发器: 节点
ephemeral-storage压力导致任务 Pod 驱逐。 - **放大因素:**共享 Token 为多商家共同依赖,无备用执行器、无启动补偿、无短周期巡检。
- **发现漏洞:**没有任务心跳、Token 剩余有效期和登录失败率告警。
- **用户体验漏洞:**前端未在合理超时后结束 loading 并给出重试或友好错误。这一点来自用户现象,仍需结合前端代码二次验证。
11. 解决方案与方案取舍
| 方案 | 优点 | 问题 | 结论 |
|---|---|---|---|
| 仅将 cron 提前 | 改动小 | 只是改变故障出现的时刻,未消除漏执行 | 不单独采用 |
| 增加任务服务副本 | 降低单 Pod 故障风险 | 需要分布式锁或调度框架防止重复刷新 | 作为辅助 |
| 每分钟检查 Token 剩余时间 | 面向目标状态,错过某次仍可自愈 | 需要锁、重试与限流 | 推荐 |
| 登录请求发现 Token 缺失时同步刷新 | 能处理极端情况 | 容易引发惊群和延迟放大 | 仅作受控兜底 |
| 扩容节点磁盘 | 降低短期驱逐风险 | 不解决日志无界增长和任务自愈设计问题 | 与治理并行 |
12. 关键实现建议
12.1 从"整点任务"改为"Token 保活"
@Scheduled(fixedDelay = 60_000)
public void ensureComponentTokenFresh() {
if (!distributedLock.tryLock("token-refresh", Duration.ofSeconds(50))) {
return;
}
try {
TokenSnapshot current = tokenStore.get();
if (current != null && current.remainingTime().compareTo(Duration.ofMinutes(20)) > 0) {
return;
}
retryTemplate.execute(ctx -> {
PlatformToken fresh = platformClient.fetchToken();
tokenStore.replaceAtomically(fresh.value(), fresh.expiresAt());
refreshAudit.success(fresh.expiresAt());
return null;
});
} finally {
distributedLock.unlock("token-refresh");
}
}
这种设计的关键不是"把任务跑得更频繁",而是把成功条件从"某一次 cron 执行过"改成"Token 的剩余有效期始终高于安全阈值"。
12.2 启动补偿与原子替换
- 应用完成启动后立即检查 Token,无值或剩余时间不足时补刷。
- 先拿到新 Token,再使用 Redis 原子命令替换旧值;不要先删旧值再请求外部平台。
- 多副本同时巡检时使用分布式锁,并设置锁超时,防止死锁与重复刷新。
- 日志仅记录刷新结果、过期时间和 Trace ID,禁止记录 Token 明文。
12.3 前端错误收敛
- 设置合理的登录超时,超时后结束 loading,避免用户无限等待。
- 后端对依赖缺失返回稳定的业务错误码,前端显示"服务暂时不可用,请稍后重试"。
- 重试按钮使用退避与防连点,避免在共享依赖异常时放大流量。
13. 验证过程与必测场景
| 验证场景 | 验收标准 |
|---|---|
| 在刷新时刻前主动删除任务 Pod | 新 Pod 启动后自动检查并补刷,不需等待下一个整点 |
| 多副本同时发现 Token 即将过期 | 只有一个副本请求开放平台,其他副本复用结果 |
| 外部平台连续失败三次 | 按退避策略重试,保留尚可用旧 Token,触发告警但不打印敏感值 |
| Redis 中 Token 被人工删除 | 巡检任务在 1--2 分钟内恢复,并记录刷新审计 |
| 后端返回依赖不可用 | 前端在规定时间内结束"授权中",显示可理解提示并允许受控重试 |
| 节点达到磁盘阈值 | 在 kubelet 驱逐业务 Pod 之前发出告警,并能定位到日志、可写层或镜像占用 |
14. Lessons Learned
- 定时任务的运行日志不能证明最终状态正确,关键依赖要做状态巡检。
- 一个共享 Token 可以同时影响多个租户,必须按平台级核心依赖对待。
- "Pod Ready"只能证明进程能服务,不能证明已恢复错过的业务任务。
- "没有页面报错"不是健康指标;无限 loading 本身就是错误处理缺失。
- 清理镜像前必须用容器运行时判断引用关系;不能只根据镜像列表猜测是否可删。
- 大量 Evicted Pod 对象会淹没有效信息,应设置历史对象保留与清理策略。
15. 后续优化计划
| 优先级 | 工作项 | 目标 | 验收方式 |
|---|---|---|---|
| P0 | 增加启动补刷和每分钟 TTL 巡检 | 错过单次 cron 也能快速自愈 | 整点前删 Pod 演练 |
| P0 | 对 Token 不存在、TTL 过低、刷新连续失败告警 | 在用户报障前发现 | 删除测试键并检查告警 |
| P0 | 前端授权超时与错误提示 | 避免无限 loading | 注入后端 5xx/超时 |
| P1 | 对节点磁盘使用率、available bytes 与 DiskPressure 告警 | 驱逐前介入 | 磁盘阈值演练 |
| P1 | 盘点日志、可写层、containerd 与 kubelet 占用 | 找到 76%--85% 磁盘占用的真实主体 | du 分层统计 + 日志保留策略 |
| P1 | 清理历史 Evicted/Succeeded Pod 对象 | 降低 API 对象噪声,提升排查效率 | 确认当前业务 Pod 不受影响 |
| P2 | 建立刷新审计和授权请求链路追踪 | 可按计划时刻、AppID 和 Trace ID 复盘 | 任意失败可在分钟级闭环 |
16. 总结
这次故障由节点临时存储压力触发,却不能简化成"磁盘满了导致 Pod 重启"。真正让一次几分钟的 Pod 替换演变为近一小时授权故障的,是业务层对"错过某一次调度"没有自愈能力。
长期修复不应只停留在扩容磁盘、重启 Pod 或调整 cron,而要把 Token 刷新改造成面向最终状态的保活机制:有启动补偿、短周期巡检、分布式锁、原子替换、失败重试、有效期告警,并在前端给出明确的超时和重试语义。
最终原则
调度系统的可靠性不能由"cron 配置正确"证明,而要由"任意 Pod 在任意时刻重建后,关键业务状态仍能在可接受时间内自动收敛"来证明。