你的 Pod 根本没有在排空:我实测过了

drainwatch 用 25 次实测拆开 Kubernetes 的优雅关闭,立即退出的 Pod 在 45 毫秒内被 RST 切断 TCP,Service 却还能对外通告已经死掉的进程 221 毫秒。

中文
复制
题图:终端窗口的标题栏写着 drainwatch · kind v1.34.0 · 25 trials,界面里 kubectl delete pod probe-0 之后打出 YOUR PODS ARE NOT DRAINING 与红色大号 221 ms

关于 Kubernetes 优雅关闭的指南讲的都是同一个故事。Pod 收到 SIGTERM。端点被移出轮转。流量停止到达。处理程序完成在途工作后退出。这个故事很合理。我想知道它是不是真的。不是看起来合理,而是有实测、有时间戳。

于是我做了 drainwatch。这是一个小型 Go 工具,它对一个 pod 保持长连接 TCP 和 UDP 连接,用不同方式终止该 pod,并记录每条连接实际是被什么、在什么时候结束的。证据来自 Kubernetes watch 事件以及数据包本身。

我跑了 25 次试验。五种终止场景,每种重复五次,一个固定不变的二进制文件,每个场景一个全新的 kind 集群。Kubernetes v1.34.0,kube-proxy 使用 iptables 模式。下面每一条结论都能追溯到仓库中提交的 report.json

发现 1:收到 SIGTERM 后怎么做,决定 TCP 的命运

同一个集群。同样的触发方式:删除 pod。同样 30 秒的宽限期。唯一的变量是应用收到 SIGTERM 后做什么。

SIGTERM 行为TCP 结果(五次重复全部一致)最后一条 TCP 连接结束于容器退出码
立即退出10/10 被 RST 切断45 ms(30–98)0
排空后退出10/10 干净关闭25.04 s(25.03–25.11)0
忽略 SIGTERM10/10 在传输中途被切断30.06 s(30.05–30.06)137

数值为五次重复的中位数,括号内为范围。

立即退出的应用会在 45 毫秒内杀掉它持有的所有连接。排空的应用让全部十条连接存活到 25 秒窗口结束,然后干净地关闭它们。忽略 SIGTERM 的应用则一直保持连接,直到 kubelet 介入。SIGKILL 在 30 秒宽限边界之后 62 毫秒内到达。五次重复的退出码都是 137。一个永不退出的应用无法决定自己的连接何时死掉。决定权在 kubelet 手里。

这些都不与文档矛盾。接下来两条发现讲的才是文档没说的部分。

发现 2:你的 Service 对外通告的是已经死掉的 pod

在「立即退出」场景里,我测了两个事件之间的间隔:最后一条 TCP 连接被切断的时刻,以及 endpoint 从 EndpointSlice 中被移除的时刻。

指标(n=5)中位数最差一次
最后一条 TCP 连接死亡于45 ms98 ms
Endpoint 被移除于293 ms591 ms
Service 对外通告一个已死进程的窗口221 ms521 ms

中位数 221 毫秒,最差的一次超过半秒,EndpointSlice 里始终挂着一个 endpoint,而它的进程早已把它持有的每一条连接都重置了。任何按这份通告路由过去的请求都无处可落。

这正是连接排空(connection draining)那套建议想要覆盖的竞态。现在它有数字了。两个时间戳取自同一个时钟,所以这个窗口是精确的。窗口在每一次重复中单独计算,再取各次重复的中位数,因此它并不等于上表中两个中位数之差。

「立即退出」场景中的死 endpoint 窗口

「立即退出」场景中的死 endpoint 窗口

同一份数据还有一个结果。通常的假设是 endpoint 移除会发生在 SIGTERM 之前,并留出一段可用的余量。在这个集群上,两个事件同时发生,差距在测量精度之内。每一次试验、每一个场景都是如此。

发现 3:UDP 没有排空可言

这个发现让我意外,而我自己就是做实时媒体系统的。

排空场景跑的是教科书式的优雅关闭:停止接受新连接,继续服务已建立的连接,做完就退出。TCP 拿到了它的 25 秒。UDP 流则被保持在同一个 pod、经由同一个 Service 上——然后在 delete 之后 2.4 秒就没了。不是死掉,而是由另一个 pod 来应答了。

删除一个由 Deployment 管理的 pod 会让 ReplicaSet 创建替代者。UDP 是无连接的。替代者一就绪,kube-proxy 就把数据报导了过去。原来的 pod 还在运行,还健康,还在服务 TCP,排空窗口里中位数还剩 22.9 秒。应用执行了一次完美的排空,而对 UDP 来说,这什么也没换来。

我知道流量发生了迁移,因为探针会在每个回复上标记自己的 pod 名称。这个机制的原理,下面再说。

删除一个 pod,两种命运。TCP 流走完完整的 25 秒排水窗口,干净关闭。UDP 流在 2.4 秒时被悄悄改接到替换 pod 上——而原 pod 仍然健康,仍在排水,窗口剩余时间中位数为 22.9 秒。

删除一个 pod,两种命运。TCP 流走完完整的 25 秒排水窗口,干净关闭。UDP 流在 2.4 秒时被悄悄改接到替换 pod 上——而原 pod 仍然健康,仍在排水,窗口剩余时间中位数为 22.9 秒。

为了隔离原因,我加了一组对照:把 Deployment 缩容到零,而不是删除 pod。这样不存在替换 pod,改接就不可能发生。结果确实没有发生。所有 UDP 流都以 ICMP port unreachable 告终,时间中位数为容器退出后 177 毫秒。这是 kube-proxy 在拒绝发往一个没有 endpoint 的 Service 的流量。驱逐(eviction)的行为和删除一样,同样会改接,两者中位数相差 3 毫秒。

所以 pod 终止期间的 UDP 流只有两种命运:在原 pod 仍在排水时被悄悄迁移到另一个后端,或者直接被拒绝。触发哪一种,取决于是否存在替换 pod,与 shutdown handler 无关。如果你跑的是 WebRTC 媒体、游戏服务器、DNS,或者任何以 UDP 为产品的服务,你的优雅关闭排空的是控制面,抛弃的是数据面。

关于机制补充一句:改接这个行为是我从时间戳推断出来的。drainwatch 记录流的命运和集群事件,不检查 kube-proxy 规则或 conntrack。

差点埋掉最重要发现的 bug。

第一次集群运行时,drainwatch 把改接的 UDP 流报告为“在观察窗口内存活”。客户端一直在收到 ack,无从得知它们来自另一个 pod。报告说这些流什么都没发生。事实恰恰相反。

这就是探针现在每次心跳和 ack 都要表明身份的原因。由新实例响应的流会被判定为已切断,记录里同时保留新旧 pod 名称。这也是我信任其余这些数字的原因。测试框架遇到任何无法验证的前置条件都会中止,拒绝在残缺的运行结果上取平均。凡是它主动造成的结果,它都会写明:exit-immediately 场景通过 SO_LINGER=0 强制发出 RST,并记录下这一动作,而不是让内核缓冲决定结果。一个抓不住自身错误的测量工具,算不上测量工具。

注意事项

这是实验环境,不是生产环境。节点是同一台机器上的容器,所以绝对延迟不可迁移。结论在于结果的结构:什么终结了哪些流,以及顺序如何。kube-proxy 运行在 iptables 模式下,IPVS 或 eBPF 数据平面可能表现不同。集群事件时间戳是 watch 收到的时间,因此只是上界。工作负载按设计只有一个副本,所以每个结果都能归因到单个 pod。

自己跑一遍。

git clone https://github.com/jaynirmal15/drainwatch
cd drainwatch && make demo # kind cluster up, one trial, report on stdout
scripts/repeat5-matrix.sh # the full 25-trial matrix, ~30 minutes

每次试验都会写出完整的 JSON 报告:环境、时间线、每条流的结果,以及时钟说明。本文背后的原始报告已提交到仓库中。

v0.2 针对的是这一版回答不了的两个问题:接入你自己的负载而不是内置探针,以及测量云负载均衡器的注销时序——在那里,发现 2 中的死端点窗口应该会更大。

关闭处理不是走过场。对 TCP 来说,它值三个数量级。对 UDP 来说,它一文不值——这是另一个问题,现在是一个已被测量的问题。


Jay Nirmal 是一名高级软件工程师,从事实时通信基础设施方面的工作。drainwatch 采用 MIT 许可证。欢迎提交 issue,也欢迎来自其他数据平面的相反结果。

来源: HackerNoon← 返回首页