本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。
kube-proxy 的细微之处:调试间歇性连接重置
最近我遇到了一个导致间歇性连接重置的 Bug。经过一番深入研究,我发现它是由几个不同的网络子系统的微妙组合引起的。这帮助我更好地理解了 Kubernetes 网络,我认为这值得与对同一主题感兴趣的更广泛受众分享。
症状
我们收到了一份用户报告,声称他们在同一个集群中使用 ClusterIP 类型的 Kubernetes 服务向运行在 Pod 中的大型文件提供服务时,遇到了连接重置。初步调试集群并没有发现任何有趣的问题:网络连接正常,下载文件也没有遇到任何问题。然而,当我们并行运行许多客户端的工作负载时,我们能够重现该问题。更神秘的是,当在没有 Kubernetes 的虚拟机上运行工作负载时,该问题无法重现。这个问题可以很容易地通过一个简单的应用程序重现,显然与 Kubernetes 网络有关,但这到底是什么原因呢?
Kubernetes 网络基础
在深入研究这个问题之前,让我们先谈谈 Kubernetes 网络的一些基础知识,因为 Kubernetes 处理来自 Pod 的网络流量的方式,会根据不同的目的地而大相径庭。
Pod 到 Pod
在 Kubernetes 中,每个 Pod 都有自己的 IP 地址。好处是,运行在 Pod 内部的应用程序可以使用其规范端口,而无需重新映射到不同的随机端口。Pod 之间具有 L3 连接。它们可以互相 ping,并互相发送 TCP 或 UDP 数据包。CNI 是解决不同主机上运行的容器之间此问题的标准。有大量的不同插件支持 CNI。
Pod 到外部
对于从 Pod 到外部地址的流量,Kubernetes 简单地使用 SNAT。它所做的是用主机的 IP:端口替换 Pod 的内部源 IP:端口。当返回的数据包回到主机时,它将 Pod 的 IP:端口重写为目的地,并将其发送回原始 Pod。整个过程对原始 Pod 是透明的,它完全不知道地址转换。
Pod 到服务
Pod 是短暂的。大多数情况下,人们希望获得可靠的服务。否则,它几乎毫无用处。因此,Kubernetes 有一个叫做“服务”的概念,它只是 Pod 前面的 L4 负载均衡器。服务有几种不同的类型。最基本的一种叫做 ClusterIP。对于这种类型的服务,它有一个唯一的 VIP 地址,只能在集群内部路由。
Kubernetes 中实现此功能的组件称为 kube-proxy。它位于每个节点上,并编程复杂的 iptables 规则以执行 Pod 和服务之间的各种过滤和 NAT。如果您进入 Kubernetes 节点并键入 iptables-save
,您将看到 Kubernetes 或其他程序插入的规则。最重要的链是 KUBE-SERVICES
、KUBE-SVC-*
和 KUBE-SEP-*
。
KUBE-SERVICES
是服务数据包的入口点。它的作用是匹配目标 IP:端口并将数据包分派到相应的KUBE-SVC-*
链。KUBE-SVC-*
链充当负载均衡器,并将数据包平均分配到KUBE-SEP-*
链。每个KUBE-SVC-*
具有与其后面的端点数量相同的KUBE-SEP-*
链数。KUBE-SEP-*
链代表一个服务端点。它简单地执行 DNAT,用 Pod 的端点 IP:端口替换服务 IP:端口。
对于 DNAT,conntrack 会介入并使用状态机跟踪连接状态。需要状态是因为它需要记住它更改到的目标地址,并在返回数据包回来时将其更改回来。Iptables 也可以依靠 conntrack 状态(ctstate)来决定数据包的命运。这 4 种 conntrack 状态尤其重要:
- NEW:conntrack 对此数据包一无所知,这发生在收到 SYN 数据包时。
- ESTABLISHED:conntrack 知道数据包属于已建立的连接,这发生在握手完成后。
- RELATED:数据包不属于任何连接,但它与另一个连接相关联,这对于 FTP 等协议特别有用。
- INVALID:数据包有问题,conntrack 不知道如何处理它。此状态在此 Kubernetes 问题中起着核心作用。
以下是 Pod 与服务之间 TCP 连接如何工作的图示。事件序列如下:
- 左侧客户端 Pod 向服务发送数据包:192.168.0.2:80
- 数据包通过客户端节点中的 iptables 规则,目的地更改为 Pod IP,即 10.0.1.2:80
- 服务器 Pod 处理数据包并发送回目标为 10.0.0.2 的数据包
- 数据包返回到客户端节点,conntrack 识别数据包并将源地址重写回 192.169.0.2:80
- 客户端 Pod 收到响应数据包

良好数据包流
什么导致了连接重置?
背景知识已经足够了,那么究竟是什么地方出了问题,导致了意外的连接重置呢?
如下图所示,问题出在数据包 3。当 conntrack 无法识别返回数据包并将其标记为 INVALID 时。最常见的原因包括:conntrack 因容量不足而无法跟踪连接,数据包本身超出 TCP 窗口等。对于那些被 conntrack 标记为 INVALID 状态的数据包,我们没有 iptables 规则来丢弃它,因此它将被转发到客户端 Pod,其源 IP 地址未被重写(如数据包 4 所示)!客户端 Pod 不识别此数据包,因为它具有不同的源 IP,即 Pod IP,而不是服务 IP。结果,客户端 Pod 会说:“等一下,我不记得曾经与这个 IP 建立过连接,为什么这家伙一直给我发送这个数据包?”基本上,客户端所做的就是简单地向服务器 Pod IP 发送一个 RST 数据包,这就是数据包 5。不幸的是,这是一个完全合法的 Pod-to-Pod 数据包,可以传递到服务器 Pod。服务器 Pod 不知道客户端发生的所有地址转换。从它的角度来看,数据包 5 是一个完全合法的数据包,就像数据包 2 和 3。服务器 Pod 所知道的只是,“好吧,客户端 Pod 不想和我说话,所以我们关闭连接吧!”轰!当然,为了使这一切发生,RST 数据包也必须是合法的,具有正确的 TCP 序列号等。但当它发生时,双方都同意关闭连接。

连接重置数据包流
如何解决?
一旦我们理解了根本原因,修复就不难了。至少有两种方法可以解决它。
- 让 conntrack 对数据包更宽松,不要将数据包标记为 INVALID。在 Linux 中,您可以通过
echo 1 > /proc/sys/net/ipv4/netfilter/ip_conntrack_tcp_be_liberal
来实现。 - 专门添加 iptables 规则以丢弃标记为 INVALID 的数据包,这样它就不会到达客户端 Pod 并造成损害。
此修复已在 v1.15+ 中提供。但是,对于受此 Bug 影响的用户,可以通过在集群中应用以下规则来缓解问题。
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
name: startup-script
labels:
app: startup-script
spec:
template:
metadata:
labels:
app: startup-script
spec:
hostPID: true
containers:
- name: startup-script
image: gcr.io/google-containers/startup-script:v1
imagePullPolicy: IfNotPresent
securityContext:
privileged: true
env:
- name: STARTUP_SCRIPT
value: |
#! /bin/bash
echo 1 > /proc/sys/net/ipv4/netfilter/ip_conntrack_tcp_be_liberal
echo done
总结
显然,这个 Bug 几乎一直存在。我很惊讶直到最近才被注意到。我认为原因可能是:(1)这种情况更多地发生在拥挤的服务器上,处理大负载,这可能不是一个常见的用例;(2)应用程序层处理重试以容忍这种重置。无论如何,无论 Kubernetes 增长多快,它仍然是一个年轻的项目。除了密切倾听客户反馈,不把任何事情视为理所当然并深入挖掘,没有其他秘密能使其成为运行应用程序的最佳平台。