nftables + flowtable 端口转发:怎么配,为什么比 iptables 快

一条笔直的高亮快速路径从哈希表直达出口网卡,绕开了上方经过四个规则处理环节的常规转发路径
目录

中转机上的端口转发,以前一直是 iptables 的 DNAT 加 SNAT。换成 nftables 并配上 flowtable 之后,连接建立以后的包在网卡入口就直接转发出去,不再走完整的内核转发路径;新增一个转发也只需要加一行 DNAT。

这篇记录完整配置和日常操作,讲清楚 flowtable 为什么更快,最后附上原来的 iptables 写法和迁移步骤,方便对照。

场景

一台单网卡(eth0)的中转机,把收到的几个端口转发到目标机 1.2.3.4:

中转机端口转发到协议
540001.2.3.4:54000TCP + UDP
540011.2.3.4:6667TCP + UDP
540021.2.3.4:54002只转 TCP

每个转发都要改两次地址:

  • DNAT:把目的地址从中转机改成目标机。
  • SNAT:把源地址从客户端改成中转机。不做 SNAT,目标机会把回包直接发给客户端,而客户端连的是中转机,对不上号,连接建立不起来。

改写关系记在 conntrack(连接跟踪表)里,回包按原路反向还原,不需要另写规则。无论用 nftables 还是 iptables,都要先打开内核转发:

bash
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:

text
#!/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
    }
}

加载并设为开机启动:

bash
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 的连接即可:

bash
iptables -I DOCKER-USER -m conntrack --ctstate DNAT -j ACCEPT

日常操作:新增、删除、持久化

flowtable 本身不用改:所有转发的 TCP/UDP 连接都会自动卸载,SNAT 也是通用规则。所以新增一个转发,只需要加一行 DNAT。

推荐:改配置文件再加载

改配置文件再加载,这样立即生效,开机也会自动加载:

bash
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 连接传输途中重新加载,连接没有中断。计数器清零,是因为规则被删除后重建了。

临时添加(重启后消失)

bash
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 时也会被清掉。

确认转发和卸载生效

bash
nft list chain ip relay prerouting     # 每条 DNAT 的 counter:命中过多少个新连接
nft list flowtables                    # flowtable 是否存在、挂在哪些网卡上
conntrack -L | grep OFFLOAD            # 正在走快速路径的连接(需要安装 conntrack 包)

走快速路径的连接在 conntrack -L 里带 [OFFLOAD] 标记,大致是这样(地址为示意):

text
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 规则,查找一次哈希表:

text
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 两条链与上面相同,这里省略
}

运行时可以直接增删条目,不用改规则:

bash
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,每个被转发的包都要走完整条路径:

  1. 网卡收包,进入 IP 层。
  2. PREROUTING 钩子:在 conntrack 里查到这个包所属的连接。nat 规则只在新连接时匹配,之后直接按记下的映射改写。
  3. 路由查找,决定从哪张网卡发出去。
  4. FORWARD 钩子:filter 规则逐条匹配。
  5. POSTROUTING 钩子:SNAT 改写;新连接在这里正式写入 conntrack。
  6. 交给邻居子系统(解析下一跳的 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

对一个已经建立的连接,每个包要做的事对比如下:

每个包要做的事iptablesnftables(不加 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 要这样写:

bash
# 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 规则了:

bash
iptables -t nat -A POSTROUTING -m conntrack --ctstate DNAT -j MASQUERADE

查看、删除和持久化:

bash
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 链):

bash
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

  1. 导出现有的 nat 规则,让工具先翻译一遍作参考:

    bash
    iptables-save -t nat > /root/nat.rules
    iptables-restore-translate -f /root/nat.rules

    输出是一条条 nft 命令,例如:

    text
    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 更清爽。

  2. 安装并加载新配置,设为开机启动:

    bash
    apt install nftables
    nft -c -f /etc/nftables.conf
    nft -f /etc/nftables.conf
    systemctl enable nftables

    新旧规则暂时并存也没关系:同一个连接只会被第一个匹配到的 nat 规则改写,两边目标一致,结果一样,转发不会中断。

  3. 删掉旧的 iptables 转发规则。只删自己加的那几条(iptables -t nat -D …);机器上有 Docker 时不要 iptables -t nat -F,那会把 Docker 的规则一起清掉。

  4. 别让旧规则开机后回来:删掉规则后执行一次 netfilter-persistent save 重新保存;如果已经不再用 iptables 保存规则,就 systemctl disable netfilter-persistent。

  5. 从客户端连一次转发端口,再用 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)。

评论