某客户配置SLB 7层监听,通过SLB访问业务偶发出现5秒才响应的情况,通过SLB访问日志查看是SLB与upstream TCP建立连接5秒超时后SLB重试转发请求到另外一个upstream后端,另外一个upstream后端建立连接成功并请求成功,由于SLB默认的TCP建联时间是5秒,第一个请求建联失败重试的请求建联正常,所以总请求时间就变成了5.x秒。
通过SLB访问日志查看,SLB后端10台upstream相同概率出现建联失败的情况,失败率不到1%。SLB 7层监听场景与后端建联失败是比较常见的一个现象,比较常见的原因是因为SLB与upstream是短连接,所以每个HTTP请求都会单独建立TCP连接,在QPS比较大的情况下,后端如果TCP监听队列比较小,就会出现TCP半连接或者全连接队列溢出导致建联失败,这种场景可以调整TCP内核参数和应用的backlog缓解问题。
此问题也是照常在后端抓包看是否是后端没有响应syn报文导致建联失败,通过抓包发现并不是连接队列溢出导致丢弃SYN而无法建联问题,抓包现场如下,SLB发给后端SYN报文,但是后端返回是ACK报文,且ACK number也不是期望的序号,正常服务端应该返回SYN ACK报文,且ACK序列号应该是客户端的Seq+1。由于服务端返回了ACK报文导致SLB无法建立连接,重传2此后5秒超时建联失败。
服务端收到SYN,回复的是ACK,没有回复SYN ACK的场景之前遇到过但是没有深究,此问题在容器/K8S环境中比较容易遇到,怀疑是因为K8S环境中会有比较多的源/目IP转换操作可能导致TCP连接串流、异常等场景。
为什么客户端发送syn,服务端回ack,没有回syn,ack
在其中一个ECS上抓包,抓取所有POD与ECS的报文,通过过滤报文异常请求如下。100.116.x.x是SLB的回源local ip地址段,10.254.109.57为ECS 网卡eth0的IP,172.29.67.0为10.254.109.57这个K8S worker节点的flannel0网卡IP,172.20.70.12为业务POD IP。
第一个连接ECS收到的五元组为: 100.116.238.146:28562-->10.254.109.57:30040
第一个连接经过IPtables转换后:172:20.67.0:40350-->172.20.70.12:8080
第一个连接请求结束后在32秒后发起了第二个请求
第二个连接ECS收到的五元组为: 100.116.238.186:35416-->10.254.109.57:30040
第二个连接经过IPtables转换后:172:20.67.0:40350-->172.20.70.12:8080
可以看到两次请求ECS收到的5元组是不同的,但是经过IPtables转换后五元组就相同了,而且两次连接请求相差32秒左右,由于第一个请求是服务端POD主动发起Fin的,所以POD在收到第二次请求的时候第一个连接五元组的TCP状态是在TIME-WAIT状态。
在 TIME_WAIT 状态的 TCP 连接,收到 SYN 后会发生什么?
在TCP正常挥手过程中,处于TIME_WAIT状态的TCP连接,如果收到相同五元组的SYN报文服务端会怎么处理?
源码分析
下面源码分析是基于 Linux 5.10 版本的内核代码。
Linux 内核在收到 TCP 报文后,会执行 tcp_v4_rcv 函数,在该函数和 TIME_WAIT 状态相关的主要代码如下:
int tcp_v4_rcv(struct sk_buff *skb) { struct sock *sk; ... //收到报文后,会调用此函数,查找对应的 sock sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source, th->dest, sdif, &refcounted); if (!sk) goto no_tcp_socket; process: //如果连接的状态为 time_wait,会跳转到 do_time_wait if (sk->sk_state == TCP_TIME_WAIT) goto do_time_wait; ... do_time_wait: ... //由tcp_timewait_state_process函数处理在 time_wait 状态收到的报文 switch (tcp_timewait_state_process(inet_twsk(sk), skb, th)) { // 如果是TCP_TW_SYN,那么允许此 SYN 重建连接 // 即允许TIM_WAIT状态变更为SYN_RECV case TCP_TW_SYN: { struct sock *sk2 = inet_lookup_listener(....); if (sk2) { .... goto process; } } // 如果是TCP_TW_ACK,那么,返回记忆中的ACK case TCP_TW_ACK: tcp_v4_timewait_ack(sk, skb); break; // 如果是TCP_TW_RST直接发送RESET包 case TCP_TW_RST: tcp_v4_send_reset(sk, skb); inet_twsk_deschedule_put(inet_twsk(sk)); goto discard_it; // 如果是TCP_TW_SUCCESS则直接丢弃此包,不做任何响应 case TCP_TW_SUCCESS:; } goto discard_it; }
该代码的过程:
- 接收到报文后,会调用 __inet_lookup_skb() 函数查找对应的 sock 结构;
- 如果连接的状态是 TIME_WAIT,会跳转到 do_time_wait 处理;
- 由 tcp_timewait_state_process() 函数来处理收到的报文,处理后根据返回值来做相应的处理。
接下来,看 tcp_timewait_state_process() 函数是如何判断 SYN 包的。
enum tcp_tw_status tcp_timewait_state_process(struct inet_timewait_sock *tw, struct sk_buff *skb, const struct tcphdr *th) { ... //paws_reject 为 false,表示没有发生时间戳回绕 //paws_reject 为 true,表示发生了时间戳回绕 bool paws_reject = false; tmp_opt.saw_tstamp = 0; //TCP头中有选项且旧连接开启了时间戳选项 if (th->doff > (sizeof(*th) >> 2) && tcptw->tw_ts_recent_stamp) { //解析选项 tcp_parse_options(twsk_net(tw), skb, &tmp_opt, 0, NULL); if (tmp_opt.saw_tstamp) { ... //检查收到的报文的时间戳是否发生了时间戳回绕 paws_reject = tcp_paws_reject(&tmp_opt, th->rst); } } .... //是SYN包、没有RST、没有ACK、时间戳没有回绕(开启timestamp参数) //是SYN包、没有RST、没有ACK、序列号递增(未开启timestamp参数) if (th->syn && !th->rst && !th->ack && !paws_reject && (after(TCP_SKB_CB(skb)->seq, tcptw->tw_rcv_nxt) || (tmp_opt.saw_tstamp && //新连接开启了时间戳 (s32)(tcptw->tw_ts_recent - tmp_opt.rcv_tsval) < 0))) { //时间戳没有回绕 // 初始化序列号 u32 isn = tcptw->tw_snd_nxt + 65535 + 2; if (isn == 0) isn++; TCP_SKB_CB(skb)->tcp_tw_isn = isn; return TCP_TW_SYN; //允许重用TIME_WAIT四元组重新建立连接 } if (!th->rst) { // 如果时间戳回绕,或者报文里包含ack,则将 TIMEWAIT 状态的持续时间重新延长 if (paws_reject || th->ack) inet_twsk_schedule(tw, TCP_TIMEWAIT_LEN); // 返回TCP_TW_ACK, 发送上一次的 ACK return tcp_timewait_check_oow_rate_limit( tw, skb, LINUX_MIB_TCPACKSKIPPEDTIMEWAIT); } inet_twsk_put(tw); return TCP_TW_SUCCESS; } tcp_timewait_check_oow_rate_limit(struct inet_timewait_sock *tw, const struct sk_buff *skb, int mib_idx) { struct tcp_timewait_sock *tcptw = tcp_twsk((struct sock *)tw); if (!tcp_oow_rate_limited(twsk_net(tw), skb, mib_idx, &tcptw->tw_last_oow_ack_time)) { /* Send ACK. Note, we do not put the bucket, * it will be released by caller. */ return TCP_TW_ACK; } /* We are rate-limiting, so just release the tw sock and drop skb. */ inet_twsk_put(tw); return TCP_TW_SUCCESS; }
从源码可以看到,核心判断语句是 (th->syn && !th->rst && !th->ack && !paws_reject && (after(TCP_SKB_CB(skb)->seq, tcptw->tw_rcv_nxt) ||
(tmp_opt.saw_tstamp && (s32)(tcptw->tw_ts_recent - tmp_opt.rcv_tsval) < 0)))
- th->syn:是否为SYN,是则为1,不是则为0,此判断中永久为1
- !th->rst:是否为RESET,是则0,不是则为1,此判断中永久为1
- !th->ack:是否为ACK,是则0,不是则为1,此判断中永久为1
- !paws_reject:是否发生序列号回绕,发生回绕则为0,未发生回绕或者未开启时间戳则为1
- after(TCP_SKB_CB(skb)->seq, tcptw->tw_rcv_nxt):SYN包的序列号是否比TIME-WAIT时候增大,增大则为1,减小为0
- tmp_opt.saw_tstamp:是否开启时间错,开启则为1,未开启则为0
- (s32)(tcptw->tw_ts_recent - tmp_opt.rcv_tsval) < 0:TIME-WAIT记录的时间搓是否比SYN报文的小,小则为1,大则为0
总的来说有如下几种情况
- 双方开启TCP时间戳
- 时间戳发生回绕:返回TCP_TW_ACK
- 时间戳未发生回绕: 返回TCP_TW_SYN
- 双方未开启TCP时间戳
- SYN序列号比TIME-WAIT中的大:返回TCP_TW_SYN
- SYN序列号比TIME-WAIT中的小:返回TCP_TW_ACK
当收到 SYN 包后,如果该 SYN 包的时间戳没有发生回绕,也就是时间戳是递增的。就会初始化一个序列号,然后返回 TCP_TW_SYN,接着就重用该连接,也就跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
如果双方都没有启用 TCP 时间戳机制,就只需要判断 SYN 包的序列号有没有发生回绕,如果 SYN 的序列号大于下一次期望收到的序列号,就可以跳过 2MSL,重用该连接。
如果收到的 SYN 是合法的,tcp_timewait_state_process() 函数就会返回 TCP_TW_SYN,然后重用此连接。如果收到的 SYN 是非法的,tcp_timewait_state_process() 函数就会返回 TCP_TW_ACK,然后会回上次发过的 ACK。
结论
当处于TCP TIME-WAIT连接时收到SYN后会判断SYN是否合法,如果合法则返回SYN,ACK报文,如果是非法SYN会返回上一次四次挥手的ACK报文。
开启TCP时间戳
- 合法 SYN:客户端SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要大。
- 非法 SYN:客户端SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要小。
未开启TCP时间戳
- 合法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要大。
- 非法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要小。
收到合法 SYN
如果处于 TIME_WAIT 状态的连接收到“合法的 SYN ”后,就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
用下图作为例子,双方都启用了 TCP 时间戳机制,TSval 是发送报文时的时间戳:
上图中,在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval (41),用 ts_recent 变量保存。处于 TIME_WAIT 状态的连接收到 SYN 后,因为SYN 的 TSval(50) 大于 ts_recent(41),所以是一个「合法的 SYN」,于是就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
收到非法的 SYN
如果处于 TIME_WAIT 状态的连接收到“非法的 SYN ”后,就会再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号(ack num),就回 RST 报文给服务端。
用下图作为例子,双方都启用了 TCP 时间戳机制,TSval 是发送报文时的时间戳:
上图中,在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval (41),用 ts_recent 变量保存。处于 TIME_WAIT 状态的连接收到 SYN 后,因为SYN 的 TSval(30) 小于 ts_recent(41),所以是一个“非法的 SYN”,于是服务端重传一次第四次挥手时候的ACK包,客户端收到后,发现并不是自己期望收到确认号,就回 RST 报文给服务端。
复现抓包
使用scapy构造五元组连接来复现看是否与源码中定义的内容一致。
ans, unans = sr(IP(dst = "目的IP") / TCP(sport = 源端口 , dport = 目的端口, flags = "S",seq = 序列号,options=[('Timestamp', (时间戳, 0))]))
双方开启时间戳
构造TCP连接,发出与处于TIME-WAIT状态下相同的五元组连接,设置不同的序列号和时间戳。
SYN报文 |
序列号增大,时间戳增大 |
序列号增大,时间戳减小 |
序列号减小,时间戳增大 |
序列号减小,时间戳减小 |
结果 |
返回SYN ACK |
返回四次挥手ACK |
返回SYN ACK |
返回四次挥手ACK |
双方关闭时间戳
构造TCP连接,发出与处于TIME-WAIT状态下相同的五元组连接,设置不同的序列号。
SYN报文 |
序列号增大 |
序列号减小 |
结果 |
返回SYN ACK |
返回四次挥手ACK |
客户端关闭时间戳,服务端开启时间戳
在客户端关闭tcp timestamp,在服务端开启timestamp测试
SYN报文 |
序列号增大 |
序列号减小 |
结果 |
返回SYN ACK |
返回四次挥手ACK |
客户端开启时间戳,服务端关闭时间戳
在客户端开启tcp timestamp,在服务端关闭timestamp测试
SYN报文 |
序列号增大,时间戳增大 |
序列号增大,时间戳减小 |
序列号减小,时间戳增大 |
序列号减小,时间戳减小 |
结果 |
返回SYN ACK |
返回SYN ACK |
返回四次挥手ACK |
返回四次挥手ACK |
客户端第一次开启时间戳,第二次关闭时间戳,服务端开启时间戳
SYN报文 |
序列号增大 |
序列号减小 |
结果 |
返回SYN ACK |
返回四次挥手ACK |
客户端第一次关闭时间戳,第二次开启时间戳,服务端开启时间戳
SYN报文 |
序列号增大 |
序列号减小 |
结果 |
返回SYN ACK |
返回四次挥手ACK |
总结
SLB发起的连接请求到达ECS后,K8S环境中IPTABLES会把SLB的LOCAL IP转换为Flannel0网卡IP,由于网卡IP是固定的,只有源端口变化,所以当请求量比较大的时候,会复用TIME-WAIT状态的TCP连接。当复用五元组的时候,如果TCP时间戳timestamps没有递增,容器POD协议栈会返回四次挥手的ACK报文,此ACK报文不是SLB期望的SYN ACK报文,导致SLB与后端服务建联超时,建联超时后SLB重试连接请求发给另外一个后端,另外一个后端重新转化源地址如果没有命中重传一次四次挥手ACK的场景则可以正常建立连接,请求可以正常完成。
解决方案
由于这个问题是Linux 内核TCP协议栈正常的行为,想要解决此问题需要改变请求的请求方式避免服务端处于Time-Wait状态的连接收到相同五元组的SYN报文。
方案一(最佳):使用阿里云terway eni的集群,由于terway是从SLB直接把报文发生给了POD的,不涉及snat地址转换,就在根源上避免了地址转换后导致五元组相同从而命中发生SYN返回ACK的条件,但是阿里云ACK集群无法在现有集群更改网络模式,无法直接从flannel调整成terway,只能重新创建集群,所以实际操作难度比较大。
方案二(次之):将lb后面的pod的网络模式改成hostnetwork,不走nodeport,此方案也是避免了经过POD的报文在node节点地址转换导致5元组相同。
方案三:流量扩展策略使用cluster及增加前后pod数量,降低频率 ,对集群运维最友好,(实测从千分之一降低到十万分之一),但是只是降低概率并不能完全避免问题。
方案四:开启服务端Time-Wait快速功能,也就是在内核中同时启用 net.ipv4.tcp_timestamps 和net.ipv4.tcp_tw_recycle,使TimeWait状态的连接在POD中快速消失,也可以避免此问题。但是在Linux在4.12版本中取消net.ipv4.tcp_tw_recycle 参数配置,无法在开启TimeWait快速回收。