![a45eae02b0fb2a8873ecd2345d01f4a3.png](https://i-blog.csdnimg.cn/blog_migrate/191d2bfebc8ec0e8383dfdf3d7494bea.jpeg)
编译:bot(才云)
技术校对:星空下的文仔(才云)
编者按:将应用迁移到 Kubernetes 时,有时候工程师们会发现一些令人费解的连接超时,无论怎么排查都找不到原因。在这篇文章中,软件架构师 Maxime Lagresle分享了自己团队的亲身经验。
Linux 内核有一个众所周知的问题,就是在做 SNAT(修改数据包的源地址)时容易出现 SYN 丢包。
默认情况下,SNAT 一般使用 iptables 伪装规则在 Docker 和 Flannel 的传出连接上执行,当多个容器同时尝试和同一外部地址建立新连接时,丢包情况就会发生。在一些情景下,可能两个连接会被分配到一个端口用做地址转换,导致一个或多个丢包,以及至少 1 秒的连接延迟。
Linux 的源代码中提到了这个现象,内核也已经支持一个缓解此问题的 flag,但目前社区还没有太多相关文档。为了让更多开发者,尤其是不熟悉 DNAT 的 Kubernetes 新手提前做好心理准备,在这篇文章中,我们将尝试去调查这个问题,并寻找可靠的解决方案。
注: DNAT 上也存在相同的问题,这意味着在 Kubernetes 上,你访问 ClusterIP 时也可能丢包。当你从 Pod 发送请求到 ClusterIP,默认情况下,kube-proxy(通过 iptables)会把 ClusterIP 改成你想要访问的 Service 的某个 Pod IP,而该问题的存在会导致 DNS 解析域名时出现间歇性延迟,详参 Issue 56903。
背景简介
Maxime Lagresle 是某公司后端架构团队的软件架构师。他的团队花了一年时间构建了一个 PaaS,经过谨慎评估,他们决定把基于 Capistrano/Marathon/Bash 的部署都迁移到 Kubernetes 上。
那时候,他们的设置依赖 Ubuntu Xenial 虚拟机(Docker v17.06)、Kubernetes v1.8,以及在主机网关模式下运行的 Flannel v1.9.0 。
在迁移时,Maxime Lagresle 团队注意到,当把这些部署放到 Kubernetes 上运行时,应用程序中的连接超时会明显增加。而在他们把第一个基于 Scala 的应用程序迁移上去后,超时的情况就更明显了——相比平时的几百毫秒,几乎每一秒都会有一个响应非常慢的请求。但这个应用程序非常普通,只负责公开 REST 端点并查询平台上的其他 Service,收集、处理数据并将数据返回给客户端。
经过观察,他们发现请求的响应时间很奇怪,几乎都被延迟了 1-3 秒。于是他们决定找出原因。
缩小问题范围
在他们的待办列表中,有一项任务是监控 KubeDNS 的表现。由于依赖 HTTP 客户端,名称解析时间可能是连接时间的一部分,他们决定先处理该任务,并确保该组件运行良好。
为了判断这一猜想,Maxime Lagresle 团队写了一个小的 DaemonSet,它可以直接查询 KubeDNS 和数据中心名称服务器,并将响应时间发送到 InfluxDB。很快,图表显示响应时间非常快,名称解析并不是延迟的罪魁祸首。
之后,他们又将重点转移到探索这些延迟背后的含义上。负责该 Scala 应用的团队先做了一些修改,使响应慢的请求在后台继续发送,并在向客户端抛出超时错误后记录持续时间。Maxime Lagresle 团队则在运行应用程序的 Kubernetes 节点上做网络跟踪,尝试将响应慢的请求与网络转储的内容进行匹配。
![61f46eb4568995bd3d00dacec623b816.png](https://i-blog.csdnimg.cn/blog_migrate/ba82a66a94cb242966c26d9e5ba313e3.jpeg)
结果显示,延迟是由第一个网络数据包的重传引起的,该数据包的作用是启动连接(具有 SYN 标志的数据包)。这解释了请求响应时间的延迟量为什么是 1-3 秒,因为这种数据包的重传延迟是 1 秒(第二次)、3 秒(第三次)、6 秒、12 秒、24 秒……以此类推。
这是一个有趣的发现,因为丢失 SYN 数据包不是由随机网络故障导致的,而可能是网络设备或 SYN 泛洪保护算法主动丢弃了新连接。
在默认的 Docker 安装中,每个容器都有一个虚拟网络接口(veth)上的 IP,它和主接口(如 eth0、docker0)一样连接着 Docker 主机上的 Linux 网桥。容器通过网桥互相通信,如果容器想连接 Docker 主机外部地址,那么数据包会先进入网桥,再通过 eth0 路由到服务器外部。
下图的示例已经对默认 Docker 设置进行了调整,以匹配网络捕获中看到的网络配置:
![26e0fdd930453d2b5cb468495dbbcc24.png](https://i-blog.csdnimg.cn/blog_migrate/34030fb33336cc53e75a54e77f292e90.jpeg)
Maxime Lagresle 团队随机查看了网桥上的数据包,之后继续查看虚拟机的主接口 eth0,并根据结果集中查看网络基础架构和虚拟机。
![4bfd81816478419fd4a5554c2fab7c6a.png](https://i-blog.csdnimg.cn/blog_migrate/4a7d4323d44d4ad5e76ce9bc3503fedf.jpeg)
网络捕获结果显示,第一个 SYN 包在 13:42:23.828339 从容器网络接口(veth)离开,经过网桥(cni0)。在 13:42:24.826211 待了 1 秒后,容器没有从 10.16.46.24 得到响应就重传了这个包。所以再一次,这个包会先出现在容器的网络接口,然后是网桥。
在下一行,我们可以看到这个包在 IP 地址和端口从 10.244.38.20:38050 转换成 10.16.34.2:10011 之后,在 13:42:24.826263 离开了 eth0。下一行显示远程服务是如何响应的。
很明显,问题很可能出在虚拟机上,与其他基础架构无关。为了配置更灵活的解决方案,他们编写了一个非常简单的 Go 程序,可以通过一些可配置的设置对端点发出请求:
- 两个请求之间的延迟;
- 并发请求的数量;
- 超时;
- 要调用的端点。
要连接的远程端点是具有 Nginx 的虚拟机,测试程序将针对此端点发出请求,并记录任何高于一秒的响应时间。
Go 程序:https://github.com/maxlaverse/snat-race-conn-test
解决问题
已知数据包是在网桥之间丢包的,eth0 正是执行 SNAT 操作的地方。所以,如果因为某些原因,导致 Linux 内核无法分配一个空闲的源端口来做地址转换,他们就看不到这个包出 eth0。
这里可以设一个简单的测试来做验证——尝试 pod-to-pod 通信并记录响应延迟的请求的数量。Maxime Lagresle 团队做了测试,发现没有丢包。接下来,他们准备深入看一下 conntrack。
注:为了更好地理解后续内容,建议读者先了解一些关于源网络地址转换的知识。
用户空间中的 conntrack
在整个过程中,他们提出了一系列猜想:
猜想一:大部分连接都被转成相同的 host:port。
这一点已经被否定了。
猜想二:这个现象很可能是一些配置错误的 SYN 泛洪保护引起的。
他们检查了网络内核参数,并没有找到自己不知道的机制;增加了 conntrack 表的大小,内核日志也没有报错。所以这个猜想也不正确。
猜想三:端口复用。如果端口耗尽并且没有可用于 SNAT 操作的端口,那么数据包是可能会被丢弃或拒绝的。
他们查看了 conntrack 表,发现 conntrack 包有一个命令,可以显示一些统计信息(conntrack -S)。在运行该命令时,有一个字段非常有趣:“insert_failed”具有非零值。
他们再次运行测试程序,并密切关注字段的统计信息,发现如果按一个丢包导致 1 秒请求延迟、两个丢包导致 3 秒请求延迟来算,统计信息里的数据和丢包的数量是完全一致的!
man page 上有对那个字段的清楚描述:尝试列表插入但失败的条目数(如果已存在相同的条目,则会发生)。但这个描述没有解答在什么情况下插入会失败?无论如何,在低负载服务器发生丢包怎么看都不像是正常现象。
Netfilter NAT & Conntrack 内核模块
在阅读了 Netfilter 内核代码之后,他们决定重新编译它并添加一些跟踪。
NAT 代码在 POSTROUTING 链上被 hook 了两次。首先,通过修改源 IP 和端口来修改包的结构;其次,如果期间没有丢包,在 conntrack 表中记录转换。这意味着 SNAT 端口分配与表中的插入之间存在延迟,如果存在冲突和数据包丢失,则可能会导致插入失败。
在 tcp 连接上执行 SNAT 时,NAT 模块会做以下尝试:
- 第一步:如果数据包的源 IP 在目标 NAT 池中,并且元组可用,则返回(数据包保持不变);
- 第二步:找到 NAT 池中使用最少的 IP,并用它替换数据包中的源 IP;
- 第三步:检查端口是否在允许的端口范围内(默认为 1024-64512),以及具有该端口的元组是否可用。如果是,请返回(源 IP 已更改,端口保留)。注:SNAT 端口范围不受内核参数值net.ipv4.ip_local_port_rangekernel 的影响;
- 第四步:端口不可用,通过调用 nf_nat_l4proto_unique_tuple() 请求 tcp 层为 SNAT 找到一个唯一的端口。
按照上述步骤,当主机仅运行一个容器时,NAT 模块很可能在第三步之后返回,容器内部进程使用的本地端口也将被保留并用于传出连接。当 Docker 主机运行多个容器时,连接的源端口更可能已被另一个容器的连接使用。在这种情况下,调用 nf_nat_l4proto_unique_tuple () 以查找 NAT 操作的可用端口。
Netfilter 还支持另外两种算法来查找 SNAT 空闲端口:
- 部分随机端口搜索初始位置分配。在 SNAT 规则有NF_NAT_RANGE_PROTO_RANDOM flag 的时候被使用;
- 完全随机端口搜索初始位置分配。在规则有 NF_NAT_RANGE_PROTO_RANDOM_FULLY flag 的时候被使用。
NF_NAT_RANGE_PROTO_RANDOM 虽然降低了两个线程以相同初始端口启动的次数,但仍有很多错误。Maxime Lagresle 团队在使用 NF_NAT_RANGE_PROTO_RANDOM_FULLY 时,才发现 conntrack 表插入错误的次数明显变少:在 Docker 测试虚拟机上,使用默认伪装规则,且有 10-80 个线程连接到同一主机时,conntrack 表的插入失败率大约在 2% 到 4%;如果在内核强制使用完全随机,错误就降到了 0(在真实集群中确实也接近 0)。
激活 K8s 上的完全随机端口分配
NF_NAT_RANGE_PROTO_RANDOM_FULLY flag 需要在伪装规则中设置。在Maxime Lagresle 团队的 Kubernetes 设置中,Flannel 负责添加这些规则。它在 Docker 镜像构建期间从源代码构建 iptables。iptables 工具不支持设置此 flag,但 Maxime Lagresle 团队已经提交了一个合并的小补丁并添加了此功能。
Flannel 补丁:
https://gist.github.com/maxlaverse/1fb3bfdd2509e317194280f530158c98
他们现在使用的是应用了这个补丁的 Flannel 修改版本,并在伪装规则上增加了 --random-fullyflag(4 行更改)。通过 DaemonSet,他们可以在每个节点上获取 conntrack 统计信息,并将指标发送到 InfluxDB 以密切关注插入错误。
通过这个补丁,他们的错误数量已经从每个节点几秒一次下降到整个集群几小时一次,效果显著。
小结
Kubernetes 正值快速发展,但上述问题却到现在还存在着,讨论它的人也不多,这一点是令人惊讶的。随着越来越多应用程序连接到相同的端点,这一问题的严重性将被很快暴露出来。
我们需要采取一些额外的解决措施,因为每个人都在使用这种 DNS 循环,或者将 IP 添加到每个主机的 NAT 池。如果你在工作中曾遇到过这个问题,希望这篇文章对你有所帮助!