网上关于 Linux 防火墙的文章很多,但大多是解决某个具体问题的零散记录。它们往往基于特定的内核版本或发行版,而不同发行版默认的防火墙工具并不相同。光是会碰到的工具名就有一长串:ipfwadm(Linux 2.0)、ipchains(Linux 2.2)、iptables、nft、firewall-cmd、ufw……没有系统了解过的读者,甚至会把 netfilter 也当成一个防火墙命令。
要弄清这些工具各自适用于什么场景,得先了解 Linux 内核的演进历史。这不是多看几篇问题导向的博客就能补上的:零散的文章读得越多,越难看清全貌,甚至越看越迷糊。
本文按时间顺序走一遍 Linux 防火墙的演进:从把规则写死在协议栈里的 ipfwadm,到引入“链”的 ipchains,再到 netfilter 把框架和规则引擎拆开,最终落到今天的三层结构:
- 用户程序:
iptables、iptables-nft、nft,负责让人写规则 - 内核钩子:netfilter 在协议栈上预留的 hook,负责“在哪里检查”
- 执行引擎:
x_tables或nf_tables,负责“怎么执行规则”
讲完演进之后,再用一章名词解释,把贯穿始终的“表”和“链”重新梳理一遍。eBPF 不属于这条主线,放在文末的拓展章节单独介绍。
适用读者
本文默认你已经用过 Linux 命令行,对防火墙规则有模糊印象即可。
- 熟悉 Linux 基本操作,知道 root 权限和内核模块是什么
- 了解 IP / TCP / UDP 的基本概念(地址、端口、连接)
- 不要求读过内核源码,涉及内核实现的部分只讲到“结构”层面
参考链接
以下是本文主要参考的官方资料,细节以它们为准。
演进总览
Linux 防火墙经历了三次结构性变化,每一次背后都有现实网络的推动:
| 转变 | 现实推动 | 结构变化 |
|---|---|---|
| ipfwadm → ipchains | 规则变多,需要能编排 | 列表变成链,可自定义、可跳转 |
| ipchains → netfilter | 地址短缺,NAT 和状态检测成为刚需 | 钩子框架,框架与执行引擎分离 |
| x_tables → nf_tables | 规则规模变大,IPv6 普及 | 内核虚拟机、增量更新、inet 族 |
%%{init: {'theme':'dark'}}%%
flowchart LR
ipfwadm["ipfwadm<br/>Linux 2.0<br/>固定规则列表"]
ipchains["ipchains<br/>Linux 2.2<br/>链与跳转"]
netfilter["netfilter<br/>Linux 2.4<br/>钩子框架"]
iptables["iptables<br/>引擎 x_tables"]
nftables["nftables<br/>Linux 3.13<br/>引擎 nf_tables"]
ipfwadm --> ipchains --> netfilter
netfilter --> iptables
netfilter --> nftables
注意图中 netfilter 之后是分叉而不是接力:iptables 和 nftables 都挂在同一套 netfilter 钩子上,换的只是规则的表达方式和执行引擎。
ipfwadm:固定规则列表
ipfwadm 是 Linux 2.0 时代(约 1996 年)的防火墙工具,规则被组织成几张固定的列表,直接嵌在协议栈的处理路径里。
Linux 最早的包过滤代码移植自 BSD 的 ipfw(Linux 1.1,1994 年前后),ipfwadm 是它在 2.0 内核上的管理工具。它只有四张固定的规则列表:
| 列表 | 作用 |
|---|---|
| input | 进入本机的数据包 |
| output | 本机发出的数据包 |
| forward | 经本机转发的数据包 |
| accounting | 流量统计(不做放行/拒绝) |
写规则的方式大致如下:
# 默认拒绝所有进入的数据包,再放行来自内网的
ipfwadm -I -p deny
ipfwadm -I -a accept -S 192.168.1.0/24
这一代的问题很直接:
- 结构死板:只有这四张列表,不能自定义分组,规则多了只能堆成一长串
- 代码耦合:过滤逻辑硬编码在协议栈各处,想加新功能就得改协议栈本身
- 无状态:每个包独立判断,不知道它属于哪条连接
ipchains:引入链
ipchains 随 Linux 2.2(1999 年)发布,最大的改进是把规则列表抽象成“链”,并允许用户自己建链、在链之间跳转。
ipchains 保留了 input、forward、output 三条内置链,但用户可以新建自定义链,把一组相关规则放在一起,再从内置链跳过去:
# 新建一条专门处理 Web 流量的链,从 input 跳转过去
ipchains -N web
ipchains -A input -p tcp -d 0/0 80 -j web
ipchains -A web -s 10.0.0.0/8 -j DENY
ipchains -A web -j ACCEPT
“链 + 跳转”让规则可以像函数调用一样组织,这个模型一直沿用到今天的 iptables 和 nftables。链在后续几代里的完整含义,要等讲完 netfilter 和两代执行引擎后才能说清,见后文“名词解释”一章。
但 ipchains 依旧是为“过滤”设计的:每个包独立判断、不知道连接状态,链的检查点也仍写死在协议栈里。等到 NAT 成为刚需,这套结构就装不下了,详见下一章的“诞生背景”。
钩子框架:netfilter
netfilter 随 Linux 2.4(2001 年)合入内核。它不再直接实现“防火墙”,而是在协议栈上预留一组钩子(hook),让其他内核模块挂上来处理数据包。
诞生背景
netfilter 的五个钩子并不是凭空设计出来的。90 年代中期互联网走向商业化,地址短缺和共享上网让 NAT 成了刚需,而 ipchains 的结构容纳不下它。
地址不够用
互联网从科研网络变成大众服务后,IPv4 地址的消耗速度远超预期,“一台机器一个公网地址”的模型开始难以为继。
- 拨号上网的家庭和小公司通常只能拿到一个公网 IP,却有多台机器要上网
- RFC 1631(1994)提出 NAT(网络地址转换),让多台主机共用一个公网地址
- RFC 1918(1996)划出
10.0.0.0/8、172.16.0.0/12、192.168.0.0/16三段私有地址,内网从此可以随意使用这些地址,出网时再转换
IPv6 是根本解法,但它的普及花了二十多年。在这段时间里,NAT 成了几乎所有网络出口的标配。
Linux 成了网关
开源、免费、能跑在旧硬件上,让 Linux 成了当时最常见的“自己动手”网关系统。
- 很多人用一台淘汰的 PC 装上 Linux,接上调制解调器,给整个家庭或办公室共享上网,IP Masquerade(地址伪装)是其中最常用的功能
- 同期的商业防火墙,如 Check Point FireWall-1、Cisco PIX,已经普遍支持 NAT 和状态检测(stateful inspection)
- Linux 想在网关和防火墙市场站住脚,就必须把这些能力做成内核的一等公民,而不是补丁
旧结构装不下
ipfwadm 和 ipchains 是为“过滤”设计的,NAT 只能以旁路的形式硬塞进去。
- 伪装挤在 forward 链里:地址改写借用 forward 链的
MASQ目标完成,过滤和改写混在同一个检查点 - 端口转发靠外挂:把公网端口映射到内网服务器,要依赖
ipportfw、ipmasqadm等补丁和外部工具 - 链的语义含混:ipchains 的
input链会看到所有进入的包,包括本该转发出去的,写规则时要时刻想着“这个包还会不会被转发、会不会被改写”
Linux 需要的不是再多一种规则,而是一套能在数据包路径的不同时机介入的通用机制,这正是 netfilter 要解决的问题。
五个钩子
netfilter 在 IPv4/IPv6 协议栈的关键位置埋了五个 hook,数据包经过时会依次调用挂在上面的函数。
%%{init: {'theme':'dark'}}%%
flowchart LR
nicIn["网卡收包"] --> pre["PRE_ROUTING"]
pre --> route1{"路由判断"}
route1 -->|"发给本机"| localIn["LOCAL_IN"]
localIn --> proc["本机进程"]
route1 -->|"需要转发"| fwd["FORWARD"]
proc --> localOut["LOCAL_OUT"]
localOut --> route2{"路由判断"}
route2 --> post["POST_ROUTING"]
fwd --> post
post --> nicOut["网卡发包"]
PRE_ROUTING:刚收到包、还没做路由判断时LOCAL_IN:确定发给本机,交给进程之前FORWARD:确定要转发给别的主机时LOCAL_OUT:本机进程刚发出包时POST_ROUTING:包即将离开网卡时
LOCAL_IN、FORWARD、LOCAL_OUT 延续了 input、forward、output 的思路;新增的 PRE_ROUTING 和 POST_ROUTING 主要是为 NAT 准备的,它们的位置由“改写地址的时机”决定:
- 目的地址转换(DNAT)在路由前:路由靠目的地址决定交给本机还是转发。端口转发必须先把目的地址从网关的公网 IP 改成内网服务器,路由才会把包转发出去;如果先路由再改,包早就被判给本机了
- 源地址转换(SNAT)在路由后:只有路由之后才知道包从哪块网卡出去,才能换成这块网卡的地址;放在最后一步,前面的过滤规则看到的仍是原始的内网地址
有了这两个钩子,地址改写有了专属位置,LOCAL_IN / LOCAL_OUT 也收窄为只管进出本机的包,转发流量单独走 FORWARD。过滤和 NAT 从此各司其职,不再纠缠在同一条链里。
每个挂在 hook 上的函数都会返回一个判决:NF_ACCEPT(继续)、NF_DROP(丢弃)、NF_QUEUE(交给用户态程序)等。同一个 hook 上可以挂多个函数,按优先级数值从小到大依次执行。
conntrack 与 NAT
有了统一的钩子,“连接跟踪”和“地址转换”终于可以作为独立模块实现,而不是散落在协议栈各处。
- conntrack(连接跟踪)挂在 hook 上记录每条连接的状态:
NEW、ESTABLISHED、RELATED、INVALID。规则从此可以写“放行已建立连接的回包”,防火墙变成了有状态的 - NAT 建立在 conntrack 之上:只需对连接的第一个包做转换决策,后续包按 conntrack 记录自动改写
这两项能力也让 Linux 补上了与商业防火墙之间最大的差距:状态检测和完整的 NAT(伪装、端口转发、一对一映射)都成了内核原生功能,不再依赖补丁。
框架与引擎分离
netfilter 最重要的意义不在某个具体功能,而在于把“在哪里检查”和“怎么检查”拆开了。
- netfilter 框架只负责提供 hook、管理优先级、传递判决
- 规则引擎作为模块挂到 hook 上,负责匹配规则、给出判决
这种分离让内核可以在不动框架的前提下替换规则引擎。后来 nftables 取代 iptables,正是借助了这一点。
iptables 与 x_tables
iptables 是和 netfilter 一起诞生的第一套规则系统。用户态的 iptables 命令负责写规则,内核侧真正存放和执行规则的是 x_tables 这套基础设施。
x_tables 是公共底座
x_tables 不是某一张表,而是 ip_tables、ip6_tables、arp_tables 共用的内核基础设施(内核源码 net/netfilter/x_tables.c)。
它负责三件事:
- 注册表:让 filter、nat 等表挂到对应的 netfilter hook 上
- 注册扩展:管理各种匹配条件(match,如
-m conntrack)和动作(target,如-j DROP) - 保存规则集:把用户态传进来的规则以固定的二进制结构保存在内核里
早期 ip*tables 和 ip6_tables 各有一份几乎相同的扩展代码,2.6.16 才把公共部分抽成 x_tables(xt* 前缀的模块即来源于此)。即便如此,IPv4、IPv6、ARP、网桥仍然各有一个用户态工具:iptables、ip6tables、arptables、ebtables。
表、链与钩子
我们平时说的“表”是注册在 x_tables 上、按用途分组的一组链。常见的表恰好也有五张,但它们和五个 hook 是两个维度。
每张表按用途划分,自带几条内置链,每条内置链挂在同名的 hook 上:
| hook | raw | mangle | nat | filter | security |
|---|---|---|---|---|---|
| PRE_ROUTING | 有 | 有 | 有 | ||
| LOCAL_IN | 有 | 有 | 有 | 有 | |
| FORWARD | 有 | 有 | 有 | ||
| LOCAL_OUT | 有 | 有 | 有 | 有 | 有 |
| POST_ROUTING | 有 | 有 |
- 横向看:一个 hook 上会挂多张表的链,按优先级依次执行。例如
PRE_ROUTING上依次是 raw、conntrack、mangle、nat(目的地址转换) - 纵向看:一张表只在它关心的 hook 上有链,例如 filter 表只有
INPUT、FORWARD、OUTPUT
所以 iptables -t filter -A INPUT ... 的含义是:往 filter 表挂在 LOCAL_IN hook 上的那条链里追加一条规则。
x_tables 的局限
x_tables 的设计在 2001 年够用,但随着规则规模和协议种类增长,问题逐渐暴露。
- 整表替换:改一条规则时,iptables 要先把整张表从内核读出来,在用户态修改,再把整张表写回内核。规则上万条时,一次增删都很慢
- 线性匹配:数据包要逐条比对规则,规则越多越慢。想按 IP 列表匹配,只能借助额外的 ipset
- 按协议族重复:同一套策略要用
iptables和ip6tables各写一遍,内核里也各有一份表 - 结构固定:表和内置链是预先定义好的,用不到也会注册并被遍历;每种新的匹配条件都要在内核里写一个新扩展模块
nftables 与 nf_tables
nftables 于 Linux 3.13(2014 年)合入内核。它在用户态提供新的 nft 工具和语法,在内核侧用 nf_tables 取代 x_tables 作为执行引擎。
常见误解
“nftables 改用了 netfilter 框架”这种说法不准确。netfilter 从 iptables 时代就一直在,nftables 保留了 netfilter 的 hook、conntrack 和 NAT,换掉的只是执行引擎:x_tables 换成了 nf_tables。
内核里的小虚拟机
nf_tables 不再为每种匹配条件写专门的内核模块,而是提供一个执行简单指令的小型虚拟机。
nft 在用户态把规则编译成一串通用指令(加载包头字段、比较、查表、给出判决等),内核只负责执行。以“放行 TCP 22 端口”为例,用 nft --debug=netlink 可以看到编译结果大致如下:
[ meta load l4proto => reg 1 ]
[ cmp eq reg 1 0x00000006 ]
[ payload load 2b @ transport header + 2 => reg 1 ]
[ cmp eq reg 1 0x00001600 ]
[ immediate reg 0 accept ]
含义是:取出四层协议号与 6(TCP)比较,再取出目的端口与 22 比较,都相等则判决为 accept。新的匹配需求大多只需在用户态组合现有指令,不必改内核。
按需创建表和链
nftables 没有预置的表和链,所有结构都由用户声明,链要自己说明挂在哪个 hook、优先级是多少。
table inet demo {
set allowed_ports {
type inet_service
elements = { 22, 80, 443 }
}
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif "lo" accept
tcp dport @allowed_ports accept
}
}
这段配置体现了 nftables 的几个特点:
inet族:一张表同时处理 IPv4 和 IPv6,不用写两遍- 链自己挂 hook:
type filter hook input priority 0明确声明挂在LOCAL_IN上,优先级为 0 - 集合(set):
@allowed_ports用哈希等数据结构查找,不再逐条比对;还有 map、verdict map 等可以直接把匹配结果映射为动作
增量与原子更新
nftables 用 netlink 消息和内核通信,规则可以增量修改,一批修改也可以作为一个事务原子提交。
- 增删一条规则只传这一条,不再整表读写
nft -f ruleset.nft提交的整个文件要么全部生效,要么全部不生效,不会出现规则只加载了一半的中间状态
保留与替换
从 iptables 到 nftables,可以这样概括哪些没变、哪些被换掉了。
| 类别 | iptables 时代 | nftables 时代 |
|---|---|---|
| 钩子框架 | netfilter hooks | 保留 |
| 连接跟踪 | conntrack | 保留 |
| 地址转换 | NAT | 保留 |
| 规则组织 | 链挂在 hook 上 | 保留 |
| 执行引擎 | x_tables | nf_tables 虚拟机 |
| 用户接口 | setsockopt 整表拷贝 | netlink 增量事务 |
| 规则表示 | 固定结构体 + 内核扩展模块 | 通用字节码指令 |
| 表和链 | 预置 filter/nat 等 | 用户按需创建 |
| 协议族 | 每个族一套工具和表 | inet 等族统一处理 |
iptables-nft:兼容桥梁
iptables 的规则和脚本积累了二十年,不可能一夜切换。iptables 1.8(2018 年)起提供 iptables-nft:命令和语法还是 iptables,但规则会被翻译成 nf_tables 执行。
同样的语法,不同的引擎
从使用者角度看几乎没有变化,变化全在内核侧。
# 在 iptables-nft 下执行
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
# 规则实际进入了 nf_tables,可以用 nft 看到
nft list ruleset
nft list ruleset 会显示类似 table ip filter { chain INPUT { ... } } 的结构:iptables-nft 在 nf_tables 里模拟出了 iptables 熟悉的表和链。少数无法直接翻译的扩展会通过兼容层继续调用原有的 xt 扩展代码。
如果想把旧规则迁移成原生 nft 语法,可以用翻译工具:
iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT
# 输出:nft 'add rule ip filter INPUT tcp dport 22 counter accept'
Debian 10、RHEL 8 及之后的主流发行版里,默认的 iptables 命令其实就是 iptables-nft。
两套规则混用的坑
legacy 和 nft 两套引擎可以同时加载,它们会各自挂在 netfilter hook 上,数据包要依次经过两边的规则。
- 任意一边判决
DROP,包就被丢弃 - 一边
ACCEPT并不会跳过另一边的检查 iptables-legacy -L看不到 nf_tables 里的规则,反之亦然
排查“明明放行了却不通”时,要确认是不是有程序(比如老版本的 Docker 或某些脚本)还在用 legacy 引擎写规则。
三代程序与两代引擎
把前面的内容按三层结构摆在一起,就得到了“基于 netfilter 的三代用户程序和两代执行引擎”。
%%{init: {'theme':'dark'}}%%
flowchart TB
subgraph frontends [更上层的前端]
firewalld["firewalld"]
ufw["ufw"]
end
subgraph userSpace [用户程序]
iptLegacy["iptables-legacy"]
iptNft["iptables-nft"]
nftCli["nft"]
end
subgraph engines [执行引擎]
xtables["x_tables"]
nftEngine["nf_tables 虚拟机"]
end
subgraph framework [netfilter 钩子框架]
hooks["PRE_ROUTING / LOCAL_IN / FORWARD / LOCAL_OUT / POST_ROUTING"]
end
firewalld --> nftCli
ufw -.-> iptLegacy
ufw -.-> iptNft
iptLegacy --> xtables
iptNft --> nftEngine
nftCli --> nftEngine
xtables --> hooks
nftEngine --> hooks
| 用户程序 | 语法 | 执行引擎 | 定位 |
|---|---|---|---|
| iptables-legacy | iptables | x_tables | 第一代,逐步淘汰 |
| iptables-nft | iptables | nf_tables | 过渡期兼容层 |
| nft | nft | nf_tables | 当前的原生工具 |
- 三代用户程序:iptables-legacy、iptables-nft、nft。中间那一代是“旧语法 + 新引擎”的桥梁
- 两代执行引擎:x_tables、nf_tables
- 不变的框架:netfilter 的 hook、conntrack、NAT
firewalld、ufw 这类工具位于更上层,它们不直接执行规则,而是把“区域”“服务”等概念翻译成下层的规则。firewalld 0.6 起可用 nftables 后端,新版本默认使用它;ufw 则通过 iptables 命令工作,具体走哪个引擎取决于系统里 iptables 指向谁。
名词解释
“表”和“链”这两个名词从 iptables 用到 nftables,名字没变,含义却跟着框架和引擎变了几轮。只看某一代的文档很难把它们说清,读完前面的历史和三层结构,再回头给它们下完整的定义。
三个名词的源头各不相同:
- 列表:ipfwadm 的四张固定规则列表,是链的前身
- 链:ipchains 把列表改称内置链,加上自定义链和跳转
- 表:iptables 按用途把链分组;到了 nftables,表的用途、hook、优先级又被拆回给链
a: 表
表是 iptables 才引入的概念,在 nftables 里保留了名字,职责却几乎被拆空,只剩容器。
源自 iptables
ipfwadm 和 ipchains 都没有表,表是 netfilter 有了五个 hook 之后才出现的。
- ipfwadm 的列表是链的前身:ipchains 把其中的 input、forward、output 改称内置链,并没有在链之上再加一层
- ipchains 不需要分组:它只为过滤设计,所有规则都落在三个检查点上,NAT 只是 forward 链里的一个
MASQ目标 - 五个 hook 带来分组需求:过滤、NAT、改包要在不同时机介入,语义也不同。DNAT 必须在路由之前,过滤不该改包。iptables 于是按用途把链分组成表,工具名也从 ipchains 变成了 iptables
一句话概括:表是 NAT 进入内核、检查点变成五个之后,为了按用途隔离规则而引入的分组。
iptables 的表
在 iptables 里,表身兼数职,前文“表、链与钩子”里的矩阵就是它的结构。
- 用途:表名就是语义。filter 管过滤,nat 管地址转换,mangle 管改包,raw 用来跳过连接跟踪。
-j DNAT只能写在 nat 表里 - 注册单位:挂到 hook 上的是整张表,优先级写死。例如 raw 为 -300、mangle 为 -150、nat 的 DNAT 为 -100、filter 为 0
- 结构预定义:表名和内置链都是固定的,用户只能往里加规则和自定义链
- 按协议族分开:ip_tables、ip6_tables 各有一套同名的表
- 替换单位:前文的“整表替换”,就是以表为单位整块读出、整块写回
nftables 的表
nftables 的表只是一个绑定协议族的命名空间,本身不带任何语义。
- 名字任意:叫
filter还是demo都可以,名字不影响行为 - 绑定协议族:
ip、ip6、inet、arp、bridge、netdev之一 - 装各种对象:链、
set、map、计数器等,set只能被同一张表里的规则引用 - 可整体管理:一次删除或停用整张表,不同程序可以各管各的表、互不干扰
原来表身上的职责被拆到了别处:
| 原来表的职责 | nftables 里的归属 |
|---|---|
| 用途(filter / nat) | 链的 type filter / type nat / type route |
| 挂哪个 hook、优先级 | 基础链自己声明 hook 和 priority |
| 协议族 | 表的 family,inet 同时处理 IPv4 和 IPv6 |
| 更新单位 | 单条规则,或一个事务批次 |
iptables-nft 的翻译结果最能说明这层关系。执行 iptables -A INPUT ... 后,nft list ruleset 里会出现:
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
}
}
旧时代的“filter 表”,在新时代等于“一个叫 filter 的普通容器,加上几条 type filter、优先级为 0 的基础链”。表的语义转移到了链上,这正是下一节要讲的内容。
b: 链
链从 ipchains 一路沿用到 nftables,到了 nftables 又接过了表的用途、hook 和优先级。它的完整定义是:
链是一个有序的规则列表。基础链挂在 netfilter hook 上作为入口,普通链只能被跳转进入。hook 调用执行引擎注册的回调函数,引擎按顺序执行链里的规则,再把判决交还给 netfilter。
不是规则集合
链常被随口说成“一组规则”,但它比集合多了顺序和控制流,更像一个函数体。
- 有序:规则从上往下逐条匹配,遇到 accept、drop 这类终结判决就停止,同样的规则换个顺序结果可能完全不同
- 有控制流:
jump跳进另一条链,执行完回到原处继续;goto跳过去不再回来;return提前返回调用方 - 有兜底:基础链带默认策略(policy),所有规则都没命中时使用
nftables 里真正叫“集合”的是前文的 set 和 map,它们是可以被规则引用的查找结构,与链是两回事。
基础链与普通链
只有一部分链挂在 hook 上,其余的链只是被调用的“子函数”。
- 基础链(base chain):挂在 hook 上,是数据包进入规则的入口。iptables 里就是各表的内置链,如 filter 表的
INPUT;nftables 里是声明了type ... hook ... priority ...的链 - 普通链(regular chain):不挂任何 hook,没有默认策略,只能被别的链
jump或goto进入,执行到末尾自动返回。ipchains 例子里的web就是这种链
所以“链挂在 hook 上”只对基础链成立。一个数据包能走到哪些普通链,取决于基础链里写了哪些跳转。
从 hook 到链
hook 并不认识“链”,它只认识注册在自己身上的回调函数。链是由执行引擎通过回调间接执行的。
netfilter 的回调函数签名大致如下:
unsigned int hook(void *priv, struct sk_buff *skb, const struct nf_hook_state *state);
skb:数据包本身state:上下文,包括当前是哪个 hook、协议族、进出网卡、网络命名空间等priv:引擎注册回调时塞进去的私有指针,回调靠它找到要执行的规则
一次完整的调用过程如下:
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant pkt as 数据包
participant nf as netfilter hook
participant cb as 引擎回调
participant chain as 链与规则
pkt->>nf: 经过 LOCAL_IN 等检查点
nf->>cb: 按优先级调用回调(priv, skb, state)
cb->>chain: 通过 priv 找到链,逐条执行规则
chain-->>cb: 命中规则的判决或默认策略
cb-->>nf: 返回 NF_ACCEPT / NF_DROP 等
nf->>nf: ACCEPT 则调用下一个回调,DROP 则丢包
两代引擎的差异
两代引擎都遵循上面的流程,区别在于注册到 hook 上的单位,以及链在内核里是否真实存在。
| 对比项 | x_tables | nf_tables |
|---|---|---|
| 注册到 hook 的单位 | 表,如 filter 表注册到三个 hook | 每条基础链各自注册 |
priv 指向 |
整张表 | 某条基础链 |
| 回调的工作 | 按 state 里的 hook 编号定位表内对应的内置链 |
直接执行这条链 |
| 链在内核里的形态 | 整张表是一块连续内存,链只是其中的起始偏移 | 独立的链对象,下挂规则列表 |
| 规则如何执行 | 比对固定结构体字段,再调用 match/target 扩展 | 虚拟机逐条执行编译好的指令 |
x_tables 一列解释了前文的几个局限:链只是偏移,改一条规则就得整表替换;新匹配条件要写内核扩展。nf_tables 把链变成内核里的独立对象,增量更新和按需建链才成为可能。
nf_tables 常被称为“DSL 执行引擎”,但内核执行的并不是 nft 语法本身:nft 在用户态把 DSL 编译成指令,通过 netlink 交给内核,内核里的虚拟机只认这些指令。
判决的传递
规则给出的判决要先变成链的结果,再变成回调的返回值,最后由 netfilter 决定是否继续。
- 链内:命中终结判决就停止;跳转出去的普通链执行完回到原处;都没命中则使用基础链的默认策略
- 链之间:同一个 hook 上的多条基础链(或多张表)各自是一个回调,按优先级依次执行
- accept 只对本链生效:某条基础链 accept 后,同一 hook 上优先级更靠后的回调照样执行
- drop 是最终判决:任意一个回调返回 drop,包立即被丢弃,后面的回调不再执行
前文“两套规则混用的坑”正是这条规则的结果:legacy 和 nft 两套引擎在同一个 hook 上各挂各的回调,一边 accept 拦不住另一边 drop。
查看当前用的哪套
在自己的机器上,可以用几条命令确认用户程序和执行引擎分别是哪一代。
# 括号里显示 nf_tables 或 legacy
iptables -V
# iptables v1.8.10 (nf_tables)
# 查看 nf_tables 里的全部规则(包括 iptables-nft 写入的)
sudo nft list ruleset
# 查看哪些引擎模块被加载了
lsmod | grep -E 'nf_tables|ip_tables'
# firewalld 使用的后端
grep FirewallBackend /etc/firewalld/firewalld.conf
# FirewallBackend=nftables
如果 lsmod 同时看到 nf_tables 和 ip_tables,说明两套引擎都在工作,需要留意上一节提到的混用问题。
回看 firewalld
有了这套三层视角,再看 firewalld 概述 里的架构图就清楚多了。
firewalld 是站在最上层的前端:它把区域、服务、策略翻译成 nft 规则,通过 nf_tables 引擎执行,最终挂在 netfilter 的 hook 上生效。所谓“firewalld 背后是 netfilter”,准确地说是:firewalld 生成规则,nf_tables 执行规则,netfilter 决定规则在哪里被执行。
拓展:eBPF
前文讲的是 Linux 防火墙的主线。云原生时代,Linux 内核里还长出了另一条分支:eBPF。它不是 netfilter 的下一代,而是与之并行发展、后来又相互融合的可编程数据面。
- 数据面:每个数据包实际经过的处理路径,负责逐包执行转发、过滤、NAT 等动作。与之相对的是控制面,负责生成策略和配置。套到前文,netfilter 的 hook 加上执行引擎是数据面,
nft、firewalld 这些写规则的工具属于控制面 - 可编程:处理逻辑由用户编写的程序决定,而不是内核预先定好的“匹配 → 判决”模型。程序可以改写包头、把包重定向到其他网卡、维护自己的状态表
所以 eBPF 并不等于“可编程防火墙”。防火墙只是它的用途之一,负载均衡、隧道封装、流量观测都可以用它实现。
两条线的时间线
把防火墙主线和 eBPF 分支放在同一条时间轴上,可以看到两者长期并行,直到近几年才开始交汇。
%%{init: {'theme':'dark'}}%%
gantt
title Linux 防火墙主线与 eBPF 分支
dateFormat YYYY
axisFormat %Y
section 防火墙主线
ipfwadm(Linux 2.0) :1996, 1999
ipchains(Linux 2.2) :1999, 2001
iptables / x_tables :2001, 2026
nftables / nf_tables :2014, 2026
iptables-nft 兼容层 :milestone, 2018, 0d
section eBPF 分支
BPF 抓包过滤器 :1992, 2014
eBPF 虚拟机 :2014, 2026
XDP :2016, 2026
bpfilter 提案 :milestone, 2018, 0d
eBPF 可挂 netfilter 钩子 :milestone, 2023, 0d
- 1990 年代:防火墙主线从 ipfwadm 走到 ipchains;BPF 只是 tcpdump 背后的抓包过滤器,与防火墙无关
- 2001–2014 年:netfilter + iptables 一统 Linux 防火墙,BPF 仍停留在抓包领域
- 2014 年:nftables 合入内核,同一年 eBPF 也开始进入内核,两条线首次在时间上重叠
- 2018 年:iptables-nft 推动主线迁移到 nf_tables;同年 bpfilter 提出用 eBPF 执行 iptables 规则,两条线开始正面竞争
- 2023 年:Linux 6.4 允许 eBPF 程序挂到 netfilter 钩子上,两条线走向融合
从抓包工具到内核虚拟机
eBPF 并不是外来的替代品,而是 Linux 内核自己长出来的能力。
- BPF 最早是 1992 年为 tcpdump 这类抓包工具设计的包过滤器,只能做简单的“抓不抓这个包”判断
- 2014 年起,Linux 内核把它扩展为通用的 eBPF 虚拟机:用受限的 C 编写程序,编译成字节码,经过内核验证器(verifier)检查安全性后,挂到网络、跟踪、安全等各种挂载点上运行
在网络方向,eBPF 主要有两个挂载点:
- XDP:在网卡驱动收包时就运行,比 netfilter 的
PRE_ROUTING还早,连内核的数据包结构都还没分配 - tc:挂在流量控制层,收发两个方向都能处理,能力比 XDP 更全
云原生的压力
推动 eBPF 进入网络领域的,是 netfilter 设计之初没有预料到的规模和使用方式。
- Kubernetes:kube-proxy 早期用 iptables 实现 Service 转发,大集群里规则动辄上万条,线性匹配和整表更新成为瓶颈,也就是前文 x_tables 的那些局限
- 超大规模网络:Facebook、Cloudflare 等用 XDP 在网卡驱动层直接处理数据包,实现负载均衡和抗 DDoS
- 使用方式变了:开发者面对的是云厂商安全组、Kubernetes NetworkPolicy 这类声明式抽象,不再关心底层是 iptables 还是 nft;防火墙下沉为平台能力,平台层可以自由选择更高效的数据面
需要说明的是,eBPF 本身的开发门槛并不低,要写受限的 C 并通过验证器检查。开发体验的改善来自上层抽象,eBPF 提供的是性能和可编程性。
竞争与融合
eBPF 和 netfilter 的关系经历了从竞争到融合的过程,两边都属于 Linux 内核社区。
- 挑战:2018 年 bpfilter 提出在内核里把 iptables 规则翻译成 eBPF 执行,直接冲击 netfilter 的地位;Cilium 等容器网络方案则用 eBPF 完全替代了 kube-proxy 的 iptables 规则
- 回应:netfilter 一侧加速推广 nftables,kube-proxy 也新增了 nftables 模式
- 融合:Linux 6.4 起新增
BPF_PROG_TYPE_NETFILTER,eBPF 程序可以直接挂到 netfilter 钩子上,与 conntrack、NAT 协同工作
bpfilter 早期的内核实现后来被移除,现在是独立的用户态项目。
并存而非替代
eBPF 没有接替 netfilter,而是在它旁边开辟了另一条数据路径。
- 主机防火墙:主流发行版和 firewalld 仍默认使用 nftables
- 容器网络、负载均衡、高性能网关:eBPF 越来越常见
- 两者可以组合:XDP 在最前面挡掉攻击流量,剩下的再交给 netfilter 做有状态过滤和 NAT