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

2021-04-09  本文已影响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)
上一篇下一篇

猜你喜欢

热点阅读