Linux 防火墙演进

已修订更新于

首发于

网上关于 Linux 防火墙的文章很多,但大多是解决某个具体问题的零散记录。它们往往基于特定的内核版本或发行版,而不同发行版默认的防火墙工具并不相同。光是会碰到的工具名就有一长串:ipfwadm(Linux 2.0)、ipchains(Linux 2.2)、iptables、nft、firewall-cmd、ufw……没有系统了解过的读者,甚至会把 netfilter 也当成一个防火墙命令。

要弄清这些工具各自适用于什么场景,得先了解 Linux 内核的演进历史。这不是多看几篇问题导向的博客就能补上的:零散的文章读得越多,越难看清全貌,甚至越看越迷糊。

本文按时间顺序走一遍 Linux 防火墙的演进:从把规则写死在协议栈里的 ipfwadm,到引入“链”的 ipchains,再到 netfilter 把框架和规则引擎拆开,最终落到今天的三层结构:

讲完演进之后,再用一章名词解释,把贯穿始终的“表”和“链”重新梳理一遍。eBPF 不属于这条主线,放在文末的拓展章节单独介绍。

适用读者

本文默认你已经用过 Linux 命令行,对防火墙规则有模糊印象即可。

参考链接

以下是本文主要参考的官方资料,细节以它们为准。

演进总览

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 地址的消耗速度远超预期,“一台机器一个公网地址”的模型开始难以为继。

IPv6 是根本解法,但它的普及花了二十多年。在这段时间里,NAT 成了几乎所有网络出口的标配。

Linux 成了网关

开源、免费、能跑在旧硬件上,让 Linux 成了当时最常见的“自己动手”网关系统。

旧结构装不下

ipfwadm 和 ipchains 是为“过滤”设计的,NAT 只能以旁路的形式硬塞进去。

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["网卡发包"]

LOCAL_IN、FORWARD、LOCAL_OUT 延续了 input、forward、output 的思路;新增的 PRE_ROUTING 和 POST_ROUTING 主要是为 NAT 准备的,它们的位置由“改写地址的时机”决定:

有了这两个钩子,地址改写有了专属位置,LOCAL_IN / LOCAL_OUT 也收窄为只管进出本机的包,转发流量单独走 FORWARD。过滤和 NAT 从此各司其职,不再纠缠在同一条链里。

每个挂在 hook 上的函数都会返回一个判决:NF_ACCEPT(继续)、NF_DROP(丢弃)、NF_QUEUE(交给用户态程序)等。同一个 hook 上可以挂多个函数,按优先级数值从小到大依次执行。

conntrack 与 NAT

有了统一的钩子,“连接跟踪”和“地址转换”终于可以作为独立模块实现,而不是散落在协议栈各处。

这两项能力也让 Linux 补上了与商业防火墙之间最大的差距:状态检测和完整的 NAT(伪装、端口转发、一对一映射)都成了内核原生功能,不再依赖补丁。

框架与引擎分离

netfilter 最重要的意义不在某个具体功能,而在于把“在哪里检查”和“怎么检查”拆开了。

这种分离让内核可以在不动框架的前提下替换规则引擎。后来 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)。

它负责三件事:

早期 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 有 有

所以 iptables -t filter -A INPUT ... 的含义是:往 filter 表挂在 LOCAL_IN hook 上的那条链里追加一条规则。

x_tables 的局限

x_tables 的设计在 2001 年够用,但随着规则规模和协议种类增长,问题逐渐暴露。

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 的几个特点:

增量与原子更新

nftables 用 netlink 消息和内核通信,规则可以增量修改,一批修改也可以作为一个事务原子提交。

保留与替换

从 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 上,数据包要依次经过两边的规则。

排查“明明放行了却不通”时,要确认是不是有程序(比如老版本的 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 当前的原生工具

firewalld、ufw 这类工具位于更上层,它们不直接执行规则,而是把“区域”“服务”等概念翻译成下层的规则。firewalld 0.6 起可用 nftables 后端,新版本默认使用它;ufw 则通过 iptables 命令工作,具体走哪个引擎取决于系统里 iptables 指向谁。

名词解释

“表”和“链”这两个名词从 iptables 用到 nftables,名字没变,含义却跟着框架和引擎变了几轮。只看某一代的文档很难把它们说清,读完前面的历史和三层结构,再回头给它们下完整的定义。

三个名词的源头各不相同:

a: 表

表是 iptables 才引入的概念,在 nftables 里保留了名字,职责却几乎被拆空,只剩容器。

源自 iptables

ipfwadm 和 ipchains 都没有表,表是 netfilter 有了五个 hook 之后才出现的。

一句话概括:表是 NAT 进入内核、检查点变成五个之后,为了按用途隔离规则而引入的分组。

iptables 的表

在 iptables 里,表身兼数职,前文“表、链与钩子”里的矩阵就是它的结构。

nftables 的表

nftables 的表只是一个绑定协议族的命名空间,本身不带任何语义。

原来表身上的职责被拆到了别处:

原来表的职责 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。

不是规则集合

链常被随口说成“一组规则”,但它比集合多了顺序和控制流,更像一个函数体。

nftables 里真正叫“集合”的是前文的 set 和 map,它们是可以被规则引用的查找结构,与链是两回事。

基础链与普通链

只有一部分链挂在 hook 上,其余的链只是被调用的“子函数”。

所以“链挂在 hook 上”只对基础链成立。一个数据包能走到哪些普通链,取决于基础链里写了哪些跳转。

从 hook 到链

hook 并不认识“链”,它只认识注册在自己身上的回调函数。链是由执行引擎通过回调间接执行的。

netfilter 的回调函数签名大致如下:

unsigned int hook(void *priv, struct sk_buff *skb, const struct nf_hook_state *state);

一次完整的调用过程如下:

%%{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 决定是否继续。

前文“两套规则混用的坑”正是这条规则的结果: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 的下一代,而是与之并行发展、后来又相互融合的可编程数据面。

所以 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

从抓包工具到内核虚拟机

eBPF 并不是外来的替代品,而是 Linux 内核自己长出来的能力。

在网络方向,eBPF 主要有两个挂载点:

云原生的压力

推动 eBPF 进入网络领域的,是 netfilter 设计之初没有预料到的规模和使用方式。

需要说明的是,eBPF 本身的开发门槛并不低,要写受限的 C 并通过验证器检查。开发体验的改善来自上层抽象,eBPF 提供的是性能和可编程性。

竞争与融合

eBPF 和 netfilter 的关系经历了从竞争到融合的过程,两边都属于 Linux 内核社区。

bpfilter 早期的内核实现后来被移除,现在是独立的用户态项目。

并存而非替代

eBPF 没有接替 netfilter,而是在它旁边开辟了另一条数据路径。

评论

可对整篇留言,或划选正文中的句子后评论该段。

登录后即可发表评论。

  • 还没有公开评论。
↑