k8s最佳实践:业务丢包问题排查
一.问题描述
有用户反馈大量图片加载不出来。
图片下载走的 k8s ingress,这个 ingress 路径对应后端 service 是一个代理静态图片文件的 nginx deployment,这个 deployment 只有一个副本,静态文件存储在 nfs 上,nginx 通过挂载 nfs 来读取静态文件来提供图片下载服务
所以调用链是:client –> k8s ingress –> nginx –> nfs。
二.原因猜测
猜测: ingress 图片下载路径对应的后端服务出问题了。
验证:在 k8s 集群直接 curl nginx 的 pod ip,发现不通,果然是后端服务的问题!
三.问题排查
1.抓包:确定存在丢包
登上 nginx pod 所在节点,进入容器的 netns (网络命名空间 )中
# 拿到 pod 中 nginx 的容器 id
$ kubectl describe pod tcpbench-6484d4b457-847gl | grep -A10 "^Containers:" | grep -Eo 'docker://.*$' | head -n 1 | sed 's/docker:\/\/\(.*\)$/\1/'
49b4135534dae77ce5151c6c7db4d528f05b69b0c6f8b9dd037ec4e7043c113e
# 通过容器 id 拿到 nginx 进程 pid
$ docker inspect -f {{.State.Pid}} 49b4135534dae77ce5151c6c7db4d528f05b69b0c6f8b9dd037ec4e7043c113e
3985
# 进入 nginx 进程所在的 netns
$ nsenter -n -t 3985
# 查看容器 netns 中的网卡信息,确认下
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
3: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 56:04:c7:28:b0:3c brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.26.0.8/26 scope global eth0
valid_lft forever preferred_lft forever
使用 tcpdump 指定端口 24568 抓容器 netns 中 eth0 网卡的包:
tcpdump -i eth0 -nnnn -ttt port 24568
在其它节点准备使用 nc 指定源端口为 24568 向容器发包:
nc -u 24568 172.16.1.21 80
观察抓包结果:
00:00:00.000000 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000206334 ecr 0,nop,wscale 9], length 0
00:00:01.032218 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000207366 ecr 0,nop,wscale 9], length 0
00:00:02.011962 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000209378 ecr 0,nop,wscale 9], length 0
00:00:04.127943 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000213506 ecr 0,nop,wscale 9], length 0
00:00:08.192056 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000221698 ecr 0,nop,wscale 9], length 0
00:00:16.127983 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000237826 ecr 0,nop,wscale 9], length 0
00:00:33.791988 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000271618 ecr 0,nop,wscale 9], length 0
SYN 包到容器内网卡了,但容器没回 ACK,像是报文到达容器内的网卡后就被丢了。
看样子跟防火墙应该也没什么关系,也检查了容器 netns 内的 iptables 规则,是空的,没问题。
排除是 iptables 规则问题,在容器 netns 中使用 netstat -s
检查下是否有丢包统计:
$ netstat -s | grep -E 'overflow|drop'
12178939 times the listen queue of a socket overflowed
12247395 SYNs to LISTEN sockets dropped
果然有丢包
全连接队列满了:xxx times the listen queue of a socket overflowed
半连接队列满了:xxx SYNs to LISTEN sockets dropped
2.寻找为什么丢包?全连接队列 满了
背景知识:
Linux 进程监听端口时,内核会给它对应的 socket 分配两个队列:
- syn queue: 半连接队列。server 收到 SYN 后,连接会先进入
SYN_RCVD
状态,并放入 syn queue,此队列的包对应还没有完全建立好的连接(TCP 三次握手还没完成)。 - accept queue: 全连接队列。当 TCP 三次握手完成之后,连接会进入
ESTABELISHED
状态并从 syn queue 移到 accept queue,等待被进程调用accept()
系统调用 “拿走”。
注意:这两个队列的连接都还没有真正被应用层接收到,当进程调用
accept()
后,连接才会被应用层处理,具体到我们这个问题的场景就是 nginx 处理 HTTP 请求。
根据之前的分析,我们可以推测是 syn queue 或 accept queue 满了。
先检查下 syncookies 配置:
$ cat /proc/sys/net/ipv4/tcp_syncookies
1
确认启用了 syncookies
,所以 syn queue 大小没有限制,不会因为 syn queue 满而丢包,并且即便没开启 syncookies
,syn queue 有大小限制,队列满了也不会使 ListenOverflows
计数器 +1。
从计数器结果来看,ListenOverflows
和 ListenDrops
的值差别不大
所以推测很有可能是 accept queue 满了
因为当 accept queue 满了会丢 SYN 包,并且同时将 ListenOverflows
与 ListenDrops
计数器分别 +1。
所以,得出结论,猜测是accept queue满了
如何验证 accept queue 满了呢?
可以在容器的 netns 中执行 ss -lnt
看下:
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 128 *:80 *:*
通过这条命令我们可以看到当前 netns 中监听 tcp 80 端口的 socket,Send-Q
为 128,Recv-Q
为 129。
什么意思呢?通过调研得知:
- 对于
LISTEN
状态,Send-Q
表示 accept queue 的最大限制大小,Recv-Q
表示其实际大小。 - 对于
ESTABELISHED
状态,Send-Q
和Recv-Q
分别表示发送和接收数据包的 buffer。
所以,看这里输出结果可以得知 accept queue 满了,当 Recv-Q
的值比 Send-Q
大 1 时表明 accept queue 溢出了,如果再收到 SYN 包就会丢弃掉。
问题1:为什么不用考虑syn queue满了?
因为默认都是启动了syn_cookies,所以一般不用担心 syn queue 满了导致丢包
- syncookies 是为了防止 SYN Flood 攻击 (一种常见的 DDoS 方式),攻击原理就是 client 不断发 SYN 包但不回最后的 ACK,填满 server 的 syn queue 从而无法建立新连接,导致 server 拒绝服务。
-
如果启用了 syncookies (net.ipv4.tcp_syncookies=1),当 syn queue 满了,server 还是可以继续接收
SYN
包并回复SYN+ACK
给 client,只是不会存入 syn queue 了。因为会利用一套巧妙的 syncookies 算法机制生成隐藏信息写入响应的SYN+ACK
包中,等 client 回ACK
时,server 再利用 syncookies 算法校验报文,校验通过后三次握手就顺利完成了。所以如果启用了 syncookies,syn queue 的逻辑大小是没有限制的, -
如果 syncookies 没有启用,syn queue 的大小就有限制,除了跟 accept queue 一样受
net.core.somaxconn
大小限制之外,还会受到net.ipv4.tcp_max_syn_backlog
的限制,即:max syn queue size = min(backlog, net.core.somaxconn, net.ipv4.tcp_max_syn_backlog)
3.寻找 全连接队列 满的原因
原因1:accept()调用很慢
导致 accept queue 满的原因一般都是因为进程调用 accept()
太慢了,导致大量连接不能被及时 “拿走”。
那么什么情况下进程调用 accept()
会很慢呢?猜测可能是进程连接负载高,处理不过来。
而负载高不仅可能是 CPU 繁忙导致,还可能是 IO 慢导致,当文件 IO 慢时就会有很多 IO WAIT,在 IO WAIT 时虽然 CPU 不怎么干活,但也会占据 CPU 时间片,影响 CPU 干其它活。
最终进一步定位发现是 nginx pod 挂载的 nfs 服务对应的 nfs server 负载较高,导致 IO 延时较大,从而使 nginx 调用 accept()
变慢,accept queue 溢出,使得大量代理静态图片文件的请求被丢弃,也就导致很多图片加载不出来。
原因2:全连接队列很小
内核既然给监听端口的 socket 分配了 syn queue 与 accept queue 两个队列,那它们有大小限制吗?可以无限往里面塞数据吗?当然不行! 资源是有限的,尤其是在内核态,所以需要限制一下这两个队列的大小。那么它们的大小是如何确定的呢?我们先来看下 listen 这个系统调用:
int listen(int sockfd, int backlog)
可以看到,能够传入一个整数类型的 backlog
参数,我们再通过 man listen
看下解释:
-
listen 的 backlog 参数同时指定了 socket 的 syn queue 与 accept queue 大小。
-
accept queue 最大不能超过
net.core.somaxconn
的值,即:-
max accept queue size = min(backlog, net.core.somaxconn)
-
解决方案:调大全连接队列大小,需要调节的参数有两个:backlog, net.core.somaxconn
somaxconn 的默认值很小
我们再看下之前 ss -lnt
的输出:
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 128 *:80 *:*
仔细一看,Send-Q
表示 accept queue 最大的大小,才 128 ?也太小了吧!
根据前面的介绍我们知道,accept queue 的最大大小会受 net.core.somaxconn
内核参数的限制,我们看下 pod 所在节点上这个内核参数的大小:
$ cat /proc/sys/net/core/somaxconn
32768
是 32768,挺大的,为什么这里 accept queue 最大大小就只有 128 了呢?
net.core.somaxconn
这个内核参数是 namespace 隔离了的,我们在容器 netns 中再确认了下:
$ cat /proc/sys/net/core/somaxconn
128
为什么只有 128?看下 stackoverflow 这里 的讨论:
The "net/core" subsys is registered per network namespace. And the initial value for somaxconn is set to 128.
原来新建的 netns 中 somaxconn 默认就为 128,在 include/linux/socket.h
中可以看到这个常量的定义:
/* Maximum queue length specifiable by listen. */
#define SOMAXCONN 128
很多人在使用 k8s 时都没太在意这个参数,为什么大家平常在较高并发下也没发现有问题呢?
因为通常进程 accept()
都是很快的,所以一般 accept queue 基本都没什么积压的数据,也就不会溢出导致丢包了。
对于并发量很高的应用,还是建议将 somaxconn 调高。虽然可以进入容器 netns 后使用 sysctl -w net.core.somaxconn=1024
或 echo 1024 > /proc/sys/net/core/somaxconn
临时调整,但调整的意义不大,因为容器内的进程一般在启动的时候才会调用 listen()
,然后 accept queue 的大小就被决定了,并且不再改变。
四.解决问题
第一步:调节内核参数somaxconn的默认值?
方式一: 使用 k8s sysctls 特性直接给 pod 指定内核参数
示例 yaml:
apiVersion: v1
kind: Pod
metadata:
name: sysctl-example
spec:
securityContext:
sysctls:
- name: net.core.somaxconn
value: "8096"
有些参数是 unsafe
类型的,不同环境不一样,我的环境里是可以直接设置 pod 的 net.core.somaxconn
这个 sysctl 的。如果你的环境不行,请参考官方文档 Using sysctls in a Kubernetes Cluster 启用 unsafe
类型的 sysctl。
方式二: 使用 initContainers 设置内核参数
示例 yaml:
apiVersion: v1
kind: Pod
metadata:
name: sysctl-example-init
spec:
initContainers:
- image: busybox
command:
- sh
- -c
- echo 1024 > /proc/sys/net/core/somaxconn
imagePullPolicy: Always
name: setsysctl
securityContext:
privileged: true
Containers:
...
方式三: 安装 tuning CNI 插件统一设置 sysctl
tuning plugin 地址: https://github.com/containernetworking/plugins/tree/master/plugins/meta/tuning
CNI 配置示例:
{
"name": "mytuning",
"type": "tuning",
"sysctl": {
"net.core.somaxconn": "1024"
}
}
第二步:调节nginx backlog 参数的默认值
支持在配置里改。通过 ngx_http_core_module 的官方文档我们可以看到它在 linux 下的默认值就是 511:
配置示例:
listen 80 default backlog=1024;
五.总结
大致总结一下排除过程:
第一步:猜测是后端服务问题
第二步:抓包,得出结论:确定存在丢包现象
第三步:寻找为什么丢包,得出结论:确定是accept queue满了
第四步:寻找如何accept queue大小由什么决定,是如何计算的?得出结论:全连接队列大小由backlog和内核参数somaxconn决定
第五步:调整nginx的backlog和内核参数somaxconn的大小
所以,在容器中使用 nginx 来支撑高并发的业务时,记得要同时调整下 net.core.somaxconn
内核参数和 nginx.conf
中的 backlog 配置。