本文已发布超过一年。较旧的文章可能包含过时内容。请检查页面信息自发布以来是否仍然正确。
kube-proxy 的精妙之处:调试间歇性连接重置
我最近遇到一个导致间歇性连接重置的 bug。经过一番深究,我发现它是由几个不同的网络子系统微妙组合造成的。这帮助我更好地理解了 Kubernetes 网络,我认为值得与对同一主题感兴趣的更广泛的受众分享。
症状
我们收到用户报告,称他们在向同一集群中运行的 pod 提供大文件时,使用 ClusterIP 类型的 Kubernetes Service 遇到了连接重置。对集群进行初步调试并没有发现任何有趣的问题:网络连接正常,下载文件也没有遇到任何问题。然而,当我们在许多客户端并行运行工作负载时,我们能够重现该问题。更令人费解的是,当使用没有 Kubernetes 的 VM 运行工作负载时,无法重现该问题。这个问题可以通过一个简单的应用轻松重现,显然与 Kubernetes 网络有关,但具体是什么呢?
Kubernetes 网络基础
在深入探讨这个问题之前,我们先了解一些 Kubernetes 网络的基础知识,因为 Kubernetes 根据不同的目的地处理来自 pod 的网络流量的方式非常不同。
Pod 到 Pod
在 Kubernetes 中,每个 pod 都有自己的 IP 地址。这样做的好处是,运行在 pod 内的应用可以使用其规范端口,而不是映射到不同的随机端口。Pod 之间具有 L3 连接性。它们可以互相 ping,并互相发送 TCP 或 UDP 数据包。CNI 是解决不同主机上运行的容器之间连接问题的标准。有大量支持 CNI 的不同插件。
Pod 到外部
对于从 pod 到外部地址的流量,Kubernetes 只使用 SNAT。它的作用是将 pod 的内部源 IP:端口替换为宿主机的 IP:端口。当返回数据包回到宿主机时,它将 pod 的 IP:端口重写为目标地址,并将其发送回原始 pod。整个过程对原始 pod 是透明的,它完全不知道地址转换的发生。
Pod 到 Service
Pod 是短暂的。大多数情况下,人们需要可靠的服务。否则,它几乎没什么用。因此 Kubernetes 有一个叫做“Service”的概念,它本质上是一个位于 pod 前端的 L4 负载均衡器。有几种不同类型的 Service。最基本的类型叫做 ClusterIP。对于这种类型的 Service,它有一个唯一的 VIP 地址,该地址只能在集群内部路由。
在 Kubernetes 中实现此功能的组件称为 kube-proxy。它驻留在每个节点上,并通过复杂的 iptables 规则在 pod 和 Service 之间执行各种过滤和 NAT 操作。如果你进入一个 Kubernetes 节点并输入 iptables-save
,你将看到由 Kubernetes 或其他程序插入的规则。最重要的链是 KUBE-SERVICES
、KUBE-SVC-*
和 KUBE-SEP-*
。
KUBE-SERVICES
是 Service 数据包的入口点。它的作用是匹配目标 IP:端口,并将数据包分发到相应的KUBE-SVC-*
链。KUBE-SVC-*
链充当负载均衡器,并将数据包平均分配到KUBE-SEP-*
链。每个KUBE-SVC-*
链都有与它背后的端点数量相同的KUBE-SEP-*
链。KUBE-SEP-*
链代表一个 Service EndPoint。它只执行 DNAT,将 Service IP:端口替换为 pod 的端点 IP:端口。
对于 DNAT,conntrack 会介入并使用状态机跟踪连接状态。需要状态是因为它需要记住它改变到的目标地址,并在返回数据包回来时将其改回。Iptables 也可以依赖 conntrack 状态 (ctstate) 来决定数据包的命运。这 4 个 conntrack 状态尤为重要
- NEW: conntrack 对此数据包一无所知,这发生在接收到 SYN 数据包时。
- ESTABLISHED: conntrack 知道该数据包属于一个已建立的连接,这发生在握手完成后。
- RELATED: 该数据包不属于任何连接,但它与另一个连接相关联,这对于 FTP 等协议特别有用。
- INVALID: 数据包有问题,conntrack 不知道如何处理它。此状态在此 Kubernetes 问题中起着核心作用。
以下是 pod 和 Service 之间 TCP 连接工作原理的图示。事件顺序如下
- 左侧客户端 pod 向 Service 发送数据包: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,而不是 Service IP。结果,客户端 pod 会说:“等等,我不记得曾经与这个 IP 建立过连接,这家伙为什么一直给我发送这个数据包?”基本上,客户端所做的是简单地向服务器 pod IP 发送一个 RST 数据包,即数据包 5。不幸的是,这是一个完全合法的 pod 到 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 发展得有多快,它仍然是一个年轻的项目。没有别的秘诀,只有认真倾听客户反馈,不把任何事情视为理所当然,而是深入挖掘,这样我们才能让它成为运行应用的最好平台。