Linux内核协议栈丢弃SYN报文的主要场景剖析

本文涉及的产品
公网NAT网关,每月750个小时 15CU
简介: 在排查网络问题的时候,经常会遇见TCP连接建立不成功的场景。如果能获取到两端抓包,两端抓包看起来如下:客户端在一直按照指数退避重传TCP SYN (因为首包没有获取到RTT及RTO,会在1, 2, 4, 8秒... 重传,直到完成net.ipv4.tcp_syn_retries次重传);服务器端能看到TCP SYN报文已经到达网卡,但是TCP协议栈没有任何回包。

作者:怀知

在排查网络问题的时候,经常会遇见TCP连接建立不成功的场景。如果能获取到两端抓包,两端抓包看起来如下:

  • 客户端在一直按照指数退避重传TCP SYN (因为首包没有获取到RTT及RTO,会在1, 2, 4, 8秒... 重传,直到完成net.ipv4.tcp_syn_retries次重传)
  • 服务器端能看到TCP SYN报文已经到达网卡,但是TCP协议栈没有任何回包。

因为这样的问题出现的频率不小,本文会从TCP协议栈方面总结常见原因。所谓的TCP协议栈方面的原因,就是TCP SYN报文已经到了内核的TCP处理模块,但在服务器端内核逻辑中不给客户端回SYNACK。客户端一直重传TCP SYN也可能由别的原因造成,比如服务器端有多块网卡造成的出入路径不一致,或者SYN报文被iptables规则阻拦,这些场景都不在本文的讨论范围之内。

Listen状态下处理TCP SYN的代码逻辑

本文以很多用户使用的CentOS 7的内核版本为基础,看看下TCP处理SYN的主要逻辑,结合案例处理的经验来分析主要可能出问题的点。处于listen状态的socket处理第一个TCP SYN报文的逻辑大概如下:

 tcp_v4_do_rcv() @net/ipv4/tcp_ipv4.c
        |--> tcp_rcv_state_process() @net/ipv4/tcp_input.c // 这个函数实现了绝大TCP状态下的接受报文的处理过程 (ESTABLISHED和TIME_WAIT除外),当然包括了我们关注的LISTEN状态
                |--> tcp_v4_conn_request() @@net/ipv4/tcp_ipv4.c // 当TCP socket出于LISTEN状态,且接收报文中TCP SYN flag是置位的,就来到这个函数中处理

CentOS中内核代码可能会有些调整,如果你需要跟踪源代码的确切行数,systemtap是一个很好的方法,如下:

# uname -r
3.10.0-693.2.2.el7.x86_64
# stap -l 'kernel.function("tcp_v4_conn_request")'
kernel.function("tcp_v4_conn_request@net/ipv4/tcp_ipv4.c:1303")

来到tcp_v4_conn_request()的逻辑里,函数逻辑的前几行如下:

image.png

进入到这个函数的前提条件是TCP socket出于LISTEN状态,且接收报文中TCP SYN flag是置位的。在进入函数逻辑后,可以发现函数要考虑各种可能发生的异常情况,但在现实中很多并不常见。比如我们在前几行看到的这两种情况:

  1. 1482行:拒绝广播和组播报文。
  2. 1490行:如果request queue (存放SYN报文的队列)满了,且isn为0,且want_cookie为flase, 则drop掉SYN报文。
    第一种情况意思比较明确,在实际中也没见过,在这里不讨论。第二种情况略为复杂,并且有小概率可能会碰到,下面简单看看:

第一个条件request queue 满实际是很容易发生的事情,syn flood攻击很容易完成这件事情。而isn在函数开始被赋值成TCP_SKB_CB(skb)->when,这个是TCP控制块结构体中用于计算RTT的字段。want_cookie则代表这syn syncookies的使用与否。在tcp_syn_flood_action()中的定义如下,如果ifdef了CONFIG_SYN_COOKIES, 内核参数的net.ipv4.tcp_syncookies也设置成1,则概述的返回是true, want_cookie则为true。
image.png

所以在上面这种drop SYN报文的情况中,真正的前提条件是没有开启net.ipv4.tcp_syncookies这个内核参数。而在实际生产系统中,net.ipv4.tcp_syncookies默认是打开的。Syn syncookies是一种时间(CPU计算)换空间(request queue队列)来抵御syn flood攻击的方式,在实际生产中看不到任何场景需要显示地关闭这个开关。所以总的来讲,1490行中这种请求在实际中也不太常见。

内核drop SYN报文的主要场景

本文的主要目的不是按照代码逻辑依次描述drop SYN报文的所有场景,而是结合之前的实际经验描述两种主要可能丢SYN报文的场景以及如何迅速判断的方法,帮助大家理解为什么服务器端会不回SYNACK。

1. Per-host PAWS检查造成drop SYN报文

问题现象

这是在实际生产环境中最常见的一种问题:对于net.ipv4.tcp_tw_recycle和net.ipv4.tcp_timestamps都开启的服务器,并且有NAT客户端访问时,这个问题出现的概率非常大。在客户端看来,问题现象通常新建连接时通时不通。

Per-host PAWS原理

PAWS是Protect Against Wrapped Sequences的简写,字面意思是防止sequence number缠绕。per-host, 是相对per-connection来讲的,就是对对端主机IP做检查而非对IP端口四元组做检查。

Per-host PAWS检查的方法是:对于被快速回收掉的TIME_WAIT socket的五元组对端主机IP, 为了防止来自同一主机的旧数据干扰,需要在60秒内新来的SYN报文TCP option中的timestamp是增长的。当客户端是在NAT环境里时这个条件往往不容易满足。

理论上只需要记住上面这句就能解掉很多客户端的三次握手时通时不通的问题。如果想要了解得更多,请看下文的详细解释。

为什么有per-host PAWS?

在RFC 1323中提到了per-host PAWS,如下:

(b) Allow old duplicate segments to expire.

To replace this function of TIME-WAIT state, a mechanism
would have to operate across connections. PAWS is defined
strictly within a single connection; the last timestamp is
TS.Recent is kept in the connection control block, and
discarded when a connection is closed.

An additional mechanism could be added to the TCP, a per-host

cache of the last timestamp received from any connection.
This value could then be used in the PAWS mechanism to reject
old duplicate segments from earlier incarnations of the
connection, if the timestamp clock can be guaranteed to have
ticked at least once since the old connection was open. This
would require that the TIME-WAIT delay plus the RTT together
must be at least one tick of the sender's timestamp clock.
Such an extension is not part of the proposal of this RFC.

Note that this is a variant on the mechanism proposed by

Garlick, Rom, and Postel [Garlick77], which required each
host to maintain connection records containing the highest
sequence numbers on every connection. Using timestamps
instead, it is only necessary to keep one quantity per remote
host, regardless of the number of simultaneous connections to
that host.

在tcp_minisocks.c的代码注释中也阐述了需要TIME_WAIT的原因,和快速回收TIME_WAIT的理论基础:PAWS机制,如下:

Main purpose of TIME-WAIT state is to close connection gracefully,

when one of ends sits in LAST-ACK or CLOSING retransmitting FIN
(and, probably, tail of data) and one or more our ACKs are lost.

What is TIME-WAIT timeout? It is associated with maximal packet

lifetime in the internet, which results in wrong conclusion, that
it is set to catch "old duplicate segments" wandering out of their path.
It is not quite correct. This timeout is calculated so that it exceeds
maximal retransmission timeout enough to allow to lose one (or more)
segments sent by peer and our ACKs. This time may be calculated from RTO.

When TIME-WAIT socket receives RST, it means that another end

finally closed and we are allowed to kill TIME-WAIT too.

Second purpose of TIME-WAIT is catching old duplicate segments.

Well, certainly it is pure paranoia, but if we load TIME-WAIT
with this semantics, we MUST NOT kill TIME-WAIT state with RSTs.

If we invented some more clever way to catch duplicates

(f.e. based on PAWS), we could truncate TIME-WAIT to several RTOs.

根据上面RFC的描述和内核代码的注释描述,可以看出Linux kernel实现了TIME-WAIT状态的快速回收机制,快速回收的细节可以参考文章《为何客户端突然出现大量TIME_WAIT堆积》中的“TCP TIME_WAIT的快速回收”部分。而Linux可以抛弃60秒TIME-WAIT时间,直接缩短到3.5倍RTO时间,是因为Linux使用了一些“聪明”的方法来捕捉旧重复报文(例如:基于PAWS机制),而Linux中确实使用了per-host PAWS来防止前面连接中的报文串扰到新的连接中。

Linux内核的实现

在tcp_ipv4.c中,在接收SYN之前,如果符合如下两个条件,需要检查peer是不是proven,即per-host PAWS检查:

  • 收到的报文有TCP option timestamp时间戳
  • 本机开启了内核参数net.ipv4.tcp_tw_recycle
...
else if (!isn) {
/* VJ's idea. We save last timestamp seen
 * from the destination in peer table, when entering
 * state TIME-WAIT, and check against it before
 * accepting new connection request.
 *
 * If "isn" is not zero, this request hit alive
 * timewait bucket, so that all the necessary checks
 * are made in the function processing timewait state.
 */
if (tmp_opt.saw_tstamp &&  // 收到的报文中有TCP timestamp option
    tcp_death_row.sysctl_tw_recycle &&  // 开启了net.ipv4.tcp_tw_recycle内核参数
    (dst = inet_csk_route_req(sk, &fl4, req)) != NULL &&
    fl4.daddr == saddr) {
    if (!tcp_peer_is_proven(req, dst, true)) {  // peer检查(per-host PAWS检查)
        NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);
        goto drop_and_release;
    }
}

在tcp_metrics.c中,是Linux per-host PAWS的实现逻辑,如下。简单描述下就是在这一节开始提到的:需要在60秒内新来的SYN报文TCP option中的timestamp是增长的。

bool tcp_peer_is_proven(struct request_sock *req, struct dst_entry *dst, bool paws_check)
{
    struct tcp_metrics_block *tm;
    bool ret;
    ...
    
    tm = __tcp_get_metrics_req(req, dst);
    if (paws_check) {
      if (tm &&
          // peer 信息保存的时间离现在在60秒(TCP_PAWS_MSL)之内
          (u32)get_seconds() - tm->tcpm_ts_stamp < TCP_PAWS_MSL &&
          // peer 信息中保存的timestamp 比当前收到的SYN报文中的timestamp大1(TCP_PAWS_WINDOW)
          (s32)(tm->tcpm_ts - req->ts_recent) > TCP_PAWS_WINDOW)
        ret = false;
      else
        ret = true;
    }
}

对NAT环境中客户端的影响

在Linux发明这个per-host PAWS机制来让TIME-WAIT状态快速回收时,认为这是"clever way",是基于IPv4地址池数量充足的网络环境下来做的解决方案。而随着Internet的快速发展,NAT的应用越来越普遍,客户端在SNAT设备内部的来访问同个服务器的环境非常普遍。

Per-host PAWS机制利用TCP option里的timestamp字段的增长来判断串扰数据,而timestamp是根据客户端各自的CPU tick得出的值,对于NAT内部的设备而言可以说是完全随机。当客户端主机1通过NAT和服务器建立TCP连接,然后服务器主动关闭并且快速回收TIME-WAIT状态socket后,其余客户端主机的新连接源IP和服务器peer table里记录的一样,但是TCP option里的timestamp和当时服务器记录的主机1的timestamp比较是完全随机的,或者理解为50%概率。如果timestamp比主机1的小,则这个新建连接在60秒内就会被拒绝,60秒后新建连接又可以成功;如果timestamp比主机1的大,则新建连接直接成功。所以在客户端看来,问题现象就是新建连接时通时不通。

这就是使用TIME-WAIT快速回收机制对NAT环境客户端带来的副作用。这个副作用不是在设计per-host PAWS机制之初就能预料到了,因为当时的网络环境和现在大为不同。而在现在的网络环境下,唯一的建议就是关闭TIME-WAIT快速回收,即让net.ipv4.tcp_tw_recycle=0。关闭net.ipv4.tcp_timestamps来去掉TCP option中的timestamp时间戳也可以解决此问题,但是因为timestamp是计算RTT和RTO的基础,通常不建议关闭。

Troubleshooting

在实际生产中,troubleshoot这个问题是一件不太容易的事情。但是对于net.ipv4.tcp_tw_recycle和net.ipv4.tcp_timestamps都开启的服务器,并且有NAT客户端访问时,这个问题出现的概率非常大,所以如果获取到这两个内核参数的设置和客户端网络的NAT环境,就可以做个基本判定。

另外可以参考netstat -s中的统计,这个统计会汇集从/proc/net/snmp,/proc/net/netstat和/proc/net/sctp/snmp拿到的数据。如下,下面这个统计值表示由于timestamp的原因多少新建连接被拒绝,这是一个历史统计总值,所以两个时间点的差值对问题排查更加有意义。

xx passive connections rejected because of time stamp

2. Accept queue满造成drop SYN报文

问题现象

没有统一且有规律的现象,发生在TCP accept queue满的时候。这种情况往往发生在用户空间的应用程序有问题的时候,总体来说发生的概率不是很大。

原理

Accept queue 翻译成完全连接队列或者接收队列,为了避免歧义,本文统一用英文原名。新的连接完成3次握手后进入accept queue, 用户空间的应用调用accept系统调用来获取这个连接,并创建一个新的socket,返回与socket关联的文件描述符(fd)。在用户空间可以利用poll等机制通过readable event来获取到有新完成3次握手的连接进入到了accept queue, 获得通知后立即调用accept系统调用来获取新的连接。

Accept queue的长度本身是有限的,它的长度取决于min [backlog, net.core.somaxconn],即这个两个参数中较小的值。

  • backlog 是应用调用listen系统调用时的第2个参数。参考#include 中的int listen(int sockfd, int backlog)。
  • net.core.somaxconn 是系统内核参数,默认是128。应用listen的时候如果设置的backlog比较大,如NGINX默认512,但是这个全局内核参数不调整的话,accept queue的长度还是会决定于其中较小的net.core.somaxconn。
    即使是并发连接量很大的情况,应用程序正常利用accpet系统调用取accept queue里的连接都不会因为效率问题而获取不及时。但是如果由于应用程序阻塞,发生取连接不及时的情况可能就可能会导致accept queue满的情况的,从而对新来的SYN报文进行丢弃。

Linux内核的实现

在tcp_ipv4中,accept queue满拒绝SYN报文的实现很简单,如下:

/* Accept backlog is full. If we have already queued enough
 * of warm entries in syn queue, drop request. It is better than
 * clogging syn queue with openreqs with exponentially increasing
 * timeout.
 */
// 如果accept queue满了,并且SYN queue中有未SYNACK重传过的半连接,则丢弃SYN请求
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
  NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
  goto drop;
}

在sock.h中定义了accept queue满的inline函数:

static inline bool sk_acceptq_is_full(const struct sock *sk)
{
    return sk->sk_ack_backlog > sk->sk_max_ack_backlog;
}

在inet_connection_sock.h和request_sock.h中定义了判断SYN queue中有未SYNACK重传过的半连接的方法:

static inline int inet_csk_reqsk_queue_young(const struct sock *sk)
{
    return reqsk_queue_len_young(&inet_csk(sk)->icsk_accept_queue);
}

static inline int reqsk_queue_len_young(const struct request_sock_queue *queue)
{
    return queue->listen_opt->qlen_young;
}

如上是3.10中的实现,其实需要判断两个条件,“accept queue满”是一个,“SYN queue中有未SYNACK重传过的半连接”是另外一个,因为通常accept queue满的时候都是有大量新进连接的时候,所以第二个条件是通常是同时满足的。如果accept queue满的时候,SYN queue中不存在未SYNACK重传过的半连接,则Linux内核还是会接受这个SYN并返回SYNACK。这种情况在实际生产中非常少见,除非发生应用进程完全停滞的情况,比如用SIGSTOP信号来停进程,这样在accept queue满的时候TCP内核协议栈仍然不会直接drop SYN报文。

因为accept queue满而drop SYN的逻辑,在比较新的内核版本中略微有变化。比如4.10的版本,内核的判断条件从两个变成了一个,即只判断accept queue是不是满,所以在这些版本中,accept queue满了后内核一定会直接drop SYN报文。

Troubleshooting

这类问题往往发生在用户空间的应用程序有问题的时候,总体来说发生的概率不是很大。有如下两种方式确认:

利用ss命令查看实时问题
利用ss命令的选项-l查看listening socket,可以看到Recv-Q和Send-Q,其中Recv-Q表示当前accept queue中的连接数量,Send-Q表示accept queue的最大长度。如下:可以看到几个进程的accept queue默认是128,因为受到系统net.core.somaxconn=128的限制。
image.png

netstat -s 统计
可以参考netstat -s中的统计,下面这个统计值表示由于socket overflowed原因多少新建连接被拒绝,同样这是一个历史统计总值,两个时间点的差值对问题排查更加有意义。

xx times the listen queue of a socket overflowed

解决建议

如果确认是由于accept queue引起的SYN报文被drop的问题,很自然会想到的解决方案是增加accept queue的长度,同时增大backlog和net.core.somaxconn两个参数能增加accept queue长度。但是通常这个只能“缓解”,而且最有可能出现的局面是accept queue在增大后又迅速被填满。所以解决这个问题最建议的方式是从应用程序看下为什么accept新连接慢,从根源上解决问题。

总结

上面总结了per-host PAWS检查和accept queue满造成SYN被丢弃这两种最主要的场景,并分别介绍了现象,原理,代码逻辑和排查方法。这两种场景能覆盖平时的绝大部分TCP协议栈丢SYN的问题,如果遇到其他协议栈里丢SYN的情况,需要结合参数配置和代码逻辑进一步case by case地排查。

相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
本实验从Web应用上云这个最基本的、最普遍的需求出发,帮助IT从业者们通过“阿里云Web应用上云解决方案”,了解一个企业级Web应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
相关文章
|
15天前
|
算法 Linux
深入探索Linux内核的内存管理机制
本文旨在为读者提供对Linux操作系统内核中内存管理机制的深入理解。通过探讨Linux内核如何高效地分配、回收和优化内存资源,我们揭示了这一复杂系统背后的原理及其对系统性能的影响。不同于常规的摘要,本文将直接进入主题,不包含背景信息或研究目的等标准部分,而是专注于技术细节和实际操作。
|
15天前
|
存储 缓存 网络协议
Linux操作系统的内核优化与性能调优####
本文深入探讨了Linux操作系统内核的优化策略与性能调优方法,旨在为系统管理员和高级用户提供一套实用的指南。通过分析内核参数调整、文件系统选择、内存管理及网络配置等关键方面,本文揭示了如何有效提升Linux系统的稳定性和运行效率。不同于常规摘要仅概述内容的做法,本摘要直接指出文章的核心价值——提供具体可行的优化措施,助力读者实现系统性能的飞跃。 ####
|
16天前
|
监控 算法 Linux
Linux内核锁机制深度剖析与实践优化####
本文作为一篇技术性文章,深入探讨了Linux操作系统内核中锁机制的工作原理、类型及其在并发控制中的应用,旨在为开发者提供关于如何有效利用这些工具来提升系统性能和稳定性的见解。不同于常规摘要的概述性质,本文将直接通过具体案例分析,展示在不同场景下选择合适的锁策略对于解决竞争条件、死锁问题的重要性,以及如何根据实际需求调整锁的粒度以达到最佳效果,为读者呈现一份实用性强的实践指南。 ####
|
16天前
|
缓存 监控 网络协议
Linux操作系统的内核优化与实践####
本文旨在探讨Linux操作系统内核的优化策略与实际应用案例,深入分析内核参数调优、编译选项配置及实时性能监控的方法。通过具体实例讲解如何根据不同应用场景调整内核设置,以提升系统性能和稳定性,为系统管理员和技术爱好者提供实用的优化指南。 ####
|
18天前
|
负载均衡 算法 Linux
深入探索Linux内核调度机制:公平与效率的平衡####
本文旨在剖析Linux操作系统内核中的进程调度机制,特别是其如何通过CFS(完全公平调度器)算法实现多任务环境下资源分配的公平性与系统响应速度之间的微妙平衡。不同于传统摘要的概览性质,本文摘要将直接聚焦于CFS的核心原理、设计目标及面临的挑战,为读者揭开Linux高效调度的秘密。 ####
32 3
|
21天前
|
负载均衡 算法 Linux
深入探索Linux内核调度器:公平与效率的平衡####
本文通过剖析Linux内核调度器的工作机制,揭示了其在多任务处理环境中如何实现时间片轮转、优先级调整及完全公平调度算法(CFS),以达到既公平又高效地分配CPU资源的目标。通过对比FIFO和RR等传统调度策略,本文展示了Linux调度器如何在复杂的计算场景下优化性能,为系统设计师和开发者提供了宝贵的设计思路。 ####
33 6
|
20天前
|
消息中间件 安全 Linux
深入探索Linux操作系统的内核机制
本文旨在为读者提供一个关于Linux操作系统内核机制的全面解析。通过探讨Linux内核的设计哲学、核心组件、以及其如何高效地管理硬件资源和系统操作,本文揭示了Linux之所以成为众多开发者和组织首选操作系统的原因。不同于常规摘要,此处我们不涉及具体代码或技术细节,而是从宏观的角度审视Linux内核的架构和功能,为对Linux感兴趣的读者提供一个高层次的理解框架。
|
1月前
|
Linux 网络安全 数据安全/隐私保护
Linux 超级强大的十六进制 dump 工具:XXD 命令,我教你应该如何使用!
在 Linux 系统中,xxd 命令是一个强大的十六进制 dump 工具,可以将文件或数据以十六进制和 ASCII 字符形式显示,帮助用户深入了解和分析数据。本文详细介绍了 xxd 命令的基本用法、高级功能及实际应用案例,包括查看文件内容、指定输出格式、写入文件、数据比较、数据提取、数据转换和数据加密解密等。通过掌握这些技巧,用户可以更高效地处理各种数据问题。
95 8
|
1月前
|
监控 Linux
如何检查 Linux 内存使用量是否耗尽?这 5 个命令堪称绝了!
本文介绍了在Linux系统中检查内存使用情况的5个常用命令:`free`、`top`、`vmstat`、`pidstat` 和 `/proc/meminfo` 文件,帮助用户准确监控内存状态,确保系统稳定运行。
260 6
下一篇
DataWorks