背景
某个客户在Kubernetes中部署了大规模的业务,因此选用IPVS作为kube-proxy
的负载均衡转发模式。以追求更高的转发性能和更新规则的速度。
但是,IPVS有一个存在了很久的连接重用问题,当客户发布服务,因为存在大量TCP短连接,客户端出现了No route to host
报错,业务服务出现故障。
这是一个存在已久的issue:kubernetes#81775。我们今天就来简单分析一下并介绍解决方案。
复现
故障必须经过复现才有排查的手段,我这里介绍一个简单的复现方法。
首先,准备一个测试用的Kubernetes集群,kube-proxy使用IPVS模式,并且为了复现故障,把内核参数net.ipv4.vs.conn_reuse_mode
设为0
(在一些低版本的kube-proxy
或低版本内核中,你会发现它默认为0
):
1
echo 0 > /proc/sys/net/ipv4/vs/conn_reuse_mode
部署一个简单的HTTP服务,包含10个副本,并定义一个LoadBalancer
类型的Service以模拟负载均衡的场景:
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
apiVersion: v1
kind: Service
metadata:
annotations:
service.beta.kubernetes.io/ucloud-load-balancer-vserver-protocol: "TCP"
service.beta.kubernetes.io/ucloud-load-balancer-type: "outer"
name: httpgraceful
spec:
type: LoadBalancer
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8080
selector:
app: httpgraceful
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: httpgraceful
labels:
app: httpgraceful
spec:
replicas: 10
selector:
matchLabels:
app: httpgraceful
template:
metadata:
labels:
app: httpgraceful
spec:
containers:
- name: http
image: uhub.service.ucloud.cn/wxyz/httpgraceful:1.0.0
imagePullPolicy: Always
ports:
- containerPort: 8080
readinessProbe:
exec:
command:
- cat
- /tmp/healthz
initialDelaySeconds: 3
periodSeconds: 3
等待Pod运行起来后,查看Service地址(这里假设为106.75.10.10
):
1
2
3
$ kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
httpgraceful LoadBalancer 172.17.43.192 106.75.10.10 80:31984/TCP 48s
确认该Service可以访问:
1
2
3
$ curl http://106.75.10.10
hello world
version:1.0.0
现在,使用ab
命令,创建大量对该Service的短连接:
1
ab -c 10 -n 2000000 http://106.75.10.10/
同时,将HTTP服务的副本数改为5,以模拟服务发布的场景:
1
kubectl scale deploy httpgraceful --replicas=5
很快,你就可以看到ab
命令输出错误:
1
2
3
Benchmarking 106.75.10.10 (be patient)
apr_socket_recv: No route to host (113)
Total of 5251 requests completed
No route to host
出现了!复现出了客户的错误。
这时候,登录机器,查看IPVS规则,你会发现该Service对应的RS出现了很多权重为0的项:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ ipvsadm -Ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 106.75.35.59:80 rr
-> 10.9.19.40:8080 Masq 1 5 2
-> 10.9.26.56:8080 Masq 0 1 0
-> 10.9.82.126:8080 Masq 0 1 1
-> 10.9.99.18:8080 Masq 0 1 0
-> 10.9.127.64:8080 Masq 0 2 0
-> 10.9.136.110:8080 Masq 1 6 1
-> 10.9.157.207:8080 Masq 1 5 0
-> 10.9.161.247:8080 Masq 1 5 0
-> 10.9.187.95:8080 Masq 1 5 0
没错,这就是导致这次问题的根因,我们会在下面进行分析。
原因分析
当业务进行缩容或者滚动迭代过程中,一定有旧的Pod被销毁。No route to host
的直接原因是,IPVS把访问流量转发到了一个被销毁的Pod上面。
具体地说,当一个Pod被销毁后,kube-controller-manager
中的Endpoint Controller
会随之把对应的Pod Ip从Endpoints
对象中摘除。kube-proxy随即通过informer获取到旧Pod Ip被摘除的事件,相应地更新ipvs转发规则:
- 先将旧的IP对应的RS权重置为0,防止新的连接调度到这个IP上。
- 等存量连接归零后(ActiveConn+InactiveConn=0),再彻底摘除掉这个旧IP。
从上面ipvsadm -Ln
的结果也可以看到,几个权重为0的RS都还有连接,所以没有被摘除。
kube-proxy不直接摘除RS是为了实现Pod的优雅退出,以让Pod在处理完所有请求之后再退出。
但我们碰到的问题实际上是,权重被置为0后,依然有新连接被调度到旧IP上。这样存量连接计数永远不会归零,旧RS永远无法被摘除。而由于旧的IP实际已经不存在,就出现了No route to host
报错。只有把流量撤走一段时间后,连接计数终于为0,旧RS被摘除。
这一切的罪魁祸首是一个内核参数:net.ipv4.vs.conn_reuse_mode
。
众所周知,在大量TCP短连接的场景下,会有很多连接处于TIME_WAIT
状态,为了减少资源占用,内核会重用这些连接的端口。
而如果net.ipv4.vs.conn_reuse_mode
为0,并且客户端的源端口被复用了,那么IPVS会将流量直接转发到之前RS,而绕过了正常的负载均衡调度。这样即使RS的权重为0,也可能会有流量被转发上去。
在大量TCP短连接时,端口复用的频率会非常高,这样即使RS的权重被设为了0,还是会有部分流量打上来,而其背后的Pod已经被删除了(优雅退出有超时时间,超过之后Kubernetes会强制删除Pod),就导致了流量被转发到了一个不存在的IP上,出现No route to host
错误。
那么其实只要将net.ipv4.vs.conn_reuse_mode
设为1,强制让复用的连接也经过负载均衡转发不就可以了?
可惜,在5.9
之前的内核版本中,net.ipv4.vs.conn_reuse_mode
设为1并使用了conntrack
时,如果出现了连接复用,IPVS会 DROP 掉第一个 SYN 包,导致 SYN 的重传,有 1s 延迟。而 Kube-proxy 在 IPVS 模式下,使用了 iptables 进行MASQUERADE
,也正好开启了net.ipv4.vs.conntrack
。
所以:
- 如果将
net.ipv4.vs.conn_reuse_mode
设为0,当出现大量短TCP连接时,会出现部分流量被转发到销毁Pod的问题。 - 如果将
net.ipv4.vs.conn_reuse_mode
设为1,当出现大量短TCP连接时,会有部分连接多出了1s的延迟的问题。
解决
也就是说,在以前的内核版本中,理想的情况是:
net.ipv4.vs.conn_reuse_mode
设为1,强制复用连接走负载均衡。net.ipv4.vs.conntrack
设为0,防止IPVS对复用连接进行DROP SYNC操作。
但是,很可惜,这实现不了。因此,如果你的内核版本低于5.9
,并且存在大量TCP短连接场景,不建议使用IPVS,建议替换为iptables模式。
5.9
的一个patch修复了在启用conntrack
时的1s延迟问题,因此,如果内核版本大于等于5.9
,将net.ipv4.vs.conn_reuse_mode
设为1是安全的,保持这个内核参数为1即可解决问题。
事实上,新的kube-proxy会自动根据你的内核版本来判断是否要修改这个内核参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
const connReuseFixedKernelVersion = "5.9"
if kernelVersion.LessThan(version.MustParseGeneric(connReuseMinSupportedKernelVersion)) {
klog.ErrorS(nil, "Can't set sysctl, kernel version doesn't satisfy minimum version requirements", "sysctl", sysctlConnReuse, "minimumKernelVersion", connReuseMinSupportedKernelVersion)
} else if kernelVersion.AtLeast(version.MustParseGeneric(connReuseFixedKernelVersion)) {
// https://github.com/kubernetes/kubernetes/issues/93297
klog.V(2).InfoS("Left as-is", "sysctl", sysctlConnReuse)
} else {
// Set the connection reuse mode
if err := utilproxy.EnsureSysctl(sysctl, sysctlConnReuse, 0); err != nil {
return nil, err
}
}
而因为我们提供给客户的内核版本经过了魔改,虽然其已经包含了这个patch,但是版本小于5.9
,所以需要在kube-proxy启动之后强制将net.ipv4.vs.conn_reuse_mode
设为1。
如果使用systemd部署kube-proxy,可以用ExecStartPost
来完成内核参数的修改:
1
2
3
[Service]
...
ExecStartPost=/sbin/sysctl -w net.ipv4.vs.conn_reuse_mode=1
如果用Pod部署kube-proxy,可以使用PostStart完成:
1
2
3
4
5
6
7
lifecycle:
postStart:
exec:
command:
- /sbin/sysctl
- -w
- net.ipv4.vs.conn_reuse_mode=1