为什么客户端发送SYN,服务端回ACK,没有回SYN,ACK

本文涉及的产品
公网NAT网关,每月750个小时 15CU
应用型负载均衡 ALB,每月750个小时 15LCU
网络型负载均衡 NLB,每月750个小时 15LCU
简介: 本文介绍了客户端发生SYN报文但是服务端回复ACK报文没有回复SYN,ACK报文的场景,深入分析了内核代码处理机制以及使用复现验证

某客户配置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;
}

该代码的过程:

  1. 接收到报文后,会调用 __inet_lookup_skb() 函数查找对应的 sock 结构;
  2. 如果连接的状态是 TIME_WAIT,会跳转到 do_time_wait 处理;
  3. 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


总的来说有如下几种情况

  1. 双方开启TCP时间戳
  1. 时间戳发生回绕:返回TCP_TW_ACK
  2. 时间戳未发生回绕: 返回TCP_TW_SYN
  1. 双方未开启TCP时间戳
  1. SYN序列号比TIME-WAIT中的大:返回TCP_TW_SYN
  2. 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快速回收。

相关实践学习
通过Ingress进行灰度发布
本场景您将运行一个简单的应用,部署一个新的应用用于新的发布,并通过Ingress能力实现灰度发布。
容器应用与集群管理
欢迎来到《容器应用与集群管理》课程,本课程是“云原生容器Clouder认证“系列中的第二阶段。课程将向您介绍与容器集群相关的概念和技术,这些概念和技术可以帮助您了解阿里云容器服务ACK/ACK Serverless的使用。同时,本课程也会向您介绍可以采取的工具、方法和可操作步骤,以帮助您了解如何基于容器服务ACK Serverless构建和管理企业级应用。 学习完本课程后,您将能够: 掌握容器集群、容器编排的基本概念 掌握Kubernetes的基础概念及核心思想 掌握阿里云容器服务ACK/ACK Serverless概念及使用方法 基于容器服务ACK Serverless搭建和管理企业级网站应用
目录
相关文章
|
7月前
|
Kubernetes 数据安全/隐私保护 Docker
|
3月前
|
存储 Kubernetes Cloud Native
部署Kubernetes客户端和Docker私有仓库的步骤
这个指南涵盖了部署Kubernetes客户端和配置Docker私有仓库的基本步骤,是基于最新的实践和工具。根据具体的需求和环境,还可能需要额外的配置和调整。
93 1
|
Kubernetes Java API
Kubernetes官方java客户端之六:OpenAPI基本操作
kubernetes官方java客户端的第二个基本能力:基于OpenAPI的功能接口
753 0
Kubernetes官方java客户端之六:OpenAPI基本操作
|
Kubernetes Java 开发工具
Kubernetes官方java客户端之三:外部应用
实战如何在K8S环境之外通过java客户端库操作K8S
220 0
Kubernetes官方java客户端之三:外部应用
|
7月前
|
Kubernetes 关系型数据库 MySQL
K8S客户端二 使用Rancher部署服务
使用rancher服务操作步骤
|
Kubernetes Java API
Kubernetes官方java客户端之四:内部应用
实战K8S的java客户端在K8S环境内部的开发和部署
161 2
Kubernetes官方java客户端之四:内部应用
|
JSON Kubernetes 数据格式
Kubernetes官方java客户端之七:patch操作
长文,助您通过java客户端对k8s进行各种patch操作
415 0
Kubernetes官方java客户端之七:patch操作
|
Kubernetes Java API
Kubernetes官方java客户端之五:proto基本操作
实战K8S官方java客户端的protobuf基本操作
319 0
Kubernetes官方java客户端之五:proto基本操作
|
JSON Kubernetes Java
Kubernetes官方java客户端之二:序列化和反序列化问题
K8S官方java客户端在使用中会遇到json处理问题,在此说明
205 0
Kubernetes官方java客户端之二:序列化和反序列化问题
|
Kubernetes Java API
Kubernetes官方java客户端之一:准备
学习K8S官方java客户端的第一篇,做好准备工作
412 0
Kubernetes官方java客户端之一:准备