Google Cloud Blog

When the Kubernetes API went silent: A three-layer networking mystery

6.9内容质量
When the Kubernetes API went silent: A three-layer networking mystery

TL;DR · AI 摘要

The three-layer GKE network mystery Google Cloud Blog Networking When the Kubernetes API went silent: A three-layer netw...

核心要点

  • 主题聚焦:When the Kubernetes API went silent: A three-lay
  • 来源:Google Cloud Blog,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#后端#云计算#安全
打开原文

三层 GKE 网络谜题 | Google Cloud 博客

网络

当 Kubernetes API 静默时:一个三层网络谜题

2026 年 10 月 31 日

##### Akash Thakur

高级技术解决方案工程师

##### 今天试用 Gemini

职场 AI 的入口

立即试用

每个支持工程师都有一个难以忘怀的案例。这个案例会在团队午餐时被提起。即使问题解决后,你仍可能在午夜盯着抓包数据,只为确认自己是否真正理解了发生了什么。这就是那个案例。

这个案例始于 2026 年 1 月,当时收到一个看似简单的支持工单:Pod 间歇性无法连接到 Kubernetes API 服务器。错误信息是一个熟悉的超时错误。客户已经排除了自身操作的问题。当时我们都不知道,我们面对的是三个不同层级的堆栈中三个独立的 bug,它们同时作用产生了相同的症状。

当问题最终解决时,大约 800 名开发人员已连续数周无法部署到生产环境,欧洲西部地区的 GKE 集群也完全停滞。在修复问题的竞赛中,我们召集了 Google Kubernetes Engine(GKE)、Private Service Connect、网络、hostnet、内核以及 Linux 上游社区的工程师,他们共同追踪到了三个不同的根本原因。最终的修复方案不是配置更改或功能标志,而是对 Linux 内核本身的补丁。

这就是我们如何到达这里的故事。

第一部分:无法停止的警报

客户在 Google Cloud 的欧洲西部地区运行着一个大型的多集群 GKE 环境。他们的集群使用了 Private Service Connect——Google Cloud 用于私有暴露 GKE 控制平面的机制——并且拥有丰富的系统级工作负载生态系统:Twistlock 安全扫描器、用于密钥管理的 External Secrets Operator、用于网络策略的 Cilium,以及多个内部 Kubernetes 操作符。

第一个错误出现在欧洲西部地区的一个用户验收测试(UAT)集群中,几天后在同一地区的第二个集群中也出现了类似错误。错误信息如下:

应用日志——跨多个命名空间的 Pod

加载中...

TimeoutError: Connect call failed ('1.2.3.4', 443) dial tcp 1.2.3.4:443: i/o timeout # 1.2.3.4 是集群内部 kube-apiserver 的虚拟 IP,# 通过 Private Service Connect 路由

最初错误是间歇性的——日志中出现峰值,而非严重中断。客户最初怀疑是自身代码的问题。他们的团队最近部署了一个使用 kopf 框架构建的自定义操作符,这也是他们首先检查的对象。但日志显示错误在操作符部署前八天就已经出现。当他们完全关闭该操作符后,超时错误仍然持续。

#### 即使将 External Secrets Operator 完全缩放至零,问题依然存在。错误来自客户工作负载,以及我们未触碰的系统级 Pod。

#### 越来越多的嫌疑人

当遇到此类案例时,第一反应通常是询问:什么发生了变化?客户的事件时间线为我们提供了多个候选对象,我们逐一进行了排查。

  • 自定义操作符(kopf):立即排除——错误在部署前一周就已出现,关闭后错误依然持续。
  • External Secrets Operator: 应客户要求已完全移除。依赖 Kubernetes 控制平面(KCP)连接的客户工作负载以及集群内部系统 Pod 中的超时问题持续存在。
  • 安全代理:已卸载。结果相同。此时我们观察到系统级 Kubernetes 组件无法连接到自己的控制平面。
  • conntrack 表耗尽:一种理论认为控制平面节点上的操作系统连接跟踪表被过多的半开连接填满。我们进行了检查。conntrack 表容量约为默认最大值 1.3M 条目的 50%——远未达到耗尽状态。
  • 控制平面过载:我们扩大了 KCP 虚拟机的规模。kube-apiserver 的延迟表现正常。但超时问题方面没有改善。

使这个案例真正奇怪的是问题的范围。我们确认超时问题出现在五个不同的集群中,影响 DaemonSets、Deployments 和系统 Pod。外部客户端(开发人员笔记本电脑、GitHub Actions runner)也遇到了同样的问题。这不是某个工作负载的故障,而是所有组件与 API 服务器之间的路径存在异常。

#### 到目前为止的时间线

  • 第1天:首次出现 I/O 超时错误(欧洲西部3区的一个 UAT 集群。间歇性出现,容易被忽视。)
  • 第10天:错误变为持续性(频率显著增加。问题不再可以忽视。)
  • 第15天:问题升级——提交 P0 级别错误(确认多个集群受影响。约 800 名开发人员受阻。生产发布被冻结。正式升级流程开始。)
  • 第20天:所有明显嫌疑对象已被排除(自定义操作符、安全代理、conntrack 耗尽、控制平面扩容——所有可能性都被排除。)
  • 第22天:收集数据包捕获(在控制平面节点 NIC 和 kube-api 容器内部同时进行捕获。调查进入最关键阶段。)

在排除所有明显嫌疑对象后,且问题范围显然广泛的情况下,我们采取了应用程序层无话可说时的常规做法:深入数据包层面进行分析。

第二部分:追踪线缆中的幽灵

要理解数据包捕获揭示的内容,首先需要了解 GKE Pod 在发起 API 调用时所经过的架构。在基于 Private Service Connect 的 GKE 集群中,看似本地连接的路径实际上却异常复杂。

(格式说明:此处建议插入 Pod → kube-apiserver 流程图:Pod -> 工作节点 -> PSC NAT+代理 -> ILB -> KCP 虚拟机 NIC -> kube-apiserver)

此处的关键组件是 Private Service Connect。当 Pod 连接到 1.2.3.4:443 时,实际上并未直接连接到控制平面;该 IP 是一个虚拟端点。连接被 Google 的网络架构拦截,通过 Private Service Connect 端点进行 NAT,经过内部直通网络负载均衡器,最终送达 KCP 虚拟机,Envoy 在实际 kube-apiserver 进程前充当本地代理。

Private Service Connect 为每个新连接添加代理协议头,使 kube-apiserver 能够看到真实的客户端 IP。这一点在后续分析中将变得至关重要。

#### 数据包揭示的内容

当我们同时在 KCP 虚拟机的 NIC(PIGv 接口,Google 的可编程虚拟 NIC)和 kube-apiserver 容器内部进行捕获时,发现了一些令人震惊的现象。

Pod 发出的 SYN 数据包到达了 KCP VM 的网卡,它们已经到达了这一步。但与预期的 TCP 握手第二步(SYN-ACK)不同,客户端收到的不是 SYN-ACK,而是一个位置错误的 ACK。这不是 RST,也不是静默。这是一个逻辑上属于不同、更早连接的 ACK。

网络协议分析器 Wireshark 将这些流标记为一个看似无害的标签:"TCP 端口号被重复使用"。

Wireshark — 抓包显示失败的握手过程

时间 源端口 → 目的端口 信息 09:13:41 10.0.13.77 53426 → 10.117.0.2 443 [SYN] Seq=0 Win=42600 09:13:41 10.117.0.2 443 → 10.0.13.77 53426 [SYN, ACK] Seq=0 Ack=1 Win=64768 ...(数据交换,连接完成) 09:13:42 10.117.0.2 443 → 10.0.13.77 53426 [TCP 重传] 09:13:42 10.117.0.2 443 → 10.0.13.77 53426 [TCP 重传] ...(同一 ACK 的多次重传)

2 分钟后,端口 53426 被用于新连接

09:13:44 10.0.13.77 53426 → 10.117.0.2 443 [SYN] Seq=1498 ← 新连接(预期:SYN-ACK)(实际收到:ACK Seq=1498 Ack=1025 — 属于之前的流) 09:13:44 10.0.13.77 53426 → 10.117.0.2 443 [SYN] 重传... 09:13:44 10.0.13.77 53426 → 10.117.0.2 443 [SYN] 重传... → 最终:I/O 超时

模式已经很清晰。某些原因导致 KCP VM 对新的 SYN 响应了一个属于几分钟前已结束连接的 ACK。旧连接使用了相同的源端口(如上例中的 53426),而 KCP VM 从未完全释放这个端口。

"KCP 服务器发送 FIN,ACK;客户端响应 FIN,ACK 并随后发送 ACK。但 KCP 在收到 FIN,ACK 之前就收到了 ACK。KCP 服务器从未对 FIN,ACK 发送 ACK —— ACK 的持续重传。RST 无法清除状态。"

#### "无效 ACK" 的激增

当一个调查线程正在分析抓包数据(pcap)时,另一支团队正在深入研究 Private Service Connect 基础设施的内部指标 —— 具体来说是用户空间数据包交换机(USPS),这是 Google 用于处理 NAT 和代理协议头注入的软件交换层。

他们在 Google 网络架构 Andromeda 的指标中发现了一个令人担忧的现象。一个名为 "Proxy protocol drop: invalid ACK packet" 的计数器在数月内一直保持平稳 —— 但在 2026 年 2 月,该计数器在受影响的 europe-west 区域突然激增,达到每秒数次丢包。

这个时间点与 USPS 版本的发布相吻合。这个版本包含了一个修复其他错误的更改,但某些方面改变了 Private Service Connect 处理特定数据包类别的行为。

#### 挑战 ACK:RFC 5961 机制

要理解拼图的下一个部分,我们需要讨论一种名为 "挑战 ACK" 的 TCP 安全机制,该机制定义在 RFC 5961 中。

想象一个 TCP 连接已关闭 —— 完成了 FIN/ACK 交互并终止。服务器端(此处为 KCP VM)可能在 TCP 状态机中仍短暂保留该连接的序列号。现在想象在不久之后,一个新的客户端连接使用相同的源端口和目的端口(相同的 5 元组)到达。从内核的角度来看,这可能是旧连接的过时数据包,也可能是攻击者试图向内核认为仍处于打开状态的会话中注入数据。

RFC 5961 规定:当收到与现有连接或最近关闭连接的已记录状态匹配的 SYN 时,不要盲目接受。应发送挑战 ACK —— 一种携带旧连接最后一个已知序列号的确认报文。如果新客户端是合法的,它会看到这个意外的 ACK,识别到连接已过期,并发送 RST 以清除状态。服务器随后会正常接受下一个 SYN。

挑战 ACK 流程 —— 预期的工作方式

步骤 1:新客户端在复用端口上发送 SYN 客户端 → 服务器:SYN(seq=X,与旧连接相同的 5 元组)

步骤 2:服务器处于 FIN_WAIT_2 状态,发送挑战 ACK 服务器 → 客户端:ACK(ack=old_seq ——"真的是你吗?")

步骤 3:客户端识别到过期状态,发送重置 客户端 → 服务器:RST

步骤 4:状态被清除。服务器正常接受下一个 SYN 客户端 → 服务器:SYN(新连接) 服务器 → 客户端:SYN-ACK ← 握手成功

该机制在挑战 ACK 被允许通过时能完美运行。但这也正是新 USPS 变更所阻止的内容。

#### 罪魁祸首:一个善意的修复

USPS 的变更旨在修复 Private Service Connect 的代理协议处理中的一个合法安全问题。其部分效果是在连接握手阶段丢弃被分类为“无效 ACK”的数据包 —— 具体来说,是在初始 SYN-ACK 发送之前到达的 ACK,这在正常情况下会是异常现象。

不幸的是,挑战 ACK 的外观与这些数据包完全一致:它们从服务器在新握手完成之前发送,作为对 SYN 的响应。从 USPS 的新视角来看,这些数据包看起来像是无效的。它们被静默丢弃。

为什么这会特别影响 GKE:Private Service Connect 使用 NAT 将单个公共端点共享给多个客户端 Pod。这意味着许多不同的 Pod(运行在不同节点、不同命名空间中)在 NAT 后可能使用相同的源端口,导致 5 元组碰撞率极高。挑战 ACK 机制被触发的频率远高于正常直接连接场景。USPS 的修复并未引入挑战 ACK —— 它们一直存在。它引入的是丢弃这些 ACK 的行为,这破坏了 GKE 的 TCP 堆栈所依赖的清除过期状态的流程。

#### 缓解措施:回滚变更

2 月,我们回滚了 USPS 的变更,恢复了旧行为,即允许挑战 ACK 通过而非丢弃。"代理协议丢弃:无效 ACK 数据包" 的发生率几乎立即回落至基准水平。

到 2026 年 3 月初,客户确认所有受影响的集群已恢复。不再出现超时错误。

但回滚并非真正的修复,只是权宜之计。它恢复了系统意外依赖的行为。更深层的问题 —— 为什么 KCP 虚拟机在 FIN_WAIT_2 状态下停留了两分钟以上?—— 仍然完全没有答案。

这个问题引出了第三部分。而那里的情况变得真正奇怪。

第三部分:三个漏洞,一个犯罪现场

缓解措施为我们争取了时间,但留下了一个悬而未决的问题:为什么KCP虚拟机的连接会在FIN_WAIT_2状态停留如此之久?一个行为良好的TCP协议栈在收到FIN报文后会进行确认并关闭连接。KCP虚拟机未能完成这一操作,导致半开连接持续超过两分钟——这足够让Private Service Connect的NAT层重新使用端口,也足够引发我们发现的挑战ACK场景。

要解答这个问题,我们需要理解的不仅是单个错误,而是三个不同的故障模式在最糟糕的时刻同时发生。

#### 错误#1:PIGv处的报文重排序

第一个发现是通过仔细分析两个关键点的同步抓包数据得出的:KCP虚拟机的主机网卡(位于运行虚拟机的物理主机内部)与PIGv(虚拟机视角下的虚拟网卡)之间的差异。

在主机网卡层面,报文按完美顺序到达。但当它们经过USPS处理后出现在PIGv时,却发生了微妙的重排序。

这源于Private Service Connect的miss-thread处理机制。当Private Service Connect拦截到一个未见过的新流(称为"miss")时,它会通过一个较慢的路径处理该报文,添加代理协议头并建立NAT状态。同一流后续的报文会走快速路径。结果是:在连接关闭过程中,属于某个流的FIN或RST报文可能相对于后续的ACK报文略微出现顺序错乱。

KCP虚拟机实际接收到的(重排序后的序列)

应该发生的情况:客户端→服务器:FIN, ACK(客户端发起关闭) 服务器→客户端:ACK(服务器确认客户端FIN) 服务器→客户端:FIN, ACK(服务器发起自身关闭) 客户端→服务器:ACK(客户端确认服务器FIN)→连接完全关闭。双方释放资源。 # KCP虚拟机实际接收到的(被PIGv/USPS重排序后): 客户端→服务器:ACK(首先到达——顺序错误) 客户端→服务器:FIN, ACK(第二到达,但内核先看到ACK) 服务器→客户端:FIN, ACK(服务器发送关闭,进入FIN_WAIT_1) 客户端→服务器:FIN, ACK(客户端旧的、现在被重排序的FIN)→服务器应该确认这个报文。但它没有...

重排序导致客户端的FIN出现在服务器已发送自身FIN之后。服务器进入FIN_WAIT_1状态,在看到客户端对服务器FIN的ACK后进入FIN_WAIT_2状态。但随后客户端自身的FIN到达时,服务器内核却忽略了这个报文。

#### 错误#2:Envoy的半关闭与冻结的定时器

为什么操作系统无法自行清理?Linux内核有一个名为tcp_fin_timeout的参数专门用于处理这个问题:FIN_WAIT_2状态的套接字在强制关闭前需要等待多长时间。在KCP虚拟机上,这个参数被设置为60秒。

但tcp_fin_timeout仅适用于"孤儿"套接字——即当前没有进程持有打开的套接字。位于KCP虚拟机前端的kube-apiserver代理Envoy正在执行半关闭操作:它调用了shutdown(SHUT_WR),关闭连接的写入端但保持套接字打开以便仍能接收数据。这是完全符合TCP规范的行为,也是优雅实现半双工终止的预期方式。

副作用:只要Envoy通过SHUT_WR持有套接字,内核就不会将其视为孤儿。tcp_fin_timeout定时器永远不会启动。套接字会无限期停留在FIN_WAIT_2状态,等待一个它永远无法正确处理的客户端FIN报文。

#### Bug #3: Linux 内核的静默失败

但还有一个缺失的环节。即使FIN顺序被调整,一个健康的内核仍应处理FIN_WAIT_2状态下的接收到的FIN,并转移到TIME_WAIT状态。套接字最终应该关闭。为什么没有发生?

这使我们深入研究了Linux TCP状态机的实现。当重新排序的客户端FIN到达KCP虚拟机时,内核的tcp_ack()函数被调用以处理它。在内核版本6.1(这是KCP虚拟机运行的Container-Optimized OS (COS)所基于的版本)中,这里存在一个bug。

RFC 5961增加了对可接受ACK编号的更严格验证——具体来说,即使ACK略微“旧”(在序列号窗口内),也可以视为有效,以处理网络重新排序。但在内核6.1中,存在一个代码路径,当tcp_ack()遇到旧但符合RFC 5961可接受窗口的ACK时,会返回零——表示“该数据包未被完全处理”。FIN处理路径中调用tcp_ack()的代码检查了这个返回值,发现为零后,什么也没做。FIN被接收但被静默丢弃。

修复该问题的Linux内核提交(上游)

commit 795a7dfbc3d9 Author: Eric Dumazet <[email protected]> net: tcp: accept old ack during closing tcp_ack() returns 0 when ack is old but acceptable wrt rfc5961. 在FIN_WAIT_2状态下,这会导致tcp_fin()从未被调用以处理接收到的有效FIN,使连接无限期卡住。 修复:在连接关闭期间正确处理旧但可接受的ACK。 该问题在6.1版本中引入,在6.9.7版本中修复。LTS回溯版本:6.6.y和6.1.y。

该bug自内核6.1版本以来一直存在,已在内核6.9.7版本中修复——但GKE的KCP虚拟机运行的是基于6.1和6.6的COS构建版本。需要将修复回溯并作为COS镜像更新发布。

#### 三个bug如何共同作用

单独来看,这三个问题都不会导致如此严重的故障。只有三者共同作用才会导致失败:

  • 仅Bug #1:数据包重新排序会轻微干扰,但内核通常仍会最终处理FIN并清理,只是速度不如理想。
  • 仅Bug #2:Envoy的半关闭是完全正确的行为。只有在FIN从未被正确处理时才会成为问题。
  • 仅Bug #3:内核未能处理旧但可接受的ACK会导致部分连接滞留,但如果没有Private Service Connect NAT的端口复用压力,影响将有限。
  • 三者共同作用:重新排序导致FIN顺序错乱;内核bug阻止其被处理;Envoy的SHUT_WR阻止超时触发;套接字无限期停留在FIN_WAIT_2状态。Private Service Connect NAT复用端口。发送挑战ACK。USPS修复丢弃该ACK。连接失败。

#### 解决方案:三管齐下的修复

解决这个问题需要在三个不同层面采取行动,并且多个团队在数周内进行了协调:

  • 即时缓解措施(2026年2月):在USPS中回滚更改。恢复了之前的行为,即转发而非丢弃Challenge ACK。客户集群在3月初恢复。
  • 正确性修复:对Private Service Connect代理协议层进行替换性更改,以正确处理Challenge ACK场景,而非简单恢复旧的丢弃行为。向测试套件中添加了PacketDrill测试以防止回归。
  • 根本原因修复 — Linux内核补丁:上游补丁("net: tcp: accept old ack during closing"),由Eric Dumazet编写。已合并到Linux 6.9.7版本,并回溯到6.6.y和6.1.y LTS分支。将在GKE 1.33.5及更高版本中发布。对于运行这些COS镜像的所有GKE版本,操作系统层面的bug已完全解决。

使这个案例与众不同的因素

大多数网络问题只在某一层解决。你找到配置错误的防火墙规则、耗尽的连接池、计算错误的MTU,修复后继续前进。这个案例不同之处在于,无论是在Private Service Connect层、内核的TCP状态机还是Envoy的连接管理中查看,症状 —— 一个从未完成的连接出现I/O超时 —— 都是相同的。每一层都有看似合理的解释,但每种解释都存在遗漏。

最终破解这个谜题的是在路径中多个点同时进行数据包捕获的严谨性。如果没有在主机网卡、PIGv和容器内部同时捕获数据包,我们永远无法发现数据包重排序现象。如果没有将这些pcap文件与2月18日USPS指标峰值相关联,我们永远无法发现被丢弃的Challenge ACK。如果没有在实验室环境中复现问题,我们永远无法发现导致FIN_WAIT_2状态永久化的内核bug。

另一个使这个案例成功的关键因素是团队协作。在两周时间内,来自七个或八个不同专业领域的工程师共同参与:GKE网络、Private Service Connect基础设施、Hostnet、Andromeda、KCP,最终还有内核社区。没有一个人能独自掌握所有信息。案例笔记、共享的bug跟踪系统、精心收集的pcap文件 —— 它们构成了分布式团队协同工作的纽带。

最终,客户恢复了生产环境。内核获得了更完善的修复方案。而我们其他人则获得了一个非常精彩的故事,说明为什么数据包重排序、半关闭套接字和一个初衷良好的安全补丁不应该被允许出现在同一时间、同一空间。

本文基于匿名客户支持案例和内部bug的真实数据。所引用的Linux内核修复是提交记录795a7dfbc3d9,作者为Eric Dumazet。特别感谢GKE、Private Service Connect、Hostnet、Andromeda、COS以及Linux网络社区的工程师们,他们的工作使这一问题得以解决。

发布于

  • 网络
  • 容器与Kubernetes
  • GKE
  • DevOps与SRE
  • 管理工具
  • 开发者与从业者