本文通过记录kubelet连接apiserver超时问题的原因及修复办法。 上篇文章回顾:
背景
kubernetes是master-slave结构,master node是集群的大脑,当master node发生故障时整个集群都"out of control"。master node中最重要的当属apiserver组件,它负责处理所有请求,并持久化状态到etcd。一般我们会部署多份apiserver实现高可用。官方建议在多个apiserver前面部署一个LB进行负载均衡,当其中一台apiserver发生故障之后,LB自动将流量切换到其他实例上面。这样虽然简单,但是也引入了额外的依赖,如果LB发生故障将会导致全部apiserver不可用。我们知道在kubernetes中node节点上kubelet与apiserver心跳超时后,controller-manager会将该node状态置为notReady,随后驱逐其上的pod,使这些pod在其他地方重建。所以当LB发生故障时,集群中所有的node都会变为notReady状态,进而导致大规模的pod驱逐。
故障发生
无独有偶,这样的事情偏偏被我们碰到了,接到线上大量node not ready的报警后,立刻上线查看,发现所有的node kubelet都报如下错误:
E0415 17:03:11.351872 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?resourceVersion=0&timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers)E0415 17:03:16.352108 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers)E0415 17:03:21.352335 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers)E0415 17:03:26.352548 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers)E0415 17:03:31.352790 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers)E0415 17:03:31.352810 16624 kubelet_node_status.go:366] Unable to update node status: update node status exceeds retry count复制代码
日志中显示的10.13.10.12是LB的地址。通过这个日志判断是kubelet连接apiserver失败,初步怀疑是网络故障,手动telnet 10.13.10.12 6443后发现一切正常,这就比较奇怪了,明明网络通信正常,kubelet为什么连不上apiserver?
赶紧用tcpdump抓包分析了一下,发现kubelet不断地给apiservre发送包却没有收到对端的ACK,登录master查看apiserver服务也一切正常。后来同事发现重启kubelet就好了,为了尽快解决问题只能把kubelet全部重启了,后面再慢慢定位问题。
定位问题
集群恢复之后,发现有故障通报LB发生了故障,联系了相关同学发现时间点刚好相符,怀疑是因为LB异常导致kubelet无法连接apiserver。
经过沟通后发现:LB会为其转发的每一个connection维护一些数据结构,当新的一台LB server上线之后会均摊一部分原来的流量,但是在其维护的数据结构中找不到该connection的记录就会认为这个请求非法,直接DROP掉。类似的事确实还发生不少,在kubernetes的isuse里有不少这样的案例,甚至需要公有云的的LB也会有这样的问题。例如:kubernetes#41916,kubernetes#48638,kubernetes-incubator/kube-aws#598
大概明白原因之后,push LB的同学改进的同时,kubelet也应该做一些改进:当kubelet连接apiserver超时之后,应该reset掉连接,进行重试。简单做了一个测试,使用iptables规则drop掉kubelet发出的流量来模拟网络异常。
首先确保kubelet与apiserver连接正常,执行netstat -antpl | grep 6443可以看到kubelet与apiserver 10.132.106.115:6443连接正常:
[root@c4-jm-i1-k8stest03 ~]# netstat -antpl |grep kubelettcp 0 0 127.0.0.1:10248 0.0.0.0:* LISTEN 23665/./kubelet tcp 0 0 10.162.1.26:63876 10.132.106.115:6443 ESTABLISHED 23665/./kubelet tcp6 0 0 :::4194 :::* LISTEN 23665/./kubelet tcp6 0 0 :::10250 :::* LISTEN 23665/./kubelet tcp6 0 0 :::10255 :::* LISTEN 23665/./kubelet tcp6 0 0 10.162.1.26:10250 10.132.1.30:61218 ESTABLISHED 23665/./kubelet 复制代码
此时执行
iptables -I OUTPUT -p tcp --sport 63876 -j DROP复制代码
将kubelet发出的包丢掉,模拟网络故障,此时可以看到netstat的输出中该连接的Send-Q正在逐步增加,并且kubelet也打印出日志显示无法连接:
[root@c4-jm-i1-k8stest03 ~]# netstat -antpl |grep kubelettcp 0 0 127.0.0.1:10248 0.0.0.0:* LISTEN 23665/./kubelet tcp 0 928 10.162.1.26:63876 10.132.106.115:6443 ESTABLISHED 23665/./kubelet 复制代码
连接被hang住了,重启kubelet之后,一切又恢复了。
这个现象和当时发生故障的情况一模一样:连接异常导致kubelet心跳超时,重启kubelet后会新建连接,恢复正常心跳。因为我们当前采用的kubernetes版本是v1.10.2,下载master分支的代码编译试了下,也是有这个问题的,感觉这个问题一直存在。
艰难修复
接下来就是怎么修复这个问题了。网上找了一下相关的issue,首先找到的是kubernetes/client-go#374这个issue,上面描述的情况和我们碰到的很相似,有人说是因为使用了HTTP/2.0协议(以下简称h2),查找了一下kubelet的源码,发现kubelet默认是使用h2协议,具体的代码实现在SetTransportDefaults这个函数中。
可以通过设置环境变量DISABLE_HTTP2来禁用h2,简单验证了一下,显式设置该环境变量禁用h2后,让连接使用http1.1确实没有这个问题了。
查阅文档发现这是http1.1与http2.0的差异:在http1.1中,默认采用keep-alive复用网络连接,发起新的请求时,如果当前有闲置的连接就会复用该连接,如果没有则新建一个连接。当kubelet连接异常时,老的连接被占用,一直hang在等待对端响应,kubelet在下一次心跳周期,因为没有可用连接就会新建一个,只要新连接正常通信,心跳包就可以正常发送。
在h2中,为了提高网络性能,一个主机只建立一个连接,所有的请求都通过该连接进行,默认情况下,即使网络异常,他还是重用这个连接,直到操作系统将连接关闭,而操作系统关闭僵尸连接的时间默认是十几分钟,具体的时间可以调整系统参数:
net.ipv4.tcp_retries2, net.ipv4.tcp_keepalive_time, net.ipv4.tcp_keepalive_probes, net.ipv4.tcp_keepalive_intvl复制代码
通过调整操作系统断开异常连接的时间实现快速恢复。
h2主动探测连接故障是通过发送Ping frame来实现,这是一个优先级比较高并且payload很少的包,网络正常时是可以快速返回,该frame默认不会发送,需要显式设置才会发送。在一些gRPC等要求可靠性比较高的通信框架中都实现了Ping frame,在gRPC On HTTP/2: Engineering A Robust, High Performance Protocol中谈到:
可以看到gRPC同样存在这样的问题,为了快速识别故障连接并恢复采用了Ping frame。但是目前kubernetes所建立的连接中并没有实现Ping frame,导致了无法及时发现连接异常并自愈。
社区那个issue已经开了很长时间好像并没有解决的痕迹,还得自己想办法。我们知道一个http.Client本身其实只做了一些http协议的处理,底层的通信是交给Transport来实现,Transport决定如何根据一个request返回对应的response。在kubernetes client-go中关于Transporth2的设置只有这一个函数。
// SetTransportDefaults applies the defaults from http.DefaultTransport// for the Proxy, Dial, and TLSHandshakeTimeout fields if unsetfunc SetTransportDefaults(t *http.Transport) *http.Transport { t = SetOldTransportDefaults(t) // Allow clients to disable http2 if needed. if s := os.Getenv("DISABLE_HTTP2"); len(s) > 0 { klog.Infof("HTTP2 has been explicitly disabled") } else { if err := http2.ConfigureTransport(t); err != nil { klog.Warningf("Transport failed http2 configuration: %v", err) } } return t}复制代码
只是调用了http2.ConfigureTransport来设置transport支持h2。这一句代码似乎太过简单,并没有任何Ping frame相关的处理逻辑。查了下golang标准库中Transport与Pingframe相关的方法。
令人遗憾的是,当前golang对于一个tcp连接的抽象ClientConn已经支持发送Ping frame,但是连接是交由连接池clientConnPool管理的,该结构是个内部的私有结构体,我们没法直接操作,封装连接池的Transport也没有暴露任何的接口来实现设置连接池中的所有连接定期发送Ping frame。如果我们想实现这个功能就必须自定义一个Transport并实现一个连接池,要实现一个稳定可靠的Transport似乎并不容易。只能求助golang社区看有没有解决方案,提交了一个issue后,很快就有人回复并提交了PR,查看了一下,实现还是比较简单的,于是基于这个PR实现了clinet-go的Ping frame的探测。
峰回路转
开发完毕准备上线的时候,想趁这次修复升级一下kubernetes版本到v1.10.11,一般patch release是保证兼容的。在测试v1.10.11的时候惊奇的发现,即使不改任何代码,这个问题也没办法复现了。说明在v1.10.2中是有问题的,在v1.10.11中恢复了,接着在master中又引入了这个问题,看来还得需要仔细阅读一下这部分代码了,到底是发生了什么。
经过阅读代码,发现这个逻辑曾经被修复过,参考下方链接:
并且backport到1.10.3的代码中,当连接异常时会会调用closeAllConns强制关闭掉所有的连接使其重建。
随后又引入了regression,将closeAllConns置为nil,导致连接无法正常关闭。
明白了这个逻辑之后修改就简单了,将closeAllConns再置为正确的值即可,给官方提交了一个pr,官方很乐意就接受了,并backport到了1.14版本中。至此这个就算完全修复了,当然可以通过上文提到的给h2增加Ping frame的方式解决该问题,这是这种方案可能比较复杂,修复时间比较长。
1、https://github.com/kubernetes/kubernetes/issues/41916
2、https://github.com/kubernetes/kubernetes/issues/48638
3、https://github.com/kubernetes-incubator/kube-aws/issues/598
4、https://github.com/kubernetes/client-go/issues/374
5、https://github.com/kubernetes/apimachinery/blob/b874eabb9a4eb99cef27db5c8d06f16542580cec/pkg/util/net/http.go#L109-L120
6、https://www.cncf.io/blog/2018/08/31/grpc-on-http-2-engineering-a-robust-high-performance-protocol/
7、https://github.com/kubernetes/kubernetes/pull/63492
8、https://github.com/kubernetes/kubernetes/pull/71174
9、https://github.com/golang/go/issues/31643
10、https://github.com/kubernetes/kubernetes/pull/78016
本文首发于公众号“小米云技术”,转载请注明出处,。