一次 Kubernetes 临时存储压力引发的小程序授权雪崩:从 Pod 驱逐到定时任务漏执行

节点临时存储 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. 核心设计与正常执行流程

  1. 任务服务按 5 0 0/1 * * ? 每小时的第 5 秒执行刷新。
  2. 调用开放平台接口获得新 Token 与有效期。
  3. 将 Token 写入 Redis 共享键,并设置 TTL。
  4. 会员服务根据脱敏 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(排查过程)

  1. 将截图时间锁定到 00:20--00:40,从会员服务日志中反查授权失败。
  2. 扩展到 00:00--01:10,发现失败在 00:00:15 开始,01:00 后消失。
  3. 统计同类关键词,确认 8 个会员服务 Pod 都出现相同依赖缺失,排除单 Pod 问题。
  4. 反查 Token 刷新服务日志,发现 00:00:05 没有刷新记录,01:00:05 恢复成功。
  5. 查询 Kubernetes 事件和 Pod 生命周期,找到 23:55:59 的 Evicted 事件,原因为节点 ephemeral-storage 不足。
  6. 对齐替代 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 在任意时刻重建后,关键业务状态仍能在可接受时间内自动收敛"来证明。

相关推荐
SatanII1 小时前
Kubernetes 网络
网络·容器·kubernetes
闲云野鹤在人间16 小时前
Kubernetes Service 与 kube-proxy 详解
云原生·容器·kubernetes
我不会起名字32216 小时前
节点 NotReady:kubelet 与容器运行时的 4 步排查
云原生·kubernetes·kubelet·排查
说实话起个名字真难啊17 小时前
实战docker部署dify及注意点
运维·docker·容器
imperialeast18 小时前
WinAXP 2.4.0.830正式版
小程序·个人开发
闲云自留地18 小时前
K8s Service 全解:服务发现、四种类型、会话保持、金丝雀发布一篇通
容器·kubernetes·服务发现
choumin19 小时前
C++ 标准容器为什么不能存引用
c++·容器·引用类型
CRMEB系统商城19 小时前
鲜花零售的预售与积分营销
开发语言·人工智能·科技·小程序·零售
镭封21 小时前
AI文字转语音能制作多人对白吗?角色分配操作指南
人工智能·小程序·音视频·语音识别·媒体