nftables + flowtable 端口转发:怎么配,为什么比 iptables 快
目录
中转机上的端口转发,以前一直是 iptables 的 DNAT 加 SNAT。换成 nftables 并配上 flowtable 之后,连接建立以后的包在网卡入口就直接转发出去,不再走完整的内核转发路径;新增一个转发也只需要加一行 DNAT。
这篇记录完整配置和日常操作,讲清楚 flowtable 为什么更快,最后附上原来的 iptables 写法和迁移步骤,方便对照。
场景
一台单网卡(eth0)的中转机,把收到的几个端口转发到目标机 1.2.3.4:
| 中转机端口 | 转发到 | 协议 |
|---|---|---|
| 54000 | 1.2.3.4:54000 | TCP + UDP |
| 54001 | 1.2.3.4:6667 | TCP + UDP |
| 54002 | 1.2.3.4:54002 | 只转 TCP |
每个转发都要改两次地址:
- DNAT:把目的地址从中转机改成目标机。
- SNAT:把源地址从客户端改成中转机。不做 SNAT,目标机会把回包直接发给客户端,而客户端连的是中转机,对不上号,连接建立不起来。
改写关系记在 conntrack(连接跟踪表)里,回包按原路反向还原,不需要另写规则。无论用 nftables 还是 iptables,都要先打开内核转发:
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-forward.conf
sysctl --system下文命令都以 root 运行。1.2.3.4 是示例地址;网卡名用 ip -br link 确认,云服务器上常见的还有 ens3、enp1s0。
nftables + flowtable 的完整配置
/etc/nftables.conf:
#!/usr/sbin/nft -f
# 只替换自己这张表,不碰其他程序的规则。
# 先声明再删除:第一次加载时表还不存在,也不会报错。
table ip relay
delete table ip relay
table ip relay {
# 快速路径:已登记的连接在 eth0 入口直接转发
flowtable ft {
hook ingress priority 0
devices = { eth0 }
}
# DNAT:每个转发一行
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
meta l4proto { tcp, udp } th dport 54000 counter dnat to 1.2.3.4 # 端口不变
meta l4proto { tcp, udp } th dport 54001 counter dnat to 1.2.3.4:6667 # 换端口
tcp dport 54002 counter dnat to 1.2.3.4 # 只转 TCP
}
# 把转发的 TCP/UDP 连接登记进 flowtable
chain forward {
type filter hook forward priority filter; policy accept;
meta l4proto { tcp, udp } flow add @ft
}
# SNAT:凡是做过 DNAT 的连接,源地址都换成中转机自己的
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ct status dnat masquerade
}
}加载并设为开机启动:
nft -c -f /etc/nftables.conf # 只检查,不生效
nft -f /etc/nftables.conf # 加载
systemctl enable nftables # 开机自动加载Debian 和 Ubuntu 的 nftables 服务读取 /etc/nftables.conf;RHEL 系读取的是 /etc/sysconfig/nftables.conf,路径相应调整。下面逐段解释这份配置。
flowtable:快速路径的入口
hook ingress 把 flowtable 挂在网卡收包的最早位置,这时包还没有进入 IP 层。devices 写转发流量进出的网卡:单网卡中转机进出都是 eth0,双网卡的路由器要把两张都写上。只有从这些网卡收到的包才会查 flowtable。
flowtable 刚创建时是空的,靠 forward 链里的 flow add 往里登记连接。
prerouting:DNAT
dnat to 1.2.3.4 只改地址、保留端口;dnat to 1.2.3.4:6667 连端口一起改。meta l4proto { tcp, udp } 同时匹配 TCP 和 UDP,th dport 取传输层头部(transport header)里的目的端口,两种协议通用。
nat 类型的链只处理每个连接的第一个包,后续的包直接按 conntrack 里记下的映射改写。所以这里的 counter 统计的是新建连接数,不是包数或流量。实测一条传了 50 MiB 的连接,对应规则的计数是 packets 1 bytes 60,这 60 字节就是那个 SYN 包。
forward:登记连接
flow add @ft 把连接登记进 flowtable(老版本写作 flow offload @ft,效果相同)。每个连接的第一个包总是走常规路径,之后的包经过这条规则时,连接才会被登记。登记以后,这个连接的包不再经过 forward 链。
postrouting:通用 SNAT
ct status dnat 匹配“做过 DNAT 的连接”,masquerade 自动使用出口网卡的地址作为源地址。这条规则不绑定任何端口或目标,以后加减转发都不用改它。中转机是固定 IP 的话,也可以写成 ct status dnat snat to <中转机 IP>。
表族 ip 只处理 IPv4;要同时转发 IPv6,改用 inet 表族。
别用 flush ruleset,也别指望自己表里的 accept
Debian 自带的 /etc/nftables.conf 开头有一行 flush ruleset,它会清空整个内核规则集,连 Docker、fail2ban、firewalld 写进去的规则一起清掉。上面的配置只删除重建 relay 这一张表。
另外,同一个钩子上的所有基础链都放行,包才能通过;任何一条链 drop 就会丢包,自己表里的 policy accept 救不回来。最常见的是装了 Docker 的机器:Docker(默认的 iptables 后端)把 FORWARD 默认策略设为 DROP,转发会全部失败。在 Docker 预留的 DOCKER-USER 链里放行做过 DNAT 的连接即可:
iptables -I DOCKER-USER -m conntrack --ctstate DNAT -j ACCEPT日常操作:新增、删除、持久化
flowtable 本身不用改:所有转发的 TCP/UDP 连接都会自动卸载,SNAT 也是通用规则。所以新增一个转发,只需要加一行 DNAT。
推荐:改配置文件再加载
改配置文件再加载,这样立即生效,开机也会自动加载:
nano /etc/nftables.conf # 在 chain prerouting 里加一行,例如:
# meta l4proto { tcp, udp } th dport 54000 counter dnat to 1.2.3.4 # 端口不变
# meta l4proto { tcp, udp } th dport 54001 counter dnat to 1.2.3.4:6667 # 换端口
# tcp dport 54002 counter dnat to 1.2.3.4 # 只转 TCP
nft -c -f /etc/nftables.conf # 先检查语法
nft -f /etc/nftables.conf # 原子替换,现有连接不断;计数器会清零删除转发也一样:从文件里删掉那一行,再执行一次 nft -f。
整个文件在一个事务里提交,要么全部生效,要么一条都不生效,不会出现删了旧规则、新规则还没加上的空档。已建立连接的地址映射保存在 conntrack 里,不依赖规则本身,所以重新加载不会打断它们:已经卸载的连接先回到常规路径,下一个包经过 forward 链时重新登记。实测在一条 TCP 连接传输途中重新加载,连接没有中断。计数器清零,是因为规则被删除后重建了。
临时添加(重启后消失)
nft 'add rule ip relay prerouting meta l4proto { tcp, udp } th dport 54000 counter dnat to 1.2.3.4'
nft -a list chain ip relay prerouting # 查看规则的 handle 编号
nft delete rule ip relay prerouting handle <编号>整条规则用单引号包起来,免得 shell 处理 { }、; 这些字符。-a 会在每条规则后面显示 # handle 36 这样的编号,删除时用它。临时规则不在配置文件里,下次执行 nft -f 时也会被清掉。
确认转发和卸载生效
nft list chain ip relay prerouting # 每条 DNAT 的 counter:命中过多少个新连接
nft list flowtables # flowtable 是否存在、挂在哪些网卡上
conntrack -L | grep OFFLOAD # 正在走快速路径的连接(需要安装 conntrack 包)走快速路径的连接在 conntrack -L 里带 [OFFLOAD] 标记,大致是这样(地址为示意):
tcp 6 src=203.0.113.7 dst=198.51.100.1 sport=51234 dport=54000 src=1.2.3.4 dst=198.51.100.1 sport=54000 dport=51234 [OFFLOAD] mark=0 use=2卸载之后,forward 链里的计数器不再增长,conntrack -L 显示的流量也停在卸载那一刻。想让 conntrack 继续统计流量,在 flowtable 里加一行 counter(Linux 5.7 起支持)。
转发多了以后:用 map
转发多起来之后,可以把“端口 → 目标地址和端口”放进一个 map,只用一条 DNAT 规则,查找一次哈希表:
table ip relay
delete table ip relay
table ip relay {
flowtable ft {
hook ingress priority 0
devices = { eth0 }
}
map portmap {
type inet_service : ipv4_addr . inet_service
elements = {
54000 : 1.2.3.4 . 54000,
54001 : 1.2.3.4 . 6667,
}
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
meta l4proto { tcp, udp } dnat ip addr . port to th dport map @portmap
}
# forward 和 postrouting 两条链与上面相同,这里省略
}运行时可以直接增删条目,不用改规则:
nft add element ip relay portmap '{ 54003 : 5.6.7.8 . 443 }'
nft delete element ip relay portmap '{ 54003 }'几个要注意的地方:
- 这条规则对 TCP 和 UDP 一视同仁,“只转 TCP”的端口还是单独写一行
tcp dport … dnat to …。 - map 不能起名叫
fwd,它是 nft 的关键字,会报syntax error, unexpected fwd。 - 用
add element加的条目不在文件里,下次nft -f时会被清掉,长期保留的条目要写进文件。实测把配置开头的delete table换成flush table的话,只会清空规则,map 里的元素都会留下,文件里删掉的条目也不会消失。
map 主要是好维护。性能上,nat 链本来就只看每个连接的第一个包,几十条 DNAT 规则逐条匹配也花不了多少时间。
为什么比 iptables 快
一个转发包在内核里的常规路径
不加 flowtable 时,无论用 iptables 还是 nftables,每个被转发的包都要走完整条路径:
- 网卡收包,进入 IP 层。
- PREROUTING 钩子:在 conntrack 里查到这个包所属的连接。nat 规则只在新连接时匹配,之后直接按记下的映射改写。
- 路由查找,决定从哪张网卡发出去。
- FORWARD 钩子:filter 规则逐条匹配。
- POSTROUTING 钩子:SNAT 改写;新连接在这里正式写入 conntrack。
- 交给邻居子系统(解析下一跳的 MAC 地址)和网卡队列,发出。
所以 DNAT 规则多几条并不是问题,nat 规则本来就只对新连接生效。真正每个包都要付的成本,是 conntrack 查表、路由查找,以及三个钩子上每张表的规则遍历。
flowtable 的快速路径
flowtable 在这条路的最前面加了一条捷径。登记连接时,它记下两个方向的地址和端口、NAT 改写方式、出口网卡和下一跳。之后从 devices 里的网卡收到的包,在 ingress 钩子查一次哈希表:
- 命中:按记下的方式改写地址和端口,TTL 减一,直接交给出口网卡发出。IP 层的接收处理、三个 netfilter 钩子、conntrack 查表、路由查找和所有规则都被跳过。
- 未命中:照常走上面的常规路径。
flowchart TD
rx["网卡收包"] --> hit{"flowtable 里有这个连接?"}
hit -- "有" --> nat["改写地址和端口,TTL 减一"]
nat --> tx["网卡发出"]
hit -- "没有" --> pre["PREROUTING:conntrack、DNAT"]
pre --> rt["路由查找"]
rt --> fwd["FORWARD:flow add 登记连接"]
fwd --> post["POSTROUTING:SNAT"]
post --> tx对一个已经建立的连接,每个包要做的事对比如下:
| 每个包要做的事 | iptables | nftables(不加 flowtable) | nftables + flowtable |
|---|---|---|---|
| 进入 IP 层 | 是 | 是 | 否 |
| 经过 PREROUTING、FORWARD、POSTROUTING | 全部经过 | 全部经过 | 都不经过 |
| conntrack 查表 | 每个包一次 | 每个包一次 | 不查,改为查一次 flowtable |
| 路由查找 | 每个包一次 | 每个包一次 | 不查,用登记时记下的出口 |
| filter 规则 | 逐条匹配 | 逐条匹配,可用集合加速 | 不匹配 |
| nat 规则 | 只匹配第一个包 | 只匹配第一个包 | 只匹配第一个包 |
| 改写地址和端口 | 是 | 是 | 是 |
| 出口队列(tc qdisc) | 经过 | 经过 | 经过 |
nftables 本身的改进
flowtable 之外,nftables 还有几处实在的改进:
- 规则更新:传统后端(legacy)的 iptables 每执行一条
iptables -A,都要把整张表从内核读出来,改完再整张写回去。规则越多越慢,多个程序同时修改还要靠-w加锁排队。nftables 通过 netlink 事务增删单条规则,nft -f把整个文件作为一个事务原子提交。 - 集合和映射:用一次哈希查找代替一串规则逐条匹配,比如上面的
portmap。 - 一套工具:
inet表族同时处理 IPv4 和 IPv6,不用分别维护 iptables 和 ip6tables。
但只把 iptables 规则一条条翻译成 nft 规则的话,转发路径并没有变,两者用的是同一套 netfilter 钩子和 conntrack。真正拉开差距的是 flowtable。
什么时候能感觉到
快速路径省下的是 CPU,所以 CPU 成为瓶颈时最明显:小包多、每秒包数高的流量(比如游戏、语音这类 UDP),单核性能弱的小 VPS 或软路由。如果中转带宽本身不高、CPU 也很闲,速度和延迟未必有明显差别,但软中断占用(top 里的 si)会更低。
哪些包不走快速路径
内核文档列出的情况:
- 只处理 IPv4/IPv6 上的 TCP 和 UDP,ICMP 等其他协议始终走常规路径。
- 每个连接的第一个包走常规路径;TCP 的 FIN、RST 包会回到常规路径,好让连接正常结束。
- 分片包、超过出口 MTU 的包回到常规路径(后者是为了给发送方回 ICMP “包太大”)。
另外,已卸载的包不再经过 ingress 之后的任何 netfilter 钩子,forward 链里的限速、日志、计数都看不到它们。要对转发流量限速,用 tc 在出口队列上做。flags offload 可以把转发进一步交给网卡硬件,但需要网卡或路由器芯片支持,云服务器的虚拟网卡一般用不上,软件 flowtable 已经够用。
原来的 iptables 怎么做转发
同样三个转发,用 iptables 要这样写:
# 54000 → 1.2.3.4:54000,TCP + UDP
iptables -t nat -A PREROUTING -p tcp --dport 54000 -j DNAT --to-destination 1.2.3.4:54000
iptables -t nat -A PREROUTING -p udp --dport 54000 -j DNAT --to-destination 1.2.3.4:54000
iptables -t nat -A POSTROUTING -d 1.2.3.4 -p tcp --dport 54000 -j MASQUERADE
iptables -t nat -A POSTROUTING -d 1.2.3.4 -p udp --dport 54000 -j MASQUERADE
# 54001 → 1.2.3.4:6667,换端口
iptables -t nat -A PREROUTING -p tcp --dport 54001 -j DNAT --to-destination 1.2.3.4:6667
iptables -t nat -A PREROUTING -p udp --dport 54001 -j DNAT --to-destination 1.2.3.4:6667
iptables -t nat -A POSTROUTING -d 1.2.3.4 -p tcp --dport 6667 -j MASQUERADE
iptables -t nat -A POSTROUTING -d 1.2.3.4 -p udp --dport 6667 -j MASQUERADE
# 54002 → 1.2.3.4:54002,只转 TCP
iptables -t nat -A PREROUTING -p tcp --dport 54002 -j DNAT --to-destination 1.2.3.4:54002
iptables -t nat -A POSTROUTING -d 1.2.3.4 -p tcp --dport 54002 -j MASQUERADE几个要点:
- iptables 一条规则只能指定一种协议,TCP 和 UDP 各写一条。
- POSTROUTING 在 DNAT 之后执行,看到的是改写后的目的地址和端口,所以换端口的那组 SNAT 要匹配
--dport 6667,而不是 54001。 MASQUERADE也可以换成-j SNAT --to-source <中转机 IP>。- 每加一个转发要加 2~4 条规则,删的时候也要成对删干净。
其实 iptables 也能写通用 SNAT:用 conntrack 模块匹配做过 DNAT 的连接,效果和 nftables 里的 ct status dnat masquerade 一样,这样每个转发就只剩 DNAT 规则了:
iptables -t nat -A POSTROUTING -m conntrack --ctstate DNAT -j MASQUERADE查看、删除和持久化:
iptables -t nat -L PREROUTING -n --line-numbers # 带编号列出
iptables -t nat -D PREROUTING 3 # 按编号删除第 3 条
apt install iptables-persistent # Debian / Ubuntu
netfilter-persistent save # 保存到 /etc/iptables/rules.v4,开机自动恢复RHEL 系用 iptables-services 包,service iptables save 保存到 /etc/sysconfig/iptables。如果 FORWARD 默认策略是 DROP,还要放行转发的连接(有 Docker 时放进 DOCKER-USER 链):
iptables -I FORWARD -m conntrack --ctstate DNAT -j ACCEPT你敲的 iptables,背后可能已经是 nftables
先看一下 iptables -V 的输出:(nf_tables) 表示 nf_tables 后端,(legacy) 表示传统后端。Debian 10、Ubuntu 21.10、RHEL 8 起默认都是 nf_tables 后端,敲下去的 iptables 命令会被翻译成 nftables 规则,用 nft list ruleset 能看到 table ip nat 这样的表。
所以 iptables 慢的原因不在命令本身,而在于它的语法里没有 flowtable。OpenWrt 曾经给 iptables 打过补丁,加了一个 FLOWOFFLOAD 目标(fw3 时代的“软件流量分载”);22.03 起的 fw4 改用 nftables 原生的 flowtable。
从 iptables 迁移到 nftables
-
导出现有的 nat 规则,让工具先翻译一遍作参考:
iptables-save -t nat > /root/nat.rules iptables-restore-translate -f /root/nat.rules输出是一条条 nft 命令,例如:
add rule ip nat PREROUTING tcp dport 54000 counter dnat to 1.2.3.4:54000 add rule ip nat PREROUTING udp dport 54000 counter dnat to 1.2.3.4:54000 add rule ip nat POSTROUTING ip daddr 1.2.3.4 tcp dport 54000 counter masquerade翻译是一对一的:不会合并 TCP 和 UDP,不会改成通用 SNAT,也不会加 flowtable。照着前面的模板整理成
/etc/nftables.conf更清爽。 -
安装并加载新配置,设为开机启动:
apt install nftables nft -c -f /etc/nftables.conf nft -f /etc/nftables.conf systemctl enable nftables新旧规则暂时并存也没关系:同一个连接只会被第一个匹配到的 nat 规则改写,两边目标一致,结果一样,转发不会中断。
-
删掉旧的 iptables 转发规则。只删自己加的那几条(
iptables -t nat -D …);机器上有 Docker 时不要iptables -t nat -F,那会把 Docker 的规则一起清掉。 -
别让旧规则开机后回来:删掉规则后执行一次
netfilter-persistent save重新保存;如果已经不再用 iptables 保存规则,就systemctl disable netfilter-persistent。 -
从客户端连一次转发端口,再用
conntrack -L | grep OFFLOAD确认连接走了快速路径。
小结
- nftables + flowtable 的转发由三部分组成:prerouting 里每个转发一行 DNAT,postrouting 里一条通用的
ct status dnat masquerade,forward 里一条flow add把连接交给 flowtable。 - 日常增删转发只改 DNAT 那一行:改完文件先用
nft -c检查,再用nft -f原子加载。 - 快的原因不在 nftables 的语法,而在 flowtable:连接建立后,包在网卡入口查一次哈希表就直接发出,跳过了 IP 层、netfilter 钩子、conntrack 和路由查找。
- iptables 要为每个转发分别写 TCP、UDP 的 DNAT 和 SNAT,也没有 flowtable;迁移时先用
iptables-restore-translate参考,再按模板整理。
参考:Netfilter's flowtable infrastructure(内核文档)、Flowtables(nftables wiki)。
评论