话说kubernetes网络疑难杂症

大家好,我是来自什么值得买的前台业务架构师owenliang,近期正在推动业务向kubernetes迁移,在这个过程中遇到了一些棘手的问题,在此逐一向大家分享。

在真正进入到问题之前,我会先对我司kubernetes现状以及kuberntes原理做一些基本介绍。

kuberntes技术栈介绍

公司业务主要以PHP、JAVA、Python为主,调用方式均为短连接HTTP方式。

我们基于Django开发了一套Devops发布系统,能够方便的完成镜像构建与Kubernetes应用发布。

Kubernetes集群直接采用Ucloud提供的UK8S集群,版本为1.15,docker版本是18.09。

Kubernetes的Node采用的是4.19.0内核,16核32G配置,kube-proxy采用的是iptables模式(也许有人会问为什么不用IPVS?答:原因后面会讲),iptables版本是1.4.21。

UK8S实现了CNI,提供了underlay扁平网络结构,POD与VM的IP可以互通,网络性能优异。

Kuberntes应用通过LoadBalancer类型的Service向集群外暴露访问地址,Ucloud会为Service创建集群外的ULB负载均衡入口,出于复杂度&管理一致性的考虑,我们现在并没有使用Ingress收敛集群流量入口。

集群内采用Coredns解析域名,我们通过hosts插件配置域名解析service的ULB IP(也许有人会问为什么要解析到ULB而不是Cluster IP ,不怕流量绕出集群么?答:原因后面会讲),并通过template插件响应NXDOMAIN给IPV6查询请求,以此优化DNS查询速度。另外,我们在容器里启用了nscd本地DNS缓存,目的是减轻对Coredns的查询压力。

kubernetes网络原理

以UK8S集群为例,我们需要充分理解其网络原理,才能对网络问题加以分析与优化,这个过程涉及到比较综合的网络理解能力。

CNI原理

首先,UK8S自定义了CNI支持扁平网络结构,这意味着VM和POD的IP地址属于同一个网段,可以直接互通,并不需要通过Nodeport来做流量转发。

下面是宿主机node的IP地址:

Ucloud在SDN层面已经实现了对POD IP的路由,因此当集群外访问POD IP时,流量将会送入到POD所在Node的网卡。

流量进入node后,CNI已经接管了node路由表的管理,它在node上配置了如下的规则:

第1条规则即SDN默认网关10.42.0.1,第2条规则表示node所处网段是10.42.x.x,后续规则是该node上每一个POD的流量递送规则。

当请求POD IP 10.42.13.2的流量被SDN送入到该node后,经过本机路由表确认需要发往与POD相连的本机虚拟网卡(veth pair),从而流量进入到POD。

从POD内来看,其路由表将node作为默认网关:

因此POD回包的目标MAC地址会ARP解析为node的物理网卡地址,进而包经过SDN处理后再次回到node,进而走node路由表默认网关FOWARD送往SDN网关。(CNI并没有在node上建立docker bridge子网,而是直接依托于SDN的扁平网络做ARP响应)

kube-proxy原理

kubernetes基于iptables或者ipvs实现service的负载均衡功能,为了理解service必须理解iptables/ipvs,而理解iptables/ipvs则必须理解它们共同的底层内核机制netfilters。

这里我先发一张netfilters的流量处理流程,翻遍大家对照理解:

为了让大家理解netfilters的原理,我将以实际的3种场景来给大家做对照说明。

场景1)pod1(node1) -> service(cluster ip/ULB ip) -> pod2I(node2)

即集群内POD1通过service负载均衡调用到POD2。

在上面我分析过,POD1发往任意IP的流量其目标MAC地址都会指向node1,进而由node1完成流量转发。

node1收到POD1发来的流量,其目标MAC地址是node1,因此流量被欣然接收。

接着流量经过netfilter的PREROUTING阶段,在此阶段会经历nat表的规则计算,以便匹配流量是否发往cluster ip/ULB ip,我们看一下规则:

这是由node1上的kube-proxy下发的iptables规则,用于实现对访问service的地址改写。

我们继续跟随target跳转到下一个链上的规则:

我截取了某个service的2条规则:

  • 第1条匹配目标IP地址是10.0.132.186的流量,这个IP其实是service的cluster ip,命中则跳转KUBE-SVC-NUA4LXHYE462BSW2链。(可见kube-proxy已经给这条规则写了注释)
  • 第2条匹配目标IP地址是10.42.185.165的流量,这个IP其实是service的ULB ip,命中则跳转KUBE-FW-NUA4LXHYE462BSW2链。

如果我们POD1调用service使用的是Cluster ip则命中第1条规则,如果调用ULB ip则命中第2条规则,之前我也介绍过我司会将域名解析为ULB ip,所以我们就看一下第2条规则的后续行为:

这里又出现了3条规则,第1条规则是使用mark模块给这个连接打了1个标记位(我们暂时不进去看),重要的是第2条规则,它完成了目标IP地址改写(即DNAT):

因为我只有1个后端POD2,所以这里最终追溯到只有1条DNAT规则,将目标IP地址从ULB ip改为这个POD2的IP:10.42.145.194。

那么经过PREROUTING nat改写后,根据netfilters流程图将判断node路由表决定这个包的去向,目标POD2 IP会命中node的默认网关(当然也有概率POD2就在这个node上,那么就如同之前所说直接从虚拟网卡送入POD2),那么流量经过FORWARD阶段->POSTROUTING阶段,最终发往SDN默认网关,进而流入POD2所在的node2。

在流量离开POD1之前还会经历一次POSTROUTING的nat表规则,这里因为在KUBE-SERVICE链命中时对连接标记了mark,所以这里会做一次SNAT改写源IP地址为node1的IP,也就是说这个连接对于POD2来说其源地址并不是POD1而是node1。(实际上在扁平网络结构里,这一步SNAT我认为可以不做)

我们就不继续描述流量到达node2时候如何进入POD2的过程了,留给大家思考。

场景2)pod1(node1) -> redis/mysql(集群外)

即POD1请求部署在集群外虚拟机上的redis,mysql等。

POD1发出的流量经过POD内路由表送往默认网关node1,进入node1的PREROUTING nat阶段,因为其目标IP地址不属于任何service,所以没有进行任何地址改写,查找node1路由表决定发往SDN默认网关,进而走FORWARD和POSTROUTING离开node1,因为没有命中KUBE-SERVICE规则所以没有mark标记,所以POSTROUTING nat也并不会做SNAT,因此在redis/mysql看来源IP是我们的POD1 IP。

场景3)集群外VM -> ULB -> node1 -> pod2(node2)

该场景其实是场景1)的升级版,集群外应用调用ULB,由ULB负载均衡到kubernetes集群某node1的对应nodeport,进而经过node1转发给node2,进入pod2。

ULB会将流量转发到service的nodeport,也就是每个node都会为这个service暴露同一个端口。

在POSTROUTING阶段的KUBE-SERVICES规则末尾,其实有一条针对nodeport端口识别的规则链:

因为ULB转发给kubernetes集群时目标IP就是node的IP,端口是service对应nodeport端口,所以这条规则特意match了目标IP地址是local(也就是本机IP)的流量。

我选中的2行规则是针对该service的匹配,它识别nodeport是46794的service,然后给mark标记它是调用service的流量,然后走DNAT规则改写(我就不给大家展开了)。

另外,不知道是UK8S的CNI还是kube-proxy默认行为,会把ULB的IP地址也加到KUBE-SERVICE的匹配规则中,因此POD通过ULB调用另外1个POD,实际流量是直接被iptables改写的,并不会真的离开集群绕道ULB。(这回答了文章开头的一个问题)

至此基本的netfilters流程就差不多了,后面我们还会涉及到更深入的一点补充。

网络疑难杂症分析

对网络流量有了基本理解之后,我们再来分析问题就会清晰很多(如果对先前的内容不是很理解,可能对网络欠缺比较多,可以自行学习这些关键字:iptables、netfilters、路由表、交换机,网桥)。

先说一些结论性的东西:

问题基本集中在4层,主要是TCP,分析问题需要端到端完整的链路分析。

重点在于,流量在node转发过程中不会过TCP协议栈,只会经过node的netfilters,最终POD才是TCP协议栈的处理者。

问题定位与优化,需要从node和POD分别思考,找到瓶颈所在。

下面是我们遇到的N个主要问题与定位,如果大家对技术细节不关心只想找到解决方法,那么下面的信息对你也很重要。

node上conntrack大量INVALID

现象

压测时,在集群node上观察conntrack -S发现大量INVALID报文。

conntrack俗称流表,或者连接跟踪表,它属于netfilters框架,在之前的netfilters图片中大家可以看到在PREROUTING mangle之前以及OUTPUT mangle之前都会经过一个connection tracking的表。

conntrack是实现nat地址转换的灵魂,一个连接仅在首次经过netfilters链条时会计算nat表,一旦conntrack记录下这次的改写关系,后续无论是去程包还是回程包都是依据conntrack表进行改写关系的处理,不会再重复执行nat表中的DNAT/SNAT规则。

因此,在node的conntrack表中记录了大量的记录,每条记录包含2部分:

  • 改写前的源IP和目标IP
  • 改写后的源IP和目标IP

conntrack是一个内核里的hash表,使用conntrack命令行工具可以查看,给大家看一个例子:

所以说,node上的conntrack在跟踪流过它的每一条连接,包括其连接状态,改写关系,因此conntrack将是kubernetes优化的一个重点对象。

我无法在一篇文章中展开给大家讲述conntrack的原理,希望大家可以在额外学习一下。

分析过程

有问题先看看内核日志,因此输入dmesg,将会看到大量的如下日志:

nf_conntracktable full, dropping packet

说明是压测期间,node上的conntrack哈希表打满了,我们只需要对其优化一下内核参数即可。

解决方法

在node上利用sysctl调整内核选项:

  • nf_conntrack_max
  • nf_conntrack_buckets
  • nf_conntrack_tcp_timeout_time_wait

具体方法我在文章末尾统一给出,想了解这些参数自己谷歌一下学习一下conntrack吧。

POD内大量TCP重传

现象

压测时,在受压POD内观察到大量TCP重传,在受压POD所在node观察到大量conntrack INVALID报文,其他层面(IP层,链路层)网络指标无异常。

受压POD所在node上的其他应用均出现调用方超时,即某应用受压影响了其他应用的网络状况。

我们知道TCP重传是因为对方回复ACK慢导致的,也许是网络慢或者干脆丢失了。

分析过程

一开始出发点是研究node上conntrack大量INVALID报文的原因,通过打开conntrack INVALID日志可以看到大量报文INVALID,但是看不出原因。

因为压测链路从ULB开始,中间环节较多,还需要向下怀疑到云厂商的ULB/SDN之类的,所以定位时间比较长。

在POD内tcpdump抓包也没有明显线索,只是知道有大量重传,利用mtr探测中间链路没有丢包。

经过几天几夜的奋战,最终开始怀疑虚拟机等方面的底层问题,所以决定脱离kubernetes集群,单纯在2个虚拟机之间压测程序看一下是否同样有大量重传,结果还是有大量重传出现!

在逐渐升高压力的过程中,发现链路层PPS包量无法继续上升,虚拟机资源充足,此刻意识到是不是虚拟机包量限制,经过与运维&Ucloud确认,我司使用的机型包括UK8S的默认机型都是低包量的,其PPS瓶颈是6万左右/秒(in+out总共),真的是相当尴尬的结局。

解决方法

升级网络增强型的虚拟机,其PPS可以高达百万/千万。

替换机型后,我重新做了一下node之间的短连接压测极限CPS(connection per second):

每秒新建短连接:约10万次

每秒包量:约80万次

SDN限制包量是相当于中间链路丢包,对虚拟机上没有直接的链路层反馈,非常难查。

此时,POD内重传恢复正常,node上conntrack的INVALID问题也因此消失,可见中间链路丢包导致conntrack跟踪TCP状态出现了很多乱序问题,导致其统计了大量的无效包。

大家在虚拟机环境下一定要注意包量问题,购买更高PPS的主机来支持高并发场景。

node上conntrack少量insert failed报文

现象

压测时,宿主机conntrack -S报少量insert failed报文。

分析过程

谷歌了一下,这是已知问题。

这是linux内核做SNAT时的时序竞争bug,2个连接并发做SNAT时候有一定概率分配到同一个本地源端口,如果2个连接的目标IP+port恰好也一样,那么相当于出现了2个连接的5元祖(协议,源IP/PORT,目标IP/PORT)完全相同,将会导致连接插入/更新conntrack表失败,因此记录为insert fail。

解决方法

先说结论,这个问题在高压下也不严重,不一定非要解决。

如果要解决,需要做如下的事情:

  • 升级iptables到1.6版本,它支持一个叫做–random-fully的端口选择策略,可以通过更加随机的方式选择端口,降低冲突率。
  • 升级kubernetes集群到1.16版本,从该版本开始kube-proxy下发SNAT规则时才会携带–random-fully选项。

这个问题还是国内的一个团队发现与修复的,给大家一些参考链接:

node上conntrack大量INVALID(再次)

现象

后续我为了得到一些极限性能数据,在2个kubernetes node之间直接进行了短连接压测,目的是对node的内核参数进行优化。

我使用netperf的TCP_CRR模式压测2个node之间极限的短连接压力表现,这里并没有涉及到POD,但是因为node有kube-proxy下发的nat规则,因此conntrack依旧会跟踪我的压测连接,就在此时我发现受压node的conntrack -S报大量INVALID报文,与压力成正比关系。

分析过程

之前已经对conntrack进行了性能优化,唯一的区别就是之前都是利用工具压测HTTP接口,而这次是利用netperf工具压测TCP短连接,主要区别是工具的改变。

遇到conntrack INVALID问题可以通过打开log观察INVALID报文的信息来定位问题,所以我打开了conntrack的INVALID log,结果发现虽然INVALID狂涨,但一条日志都没有滚动,一度怀疑是不是我打开conntrack INVALID log的方式有问题,直到偶尔蹦出一条日志才让我打消了怀疑。

所以,我开始怀疑是不是conntrack在某些INVALID场景下不会打印调试日志呢?

于是我决定看一下linux kernel关于conntrack的源码。

1)找到TCP协议追踪处理的主要方法:https://github.com/torvalds/linux/blob/master/net/netfilter/nf_conntrack_proto_tcp.c#L837

也就是说,当收到新来的SYN时,conntrack表里还有上一次的TIMEWAIT记录,此时conntrack的逻辑时删除旧的插入新的,并且返回一个NF_REPEAT(repeat表示重复)的负值。

但是这个逻辑并没有打印日志,而其他返回负值的地方都会打印INVALID log。

2)调用该函数的逻辑则认为返回负值就是INVALID,所以会增加一次计数,但是对于返回-NF_REPEAT的情况,会goto跳到代码最上面重新处理一遍这个包:

但此时我还是有一点疑惑,为什么服务端会遇到TIME_WAIT,通常不都应该是客户端主动关闭连接吗?

于是我在2个node上使用netstat -tanlp|grep TIME_WAIT统计了一下TIME_WAIT连接数量,竟然发现受压node有大量TIME_WAIT,而netperf发压方几乎没有一条TIME_WAIT,这说明netperf的行为是服务端主动关闭连接!

我又在受压node上看了一下netstat -s,观察到海量的TIME_WAIT连接溢出被丢弃,这个问题也就基本定位了:

解决方法

暂时不需要解决,因为返回-NF_REPEAT的话conntrack会goto跳转二次进入处理方法,将该包最终按照正常包处理,最终不会是INVALID包,因此这个INVALID计数也算作conntrack实现的一个”bug”吧。

滚动发布时调用方超时

在最初,我们kubernetess集群采用的是IPVS模式,因此也遇到了一个大坑。

现象

我们使用deployment发布应用,但是发现每次滚动发布都会引发调用方超时,匪夷所思。

分析过程

我首先详细复习了一下Deployment和Service的联动流程,明确要做如下几点调整:

  • POD一定要有livenessProbe和readinessProbe两个健康检查,否则service就会把流量打到一个还没准备好的新POD上。
  • 程序一定要有优雅退出逻辑,kubernetes会首先从service中摘除旧POD的转发规则,然后才会给POD发送SIGTERM退出信号,此时不会有新流量进入,但是程序必须把现有连接服务完毕再退出。
  • 一定要给POD足够的优雅退出时间,通过YAML中设置terminationGracePeriodSeconds实现,建议给比较大的时间窗口(比如30秒),避免流量较多的时候处理残余流量耗时较长,因为一旦超时就会SIGKILL掉程序,导致部分调用就失败了。
  • kubernetes默认行为是给程序发送SIGTERM信号,然后等待terminationGracePeriodSeconds秒后如果程序还没退出则发送SGIKILL。然而NGINX的优雅退出信号并不是SIGTERM,所以我们可能需要借助preStop的Hook,自定义向Nginx发送对应的优雅退出信号,这样kubernetes就不会发送默认的SIGTERM信号了,而且terminationGracePeriodSeconds依旧有效。

做了如上调整后,滚动发布仍旧引发了调用方一连串的报错,QPS越高则越严重。

于是重新思考了一下,要么是上线新POD引发超时,要么是下线旧POD引发的超时,而滚动发布又同时在做下线&上线,怎么区分定位一下呢?

于是决定先将POD从5个扩容程10个,发现没有报错,这说明新POD的健康检查是有效的,流量的进入时机也没问题。而从10个缩为5个POD则引发了报错,因此缩小范围是下线POD引起的。

可是kubernetes下线POD的过程是先摘除service转发,才发送SIGTERM信号给POD,程序也已经是优雅退出的,为啥会有问题呢?

于是开始怀疑是不是其他问题,所以首先怀疑到一个kubernetes工作机制:

deployment会标记1个POD为terminating状态,然后发生了2个并发的事情:

  • kubelet收到etcd变化事件,会立即给POD发送SIGTERM,程序开始优雅退出。
  • kube-proxy收到etcd变化事件,,会立即摘除node上对应pod的iptables转发规则。

kubelet和kube-proxy感知到etcd事件的先后存在时间差,如果先发送SIGTERM再摘除规则就会导致部分新连接在竞争窗口内受损,因为SIGTERM已经让程序关闭监听了。

为了验证这个想法,我们在preStop里首先sleep了2秒再发送优雅退出信号:

– lifecycle:
preStop:
exec:
command:
– /bin/sh
– -c
– sleep 2;pkill xxx;

sleep的目的是为了让iptables规则先下线,再停止程序监听。

然而,这个也没有修复问题。

最终,还是谷歌帮助了我们,明确这是IPVS下线规则时候的一个bug,会导致新连接发送到已经下线的POD内。

解决方法

具体原因大家就看看官方issue吧:https://github.com/kubernetes/kubernetes/issues/81775

目前有2个解决方案:

  • 忍着,每次滚动发布都要报错,等待未来的官方方案。
  • 替换iptables方案。

我们选择了后者,因为涉及到HPA弹性的需求,我们不可能容忍每次伸缩都引发调用方超时。

iptables我们维护起来更加熟悉,它在service规模超过1000+的情况下才会出现性能下降,对我们来说也足够了。

其他影响不大的问题-1(不明显的问题,但可能帮助到大家的场景)

还是与conntrack有关,下面的选项需要生效到所有node:

/proc/sys/net/ipv4/netfilter/ip_conntrack_tcp_be_liberal=1

它令conntrack在跟踪TCP连接时对TCP滑动窗口的sequence校验宽松一些,否则TCP包乱序到达时conntrack会认为包超出了自己已知的窗口范围,会将包标记为INVALID,进而导致被kubernetes下发的一条INVALID DROP规则丢弃,这样会引发调用方超时重传。

大家感兴趣可以看一下官方说明:https://kubernetes.io/blog/2019/03/29/kube-proxy-subtleties-debugging-an-intermittent-connection-reset/

其他影响不大的问题-2(不明显的问题,但可能帮助到大家的场景)

ucloud实现的CNI在POD里下发了一条没有用的SNAT规则,这将会触发POD内conntrack机制,导致POD内也要面临conntrack性能问题:

我们目前是通过privileged特权的init-container,在POD启动之前执行了iptables -t nat -F删除了该规则,后续ucloud也会修正这个历史遗留问题。

优化参数

上面讨论了那么多问题,最终其实就是几行优化参数,吃快餐的同学可以直接拿走。

node调参

node瓶颈是conntrack,它跟踪所有流量,但自身又不处理协议栈:

pod调参

pod内conntrack不开启,主要瓶颈在TCP协议栈处理短连接部分:

一键node/port通用

如果想快速验证一下所有参数,在node以及privileged的POD内生效如下命令即可:

用到的工具

输出conntrack INVALID日志

生效如下命令:

观察dmesg。

netperf压测

服务端启动命令(会启动到后台,会打印一条warinng日志没关系):

netserver

客户端短连接压测(-l持续秒数,-m请求应答的大小):

netperf -t TCP_CRR -H 目标服务器 -l 1200 — -m50,100 &

需要启动多个netperf客户端到后台,每一个客户端会令netserver服务端启动一个独立端口,避免单个TCP监听队列的瓶颈问题,从而可以试探出整机的TCP短连接极限。

网络分析

随便想了一些常用的命令:

  • netstat -s
  • conntrack -S
  • sar -n ETCP 1
  • sar -n DEV 1
  • ifconfig
  • mtu
  • tcpdump
  • netperf
  • iptables
  • top
  • vmstat
  • iostat
  • iotop
  • ss
  • netstat -tanlp

结束语

问题遇到的越早越好,如果你没有遇到问题,说明你的问题更大。

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~