谁动了你的五元组-nf_conntrack与NAT的性能

在互联网上一个五元组标识一个应用程序到远端的另一个应用程序的连接。要保证端到端的可达性,显然在全局范围内,五元组必须是唯一的。

保证五元组的全局唯一性看起来是个重体力劳动,以IPv4网络为例,仅仅考虑TCP和UDP,一个五元组空间包括两个32位IPv4地址,两个16位端口以及一个协议,总共 2 32 × 2 + 16 × 2 + 1 2^{32\times 2+16\times 2+1} 232×2+16×2+1种组合。穷尽这么大一个空间来寻找一个没有被使用的元组绝对是重体力劳动。

然而在分布式的互联网环境,五元组的全局唯一性其实非常容易保证,互联网算力分布在世界的各个角落,这个五元组的唯一性是由全世界所有的计算机一起保证的:

  • 每个计算机的IP地址是不同的,这就卸载了穷举 2 32 2^{32} 232种可能性的算力。
  • 访问目标如果不同,又将卸载穷举 2 32 2^{32} 232种可能性的算力。
  • 最终,我们可能只需要在万数量级的端口范畴计算就可以了。

但是互联网上的主机并不是完全分布对等的,这完全是因为NAT的存在!

NAT的存在,将已经是分布在每台计算机的保证五元组唯一的计算重新集中了起来!

由于互联网计算机的分布式特性,五元组的唯一性是在连接初始时就可以保证的,当一个五元组经过任何一台中间设备时,该设备完全可以保证这个五元组的唯一性。

但如果一台设备对数据包的五元组做了NAT,由于该NAT设备并没有全局的五元组信息库,因此它在做NAT时不得不通过精心的计算以确保NAT过后五元组唯一性仍然可以保证。当然,它也仅仅保证经由本机的连接的五元组唯一性。

对于Linux内核Netfilter实现的NAT而言,五元组唯一性是通过 get_unique_tuple 函数实现的,我不会在这里分析该函数的实现,它大致说的是:

  • 对于SNAT,尽量使用保存的已知tuple,因为大概率它们访问的目标是不同的,这会大大卸载算力。( 【第一步】 )
  • 万不得已再穷举tuple命名空间,但依然有很多trick优化,盲搜多次,然后放弃。
  • get_unique_tuple不会穷尽整个命名空间,盲搜失败后便停止,NAT的失败并非意味着tuple命名空间的枯竭。

但以上的过程依然是个重体力劳动,特别是在流量很大的情况下。

有人可能会有疑问,我只配置了一条NAT规则,仅仅有针对性的转换特定流的一个源IP地址,这个怎么可能对全局产生影响呢?

很多人都会有这种疑问,其实这个很容易解释:

  • NAT只有开启和关闭之说,与规则如何无关。

只要开启了NAT,所有的数据流必须要同等对待。在真正匹配到NAT规则之前,系统对规则和匹配情况并不知情,事实上,五元组的唯一性完全是NAT自身来保证的。因此只要是开启了NAT,必须对每一条流施加get_unique_tuple这个重体力劳动!

即便一条流没有匹配到任何NAT规则,它依然要执行nf_nat_alloc_null_binding来将所有流纳入到一个全局的tuple命名空间,这为NAT真正执行get_unique_tuple时提供了优化:

  • 在执行SNAT的HOOK点,将tuple加入到一个nf_nat_bysource链表,为上述 【第一步】 提供依据。

曾经有一个问题,当NAT的HOOK函数注册的时候,之前的conntrack并没有被纳入NAT全局的tuple命名空间,也并没有加入到nf_nat_bysource链表,会不会有问题呢?我跟别人解答这个问题,答案是:

  • 不会有问题,只是损失些性能罢了。如果一个流进入nf_nat_fn时和已记录5-tuple有冲突,那么该流不会同时是NEW且!nf_nat_initialized,早就命中了不是吗?

然而真的是这样吗?非也!

我想表达的是,nf_nat_alloc_null_binding这个函数是必须的,同时它可能会默默改变你的连接的源端口,信吗?

我不想过多的解释细节,如果你懂nf_conntrack和NAT的细节,应该知道我下面的脚本再说什么。

给出测试拓扑环境:

  • 客户端 192.168.56.101:12345 连接服务器 192.168.56.102:80

首先,我们设置以下的iptables规则,仅仅TRACK到达80端口的连接,同时添加一条无关的NAT规则,以注册conntrack HOOK(由于conntrack HOOK的延迟注册,需要实际添加一条NAT规则):

*raw
-A PREROUTING -p udp -j NOTRACK
-A PREROUTING -p tcp -m tcp ! --sport 80 -j NOTRACK
-A OUTPUT -p tcp -m tcp ! --dport 80 -j NOTRACK
-A OUTPUT -p udp -j NOTRACK
*nat
-A OUTPUT -d 2.3.4.5/32 -p udp -j DNAT --to-destination 5.4.3.2

其次,我给出一个python程序,一个TCP客户端,bind特定的地址端口,连接特定的地址端口:

#!/usr/bin/python3

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('192.168.56.101', 12345))

server_address = ('192.168.56.102', 80)
sock.connect(server_address)
sock.close();

最后,我给出一个stap脚本,它的意思是:

  • 在python新建连接被conntrack记录前什么也不做。
  • 在python新建连接被NEW后模拟创建一个“与之reply方向冲突”的conntrack项,在实际中这完全是可能的。
  • 观察python新建连接发出的包,其端口号竟然变了!

脚本如下:

#!/usr/bin/stap -g

%{
    
    
#include <net/netfilter/nf_conntrack.h>
%}

probe module("nf_nat").function("__nf_nat_alloc_null_binding")
{
    
    
	if ($manip == 1) {
    
    
		// 在OUTPUT NAT执行时,模拟一个完全正常的可能发生的conntrack item插入
		system("conntrack -I --protonum 6 --timeout 100 --reply-src 192.168.56.102 --reply-dst 192.168.56.101 --state SYN_SENT --reply-port-dst 12345 --reply-port-src 80 --src 1.1.1.1 --dst 192.168.56.102");
		// 防止stap同步问题,延迟一会儿再整
		mdelay(100);
	}
}

// 打印一些看似无关紧要的信息,但确实给出了tuple冲突的结果
probe module("nf_conntrack").function("nf_conntrack_tuple_taken").return
{
    
    
	printf("nf_conntrack_tuple_taken   ret:%d\n", $return);
}

%{
    
    
struct nf_conn *thief = NULL;
%}

function alertit(stp_ct:long)
%{
    
    
	struct nf_conn *ct = (struct nf_conn *)STAP_ARG_stp_ct;
	struct nf_conntrack_tuple *tuple;
	unsigned short port;

	tuple = &ct->tuplehash[IP_CT_DIR_REPLY].tuple;
	port = ntohs((unsigned short)tuple->dst.u.all);
	if (port == 80 && thief == NULL) {
    
    
		STAP_PRINTF("The thief coming!\n");
		thief = ct;
	}
%}

probe module("nf_conntrack").function("nf_conntrack_hash_check_insert")
{
    
    
	alertit($ct);
}

function run_away(stp_tuple:long, stp_ct:long)
%{
    
    
	struct nf_conntrack_tuple *tuple = (struct nf_conntrack_tuple *)STAP_ARG_stp_tuple;
	struct nf_conn *ct = (struct nf_conn *)STAP_ARG_stp_ct;
	struct nf_conntrack_tuple *t;

	if (thief) {
    
    
		t = &thief->tuplehash[IP_CT_DIR_REPLY].tuple;
		//t->dst.u.all = 100; // 这两条注释本来是想破坏掉这个conntrack项的
		//t->src.u.all = 100;
		thief = NULL;
		STAP_PRINTF("The thief ran away...\n");
	}
%}

probe module("nf_conntrack").function("nf_conntrack_alter_reply")
{
    
    
	run_away($newreply, $ct);
}

执行stap脚本,然后执行client.py,但是我们看一下tcpdump抓包:

21:18:50.098848 IP 192.168.56.101.35010 > 192.168.56.102.80: Flags [S], seq 1990086505, win 64240, options [mss 1460,sackOK,TS val 814885365 ecr 0,nop,wscale 7], length 0
21:18:50.098872 IP 192.168.56.102.80 > 192.168.56.101.35010: Flags [S.], seq 1199002250, ack 1990086506, win 65160, options [mss 1460,sackOK,TS val 2915891341 ecr 814885365,nop,wscale 7], length 0
21:18:50.099064 IP 192.168.56.101.35010 > 192.168.56.102.80: Flags [.], ack 1, win 502, options [nop,nop,TS val 814885466 ecr 2915891341], length 0

可以看到,端口已经不是12345了,它变成了35010,这一切都是在 get_unique_tuple 中,此处不细聊。

一个真实的场景就是,在192.168.56.101.12345 > 192.168.56.102.80发起, conntrack初始NEW之后,confirm之前 有一个conntrack项被创建,或者是直接恶意插入的,或者某个流是真的命中了一条NAT规则:

src 1.1.1.1:12345 dst 192.168.56.102:80 --> src 192.168.56.101:12345 dst 192.168.56.102:80

那么原始测试流的源端口将会被偷偷地,默默地改变!

关于nf_nat_alloc_null_binding,我已经单独写了一篇文章,这其实是我一直都想说的话题:
https://blog.csdn.net/dog250/article/details/112691374


下图展示一个nf_conntrack/NAT的宏观场面,我把重体力劳动都用浅红色底色表示:

在这里插入图片描述

现在我们看到一些相对耗时的重体力劳动,如果要优化性能,避开这些位置,或者优化这些位置均可。

nf_conntrack已经在这些方面有所工作了,比方说如果没有一条NAT规则被添加,那么就干脆不注册conntrack HOOK(这并不完美,因为其它的模块只要注册了conntrack HOOK,NAT依然是数据包的必经之路)。

不管怎么说,认清conntrack & NAT的性能瓶颈到底在哪儿是必要的,如果你的机器连接数达到了几十万上百万,你看看你的conntrack hash表的大小是不是很小,遍历一次冲突链表的开销会不会很大,这些情况有时候并不是nf_conntrack本身的问题,可能仅仅是你的配置问题。必要的时候,想办法把不相关的流量NOTRACK掉也是一种优化方案,比方说,对于那些频繁的又没有NAT,status filter等需求的短连接,NOTRACK将会避开get_unique_tuple以及spin lock从而大大提高单机性能。


浙江温州皮鞋湿,下雨进水不会胖。

猜你喜欢

转载自blog.csdn.net/dog250/article/details/112506797