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()的逻辑里,函数逻辑的前几行如下:
tcpsyn1

进入到这个函数的前提条件是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。
tcpsyn2

所以在上面这种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.hrequest_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的限制。
tcpsyn3

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应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
目录
相关文章
|
3天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
19 4
|
7天前
|
缓存 算法 Linux
深入理解Linux内核调度器:公平性与性能的平衡####
真知灼见 本文将带你深入了解Linux操作系统的核心组件之一——完全公平调度器(CFS),通过剖析其设计原理、工作机制以及在实际系统中的应用效果,揭示它是如何在众多进程间实现资源分配的公平性与高效性的。不同于传统的摘要概述,本文旨在通过直观且富有洞察力的视角,让读者仿佛亲身体验到CFS在复杂系统环境中游刃有余地进行任务调度的过程。 ####
27 6
|
5天前
|
缓存 资源调度 安全
深入探索Linux操作系统的心脏——内核配置与优化####
本文作为一篇技术性深度解析文章,旨在引领读者踏上一场揭秘Linux内核配置与优化的奇妙之旅。不同于传统的摘要概述,本文将以实战为导向,直接跳入核心内容,探讨如何通过精细调整内核参数来提升系统性能、增强安全性及实现资源高效利用。从基础概念到高级技巧,逐步揭示那些隐藏在命令行背后的强大功能,为系统管理员和高级用户打开一扇通往极致性能与定制化体验的大门。 --- ###
26 9
|
4天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
21 6
|
5天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
21 5
|
5天前
|
算法 Unix Linux
深入理解Linux内核调度器:原理与优化
本文探讨了Linux操作系统的心脏——内核调度器(Scheduler)的工作原理,以及如何通过参数调整和代码优化来提高系统性能。不同于常规摘要仅概述内容,本摘要旨在激发读者对Linux内核调度机制深层次运作的兴趣,并简要介绍文章将覆盖的关键话题,如调度算法、实时性增强及节能策略等。
|
6天前
|
存储 监控 安全
Linux内核调优的艺术:从基础到高级###
本文深入探讨了Linux操作系统的心脏——内核的调优方法。文章首先概述了Linux内核的基本结构与工作原理,随后详细阐述了内核调优的重要性及基本原则。通过具体的参数调整示例(如sysctl、/proc/sys目录中的设置),文章展示了如何根据实际应用场景优化系统性能,包括提升CPU利用率、内存管理效率以及I/O性能等关键方面。最后,介绍了一些高级工具和技术,如perf、eBPF和SystemTap,用于更深层次的性能分析和问题定位。本文旨在为系统管理员和高级用户提供实用的内核调优策略,以最大化Linux系统的效率和稳定性。 ###
|
5天前
|
Java Linux Android开发
深入探索Android系统架构:从Linux内核到应用层
本文将带领读者深入了解Android操作系统的复杂架构,从其基于Linux的内核到丰富多彩的应用层。我们将探讨Android的各个关键组件,包括硬件抽象层(HAL)、运行时环境、以及核心库等,揭示它们如何协同工作以支持广泛的设备和应用。通过本文,您将对Android系统的工作原理有一个全面的认识,理解其如何平衡开放性与安全性,以及如何在多样化的设备上提供一致的用户体验。
|
8天前
|
Linux 数据库
Linux内核中的锁机制:保障并发操作的数据一致性####
【10月更文挑战第29天】 在多线程编程中,确保数据一致性和防止竞争条件是至关重要的。本文将深入探讨Linux操作系统中实现的几种关键锁机制,包括自旋锁、互斥锁和读写锁等。通过分析这些锁的设计原理和使用场景,帮助读者理解如何在实际应用中选择合适的锁机制以优化系统性能和稳定性。 ####
24 6
|
8天前
|
机器学习/深度学习 负载均衡 算法
深入探索Linux内核调度机制的优化策略###
本文旨在为读者揭开Linux操作系统中至关重要的一环——CPU调度机制的神秘面纱。通过深入浅出地解析其工作原理,并探讨一系列创新优化策略,本文不仅增强了技术爱好者的理论知识,更为系统管理员和软件开发者提供了实用的性能调优指南,旨在促进系统的高效运行与资源利用最大化。 ###