Linux 虚拟网络(二)-docker 网络的原型
这是"Linux 虚拟网络"系列第二篇。第一篇造完了交换机(bridge)、网线(veth)和"另一台主机"(netns),看着一次 ping 把学习、转发、泛洪演了一遍,并讲清了 IP 为什么必须落在 br0 上。本篇把对应表收尾:两种特殊的接口(VLAN 子接口与 tap),然后用这张表逐行读懂 docker 的网络,再讲 bond 与三个常见疑问。
先把地图重新挂出来:
| | |
|---|
| | |
| | ip link add br0 type bridge |
| | ip link add veth0 type veth peer name veth1 |
| | ip link add bond0 type bond |
| | ip link add link ens18 name ens18.100 type vlan id 100 |
| | |
| network namespace(网络命名空间) | ip netns add ns1 |
这张表里,物理网卡天生就有,bridge、veth、netns 第一篇已经亲手造过、拆过。剩下三样:VLAN 子接口、tap、bond。本篇先从前两个接口讲起,bond 放在第三章。
一、两种特殊的接口:VLAN 子接口与 tap
它们的共同点是:都不是独立形态的设备,而是依附于既有链路的逻辑接口。一个建立在 Trunk 链路上,一个建立在用户态与内核态的边界上。
1.1 VLAN 子接口:从 Trunk 里终结一路 VLAN
先立场景:一台宿主机的上联口 ens18,接在物理交换机的 Trunk 口上,这条链路上跑着好几个 VLAN 的流量,比如 VLAN 100 是管理网,VLAN 200 是业务网。宿主机自己要在管理网里有个 IP(被 SSH、被监控),它就得从这条 Trunk 里分离出 VLAN 100 的流量,终结到自己的协议栈:

ip link add link ens18 name ens18.100 type vlan id 100
这条命令的作用:在物理网卡 ens18 上创建一个 VLAN 子接口 ens18.100,VLAN ID 是 100。
ens18.100 只是一个接口名字,里面的 .100 是一种常见命名习惯,表示它对应 VLAN ID 100。
验证:
ip -br link show# 典型回复lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP> ens18 UP bc:24:11:a1:21:90 <BROADCAST,MULTICAST,UP,LOWER_UP> virbr0 DOWN 52:54:00:55:3d:e4 <NO-CARRIER,BROADCAST,MULTICAST,UP> ens18.100@ens18 DOWN bc:24:11:a1:21:90 <BROADCAST,MULTICAST>
语义就一句话:发出去的帧打上 VLAN 100 的标签,收进来只认打着 VLAN 100 标签的帧,进出时打标签、剥标签。
接口名 ens18.100@ens18 里那个 @,标注的是它基于哪个父接口创建。它不是一块独立的网卡,是 Trunk 链路上派生出的一个逻辑接口。第一篇开头那一屏里的 ens18.100@ens18,就是它。
它和第一篇第二章的 vlan_filtering 是两个视角,极易混淆,这里区分清楚:
vlan_filtering 是转发视角:桥作为一台交换机,管每个端口怎么处理标签,解决的是帧怎么转发;
VLAN 子接口是终结视角:主机作为一个网络端点,要从 Trunk 里取哪一路流量进自己的协议栈,解决的是流量在哪落到三层。
1.2 tap:虚拟机接入协议栈的接口
第一篇第二章那张图里,虚拟机是直接连到 br0 的,中间那层插头被省略了没画。

它是什么?答案是 tap 设备。
tap 的特殊之处在于它的两头不对称:一头是内核里的一个普通网口,能 ip link 看到、能 up/down、能插进网桥,和别的 netdev 完全平等;
另一头是一个文件描述符。QEMU 拿着这个 fd:虚拟机的虚拟网卡发出一个以太帧,QEMU 把它原样 write 进这个 fd,内核就当作"有个帧从一块网卡进来了";反方向,内核发往这个口的帧,QEMU read 出来递给虚拟机的虚拟网卡。
一句话总结 tap:它让用户态程序以一块网卡的身份接入内核协议栈,用户态一侧持有文件描述符,内核一侧是一个正式注册的 net_device。

虚拟机之所以能"插"到网桥上、以自己的 MAC 出现在网段里,靠的就是这个接口。
Proxmox 里的 tap100i0、KVM 里的 vnet0、EVE-NG 拓扑接物理网时的那些接口,全是它。
这里要划清一条界线:tap 是虚拟机专用的接入机制,容器不用它。原因下一章就会看到:容器的网络栈是内核里的 network namespace,本就在内核态,主机与容器之间用一对 veth(两端都是内核对象)就能打通,无需跨越用户态边界。所以你在只跑容器、不跑虚拟机的机器上执行 ip link,会看到一堆 veth 却找不到一个 tap,这是对的,不是漏了。
本系列涉及的场景里,tap 只出现在虚拟机一侧:QEMU/KVM、Proxmox、EVE-NG 接物理网。它在别处也有身影,比如 OpenVPN 的 tap 模式、rootless 容器的 pasta,但触发条件始终是同一个:有一个用户态程序,需要在内核里拥有一块网卡。
顺带区分它的孪生兄弟:tap 搬运的是二层以太帧,tun 搬运的是三层 IP 包,所以 VPN 软件常用 tun,虚拟机桥接必须用 tap。
二、docker 网络:这张表的一道应用题
第一篇我们把 veth 直插 br0,容器与外部同网段,全程二层,协议栈只旁挂不居中。Docker 默认走另一条路:自建一座 docker0,让协议栈在两桥之间做路由和 NAT。先看整条链:

现在做一件有意思的事。我们在一台 openEuler 24.03 操作系统上,用 dnf install -y moby-engine moby-client 装好 docker,执行 systemctl enable --now docker 之后,再用以下命令验证:
ip -br link

输出里会有 docker0。
此时我们再拉起一个容器:

还有一串 veth0ce42e6@if6 之类的接口。第一篇开头那一屏的最后两行,谜底就在这。再确认一下:
ip -br link show type bridge# 返回virbr0 DOWN 52:54:00:55:3d:e4 <NO-CARRIER,BROADCAST,MULTICAST,UP> docker0 UP 02:42:92:25:d9:18 <BROADCAST,MULTICAST,UP,LOWER_UP>ip link show master docker0 # 那串 veth,全都挂在 docker0 名下# 返回veth0ce42e6@if6 UP b6:88:c8:52:b1:47 <BROADCAST,MULTICAST,UP,LOWER_UP>
docker0 就是一台网桥,那串 veth 就是网线,每个容器就是一个 network namespace。
容器网络 = netns + veth + bridge,一件新东西都没有。你在第一篇第三章亲手搭的那套 ns1 + veth + br0,就是 docker 网络的等价手工实现。docker 所做的,只是在每次 docker run 时替你把那几条 ip 命令跑了一遍:造 netns、造 veth 对、一头进桥、一头塞进容器改名成 eth0、配上 IP。
容器一停,veth 随之销毁。默认的 bridge 网络下,一个运行中的容器对应一头 veth,所以宿主机上那串 veth 的数量,基本就是活着的容器数。(严格说是"基本":容器接入多个自定义网络时会有多头 veth,host 和 none 模式则一头都没有。)
Kubernetes 也没有跳出这张图:最基础的 bridge CNI 插件,干的就是同一件事,只是桥的名字、网段的分配由 CNI 来管,规模从一台机器变成一个集群。
这就是对应表作为"地图"的价值:所谓容器网络,不是一门新学问,而是既有对象的一次组合。你看懂了 bridge、veth、netns,就已经看懂了 docker 单机网络的全部,以及 k8s 网络的地基。
三、bond:把多块网卡聚合为一条逻辑链路
网桥解决的是第一篇开篇那对矛盾的一个方向:一个口,要喂多个端点。bond 解决的是相反方向:多个口,要拧成一条链路。动机很朴素:服务器的上行链路,既怕断(单口是单点故障),又嫌窄(单口带宽就是上限),于是把两块、四块网卡在逻辑上合成一个口,对上层表现为一块"网卡"。这就是 bond,对应物理世界里交换机之间的链路聚合。
bond 内核提供 7 种模式(mode 0 到 6),但生产环境的心智模型只需抓住其中两个:主备(mode 1,active-backup)与聚合(mode 4,802.3ad)。
- 主备(active-backup):求冗余。 同一时刻只有一块网卡承载流量,主接口故障时由备接口接管(failover)。它的优点是对交换机零要求,两根线插在傻交换机上也能用,所以它是可靠性场景的保底选择;
- 聚合(802.3ad,即 LACP):求带宽,顺带冗余。 所有成员口同时干活,流量分摊着走。代价是交换机侧必须配好对应的聚合组,双方通过 LACP 协商确认成员口同属一个聚合组,两边配置不匹配,链路直接起不来。
3.1 LACP 为什么不提升单条流带宽
这是生产环境里关于 bond 最高频的认知错误,必须单独讲。两个 1G 口做 LACP 聚合,很多人期待单个大文件传输能跑到 2Gbps,跑不到,上限还是 1Gbps。
原因在 bond 的分发方式上:一个帧从哪个成员口出去,是对帧的特征字段做哈希决定的,源/目的 MAC,或者再加上 IP 和端口,取决于 xmit_hash_policy 配置。关键在于:同一条流的这些字段是恒定的,哈希结果就恒定,于是这条流的所有帧永远从同一个成员口出去。 单条流的带宽上限,永远是单个成员口的带宽;聚合提升的是多条流的总吞吐:流多了,哈希把它们摊到不同的口上,总量才上得去。
为什么要这样设计,而不是逐帧轮流发、把两个口都用满?因为保序。
同一条流的帧若在两个口之间乱跳,两条路径的时延差会造成报文乱序,TCP 见到乱序会触发重复 ACK 乃至重传,性能损失远大于那点带宽收益。宁可单流不超一口,也要保证同流同路,这是链路聚合的基本设计取舍。
顺带说一句 M-LAG:它把聚合的对端从一台交换机换成两台协同的交换机,让服务器跨两台交换机上行,任一台整机故障链路都不断。但它沿用的是同一套哈希分发,所以上一段那个结论对它照样成立,单条流依旧走单个成员口,M-LAG 提升的是冗余,不是单流带宽。这里点到为止(具体可以看我的微信公众号,M-LAG 系列文章)。
3.2 控制面与数据面
借着 bond,正式引入一对贯穿整个系列的概念:
- LACP 是控制面:协商聚合组的成员关系、成员口的加入与移除、故障的感知,它解决"这个聚合组由哪些口构成"的问题,跑的是协议报文,慢,但必须严谨;
- 哈希分发是数据面:每一个具体的帧从哪个口出去,它逐帧执行,必须快,所以只做一次哈希运算,不做任何复杂决策。
控制面定规则,数据面跑流量;控制面慢而稳,数据面快而简。
第三篇讲收编、讲一帧的旅程,骨子里都是在分这两件事:谁在定规则,谁在跑流量。这对概念值得现在就记住。
3.3 bond 的动手部分
按本篇的节奏,此刻应该 ip link add bond0 type bond mode 802.3ad,然后往里塞网卡了。
但只要你的网卡已经挂在某个网桥下(Proxmox、EVE-NG 的机器几乎必然如此),这一塞就会撞上一条内核规则,命令直接报错被拒。
这条规则就是:一块网卡,同一时刻只能有一个 master。 网桥收编网卡(enslave,即建立 master/slave 主从关系),bond 也收编网卡,它们在内核眼里是同类:都是"收编者"。
收编的本质是什么、为什么名额只有一个、报错怎么解、以及一个数据帧从物理口进来到虚拟机收到它的完整旅程,这是第三篇的主线。到时候我们会故意制造这个报错,再亲手把它解开。
四、三个常见疑问
问一:为什么我在自己机器上敲 ip link show,只看到网桥和 bond,veth 和 tap 一个都没有?它们是不是可有可无?
答:因为它们的生命周期属于工作负载,不属于系统。
网桥和 bond 是基础设施:写进了持久化配置,开机就有,没有虚拟机、没有容器,它们也在那儿待着,它们是"机房布线"。veth 和 tap 则是随需创建的:
docker 在 docker run 那一刻才创建 veth 对,容器一停,veth 随之消失;
QEMU 在虚拟机开机那一刻才创建 tap,关机即销毁。
所以你看不到它们,不是它们不重要,恰恰是它们在忠实地跟随负载:你那台机器此刻多半没有跑着的容器和虚拟机。起一个容器再看,veth 立刻出现;停掉,立刻消失。
问二:那 veth 和 tap 的必要性到底在哪?docker 和虚拟机是必须依赖它们吗?
答:先看清一件事,bridge/bond 和 veth/tap 不是同一层的东西。
bridge 和 bond 是转发设备,决定帧怎么走;
veth 和 tap 是接入机制,解决帧怎么才能进入这张图。
veth 的必要性:netns 之间的协议栈是完全隔离的,隔离到连"把帧递给对方"的通道都没有。必须有一个两端可以分属两个命名空间的设备,帧才能从一个协议栈进入另一个协议栈。veth 正是为此设计的。没有它,netns 的隔离就是完全封闭的,容器无法与外界通信。
tap 的必要性:虚拟机的网卡是 QEMU 这个用户态进程模拟的,以太帧诞生在用户态内存里,而内核协议栈只认"从 net_device 进来的帧"。两者之间需要一个转换机制:一侧是文件描述符,用户态能 read/write;一侧是 net_device,内核按网卡处理。这正是 tap。
至于"必须"二字,严谨的回答是:不是唯一解,但是默认解。
docker 的 host 模式不隔离网络,自然不需要 veth;macvlan/ipvlan 也绕开了 veth+bridge。虚拟机侧,macvtap 可以不经网桥直挂物理口,SR-IOV 干脆让帧不进内核。但只要走的是默认路径(docker 的 bridge 网络、虚拟机桥接到 Linux bridge),veth 和 tap 就是必需的,而生产环境里,默认路径占了绝大多数。
问三:让虚拟机以独立 MAC 出现在物理网段上,网桥是唯一方案吗?
答:不是。macvlan 可以直接在物理口上虚拟出多个独立 MAC 的子设备,SR-IOV 则由网卡硬件切分出虚拟功能口。但 macvlan 有个著名限制:宿主机默认无法通过同一物理口和这些子设备二层互通;SR-IOV 则依赖硬件支持。所以更准确的说法是,网桥不是唯一解,但它是最通用的解:不挑硬件、宿主机与虚拟机互通无碍、语义与物理交换机完全对齐。这也是 Proxmox、EVE-NG、docker 默认都选它的原因。
小结
把本篇归结为一张表、一个模型:
一张表:机房里的每样东西,内核里都有一个对象。交换机是 bridge,网线是 veth,链路聚合是 bond,Trunk 上的 VLAN 是子接口,虚拟机的插头是 tap,另一台服务器是 netns。物理机房怎么组网,内核里就怎么组网。
一个模型:这些对象和物理网卡在内核里是平等公民,全是 net_device。所以 ip link 一套语法通吃,所以 docker/k8s/Proxmox/EVE-NG 的网络没有任何新东西,全是这张表的应用题。
lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>ens18 UP 3c:ec:ef:6a:12:34 <BROADCAST,MULTICAST,UP,LOWER_UP>bond0 UP ca:a0:14:c4:24:58 <BROADCAST,MULTICAST,MASTER,UP,LOWER_UP>vmbr0 UP 3c:ec:ef:6a:12:34 <BROADCAST,MULTICAST,UP,LOWER_UP>ens18.100@ens18 UP 3c:ec:ef:6a:12:34 <BROADCAST,MULTICAST,UP,LOWER_UP>tap100i0 UNKNOWN 8a:1f:33:9c:57:02 <BROADCAST,MULTICAST,UP,LOWER_UP>veth1a2b3c@if14 UP f2:6d:0a:88:41:c5 <BROADCAST,MULTICAST,UP,LOWER_UP>docker0 DOWN 02:42:8e:11:2f:66 <NO-CARRIER,BROADCAST,MULTICAST,UP>
现在九行没有一行陌生:lo 是回环;ens18 和 ens19 是两块物理口,它们带着 SLAVE,说明已经被收编;收编它们的是 bond0,带 MASTER,三者共用一个 MAC,在二层是一个身份;bond0 又整个插进了 vmbr0 这台网桥,桥继承了同一个 MAC;vmbr0.100 是从桥上分出的 VLAN 子接口,宿主机的管理 IP 落在它身上;tap100i0 是某台虚拟机的插头;docker0 是 docker 自建的网桥,veth1a2b3c 是某个容器的网线,它插在 docker0 上,所以这座桥有载波。
-br 模式看不到 master 关系,ens18 属于谁、bond0 属于谁,都要靠 ip -br link show master bond0 这类命令问出来。而 bond0 身上那个 MASTER 标志和 vmbr0 身上的一片空白,恰好提醒了一件事:网桥和 bond 收编网卡的方式,在内核里并不是同一套标志位,却共用同一个名额。这个名额是第三篇的第一主角。
一句话总结:看懂 Linux 虚拟网络,不需要学一堆"虚拟化技术",只需要认下这张对应表,然后像在机房里一样思考。
第三篇预告:网桥和 bond 都是"收编者",收编的规则(master 与 slave、一个 rx_handler 名额引发的 EBUSY 报错、nomaster 为什么是必经手续)是第三篇的第一主角;第二主角是"一帧的旅程":一个数据帧从物理口进来,途经网桥、穿过 tap、抵达虚拟机的完整走线;最后用 ip link → ip addr → ip route → ip neigh 四问搭出一把排障阶梯。而所有人都会遇到的那个问题(ip 命令配的这一切,为什么一重启就没了),留给第四篇的 nmcli 来回答。