1763 字
9 分钟
证明「不是 Pod 的问题」,靠的是时间线对得上

生产上一个评测服务一天内重启三次,用户的任务跑一半被中断,想重试又被接口拒掉。我只有日志查询权限,没有集群权限。第一次汇报我给的理由是「日志里没有 OOM 记录,所以不是内存问题」,当场被否掉。我的判断是:环境问题和代码问题落到现象上是同一句「容器重启了」,把它们分开靠的是时间线对不对得上。

内存超限、节点驱逐、存活探针杀进程三种解释各自该留下的痕迹,以及探针那条的重启时刻式子:阻塞起点 + 失败阈值 × 探测周期 + 关停宽限期

同一句「容器重启了」,底下挂着三种解释#

日志平台上的重启只是一行记录。能产生这一行的至少有三条路:

  • 内存超限,内核直接杀进程;
  • 节点抖了一下或者被驱逐,kubelet 把容器重新拉起来;
  • 代码把进程卡住,存活探针连续探不到,kubelet 判它已经死了。

在「容器重启了」这个层面,三条完全一样。想把责任从基础设施切回代码,得拿出能把前两条删掉的东西。

我最早拿的是「日志里没有 OOM 记录」。这条不成立:内存超限是内核发 SIGKILL,进程来不及写任何东西,查不到 OOM 日志对三条路一条都删不掉。否定性证据按强度分档我单独写过一篇,grep 不到只能证明你搜过的文件里没有,最弱的那档就是这种形式。

先写出每种解释该留下什么痕迹,再去日志里找#

内存超限的痕迹是进程瞬间消失,没有任何关停日志,正在处理的请求连接被内核直接关掉,客户端拿到 RST 或者超时,不会拿到正常响应。

代码把事件循环卡住完全是另一回事:进程一直活着,监听端口没关,请求照样被 accept 进来,只是排在事件循环的队列里没人取。阻塞一结束,积压的请求会在一瞬间全部被处理完。

日志里查到的是后一种。打分那一百多秒里,健康检查一次都没响应;打分结束那一刻,积压的健康检查在几毫秒内全部返回 200。进程从头到尾没死过,它只是没空。

根因是打分函数签名写了 async,里面却直接调了一个同步的大模型客户端。这一行自己不报错,只表现成「卡住了」,属于静默失败那一类。机制和后续怎么处理我单写了一篇。

重启时刻能算出来,而且三次都对#

如果是存活探针杀的,重启时刻不随机——它等于阻塞开始的时刻,加上失败阈值乘以探测周期,再加上关停宽限期。这三个数都写在 Deployment 里,我有现成的。

三次重启的实际时刻,和这个式子算出来的误差都在 1 秒以内。

式子能用的前提是阻塞起点有个准确时间,我用的是打分请求进来那条业务日志。排这类问题时业务日志比重启记录值钱,重启记录只给你结果,起点只在业务日志里;起点找不着,这条式子就算不了。

方向不能反:式子是拿探针配置正着算出一个预测时刻,再去比对实际时刻。反过来先看到重启时刻、再挑参数去凑,永远能凑上,也就什么都证明不了。三次用同一个式子、同一组参数都对上,凑不出来。内存超限也产生不了这种固定间隔,OOM 是水位一到就杀,和探针阈值没关系。

关停流程是另一条独立的线。三次重启里有两次,重启前打出了完整的优雅关停日志。存活探针失败走的是 SIGTERM,先跑关停 handler,到宽限期才补 SIGKILL;内存超限直接 SIGKILL,一行 handler 都跑不到。第三次我没查到关停日志,可能是没打,也可能是我的查询时间窗没覆盖到。报告里写的是两次,不是每次。

关停 handler 里有一段会把进行中的任务状态改成 cancelled,而发起评测的接口只接受三种状态:awaiting_evaluation、failed、completed。任务被写成 cancelled 之后用户点重试就被拒,这正是报障单上那句「重试还被拒绝」。这个把用户锁死的 bug 本身是证据——只有关停 handler 真的跑过,状态才会变成 cancelled,用户点不动反推出进程收到的是 SIGTERM。

对照组删掉最后一种解释,但有一条我没能坐实#

到这里还剩一个可能:探针配得太紧,一百秒的阻塞被判死是配置的问题,谁跑在这个配置上都会被杀,和代码无关。

这条用对照组删掉。同一个 Deployment 下三个副本,探针参数、资源限额、镜像完全一样。当天另外两个副本零重启,只有接到评测任务那一台重启了三次。变量只剩一个——那台在跑打分。

对照组的强度全靠一个前提:它和出事那台确实只差一个变量。同 Deployment 的副本满足这个前提;同节点上别的 Pod 不满足,它们镜像不同、探针不同,拿来当对照什么也说明不了。前提不成立的时候,对照组是装饰。

它删掉的只是「配置本身定得太紧」,没删掉「出事那台所在的节点有问题」——节点级的抖动只影响落在那个节点上的 Pod,另外两个副本没事证明不了它那台的节点没事。节点那条还是靠时间线删掉的:驱逐和节点压力不会让重启时刻正好落在探针式子算出的那一秒上,三次都落上更不会。

「被存活探针杀掉」这条到交付时仍然是算出来的。我手上有三样东西:阻塞时长够、重启时刻对得上式子、有关停日志。三样互相不依赖,但没有任何一行记录直接写着原因。

坐实它只要一条命令:kubectl describe pod 看 Last State 里的 Reason 和 Exit Code,或者 kubectl get events 里 kubelet 发的 Unhealthy 和 Killing。我没有集群权限,做不了。报告里我把它单列成「未证实」,写明需要谁执行哪条命令来确认。四条证据摆在一起很容易让人以为每条都是实证的,实际是三条实证、一条推算:阻塞痕迹、关停日志、对照组是查到的,重启时刻那条是算出来的。