把 maxUnavailable: 0 写进 Deployment,通常意味着你已经认定:发布过程中丢请求这件事不可接受。它的字面承诺很硬——Kubernetes 不会在替代 Pod 就绪前拿走任何一个 Pod,所以健康 Pod 的数量始终是满的,理论上没人会拿到错误。 有人想验证这个承诺是否成立。做法是:四个副本挂在一个云负载均衡后面,二十个 keep-alive 客户端持续打流量,然后触发一次滚动更新,同时统计每一次失败。 结论是:承诺不成立。这一点他本来就有心理准备。真正没料到的是——所有人推荐的那个修复方案,几乎没让失败数动一下;而真正解决问题的东西,压根不在 Kubernetes 的配置里。 先跑一个对照组 环境不复杂:一个小的 Python HTTP 服务,四个副本,一个 type: LoadBalancer 的 Service,跑在 DigitalOcean Kubernetes 上。客户端用 HTTP/1.1 加 keep-alive,复用连接——因为真实客户端就是这么干的,而一个每次请求都新建连接的压测工具,会把大部分问题藏起来。 发布策略选的是最谨慎的那档:maxSurge: 1,maxUnavailable: 0。每轮大约在 110 秒内发出 19000 个请求,并在第 25 秒触发一次滚动更新。各轮之间唯一变化的,是应用如何处理自己被关闭这件事。 在动任何配置之前,先跑对照组:同样的负载,同样的时长,不触发发布。 - requests=19380,ok=19380,failed=0 一万九千多个请求,零失败。这意味着后面出现的任何失败,都只能归因于发布本身,而不是背景噪声。这一步他差点跳过,事后看绝不该跳——没有这个基线,下面所有数字都读不出意义。 三次尝试,三次都没解决 第一次,最朴素的应用。收到 SIGTERM 就直接退出,基本等于一个从没考虑过关停的进程的默认行为。 - requests=19302,ok=19251,failed=51 - 其中 33 个 RemoteDisconnected,13 个 ConnectionRefusedError,4 个 ConnectionResetError 51 个失败,重跑一次是 35 个。注意这些失败不是什么:没有一个是 5xx。全部发生在连接层。这也意味着,一个只统计 HTTP 状态码的监控体系,会告诉你这次发布干干净净。 第二次,正经的优雅关闭。服务在收到 SIGTERM 后停止接受新连接,让进行中的请求跑完,几秒后再退出——这就是所有框架宣传"优雅关闭"时所指的那套东西。 - requests=19392,ok=19367,failed=25 - 25 个全部是 RemoteDisconnected 失败数砍掉一半,是实打实的进展,但还剩 25 个。 第三次,所有人推荐的那个修复:加一个 preStop 钩子,让 Pod 在真正开始关闭前先停一会儿,给端点摘除留出时间。 - requests=19308,ok=19288,failed=20 25 变成 20。对于一个被当作标准答案的改动来说,这算不上答案。 失败到底发生在哪一刻 到这里实验才真正变得有用:一个拒绝移动的结果,说明解释错了,而不是修复力度不够。 他安排了一个观察程序,每秒四次记录 Service 的 EndpointSlice 里有哪些 Pod 地址,同时 Pod 自己记录收到 SIGTERM 的时刻。把两条时间线对齐,就能得到"Pod 被通知停止"到"Pod 被移出轮转"之间的间隔。 正值意味着 SIGTERM 已经到了、端点却还活着——这就是大家都在说的那个竞态。它确实存在,宽度大约半秒,而 preStop 把它完全关上了。端点现在会在进程收到信号之前就被移出轮转。 所以 preStop 完美地完成了它的工作,可还是有二十个请求失败了。这二十个不可能是新流量打到已经死掉的 Pod 上,因为根本没有新流量被发往那里。 把剩下的失败和 SIGTERM 的时间戳画在一起,答案立刻就出来了。 每一个失败都落在某次 SIGTERM 之后 3.0 秒。三秒正好是他的优雅处理器在调用 os._exit 之前等待的时长。失败不是发生在 Pod 被通知停止的时候,而是发生在 Pod 真正停止的时候。 负载均衡器握着到那个 Pod 的、已经建立的 keep-alive 连接。拒绝新连接对这些连接毫无作用,preStop 的等待同样毫无作用。它们待在一个连接池里,在池子看来完全健康,直到进程退出,把它们从使用中切断。 真正解决问题的是什么 一旦把问题这样描述出来,修复方案就显而易见了,而且它根本不是 Kubernetes 的设置。应用必须自己从连接池里走出去,而不是等着被移出去。在 HTTP/1.1 上,这意味着告诉客户端不要再复用这条连接: if draining.is_set(): self.send_header("Connection", "close") self.close_connection = True 继续正常服务,但在每次响应之后关闭连接。连接池会在接下来的几秒里自己排空。然后再退出,同时把 terminationGracePeriodSeconds 设得足够高,别让别的东西先把你杀掉。 - requests=25884,ok=25884,failed=0 - requests=25871,ok=25871,failed=0 零,两次,超过五万个请求。完整的进展,同一个集群,同样的负载,同样的发布。 他搞错了什么 两件事,第一件差点让这篇文章写不出来。 他做这个实验时,预期 preStop 就是结局。计划中的文章是那个熟悉的套路:端点摘除竞态,加上 sleep 之后失败数令人满意地降到零。当第三次尝试回来是二十而不是零时,他花了很长时间假设五秒不够长、应该试试十五秒——那会是一条缓慢却什么也学不到的弯路。而时间数据其实早就躺在一个他没打开过的文件里。 第二件是他差点没跑对照组。感觉是多余的,因为显然稳定负载打在稳定部署上不会失败。如果基线回来是二十个失败,那后面每一个数字都会被误读成发布造成的,而实际上它们只是背景噪声。

打开网易新闻 查看精彩图片