Linux Netfilter/NAT的两个典型问题

上周有一天下班回家路上,在一个三流技术群被一群网络新手和大佬一起鄙视是什么感觉?只因为我在讨论Netfilter而没有说eBPF,XDP,DPDK?嗯,我必须好好说道说道。

十年前以及更久,那是Netfilter的黄金时期,几乎任何网络相关的功能,均可以在Netfilter上实现,当时懂Netfilter的人绝对是Linux网络领域的大佬,但是随着进入了移动互联网时代,互联网巨头们在大流量大并发的大背景驱使下,Linux内核协议栈已经显得力不从心,于是各种优化手段蜂拥而至,几乎革了内核协议栈的命。

人们将协议栈的转发逻辑移到用户态,人们在网卡底层bypass,不过说真的,直到2014年,依然很少有人专门做转发的。

人多力量大,当互联网巨头这方面的需求越来越强烈时,在中国这种内卷的环境中,大家就一窝蜂扑向这个特定的领域了,等到2016年往后,如果你的简历上不写上几笔路由转发方面的优化,那简历基本是通不过的,一瞬间,几乎所有人都成了内核协议栈bypass专家。

不幸的是,我会过这方面的很多 “专家” ,绝大多数都是垃圾!他们竟然很多连基本的东西都不懂,但是XDP,DPDK这些却玩的很溜,这让人多少感到有点不公平,但他们把握住了时机和风口,我也开始祝福他们。

不光内核协议栈bypass,各大硬件厂商也纷至沓来,现如今,SmartNIC的idea已经满天飞舞,我开玩笑地说,把树莓派模拟成智能网卡,USB线实现南北向接口,人人都能玩SmartNIC。这方面的笑话就不多说了。

貌似跑题了,上面这些有时间我会详细阐述,但不是现在,现在我要说一些具体的事。

我承认,现在已经是eBPF,XDP,DPDK的时代了,我还要谈老掉牙的Netfilter,显得非常不合时宜。

Netfilter代表的是旧时代,在高大上的会议,高端论坛,或者哪怕是一些三流微信群,Netfilter是不能说出口的,人们甚至都不屑于喷它了,Netfilter代表着复杂,落后,低效,不思进取。2021年了,谈Netfilter即不正确,但是,我就是旧时代的代表,即使我也承认新时代的产物更加简约,更加高效,我依然还是想有事没事多聊聊Netfilter。

过往两年,我觉得坚持比解释更重要,首先我承认自己掌握的技术过时了,其次我也在不断学习新技术,最后必须强调,即便是Netfilter过时了,它依然很强大。

我不想像一个守旧派一样继续讲解和传播Netfilter的技术,我只想像个时髦的考古学家一样,能继续挖掘更多关于Netfilter背后的东西,我只是希望这些东西能指引新时代的产物不要成为至少是延迟成为下一个Netfilter。

今天主要聊聊Netfilter/NAT的两个问题。

延迟注册的nf_conntrack HOOK

是个人都知道nf_conntrack不好,是的,它复杂,它严重影响了单机性能,它饱受诟病。曾经有人抱怨,当然我肯定也抱怨过,只要加载了NAT内核模块,它所依赖的nf_conntrack机制就会使能,结果就是所有的流量都被都被跟踪了起来,即便你一条NAT规则都不添加也依然如此,除非你显式地增加一条NOTRACK规则,这很不幸地增加了操作的复杂性。

一般的运维管理员并不懂nf_conntrack的内部原理,在他们看来这是一个提供了特定功能的黑盒子。但是如果不懂这玩意儿内部的原理,便无法理解其性能瓶颈到底在哪,出现问题时则会非常棘手。问题在于,无缘无故就使能了nf_conntrack合适吗,我知道NAT依赖nf_conntrack,可我明明一条NAT也没有配置啊。

为了解决这个问题,4.10以后的内核改为了延迟注册nf_conntrack:

  • 当你的第一条NAT规则被添加时,才会注册nf_conntrack的HOOK。

当你添加第一条NAT规则时,xt_nat_checkentry将会被调用,而它要做的事情就是延迟注册nf_conntrack HOOK。

好了,问题貌似解决了,当不添加NAT规则时,事实上nf_conntrack并不启用,然而新的问题让人confuse:

  • 在nat表上没有一条NAT规则时增加一条非NAT(非SNAT,DNAT,MASQURADE)规则时,为什么不生效?

比方说,在没有任何NAT规则的前提下增加下列规则:

iptables -t nat -A OUTPUT -j MARK --set-mark 123

我当然知道只有流的首包才会去匹配这条规则,问题是,即便是流首包也不会被打上123这个mark啊。当你用iptables -t nat -L -v看流量计数时,会发现没有任何本地始发的流首包会匹配到这条规则。

如果你了解nf_conntrack的延迟注册,你当然知道答案。由于并没有任何NAT规则的存在,nf_conntrack并未被注册,因此在流首包进入NAT的HOOK处理函数时,其依赖的conntrack为空,因此并不会进一步为其匹配nat表上的规则,mark当然不会被打上。这怪不得别人,谁让你不在nat上做NAT而做别的,不信你去试试在nat上做DROP。

这并不是问题的全部,问题在于,对于大多数人,你可能会觉得,怎么偶尔这条–set-mark就会生效,偶尔就不生效。对于大多数运维管理员而言,不能指望他们理解如果在nat表做除了NAT之外的动作后会发生什么,更别指望他们能理解这一切的背后到底是怎么发生的了。

最后,一个形而上的问题就是,延迟注册nf_conntrack这个改动真的好吗?它解决的问题多呢还是带来的问题多呢?我认为是后者。

nf_conntrack延迟注册的意义仅仅是稍微延迟了nf_conntrack对整个系统的影响,只要你添加了一条NAT规则,它马上影响全局,同时,延迟注册这个行为在人们想在nat表上做点别的时,给人们带来了困扰。作为等效操作,在保留nf_conntrack模块加载即注册的前提下,为什么不把问题丢给操作者呢,即在添加NAT规则前一刻加载NAT模块,延迟加载NAT模块比延迟注册nf_coonntrack更直接,更何况,你加载了NAT模块而不配置NAT规则,意义何在呢?

iptables和nftables同时做NAT的问题

Linux内核的NAT机制一直以来都只有iptables在用,它的处理逻辑并没有问题,NAT的操作逻辑如下两个原则:

  • 只有尚未完成NAT匹配的流首包会去匹配NAT规则集。
  • 任意一个规则集匹配完成后,无论是否命中规则,均会设置NAT匹配已完成。

对于流首包,在遍历NAT规则集之后,为了保证五元组的全局唯一性,无论是否命中NAT规则,均需要将所有连接的五元组纳入到同一个全局命名空间,这也是为什么要对即使未命中NAT规则的流执行alloc null binding的原因,在alloc null binding的操作中,如果有五元组唯一性保证的需要,依然可能会对流的源端口进行转换,在操作的最后,会设置NAT已完成的标志。这是一个NAT逻辑的重要细节。

一直好好的,一直没有问题,然而后来有了nftables之后,事情就起了变化。nftables有自己的规则匹配逻辑,但是它和iptables公用一套基础设施,按照上述的两个原则,iptables和nftables是不能共存的,因为无论数据包先匹配了谁的规则集,均会设置NAT匹配已完成,这将导致不会再进行另一个的规则集的匹配。

本来我是想好好分析一下这个case,幸运的是,这个问题已经解决了,解决它的patch如下:
https://lore.kernel.org/netfilter-devel/[email protected]/

其核心就是将上述两个原则做了更新:

  • 只有尚未完成NAT匹配的流首包会去匹配NAT规则集。
  • 所有规则集全部匹配完成后,无论是否命中规则,均会设置NAT匹配已完成。

重点就是下面的代码段:

 		if (!nf_nat_initialized(ct, maniptype)) {
     
+			struct nf_nat_lookup_hook_priv *lpriv = priv;
+			struct nf_hook_entries *e = rcu_dereference(lpriv->entries);
 			unsigned int ret;
-
-			ret = do_chain(priv, skb, state); // 这里是个回调函数,要么是iptables的,要么是nftables的,或者你自己写的
-			if (ret != NF_ACCEPT)
-				return ret;
-
-			if (nf_nat_initialized(ct, HOOK2MANIP(state->hook)))
-				break;
-
+			int i;
+
+			if (!e)
+				goto null_bind;
+
+			for (i = 0; i < e->num_hook_entries; i++) {
      // 更新成了在一个循环中完成所有规则集的匹配。
+				ret = e->hooks[i].hook(e->hooks[i].priv, skb,
+						       state);
+				if (ret != NF_ACCEPT)
+					return ret;
+				if (nf_nat_initialized(ct, maniptype))
+					goto do_nat;
+			}
+null_bind:
 			ret = nf_nat_alloc_null_binding(ct, state->hook);
 			if (ret != NF_ACCEPT)
 				return ret;

这类问题很难排查,但是如果熟悉systemtap这种trace工具,就好办的多。以下的脚本可以list出Netfilter某个HOOK点上注册的HOOK:

#!/usr/bin/stap

global hook

probe kernel.function("nf_hook_slow")
{
     
	if ($state->hook != hook)
		next;
	num = $e->num_hook_entries;
	for (i = 0; i < num; i++) {
     
		hfn = @cast(&$e->hooks[i], "struct nf_hook_entry")->hook;
		s1 = modname(hfn);
		if (s1 == "kernel") {
     
			s1 = symname(hfn);
			printf("%s at [kernel]\n", s1);
		} else {
     
			printf("%p at [%s]\n", hfn, s1);
		}
	}
	exit();
}

probe begin {
     
	hook = $1;
}

我就是用这个发现nftables模块和iptables打架的。

虽然iptables,nftables不能共存这个问题是解决了,但后面会不会还有别的坑呢?不得而知。从设计上讲,NAT的HOOK函数中,将do_chain作为回调传进来,这本身就不对,这好像是在告诉新规则集的实现者,你只需要提供一个不同的回调函数就可以了,然而事实是,只有iptables会使用NAT这个错误的假设,一开始就让NAT HOOK的实现者把 “只能匹配一个规则集” 这个逻辑写死了。

内核中还有多少这种问题,太多了!比如说TCP协议的实现就是另一个例子。然而,我这里并非要表达一种消极贬义的抱怨,恰恰相反,我认为这才是系统进化的根源,就好像单细胞生物从来都想象不到人会是什么样子,它们甚至没有想象的能力,但就是在亿万年痛苦的自我否定中不断重构自身,才成就了我们自身以及我们周围的生态,直到今天,人体本身依然存在很多让人怒其不争的缺陷。

马上要午饭了,感谢疯子,感谢小小和安德森先生给了我这一上午的时间写这些乱七八糟的东西。


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

你可能感兴趣的:(netfiter)