TCP keepalive的探究 (1) : NAT和保活机制

关于应用层的TCP连接保活(Keepalive)机制,相信大家都听说过。对于长连接TCP保活,典型的方法是发送应用层的心跳包,但这将增加开发人员的工作量:需要专门为心跳包制定协议。而在Linux的socket通信API中,自带了TCP_KEEPALIVE的相关参数设定,通过这种方式实现TCP长连接保活,无需修改原程序的逻辑,开发人员不需要关心心跳包的实现。本系列文章将从路由器NAT原理、keepalive基本的代码实现、浏览器保活机制、存在的问题几个方面逐步深入探究。

NAT

为什么要使用TCP keepalive?这得从NAT(地址转换)原理开始讲起。狭义上,NAT分为SNAT(原地址转换)和DNAT(目标地址转换),关于DNAT,有兴趣的同学可以自行查阅,本文只讨论SNAT。

我们都知道,路由器的最基本功能是对第三层(网络层)上的IP报文进行转发。实际上,路由器还有很关键的一个功能,这便是NAT。特别是对于ISP对普通用户链路上的路由器,NAT功能尤为重要。

为什么要使用NAT?原因很简单:IPv4地址非常稀缺。上网需求庞大,这使得ISP不可能为每一个入网用户都提供一个独立的公网IP,因此通常情况下,ISP会把用户接入局域网,使得多个用户共享同一个公网IP,而每一个用户各分得一个局域网内网IP。而连接公网和局域网的这台路由器,称之为网关(gateway),NAT的过程就发生在这台网关路由器上。

三层地址转换

局域网内的主机向公网发出的网络层IP报文,将经由网关被转发至公网,而在该转发过程中发生了地址转换。网关将该IP报文中的 源IP地址 从”该主机的内网IP”修改为”网关的公网IP”。

比如,局域网主机获得的内网IP为192.168.1.100,网关的公网IP为210.177.63.2,局域网主机向公网目标主机发出的IP报文中,源IP字段数据为192.168.1.100,在经过网关时,该字段数据将被修改为210.177.63.2

为什么要这么做,相信大家已经猜到了。公网上的目标主机在收到这个IP报文后,需要知道这个IP报文的来源地址,并向该来源地址发送响应报文,但如果不经过NAT,目标主机拿到的来源地址是192.168.1.100,这显然是一个公网上不可访问到的私有地址,目标主机无法将响应报文发送到正确的来源主机上。开启了NAT之后,IP报文的来源地址被网关修改为210.177.63.2,这是一个公网地址,目标主机将向这个地址(即网关路由器的公网地址)发送响应报文。

但是请注意,如果这个IP报文的数据段不含传输层协议报文,而是一个pure的网络层packet,来自目标主机的响应报文是不能被网关准确转发到多台局域网主机中的其中一台的。(ICMP报文除外,其报头中有Identifier字段用于标识不同的主机或进程,网关在处理Identifier时类似于下面提到的运输层端口)

传输层端口转换表

在三层地址转换中,我们可以保证局域网内主机向公网发出的IP报文能顺利到达目的主机,但是从目的主机返回的IP报文却不能准确送至指定局域网主机(我们不能让网关把IP报文广播至全部局域网主机,因为这样必然会带来安全和性能问题)。为了解决这个问题,网关路由器需要借助传输层端口,通常情况下是TCP或UDP端口,由此来生成一张端口转换表。

让我们通过一个实例来说明端口转换表如何运作。
假设局域网主机A192.168.1.100需要与公网上的目标主机B210.199.38.2:80进行一次TCP通信。其中A所在局域网的网关C的公网IP地址为210.177.63.2。步骤如下:

1. 局域网主机A192.168.1.100发出TCP连接请求,A上的TCP端口为系统分配的53600。该TCP握手包中,包含源地址和端口192.168.1.100:53600,目的地址和端口210.199.38.2:80
2. 网关C将该包的原地址和端口修改为210.177.63.2:63000,其中63000是网关分配的临时端口。
3. 网关C在端口转换表中增加一条记录:

内网主机IP 内网主机端口 网关端口 目的主机IP 目的主机端口
192.168.1.100 53600 63000 210.199.38.2 80

4. 网关C将修改后的TCP包发送至目的主机B。
5. 目的主机B收到后,发送响应TCP包。该响应TCP包含有以下信息:源地址和端口210.199.38.2:80,目的地址和端口210.177.63.2:63000
6. 网关C收到这个来自B的响应包后,随即在端口转换表中查找记录。该记录须符合以下条件:目的主机IP==210.199.38.2,目的主机端口==80,网关端口==63000
7. 网关C搜索到这条记录,记录显示内网主机IP为192.168.1.100,内网主机端口为53600
8. 网关C将该包的目的地址和端口修改为192.168.1.100:53600
9. 网关C随即将该修改后的TCP包转发至192.168.1.100:53600,即局域网主机A。此时运输层数据的一次交换已完成。

问题所在

在网关C上,由于端口数量有限(0~65535),端口转换表的维护占用系统资源,因此不能无休止地向端口转换表中增加记录。对于过期的记录,网关需要将其删除。如何判断哪些是过期记录?网关认为,一段时间内无活动的连接是过期的,应定时检测转换表中的非活动连接,并将之丢弃。而这个丢弃的过程,网关不会以任何的方式通告该连接的任何一端。

那么问题就来了:如果一个客户端应用程序由于业务需要,需要与服务端维持长连接(如TCP聊天程序),而如果在特别长的时间内(在博主的ISP环境下,该时间在3分钟左右),这个连接没有任何的数据交换,网关会认为这个连接过期并将这个连接从端口转换表中丢弃。该连接被丢弃时,客户端和服务端对此是完全无感知的。在连接被丢弃后,客户端将收不到服务端的数据推送,客户端发送的数据包也不能到达服务端。

第一次实验

让我们使用TCP测试工具netcat来实际实验一下。

  • 在公网服务器上,使用nc -l 9999命令监听TCP端口9999
  • 在局域网主机上,使用nc XX.XX.XX.XX 9999命令连接到这台公网服务器的9999端口。
  • 进行基本的双向发包测试。
  • 不关闭连接,在空闲5分钟后再进行双向发包测试。

在我的例子中,在双方建立TCP连接后,客户端(局域网主机)发送一行hello from client,服务端发送一行hello from server
等待5分钟,然后客户端发送一行test from client

通过wireshark在客户端主机上抓包,跟踪这个TCP连接得出如下结果:

从上图可得出:

  • 在第144秒时,通过TCP三次握手,双方建立连接。
  • 随后双方各发一行hello信息,并都成功接收到ACK响应包,证明发送成功。
  • 在第500秒时,客户端发送test from client,但是没有收到对方响应ACK,导致客户端多次重发(TCP Retransmission),但是仍然收不到ACK。

在服务端上,仅能收到客户端一开始发送的hello from client,5分钟后客户端发送的test from client并不能收到:

而在服务端尝试发送test from server,客户端也收不到了。

这表明,在这空闲的5分钟内,网关路由器已经“掐断”了这个TCP连接,导致5分钟后该连接不可再用。但无论是客户端还是服务端,都不知道这个连接已经作废了,因此客户端在发包没有收到ACK后仍在尝试重发,双方的netcat进程仍然没有退出,说明了网关在掐断连接时并没有通知双方。

TCP Keepalive

如果我们的业务需要我们维持长连接,这就要避免网关“干掉”我们的长连接。解决方法就是,让网关认为我们的TCP连接是活动的。在应用层,我们可以通过定时发送心跳包的方式实现。而如果使用Linux提供的TCP_KEEPALIVE,在应用层我们可完全不关心心跳包何时发送、发送什么内容,这一切由操作系统自动管理:操作系统会在该TCP连接上定时发送探测包,探测包既能像心跳包一样起到连接保活的作用,也能自动检测连接的有效性,并自动关闭无效连接。

在Linux全局内核设置中,有以下三个参数:

1
2
3
4
5
6
# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9

  • tcp_keepalive_time: 如果在该时间内没有数据往来,则发送探测包。
  • tcp_keepalive_intvl: 探测包发送间隔时间。
  • tcp_keepalive_probes: 尝试探测的次数。如果发送的探测包次数超过该值仍然没有收到对方响应,则认为连接已失效并关闭连接。

TCP Keepalive默认是关闭的。要启用这个特性,需要在程序中如下设置(代码实例来自Linux下TCP keepalive属性的表现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <netinet/tcp.h>

int keepAlive = 1; // 开启keepalive属性
int keepIdle = 60; // 如该连接在60秒内没有任何数据往来,则进行探测
int keepInterval = 5; // 探测时发包的时间间隔为5 秒
int keepCount = 3; // 探测尝试的次数.如果第1次探测包就收到响应了,则后2次的不再发.

setsockopt(rs, SOL_SOCKET, SO_KEEPALIVE, (void *)&keepAlive, sizeof(keepAlive));
setsockopt(rs, SOL_TCP, TCP_KEEPIDLE, (void*)&keepIdle, sizeof(keepIdle)); //对应tcp_keepalive_time
setsockopt(rs, SOL_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval)); //对应tcp_keepalive_intvl
setsockopt(rs, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount)); //对应tcp_keepalive_probes

如果省略TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT三个属性的设置,将使用上文的三个系统全局默认值。

第二次实验

这次我们使用 netcat-keepalive 来测试。这个Github上的开源项目在netcat的基础上加入了上述的代码。参数说明请参照README。

测试方法基本不变。唯一的不同之处是,客户端使用netcat-keepalive,并开启TCP Keepalive特性。

客户端上的测试结果和wireshark抓包记录如下:

抓包记录显示,在空闲的5分钟内,客户端每隔30秒发送一个TCP探测包(TCP Keep-Alive),并收到服务端ACK(TCP Keep-Alive ACK)。在5分钟后客户端发送test from client,服务端发送test from server,均发送成功。

服务端上的截图:

这证明,我们通过TCP Keepalive,成功地阻止了网关路由器丢弃我们的TCP长连接,所以我们在5分钟后仍能够使用这个长连接进行通信。

让我们来看看这个TCP Keep-Alive探测包是个什么东西:

由上图可看出,探测包是一个特殊的TCP包:它的长度为零,Flags位ACK置1,Options置为两个NOP,而它的端口信息和普通的TCP数据包是一样的。

对于服务端响应的TCP Keep-Alive ACK探测包,是由服务器操作系统发送的。实际上,在使用应用层TCP编程时,并不能收到这个探测包,所以服务端应用程序对该探测包是无感知的。

待续…

本文从NAT基本原理介绍了TCP Keepalive的原理和基本实现,在下篇文章中,我们将探究Chrome浏览器对于TCP保活的实现。