Kubernetes 疑难杂症排查分享:神秘的溢出与丢包

作者: 陈鹏

上一篇 Kubernetes 疑难杂症排查分享: 诡异的 No route to host 不小心又爆火,这次继续带来干货,看之前请提前泡好茶,避免口干。

问题描述

有用户反馈大量图片加载不出来。

图片下载走的 k8s ingress,这个 ingress 路径对应后端 service 是一个代理静态图片文件的 nginx deployment,这个 deployment 只有一个副本,静态文件存储在 nfs 上,nginx 通过挂载 nfs 来读取静态文件来提供图片下载服务,所以调用链是:client –> k8s ingress –> nginx –> nfs。

猜测

猜测: ingress 图片下载路径对应的后端服务出问题了。

验证:在 k8s 集群直接 curl nginx 的 pod ip,发现不通,果然是后端服务的问题!

抓包

继续抓包测试观察,登上 nginx pod 所在节点,进入容器的 netns 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 拿到 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 网卡的包:

1
tcpdump -i eth0 -nnnn -ttt port 24568

在其它节点准备使用 nc 指定源端口为 24568 向容器发包:

1
nc -u 24568 172.16.1.21 80

观察抓包结果:

1
2
3
4
5
6
7
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 检查下是否有丢包统计:

1
2
3
$ netstat -s | grep -E 'overflow|drop'
12178939 times the listen queue of a socket overflowed
12247395 SYNs to LISTEN sockets dropped

果然有丢包,为了理解这里的丢包统计,我深入研究了一下,下面插播一些相关知识。

syn queue 与 accept queue

Linux 进程监听端口时,内核会给它对应的 socket 分配两个队列:

  • syn queue: 半连接队列。server 收到 SYN 后,连接会先进入 SYN_RCVD 状态,并放入 syn queue,此队列的包对应还没有完全建立好的连接(TCP 三次握手还没完成)。
  • accept queue: 全连接队列。当 TCP 三次握手完成之后,连接会进入 ESTABELISHED 状态并从 syn queue 移到 accept queue,等待被进程调用 accept() 系统调用 “拿走”。

注意:这两个队列的连接都还没有真正被应用层接收到,当进程调用 accept() 后,连接才会被应用层处理,具体到我们这个问题的场景就是 nginx 处理 HTTP 请求。

为了更好理解,可以看下这张 TCP 连接建立过程的示意图:

listen 与 accept

不管使用什么语言和框架,在写 server 端应用时,它们的底层在监听端口时最终都会调用 listen() 系统调用,处理新请求时都会先调用 accept() 系统调用来获取新的连接,然后再处理请求,只是有各自不同的封装而已,以 go 语言为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 调用 listen 监听端口
l, err := net.Listen("tcp", ":80")
if err != nil {
panic(err)
}
for {
// 不断调用 accept 获取新连接,如果 accept queue 为空就一直阻塞
conn, err := l.Accept()
if err != nil {
log.Println("accept error:", err)
continue
}
// 每来一个新连接意味着一个新请求,启动协程处理请求
go handle(conn)
}

Linux 的 backlog

内核既然给监听端口的 socket 分配了 syn queue 与 accept queue 两个队列,那它们有大小限制吗?可以无限往里面塞数据吗?当然不行! 资源是有限的,尤其是在内核态,所以需要限制一下这两个队列的大小。那么它们的大小是如何确定的呢?我们先来看下 listen 这个系统调用:

1
int listen(int sockfd, int backlog)

可以看到,能够传入一个整数类型的 backlog 参数,我们再通过 man listen 看下解释:

The behavior of the backlog argument on TCP sockets changed with Linux 2.2. Now it specifies the queue length for completely established sockets waiting to be accepted, instead of the number of incomplete connection requests. The maximum length of the queue for incomplete sockets can be set using /proc/sys/net/ipv4/tcp_max_syn_backlog. When syncookies are enabled there is no logical maximum length and this setting is ignored. See tcp(7) for more information.

If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently truncated to that value; the default value in this file is 128. In kernels before 2.4.25, this limit was a hard coded value, SOMAXCONN, with the value 128.

继续深挖了一下源码,结合这里的解释提炼一下:

  • listen 的 backlog 参数同时指定了 socket 的 syn queue 与 accept queue 大小。
  • accept queue 最大不能超过 net.core.somaxconn 的值,即:

    1
    max accept queue size = min(backlog, net.core.somaxconn)
  • 如果启用了 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 满了导致丢包。syncookies 是为了防止 SYN Flood 攻击 (一种常见的 DDoS 方式),攻击原理就是 client 不断发 SYN 包但不回最后的 ACK,填满 server 的 syn queue 从而无法建立新连接,导致 server 拒绝服务。
  • 如果 syncookies 没有启用,syn queue 的大小就有限制,除了跟 accept queue 一样受 net.core.somaxconn 大小限制之外,还会受到 net.ipv4.tcp_max_syn_backlog 的限制,即:
    1
    max syn queue size = min(backlog, net.core.somaxconn, net.ipv4.tcp_max_syn_backlog)

4.3 及其之前版本的内核,syn queue 的大小计算方式跟现在新版内核这里还不一样,详细请参考 commit ef547f2ac16b

队列溢出

毫无疑问,在队列大小有限制的情况下,如果队列满了,再有新连接过来肯定就有问题。

翻下 linux 源码,看下处理 SYN 包的部分,在 net/ipv4/tcp_input.ctcp_conn_request 函数:

1
2
3
4
5
6
7
8
9
10
11
if ((net->ipv4.sysctl_tcp_syncookies == 2 ||
inet_csk_reqsk_queue_is_full(sk)) && !isn) {
want_cookie = tcp_syn_flood_action(sk, rsk_ops->slab_name);
if (!want_cookie)
goto drop;
}

if (sk_acceptq_is_full(sk)) {
NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
goto drop;
}

goto drop 最终会走到 tcp_listendrop 函数,实际上就是将 ListenDrops 计数器 +1:

1
2
3
4
5
static inline void tcp_listendrop(const struct sock *sk)
{
atomic_inc(&((struct sock *)sk)->sk_drops);
__NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENDROPS);
}

大致可以看出来,对于 SYN 包:

  • 如果 syn queue 满了并且没有开启 syncookies 就丢包,并将 ListenDrops 计数器 +1。
  • 如果 accept queue 满了也会丢包,并将 ListenOverflowsListenDrops 计数器 +1。

而我们前面排查问题通过 netstat -s 看到的丢包统计,其实就是对应的 ListenOverflowsListenDrops 这两个计数器。

除了用 netstat -s,还可以使用 nstat -az 直接看系统内各个计数器的值:

1
2
3
$ nstat -az | grep -E 'TcpExtListenOverflows|TcpExtListenDrops'
TcpExtListenOverflows 12178939 0.0
TcpExtListenDrops 12247395 0.0

另外,对于低版本内核,当 accept queue 满了,并不会完全丢弃 SYN 包,而是对 SYN 限速。把内核源码切到 3.10 版本,看 net/ipv4/tcp_ipv4.ctcp_v4_conn_request 函数:

1
2
3
4
5
6
7
8
9
/* Accept backlog is full. If we have already queued enough
* of warm entries in syn queue, drop request. It is better than
* clogging syn queue with openreqs with exponentially increasing
* timeout.
*/
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
goto drop;
}

其中 inet_csk_reqsk_queue_young(sk) > 1 的条件实际就是用于限速,仿佛在对 client 说: 哥们,你慢点!我的 accept queue 都满了,即便咱们握手成功,连接也可能放不进去呀。

回到问题上来

总结之前观察到两个现象:

  • 容器内抓包发现收到 client 的 SYN,但 nginx 没回包。
  • 通过 netstat -s 发现有溢出和丢包的统计 (ListenOverflowsListenDrops)。

根据之前的分析,我们可以推测是 syn queue 或 accept queue 满了。

先检查下 syncookies 配置:

1
2
$ cat /proc/sys/net/ipv4/tcp_syncookies
1

确认启用了 syncookies,所以 syn queue 大小没有限制,不会因为 syn queue 满而丢包,并且即便没开启 syncookies,syn queue 有大小限制,队列满了也不会使 ListenOverflows 计数器 +1。

从计数器结果来看,ListenOverflowsListenDrops 的值差别不大,所以推测很有可能是 accept queue 满了,因为当 accept queue 满了会丢 SYN 包,并且同时将 ListenOverflowsListenDrops 计数器分别 +1。

如何验证 accept queue 满了呢?可以在容器的 netns 中执行 ss -lnt 看下:

1
2
3
$ 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-QRecv-Q 分别表示发送和接收数据包的 buffer。

所以,看这里输出结果可以得知 accept queue 满了,当 Recv-Q 的值比 Send-Q 大 1 时表明 accept queue 溢出了,如果再收到 SYN 包就会丢弃掉。

导致 accept queue 满的原因一般都是因为进程调用 accept() 太慢了,导致大量连接不能被及时 “拿走”。

那么什么情况下进程调用 accept() 会很慢呢?猜测可能是进程连接负载高,处理不过来。

而负载高不仅可能是 CPU 繁忙导致,还可能是 IO 慢导致,当文件 IO 慢时就会有很多 IO WAIT,在 IO WAIT 时虽然 CPU 不怎么干活,但也会占据 CPU 时间片,影响 CPU 干其它活。

最终进一步定位发现是 nginx pod 挂载的 nfs 服务对应的 nfs server 负载较高,导致 IO 延时较大,从而使 nginx 调用 accept() 变慢,accept queue 溢出,使得大量代理静态图片文件的请求被丢弃,也就导致很多图片加载不出来。

虽然根因不是 k8s 导致的问题,但也从中挖出一些在高并发场景下值得优化的点,请继续往下看。

somaxconn 的默认值很小

我们再看下之前 ss -lnt 的输出:

1
2
3
$ 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 所在节点上这个内核参数的大小:

1
2
$ cat /proc/sys/net/core/somaxconn
32768

是 32768,挺大的,为什么这里 accept queue 最大大小就只有 128 了呢?

net.core.somaxconn 这个内核参数是 namespace 隔离了的,我们在容器 netns 中再确认了下:

1
2
$ 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 中可以看到这个常量的定义:

1
2
/* Maximum queue length specifiable by listen.  */
#define SOMAXCONN 128

很多人在使用 k8s 时都没太在意这个参数,为什么大家平常在较高并发下也没发现有问题呢?

因为通常进程 accept() 都是很快的,所以一般 accept queue 基本都没什么积压的数据,也就不会溢出导致丢包了。

对于并发量很高的应用,还是建议将 somaxconn 调高。虽然可以进入容器 netns 后使用 sysctl -w net.core.somaxconn=1024echo 1024 > /proc/sys/net/core/somaxconn 临时调整,但调整的意义不大,因为容器内的进程一般在启动的时候才会调用 listen(),然后 accept queue 的大小就被决定了,并且不再改变。

下面介绍几种调整方式:

方式一: 使用 k8s sysctls 特性直接给 pod 指定内核参数

示例 yaml:

1
2
3
4
5
6
7
8
9
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。

注:此特性在 k8s v1.12 beta,默认开启。

方式二: 使用 initContainers 设置内核参数

示例 yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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:
...

注: init container 需要 privileged 权限。

方式三: 安装 tuning CNI 插件统一设置 sysctl

tuning plugin 地址: https://github.com/containernetworking/plugins/tree/master/plugins/meta/tuning

CNI 配置示例:

1
2
3
4
5
6
7
{
"name": "mytuning",
"type": "tuning",
"sysctl": {
"net.core.somaxconn": "1024"
}
}

nginx 的 backlog

我们使用方式一尝试给 nginx pod 的 somaxconn 调高到 8096 后观察:

1
2
3
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 512 511 *:80 *:*

WTF? 还是溢出了,而且调高了 somaxconn 之后虽然 accept queue 的最大大小 (Send-Q) 变大了,但跟 8096 还差很远呀!

在经过一番研究,发现 nginx 在 listen() 时并没有读取 somaxconn 作为 backlog 默认值传入,它有自己的默认值,也支持在配置里改。通过 ngx_http_core_module 的官方文档我们可以看到它在 linux 下的默认值就是 511:

1
2
backlog=number
sets the backlog parameter in the listen() call that limits the maximum length for the queue of pending connections. By default, backlog is set to -1 on FreeBSD, DragonFly BSD, and macOS, and to 511 on other platforms.

配置示例:

1
listen  80  default  backlog=1024;

所以,在容器中使用 nginx 来支撑高并发的业务时,记得要同时调整下 net.core.somaxconn 内核参数和 nginx.conf 中的 backlog 配置。

参考资料

Kubernetes 疑难杂症排查分享: 诡异的 No route to host

作者: 陈鹏

之前发过一篇干货满满的爆火文章 Kubernetes 网络疑难杂症排查分享,包含多个疑难杂症的排查案例分享,信息量巨大。这次我又带来了续集,只讲一个案例,但信息量也不小,Are you ready ?

问题反馈

有用户反馈 Deployment 滚动更新的时候,业务日志偶尔会报 “No route to host” 的错误。

分析

之前没遇到滚动更新会报 “No route to host” 的问题,我们先看下滚动更新导致连接异常有哪些常见的报错:

  • Connection reset by peer: 连接被重置。通常是连接建立过,但 server 端发现 client 发的包不对劲就返回 RST,应用层就报错连接被重置。比如在 server 滚动更新过程中,client 给 server 发的请求还没完全结束,或者本身是一个类似 grpc 的多路复用长连接,当 server 对应的旧 Pod 删除(没有做优雅结束,停止时没有关闭连接),新 Pod 很快创建启动并且刚好有跟之前旧 Pod 一样的 IP,这时 kube-proxy 也没感知到这个 IP 其实已经被删除然后又被重建了,针对这个 IP 的规则就不会更新,旧的连接依然发往这个 IP,但旧 Pod 已经不在了,后面继续发包时依然转发给这个 Pod IP,最终会被转发到这个有相同 IP 的新 Pod 上,而新 Pod 收到此包时检查报文发现不对劲,就返回 RST 给 client 告知将连接重置。针对这种情况,建议应用自身处理好优雅结束:Pod 进入 Terminating 状态后会发送 SIGTERM 信号给业务进程,业务进程的代码需处理这个信号,在进程退出前关闭所有连接。
  • Connection refused: 连接被拒绝。通常是连接还没建立,client 正在发 SYN 包请求建立连接,但到了 server 之后发现端口没监听,内核就返回 RST 包,然后应用层就报错连接被拒绝。比如在 server 滚动更新过程中,旧的 Pod 中的进程很快就停止了(网卡还未完全销毁),但 client 所在节点的 iptables/ipvs 规则还没更新,包就可能会被转发到了这个停止的 Pod (由于 k8s 的 controller 模式,从 Pod 删除到 service 的 endpoint 更新,再到 kube-proxy watch 到更新并更新 节点上的 iptables/ipvs 规则,这个过程是异步的,中间存在一点时间差,所以有可能存在 Pod 中的进程已经没有监听,但 iptables/ipvs 规则还没更新的情况)。针对这种情况,建议给容器加一个 preStop,在真正销毁 Pod 之前等待一段时间,留时间给 kube-proxy 更新转发规则,更新完之后就不会再有新连接往这个旧 Pod 转发了,preStop 示例:

    1
    2
    3
    4
    5
    6
    7
    lifecycle:
    preStop:
    exec:
    command:
    - /bin/bash
    - -c
    - sleep 30

    另外,还可能是新的 Pod 启动比较慢,虽然状态已经 Ready,但实际上可能端口还没监听,新的请求被转发到这个还没完全启动的 Pod 就会报错连接被拒绝。针对这种情况,建议给容器加就绪检查 (readinessProbe),让容器真正启动完之后才将其状态置为 Ready,然后 kube-proxy 才会更新转发规则,这样就能保证新的请求只被转发到完全启动的 Pod,readinessProbe 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    readinessProbe:
    httpGet:
    path: /healthz
    port: 80
    httpHeaders:
    - name: X-Custom-Header
    value: Awesome
    initialDelaySeconds: 15
    timeoutSeconds: 1
  • Connection timed out: 连接超时。通常是连接还没建立,client 发 SYN 请求建立连接一直等到超时时间都没有收到 ACK,然后就报错连接超时。这个可能场景跟前面 Connection refused 可能的场景类似,不同点在于端口有监听,但进程无法正常响应了: 转发规则还没更新,旧 Pod 的进程正在停止过程中,虽然端口有监听,但已经不响应了;或者转发规则更新了,新 Pod 端口也监听了,但还没有真正就绪,还没有能力处理新请求。针对这些情况的建议跟前面一样:加 preStop 和 readinessProbe。

下面我们来继续分析下滚动更新时发生 No route to host 的可能情况。

这个报错很明显,IP 无法路由,通常是将报文发到了一个已经彻底销毁的 Pod (网卡已经不在)。不可能发到一个网卡还没创建好的 Pod,因为即便不加存活检查,也是要等到 Pod 网络初始化完后才可能 Ready,然后 kube-proxy 才会更新转发规则。

什么情况下会转发到一个已经彻底销毁的 Pod? 借鉴前面几种滚动更新的报错分析,我们推测应该是 Pod 很快销毁了但转发规则还没更新,从而新的请求被转发了这个已经销毁的 Pod,最终报文到达这个 Pod 所在 PodCIDR 的 Node 上时,Node 发现本机已经没有这个 IP 的容器,然后 Node 就返回 ICMP 包告知 client 这个 IP 不可达,client 收到 ICMP 后,应用层就会报错 “No route to host”。

所以根据我们的分析,关键点在于 Pod 销毁太快,转发规则还没来得及更新,导致后来的请求被转发到已销毁的 Pod。针对这种情况,我们可以给容器加一个 preStop,留时间给 kube-proxy 更新转发规则来解决,参考 《Kubernetes实践指南》中的部分章节: https://k8s.imroc.io/best-practice/high-availability-deployment-of-applications#smooth-update-using-prestophook-and-readinessprobe

问题没有解决

我们自己没有复现用户的 “No route to host” 的问题,可能是复现条件比较苛刻,最后将我们上面理论上的分析结论作为解决方案给到了用户。

但用户尝试加了 preStop 之后,问题依然存在,服务滚动更新时偶尔还是会出现 “No route to host”。

深入分析

为了弄清楚根本原因,我们请求用户协助搭建了一个可以复现问题的测试环境,最终这个问题在测试环境中可以稳定复现。

仔细观察,实际是部署两个服务:ServiceA 和 ServiceB。使用 ab 压测工具去压测 ServiceA (短连接),然后 ServiceA 会通过 RPC 调用 ServiceB (短连接),滚动更新的是 ServiceB,报错发生在 ServiceA 调用 ServiceB 这条链路。

在 ServiceB 滚动更新期间,新的 Pod Ready 了之后会被添加到 IPVS 规则的 RS 列表,但旧的 Pod 不会立即被踢掉,而是将新的 Pod 权重置为1,旧的置为 0,通过在 client 所在节点查看 IPVS 规则可以看出来:

1
2
3
4
5
6
root@VM-0-3-ubuntu:~# ipvsadm -ln -t 172.16.255.241:80
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 172.16.255.241:80 rr
-> 172.16.8.106:80 Masq 0 5 14048
-> 172.16.8.107:80 Masq 1 2 243

为什么不立即踢掉旧的 Pod 呢?因为要支持优雅结束,让存量的连接处理完,等存量连接全部结束了再踢掉它(ActiveConn+InactiveConn=0),这个逻辑可以通过这里的代码确认:https://github.com/kubernetes/kubernetes/blob/v1.17.0/pkg/proxy/ipvs/graceful_termination.go#L170

然后再通过 ipvsadm -lnc | grep 172.16.8.106 发现旧 Pod 上的连接大多是 TIME_WAIT 状态,这个也容易理解:因为 ServiceA 作为 client 发起短连接请求调用 ServiceB,调用完成就会关闭连接,TCP 三次挥手后进入 TIME_WAIT 状态,等待 2*MSL (2 分钟) 的时长再清理连接。

经过上面的分析,看起来都是符合预期的,那为什么还会出现 “No route to host” 呢?难道权重被置为 0 之后还有新连接往这个旧 Pod 转发?我们来抓包看下:

1
2
3
4
5
6
7
root@VM-0-3-ubuntu:~# tcpdump -i eth0 host 172.16.8.106 -n -tttt
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
2019-12-13 11:49:47.319093 IP 10.0.0.3.36708 > 172.16.8.106.80: Flags [S], seq 3988339656, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0
2019-12-13 11:49:47.319133 IP 10.0.0.3.36706 > 172.16.8.106.80: Flags [S], seq 109196945, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0
2019-12-13 11:49:47.319144 IP 10.0.0.3.36704 > 172.16.8.106.80: Flags [S], seq 1838682063, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0
2019-12-13 11:49:47.319153 IP 10.0.0.3.36702 > 172.16.8.106.80: Flags [S], seq 1591982963, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0

果然是!即使权重为 0,仍然会尝试发 SYN 包跟这个旧 Pod 建立连接,但永远无法收到 ACK,因为旧 Pod 已经销毁了。为什么会这样呢?难道是 IPVS 内核模块的调度算法有问题?尝试去看了下 linux 内核源码,并没有发现哪个调度策略的实现函数会将新连接调度到权重为 0 的 rs 上。

这就奇怪了,可能不是调度算法的问题?继续尝试看更多的代码,主要是 net/netfilter/ipvs/ip_vs_core.c 中的 ip_vs_in 函数,也就是 IPVS 模块处理报文的主要入口,发现它会先在本地连接转发表看这个包是否已经有对应的连接了(匹配五元组),如果有就说明它不是新连接也就不会调度,直接发给这个连接对应的之前已经调度过的 rs (也不会判断权重);如果没匹配到说明这个包是新的连接,就会走到调度这里 (rr, wrr 等调度策略),这个逻辑看起来也没问题。

那为什么会转发到权重为 0 的 rs ?难道是匹配连接这里出问题了?新的连接匹配到了旧的连接?我开始做实验验证这个猜想,修改一下这里的逻辑:检查匹配到的连接对应的 rs 如果权重为 0,则重新调度。然后重新编译和加载 IPVS 内核模块,再重新压测一下,发现问题解决了!没有报 “No route to host” 了。

虽然通过改内核源码解决了,但我知道这不是一个好的解决方案,它会导致 IPVS 不支持连接的优雅结束,因为不再转发包给权重为 0 的 rs,存量的连接就会立即中断。

继续陷入深思……

这个实验只是证明了猜想:新连接匹配到了旧连接。那为什么会这样呢?难道新连接报文的五元组跟旧连接的相同了?

经过一番思考,发现这个是有可能的。因为 ServiceA 作为 client 请求 ServiceB,不同请求的源 IP 始终是相同的,关键点在于源端口是否可能相同。由于 ServiceA 向 ServiceB 发起大量短连接,ServiceA 所在节点就会有大量 TIME_WAIT 状态的连接,需要等 2 分钟 (2*MSL) 才会清理,而由于连接量太大,每次发起的连接都会占用一个源端口,当源端口不够用了,就会重用 TIME_WAIT 状态连接的源端口,这个时候当报文进入 IPVS 模块,检测到它的五元组跟本地连接转发表中的某个连接一致(TIME_WAIT 状态),就以为它是一个存量连接,然后直接将报文转发给这个连接之前对应的 rs 上,然而这个 rs 对应的 Pod 早已销毁,所以抓包看到的现象是将 SYN 发给了旧 Pod,并且无法收到 ACK,伴随着返回 ICMP 告知这个 IP 不可达,也被应用解释为 “No route to host”。

后来无意间又发现一个还在 open 状态的 issue,虽然还没提到 “No route to host” 关键字,但讨论的跟我们这个其实是同一个问题。我也参与了讨论,有兴趣的同学可以看下:https://github.com/kubernetes/kubernetes/issues/81775

总结

这个问题通常发生的场景就是类似于我们测试环境这种:ServiceA 对外提供服务,当外部发起请求,ServiceA 会通过 rpc 或 http 调用 ServiceB,如果外部请求量变大,ServiceA 调用 ServiceB 的量也会跟着变大,大到一定程度,ServiceA 所在节点源端口不够用,复用 TIME_WAIT 状态连接的源端口,导致五元组跟 IPVS 里连接转发表中的 TIME_WAIT 连接相同,IPVS 就认为这是一个存量连接的报文,就不判断权重直接转发给之前的 rs,导致转发到已销毁的 Pod,从而发生 “No route to host”。

如何规避?集群规模小可以使用 iptables 模式,如果需要使用 ipvs 模式,可以增加 ServiceA 的副本,并且配置反亲和性 (podAntiAffinity),让 ServiceA 的 Pod 部署到不同节点,分摊流量,避免流量集中到某一个节点,导致调用 ServiceB 时源端口复用。

如何彻底解决?暂时还没有一个完美的方案。

Issue 85517 讨论让 kube-proxy 支持自定义配置几种连接状态的超时时间,但这对 TIME_WAIT 状态无效。

Issue 81308 讨论 IVPS 的优雅结束是否不考虑不活跃的连接 (包括 TIME_WAIT 状态的连接),也就是只考虑活跃连接,当活跃连接数为 0 之后立即踢掉 rs。这个确实可以更快的踢掉 rs,但无法让优雅结束做到那么优雅了,并且有人测试了,即便是不考虑不活跃连接,当请求量很大,还是不能很快踢掉 rs,因为源端口复用还是会导致不断有新的连接占用旧的连接,在较新的内核版本,SYN_RECV 状态也被视为活跃连接,所以活跃连接数还是不会很快降到 0。

这个问题的终极解决方案该走向何方,我们拭目以待,感兴趣的同学可以持续关注 issue 81775 并参与讨论。想学习更多 K8S 知识,可以关注本人的开源书《Kubernetes实践指南》: https://k8s.imroc.io

k8s v1.17 新特性预告: 拓扑感知服务路由

作者: 陈鹏

大家好,我是 roc,来自腾讯云容器服务(TKE)团队,今天给大家介绍下我参与开发的一个 k8s v1.17 新特性: 拓扑感知服务路由。

名词解释

  • 拓扑域: 表示在集群中的某一类 “地方”,比如某节点、某机架、某可用区或某地域等,这些都可以作为某种拓扑域。
  • endpoint: k8s 某个服务的某个 ip+port,通常是 pod 的 ip+port。
  • service: k8s 的 service 资源(服务),关联一组 endpoint ,访问 service 会被转发到关联的某个 endpoint 上。

背景

拓扑感知服务路由,此特性最初由杜军大佬提出并设计。为什么要设计此特性呢?想象一下,k8s 集群节点分布在不同的地方,service 对应的 endpoints 分布在不同节点,传统转发策略会对所有 endpoint 做负载均衡,通常会等概率转发,当访问 service 时,流量就可能被分散打到这些不同的地方。虽然 service 转发做了负载均衡,但如果 endpoint 距离比较远,流量转发过去网络时延就相对比较高,会影响网络性能,在某些情况下甚至还可能会付出额外的流量费用。要是如能实现 service 就近转发 endpoint,是不是就可以实现降低网络时延,提升网络性能了呢?是的!这也正是该特性所提出的目的和意义。

k8s 亲和性

service 的就近转发实际就是一种网络的亲和性,倾向于转发到离自己比较近的 endpoint。在此特性之前,已经在调度和存储方面有一些亲和性的设计与实现:

  • 节点亲和性 (Node Affinity): 让 Pod 被调度到符合一些期望条件的 Node 上,比如限制调度到某一可用区,或者要求节点支持 GPU,这算是调度亲和,调度结果取决于节点属性。
  • Pod 亲和性与反亲和性 (Pod Affinity/AntiAffinity): 让一组 Pod 调度到同一拓扑域的节点上,或者打散到不同拓扑域的节点, 这也算是调度亲和,调度结果取决于其它 Pod。
  • 数据卷拓扑感知调度 (Volume Topology-aware Scheduling): 让 Pod 只被调度到符合其绑定的存储所在拓扑域的节点上,这算是调度与存储的亲和,调度结果取决于存储的拓扑域。
  • 本地数据卷 (Local Persistent Volume): 让 Pod 使用本地数据卷,比如高性能 SSD,在某些需要高 IOPS 低时延的场景很有用,它还会保证 Pod 始终被调度到同一节点,数据就不会不丢失,这也算是调度与存储的亲和,调度结果取决于存储所在节点。
  • 数据卷拓扑感知动态创建 (Topology-Aware Volume Dynamic Provisioning): 先调度 Pod,再根据 Pod 所在节点的拓扑域来创建存储,这算是存储与调度的亲和,存储的创建取决于调度的结果。

而 k8s 目前在网络方面还没有亲和性能力,拓扑感知服务路由这个新特性恰好可以补齐这个的空缺,此特性使得 service 可以实现就近转发而不是所有 endpoint 等概率转发。

如何实现

我们知道,service 转发主要是 node 上的 kube-proxy 进程通过 watch apiserver 获取 service 对应的 endpoint,再写入 iptables 或 ipvs 规则来实现的; 对于 headless service,主要是通过 kube-dns 或 coredns 动态解析到不同 endpoint ip 来实现的。实现 service 就近转发的关键点就在于如何将流量转发到跟当前节点在同一拓扑域的 endpoint 上,也就是会进行一次 endpoint 筛选,选出一部分符合当前节点拓扑域的 endpoint 进行转发。

那么如何判断 endpoint 跟当前节点是否在同一拓扑域里呢?只要能获取到 endpoint 的拓扑信息,用它跟当前节点拓扑对比下就可以知道了。那又如何获取 endpoint 的拓扑信息呢?答案是通过 endpoint 所在节点的 label,我们可以使用 node label 来描述拓扑域。

通常在节点初始化的时候,controller-manager 就会为节点打上许多 label,比如 kubernetes.io/hostname 表示节点的 hostname 来区分节点;另外,在云厂商提供的 k8s 服务,或者使用 cloud-controller-manager 的自建集群,通常还会给节点打上 failure-domain.beta.kubernetes.io/zonefailure-domain.beta.kubernetes.io/region 以区分节点所在可用区和所在地域,但自 v1.17 开始将会改名成 topology.kubernetes.io/zonetopology.kubernetes.io/region,参见 PR #81431

如何根据 endpoint 查到它所在节点的这些 label 呢?答案是通过 Endpoint Slice,该特性在 v1.16 发布了 alpha,在 v1.17 将会进入 beta,它相当于 Endpoint API 增强版,通过将 endpoint 做数据分片来解决大规模 endpoint 的性能问题,并且可以携带更多的信息,包括 endpoint 所在节点的拓扑信息,拓扑感知服务路由特性会通过 Endpoint Slice 获取这些拓扑信息实现 endpoint 筛选 (过滤出在同一拓扑域的 endpoint),然后再转换为 iptables 或 ipvs 规则写入节点以实现拓扑感知的路由转发。

细心的你可能已经发现,之前每个节点上转发 service 的 iptables/ipvs 规则基本是一样的,但启用了拓扑感知服务路由特性之后,每个节点上的转发规则就可能不一样了,因为不同节点的拓扑信息不一样,导致过滤出的 endpoint 就不一样,也正是因为这样,service 转发变得不再等概率,灵活的就近转发才得以实现。

当前还不支持 headless service 的拓扑路由,计划在 beta 阶段支持。由于 headless service 不是通过 kube-proxy 生成转发规则,而是通过 dns 动态解析实现的,所以需要改 kube-dns/coredns 来支持这个特性。

前提条件

启用当前 alpha 实现的拓扑感知服务路由特性需要满足以下前提条件:

  • 集群版本在 v1.17 及其以上。
  • Kube-proxy 以 iptables 或 IPVS 模式运行 (alpha 阶段暂时只实现了这两种模式)。
  • 启用了 Endpoint Slices (此特性虽然在 v1.17 进入 beta,但没有默认开启)。

如何启用此特性

给所有 k8s 组件打开 ServiceTopologyEndpointSlice 这两个 feature:

1
--feature-gates="ServiceTopology=true,EndpointSlice=true"

如何使用

在 Service spec 里加上 topologyKeys 字段,表示该 Service 优先顺序选用的拓扑域列表,对应节点标签的 key;当访问此 Service 时,会找是否有 endpoint 有对应 topology key 的拓扑信息并且 value 跟当前节点也一样,如果是,那就选定此 topology key 作为当前转发的拓扑域,并且筛选出其余所有在这个拓扑域的 endpoint 来进行转发;如果没有找到任何 endpoint 在当前 topology key 对应拓扑域,就会尝试第二个 topology key,依此类推;如果遍历完所有 topology key 也没有匹配到 endpoint 就会拒绝转发,就像此 service 没有后端 endpoint 一样。

有一个特殊的 topology key “*“,它可以匹配所有 endpoint,如果 topologyKeys 包含了 *,它必须在列表末尾,通常是在没有匹配到合适的拓扑域来实现就近转发时,就打消就近转发的念头,可以转发到任意 endpoint 上。

当前 topology key 支持以下可能的值(未来会增加更多):

  • kubernetes.io/hostname: 节点的 hostname,通常将它放列表中第一个,表示如果本机有 endpoint 就直接转发到本机的 endpoint。
  • topology.kubernetes.io/zone: 节点所在的可用区,通常将它放在 kubernetes.io/hostname 后面,表示如果本机没有对应 endpoint,就转发到当前可用区其它节点上的 endpoint(部分云厂商跨可用区通信会收取额外的流量费用)。
  • topology.kubernetes.io/region: 表示节点所在的地域,表示转发到当前地域的 endpoint,这个用的应该会比较少,因为通常集群所有节点都只会在同一个地域,如果节点跨地域了,节点之间通信延时将会很高。
  • *: 忽略拓扑域,匹配所有 endpoint,相当于一个保底策略,避免丢包,只能放在列表末尾。

除此之外,还有以下约束:

  • topologyKeysexternalTrafficPolicy=Local 不兼容,是互斥的,如果 externalTrafficPolicyLocal,就不能定义 topologyKeys,反之亦然。
  • topology key 必须是合法的 label 格式,并且最多定义 16 个 key。

这里给出一个简单的 Service 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: nginx
spec:
type: ClusterIP
ports:
- name: http
port: 80
protocol: TCP
targetPort: 80
selector:
app: nginx
topologyKeys: ["kubernetes.io/hostname", "topology.kubernetes.io/zone", "*"]

解释: 当访问 nginx 服务时,首先看本机是否有这个服务的 endpoint,如果有就直接本机路由过去;如果没有,就看是否有 endpoint 位于当前节点所在可用区,如果有,就转发过去,如果还是没有,就转发给任意 endpoint。

上图就是其中一次转发的例子:Pod 访问 nginx 这个 service 时,发现本机没有 endpoint,就找当前可用区的,找到了就转发过去,也就不会考虑转发给另一可用区的 endpoint。

背后小故事

此特性的 KEP Proposal 最终被认可(合并)时的设计与当前最终的代码实现已经有一些差别,实现方案历经一变再变,但同时也推动了其它特性的发展,我来讲下这其中的故事。

一开始设计是在 alpha 时,让 kube-proxy 直接暴力 watch node,每个节点都有一份全局的 node 的缓存,通过 endpoint 的 nodeName 字段找到对应的 node 缓存,再查 node 包含的 label 就可以知道该 endpoint 的拓扑域了,但在集群节点数量多的情况下,kube-proxy 将会消耗大量资源,不过优点是实现上很简单,可以作为 alpha 阶段的实现,beta 时再从 watch node 切换到 watch 一个新设计的 PodLocator API,作为拓扑信息存储的中介,避免 watch 庞大的 node。

实际上一开始我也是按照 watch node 的方式,花了九牛二虎之力终于实现了这个特性,后来 v1.15 时 k8s 又支持了 metadata-only watch,参见 PR 71548,利用此特性可以仅仅 watch node 的 metadata,而不用 watch 整个 node,可以极大减小传输和缓存的数据量,然后我就将实现切成了 watch node metadata; 即便如此,metadata 还是会更新比较频繁,主要是 resourceVersion 会经常变 (kubelet 经常上报 node 状态),所以虽然 watch node metadata 比 watch node 要好,但也还是可能会造成大量不必要的网络流量,但作为 alpha 实现是可以接受的。

可惜在 v1.16 code freeze 之前没能将此特性合进去,只因有一点小细节还没讨论清楚。 实际在实现 watch node 方案期间,Endpoint Slice 特性就提出来了,在这个特性讨论的阶段,我们就想到了可以利用它来携带拓扑信息,以便让拓扑感知服务路由这个特性后续可以直接利用 Endpoint Slice 来获取拓扑信息,也就可以替代之前设计的 PodLocator API,但由于它还处于很早期阶段,并且代码还未合并进去,所以 alpha 阶段先不考虑 watch Endpint Slice。后来,Endpoint Slice 特性在 v1.16 发布了 alpha。

由于 v1.16 没能将拓扑感知服务路由特性合进去,在 v1.17 周期开始后,有更多时间来讨论小细节,并且 Endpoint Slice 代码已经合并,我就干脆直接又将实现从 watch node metadata 切成了 watch Endpint Slice,在 alpha 阶段就做了打算在 beta 阶段做的事情,终于,此特性实现代码最终合进了主干。

结尾

拓扑感知服务路由可以实现 service 就近转发,减少网络延时,进一步提升 k8s 的网络性能,此特性将于 k8s v1.17 发布 alpha,时间是 12 月上旬,让我们一起期待吧!k8s 网络是块难啃的硬骨头,感兴趣的同学可以看下杜军的新书 《Kubernetes 网络权威指南》,整理巩固一下 k8s 的网络知识。

参考资料

Kubernetes 网络疑难杂症排查分享

作者: 陈鹏

大家好,我是 roc,来自腾讯云容器服务(TKE)团队,经常帮助用户解决各种 K8S 的疑难杂症,积累了比较丰富的经验,本文分享几个比较复杂的网络方面的问题排查和解决思路,深入分析并展开相关知识,信息量巨大,相关经验不足的同学可能需要细细品味才能消化,我建议收藏本文反复研读,当完全看懂后我相信你的功底会更加扎实,解决问题的能力会大大提升。

本文发现的问题是在使用 TKE 时遇到的,不同厂商的网络环境可能不一样,文中会对不同的问题的网络环境进行说明

跨 VPC 访问 NodePort 经常超时

现象: 从 VPC a 访问 VPC b 的 TKE 集群的某个节点的 NodePort,有时候正常,有时候会卡住直到超时。

原因怎么查?

当然是先抓包看看啦,抓 server 端 NodePort 的包,发现异常时 server 能收到 SYN,但没响应 ACK:

反复执行 netstat -s | grep LISTEN 发现 SYN 被丢弃数量不断增加:

分析:

  • 两个VPC之间使用对等连接打通的,CVM 之间通信应该就跟在一个内网一样可以互通。
  • 为什么同一 VPC 下访问没问题,跨 VPC 有问题? 两者访问的区别是什么?

再仔细看下 client 所在环境,发现 client 是 VPC a 的 TKE 集群节点,捋一下:

  • client 在 VPC a 的 TKE 集群的节点
  • server 在 VPC b 的 TKE 集群的节点

因为 TKE 集群中有个叫 ip-masq-agent 的 daemonset,它会给 node 写 iptables 规则,默认 SNAT 目的 IP 是 VPC 之外的报文,所以 client 访问 server 会做 SNAT,也就是这里跨 VPC 相比同 VPC 访问 NodePort 多了一次 SNAT,如果是因为多了一次 SNAT 导致的这个问题,直觉告诉我这个应该跟内核参数有关,因为是 server 收到包没回包,所以应该是 server 所在 node 的内核参数问题,对比这个 node 和 普通 TKE node 的默认内核参数,发现这个 node net.ipv4.tcp_tw_recycle = 1,这个参数默认是关闭的,跟用户沟通后发现这个内核参数确实在做压测的时候调整过。

解释一下,TCP 主动关闭连接的一方在发送最后一个 ACK 会进入 TIME_AWAIT 状态,再等待 2 个 MSL 时间后才会关闭(因为如果 server 没收到 client 第四次挥手确认报文,server 会重发第三次挥手 FIN 报文,所以 client 需要停留 2 MSL的时长来处理可能会重复收到的报文段;同时等待 2 MSL 也可以让由于网络不通畅产生的滞留报文失效,避免新建立的连接收到之前旧连接的报文),了解更详细的过程请参考 TCP 四次挥手。

参数 tcp_tw_recycle 用于快速回收 TIME_AWAIT 连接,通常在增加连接并发能力的场景会开启,比如发起大量短连接,快速回收可避免 tw_buckets 资源耗尽导致无法建立新连接 (time wait bucket table overflow)

查得 tcp_tw_recycle 有个坑,在 RFC1323 有段描述:

An additional mechanism could be added to the TCP, a per-host cache of the last timestamp received from any connection. This value could then be used in the PAWS mechanism to reject old duplicate segments from earlier incarnations of the connection, if the timestamp clock can be guaranteed to have ticked at least once since the old connection was open. This would require that the TIME-WAIT delay plus the RTT together must be at least one tick of the sender’s timestamp clock. Such an extension is not part of the proposal of this RFC.

大概意思是说 TCP 有一种行为,可以缓存每个连接最新的时间戳,后续请求中如果时间戳小于缓存的时间戳,即视为无效,相应的数据包会被丢弃。

Linux 是否启用这种行为取决于 tcp_timestampstcp_tw_recycle,因为 tcp_timestamps 缺省开启,所以当 tcp_tw_recycle 被开启后,实际上这种行为就被激活了,当客户端或服务端以 NAT 方式构建的时候就可能出现问题。

当多个客户端通过 NAT 方式联网并与服务端交互时,服务端看到的是同一个 IP,也就是说对服务端而言这些客户端实际上等同于一个,可惜由于这些客户端的时间戳可能存在差异,于是乎从服务端的视角看,便可能出现时间戳错乱的现象,进而直接导致时间戳小的数据包被丢弃。如果发生了此类问题,具体的表现通常是是客户端明明发送的 SYN,但服务端就是不响应 ACK。

回到我们的问题上,client 所在节点上可能也会有其它 pod 访问到 server 所在节点,而它们都被 SNAT 成了 client 所在节点的 NODE IP,但时间戳存在差异,server 就会看到时间戳错乱,因为开启了 tcp_tw_recycletcp_timestamps 激活了上述行为,就丢掉了比缓存时间戳小的报文,导致部分 SYN 被丢弃,这也解释了为什么之前我们抓包发现异常时 server 收到了 SYN,但没有响应 ACK,进而说明为什么 client 的请求部分会卡住直到超时。

由于 tcp_tw_recycle 坑太多,在内核 4.12 之后已移除: remove tcp_tw_recycle

LB 压测 CPS 低

现象: LoadBalancer 类型的 Service,直接压测 NodePort CPS 比较高,但如果压测 LB CPS 就很低。

环境说明: 用户使用的黑石TKE,不是公有云TKE,黑石的机器是物理机,LB的实现也跟公有云不一样,但 LoadBalancer 类型的 Service 的实现同样也是 LB 绑定各节点的 NodePort,报文发到 LB 后转到节点的 NodePort, 然后再路由到对应 pod,而测试在公有云 TKE 环境下没有这个问题。

client 抓包: 大量SYN重传。

server 抓包: 抓 NodePort 的包,发现当 client SYN 重传时 server 能收到 SYN 包但没有响应。

又是 SYN 收到但没响应,难道又是开启 tcp_tw_recycle 导致的?检查节点的内核参数发现并没有开启,除了这个原因,还会有什么情况能导致被丢弃?

conntrack -S 看到 insert_failed 数量在不断增加,也就是 conntrack 在插入很多新连接的时候失败了,为什么会插入失败?什么情况下会插入失败?

挖内核源码: netfilter conntrack 模块为每个连接创建 conntrack 表项时,表项的创建和最终插入之间还有一段逻辑,没有加锁,是一种乐观锁的过程。conntrack 表项并发刚创建时五元组不冲突的话可以创建成功,但中间经过 NAT 转换之后五元组就可能变成相同,第一个可以插入成功,后面的就会插入失败,因为已经有相同的表项存在。比如一个 SYN 已经做了 NAT 但是还没到最终插入的时候,另一个 SYN 也在做 NAT,因为之前那个 SYN 还没插入,这个 SYN 做 NAT 的时候就认为这个五元组没有被占用,那么它 NAT 之后的五元组就可能跟那个还没插入的包相同。

在我们这个问题里实际就是 netfilter 做 SNAT 时源端口选举冲突了,黑石 LB 会做 SNAT,SNAT 时使用了 16 个不同 IP 做源,但是短时间内源 Port 却是集中一致的,并发两个 SYN a 和SYN b,被 LB SNAT 后源 IP 不同但源 Port 很可能相同,这里就假设两个报文被 LB SNAT 之后它们源 IP 不同源 Port 相同,报文同时到了节点的 NodePort 会再次做 SNAT 再转发到对应的 Pod,当报文到了 NodePort 时,这时它们五元组不冲突,netfilter 为它们分别创建了 conntrack 表项,SYN a 被节点 SNAT 时默认行为是 从 port_range 范围的当前源 Port 作为起始位置开始循环遍历,选举出没有被占用的作为源 Port,因为这两个 SYN 源 Port 相同,所以它们源 Port 选举的起始位置相同,当 SYN a 选出源 Port 但还没将 conntrack 表项插入时,netfilter 认为这个 Port 没被占用就很可能给 SYN b 也选了相同的源 Port,这时他们五元组就相同了,当 SYN a 的 conntrack 表项插入后再插入 SYN b 的 conntrack 表项时,发现已经有相同的记录就将 SYN b 的 conntrack 表项丢弃了。

解决方法探索: 不使用源端口选举,在 iptables 的 MASQUERADE 规则如果加 --random-fully 这个 flag 可以让端口选举完全随机,基本上能避免绝大多数的冲突,但也无法完全杜绝。最终决定开发 LB 直接绑 Pod IP,不基于 NodePort,从而避免 netfilter 的 SNAT 源端口冲突问题。

DNS 解析偶尔 5S 延时

网上一搜,是已知问题,仔细分析,实际跟之前黑石 TKE 压测 LB CPS 低的根因是同一个,都是因为 netfilter conntrack 模块的设计问题,只不过之前发生在 SNAT,这个发生在 DNAT,这里用我的语言来总结下原因:

DNS client (glibc 或 musl libc) 会并发请求 A 和 AAAA 记录,跟 DNS Server 通信自然会先 connect (建立fd),后面请求报文使用这个 fd 来发送,由于 UDP 是无状态协议, connect 时并不会创建 conntrack 表项, 而并发请求的 A 和 AAAA 记录默认使用同一个 fd 发包,这时它们源 Port 相同,当并发发包时,两个包都还没有被插入 conntrack 表项,所以 netfilter 会为它们分别创建 conntrack 表项,而集群内请求 kube-dns 或 coredns 都是访问的CLUSTER-IP,报文最终会被 DNAT 成一个 endpoint 的 POD IP,当两个包被 DNAT 成同一个 IP,最终它们的五元组就相同了,在最终插入的时候后面那个包就会被丢掉,如果 dns 的 pod 副本只有一个实例的情况就很容易发生,现象就是 dns 请求超时,client 默认策略是等待 5s 自动重试,如果重试成功,我们看到的现象就是 dns 请求有 5s 的延时。

参考 weave works 工程师总结的文章: Racy conntrack and DNS lookup timeouts

解决方案一: 使用 TCP 发送 DNS 请求

如果使用 TCP 发 DNS 请求,connect 时就会插入 conntrack 表项,而并发的 A 和 AAAA 请求使用同一个 fd,所以只会有一次 connect,也就只会尝试创建一个 conntrack 表项,也就避免插入时冲突。

resolv.conf 可以加 options use-vc 强制 glibc 使用 TCP 协议发送 DNS query。下面是这个 man resolv.conf中关于这个选项的说明:

1
2
3
use-vc (since glibc 2.14)
Sets RES_USEVC in _res.options. This option forces the
use of TCP for DNS resolutions.

解决方案二: 避免相同五元组 DNS 请求的并发

resolv.conf 还有另外两个相关的参数:

  • single-request-reopen (since glibc 2.9): A 和 AAAA 请求使用不同的 socket 来发送,这样它们的源 Port 就不同,五元组也就不同,避免了使用同一个 conntrack 表项。
  • single-request (since glibc 2.10): A 和 AAAA 请求改成串行,没有并发,从而也避免了冲突。

man resolv.conf 中解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
single-request-reopen (since glibc 2.9)
Sets RES_SNGLKUPREOP in _res.options. The resolver
uses the same socket for the A and AAAA requests. Some
hardware mistakenly sends back only one reply. When
that happens the client system will sit and wait for
the second reply. Turning this option on changes this
behavior so that if two requests from the same port are
not handled correctly it will close the socket and open
a new one before sending the second request.

single-request (since glibc 2.10)
Sets RES_SNGLKUP in _res.options. By default, glibc
performs IPv4 and IPv6 lookups in parallel since
version 2.9. Some appliance DNS servers cannot handle
these queries properly and make the requests time out.
This option disables the behavior and makes glibc
perform the IPv6 and IPv4 requests sequentially (at the
cost of some slowdown of the resolving process).

要给容器的 resolv.conf 加上 options 参数,最方便的是直接在 Pod Spec 里面的 dnsConfig 加 (k8s v1.9 及以上才支持)

1
2
3
4
spec:
dnsConfig:
options:
- name: single-request-reopen

加 options 还有其它一些方法:

  • 在容器的 ENTRYPOINT 或者 CMD 脚本中,执行 /bin/echo 'options single-request-reopen' >> /etc/resolv.conf
  • 在 postStart hook 里加:

    1
    2
    3
    4
    5
    6
    7
    lifecycle:
    postStart:
    exec:
    command:
    - /bin/sh
    - -c
    - "/bin/echo 'options single-request-reopen' >> /etc/resolv.conf"
  • 使用 MutatingAdmissionWebhook,这是 1.9 引入的 Controller,用于对一个指定的资源的操作之前,对这个资源进行变更。 istio 的自动 sidecar 注入就是用这个功能来实现的,我们也可以通过 MutatingAdmissionWebhook 来自动给所有 Pod 注入 resolv.conf 文件,不过需要一定的开发量。

解决方案三: 使用本地 DNS 缓存

仔细观察可以看到前面两种方案是 glibc 支持的,而基于 alpine 的镜像底层库是 musl libc 不是 glibc,所以即使加了这些 options 也没用,这种情况可以考虑使用本地 DNS 缓存来解决,容器的 DNS 请求都发往本地的 DNS 缓存服务(dnsmasq, nscd等),不需要走 DNAT,也不会发生 conntrack 冲突。另外还有个好处,就是避免 DNS 服务成为性能瓶颈。

使用本地DNS缓存有两种方式:

  • 每个容器自带一个 DNS 缓存服务
  • 每个节点运行一个 DNS 缓存服务,所有容器都把本节点的 DNS 缓存作为自己的 nameserver

从资源效率的角度来考虑的话,推荐后一种方式。

Pod 访问另一个集群的 apiserver 有延时

现象:集群 a 的 Pod 内通过 kubectl 访问集群 b 的内网地址,偶尔出现延时的情况,但直接在宿主机上用同样的方法却没有这个问题。

提炼环境和现象精髓:

  1. 在 pod 内将另一个集群 apiserver 的 ip 写到了 hosts,因为 TKE apiserver 开启内网集群外内网访问创建的内网 LB 暂时没有支持自动绑内网 DNS 域名解析,所以集群外的内网访问 apiserver 需要加 hosts
  2. pod 内执行 kubectl 访问另一个集群偶尔延迟 5s,有时甚至10s

观察到 5s 延时,感觉跟之前 conntrack 的丢包导致 dns 解析 5s 延时有关,但是加了 hosts 呀,怎么还去解析域名?

进入 pod netns 抓包: 执行 kubectl 时确实有 dns 解析,并且发生延时的时候 dns 请求没有响应然后做了重试。

看起来延时应该就是之前已知 conntrack 丢包导致 dns 5s 超时重试导致的。但是为什么会去解析域名? 明明配了 hosts 啊,正常情况应该是优先查找 hosts,没找到才去请求 dns 呀,有什么配置可以控制查找顺序?

搜了一下发现: /etc/nsswitch.conf 可以控制,但看有问题的 pod 里没有这个文件。然后观察到有问题的 pod 用的 alpine 镜像,试试其它镜像后发现只有基于 alpine 的镜像才会有这个问题。

再一搜发现: musl libc 并不会使用 /etc/nsswitch.conf ,也就是说 alpine 镜像并没有实现用这个文件控制域名查找优先顺序,瞥了一眼 musl libc 的 gethostbynamegetaddrinfo 的实现,看起来也没有读这个文件来控制查找顺序,写死了先查 hosts,没找到再查 dns。

这么说,那还是该先查 hosts 再查 dns 呀,为什么这里抓包看到是先查的 dns? (如果是先查 hosts 就能命中查询,不会再发起dns请求)

访问 apiserver 的 client 是 kubectl,用 go 写的,会不会是 go 程序解析域名时压根没调底层 c 库的 gethostbynamegetaddrinfo?

搜一下发现果然是这样: go runtime 用 go 实现了 glibc 的 getaddrinfo 的行为来解析域名,减少了 c 库调用 (应该是考虑到减少 cgo 调用带来的的性能损耗)

issue: net: replicate DNS resolution behaviour of getaddrinfo(glibc) in the go dns resolver

翻源码验证下:

Unix 系的 OS 下,除了 openbsd, go runtime 会读取 /etc/nsswitch.conf (net/conf.go):

hostLookupOrder 函数决定域名解析顺序的策略,Linux 下,如果没有 nsswitch.conf 文件就 dns 比 hosts 文件优先 (net/conf.go):

可以看到 hostLookupDNSFiles 的意思是 dns first (net/dnsclient_unix.go):

所以虽然 alpine 用的 musl libc 不是 glibc,但 go 程序解析域名还是一样走的 glibc 的逻辑,而 alpine 没有 /etc/nsswitch.conf 文件,也就解释了为什么 kubectl 访问 apiserver 先做 dns 解析,没解析到再查的 hosts,导致每次访问都去请求 dns,恰好又碰到 conntrack 那个丢包问题导致 dns 5s 延时,在用户这里表现就是 pod 内用 kubectl 访问 apiserver 偶尔出现 5s 延时,有时出现 10s 是因为重试的那次 dns 请求刚好也遇到 conntrack 丢包导致延时又叠加了 5s 。

解决方案:

  1. 换基础镜像,不用 alpine
  2. 挂载 nsswitch.conf 文件 (可以用 hostPath)

DNS 解析异常

现象: 有个用户反馈域名解析有时有问题,看报错是解析超时。

第一反应当然是看 coredns 的 log:

1
2
[ERROR] 2 loginspub.xxxxmobile-inc.net. 
A: unreachable backend: read udp 172.16.0.230:43742->10.225.30.181:53: i/o timeout

这是上游 DNS 解析异常了,因为解析外部域名 coredns 默认会请求上游 DNS 来查询,这里的上游 DNS 默认是 coredns pod 所在宿主机的 resolv.conf 里面的 nameserver (coredns pod 的 dnsPolicy 为 “Default”,也就是会将宿主机里的 resolv.conf 里的 nameserver 加到容器里的 resolv.conf, coredns 默认配置 proxy . /etc/resolv.conf, 意思是非 service 域名会使用 coredns 容器中 resolv.conf 文件里的 nameserver 来解析)

确认了下,超时的上游 DNS 10.225.30.181 并不是期望的 nameserver,VPC 默认 DNS 应该是 180 开头的。看了 coredns 所在节点的 resolv.conf,发现确实多出了这个非期望的 nameserver,跟用户确认了下,这个 DNS 不是用户自己加上去的,添加节点时这个 nameserver 本身就在 resolv.conf 中。

根据内部同学反馈, 10.225.30.181 是广州一台年久失修将被撤裁的 DNS,物理网络,没有 VIP,撤掉就没有了,所以如果 coredns 用到了这台 DNS 解析时就可能 timeout。后面我们自己测试,某些 VPC 的集群确实会有这个 nameserver,奇了怪了,哪里冒出来的?

又试了下直接创建 CVM,不加进 TKE 节点发现没有这个 nameserver,只要一加进 TKE 节点就有了 !!!

看起来是 TKE 的问题,将 CVM 添加到 TKE 集群会自动重装系统,初始化并加进集群成为 K8S 的 node,确认了初始化过程并不会写 resolv.conf,会不会是 TKE 的 OS 镜像问题?尝试搜一下除了 /etc/resolv.conf 之外哪里还有这个 nameserver 的 IP,最后发现 /etc/resolvconf/resolv.conf.d/base 这里面有。

看下 /etc/resolvconf/resolv.conf.d/base 的作用:Ubuntu 的 /etc/resolv.conf 是动态生成的,每次重启都会将 /etc/resolvconf/resolv.conf.d/base 里面的内容加到 /etc/resolv.conf 里。

经确认: 这个文件确实是 TKE 的 Ubuntu OS 镜像里自带的,可能发布 OS 镜像时不小心加进去的。

那为什么有些 VPC 的集群的节点 /etc/resolv.conf 里面没那个 IP 呢?它们的 OS 镜像里也都有那个文件那个 IP 呀。

请教其它部门同学发现:

  • 非 dhcp 子机,cvm 的 cloud-init 会覆盖 /etc/resolv.conf 来设置 dns
  • dhcp 子机,cloud-init 不会设置,而是通过 dhcp 动态下发
  • 2018 年 4 月 之后创建的 VPC 就都是 dhcp 类型了的,比较新的 VPC 都是 dhcp 类型的

真相大白:/etc/resolv.conf 一开始内容都包含 /etc/resolvconf/resolv.conf.d/base 的内容,也就是都有那个不期望的 nameserver,但老的 VPC 由于不是 dhcp 类型,所以 cloud-init 会覆盖 /etc/resolv.conf,抹掉了不被期望的 nameserver,而新创建的 VPC 都是 dhcp 类型,cloud-init 不会覆盖 /etc/resolv.conf,导致不被期望的 nameserver 残留在了 /etc/resolv.conf,而 coredns pod 的 dnsPolicy 为 “Default”,也就是会将宿主机的 /etc/resolv.conf 中的 nameserver 加到容器里,coredns 解析集群外的域名默认使用这些 nameserver 来解析,当用到那个将被撤裁的 nameserver 就可能 timeout。

临时解决: 删掉 /etc/resolvconf/resolv.conf.d/base 重启

长期解决: 我们重新制作 TKE Ubuntu OS 镜像然后发布更新

这下应该没问题了吧,But, 用户反馈还是会偶尔解析有问题,但现象不一样了,这次并不是 dns timeout。

用脚本跑测试仔细分析现象:

  • 请求 loginspub.xxxxmobile-inc.net 时,偶尔提示域名无法解析
  • 请求 accounts.google.com 时,偶尔提示连接失败

进入 dns 解析偶尔异常的容器的 netns 抓包:

  • dns 请求会并发请求 A 和 AAAA 记录
  • 测试脚本发请求打印序号,抓包然后 wireshark 分析对比异常时请求序号偏移量,找到异常时的 dns 请求报文,发现异常时 A 和 AAAA 记录的请求 id 冲突,并且 AAAA 响应先返回

正常情况下id不会冲突,这里冲突了也就能解释这个 dns 解析异常的现象了:

  • loginspub.xxxxmobile-inc.net 没有 AAAA (ipv6) 记录,它的响应先返回告知 client 不存在此记录,由于请求 id 跟 A 记录请求冲突,后面 A 记录响应返回了 client 发现 id 重复就忽略了,然后认为这个域名无法解析
  • accounts.google.com 有 AAAA 记录,响应先返回了,client 就拿这个记录去尝试请求,但当前容器环境不支持 ipv6,所以会连接失败

那为什么 dns 请求 id 会冲突?

继续观察发现: 其它节点上的 pod 不会复现这个问题,有问题这个节点上也不是所有 pod 都有这个问题,只有基于 alpine 镜像的容器才有这个问题,在此节点新起一个测试的 alpine:latest 的容器也一样有这个问题。

为什么 alpine 镜像的容器在这个节点上有问题在其它节点上没问题? 为什么其他镜像的容器都没问题?它们跟 alpine 的区别是什么?

发现一点区别: alpine 使用的底层 c 库是 musl libc,其它镜像基本都是 glibc

翻 musl libc 源码, 构造 dns 请求时,请求 id 的生成没加锁,而且跟当前时间戳有关:

看注释,作者应该认为这样id基本不会冲突,事实证明,绝大多数情况确实不会冲突,我在网上搜了很久没有搜到任何关于 musl libc 的 dns 请求 id 冲突的情况。这个看起来取决于硬件,可能在某种类型硬件的机器上运行,短时间内生成的 id 就可能冲突。我尝试跟用户在相同地域的集群,添加相同配置相同机型的节点,也复现了这个问题,但后来删除再添加时又不能复现了,看起来后面新建的 cvm 又跑在了另一种硬件的母机上了。

OK,能解释通了,再底层的细节就不清楚了,我们来看下解决方案:

  • 换基础镜像 (不用alpine)
  • 完全静态编译业务程序(不依赖底层c库),比如go语言程序编译时可以关闭 cgo (CGO_ENABLED=0),并告诉链接器要静态链接 (go build 后面加 -ldflags '-d'),但这需要语言和编译工具支持才可以

最终建议用户基础镜像换成另一个比较小的镜像: debian:stretch-slim

问题解决,但用户后面觉得 debian:stretch-slim 做出来的镜像太大了,有 6MB 多,而之前基于 alpine 做出来只有 1MB 多,最后使用了一个非官方的修改过 musl libc 的 alpine 镜像作为基础镜像,里面禁止了 AAAA 请求从而避免这个问题。

Pod 偶尔存活检查失败

现象: Pod 偶尔会存活检查失败,导致 Pod 重启,业务偶尔连接异常。

之前从未遇到这种情况,在自己测试环境尝试复现也没有成功,只有在用户这个环境才可以复现。这个用户环境流量较大,感觉跟连接数或并发量有关。

用户反馈说在友商的环境里没这个问题。

对比友商的内核参数发现有些区别,尝试将节点内核参数改成跟友商的一样,发现问题没有复现了。

再对比分析下内核参数差异,最后发现是 backlog 太小导致的,节点的 net.ipv4.tcp_max_syn_backlog 默认是 1024,如果短时间内并发新建 TCP 连接太多,SYN 队列就可能溢出,导致部分新连接无法建立。

解释一下:

TCP 连接建立会经过三次握手,server 收到 SYN 后会将连接加入 SYN 队列,当收到最后一个 ACK 后连接建立,这时会将连接从 SYN 队列中移动到 ACCEPT 队列。在 SYN 队列中的连接都是没有建立完全的连接,处于半连接状态。如果 SYN 队列比较小,而短时间内并发新建的连接比较多,同时处于半连接状态的连接就多,SYN 队列就可能溢出,tcp_max_syn_backlog 可以控制 SYN 队列大小,用户节点的 backlog 大小默认是 1024,改成 8096 后就可以解决问题。

访问 externalTrafficPolicy 为 Local 的 Service 对应 LB 有时超时

现象:用户在 TKE 创建了公网 LoadBalancer 类型的 Service,externalTrafficPolicy 设为了 Local,访问这个 Service 对应的公网 LB 有时会超时。

externalTrafficPolicy 为 Local 的 Service 用于在四层获取客户端真实源 IP,官方参考文档:Source IP for Services with Type=LoadBalancer

TKE 的 LoadBalancer 类型 Service 实现是使用 CLB 绑定所有节点对应 Service 的 NodePort,CLB 不做 SNAT,报文转发到 NodePort 时源 IP 还是真实的客户端 IP,如果 NodePort 对应 Service 的 externalTrafficPolicy 不是 Local 的就会做 SNAT,到 pod 时就看不到客户端真实源 IP 了,但如果是 Local 的话就不做 SNAT,如果本机 node 有这个 Service 的 endpoint 就转到对应 pod,如果没有就直接丢掉,因为如果转到其它 node 上的 pod 就必须要做 SNAT,不然无法回包,而 SNAT 之后就无法获取真实源 IP 了。

LB 会对绑定节点的 NodePort 做健康检查探测,检查 LB 的健康检查状态: 发现这个 NodePort 的所有节点都不健康 !!!

那么问题来了:

  1. 为什么会全不健康,这个 Service 有对应的 pod 实例,有些节点上是有 endpoint 的,为什么它们也不健康?
  2. LB 健康检查全不健康,但是为什么有时还是可以访问后端服务?

跟 LB 的同学确认: 如果后端 rs 全不健康会激活 LB 的全死全活逻辑,也就是所有后端 rs 都可以转发。

那么有 endpoint 的 node 也是不健康这个怎么解释?

在有 endpoint 的 node 上抓 NodePort 的包: 发现很多来自 LB 的 SYN,但是没有响应 ACK。

看起来报文在哪被丢了,继续抓下 cbr0 看下: 发现没有来自 LB 的包,说明报文在 cbr0 之前被丢了。

再观察用户集群环境信息:

  1. k8s 版本1.12
  2. 启用了 ipvs
  3. 只有 local 的 service 才有异常

尝试新建一个 1.12 启用 ipvs 和一个没启用 ipvs 的测试集群。也都创建 Local 的 LoadBalancer Service,发现启用 ipvs 的测试集群复现了那个问题,没启用 ipvs 的集群没这个问题。

再尝试创建 1.10 的集群,也启用 ipvs,发现没这个问题。

看起来跟集群版本和是否启用 ipvs 有关。

1.12 对比 1.10 启用 ipvs 的集群: 1.12 的会将 LB 的 EXTERNAL-IP 绑到 kube-ipvs0 上,而 1.10 的不会:

1
2
3
$ ip a show kube-ipvs0 | grep -A2 170.106.134.124
inet 170.106.134.124/32 brd 170.106.134.124 scope global kube-ipvs0
valid_lft forever preferred_lft forever

  • 170.106.134.124 是 LB 的公网 IP
  • 1.12 启用 ipvs 的集群将 LB 的公网 IP 绑到了 kube-ipvs0 网卡上

kube-ipvs0 是一个 dummy interface,实际不会接收报文,可以看到它的网卡状态是 DOWN,主要用于绑 ipvs 规则的 VIP,因为 ipvs 主要工作在 netfilter 的 INPUT 链,报文通过 PREROUTING 链之后需要决定下一步该进入 INPUT 还是 FORWARD 链,如果是本机 IP 就会进入 INPUT,如果不是就会进入 FORWARD 转发到其它机器。所以 k8s 利用 kube-ipvs0 这个网卡将 service 相关的 VIP 绑在上面以便让报文进入 INPUT 进而被 ipvs 转发。

当 IP 被绑到 kube-ipvs0 上,内核会自动将上面的 IP 写入 local 路由:

1
2
$ ip route show table local | grep 170.106.134.124
local 170.106.134.124 dev kube-ipvs0 proto kernel scope host src 170.106.134.124

内核认为在 local 路由里的 IP 是本机 IP,而 linux 默认有个行为: 忽略任何来自非回环网卡并且源 IP 是本机 IP 的报文。而 LB 的探测报文源 IP 就是 LB IP,也就是 Service 的 EXTERNAL-IP 猜想就是因为这个 IP 被绑到 kube-ipvs0,自动加进 local 路由导致内核直接忽略了 LB 的探测报文。

带着猜想做实现, 试一下将 LB IP 从 local 路由中删除:

1
ip route del table local local 170.106.134.124 dev kube-ipvs0  proto kernel  scope host  src 170.106.134.124

发现这个 node 的在 LB 的健康检查的状态变成健康了! 看来就是因为这个 LB IP 被绑到 kube-ipvs0 导致内核忽略了来自 LB 的探测报文,然后 LB 收不到回包认为不健康。

那为什么其它厂商没反馈这个问题?应该是 LB 的实现问题,腾讯云的公网 CLB 的健康探测报文源 IP 就是 LB 的公网 IP,而大多数厂商的 LB 探测报文源 IP 是保留 IP 并非 LB 自身的 VIP。

如何解决呢? 发现一个内核参数: accept_local 可以让 linux 接收源 IP 是本机 IP 的报文。

试了开启这个参数,确实在 cbr0 收到来自 LB 的探测报文了,说明报文能被 pod 收到,但抓 eth0 还是没有给 LB 回包。

为什么没有回包? 分析下五元组,要给 LB 回包,那么 目的IP:目的Port 必须是探测报文的 源IP:源Port,所以目的 IP 就是 LB IP,由于容器不在主 netns,发包经过 veth pair 到 cbr0 之后需要再经过 netfilter 处理,报文进入 PREROUTING 链然后发现目的 IP 是本机 IP,进入 INPUT 链,所以报文就出不去了。再分析下进入 INPUT 后会怎样,因为目的 Port 跟 LB 探测报文源 Port 相同,是一个随机端口,不在 Service 的端口列表,所以没有对应的 IPVS 规则,IPVS 也就不会转发它,而 kube-ipvs0 上虽然绑了这个 IP,但它是一个 dummy interface,不会收包,所以报文最后又被忽略了。

再看看为什么 1.12 启用 ipvs 会绑 EXTERNAL-IPkube-ipvs0,翻翻 k8s 的 kube-proxy 支持 ipvs 的 proposal,发现有个地方说法有点漏洞:

LB 类型 Service 的 status 里有 ingress IP,实际就是 kubectl get service 看到的 EXTERNAL-IP,这里说不会绑定这个 IP 到 kube-ipvs0,但后面又说会给它创建 ipvs 规则,既然没有绑到 kube-ipvs0,那么这个 IP 的报文根本不会进入 INPUT 被 ipvs 模块转发,创建的 ipvs 规则也是没用的。

后来找到作者私聊,思考了下,发现设计上确实有这个问题。

看了下 1.10 确实也是这么实现的,但是为什么 1.12 又绑了这个 IP 呢? 调研后发现是因为 #59976 这个 issue 发现一个问题,后来引入 #63066 这个 PR 修复的,而这个 PR 的行为就是让 LB IP 绑到 kube-ipvs0,这个提交影响 1.11 及其之后的版本。

#59976 的问题是因为没绑 LB IP到 kube-ipvs0 上,在自建集群使用 MetalLB 来实现 LoadBalancer 类型的 Service,而有些网络环境下,pod 是无法直接访问 LB 的,导致 pod 访问 LB IP 时访问不了,而如果将 LB IP 绑到 kube-ipvs0 上就可以通过 ipvs 转发到 LB 类型 Service 对应的 pod 去, 而不需要真正经过 LB,所以引入了 #63066 这个PR。

临时方案: 将 #63066 这个 PR 的更改回滚下,重新编译 kube-proxy,提供升级脚本升级存量 kube-proxy。

如果是让 LB 健康检查探测支持用保留 IP 而不是自身的公网 IP ,也是可以解决,但需要跨团队合作,而且如果多个厂商都遇到这个问题,每家都需要为解决这个问题而做开发调整,代价较高,所以长期方案需要跟社区沟通一起推进,所以我提了 issue,将问题描述的很清楚: #79783

小思考: 为什么 CLB 可以不做 SNAT ? 回包目的 IP 就是真实客户端 IP,但客户端是直接跟 LB IP 建立的连接,如果回包不经过 LB 是不可能发送成功的呀。

是因为 CLB 的实现是在母机上通过隧道跟 CVM 互联的,多了一层封装,回包始终会经过 LB。

就是因为 CLB 不做 SNAT,正常来自客户端的报文是可以发送到 nodeport,但健康检查探测报文由于源 IP 是 LB IP 被绑到 kube-ipvs0 导致被忽略,也就解释了为什么健康检查失败,但通过LB能访问后端服务,只是有时会超时。那么如果要做 SNAT 的 LB 岂不是更糟糕,所有报文都变成 LB IP,所有报文都会被忽略?

我提的 issue 有回复指出,AWS 的 LB 会做 SNAT,但它们不将 LB 的 IP 写到 Service 的 Status 里,只写了 hostname,所以也不会绑 LB IP 到 kube-ipvs0:

但是只写 hostname 也得 LB 支持自动绑域名解析,并且个人觉得只写 hostname 很别扭,通过 kubectl get svc 或者其它 k8s 管理系统无法直接获取 LB IP,这不是一个好的解决方法。

我提了 #79976 这个 PR 可以解决问题: 给 kube-proxy 加 --exclude-external-ip 这个 flag 控制是否为 LB IP
创建 ipvs 规则和绑定 kube-ipvs0

但有人担心增加 kube-proxy flag 会增加 kube-proxy 的调试复杂度,看能否在 iptables 层面解决:

仔细一想,确实可行,打算有空实现下,重新提个 PR:

结语

至此,我们一起完成了一段奇妙的问题排查之旅,信息量很大并且比较复杂,有些没看懂很正常,但我希望你可以收藏起来反复阅读,一起在技术的道路上打怪升级。

Kubernetes 问题排查:Pod 状态一直 Terminating

作者: 陈鹏

查看 Pod 事件:

1
$ kubectl describe pod/apigateway-6dc48bf8b6-clcwk -n cn-staging

Need to kill Pod

1
Normal  Killing  39s (x735 over 15h)  kubelet, 10.179.80.31  Killing container with id docker://apigateway:Need to kill Pod

可能是磁盘满了,无法创建和删除 pod

处理建议是参考Kubernetes 最佳实践:处理容器数据磁盘被写满

DeadlineExceeded

1
Warning FailedSync 3m (x408 over 1h) kubelet, 10.179.80.31 error determining status: rpc error: code = DeadlineExceeded desc = context deadline exceeded

怀疑是17版本dockerd的BUG。可通过 kubectl -n cn-staging delete pod apigateway-6dc48bf8b6-clcwk --force --grace-period=0 强制删除pod,但 docker ps 仍看得到这个容器

处置建议:

  • 升级到docker 18. 该版本使用了新的 containerd,针对很多bug进行了修复。
  • 如果出现terminating状态的话,可以提供让容器专家进行排查,不建议直接强行删除,会可能导致一些业务上问题。

存在 Finalizers

k8s 资源的 metadata 里如果存在 finalizers,那么该资源一般是由某程序创建的,并且在其创建的资源的 metadata 里的 finalizers 加了一个它的标识,这意味着这个资源被删除时需要由创建资源的程序来做删除前的清理,清理完了它需要将标识从该资源的 finalizers 中移除,然后才会最终彻底删除资源。比如 Rancher 创建的一些资源就会写入 finalizers 标识。

处理建议:kubectl edit 手动编辑资源定义,删掉 finalizers,这时再看下资源,就会发现已经删掉了

Kubernetes 踩坑分享:开启tcp_tw_recycle内核参数在NAT环境会丢包

作者: 陈鹏

原因

tcp_tw_recycle 参数,它用来快速回收 TIME_WAIT 连接,不过如果在 NAT 环境下会引发问题。 RFC1323 中有如下一段描述:

An additional mechanism could be added to the TCP, a per-host cache of the last timestamp received from any connection. This value could then be used in the PAWS mechanism to reject old duplicate segments from earlier incarnations of the connection, if the timestamp clock can be guaranteed to have ticked at least once since the old connection was open. This would require that the TIME-WAIT delay plus the RTT together must be at least one tick of the sender’s timestamp clock. Such an extension is not part of the proposal of this RFC.

  • 大概意思是说TCP有一种行为,可以缓存每个连接最新的时间戳,后续请求中如果时间戳小于缓存的时间戳,即视为无效,相应的数据包会被丢弃。

  • Linux是否启用这种行为取决于tcp_timestamps和tcp_tw_recycle,因为tcp_timestamps缺省就是开启的,所以当tcp_tw_recycle被开启后,实际上这种行为就被激活了,当客户端或服务端以NAT方式构建的时候就可能出现问题,下面以客户端NAT为例来说明:

  • 当多个客户端通过NAT方式联网并与服务端交互时,服务端看到的是同一个IP,也就是说对服务端而言这些客户端实际上等同于一个,可惜由于这些客户端的时间戳可能存在差异,于是乎从服务端的视角看,便可能出现时间戳错乱的现象,进而直接导致时间戳小的数据包被丢弃。如果发生了此类问题,具体的表现通常是是客户端明明发送的SYN,但服务端就是不响应ACK。

  • 在4.12之后的内核已移除tcp_tw_recycle内核参数: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=4396e46187ca5070219b81773c4e65088dac50cc https://github.com/torvalds/linux/commit/4396e46187ca5070219b81773c4e65088dac50cc

TKE中 使用 NAT 的场景

  • 跨 VPC 访问(通过对等连接、云联网、专线等方式打通),会做 SNAT

kubernetes 最佳实践:处理容器数据磁盘被写满

作者: 陈鹏

容器数据磁盘被写满造成的危害:

  • 不能创建 Pod (一直 ContainerCreating)
  • 不能删除 Pod (一直 Terminating)
  • 无法 exec 到容器

判断是否被写满:

容器数据目录大多会单独挂数据盘,路径一般是 /var/lib/docker,也可能是 /data/docker/opt/docker,取决于节点被添加时的配置:

可通过 docker info 确定:

1
2
3
4
$ docker info
...
Docker Root Dir: /var/lib/docker
...

如果没有单独挂数据盘,则会使用系统盘存储。判断是否被写满:

1
2
3
4
5
6
$ df
Filesystem 1K-blocks Used Available Use% Mounted on
...
/dev/vda1 51474044 4619112 44233548 10% /
...
/dev/vdb 20511356 20511356 0 100% /var/lib/docker

解决方法

先恢复业务,清理磁盘空间

重启 dockerd (清理容器日志输出和可写层文件)

  • 重启前需要稍微腾出一点空间,不然重启 docker 会失败,可以手动删除一些docker的log文件或可写层文件,通常删除log:
1
2
3
4
$ cd /var/lib/docker/containers
$ du -sh * # 找到比较大的目录
$ cd dda02c9a7491fa797ab730c1568ba06cba74cecd4e4a82e9d90d00fa11de743c
$ cat /dev/null > dda02c9a7491fa797ab730c1568ba06cba74cecd4e4a82e9d90d00fa11de743c-json.log.9 # 删除log文件

注意: 使用 cat /dev/null > 方式删除而不用 rm,因为用 rm 删除的文件,docker 进程可能不会释放文件,空间也就不会释放;log 的后缀数字越大表示越久远,先删除旧日志。

  • 将该 node 标记不可调度,并将其已有的 pod 驱逐到其它节点,这样重启dockerd就会让该节点的pod对应的容器删掉,容器相关的日志(标准输出)与容器内产生的数据文件(可写层)也会被清理:
1
kubectl drain 10.179.80.31
  • 重启 dockerd:
1
systemctl restart dockerd
  • 取消不可调度的标记:
1
kubectl uncordon 10.179.80.31

定位根因,彻底解决

问题定位方法见附录,这里列举根因对应的解决方法:

  • 日志输出量大导致磁盘写满:
    • 减少日志输出
    • 增大磁盘空间
    • 减小单机可调度的pod数量
  • 可写层量大导致磁盘写满: 优化程序逻辑,不写文件到容器内或控制写入文件的大小与数量
  • 镜像占用空间大导致磁盘写满:
    • 增大磁盘空间
    • 删除不需要的镜像

附录

查看docker的磁盘空间占用情况

1
$ docker system df -v

定位容器写满磁盘的原因

进入容器数据目录(假设是 /var/lib/docker,并且存储驱动是 aufs):

1
2
$ cd /var/lib/docker
$ du -sh *

  • containers 目录: 体积大说明日志输出量大
  • aufs 目录

  • diff 子目录: 容器可写层,体积大说明可写层数据量大(程序在容器里写入文件)
  • mnt 子目录: 联合挂载点,内容为容器里看到的内容,即包含镜像本身内容以及可写层内容

找出日志输出量大的 pod

TKE 的 pod 中每个容器输出的日志最大存储 1G (日志轮转,最大10个文件,每个文件最大100m,可用 docker inpect 查看):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ docker inspect fef835ebfc88
[
{
...
"HostConfig": {
...
"LogConfig": {
"Type": "json-file",
"Config": {
"max-file": "10",
"max-size": "100m"
}
},
...

查看哪些容器日志输出量大:

1
2
$ cd /var/lib/docker/containers
$ du -sh *

目录名即为容器id,使用前几位与 docker ps 结果匹配可找出对应容器,最后就可以推算出是哪些 pod 搞的鬼

找出可写层数据量大的 pod

可写层的数据主要是容器内程序自身写入的,无法控制大小,可写层越大说明容器写入的文件越多或越大,通常是容器内程序将log写到文件里了,查看一下哪个容器的可写层数据量大:

1
2
$ cd /var/lib/docker/aufs/diff
$ du -sh *


通过可写层目录(diff的子目录)反查容器id:

1
2
$ grep 834d97500892f56b24c6e63ffd4e520fc29c6c0d809a3472055116f59fb1d2be /var/lib/docker/image/aufs/layerdb/mounts/*/mount-id
/var/lib/docker/image/aufs/layerdb/mounts/eb76fcd31dfbe5fc949b67e4ad717e002847d15334791715ff7d96bb2c8785f9/mount-id:834d97500892f56b24c6e63ffd4e520fc29c6c0d809a3472055116f59fb1d2be

mounts 后面一级的id即为容器id: eb76fcd31dfbe5fc949b67e4ad717e002847d15334791715ff7d96bb2c8785f9,使用前几位与 docker ps 结果匹配可找出对应容器,最后就可以推算出是哪些 pod 搞的鬼

找出体积大的镜像

看看哪些镜像比较占空间

Kubernetes 最佳实践:处理内存碎片化

作者: 陈鹏

内存碎片化造成的危害

节点的内存碎片化严重,导致docker运行容器时,无法分到大的内存块,导致start docker失败。最终导致服务更新时,状态一直都是启动中

判断是否内存碎片化严重

内核日志显示:

进一步查看的系统内存(cache多可能是io导致的,为了提高io效率留下的缓存,这部分内存实际是可以释放的):

查看slab (后面的0多表示伙伴系统没有大块内存了):

解决方法

  • 周期性地或者在发现大块内存不足时,先进行drop_cache操作:

    1
    echo 3 > /proc/sys/vm/drop_caches
  • 必要时候进行内存整理,开销会比较大,会造成业务卡住一段时间(慎用):

    1
    echo 1 > /proc/sys/vm/compact_memory

附录

相关链接:

Kubernetes 最佳实践:解决长连接服务扩容失效

作者: 陈鹏

在现网运营中,有很多场景为了提高效率,一般都采用建立长连接的方式来请求。我们发现在客户端以长连接请求服务端的场景下,K8S的自动扩容会失效。原因是客户端长连接一直保留在老的Pod容器中,新扩容的Pod没有新的连接过来,导致K8S按照步长扩容第一批Pod之后就停止了扩容操作,而且新扩容的Pod没能承载请求,进而出现服务过载的情况,自动扩容失去了意义。

对长连接扩容失效的问题,我们的解决方法是将长连接转换为短连接。我们参考了 nginx keepalive 的设计,nginx 中 keepalive_requests 这个配置项设定了一个TCP连接能处理的最大请求数,达到设定值(比如1000)之后服务端会在 http 的 Header 头标记 “Connection:close”,通知客户端处理完当前的请求后关闭连接,新的请求需要重新建立TCP连接,所以这个过程中不会出现请求失败,同时又达到了将长连接按需转换为短连接的目的。通过这个办法客户端和云K8S服务端处理完一批请求后不断的更新TCP连接,自动扩容的新Pod能接收到新的连接请求,从而解决了自动扩容失效的问题。

由于Golang并没有提供方法可以获取到每个连接处理过的请求数,我们重写了 net.Listenernet.Conn,注入请求计数器,对每个连接处理的请求做计数,并通过 net.Conn.LocalAddr() 获得计数值,判断达到阈值 1000 后在返回的 Header 中插入 “Connection:close” 通知客户端关闭连接,重新建立连接来发起请求。以上处理逻辑用 Golang 实现示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
"net"
"github.com/gin-gonic/gin"
"net/http"
)

//重新定义net.Listener
type counterListener struct {
net.Listener
}
//重写net.Listener.Accept(),对接收到的连接注入请求计数器
func (c *counterListener) Accept() (net.Conn, error) {
conn, err := c.Listener.Accept()
if err != nil {
return nil, err
}
return &counterConn{Conn: conn}, nil
}
//定义计数器counter和计数方法Increment()
type counter int
func (c *counter) Increment() int {
*c++
return int(*c)
}

//重新定义net.Conn,注入计数器ct
type counterConn struct {
net.Conn
ct counter
}

//重写net.Conn.LocalAddr(),返回本地网络地址的同时返回该连接累计处理过的请求数
func (c *counterConn) LocalAddr() net.Addr {
return &counterAddr{c.Conn.LocalAddr(), &c.ct}
}

//定义TCP连接计数器,指向连接累计请求的计数器
type counterAddr struct {
net.Addr
*counter
}

func main() {
r := gin.New()
r.Use(func(c *gin.Context) {
localAddr := c.Request.Context().Value(http.LocalAddrContextKey)
if ct, ok := localAddr.(interface{ Increment() int }); ok {
if ct.Increment() >= 1000 {
c.Header("Connection", "close")
}
}
c.Next()
})
r.GET("/", func(c *gin.Context) {
c.String(200, "plain/text", "hello")
})
l, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
err = http.Serve(&counterListener{l}, r)
if err != nil {
panic(err)
}
}

Kubernetes 问题定位技巧:容器内抓包

作者: 陈鹏

在使用 kubernetes 跑应用的时候,可能会遇到一些网络问题,比较常见的是服务端无响应(超时)或回包内容不正常,如果没找出各种配置上有问题,这时我们需要确认数据包到底有没有最终被路由到容器里,或者报文到达容器的内容和出容器的内容符不符合预期,通过分析报文可以进一步缩小问题范围。那么如何在容器内抓包呢?本文提供实用的脚本一键进入容器网络命名空间(netns),使用宿主机上的tcpdump进行抓包。

使用脚本一键进入 pod netns 抓包

  • 发现某个服务不通,最好将其副本数调为1,并找到这个副本 pod 所在节点和 pod 名称

    1
    kubectl get pod -o wide
  • 登录 pod 所在节点,将如下脚本粘贴到 shell (注册函数到当前登录的 shell,我们后面用)

    1
    2
    3
    4
    5
    6
    7
    8
    function e() {
    set -eu
    ns=${2-"default"}
    pod=`kubectl -n $ns describe pod $1 | grep -Eo 'docker://.*$' | head -n 1 | sed 's/docker:\/\/\(.*\)$/\1/'`
    pid=`docker inspect -f {{.State.Pid}} $pod`
    echo "enter pod netns successfully for $ns/$1"
    nsenter -n --target $pid
    }
  • 一键进入 pod 所在的 netns,格式:e POD_NAME NAMESPACE,示例:

    1
    2
    e istio-galley-58c7c7c646-m6568 istio-system
    e proxy-5546768954-9rxg6 # 省略 NAMESPACE 默认为 default
  • 这时已经进入 pod 的 netns,可以执行宿主机上的 ip aifconfig 来查看容器的网卡,执行 netstat -tunlp 查看当前容器监听了哪些端口,再通过 tcpdump 抓包:

    1
    tcpdump -i eth0 -w test.pcap port 80
  • ctrl-c 停止抓包,再用 scpsz 将抓下来的包下载到本地使用 wireshark 分析,提供一些常用的 wireshark 过滤语法:

    1
    2
    3
    4
    5
    6
    # 使用 telnet 连上并发送一些测试文本,比如 "lbtest",
    # 用下面语句可以看发送的测试报文有没有到容器
    tcp contains "lbtest"
    # 如果容器提供的是http服务,可以使用 curl 发送一些测试路径的请求,
    # 通过下面语句过滤 uri 看报文有没有都容器
    http.request.uri=="/mytest"

脚本原理

我们解释下步骤二中用到的脚本的原理

  • 查看指定 pod 运行的容器 ID

    1
    kubectl describe pod <pod> -n mservice
  • 获得容器进程的 pid

    1
    docker inspect -f {{.State.Pid}} <container>
  • 进入该容器的 network namespace

    1
    nsenter -n --target <PID>

依赖宿主机的命名:kubectl, docker, nsenter, grep, head, sed