Kubernetes IPVS 模式复用TCP连接问题

Posted by Fioncat on May 18, 2023

背景

某个客户在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