【计算机网络】15、NAT、NAPT 网络地址转换、打洞

NAT 作为当今现实网络中不可或缺的一部分,虽然应用广泛,但是对它的介绍却远不及其他网络协议。另一方面,IETF 也把 NAT 视为 IPv4 的权宜之计,在很长一段时间内都寄解决地址短缺问题之希望于大力推广 IPv6。从 RFC 的提出时间就可以看出,很多 NAT 穿透相关的 RFC 提出时间都晚 IPv6 不少。而现在看来,IPv6 的推广乃至 IPv4 的废弃还有相当长的路要走,所以可以预见,NAT 还将陪伴我们不少时日。

一、概念

NAT(Network address translation)就是网络地址转换技术。按照 Wikipedia 的解释,它就是一个在路由设备上修改 IP 首部的地址,从而把一个地址变成另一个地址的技术,简而言之就是针对 IP 地址的重命名。比如在路由器上设定把来自 A 网络的 IP 包中的地址 10.0.0.2 重命名成 10.1.1.3,然后转发到 B 网络,反之亦然。这样对于 B 网络来说,访问 10.1.1.3 就等同于访问 A 网络中的 10.0.0.2 了。更加复杂的 NAT 技术还可能涉及对 TCP、UDP 协议中端口号的修改,不过总而言之,NAT 就是一个修改数据包头部完成 “重命名” 的技术。

目前 NAT 技术最广泛的应用是解决 IPv4 地址短缺问题。它的思路非常简单,就是重复利用同一个 IP 地址,并在路由器转发数据包的时候进行 “重命名”。比如在常识中,无论在家、学校还是餐厅里网上冲浪,路由器管理页面的地址总是 192.168.0.1、手机的地址也总是 192.168 开头。而 IP 协议中要求每个设备都有不同的 IP 地址,否则就会混淆不同的设备。之所以我们还能继续网上冲浪,就是因为路由器上使用了 NAT 技术把这些192.168 开头的内网地址 “重命名” 成路由器自身的地址,然后转发给互联网。这样,不同的内网就可以使用同一个内网地址(比如学校和家里都有可能有 192.168.0.233 这个设备),但也不影响它们接入互联网。而如何完成 “重命名” 并避免可能发生的冲突就是 NAT 技术的关键。

NAT 技术可以重写 IP 数据包的源 IP 或者目的 IP,被普遍地用来解决公网 IP 地址短缺的问题。它的主要原理就是,网络中的多台主机,通过共享同一个公网 IP 地址,来访问外网资源。同时,由于 NAT 屏蔽了内网网络,自然也就为局域网中的机器提供了安全隔离。

你既可以在支持网络地址转换的路由器(称为 NAT 网关)中配置 NAT,也可以在 Linux 服务器中配置 NAT。如果采用第二种方式,Linux 服务器实际上充当的是“软”路由器的角色。

NAT 的主要目的,是实现地址转换。根据实现方式的不同,NAT 可以分为三类:

  • 静态 NAT,即内网 IP 与公网 IP 是一对一的永久映射关系;
  • 动态 NAT,即内网 IP 从公网 IP 池中,动态选择一个进行映射;
  • 网络地址端口转换 NAPT(Network Address and Port Translation),即把内网 IP 映射到公网 IP 的不同端口上,让多个内网 IP 可以共享同一个公网 IP 地址。

NAPT 是目前最流行的 NAT 类型,我们在 Linux 中配置的 NAT 也是这种类型。而根据转换方式的不同,我们又可以把 NAPT 分为三类。

  • 第一类是源地址转换 SNAT,即目的地址不变,只替换源 IP 或源端口。SNAT 主要用于,多个内网 IP 共享同一个公网 IP ,来访问外网资源的场景。
  • 第二类是目的地址转换 DNAT,即源 IP 保持不变,只替换目的 IP 或者目的端口。DNAT 主要通过公网 IP 的不同端口号,来访问内网的多种服务,同时会隐藏后端服务器的真实 IP 地址。
  • 第三类是双向地址转换,即同时使用 SNAT 和 DNAT。当接收到网络包时,执行 DNAT,把目的 IP 转换为内网 IP;而在发送网络包时,执行 SNAT,把源 IP 替换为外部 IP。
    • 双向地址转换,其实就是外网 IP 与内网 IP 的一对一映射关系,所以常用在虚拟化环境中,为虚拟机分配浮动的公网 IP 地址。

为了帮你理解 NAPT,我画了一张图。我们假设:

  • 本地服务器的内网 IP 地址为 192.168.0.2;
  • NAT 网关中的公网 IP 地址为 100.100.100.100;
  • 要访问的目的服务器 baidu.com 的地址为 123.125.115.110。

那么 SNAT 和 DNAT 的过程,就如下图所示,从图中,你可以发现:

  • 当服务器访问 baidu.com 时,NAT 网关会把源地址,从服务器的内网 IP 192.168.0.2 替换成公网 IP 地址 100.100.100.100,然后才发送给 baidu.com;
  • 当 baidu.com 发回响应包时,NAT 网关又会把目的地址,从公网 IP 地址 100.100.100.100 替换成服务器内网 IP 192.168.0.2,然后再发送给内网中的服务器。

1.1 iptables 和 NAT

Linux 内核提供的 Netfilter 框架,允许对网络数据包进行修改(比如 NAT)和过滤(比如防火墙)。在这个基础上,iptables、ip6tables、ebtables 等工具,又提供了更易用的命令行接口,以便系统管理员配置和管理 NAT、防火墙的规则。

其中,iptables 就是最常用的一种配置工具。要掌握 iptables 的原理和使用方法,最核心的就是弄清楚,网络数据包通过 Netfilter 时的工作流向,下面这张图就展示了这一过程。

在这张图中,绿色背景的方框,表示表(table),用来管理链。Linux 支持 4 种表,包括 filter(用于过滤)、nat(用于 NAT)、mangle(用于修改分组数据) 和 raw(用于原始数据包)等。

跟 table 一起的白色背景方框,则表示链(chain),用来管理具体的 iptables 规则。每个表中可以包含多条链,比如:

  • filter 表中,内置 INPUT、OUTPUT 和 FORWARD 链;
  • nat 表中,内置 PREROUTING、POSTROUTING、OUTPUT 等。

当然,你也可以根据需要,创建你自己的链。

灰色的 conntrack,表示连接跟踪模块。它通过内核中的连接跟踪表(也就是哈希表),记录网络连接的状态,是 iptables 状态过滤(-m state)和 NAT 的实现基础。

iptables 的所有规则,就会放到这些表和链中,并按照图中顺序和规则的优先级顺序来执行。

针对今天的主题,要实现 NAT 功能,主要是在 nat 表进行操作。而 nat 表内置了三个链:

  • PREROUTING,用于路由判断前所执行的规则,比如,对接收到的数据包进行 DNAT。
  • POSTROUTING,用于路由判断后所执行的规则,比如,对发送或转发的数据包进行 SNAT 或 MASQUERADE。
  • OUTPUT,类似于 PREROUTING,但只处理从本机发送出去的包。

熟悉 iptables 中的表和链后,相应的 NAT 规则就比较简单了。我们还以 NAPT 的三个分类为例,来具体解读一下。

1.1.1 SNAT

根据刚才内容,我们知道,SNAT 需要在 nat 表的 POSTROUTING 链中配置。我们常用两种方式来配置它。

第一种方法,是为一个子网统一配置 SNAT,并由 Linux 选择默认的出口 IP。这实际上就是经常说的 MASQUERADE
$ iptables -t nat -A POSTROUTING -s 192.168.0.0/16 -j MASQUERADE

第二种方法,是为具体的 IP 地址配置 SNAT,并指定转换后的源地址:
$ iptables -t nat -A POSTROUTING -s 192.168.0.2 -j SNAT --to-source 100.100.100.100
1.1.1.1 MASQUERADE

MASQUERADE 是最常用的 SNAT 规则之一,通常用来为多个内网 IP 地址,提供共享的出口 IP。假设现在有一台 Linux 服务器,用了 MASQUERADE 方式,为内网所有 IP 提供出口访问功能。那么,当多个内网 IP 地址的端口号相同时,MASQUERADE 还能正常工作吗?内网 IP 地址数量或者请求数比较多的时候,这种使用方式有没有什么潜在问题呢?

当多个内网 IP 地址的端口号相同时,MASQUERADE 当然仍可以正常工作。不过,你肯定也听说过,配置 MASQUERADE 后,需要各个应用程序去手动配置修改端口号。

实际上,MASQUERADE 通过 conntrack 机制,记录了每个连接的信息。而在刚才第三个问题 中,我提到过,标志一个连接需要五元组,只要这五元组不是同时相同,网络连接就可以正常进行。

再看第二点,在内网 IP 地址和连接数比较小时,这种方式的问题不大。但在 IP 地址或并发连接数特别大的情况下,就可能碰到各种各样的资源限制。

比如,MASQUERADE 既然把内部多个 IP ,转换成了相同的外网 IP(即 SNAT),那么,为了确保发送出去的源端口不重复,原来网络包的源端口也可能会被重新分配。这样的话,转换后的外网 IP 的端口号,就成了限制连接数的一个重要因素。

除此之外,连接跟踪、MASQUERADE 机器的网络带宽等,都是潜在的瓶颈,并且还存在单点的问题。这些情况,在我们实际使用中都需要特别注意。

1.1.2 DNAT

再来看 DNAT,显然,DNAT 需要在 nat 表的 PREROUTING 或者 OUTPUT 链中配置,其中, PREROUTING 链更常用一些(因为它还可以用于转发的包)。

$ iptables -t nat -A PREROUTING -d 100.100.100.100 -j DNAT --to-destination 192.168.0.2

1.1.3 双向地址转换

双向地址转换,就是同时添加 SNAT 和 DNAT 规则,为公网 IP 和内网 IP 实现一对一的映射关系,即:

$ iptables -t nat -A POSTROUTING -s 192.168.0.2 -j SNAT --to-source 100.100.100.100
$ iptables -t nat -A PREROUTING -d 100.100.100.100 -j DNAT --to-destination 192.168.0.2

在使用 iptables 配置 NAT 规则时,Linux 需要转发来自其他 IP 的网络包,所以你千万不要忘记开启 Linux 的 IP 转发功能。

你可以执行下面的命令,查看这一功能是否开启。如果输出的结果是 1,就表示已经开启了 IP 转发:
$ sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1


如果还没开启,你可以执行下面的命令,手动开启:
$ sysctl -w net.ipv4.ip_forward=1
net.ipv4.ip_forward = 1


当然,为了避免重启后配置丢失,不要忘记将配置写入 /etc/sysctl.conf 文件中:
$ cat /etc/sysctl.conf | grep ip_forward
net.ipv4.ip_forward=1

1.2 NAT 性能

Linux 中的 NAT ,基于内核的连接跟踪模块实现。所以,它维护每个连接状态的同时,也会带来很高的性能成本。

实验如下:机器配置:2 CPU,8GB 内存。预先安装 docker、tcpdump、curl、ab、SystemTap 等工具,比如

# Ubuntu
$ apt-get install -y docker.io tcpdump curl apache2-utils

# CentOS
$ curl -fsSL https://get.docker.com | sh
$ yum install -y tcpdump curl httpd-tools

其中 SystemTap 是 Linux 的一种动态追踪框架,它把用户提供的脚本,转换为内核模块来执行,用来监测和跟踪内核的行为。安装方式如下:

# Ubuntu
apt-get install -y systemtap-runtime systemtap
# Configure ddebs source
echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse
deb http://ddebs.ubuntu.com $(lsb_release -cs)-updates main restricted universe multiverse
deb http://ddebs.ubuntu.com $(lsb_release -cs)-proposed main restricted universe multiverse" | \
sudo tee -a /etc/apt/sources.list.d/ddebs.list
# Install dbgsym
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F2EDC64DC5AEE1F6B9C621F0C8CAB6595FDFF622
apt-get update
apt install ubuntu-dbgsym-keyring
stap-prep
apt-get install linux-image-`uname -r`-dbgsym
 
# CentOS
yum install systemtap kernel-devel yum-utils kernel
stab-prep

本次案例还是我们最常见的 Nginx,并且会用 ab 作为它的客户端,进行压力测试。案例中总共用到两台虚拟机,我画了一张图来表示它们的关系。

接下来,我们打开两个终端,分别 SSH 登录到两台机器上(以下步骤,假设终端编号与图示 VM 编号一致),并安装上面提到的这些工具。注意,curl 和 ab 只需要在客户端 VM(即 VM2)中安装。

为了对比 NAT 带来的性能问题,我们首先运行一个不用 NAT 的 Nginx 服务,并用 ab 测试它的性能。
在终端一中,执行下面的命令,启动 Nginx,注意选项 --network=host ,表示容器使用 Host 网络模式,即不使用 NAT:
$ docker run --name nginx-hostnet --privileged --network=host -itd feisky/nginx:80


然后到终端二中,执行 curl 命令,确认 Nginx 正常启动:
$ curl http://192.168.0.30/
...
<p><em>Thank you for using nginx.</em></p>
</body>
</html>


继续在终端二中,执行 ab 命令,对 Nginx 进行压力测试。不过在测试前要注意,Linux 默认允许打开的文件描述数比较小,比如在我的机器中,这个值只有 1024# open files
$ ulimit -n
1024


所以,执行 ab 前,先要把这个选项调大,比如调成 65536:
# 临时增大当前会话的最大文件描述符数
$ ulimit -n 65536



接下来,再去执行 ab 命令,进行压力测试:
# -c 表示并发请求数为 5000,-n 表示总的请求数为 10 万
# -r 表示套接字接收错误时仍然继续执行,-s 表示设置每个请求的超时时间为 2s
$ ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30/
...
Requests per second:    6576.21 [#/sec] (mean)
Time per request:       760.317 [ms] (mean)
Time per request:       0.152 [ms] (mean, across all concurrent requests)
Transfer rate:          5390.19 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0  177 714.3      9    7338
Processing:     0   27  39.8     19     961
Waiting:        0   23  39.5     16     951
Total:          1  204 716.3     28    7349
...


关于 ab 输出界面的含义,我已经在 怎么评估系统的网络性能 文章中介绍过,忘了的话自己先去复习。从这次的界面,你可以看出:
- 每秒请求数(Requests per second)为 6576;
- 每个请求的平均延迟(Time per request)为 760ms;
- 建立连接的平均延迟(Connect)为 177ms。
记住这几个数值,这将是接下来案例的基准指标。


接着,回到终端一,停止这个未使用 NAT 的 Nginx 应用:
$ docker rm -f nginx-hostnet
再执行下面的命令,启动今天的案例应用。
案例应用监听在 8080 端口,并且使用了 DNAT ,来实现 Host 的 8080 端口,到容器的 8080 端口的映射关系:
$ docker run --name nginx --privileged -p 8080:8080 -itd feisky/nginx:nat


Nginx 启动后,你可以执行 iptables 命令,确认 DNAT 规则已经创建:
$ iptables -nL -t nat
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
DOCKER     all  --  0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL
 
...
 
Chain DOCKER (2 references)
target     prot opt source               destination
RETURN     all  --  0.0.0.0/0            0.0.0.0/0
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.17.0.2:8080


你可以看到,在 PREROUTING 链中,目的为本地的请求,会转到 DOCKER 链;而在 DOCKER 链中,目的端口为 8080 的 tcp 请求,会被 DNAT 到 172.17.0.2 的 8080 端口。其中,172.17.0.2 就是 Nginx 容器的 IP 地址。


接下来,我们切换到终端二中,执行 curl 命令,确认 Nginx 已经正常启动:
$ curl http://192.168.0.30:8080/
...
<p><em>Thank you for using nginx.</em></p>
</body>
</html>



然后,再次执行上述的 ab 命令,不过这次注意,要把请求的端口号换成 8080# -c 表示并发请求数为 5000,-n 表示总的请求数为 10 万
# -r 表示套接字接收错误时仍然继续执行,-s 表示设置每个请求的超时时间为 2s
$ ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30:8080/
...
apr_pollset_poll: The timeout specified has expired (70007)
Total of 5602 requests completed


果然,刚才正常运行的 ab ,现在失败了,还报了连接超时的错误。运行 ab 时的 -s 参数,设置了每个请求的超时时间为 2s,而从输出可以看到,这次只完成了 5602 个请求。
既然是为了得到 ab 的测试结果,我们不妨把超时时间延长一下试试,比如延长到 30s。延迟增大意味着要等更长时间,为了快点得到结果,我们可以同时把总测试次数,也减少到 10000:
$ ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
...
Requests per second:    76.47 [#/sec] (mean)
Time per request:       65380.868 [ms] (mean)
Time per request:       13.076 [ms] (mean, across all concurrent requests)
Transfer rate:          44.79 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0 1300 5578.0      1   65184
Processing:     0 37916 59283.2      1  130682
Waiting:        0    2   8.7      1     414
Total:          1 39216 58711.6   1021  130682
...


再重新看看 ab 的输出,这次的结果显示:
- 每秒请求数(Requests per second)为 76;
- 每个请求的延迟(Time per request)为 65s;
- 建立连接的延迟(Connect)为 1300ms。


显然,每个指标都比前面差了很多。
那么,碰到这种问题时,你会怎么办呢?你可以根据前面的讲解,先自己分析一下,再继续学习下面的内容。
在上一节,我们使用 tcpdump 抓包的方法,找出了延迟增大的根源。那么今天的案例,我们仍然可以用类似的方法寻找线索。不过,现在换个思路,因为今天我们已经事先知道了问题的根源——那就是 NAT。
回忆一下 Netfilter 中,网络包的流向以及 NAT 的原理,你会发现,要保证 NAT 正常工作,就至少需要两个步骤:
- 第一,利用 Netfilter 中的钩子函数(Hook),修改源地址或者目的地址。
- 第二,利用连接跟踪模块 conntrack ,关联同一个连接的请求和响应。

是不是这两个地方出现了问题呢?我们用前面提到的动态追踪工具 SystemTap 来试试。
由于今天案例是在压测场景下,并发请求数大大降低,并且我们清楚知道 NAT 是罪魁祸首。所以,我们有理由怀疑,内核中发生了丢包现象。
我们可以回到终端一中,创建一个 dropwatch.stp 的脚本文件,并写入下面的内容:

#! /usr/bin/env stap
 
############################################################
# Dropwatch.stp
# Author: Neil Horman <[email protected]>
# An example script to mimic the behavior of the dropwatch utility
# http://fedorahosted.org/dropwatch
############################################################
 
# Array to hold the list of drop points we find
global locations
 
# Note when we turn the monitor on and off
probe begin {
    
     printf("Monitoring for dropped packets\n") }
probe end {
    
     printf("Stopping dropped packet monitor\n") }
 
# increment a drop counter for every location we drop at
probe kernel.trace("kfree_skb") {
    
     locations[$location] <<< 1 }
 
# Every 5 seconds report our drop locations
probe timer.sec(5)
{
    
    
  printf("\n")
  foreach (l in locations-) {
    
    
    printf("%d packets dropped at %s\n",
           @count(locations[l]), symname(l))
  }
  delete locations
}

这个脚本,跟踪内核函数 kfree_skb() 的调用,并统计丢包的位置。文件保存好后,执行下面的 stap 命令,就可以运行丢包跟踪脚本。这里的 stap,是 SystemTap 的命令行工具:

$ stap --all-modules dropwatch.stp
Monitoring for dropped packets

当你看到 probe begin 输出的 “Monitoring for dropped packets” 时,表明 SystemTap 已经将脚本编译为内核模块,并启动运行了。

接着,我们切换到终端二中,再次执行 ab 命令:
$ ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/


然后,再次回到终端一中,观察 stap 命令的输出:
10031 packets dropped at nf_hook_slow
676 packets dropped at tcp_v4_rcv
 
7284 packets dropped at nf_hook_slow
268 packets dropped at tcp_v4_rcv


你会发现,大量丢包都发生在 nf_hook_slow 位置。看到这个名字,你应该能想到,这是在 Netfilter Hook 的钩子函数中,出现丢包问题了。但是不是 NAT,还不能确定。接下来,我们还得再跟踪 nf_hook_slow 的执行过程,这一步可以通过 perf 来完成。
我们切换到终端二中,再次执行 ab 命令:
$ ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/


然后,再次切换回终端一,执行 perf record 和 perf report 命令
# 记录一会(比如 30s)后按 Ctrl+C 结束
$ perf record -a -g -- sleep 30
 
# 输出报告
$ perf report -g graph,0

在 perf report 界面中,输入查找命令 / 然后,在弹出的对话框中,输入 nf_hook_slow;最后再展开调用栈,就可以得到下面这个调用图:

从这个图我们可以看到,nf_hook_slow 调用最多的有三个地方,分别是 ipv4_conntrack_in、br_nf_pre_routing 以及 iptable_nat_ipv4_in。换言之,nf_hook_slow 主要在执行三个动作。

  • 第一,接收网络包时,在连接跟踪表中查找连接,并为新的连接分配跟踪对象(Bucket)。
  • 第二,在 Linux 网桥中转发包。这是因为案例 Nginx 是一个 Docker 容器,而容器的网络通过网桥来实现;
  • 第三,接收网络包时,执行 DNAT,即把 8080 端口收到的包转发给容器。

到这里,我们其实就找到了性能下降的三个来源。这三个来源,都是 Linux 的内核机制,所以接下来的优化,自然也是要从内核入手。

根据以前各个资源模块的内容,我们知道,Linux 内核为用户提供了大量的可配置选项,这些选项可以通过 proc 文件系统,或者 sys 文件系统,来查看和修改。除此之外,你还可以用 sysctl 这个命令行工具,来查看和修改内核配置。

比如,我们今天的主题是 DNAT,而 DNAT 的基础是 conntrack,所以我们可以先看看,内核提供了哪些 conntrack 的配置选项。

我们在终端一中,继续执行下面的命令:
$ sysctl -a | grep conntrack
net.netfilter.nf_conntrack_count = 180
net.netfilter.nf_conntrack_max = 1000
net.netfilter.nf_conntrack_buckets = 65536
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 60
net.netfilter.nf_conntrack_tcp_timeout_syn_sent = 120
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
...

你可以看到,这里最重要的三个指标:

  • net.netfilter.nf_conntrack_count,表示当前连接跟踪数;
  • net.netfilter.nf_conntrack_max,表示最大连接跟踪数;
  • net.netfilter.nf_conntrack_buckets,表示连接跟踪表的大小。

所以,这个输出告诉我们,当前连接跟踪数是 180,最大连接跟踪数是 1000,连接跟踪表的大小,则是 65536。

回想一下前面的 ab 命令,并发请求数是 5000,而请求数是 100000。显然,跟踪表设置成,只记录 1000 个连接,是远远不够的。

实际上,内核在工作异常时,会把异常信息记录到日志中。比如前面的 ab 测试,内核已经在日志中报出了 “nf_conntrack: table full” 的错误。执行 dmesg 命令,你就可以看到:

$ dmesg | tail
[104235.156774] nf_conntrack: nf_conntrack: table full, dropping packet
[104243.800401] net_ratelimit: 3939 callbacks suppressed
[104243.800401] nf_conntrack: nf_conntrack: table full, dropping packet
[104262.962157] nf_conntrack: nf_conntrack: table full, dropping packet


其中,net_ratelimit 表示有大量的日志被压缩掉了,这是内核预防日志攻击的一种措施。而当你看到 “nf_conntrack: table full” 的错误时,就表明 nf_conntrack_max 太小了。

那是不是,直接把连接跟踪表调大就可以了呢?调节前,你先得明白,连接跟踪表,实际上是内存中的一个哈希表。如果连接跟踪数过大,也会耗费大量内存。

其实,我们上面看到的 nf_conntrack_buckets,就是哈希表的大小。哈希表中的每一项,都是一个链表(称为 Bucket),而链表长度,就等于 nf_conntrack_max 除以 nf_conntrack_buckets。

比如,我们可以估算一下,上述配置的连接跟踪表占用的内存大小:
# 连接跟踪对象大小为 376,链表项大小为 16
nf_conntrack_max* 连接跟踪对象大小 +nf_conntrack_buckets* 链表项大小 
= 1000*376+65536*16 B
= 1.4 MB


接下来,我们将 nf_conntrack_max 改大一些,比如改成 131072(即 nf_conntrack_buckets 的 2 倍):
$ sysctl -w net.netfilter.nf_conntrack_max=131072
$ sysctl -w net.netfilter.nf_conntrack_buckets=65536


然后再切换到终端二中,重新执行 ab 命令。注意,这次我们把超时时间也改回原来的 2s:
$ ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30:8080/
...
Requests per second:    6315.99 [#/sec] (mean)
Time per request:       791.641 [ms] (mean)
Time per request:       0.158 [ms] (mean, across all concurrent requests)
Transfer rate:          4985.15 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0  355 793.7     29    7352
Processing:     8  311 855.9     51   14481
Waiting:        0  292 851.5     36   14481
Total:         15  666 1216.3    148   14645


果然,现在你可以看到:
- 每秒请求数(Requests per second)为 6315(不用 NAT 时为 6576);
- 每个请求的延迟(Time per request)为 791ms(不用 NAT 时为 760ms);
- 建立连接的延迟(Connect)为 355ms(不用 NAT 时为 177ms)。


这个结果,已经比刚才的测试好了很多,也很接近最初不用 NAT 时的基准结果了。
不过,你可能还是很好奇,连接跟踪表里,到底都包含了哪些东西?这里的东西,又是怎么刷新的呢?
实际上,你可以用 conntrack 命令行工具,来查看连接跟踪表的内容。比如:
# -L 表示列表,-o 表示以扩展格式显示
$ conntrack -L -o extended | head
ipv4     2 tcp      6 7 TIME_WAIT src=192.168.0.2 dst=192.168.0.96 sport=51744 dport=8080 src=172.17.0.2 dst=192.168.0.2 sport=8080 dport=51744 [ASSURED] mark=0 use=1
ipv4     2 tcp      6 6 TIME_WAIT src=192.168.0.2 dst=192.168.0.96 sport=51524 dport=8080 src=172.17.0.2 dst=192.168.0.2 sport=8080 dport=51524 [ASSURED] mark=0 use=1


从这里你可以发现,连接跟踪表里的对象,包括了协议、连接状态、源 IP、源端口、目的 IP、目的端口、跟踪状态等。由于这个格式是固定的,所以我们可以用 awk、sort 等工具,对其进行统计分析。
比如,我们还是以 ab 为例。在终端二启动 ab 命令后,再回到终端一中,执行下面的命令:
# 统计总的连接跟踪数
$ conntrack -L -o extended | wc -l
14289
 
# 统计 TCP 协议各个状态的连接跟踪数
$ conntrack -L -o extended | awk '/^.*tcp.*$/ {sum[$6]++} END {for(i in sum) print i, sum[i]}'
SYN_RECV 4
CLOSE_WAIT 9
ESTABLISHED 2877
FIN_WAIT 3
SYN_SENT 2113
TIME_WAIT 9283
 
# 统计各个源 IP 的连接跟踪数
$ conntrack -L -o extended | awk '{print $7}' | cut -d "=" -f 2 | sort | uniq -c | sort -nr | head -n 10
  14116 192.168.0.2
    172 192.168.0.96


这里统计了总连接跟踪数,TCP 协议各个状态的连接跟踪数,以及各个源 IP 的连接跟踪数。你可以看到,大部分 TCP 的连接跟踪,都处于 TIME_WAIT 状态,并且它们大都来自于 192.168.0.2 这个 IP 地址(也就是运行 ab 命令的 VM2)。

这些处于 TIME_WAIT 的连接跟踪记录,会在超时后清理,而默认的超时时间是 120s,你可以执行下面的命令来查看:
$ sysctl net.netfilter.nf_conntrack_tcp_timeout_time_wait
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
所以,如果你的连接数非常大,确实也应该考虑,适当减小超时时间。
除了上面这些常见配置,conntrack 还包含了其他很多配置选项,你可以根据实际需要,参考 nf_conntrack 的文档(https://www.kernel.org/doc/Documentation/networking/nf_conntrack-sysctl.txt)来配置。

由于 NAT 基于 Linux 内核的连接跟踪机制来实现。所以,在分析 NAT 性能问题时,我们可以先从 conntrack 角度来分析,比如用 systemtap、perf 等,分析内核中 conntrack 的行文;然后,通过调整 netfilter 内核选项的参数,来进行优化。

其实,Linux 这种通过连接跟踪机制实现的 NAT,也常被称为有状态的 NAT,而维护状态,也带来了很高的性能成本。

所以,除了调整内核行为外,在不需要状态跟踪的场景下(比如只需要按预定的 IP 和端口进行映射,而不需要动态映射),我们也可以使用无状态的 NAT (比如用 tc 或基于 DPDK 开发),来进一步提升性能。

二、分类(主要是传统 NAT)

要进一步理解 NAT,首先就是了解 NAT 的分类。RFC2663 把 NAT 分成了四类:传统 NAT、双向 NAT、两次 NAT、多宿主 NAT。由于最常见的就是传统 NAT,所以我就偷个懒,只介绍传统 NAT 了。

传统 NAT 主要做的就是维护一个内部网络,就像上一节里介绍的那样。它位于内部网络与外部网络(比如互联网)之间,保证内网地址不会被泄露到外网中去。如果再对重命名方式进行细分,传统 NAT 还可以分成两类:

  • 基本 NAT(Basic NAT)
  • NAPT(Network Address Port Translation,网络地址端口转换)

2.1 基本 NAT

基本 NAT 就是只针对 IP 地址的 “重命名”。由于基本 NAT 并不考虑更高层的协议,所以它只是实现了一个内部 IP 地址到外部 IP 地址的一一对应。不妨把已使用的内部 IP、NAT 设备拥有的外部 IP 看成 In、Ex 两个集合。

  • 如果内部 IP 数量更少,len(In) < len(Ex),那么每个内部地址都能被映射到一个外部地址。
  • 如果内部 IP 数量更多,len(In) > len(Ex),就不能保证同一时间每个内部设备都能访问外部网络了(可能分配不到外部地址)。

2.2 NAPT

不难看出,基本 NAT 对于 IP 地址的复用效果相当有限。假设如果 NAT 设备只有一个外部地址的话,同一时间就只能有一个内部设备可以访问外部了,显然这对我们网上冲浪带来了极大地不便。NAPT 对此的解决方法是,考虑高层传输协议 TCP、UDP 的端口(其实不只是端口,任何传输层标志都行,比如 ICMP 的 ID),以 (IP地址, 端口) 为单位进行重命名。这样操作空间就突然变大了 65535 倍,复用效率直接拉满。所以大多数路由设备都实现了 NAPT,日常生活中见到最多的也是 NAPT。平常我们说的 NAT 也基本上就指的是 NAPT。

鉴于 NAPT 的重要性,下文介绍下不同的 NAPT 类型和它们的实现原理。

图示是一种常见的 NAPT 拓扑。

  • 当内网设备访问访问目标时,它发送包 [iAddr:iPort -> dAddr:dPort] 给路由器。路由器的 NAPT 程序转换内部地址,改写包为 [eAddr:ePort -> dAddr:dPort],之后转发到外部网络。
  • 反之,当访问目标答复内网设备时,它发送包 [dAddr:dPort -> eAddr:ePort] 给路由器,路由器接收后通过 NAPT 程序改写包为 [dAddr:dPort -> iAddr:iPort] 然后转发给内网设备。

可以看到:

  • 发送过程(内部到外部)中 NAPT 程序改写数据包的源地址,进行源 NAT(SNAT)。
  • 在接受过程(外部到内部)中改写数据包的目标地址,进行目标 NAT(DNAT)。这两个相对应的过程一并组成了 NAPT。

在改写包的过程中,最关键的过程就是确定 eAddr 与 ePort。这也是不同 NAPT 实现的主要区别。

NAT 改变了报文中的 IP 地址。但是,为什么我们平时上网时,并没感觉到 NAT 的存在?

在上文中的例子里,多台内网设备共用同公网 IP。外部数据到达路由器后,路由器应该将数据发送给哪个内网设备?

NAPT 中的 P 即为 port(端口号):

我们使用的大部分软件,例如网页浏览器,都是客户端软件。客户端需要主动向服务器发起连接。

例如,当我们访问少数派网站 sspai.com 后,浏览器能够解析到网站的 IP 地址(下文以 119.23.1.2 为例,不代表网站真实 IP),主动向该地址发起报文,建立连接。由于浏览网页使用的 HTTPS 协议,端口号为 443,所以报文中的目的端口号填充为 443,来源端口号是一个随机分配的值。

当少数派网站收到请求后,则会向用户的浏览器发送网页数据1。发送的数据中,来源端口号为 443,目的端口号则为用户请求报文中的源端口号。

从下图中也可以看出,回应报文的目的 IP,就是请求报文的源 IP,也就是用户电脑的 IP 地址;回应报文的目的端口号,就是请求报文中的源端口号,也就是用户浏览器的端口号。这样,少数派服务器的回应报文,就能根据 IP 地址发送回用户电脑,并根据端口号最终到达浏览器:

那么,如果用户的电脑是内网设备,经过了 NAT,使用浏览器访问少数派网站时,又会是什么样的过程呢?

首先,浏览器同样会发送请求报文,报文的源 IP 为电脑的内网 IP (此处以 192.168.1.126 为例)。

当报文到达路由器后,路由器将报文源 IP 修改为公网 IP(1.1.1.10),并分配一个新的源端口号。在这个过程中,路由器会记录下源 IP 和源端口号,在转换前后的对应关系,形成 NAT 表项。

路由器将源 IP 和源端口号转换后的报文发送到服务器,服务器回应的报文,目的 IP 和目的端口端口号,就是请求报文中的源 IP 和源端口号。这样,报文就能根据目的 IP,到达用户的路由器上。

路由器收到来自服务器的报文,根据 NAT 表项,将目的 IP 和目的端口号,从外部 IP、外部端口,转换为内部 IP、内部端口。这样,报文就能顺利到达用户电脑的浏览器上。

从上面的例子中可以看出,NAT 通过记录端口号、IP 地址的对应关系,将出方向报文的源 IP、源端口号从内部地址转换为外部地址,将入方向报文的目的 IP、目的端口号从外部地址转换为内部地址,让内网设备也能正常访问 Internet。但如下两种情况,是 NAT 难以做到的:

  • 内网设备做为服务器,外部设备主动向内网设备发起连接
  • 使用 TCP、UDP 之外的、没有端口号的协议进行通信

由于我们日常上网,使用的基本上都是 TCP 和 UDP 协议。而且自己的设备一般是做为客户端,主动连接第三方服务器的。所以,在日常上网的情况下,我们一般不会感受到 NAT 的存在。

三、访问NAT下的内网设备的方式

NAT 缓解了 IP 地址资源不足的问题,同时能使家庭中的多个设备共享同一条宽带,同时上网。另外,启用 NAT 后,外部设备无法主动发起对内网设备的连接,相当于起到了防火墙的作用,保护了内网设备,一定程度上提高了安全性。

NAT 通过「巧妙」的方式,在内部地址和外部地址之间进行转换。大部分情况下,我们感受不到 NAT 的存在。但仍有部分应用,需要内网设备做为服务器,被外部连接,例如:

  • 远程访问家中的 NAS、监控摄像头
  • eMule、BitTorrent 等 P2P 文件分享应用,使自己的设备可供外部连接,从而能够连接到更多分享者,获取更快的下载、上传速度
  • 部分语音通话、视频会议应用,通信双方直接连接,获取更好的通话质量
  • 部分联机游戏,不会经过第三方服务器,需要玩家之间直接建立连接

对于这些应用,如果设备位于 NAT 之内,没有公网 IP,就难以实现了。

那么,在 NAT 环境下,应该如何让内网设备做为服务器,使内网设备被外部连接?下文将介绍几种常见的方式。

3.1 多拨

部分运营商,支持在多个设备上,通过 PPPoE 登录同一个宽带账号。每个设备都能获取到一个独立的公网 IP。

如果想让游戏主机等设备获取独立的公网 IP,供外部连接,可以在光猫之后连接交换机。游戏主机连接交换机,直接进行 PPPoE 拨号。无线路由器也连接交换机,家中的其他网络设备经过无线路由器访问 Internet。

但是多拨的局限也很大:

  • 仅部分运营商支持多拨
  • 一些运营商已不再为用户分配公网 IP,即使通过多拨,也获取不到公网 IP
  • 越来越多的设备不再支持 PPPoE。例如 Xbox 360 支持 PPPoE,但 Xbox One 之后的版本已不再支持
  • 设备直接获取公网 IP,暴露在公网上,安全性较差。可能需要单独设置防火墙
  • 需要额外购买交换机,连接在光猫和路由器之间。会改变家庭网络拓扑,操作比较复杂

所以,这种方式不太常用。

3.2 端口转发、DMZ

上文中介绍的 NAT,路由器会根据内网设备发出的报文,自动形成 NAT 表项。实际上,用户还可以在路由器上手动配置端口映射关系,让内网设备可被外部访问。

其中,DMZ 功能,可以指定一台内网设备为 DMZ 主机。到达路由器上的报文,如果没有匹配 NAT 表项,就会转发到 DMZ 主机。从而使 DMZ 主机可被外部访问。

DMZ 功能能让一台内网设备上的所有端口,都能被公网访问。但这样做也影响了内网设备的安全性,如果没有特殊需要,不建议打开这一功能。

而 端口转发 功能,可以手动设置端口映射关系,让指定内网设备的指定端口,能够被公网访问。这种方式能够精确控制哪些设备的哪些端口可被公网访问。但需要用户具有一定的网络知识,知道需要被公网访问的应用的端口号,才能正确设置。

3.3 UPnP IGD、NAT-PMP

上文中的端口转发功能,需要手动配置端口转发规则,操作起来比较麻烦。而 UPnP IGD 和 NAT-PMP 协议,则能实现自动配置端口转发规则。

UPnP IGD(互联网网关设备协议)和 NAT-PMP(NAT 端口映射协议)分别由微软和 Apple 提出,功能类似,都可以让应用程序告诉路由器需要打开的端口,让路由器自动设置端口转发规则。

UPnP IGD 和 NAT-PMP 的工作,需要应用程序和路由器的配合。首先需要在路由器上打开 UPnP 或 NAT-PMP 功能

3.4 服务器中转:frp 内网穿透

上文中介绍了一系列使内网设备可被外部访问的方式。但这些方式或者需要用户手动配置,或者路由器的支持,或者需要运营商的支持…… 如果上述方式都不可用,就要通过第三方服务器中转的方式,让内网设备供外部访问。

这种方式虽然需要第三方服务器的参与,浪费资源,但成功率最高,所以应用范围也很普遍。例如常见的游戏加速器,就可以通过第三方服务器中转的方式,为游戏主机提供更高的 NAT 类型:网易UU加速盒

也有不少开源的反向代理工具,可以搭建在自己的服务器上,使内网服务可在公网访问:如 frpnps

服务器中转需要额外的服务器,且需要消耗服务器上的流量。所以这种方式往往需要用户额外付费,例如购买游戏加速器会员,或者自行购买虚拟服务器,并在服务器上搭建反向代理应用。

而对于微信语音、视频通话等应用,默认也会使用其他 NAT 穿透技术,来节省微信服务器的流量费用,降低成本。当其他 NAT 穿透方式不可用时,则采用服务器中转的方式,保证能够正常通话。

3.4.1 NAT 打洞

如果两台设备都位于 NAT 路由器之后,没有公网 IP。在没有第三方服务器的中转下,是不是就没有办法直接进行通信了?

答案并不是这样的。NAT 打洞,就可以使两台内网设备能够直接通信,不需要第三方服务器的中转、不需要对路由器进行特殊设置、也不需要运营商的配置。微信语音、腾讯会议、Skype 通信等消耗流量较大的应用,都会利用 NAT 打洞实现内网设备间的直接通信。

这项技术听起来很神奇,但是原理并不复杂:

我们以 PC 1、PC 2 两台主机的通信为例。两台主机均位于 NAT 路由器之后,各自的 IP 地址都是内网地址,无法互相通信:

在两台主机能够直接通信之前,需要一台第三方服务器:

PC 1、PC 2 首先需要给「第三方服务器」发送一个报文。经过 NAT 路由器后,报文的源 IP 和源端口号被转换,同时在路由器上形成 NAT 表项:

报文到达「第三方服务器」后,「第三方服务器」记录下 PC1、PC2 两侧报文的源 IP 和源端口号,也就是 PC1、PC2 两侧的公网 IP 和外部端口号。然后「第三方服务器」将两台设备的公网 IP、外部端口号发送给对方。这样PC1、PC2 都能相互知道对方的公网 IP 和外部端口号:

其中一部分路由器的 NAT 检查比较宽松。一旦 NAT 表项建立,只要路由器上收到的报文,目的 IP 和目的端口号能够匹配到 NAT 表项,都会转发到表项对应的内网设备。对于这样的路由器,PC 1、PC 2 互相用对方的公网 IP 与外部端口号,就能直接通信了,不再需要第三方服务器:

另一部分路由器的 NAT 检查比较严格,只有内网设备向指定的目的 IP、目的端口号发送过数据,来自这个 IP 和端口号的报文,才能转发到内网设备:

对于这样的路由器,PC 1、PC 2 两台主机需要同时向对方的公网 IP 和外部端口号发送一个报文。这样,PC 1 侧的路由器认为 PC 1 向 PC 2 发送过数据;PC 2 侧的路由器认为 PC 2 向 PC 1 发送过数据,PC 1 和 PC 2 就能相互通信了。

经历了上述步骤,NAT 打洞成功,两台设备就可以不依赖第三方路由器,直接进行通信。当然,上述过程只是一个简化的描述,不完全描述。如果想要进一步详细了解 NAT 打洞的过程,建议参考文末的 RFC 文档链接。

可以看出,NAT 打洞可以在无需路由器特殊配置、无需运营商配合的情况下,实现两个内网设备的相互通信。另外,对于多层 NAT 的网络环境(例如运营商和家庭路由器各进行一级 NAT),NAT 打洞也能正常处理。

3.4.2 NAT 类型与打洞成功率

在一些路由器的设置页面或文档中,我们会看到,NAT 能设置成不同的类型,例如 Full cone、Restricted cone、Port-Restricted cone、Symmetric。那么,这些 NAT 类型有何区别?

3.4.2.1 完全圆锥形 NAT(Full cone NAT)

对于完全圆锥形 NAT,内网 IP 和内网端口号,被映射为外部 IP 和外部端口号。当路由器收到来自外部的报文时,只要报文的目的 IP 和目的端口号,匹配到 NAT 表项的外部 IP 和外部端口号,都会转换为对应的内网 IP 和内网端口号,转发到内网设备。

对于外部报文,路由器并不关心报文的源 IP 和源端口号(即报文来自谁),只要收到匹配 NAT 表项的报文,都能发送到内网设备。所以,完全圆锥形 NAT 是最宽松的 NAT,打洞最方便。

3.4.2.2 受限圆锥形 NAT(Restricted cone NAT)

与完全圆锥形 NAT 相比,受限圆锥形 NAT,在内网设备向外发送报文时,路由器除了生成 NAT 表项,还会根据报文的目的 IP,记录下内网设备正在与哪些外部设备通信。

这样,只有内网设备先发送报文给外部设备,外部设备回应的报文,才会被转发到内网设备。而其他外部设备发送过来的报文,即使匹配 NAT 表项,也无法发送到内网设备。

这样的 NAT 安全性有一定的提高,但是也提高了打洞难度。两台内网设备需要互相给对方发送一个报文,才能打洞成功。

3.4.2.3 端口受限圆锥形 NAT(Port-Restricted cone NAT)

端口受限圆锥形 NAT 和受限圆锥形 NAT 类似,但增加了检查的严格程度:受限圆锥形 NAT,只会外部设备的 IP 地址,来检查内网设备与哪些外部设备通信过。而端口受限圆锥形 NAT,会同时根据 IP 地址和端口号来进行检查。

3.4.2.4 对称 NAT(Symmetric NAT)

前面的三种圆锥形 NAT,会根据内网设备发出去的报文的源 IP、源端口号两个信息建立 NAT 表项,将内网 IP 和内网端口号映射到外部 IP 和外部端口号。内网设备发出去的报文,无论目的 IP 和目的端口号如何变化,不管发给哪台外部设备,都会被映射为相同的外部 IP 和外部端口号。

而对称 NAT,会同时根据内网设备出方向报文的源 IP、源端口号、目的 IP、目的端口号四个信息来建立 NAT 表项。如果报文的目的 IP、目的端口号发生了变化,映射到的外部端口号也会发生改变。

对于对称 NAT,我们再来回顾一下前文中 NAT 打洞的过程。内网设备首先和第三方服务器通信,内网 IP 和内网端口号会被映射为一个外部 IP 和外部端口号。接下来,内网设备和另一台设备通信,相同的内网 IP 和内网端口号,又会被映射为另外一个外部端口号。这样,NAT 打洞就无法成功。

所以,在对称 NAT 下,很难进行 NAT 打洞。

3.4.3 NAT 打洞的应用

前文中已经提到,语音通话、视频会议应用,以及在线游戏,都用到了 NAT 打洞。那么,利用 NAT 打洞,还能实现哪些有趣的应用?

其实,最常见的应用,就是通过 NAT 打洞,将多个设备组建一个虚拟局域网。例如在家中有 NAS,且没有公网 IP 的情况下,通过这些利用 NAT 打洞的工具,仍然可以在离开家的时候,用手机直接访问 NAS 上的文件。而且由于是直接通信,不会因为第三方服务器中转而降低传输速度。https://www.zerotier.com/、https://tailscale.com/

3.4.4 NAT 打洞的缺点

比起使用公网 IP 直接通信,NAT 打洞仍存在不少缺点,例如:

  • NAT 打洞仍需第三方服务器的参与
  • NAT 打洞无法 100% 成功,尤其是对称 NAT,更难打洞成功
  • NAT 打洞的过程,需要开发者对应用程序进行适配
  • 为了节省资源,路由器上的 NAT 表项会超时删除。所以,NAT 打洞后,需要定期发送报文,维持路由器上的 NAT 表项。否则需要重新打洞
  • NAT 打洞的操作本身,也增加了延迟
  • NAT 打洞对 TCP 的支持不佳,一般使用 UDP。但不少运营商会对 UDP 进行限速,导致打洞后虽然设备间能直接通信,但无法以较快的速率传输

为了保证安全性,家用路由器、PC、NAS 等设备上,一般都有防火墙功能。防火墙默认会阻止传入连接,除非用户手动配置防火墙,打开特定端口。所以,即使 NAT 被淘汰,类似 NAT 打洞的技术,在 IPv6 时代仍会得到应用。

那么,当 IPv6 普及,NAT 消失,Internet 是否会诞生新的有趣的应用?家中的每个物联网设备都有了公网 IPv6 地址,是否会有一些新的玩法?是否会带来新的安全性问题?随着国内大幅度推进 IPv6 的建设,这些问题应该很快会有答案。

NAT介绍
NAT 应用介绍
42张图介绍 NAT

猜你喜欢

转载自blog.csdn.net/jiaoyangwm/article/details/134738348