FutureTech
首发于FutureTech

从SO_REUSEADDR选项说起

背景

之前在做长链服务相关的东西,底层使用的是netty。netty在启动服务的时候有个option是SO_REUSEADDR,因为我们用的是linux系统,那么底层实现其实就是用户态的选项SO_REUSEADDR。官网对这个参数的解释是:Specifies that the rules used in validating addresses supplied to bind() should allow reuse of local addresses, if this is supported by the protocol. This option takes an int value. This is a Boolean option。看到很多样例代码都把该值设置成了true,就觉得很好奇。

后来查了查这个选项对应的linux内核参数是tcp_tw_reuse,官网对它的解释是:(Boolean; default: disabled; since Linux 2.4.19/2.6)Allow to reuse TIME_WAIT sockets for new connections when it is safe from protocol viewpoint. It should not be changed without advice/request of technical experts。同时发现另外一个内核参数tcp_tw_recycle,官网对它的解释是:(Boolean; default: disabled; since Linux 2.4)Enable fast recycling of TIME_WAIT sockets. Enabling this option is not recommended since this causes problems when working with NAT (Network Address Translation)。一开始看下来感觉这两个参数都是和TIME_WAIT相关,貌似都是为了解决这个问题而产生的,但是这两个参数又有什么区别呢?

这些都要从TIME_WAIT讲起,TIME_WAIT是TCP连接过程中的一个状态,具体如下图:

TIME_WAIT的作用

这里具体就不解释了,解释这个的网站太多了,TIME_WAIT有两个作用:

  1. 防止上一个TCP连接的延迟的数据包(发起关闭,但关闭没完成),被接收后,影响到新的TCP连接。
  2. 另外一个作用是,当最后一个ACK丢失时,远程连接进入LAST-ACK状态,它可以确保远程已经关闭当前TCP连接。

说到TCP,其实四元组这个概念简单且特别重要,经常被人忽略和误解。唯一连接确认方式为四元组:源IP地址、目的IP地址、源端口、目的端口。也就是说,TCP是用这一个四元组来表述一个连接。我经常会听到有人跟我说(包括面试官和被试),服务端建立连接的数量是受到端口数量的限制,即一个IP只能有65535个端口,也就是理论上服务端一个IP只能建立65535个连接。其实从四元组的概念可以显然知道服务端建立连接的数量显然和端口限制无关,但是客户端会受到这个限制,所以在测试C100K服务的时候,会利用ip alias的技术来解决客户端端口限制的问题。

RFC 793中强调TIME-WAIT状态必须是两倍的MSL时间(max segment lifetime),在linux上,这个限制时间无法调整,写死为1分钟了。

问题

在处理大量连接的服务器上,这个状态会带来以下三个问题:

  • 连接表中的slot被大量占用,导致新的连接建立被阻止
  • 在内核中,内存被socket结构体占用
  • 额外的CPU开销

连接表槽

处于TIME-WAIT状态的TCP连接,在链接表槽中存活1分钟,意味着另一个相同四元组(源地址,源端口,目标地址,目标端口)的连接不能出现,也就是说新的TCP(相同四元组)连接无法建立。

解决办法是,增加四元组的范围,这有很多方法去实现。(以下建议的顺序,实施难度从小到大排列)

  • 修改net.ipv4.ip_local_port_range参数,增加客户端端口可用范围。
  • 增加服务端端口,多监听一些端口,比如81、82、83这些,web服务器前有负载均衡,对用户友好。
  • 增加客户端IP,尤其是作为负载均衡服务器时,使用更多IP去跟后端的web服务器通讯。
  • 增加服务端IP。

当然了,最后的办法是调整net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle。但不到万不得已,千万别这么做,稍后再讲。

内存

保持大量的连接时,当多为每一连接多保留1分钟,就会多消耗一些服务器的内存。举个栗子,如果服务器每秒处理了1W个新的TCP连接,那么服务器将会存货1W/s*60s = 60W个TIME-WAIT状态的TCP连接,那这将会占用多大的内存么?不用担心,没那么多。

首先,从应用的角度来看,一个TIME-WAIT状态的socket不会消耗任何内存:socket已经关了。在内核中,TIME-WAIT状态的socket,对于三种不同的作用,有三个不同的结构。

一、“TCP established hash table”的连接存储哈希表(包括其他非established状态的连接),当有新的数据包发来时,是用来定位查找存活状态的连接的。

该哈希表的bucket都包括在TIME-WAIT连接列表以及正在活跃的连接列表中。该哈希表的大小,取决于操作系统内存大小。在系统引导时,会打印出来,dmesg日志中可以看到。这个数值,有可能被kernel启动参数thash_entries(设置TCP连接哈希表的最大数目)的改动而将其覆盖。在TIME-WAIT状态连接列表中,每一个元素都是一个tcp_timewait_sock结构体,其他状态的连接都是tcp_sock结构体。

二、有一组叫做“death row”的连接列表,是用来终止TIME-WAIT状态的连接的,这会在他们过期之前,开始申请。它占用的内存空间,跟在连接哈希表中的一样。这个结构体hlist_node tw_death_node是inet_timewait_sock的一个成员。

三、有个绑定端口的哈希表,存储绑定端口跟其他参数,用来确保当前端口没有被使用的,比如在listen监听时的指定端口,或者连接其他socket时,系统动态分配的端口。该哈希表的大小跟连接哈希表大小一样。每个元素都是inet_bind_socket结构体。每个绑定的端口都会有一个元素。对于web服务器来说,它绑定的是80端口,其TIME-WAIT连接都是共享同一个entry的。另外,连接到远程服务器的本地连接,他们的端口都是随机分配的,并不共享其entry。

所以,我们只关心结构体tcp_timewait_sock跟结构体inet_bind_socket所占用的空间大小。每一个连到远程,或远程连到本地的一个TIME-WAIT状态的连接,都有一个tcp_timewait_sock结构体。还有个结构体inet_bind_socket,只会在连到远程的连接时候存在,远程连过来的连接没这个结构体。

故,当服务器上有4W个连进来的连接进入TIME-WAIT状态时,才用了10MB不到的内存。如果服务器上有4W个连接到远程的连接进入TIME-WAIT状态时,才用了2.5MB的内存。再来看下slabtop的结果,这里测试数据是5W个TIME-WAIT状态的连接结果,其中4.5W是连接到远程的连接:

$ sudo slabtop -o | grep -E '(^  OBJS|tw_sock_TCP|tcp_bind_bucket)'
  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME                   
 50955  49725  97%    0.25K   3397       15     13588K tw_sock_TCP            
 44840  36556  81%    0.06K    760       59      3040K tcp_bind_bucket

命令执行结果原样输出,一个字符都没动。TIME-WAIT状态的连接占用内存非常的小。如果你的服务器上要处理每秒成千上万的新建TCP连接,你可能需要多一点的内存才能 正确无误的跟客户端做数据通信。但TIME-WAIT状态连接的内存占用,简直可以无视。

CPU

在CPU这边,查找一个空闲端口的操作,还是有一些消耗的。这由inet_csk_get_port() 函数,加锁,遍历整个空闲端口列表实现。这个哈希表里条目数量大通常不是问题,如果服务器上存在大量连接到远程TIME-WAIT状态的连接(比如FPM连redis、memcache之类),都会同享相同的profile,这个特性会非常快的按照顺序找到一个新的空闲端口。

其他解决办法

如果你读了上面的内容,觉得还有疑问的话,还有其他三个解决方案:

  • 禁用socket linger
  • net.ipv4.tcp_tw_reuse
  • net.ipv4.tcp_tw_recycle

Socket lingering

当close被调用时,SOCKET需要延迟关闭(lingering),在内核buffers中的残留数据将会发送到远程地址,同时,socket会切换到TIME-WAIT状态。如果禁用此选项,则调用close之后,底层也会关闭,不会将Buffers中残留数据未发送的数据继续发送。

不过呢,应用程序可以选择禁用socket lingering延迟关闭行为。关于socket lingering 延迟关闭,下面两个行为简单描述一下:

第一种情况,close函数后,并不会直接终止该四元组连接序号,而是在buffers任何残留数据都会被丢弃。该TCP连接将会收到一个RST的关闭信号,之后,服务端将立刻销毁该(四元组)连接。 在这种做法中,不会再有TIME-WAIT状态的SOCKET出现。

第二种情况,如果当调用close函数后,socket发送buffer中仍然有残留数据,此进程将会休眠,直到所有数据都发送完成并确认,或者所配置的linger计时器过期了。非阻塞socket可以设置不休眠。如上,这些过程都都在底层发生,这个机制确保残留数据在配置的超时时间内都发送出去。 如果数据正常发送出去,close包也正常发送,那么将会转换为TIME-WAIT状态。其他异常情况下,客户端将会收到RST的连接关闭信号,同时,服务端残留数据会被丢弃。

这里的两种情况,禁用socket linger延迟关闭不是万金油。但在HAproxy,Nginx(反代)场景中,在TCP协议上层的应用上(比如HTTP),比较合适。同样,也有很多无可厚非的理由不能禁用它。

net.ipv4.tcp_tw_reuse

TIME-WAIT状态是为了防止不相关的延迟请求包被接受。但在某些特定条件下,很有可能出现,新建立的TCP连接请求包,被老连接误处理。RFC 1323实现了TCP拓展规范,以保证网络繁忙状态下的高可用。除此之外,它还定义了一个新的TCP选项–两个四字节的timestamp fields时间戳字段,第一个是TCP发送方的当前时钟时间戳,而第二个是从远程主机接收到的最新时间戳。

启用net.ipv4.tcp_tw_reuse后,如果新的时间戳,比以前存储的时间戳更大,那么linux将会从TIME-WAIT状态的存活连接中选取一个,重新分配给这个TCP连接。连出的TIME-WAIT状态的连接,仅仅1秒后就可以被重用了。

TIME-WAIT的第一个作用是避免新的连接(不相关的)接收到重复的数据包。由于使用了时间戳,重复的数据包会因为timestamp过期而丢弃。

第二个作用是确保远程端(远程的不一定是服务端,有可能,对于服务器来说,远程的是客户端,我这里就用远程端来代替)是不是在LAST-ACK状态。因为有可能丢ACK包丢。远程端会重发FIN包,直到

  • 放弃(连接断开)
  • 等到ACK包
  • 收到RST包

如果 FIN包接及时收到,本地端依然是TIME-WAIT状态,同时,ACK包也会发送出去。

一旦新的连接替换了TIME-WAIT的entry,新连接的SYN包会被忽略掉(这得感谢timestramps),也不会应答RST包,但会重传FIN包。 FIN包将会收到一个RST包的应答(因为本地连接是SYN-SENT状态),这会让远程端跳过LAST-ACK状态。 最初的SYN包将会在1秒后重新发送,然后完成连接的建立。看起来没有啥错误发生,只是延迟了一下。

另外,当连接被重用时,TWrecycled计数器会增加的。

net.ipv4.tcp_tw_recycle

这种机制也依赖时间戳选项,这也会影响到所有连接进来和连接出去的连接。TIME-WAIT状态计划更早的过期:它将会在超时重发(RTO)间隔(利用RTT计算出来的)后移除。可以执行ss指令,获取当前存活的TCP连接状态,查看这些数据。

Linux将会放弃所有来自远程端的timestramp时间戳小于上次记录的时间戳(也是远程端发来的)的任何数据包。除非TIME-WAIT状态已经过期。

if (tmp_opt.saw_tstamp &&
    tcp_death_row.sysctl_tw_recycle &&
    (dst = inet_csk_route_req(sk, &fl4, req, want_cookie)) != NULL &&
    fl4.daddr == saddr &&
    (peer = rt_get_peer((struct rtable *)dst, fl4.daddr)) != NULL) {
        inet_peer_refcheck(peer);
        if ((u32)get_seconds() - peer->tcp_ts_stamp < TCP_PAWS_MSL &&
            (s32)(peer->tcp_ts - req->ts_recent) >
                                        TCP_PAWS_WINDOW) {
                NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);
                goto drop_and_release;
        }
}

当远程端主机HOST处于NAT网络中时,时间戳在一分钟之内(MSL时间间隔)将禁止了NAT网络后面,除了这台主机以外的其他任何主机连接,因为他们都有各自CPU CLOCK,各自的时间戳。这会导致很多疑难杂症,很难去排查,建议你禁用这个选项。net.ipv4.tcp_tw_recycle已经被linux4.12移除

总结

最合适的解决方案是增加更多的四元组数目,比如,服务器可用端口,或服务器IP,让服务器能容纳足够多的TIME-WAIT状态连接。

在服务端,不要启用net.ipv4.tcp_tw_recycle,除非你能确保你的服务器网络环境不是NAT。在服务端上启用net.ipv4.tw_reuse对于连接进来的TCP连接来说作用并不大。

在客户端上启用net.ipv4.tcp_tw_reuse,还算稍微安全的解决TIME-WAIT的方案。再开启net.ipv4.tcp_tw_recycle的话,对客户端并没起到更好的作用,反而会发生很多诡异的事情。

而且,在协议被设计的时候就考虑到不要让客户端先关闭连接。客户端其实没有能力去处理TIME-WAIT状态带来的问题,最好的选择是让服务端去处理这个问题。

最后引用一下W. Richard Stevens在《UNIX网络编程》的一句话:

The TIME_WAIT state is our friend and is there to help us (i.e., to let old duplicate segments expire in the network). Instead of trying to avoid the state, we should understand it.


参考文献

Coping with the TCP TIME-WAIT state on busy Linux servers 本文基本算是这篇文章的一个翻译,个人赶脚写的比较详细,比较靠谱。不过这个文章可能被墙,建议看国内转发的这篇。

Coping with the TCP TIME-WAIT state on busy Linux servers 国内转发

setsockopt(3): set socket options

TCP protocol - Linux man page

发布于 2017-11-26

文章被以下专栏收录