引子
在生产环境部署服务器集群的时候,由于服务器进程众多,需要监听的端口也非常多,通常我们会通过一定规则为每个进行指定特定的端口来绑定,这没什么问题。
但是由于服务器之间需要通信,因此服务器进程之间会建立大量的 TCP 连接。在主动连接的一方,我们不必类似于监听一样手动 bind 固定的端口,操作系统会为我们随机选择一个端口,来与目的端口进行通信。当连接数量很多时,这种随机选择的端口会和我们指定的端口冲突。(注意 UDP 虽然没有连接的概念,也是要占用端口的)
一种典型的情况是
进程 A 和 B 启动完成,B 建立了一条连接到 A,本地端口选择为10000
进程 C 恰好监听在端口10000,于是 C 进程启动时就会因为端口被占用而启动失败
解决方案
为了解决这个问题,我们通常分别约定监听端口和随机选择端口的范围。
例如,在指定监听端口的时候,我们可以指定 5000 - 15000 是可用的监听范围。而本地随机选择的端口范围设置为 15000 - 65000。这样就可以有效避免冲突。
在 linux 下有内核选项可以设置本地端口的范围:
1 | $ cat /proc/sys/net/ipv4/ip_local_port_range |
关于 ip_local_port_range
的定义如下
1 | The /proc/sys/net/ipv4/ip_local_port_range defines the local port range that is used by TCP and UDP traffic to choose the local port. |
我们利用 sysctl
命令修改其值为15000 65000
1 | sudo sysctl -w net.ipv4.ip_local_port_range="15000 65000" |
TIME_WAIT
还有一个很常见的影响端口使用的是连接 TIME_WAIT 状态。
在 TCP 连接关闭时,需要经历四次挥手的过程,而主动发起关闭的一方,发送完最后一个 ACK (最后一步) 后,进入 TIME_WAIT 状态,且需要等待 2MSL
的时间。等待时间过去后,连接关闭。
示意图如下
MSL(Maximum Segment Lifetime)
:报文最大生存时间,用于限制 TCP 包在网络中最大留存时间,超过这个时间,包将被丢弃。IP 层有个类似的 TTL 跳数来决定 IP 报文的去留,MSL 和 TTL 共同限制了 TCP 包的生存时间。由此可知,当网络拥塞时,超过 TCP 生存时间的包会被丢弃,导致丢包。RFC 建议 MSL 为 2 分钟,而 Linux 下为 30 秒。
TIME_WAIT 等待的时间 2MSL 是常量 TCP_TIMEWAIT_LEN
(linux 下就是1分钟)定义的,除非重新编译内核,否则不能修改。
1 | #define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT |
那为什么需要等待 2MSL
的时间呢?
第一个原因,是为了正常终止通信的一半通道(我方已关闭一半,确保对方也正确关闭另一半)。由于 TCP 是全双工,连接终止时需要双方分别关闭对应的通道。在收到进入 TIME_WAIT 时,肯定一方已经关闭了一半通道且本方收到了关闭另一半通道的 FIN 包,剩下的就是本方发出 FIN 包的 ACK,然后等待接收,完成后整个 TCP 连接就关闭了。
也就是说,进入 TIME_WAIT 状态之后,唯一的步骤就是对方收到 ACK 包之后关闭 TCP。这里的问题就在于万一 ACK 包由于网络拥塞没有及时被对方收到,那一段时间之后,对方会认为是之前发的 FIN 包没有被收到造成的,采取的措施就是重发 FIN 包到本方,那之前丢的那个 ACK 包的最大存活时间是 MSL,重发的 FIN 包也是在 MSL 时间内存活,加起来就是 2MSL 的时间。只要在 2MSL 时间内,其状态一直是 TIME_WAIT,那么就能处理这种丢包的情况。处理完了,就能关闭了(仁至义尽,如果再丢包,那就不管了)。
可以想像一下,如果没有这个 TIME_WAIT 状态而直接关闭呢?如果最后一个 ACK 丢失,那对方会处于 LAST_ACK 状态。这种情况下,如果主动关闭的一方再次发起一次连接到对方,且双方端口也一样,那该条连接相当于被复用,对方在该连接收到新的三次握手的 SYN 包 (并且序列号满足要求) 后,会直接返回 RST,认为包非法。
还有一个原因,就是让被关闭的连接上所有的包都消逝掉,防止新建的连接误收了之前连接的包。这种情况可能发生吗?
我们假设进入 TIME_WAIT 状态之后只等很短的时间就关闭连接,释放资源,在这里就是之前连接的本方端口可以再次被使用了。如果是客户端主动关闭的,那可以复用的就是之前随机分配的本地端口(客户端 connect 服务器的时候,操作系统随机选择一个本地端口与服务器的固定端口建立连接);如果是服务器主动关闭的,可以复用的就是之前监听的固定端口。
如果端口被复用后,连接的五元组(双方 IP,双方端口,协议类型)都一样,那么这条连接建立后就很可能接收到之前被关闭的同样五元组的连接的残余包了。(其实我在想,用五元组标识连接有隐患,为啥不再搞个序列号加以区分)。
残余包很可能是比较危险的,例如上面提到的如果本方发出的最后一个 ACK 包没有收到,那对方重发的 FIN 包被新建的连接收到了,那就麻烦了。此外,还可能有延迟收到的普通包。
有了持续 2MSL 时间的 TIME_WAIT 状态,就可以处理延迟包的情况了:收到重发的 FIN 包,再回一个 ACK;收到其他包,直接丢弃。
了解了 TIME_WAIT 状态的原理,我们再来说下其造成的端口冲突情况。
TIME_WAIT 状态,如果是客户端主动断开连接,影响并不大,主要是之前随机选择的本地端口 2MSL 时间内不可用,再建立连接时选一个其他的就可以了。但是如果是服务器主动断开连接呢?我们经常遇到的一种情况就是服务器临时关闭然后重启,此时服务器会主动关闭所有监听端口上已经建立的连接,然后重新启动监听在同一端口。
前面我们也分析了,如果是服务器主动断开的连接,本方端口,也就是监听的端口,在 2MSL 时间内不能再被使用,这造成了我们重启启动进程并监听时,提示『端口已被使用』,启动失败。
除了 TIME_WAIT,其他状态如 CLOSE_WAIT、ESTABLISHED 状态的连接对应的本方端口也是不可用的
为了解决这个问题,通常服务器的做法是在设置监听 socket 启用 SO_REUSEADDR
选项重用处于 2MSL 时间内的连接资源。
具体使用可以这样
1 | int reuseaddr = 1; |
事实上 SO_REUSEADDR 选项不仅能重用端口,还能重用 IP 资源。例如一个进程在监听在地址 0.0.0.0:80,而另一个进程可以监听地址 10.1.164.1:80。
另一种方式是修改 linux 的内核参数 net.ipv4.tcp_tw_reuse
,缺省是 0 不开启重用。
1 | $ sudo echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse |
从名字可以想到,这个选项是允许重用处于 TIME_WAIT 状态的 socket 连接。man 7 tcp
的文档中有如下解释
1 | tcp_tw_reuse (Boolean; default: disabled; since Linux 2.4.19/2.6) |
虽然选项设置里名字里是 ipv4,对 ipv6 也是适用的。
其重用原理可能是根据 tcp 的时间戳来区分新老连接的包,具体可以查看 这篇文章。
而另外一个选项 net.ipv4.tcp_tw_recycle
意思是开启 TIME_WAIT 状态 sockets 的快速回收。之前 2MSL 时间才回收的连接,很可能很快就被回收(一种说法回收时间是连接的 RTT)。启用之后,对于涉及 NAT 的网络情况会产生一些问题,因此不建议使用。似乎该选项在新版的 linux 内核中已经被废弃。
1 | tcp_tw_recycle (Boolean; default: disabled; since Linux 2.4) |
参考资料