美文网首页
k8s conntrack 表项超时导致tcp长连接中断

k8s conntrack 表项超时导致tcp长连接中断

作者: 分享放大价值 | 来源:发表于2021-04-09 15:49 被阅读0次

    此问题是在公司业务中出现的,经过分析感觉和具体业务没啥关系,所以尝试在自搭的k8s环境中模拟复现,事实证明确实可以复现。拓扑如下


    image.png

    拓扑比较简单,client和server建立http长连接后,过大概一天后,client再发送数据到server,会收到server端的rst消息,导致client端发送数据时收到error(reset by peer)关闭socket连接。

    先说下复现步骤,再分析原因。

    复现步骤

    1. 创建pod和svc
      用到的yaml文件如下,创建一个client pod,两个server pod,和一个service,监听端口2222,后端pod为server pod。
      pod镜像用的是nginx,这个无所谓,只要能执行后面的client和server bin即可。
      使用命令 kubectl apply -f service.yaml 应用yaml配置。
    root@master:~# cat service.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: client
    spec:
      selector:
        matchLabels:
          app: myapp1
      replicas: 1
      template:
        metadata:
          labels:
            app: myapp1
        spec:
          nodeName: master
    
          containers:
          - name: nginx
            image: nginx
    
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: server
    spec:
      selector:
        matchLabels:
          app: myapp
      replicas: 2
      template:
        metadata:
          labels:
            app: myapp
        spec:
          nodeName: node1
    
          containers:
          - name: nginx
            image: nginx
            ports:
            - containerPort: 2222
    
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: myservice
    spec:
      selector:
        app: myapp
      ports:
      - protocol: TCP
        port: 2222
        targetPort: 2222
    
    1. client和server 简单c程序,用来建立tcp连接
      client端口代码,用来连接server的service ip 10.108.33.37
    root@master:~/test# cat client.c
    #include <netinet/in.h>
    #include <netinet/tcp.h>
    #include <sys/socket.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <stdio.h>
    #include <errno.h>
    #include <unistd.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    
    void main(void)
    {
            int fd, ret;
            struct sockaddr_in addr;
    
            fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
            if(fd < 0) {
                    perror("socket create failed");
                    return ;
            }
    
            addr.sin_family  = AF_INET;
            addr.sin_port  = htons(2222);
            addr.sin_addr.s_addr = inet_addr("10.108.33.37");
            ret = connect(fd, (const struct sockaddr *)&addr, sizeof(addr));
            if( ret != 0) {
                    perror("socket connect1 failed");
                    return ;
            }
    
            char buff[10];
            while(1) {
                printf("please input:");
                gets(buff);
                ret = send(fd, buff, sizeof(buff), 0);
                perror("send result\n");
                sleep(1);
            }
    }
    

    server端代码,监听2222端口

    root@master:~/test# cat server.c
    #include<stdio.h>
    #include<stdlib.h>
    #include<sys/types.h>
    #include<sys/socket.h>
    #include<sys/wait.h>
    #include<string.h>
    #include<netinet/in.h>
    #include<unistd.h>
    #include <errno.h>
    
    int main()
    {
        int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
        int connfd;
        int pid;
        int ret;
    
        char buff[1024];
        struct sockaddr_in serveraddr;
        serveraddr.sin_family = AF_INET;
        serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
        serveraddr.sin_port = htons(2222);
        bind(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
        listen(fd, 1024);
    
        while(1){
            connfd = accept(fd, (struct sockaddr *)NULL, NULL);
            if(connfd != -1)
            {
                while(1) {
                    ret = 0;
                    memset(buff, 0, sizeof(buff));
                    ret = recv(connfd, buff, strlen(buff)+1, 0);
                }
            }
        }
    
        return 0;
    }
    

    编译client和server

    root@master:~/test# gcc -o client client.c
    root@master:~/test# gcc -o server server.c
    
    1. 将client和server拷贝到pod中
    //获取pod name
    root@master:~/test# kubectl get pod -o wide
    NAME                      READY   STATUS    RESTARTS   AGE    IP               NODE     NOMINATED NODE   READINESS GATES
    client-797b85996c-tqhhh   1/1     Running   0          7d1h   172.18.219.65    master   <none>           <none>
    server-65d547c44-5mjgl    1/1     Running   0          7d1h   172.18.166.130   node1    <none>           <none>
    server-65d547c44-d9p9d    1/1     Running   0          13h    172.18.166.131   node1    <none>           <none>
    
    //将client和server分别拷贝到对应的pod中
    root@master:~/test# kubectl cp client client-797b85996c-tqhhh:/
    root@master:~/test# kubectl cp server server-65d547c44-5mjgl:/
    root@master:~/test# kubectl cp server server-65d547c44-d9p9d:/
    
    1. 接下来需要开几个终端,开始复现
    //两个终端上,启动server
    //terminal 1
    root@master:~# kubectl exec -it server-65d547c44-5mjgl bash
    root@server-65d547c44-5mjgl:/# ./server
    //terminal 2
    root@master:~# kubectl exec -it server-65d547c44-d9p9d bash
    root@server-65d547c44-d9p9d:/# ./server
    
    //terminal 3,在client pod中执行client,主动连接server
    root@master:~# kubectl exec -it client-797b85996c-tqhhh bash
    root@client-797b85996c-tqhhh:/# ./client
    please input:  --->出现此提示,说明connect server成功,即三次握手完成
    
    root@client-797b85996c-tqhhh:/# ./client
    please input:1   ---> 输入1
    send result: Success ---> 发送1成功
                           ---> 输入2之前,在另一个终端使用命令 conntrack -F 将连接跟踪表清空
    please input:2  ---> 输入2
    send result: Success --->虽然显示发送成功,同时也会接受到server的rst消息
    please input:3  ---> 再输入3,
    send result: Connection reset by peer --->因为收到rst消息,这次发送失败
    

    分析原因

    client向server的service ip10.108.33.37发起连接时,会在client pod所在node上经过netfilter/conntrack的处理,将service ip转换成server的pod id,具体转换规则可参见上面拓扑图中,也可以参考这篇文章
    简单点说就是每次新连接都会首先查找iptables规则,将service ip转换成server的pod ip,如果service的后端有多个pod,会将连接random到多个pod上,同时也会建立conntrack表项,后续的报文直接查找conntrack表项即可,不用再查找iptables规则。但是conntrack表项是有超时时间的,可通过 nf_conntrack_tcp_timeout_established 调整,我的环境上,默认值为86400,也就是24小时。

    root@master:~# sysctl -n net.netfilter.nf_conntrack_tcp_timeout_established
    86400
    

    所以可通过减小此值,缩短复现所用时间,或者干脆使用 "conntrack -F" 将所有的表项删除。为了尽快复现,上面步骤采用了后一种方法。在复现过程中,还可以使用 conntrack -E 获取表项创建,更新和删除事件,如下

    //terminal 4
    root@master:~# conntrack -E | grep 10.108.33.37
    //client发送的第一个syn报文,经过iptables将service ip 10.108.33.37转换成 pod id 172.18.166.131
    [NEW] tcp      6 120 SYN_SENT src=172.18.219.65 dst=10.108.33.37 sport=59468 dport=2222 [UNREPLIED] src=172.18.166.131 dst=172.18.219.65 sport=2222 dport=59468
    //收到server发送的syn和ack报文,状态转换到 SYN_RECV
    [UPDATE] tcp      6 60 SYN_RECV src=172.18.219.65 dst=10.108.33.37 sport=59468 dport=2222 src=172.18.166.131 dst=172.18.219.65 sport=2222 dport=59468
    //收到client发送的ack报文后,认为三次握手成功,状态转换到 ESTABLISHED。后续的数据报文根据此表项进行转发
    [UPDATE] tcp      6 86400 ESTABLISHED src=172.18.219.65 dst=10.108.33.37 sport=59468 dport=2222 src=172.18.166.131 dst=172.18.219.65 sport=2222 dport=59468 [ASSURED]
    
    //执行 conntrack -F 后,会将此表项删除
    [DESTROY] tcp      6 src=172.18.219.65 dst=10.108.33.37 sport=59468 dport=2222 src=172.18.166.131 dst=172.18.219.65 sport=2222 dport=59468 [ASSURED]
    
    //client再次发送数据2时,因为之前的表项被删除了,需要重新查找iptables规则,但这次转换的pod id为另一个pod的ip 172.18.166.130
    [NEW] tcp      6 300 ESTABLISHED src=172.18.219.65 dst=10.108.33.37 sport=59468 dport=2222 [UNREPLIED] src=172.18.166.130 dst=172.18.219.65 sport=2222 dport=59468
    //server收到新数据流,但是不是syn报文,就认为是不合法的,回复rst。conntrack收到rst后,也会将表项删除
    [DESTROY] tcp      6 src=172.18.219.65 dst=10.108.33.37 sport=59468 dport=2222 [UNREPLIED] src=172.18.166.130 dst=172.18.219.65 sport=2222 dport=59468
    

    除了查看conntrack表项事件外,也可以抓包查看,这里就不抓包了。
    kernel中tcp连接的处理
    对于监听的server来说,收到的新连接的第一个报文应该是syn报文,如果收到了ack报文,会回复rst给对端

    int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
        //处于listen状态的socket,收到ack报文,回复rst消息给对端
        if (tcp_rcv_state_process(sk, skb)) {
            rsk = sk;
            goto reset;
        }
        return 0;
    
    reset:
        tcp_v4_send_reset(rsk, skb);
    discard:
        kfree_skb(skb);
        /* Be careful here. If this function gets more complicated and
         * gcc suffers from register pressure on the x86, sk (in %ebx)
         * might be destroyed here. This current version compiles correctly,
         * but you have been warned.
         */
        return 0;
    
    int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)
        switch (sk->sk_state) {
        //处于listen状态的socket,收到ack报文,返回1
        case TCP_LISTEN:
            if (th->ack)
                return 1;
    

    client收到rst的处理,会设置 sk->sk_err 为 ECONNRESET,当下一次client send发送数据时,就会返回 ECONNRESET。

    tcp_v4_do_rcv -> tcp_rcv_established -> tcp_validate_incoming -> tcp_reset
    
    #define ECONNRESET  54  /* Connection reset by peer */
    
    /* When we get a reset we do this. */
    void tcp_reset(struct sock *sk)
        /* We want the right error as BSD sees it (and indeed as we do). */
        switch (sk->sk_state) {
        case TCP_SYN_SENT:
            sk->sk_err = ECONNREFUSED;
            break;
        case TCP_CLOSE_WAIT:
            sk->sk_err = EPIPE;
            break;
        case TCP_CLOSE:
            return;
        default:
            sk->sk_err = ECONNRESET;
        }
    

    总结
    综上可知,此问题出现主要是因为conntrack表项超时被删除,但是应用是不知道的,下次client发送数据时(ack报文),需要重新查找iptables规则转换目的ip,但是不一定会使用上次的pod id,如果是新的pod ip,对于监听此pod ip的server来说,收到的新连接的报文是ack报文,被认为是不合法的报文,所以才会给client回复rst消息。
    值得注意的是,此问题只在service有多个后端pod情况下才会出现,如果只有一个后端pod,每次新连接都能找到同一个pod ip,也就不会出问题。

    解决办法

    a. 可以调整 nf_conntrack_tcp_timeout_established 为更大值,但是只会减小问题发生的概率,不能根本解决问题。
    b. 应用设置keepalive使用保活机制,不让conntrack表项超时。这个需要合理设置keepalive时间和conntrack表项超时时间。

    使用如下函数使能keepalive

    int flag = 1;
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, (void*)&flag, sizeof(flag));
    

    使用keepalive机制后,有三个参数也需要设置一下

    tcp_keepalive_time: 没有数据报文多长时间后发送keepalive报文
    tcp_keepalive_probes: 发送keepalive报文次数
    tcp_keepalive_intvl: 发送keepalive报文间隔
    

    如果发送了tcp_keepalive_probes次keepalive报文后,仍然没有收到响应报文,则认为连接已经端口。

    这三个参数可以在代码中设置socket级别

    int _idle  = 60;
    int _intvl = 3;
    int _cnt   = 3;
    setsockopt(fd, SOL_TCP, TCP_KEEPIDLE, (void*)&_idle, sizeof(_idle));
    setsockopt(fd, SOL_TCP, TCP_KEEPINTVL, (void*)&_intvl, sizeof(_intvl));
    setsockopt(fd, SOL_TCP, TCP_KEEPCNT, (void*)&_cnt, sizeof(_cnt));
    

    也可以在node上设置,如下是默认值

    root@master:~# sysctl -a | grep keepalive
    net.ipv4.tcp_keepalive_intvl = 75
    net.ipv4.tcp_keepalive_probes = 9
    net.ipv4.tcp_keepalive_time = 7200
    

    可以通过下面命令查看是否使能keepalive机制,如果Timer字段为keepalive,则说明已经使能

    root@master:~# netstat -altpn --timers
    Active Internet connections (servers and established)
    Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name     Timer
    tcp6       0      0 192.168.122.20:6443     192.168.122.20:32864    ESTABLISHED 3572/kube-apiserver  keepalive (34.34/0/0)
    

    相关文章

      网友评论

          本文标题:k8s conntrack 表项超时导致tcp长连接中断

          本文链接:https://www.haomeiwen.com/subject/urrikltx.html