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

作者:怀知

在排查网络问题的时候,经常会遇见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()的逻辑里,函数逻辑的前几行如下:

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

进入到这个函数的前提条件是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。
Linux内核协议栈丢弃SYN报文的主要场景剖析

所以在上面这种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的限制。
Linux内核协议栈丢弃SYN报文的主要场景剖析

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地排查。

上一篇:chrome,opera..通过file协议浏览html代码时,发送的ajax请求本地文件,会报跨域错误


下一篇:用Python从零开始创建区块链1