这是"Linux 虚拟网络"系列第三篇。第一篇立了一张对应表:机房里的每样东西(交换机、网线、链路聚合、另一台服务器),内核里都有一个平等的 net_device 对象,我们亲手造了网桥、veth 和 netns,还揭穿了 docker 网络的底细。第一篇在"生产环境的巨坑"一节点到过
netdev_rx_handler_register()这个名字,但只讲了现象;它到底改了内核里的什么,本篇从源码讲清。第二篇讲 bond 时又提到过一条内核规则:一块网卡,同一时刻只能有一个 master,当时也把解释留给了本篇。这两处悬念,其实是同一个机制的两面。
先把两处悬念摆出来:
悬念一:物理网卡被并入网桥后,它身上的 IP 明明还在,却"形同虚设",帧一进来就被网桥接管走,到不了三层。第一篇点了名字,没拆开:接管,是怎么接管的?
悬念二:第二篇立了一条规则,一块网卡,同一时刻只能有一个 master,却没有给出理由。这条规则从哪来?为什么是必然,而不是实现上的偷懒?
答案放在最前面:这两个悬念是同一个机制的两面。这个机制,就是收编。
先立概念。网桥和 bond 在内核眼里是同类,都是"聚合性"设备:把若干网卡收归自己名下,统一管理。被收编的网卡叫 slave(从设备),收编者叫 master(主设备)。这层关系用 ip 一眼可见:
ip -d link show veth0# ...... master br0 ......master br0 这个字段,就是 veth0 的归属:一块网卡的收包路径必须有唯一明确的上级设备。
概念立好,看真身。收编在内核里到底改了什么?
一个帧从网卡进入内核后,在被递交给协议栈(三层)之前,要过一道关口:内核会看这块网卡的 rx_handler 指针上挂没挂人。默认情况下没人,帧径直送往三层,查路由、进 socket,走普通主机的路。而 enslave 发生的那一刻,master 干的核心动作,就是把自己的处理函数注册到这个指针上,成为这块网卡收包的第一接收人。内核源码(v6.9,net/core/dev.c)里,这个注册函数朴素得出奇:
intnetdev_rx_handler_register(struct net_device *dev,rx_handler_func_t *rx_handler,void *rx_handler_data){if (netdev_is_rx_handler_busy(dev))return -EBUSY;if (dev->priv_flags & IFF_NO_RX_HANDLER)return -EINVAL;/* Note: rx_handler_data must be set before rx_handler */ rcu_assign_pointer(dev->rx_handler_data, rx_handler_data); rcu_assign_pointer(dev->rx_handler, rx_handler);return0;}代码出自 Linux v6.9,
net/core/dev.c,第 5254 至 5269 行https://elixir.bootlin.com/linux/v6.9/source/net/core/dev.c#L5254
从此,帧从这块网卡进来,第一站不再是宿主机协议栈,而是被 rx_handler 截给 master:网桥的 handler 拿去查 FDB 做二层转发,bond 的 handler 拿去归入聚合链路。收编的本质,就是劫持收包路径。
现在两个悬念可以一起解了,因为它们全是这段代码的推论:
推论一(悬念一):IP 为什么形同虚设。 帧被 rx_handler 截走,不再送往三层,而 IP 是三层的东西。网卡上的 IP 一个字节没动,但再也收不到任何包。"IP 还在"和"IP 有用"从此是两回事。
推论二(悬念二):为什么只能有一个 master。 看代码:dev->rx_handler 是一个指针,一个指针只能指向一个函数。已经有人注册,再来注册,直接吃 -EBUSY。往深处问一层,这不是实现上的偷懒,而是语义上的必然:如果一块网卡同时属于网桥和 bond,一个帧进来,该交给网桥转发还是交给 bond 聚合?二义性无法消解,内核干脆从根上禁止。
推论三:换 master 必先解除原从属。 名额只有一个,想从网桥转投 bond,必须先解除原从属关系,ip link set ens18 nomaster,变回"无主"状态,才能被重新收编。这一步不是多余操作,是单一 master 规则逼出来的必经手续。至于 ip link set ens18 master bond0 看上去能一步到位,是因为 netlink 路径在内核里替你先调了旧 master 的解除接口(net/core/rtnetlink.c 的 do_set_master()),名额始终只有一个,只是手续办在了你看不见的地方;sysfs、brctl 这些老路径不代办,会直接把 -EBUSY 摆在你面前。
用 ip addr 验证推论一
推论一值得用一个实验坐实,因为"配桥后 IP 去哪了"是流传最广的误解,很多资料说 IP"被挪到"了网桥上,内核根本不干这事。找一对 veth 当假网卡(不碰真网卡,零风险):
ip link add veth2 type veth peer name veth3ip link set veth2 up && ip link set veth3 upip addr add 10.99.0.1/24 dev veth2ip link add br0 type bridge验证收编前的ip地址
ip -br addr show veth2# 返回veth2@veth3 UP 10.99.0.1/24 fe80::e0cd:1dff:fef6:d2b6/64收编:将网卡接口 veth2 加入到网桥 br0 里,成为 br0 的一个端口
ip link set veth2 master br0然后查看ip
ip -br addr show veth2 veth2@veth3 UP 10.99.0.1/24 fe80::e0cd:1dff:fef6:d2b6/64前后两次输出一模一样。IP 没有被挪走,没有被删除,它只是随着收包路径被劫持而失去了全部意义。第一篇讲的远程配桥失联,机制层面到这里才算讲完:不是 IP 丢了,是持有 IP 的那块网卡,已经不再把帧交给三层了,所以铁律是把 IP 主动配到 br0(交换机的 SVI)上,那里才是三层的新家。
机制讲完,该让这张图动起来了。这一节要证明的事情只有一件:一个 ICMP 包从 ping 到达对端,途中每一步都在内核里留下了可以查到的痕迹。 下面的命令和回显全部可以照着复现。
为了让每一步都能对照,先约定四个步骤的含义。第一步是三层选路,第二步是二层要 MAC,这两步发生在帧出门之前;第三步和第四步才是帧真正穿过网桥的过程。
第一篇搭的拓扑(br0 + veth 网线 + ns1"主机")如果还在就直接用,清理过的话下面几条命令搭回来。
ip link add br0 type bridgeip link set br0 upip netns add ns1ip link add veth0 type veth peer name veth1ip link set veth0 master br0 ip link set veth0 upip link set veth1 netns ns1 ip netns exec ns1 ip link set veth1 upip netns exec ns1 ip addr add 192.168.100.2/24 dev veth1ip addr add 192.168.100.1/24 dev br0 示意图如下:
+-------------------------------+| host netns || br0 192.168.100.1/24 || | || veth0 |+-----|-------------------------+ | veth pair+-----|-------------------------+| veth1 192.168.100.2/24 || ns1 netns |+-------------------------------+搭好之后先看一眼 br0 的身份:
ip -br link show br0# 返回br0 UP ee:dc:d5:ae:e2:27 <BROADCAST,MULTICAST,UP,LOWER_UP>ip -br link show veth0# 返回veth0@if6 UP ee:dc:d5:ae:e2:27 <BROADCAST,MULTICAST,UP,LOWER_UP> br0 的 MAC 和 veth0 一模一样,网桥继承了成员口的 MAC。记住这个地址,后面抓包里所有以它为源的帧,都是宿主机发出来的。
实验要从零开始,先把内核的记忆清干净:
ip neigh flush dev br0 # 清空邻居表ip link set veth0 downip link set veth0 up # 端口 down 再 up,FDB 里的动态表项随之清除第二步为什么用 down/up 而不是更直观的 bridge fdb flush dev veth0?因为后者在不少内核上并不受支持:

复位之后,确认起点确实是空的:
ip neigh show dev br0# (无输出)FDB 这边要多说一句。直接看是有内容的:
bridge fdb show br br0# 返回33:33:00:00:00:01 dev br0 self permanent01:00:5e:00:00:6a dev br0 self permanent33:33:00:00:00:6a dev br0 self permanent01:00:5e:00:00:01 dev br0 self permanent33:33:ff:55:f9:f4 dev br0 self permanentee:dc:d5:ae:e2:27 dev veth0 vlan 1 master br0 permanentee:dc:d5:ae:e2:27 dev veth0 master br0 permanent01:00:5e:00:00:01 dev veth0 self permanent33:33:00:00:00:01 dev veth0 self permanent33:33:ff:ae:e2:27 dev veth0 self permanent末尾都带着 permanent,它们是组播地址和端口自身的 MAC,由内核在建桥、加端口时静态写入,不是学来的。真正需要观察的是学习得到的动态表项,所以把 permanent 过滤掉:
bridge fdb show br br0 | grep -v permanent# 返回空发包前内核先查路由表。这个决策不用等 ping,可以直接问出来:
ip route get 192.168.100.2# 返回192.168.100.2 dev br0 src 192.168.100.1 uid 0 cache 内核的回答:走 br0,源地址用 192.168.100.1。注意出发点是 br0 而不是任何物理口,三层身份在 SVI 上,旅程从 SVI 启程。这就是 ip route 对象在排障里的用法:
不用背路由表,直接让内核告诉你"这个目的地,我会怎么走"。
三层决定了从哪个口走,二层还缺一个目的 MAC。内核去查邻居表(ARP 表的现代名字),而 3.2 已经确认它是空的,所以内核只能现问:发一个 ARP 请求。
这一步可以直接抓下来。开两个终端,终端 A 先挂上抓包,arp or icmp 一起抓,是为了把两种帧放进同一条时间线:
tcpdump -i br0 -e -n 'arp or icmp' -c 4终端 B 敲 ping:
ping -c 1 192.168.100.2# 64 bytes from 192.168.100.2: icmp_seq=1 ttl=64 time=0.075 ms回到终端 A,四行输出把整件事说完了(开头那两行 dropped privs to tcpdump 和 verbose output suppressed 是 tcpdump 的例行提示,不是报错):

-e 让 tcpdump 把以太网头也打出来,于是四件事同时被坐实。
其一,第一行的目的地址是 Broadcast,ARP 请求确实是一个广播帧。部分 tcpdump 版本会把它原样打印成 ff:ff:ff:ff:ff:ff,两者是同一个地址,都指全 1 的以太网广播地址。第二行的应答帧目的地址已经是一个具体的单播 MAC。广播出去,单播回来。
其二,第一行的源 MAC ee:dc:d5:ae:e2:27 就是 br0 的 MAC,也正是 veth0 的 MAC,3.2章节中那条 permanent 表项里出现过它。网桥继承了成员口的 MAC,所以宿主机以 br0 的身份发出的帧,源地址长这样。
其三,看顺序和时间戳。ARP 请求 .808401,应答 .808461,两者相隔 60 微秒;ICMP 请求 .808462,只比 ARP 应答晚 1 微秒。ICMP 包直到 ARP 应答回来之后才发出,先解析、后发包的因果关系写在时间戳里。那 1 微秒的间隔还透露了一个细节:这个 ICMP 包在等待期间并没有被丢弃,而是挂在邻居表项的待发队列里,地址一解析出来就立刻送走。
其四,第三行 ICMP 请求的目的 MAC ee:2a:50:9f:3c:5f,正是第二行 ARP 应答告知的那个地址。第二步查到的东西,第三步用上了。
顺带提醒一句:不要指望从 ping 的 RTT 里看出 ARP 的开销。上面这次 ARP 一来一回只花了 60 微秒,在 veth 这种内核内部链路上,它淹没在 RTT 的抖动里,首包不一定比次包慢。想看 ARP,就去看抓包,不要看 RTT。
这个广播帧从 br0 灌进网桥。
网桥先看目的 MAC:全 1 的广播地址,无条件泛洪,从所有其他端口发出去,FDB 这一步根本不会执行。
第一篇留的那个伏笔("记住泛洪")在这里第一次派上正经用场:没有泛洪,第一个包永远找不到路。
泛洪还有第二个入口,值得一并说清。目的 MAC 是单播、但查 FDB 没有命中,这种帧叫未知单播,网桥同样泛洪。两条入口,一条不查表,一条查了没查到,通向同一个动作。
3.6 节的 ICMP 帧走的是第三条路:查表命中,单口转发。
ns1 里的 veth1 收到 ARP 请求,应答。应答帧回程进入网桥,网桥看到源 MAC ee:2a:50:9f:3c:5f 从 veth0 口进来,学习,记入 FDB;宿主机收到应答,邻居表记下 IP 与 MAC 的对应。
现在把 3.2 的两条命令原样再敲一遍,和空输出对照:
ip neigh show dev br0# 返回192.168.100.2 lladdr ee:2a:50:9f:3c:5f STALE 一个 ping 的工夫,空表被填写完毕
MAC 拿到了,ICMP 帧带着目的 MAC 出发,网桥查 FDB 命中,从 veth0 单口转发。泛洪只此一次,后续全是精确投递。
想单独看这一步,把抓包点从 br0 挪到 veth0:
tcpdump -i veth0 -e -n icmp -c 2目的 MAC 不再是广播,而是 bridge fdb 里那一行给出的那个 MAC。第三步学到的表项,就是第四步用掉的表项。

帧顺着 veth 这根"网线"穿进 ns1,对端协议栈收包、回应答,反向再走一遍已经铺好的路。
如果对端是真虚拟机,这一步只多一环:FDB 命中的是 tap 口,帧到达 tap 后,QEMU 从那个文件描述符里把它 read 出来,递给虚拟机的虚拟网卡,第一篇讲的 tap 接口完成最后一棒。
反方向也值得走一遍:外部主机 ping 虚拟机,帧从物理口 ens18 进来,第一站是谁?
正是本篇开头那个 rx_handler。物理口已被 vmbr0 收编,帧被截给网桥,查 FDB,命中 tap,穿进虚拟机。
这个方向的第一站,就是收编机制本身。 本篇的两条主线,在同一个帧上会合了。
实验做完,一条命令拆干净:
ip link del br0ip link del veth0 # veth1 随对端一起消失ip netns del ns1这一章在整个系列里的位置,值得先交代清楚。ip 命令建立的一切都活在运行态,重启即消失,所以生产环境不靠它来做持久化配置,那件事交给第四篇的 nmcli。
但查看不存在这个问题:不论一台机器的网络是谁配的、用什么工具配的,内核里的实际状态只有一份,ip 就是读这一份状态最直接的入口。nmcli 告诉你它打算把网络配成什么样,ip 告诉你内核现在是什么样,两者对不上的时候,以 ip 为准。
所以从实际工作的角度看,ip 用来配置的时刻不多(实验环境、临时验证、应急恢复除外),用来查看的时刻天天都有。本章要讲的就是怎么查。
前两篇和本篇前半段建立的模型,正是为这一章服务的。4.2 里会看到四个接口共用同一个 MAC,一眼就能读出两级收编关系,前提是你知道 bond 会把自己的 MAC 压到成员口上、网桥会继承成员口的 MAC。
不知道收编机制的人,看到那四行只会觉得是巧合。模型是为了读懂输出而建的,读法在这里。
旅程走完,回头看会发现:每一步用到的命令,恰好对应 ip 的四个核心对象。
按旅程的顺序排下来,就是一套固定的排查顺序,遇到任何"网络不通",从 link 开始逐层往下问。
link 看骨架,addr 看身份,route 看决策,neigh 看邻居。
这是二层骨架。-br 是 brief 的缩写,把每个接口压成一行,四列依次是接口名、STATE、MAC、flags。
下面这份输出来自一台 EVE-NG 宿主机,接口多、状态杂,正好把这条命令能回答的问题一次问完:
ip -br link#返回lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>eth0 DOWN bc:97:e1:b1:e5:f2 <NO-CARRIER,BROADCAST,MULTICAST,UP>eth1 DOWN bc:97:e1:b1:e5:f3 <BROADCAST,MULTICAST>eth2 DOWN bc:97:e1:b1:e5:f4 <NO-CARRIER,BROADCAST,MULTICAST,UP>eth3 DOWN bc:97:e1:b1:e5:f5 <NO-CARRIER,BROADCAST,MULTICAST,UP>eth4 UP ca:a0:14:c4:24:58 <BROADCAST,MULTICAST,SLAVE,UP,LOWER_UP>eth5 DOWN bc:97:e1:e7:de:31 <NO-CARRIER,BROADCAST,MULTICAST,UP>eth6 UP ca:a0:14:c4:24:58 <BROADCAST,MULTICAST,SLAVE,UP,LOWER_UP>eth7 DOWN bc:97:e1:e2:cc:e1 <NO-CARRIER,BROADCAST,MULTICAST,UP>bond0 UP ca:a0:14:c4:24:58 <BROADCAST,MULTICAST,MASTER,UP,LOWER_UP>pnet0 UP ca:a0:14:c4:24:58 <BROADCAST,MULTICAST,UP,LOWER_UP>pnet1 DOWN 32:2d:37:df:44:0f <NO-CARRIER,BROADCAST,MULTICAST,UP>...... pnet2 至 pnet9 同上,略 ......natmac UNKNOWN 00:01:01:01:01:01 <BROADCAST,NOARP,UP,LOWER_UP>nat0 UP 06:37:bc:57:1d:df <BROADCAST,MULTICAST,UP,LOWER_UP>docker0 DOWN a6:7e:da:1f:2b:f1 <NO-CARRIER,BROADCAST,MULTICAST,UP>wg0 UNKNOWN <POINTOPOINT,NOARP,UP,LOWER_UP>这一屏信息量很大,拆成四个问题来读。
eth0 那一行,第二列写着 DOWN,尖括号里却又有 UP,看着自相矛盾。不是矛盾,是两件事。
尖括号里的 UP 是管理态,表示有人执行过 ip link set eth0 up,管理员意志上要它起来。第二列的 STATE 是 operstate(RFC 2863 定义的操作状态),表示链路实际上通不通。NO-CARRIER 给出了答案:没有载波,对物理口这等价于网线没插。
把 eth0 和 eth1 摆在一起,这两个维度就分开了:
eth0 DOWN ...... <NO-CARRIER,BROADCAST,MULTICAST,UP>eth1 DOWN ...... <BROADCAST,MULTICAST>两者第二列都是 DOWN,flags 却截然不同。eth0 有 UP 也有 NO-CARRIER,说明它被 up 过,只是没插网线;eth1 两者都没有,说明它压根就没被 up 过。**NO-CARRIER 只会出现在已经 up 的接口上**,一个从未 up 的接口,内核不会去关心它有没有载波,也就不会打这个标志。
这个区别直接决定了下一步动作:eth0 去机房看网线,eth1 敲 ip link set eth1 up。
关于 link 一句话总结:flags 里的 UP 是"想不想通",STATE 是"实际通不通",两者都看才算看完。
eth4、eth6、bond0、pnet0,四行的 MAC 都是 ca:a0:14:c4:24:58。这不是巧合,是两级收编叠出来的。
bond0 建立时拿到一个随机 MAC(ca 的次低位为 1,是本地管理地址,内核生成的就长这样),然后把 eth4 和 eth6 收编为成员,并把自己的 MAC 统一压到两个成员口上,覆盖掉它们出厂的 bc:97:...。这是 bond 的默认行为,聚合链路对外必须呈现同一个 MAC。
接着 pnet0 这个网桥把 bond0 收编为成员,网桥继承成员口的 MAC,于是第四行也变成了同一个地址。
顺着 MAC 读这一屏,收编关系一目了然:两块物理网卡进 bond,bond 再进网桥。
eth4 和 eth6 的 flags 里有 SLAVE,bond0 有 MASTER。这是 bond 特有的:内核给 bond 成员置了 IFF_SLAVE 标志。
bond0 自己也是 pnet0 的成员,它的 flags 里却没有 SLAVE,因为网桥不会给成员口置这个标志。所以只看 -br 输出,你能一眼认出 bond 的成员,却认不出网桥的成员。
要看归属,得换命令。问某个接口在谁名下:
ip -d link show bond0 # ...... master pnet0 ......或者反过来,问某个 master 名下有谁:
ip -br link show master pnet0这条 master 过滤是排障时最该条件反射敲出来的一条。第一篇讲过的那个经典故障,bond 建了但不干活,成因就是开机时网桥先到先得,把物理网卡收编了进去,rx_handler 的名额已被占,bond 再想收编只能被拒,报错还常常被启动脚本吞掉。表现出来就是 bond0 一个成员都没有,永远 NO-CARRIER。这时候翻 bond 的配置没有用,一条 master 过滤直接把"谁把我的网卡抢走了"问出来。
三者不是并列关系。收编的规则只有一条,master 把自己的处理函数注册到成员口的 rx_handler 指针上,指针只有一个,所以一块网卡只能有一个 master。而 master 自己也是一个 net_device,它同样能被别人收编,于是有了这台机器上的链条:
eth4 ┐ ├─→ bond0 ─→ pnet0 ─→ 协议栈(IP 在这里)eth6 ┘帧从 eth4 进来被劫持一次,交给 bond0 归入聚合链路;bond0 上送时再被劫持一次,交给 pnet0 查 FDB 转发。两级收编,两次劫持。
顺序不能反过来,理由在语义。bond 把若干条链路合成一条链路,网桥把若干个端口变成一台交换机,所以只能先把两根网线并成一根,再把这根线插到交换机上。
三层身份落在链条顶端那个没有 master 的设备上。中间的 eth4、eth6、bond0 即便配了 IP,帧也到不了三层。
lo、natmac、wg0 三行的 STATE 是 UNKNOWN,都不是故障。
operstate 由驱动上报,驱动不报,内核就填 UNKNOWN。这三个接口没有一个是常规以太网驱动:环回口没有载波概念;natmac 是个虚拟哑口,不做地址解析(NOARP),也没有载波可报;wg0 是 WireGuard 隧道,POINTOPOINT,NOARP 说明它工作在三层,连以太网头都没有,所以 MAC 那一列干脆是空的。
顺带说 pnet1 到 pnet9 和 docker0,全是 NO-CARRIER 加 UP:这些网桥被 up 过,但名下没有任何一个活跃成员口,所以没有载波。这和一个 bond 底下没有可用成员时的表现是同一回事。
link 看完骨架,addr 回答一个问题:IP 配没配,配在了谁身上。
ip -br addr返回lo UNKNOWN 127.0.0.1/8eth0 DOWN...... eth1 至 eth3、eth5、eth7 同为空,略 ......eth4 UPeth6 UPbond0 UPpnet0 UP 172.18.3.20/24pnet1 DOWN...... pnet2 至 pnet9 同为空,略 ......natmac UNKNOWNnat0 UP 172.29.129.254/24docker0 DOWN 172.17.0.1/16wg0 UNKNOWN 172.29.130.254/24eth4、eth6、bond0 三行的 IP 栏全是空的,pnet0 独自扛着 172.18.3.20/24。这正是第二章那条规则的必然结果。
分析原因:eth4 与 eth6 的收包路径被 bond0 劫持,bond0 的收包路径又被 pnet0 劫持,链条上任何一级配了 IP 都收不到包,三层身份只能落在链条顶端那个没有 master 的设备上。
物理网卡eth4 ┐ ├─→ LACP bond0 ─→ 网桥 pnet0 ─→ 协议栈(IP 在这里)物理网卡eth6 ┘反过来说反过来说,如果你看到 IP 配在了这条链条的中间一级(这台机器上是 bond0 或 eth4),那不是能用但不规范,是根本收不到包。判据不是设备类型,而是它头上有没有 master:一个不入桥的 bond,IP 本来就该配在 bond0 上。
第二章用 ip addr 前后对照证明过:enslave 不会挪走 IP,也不会删除 IP,它只是让持有这个 IP 的接口不再把帧交给三层。
再看有 IP 的另外三行,它们各自代表一类三层落脚点。nat0 是 EVE-NG 给实验网络做 NAT 的网桥,docker0 是 docker 的默认网桥,两者都是自己名下有成员口的 master,IP 配在 master 上,天经地义。
wg0 则是 WireGuard 隧道,4.2 里它的 MAC 栏为空,是个纯三层设备,压根不参与二层,也就无所谓收编。
最后看那些空行。eth0 到 eth7、pnet1 到 pnet9、natmac,IP 栏一律为空。
空不等于故障:物理口作为二层成员本就不该有 IP,空闲的 pnet 网桥还没有承载任何实验拓扑,natmac 带着 空不等于故障,但三行的理由各不相同。eth0 到 eth7 里,eth4 和 eth6 是 bond 的成员,收包路径被 bond0 劫持;其余六个口连 up 都没 up 过,谈不上收发。pnet1 到 pnet9 是空闲的网桥,名下没有任何成员口,还没承载实验拓扑。至于 natmac,它的 flags 里那个 NOARP 常被误当成理由,其实不是:wg0 同样带 NOARP,却稳稳配着 172.29.130.254/24。natmac 的 IP 栏为空,是因为它自己也是一个被收编的成员口,4.7 节的 networkctl 会把这一点直接标出来。
判断一个接口的 IP 该不该为空,看的不是它自己,而是它在收编链条上的位置。
总结:沿着收编链条往上找,IP 只能配在最顶端那个没有 master 的设备上;三层设备不在这条链条里,另当别论。
不用翻路由表,直接让内核复述它对某个目的地的决策。
ip route get 8.8.8.8# 返回8.8.8.8 via 172.18.3.254 dev pnet0 src 172.18.3.20 uid 0 cache说明一下:dev pnet0 是出口设备,via 172.18.3.254 是下一跳网关(目的地在同网段、可以直达时这个字段不出现),src 172.18.3.20 是内核为这个包选定的源地址,uid 0 是发起查询者的用户 ID,末尾的 cache 表示这是一条路由缓存结果。
把它和 4.3 的输出对照着读,链条闭合了:ip -br addr 里持有 172.18.3.20/24 的正是 pnet0,这里内核选出的出口设备和源地址,恰好都落在收编链条顶端那个设备上。IP 配在哪,包就从哪出发。
出口设备是不是你预期的那一个,这台机器有 bond、有10个 pnet 网桥,还有 docker0 和 wg0,走错口是很容易发生的事。
route 说了包从哪个口走、下一跳是谁,neigh 负责把那个下一跳的 IP 翻译成 MAC。
ip neigh# 返回172.18.3.254 dev pnet0 lladdr 00:00:5e:00:01:02 REACHABLE整张表只有一行,这本身就是个信息:邻居表只记录本机真正通信过的对象。这台机器上行只走网关,所以表里只有网关。
三个状态要认得。REACHABLE 是健康,最近确认过。STALE 是缓存过期待确认,只要还带着 lladdr,下次发包时内核会顺手验证,通常无害。FAILED 那一行连 lladdr 都没有,明确告诉你 ARP 请求发出去了、没人应答,二层不通。
这一行的 MAC 值得多看一眼。00:00:5e:00:01:02 不是任何一块网卡的出厂地址,00-00-5e-00-01-XX 是 IANA 分配给 VRRP 的虚拟 MAC 前缀,末字节是 VRID,这里是 2。换句话说,172.18.3.254 是两台交换机用 VRRP 撑起来的虚拟网关,主备切换时 IP 和 MAC 都不变,本机的邻居表因此不需要重新学习。
看懂这一个 MAC,就知道了上联的高可用架构。
这台 EVE-NG 的上联,正是 M-LAG 系列里那两台华为 CloudEngine CE6856-48T6Q-HI。一条邻居表项,把上联交换机的高可用架构摆在了面前:不必登录交换机,不必看拓扑图,一个 MAC 就够了。
另外看到 FAILED,回头和第三章那两张地图对照:
ip neigh show dev pnet0 # 三层的地图:这个 IP 是哪个 MACbridge fdb show br pnet0 # 二层的地图:这个 MAC 在哪个端口ip neigh 里查不到,说明对端根本没应答 ARP,问题在对端或链路。ip neigh 有、bridge fdb 里却找不到那个 MAC,说明网桥没学到它,问题在成员口这一段。断在哪,两表一比就清楚。
四个对象走完,骨架层面的问题基本无处藏身。要继续深入功能面,补几个工具。
-d 是贯穿所有对象的 detail 开关。上面能认出网桥成员,靠的就是它。网桥的 STP 与 vlan_filtering、bond 的模式与参数、VLAN 子接口的标签,全靠它吐出来,是最值得记住的一个标志。
bridge 命令(bridge link / bridge fdb / bridge vlan)管二层转发面,是老 brctl 的正式接班人。
/proc/net/bonding/bond0 是 bond 运行态的权威档案。模式、每个成员的 MII 状态,以及 802.3ad 下的 Aggregator ID 和 Actor/Partner 协商详情,排 LACP 问题基本只看这个文件。回到 4.2 那台机器,eth4 和 eth6 到底有没有和交换机协商上,ip -br link 只能告诉你它们 UP,答案在这个文件里。
ip 有一个短板:它不把设备类型摊在明面上。想知道 pnet0 是网桥还是别的什么,得敲 ip -d link show pnet0,或者反过来用 ip -br link show type bridge 逐类去问。接口一多,这就成了苦差事。
networkctl list 一屏给全:

TYPE 列是 ip 给不了的东西:ether、bond、bridge、wireguard、loopback 一目了然。4.1 里靠 MAC 相同推断出来的收编链条,在这里换成了直接标注。
OPERATIONAL 列把 4.2 讲的两个维度压成了一个词。
eth1 是 off,从未被 up;eth2 是 no-carrier,up 了但没插网线;
pnet0 是 routable,有可路由地址,正对应 4.3 里那个 172.18.3.20/24。
docker0 明明有 IP 却是 no-carrier,因为它名下没有一个活跃的成员口。
最值钱的是 enslaved 这个词。4.2 说过,网桥不给成员口置 IFF_SLAVE,所以 ip -br link 里的 bond0 看不出自己是 pnet0 的成员。
networkctl 直接标出来了,bond0 和 natmac 都是 enslaved。这一列把 ip -br link 的盲区补上了,也回答了 4.3 留下的那个问题:natmac 的 IP 栏为什么是空的。不是因为它带 NOARP,是因为它有 master。
两点需要说清楚。
其一,networkctl 来自 systemd 包,不是某个发行版专属,凡是用 systemd 的系统都有。它属于 systemd-networkd 这一套;另一套是 NetworkManager,对应的命令是 nmcli device(简写 nmcli dev)。
Ubuntu Server 默认经 netplan 使用 systemd-networkd,RHEL 系默认使用 NetworkManager,看起来像发行版差异,实际是两个网络管理栈的差异。
其二,SETUP 列一整列 unmanaged,意思是这些接口不归 systemd-networkd 管(这台 EVE-NG 用自己的脚本配置网络),并不是出错。列表本身走 netlink 直接问内核,与谁在管无关。OPERATIONAL 和 TYPE 是内核的事实,SETUP 是 networkd 的视角,看这张表时要分清哪一列在说什么。
总结:看类型和归属,先敲 networkctl list;要看细节和真相,回到 ip -d。
第一、二篇给了你一张静态的对应表,机房里的每样东西,内核里都有一个对象;
本篇给了你动态的规则与走线,对象之间如何收编、一个帧如何穿行其间。地图与走法都齐了。
但还差最后一块:本系列敲过的每一条 ip 命令,造的每一个对象,都活在运行态里,重启一次,全部不复存在。生产环境靠什么记住这份配置?用什么模型描述"开机后这台机器的网络应该长什么样"?
这就是第四篇的主角:nmcli,以及它背后那套分层的配置管理哲学。