1064 字
5 分钟
日志里的「已取消」是一句局部真话

一行日志写着「已取消」,我拿它当证据,判断下游停了。结果下游在继续跑。日志没撒谎,它描述的是写它那个函数自己的状态。我的判断是:日志的作用域只到写它的那一层,跨层的事实要去调用链里确认。

一行日志的作用域边界

一行日志能证明什么#

删掉一个 Pod,kubelet 日志里会出现:

Cancelling current pod sync

读到这句,自然的理解是「这个 Pod 相关的事都停了」。我当时就是这么理解的,并且据此排除了一整条排查方向。

这句话本身是对的。kubelet 给每个 Pod 维护一个独立的调谐循环,内部叫 pod worker。Pod 被删除时,这个循环持有的 context 确实被取消了,日志由此而来。

问题在于它的作用域。写这行日志的函数知道自己的 context 没了,但它不知道——也没必要知道——此前下发出去的那些调用现在是什么状态。日志是一个函数对自己的陈述,不是对整条链路的陈述。

取消能力在哪一跳被摘掉#

kubelet 向容器运行时下发操作走的是 SyncPod。传进去的不是 pod worker 自己那个 context,而是:

ctx = context.WithoutCancel(ctx)

这是 Go 1.21 加进标准库的函数。它返回一个新 context,继承原 context 里的值(trace ID、超时设定之外的附加数据都还在),但切断取消的传导:父 context 取消时,这个新 context 不取消。

于是 CRI 的 PullImage 拿到的是一个永远不会进入取消状态的 context。containerd 那侧的下载没有收到任何信号,继续跑。

这条路径是在 Kubernetes 1.34.1 的 kubelet 源码里确认的。

这个设计是对的#

发现它之后我的第一反应是「这是个 bug」。不是。

镜像拉取在节点上是共享的。同一个镜像可能同时被好几个 Pod 等着,它们落在同一台机器上,kubelet 只会发起一次拉取。这时删掉其中一个 Pod,如果取消信号一路传下去,下载就断了,另外几个还在等的 Pod 跟着遭殃。

WithoutCancel 在这里表达的是:这次拉取不属于任何单个 Pod,不该被单个 Pod 的生命周期左右。

所以这不是疏忽,是一个权衡的结果。只是这个权衡的另一面——调用方放弃时没有任何机制能喊停——对使用这套系统的人不可见。它既不在日志里,也不在任何文档里,只在源码的一行函数调用里。

什么时候该去读源码#

读源码是有成本的,不能每条日志都去追。我自己的触发条件是这三种:

日志描述的事和观察到的现象对不上。 这次就是:日志说取消了,带宽监控说在下载。两个都是事实,那一定有一个的作用域和我以为的不一样。

一行日志要被当作决策依据。 如果只是用来判断「大概走到哪一步了」,不准也无所谓。但如果要据此排除一条排查方向,或者据此决定某个功能不用做,那它的准确范围就得先确认。

行为跨了组件边界。 kubelet 打的日志,描述 containerd 的状态——中间隔着 CRI 这一层 gRPC 调用,任何一层都可能改变语义。同一个进程内的日志可以更信任一些。

还有一个反过来的判据:源码读出来的结论也要验证。源码里可能有我没读到的分支,也可能编译进去的版本和我读的不是一个。所以读完之后我做了对照实验,只换一个变量——谁向 containerd 发起这次拉取。结果和源码推断一致:默认路径下删掉 Pod 之后下载还跑了五分多钟,平台自己发起的那组一两秒就停。

没验到的部分:那条源码结论只在 1.34.1 上确认过,生产集群跑的是不是同一个版本,还没核。