此问题是在公司业务中出现的,经过分析感觉和具体业务没啥关系,所以尝试在自搭的k8s环境中模拟复现,事实证明确实可以复现。拓扑如下
image.png
拓扑比较简单,client和server建立http长连接后,过大概一天后,client再发送数据到server,会收到server端的rst消息,导致client端发送数据时收到error(reset by peer)关闭socket连接。
先说下复现步骤,再分析原因。
复现步骤
- 创建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
- 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
- 将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:/
- 接下来需要开几个终端,开始复现
//两个终端上,启动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)
网友评论