做Linux网络方向久了,你会发现无论KVM虚拟机、Docker容器,还是各种自定义组网方案,底层都绕不开三个内核虚拟设备:Linux Bridge、Tun/Tap、Veth Pair。很多人分开理解时觉得每个都简单,可一旦要自己搭一套虚拟网络,就出现连不上、ping不通、流量丢了也不知道去哪查的情况。这篇文章我就按自己的理解,把这三个设备从概念、原理到典型组合逐层拆开讲一遍,重点放在数据包的实际走向和动手验证上,希望能帮你建立起一套完整的网络数据路径直觉。
1. 先搞清楚三个设备分别是什么
1.1 Linux Bridge:内核里的一台二层交换机
Linux Bridge 本质上就是内核软件实现的一台二层交换机。传统交换机做什么,它就做什么:学习MAC地址、根据目的MAC转发帧、处理广播帧、支持生成树协议。它工作在OSI模型的第二层,对IP、TCP、UDP这些上层协议完全无感。
有人会问,Linux 内核里为什么还需要一个“交换机”?因为虚拟机或容器需要接入同一张局域网时,最直接的办法就是有一个二层设备把它们的虚拟网卡全部集中起来。如果不用 Bridge,你就得在宿主机上为每个虚拟机单独做路由,或者用VLAN交换机硬件,这种方案既不灵活也不现实。Bridge 出现后,虚拟机网卡、容器网卡、物理网卡都可以插到这个“虚拟交换机”的端口上,彼此之间就像插在同一个交换机上一样通信。
一个容易被忽略的点是:Bridge 本身也是一个网络设备,它有自己的IP地址、MAC地址,还能像普通网卡一样收发数据包。这个特性很重要,因为它意味着宿主机可以把 Bridge 的IP当作虚拟网络的网关,让虚拟机/容器通过它访问外部,这部分在后面实操会用到。
1.2 Tun/Tap:一对“虚拟网卡”师兄弟
Tun 和 Tap 是内核提供的一对虚拟网络设备,它们长得像网卡,但真正特殊的地方是:任何发到这些设备上的数据包,都会在内核里被转交给一个用户态程序;用户态程序写入的数据,也会被内核模拟成数据包发出来。
简单说,它们就是内核和用户态程序之间的“网络数据通道”。
Tun 和 Tap 的区别在于工作的网络层级:
- Tun 工作在第三层,收发的是IP数据包,设备本身没有MAC地址;
- Tap 工作在第二层,收发的是完整以太网帧,设备有MAC地址。
用一个生活里的类比:Tap 像是一台完整网卡,所有进出的数据都以“数据帧”为最小单位,你拿到手的是一个带完整信封的包裹;Tun 则直接处理已经拆开的信件,也就是IP报文,不关心MAC层怎么封装。
所以 Tap 更常被虚拟机监控器(如QEMU/KVM)用来模拟真实网卡,因为虚拟机需要能收发完整以太网帧;Tun 则常被用来构建路由型隧道或用户态协议栈,因为你只需要处理IP包就可以了。
注意它们的设备节点都是/dev/net/tun,通过 ioctl 的TUNSETIFF来指定创建成 Tun 还是 Tap,而内核为它们创建的虚拟网卡名称则类似tun0、tap0。
1.3 Veth Pair:一根成对出现的虚拟网线
Veth Pair 特别有意思,它永远成对出现,两根虚拟网线头尾相连。往一端写入的数据包,会原封不动地从另一端出来;反方向也一样。
你可以把它想象成一根已经做好水晶头的网线,两端都插上设备就能通信。Veth Pair 最常见的用法,就是把一个网络命名空间(network namespace)和宿主机网络连通起来。比如 Docker 容器创建时会生成一个 veth 对,一端放到容器里变成eth0,另一端留在宿主机上叫vethxxxxxx,这样容器网络就和宿主机网络打通了。
这里有一个关键点:Veth Pair 只是帮你建立了一条二层链路,它本身不具备路由能力,也不具备交换能力。它只负责“透传”,一端发一端收,没有MAC学习、没有转发决策。Bridge、路由这些工作,都得靠它两端连接的“上级设施”——通常是 Bridge 或网络命名空间里的路由栈——来完成。
2. 从数据包视角看清完整链路
2.1 一次完整的容器发包过程推演
理解了单个设备后,我们来看它们组合在一起时,一个包是怎么走的。拿最常见的小型容器组网场景举例:
[容器 eth0] --- veth A / veth B --- [Linux Bridge br0] --- [eth0] --- [外部网络]这里容器里的eth0其实就是 veth 对的一端,宿主机上看到的vethB是另一端。vethB 被插到了 Bridge 的端口上,Bridge 再通过宿主机的物理网卡 eth0 连向外网。
当容器里执行ping 8.8.8.8时:
- 容器进程构造一个ICMP请求,交给容器内协议栈。协议栈查路由,发现默认网关是
10.0.0.1,于是把包交给eth0。 eth0就是 vethA,包从 vethA 发出后,瞬间出现在 vethB 上。这一跳没有经过任何物理线路,完全在内核内存中完成。- vethB 把包交给所在 Bridge 的端口。Bridge 收到数据帧后,先看源MAC,把“该源MAC来自vethB这个端口”记录下来,更新MAC转发表。
- Bridge 再看目的MAC。如果目的MAC是网关(br0)的MAC,说明这个帧是发给Bridge自身的,Bridge会把帧上交给宿主机的网络协议栈;如果目的MAC是其他主机,Bridge会查表,从对应端口转发出去;查不到就向所有端口广播,这就是flood。
- 宿主机协议栈收到包之后,根据
10.0.0.1是自身IP,意识到这个包是给自己的,于是再检查IP层,发现目的IP8.8.8.8不是本机IP,需要路由转发。如果开启了ip_forward,就把包交给路由决策,下一跳交给物理网卡 eth0,最终从真实接口发出去。
整个过程里,Bridge处理的是“在同一局域网内怎么把帧送到正确网卡”,veth 解决的是“怎么把两个不同命名空间的网卡接在一起”,而最终真正的出口还是物理网卡。
2.2 Tap/Tun 与用户态程序的数据往返
Tap/Tun 的路径和 veth 不太一样。它们不是“网线”,而是一个“路口”,数据包会从内核走入用户态程序。以 QEMU/KVM 虚拟机为例:
[虚拟机 eth0] --- virtio-net --- [QEMU进程] --- [tap0] --- [Linux Bridge br0]QEMU 进程打开/dev/net/tun,创建了 tap0,并把 tap0 插到 br0 上。虚拟机内部发一个包时,这个包会先到 QEMU 模拟的网卡,再被 QEMU 进程拿到,写入 tap0。写入 tap0 后,内核把这当成从 tap0 这个“网卡”收到了一个数据帧,于是走一遍正常收包流程:Bridge 检查MAC表、决定转发到哪个端口。
反过来,当外部有包要发给虚拟机时,包到达 Bridge,Bridge 查MAC表发现虚拟机的MAC对应 tap0 端口,于是把帧从 tap0 发出去。内核将这个数据帧放到 tap0 对应的 fd 里,QEMU 的进程循环读取这个 fd,再把帧交给虚拟机内部处理。
整个过程里,QEMU 扮演的角色非常像一个“软件物理网卡”。它不关心桥怎么转发,也不关心自身协议栈,它只负责在 fd 和虚拟机之间搬运数据帧。
Tun 的路径在逻辑上更简单,因为它只处理IP包,没有MAC帧。一个用户态路由程序读到的就是完整的IP报文,可以直接解析包头、改路由、封装进其他协议,然后丢回内核或通过socket发出。这也是很多自定义协议栈、代理类工具、反向隧道的核心底座。
2.3 三者之间的组合关系
先别急着背命令,把三者关系理顺,很多问题会迎刃而解。
- Bridge是“中枢”,负责把多个虚拟网卡汇聚成一个二层网络;
- veth pair是“连接线”,用来把不同命名空间的网卡拉通,最典型的是把容器和宿主机连通;
- Tap是“门卫”,负责把二层帧从内核递给用户态程序,最典型的是虚拟机的虚拟网卡;
- Tun和 Tap 类似,但只传递IP包,适合用户态路由、协议转换。
实际项目中,你经常会看到 Bridge + Veth、Bridge + Tap 这种组合。前者是容器网络的常态,后者是虚拟机的常态。Tun 则经常单独存在,前面接路由表,后面接用户态处理逻辑。无论哪种组合,本质都是“让数据包按照我们期望的路径,在内核、命名空间和用户态程序之间流转”。
3. 三个实战场景:把设备真正用起来
3.1 场景一:Bridge + Tap 手工搭一张虚拟机网卡
这一步不借助任何管理工具,纯手工模拟 KVM 虚拟机的接入方式。先创建 Bridge 和 Tap,再把 Tap 插到桥上,最后用 QEMU 把虚拟机加进来。
# 创建网桥 br0 ip link add br0 type bridge ip link set br0 up # 创建 tap0,指定为 tap 模式 ip tuntap add dev tap0 mode tap ip link set tap0 up # 把 tap0 加入网桥 br0 ip link set tap0 master br0 # 给 br0 配置一个 IP,作为虚拟机网关 ip addr add 192.168.100.1/24 dev br0启动虚拟机的 QEMU 命令大概是这样:
qemu-system-x86_64 \ -name vm1 \ -netdev tap,id=net0,ifname=tap0,script=no,downscript=no \ -device virtio-net-pci,netdev=net0 \ -m 1024 \ -hda /path/to/disk.img如果你在虚拟机里配置192.168.100.50/24,网关指向192.168.100.1,宿主机本身也能ping 192.168.100.50通。这就是一套完整的 Bridge + Tap 环境。
我在实操时踩过一次坑:QEMU 参数里必须加上script=no和downscript=no,否则 QEMU 启动时会自动调用/etc/qemu-ifup脚本去配置网络。手动环境下这个脚本可能不存在或者行为不可控,加不加取决于你是否已经自己把 tap0 建好并加入桥了。既然我们手动做了,就要明确告诉 QEMU 不要再干预。
另外,Tap 设备本身不需要配置IP。它就是一个二层口,只要 up 就行,加IP反而容易造成混淆。
3.2 场景二:只靠 Bridge + Veth 搭一个小型容器网络
这一段我们用脚本手动创建两个网络命名空间,再通过 Bridge 把它们连起来,不依赖 Docker 等容器运行时。
# 创建两个网络命名空间 ip netns add ns1 ip netns add ns2 # 创建网桥 ip link add br1 type bridge ip link set br1 up # 创建 veth 对,并把每对的一端放入 ns ip link add veth1 type veth peer name veth1-peer ip link add veth2 type veth peer name veth2-peer ip link set veth1 netns ns1 ip link set veth2 netns ns2 # 另一端加入网桥 ip link set veth1-peer master br1 ip link set veth2-peer master br1 ip link set veth1-peer up ip link set veth2-peer up # 给命名空间内的接口配 IP ip netns exec ns1 ip addr add 10.0.0.10/24 dev veth1 ip netns exec ns1 ip link set veth1 up ip netns exec ns2 ip addr add 10.0.0.20/24 dev veth2 ip netns exec ns2 ip link set veth2 up此时从 ns1 ping ns2 应该能通:
ip netns exec ns1 ping 10.0.0.20这个场景最有意思的地方在于:你完全可以看到 Bridge 的转发表如何被建立起来。在 ns1 里 ping 一次后,在宿主机上执行:
bridge fdb show | grep br1你会看到 10.0.0.10 对应的 veth1-peer MAC 被记录到了 veth1-peer 端口下。这就是一次完整的MAC学习。
如果想进一步让 ns1 能访问外网,可以在宿主机上把 br1 当作网关,用 veth 对连接默认命名空间和 ns1,或者直接给 br1 配置IP。最简单的做法:
ip addr add 10.0.0.1/24 dev br1 ip netns exec ns1 ip route add default via 10.0.0.1 echo 1 > /proc/sys/net/ipv4/ip_forward然后配置 NAT,非本网段的包就能被转发出去了。建议把MASQUERADE规则写上:
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE没有这一步,ns1 到外部网络的包即使被宿主机转发出去,回来时也会因为没有合法的源地址而无法工作。
3.3 场景三:用 Tun 写一个用户态路由程序
最后演示 Tun 设备。它的核心价值是:把内核路由到 tun0 的IP包,交给用户态程序;用户态程序处理后再通过 write 写回 fd,重新注入内核。这里我用一段极简的 Python 代码演示交互过程。
import os import fcntl import struct import subprocess TUNSETIFF = 0x400454ca IFF_TUN = 0x0001 IFF_NO_PI = 0x1000 # 打开 /dev/net/tun fd = os.open("/dev/net/tun", os.O_RDWR) # 创建 tun0,不使用额外包信息头 ifr = struct.pack("16sH", b"tun0", IFF_TUN | IFF_NO_PI) fcntl.ioctl(fd, TUNSETIFF, ifr) # 配置 tun0:10.0.0.1/24,启用 subprocess.run(["ip", "addr", "add", "10.0.0.1/24", "dev", "tun0"]) subprocess.run(["ip", "link", "set", "tun0", "up"]) print("user-space loop started, press Ctrl+C to stop") while True: pkt = os.read(fd, 2048) print("got packet, length:", len(pkt)) # 这里可以加各种处理,比如改路由、封装、丢弃等 # 什么都不做的话,包等于被黑洞了在另一个终端里ping 10.0.0.2,你会看到用户态程序读到了ICMP请求包。这个演示看起来简单,但你已经走通了“内核路由 → 虚拟网卡 → 用户态程序”这条链路。
实际项目中,用户态程序往往还会构造响应或重新把包改写后从另一个 socket 发出去。理解了这一点,你就能看懂很多用户态协议栈、透明代理类工具的大致原理了:它们本质上就是在 Tun 设备上“截获”IP包并做自定义处理,处理完再放回内核或从物理网卡送出。
4. 常见问题与排查技巧实录
4.1 Bridge 刚创建后,插上端口也不通
这是新手最容易踩的坑:ip link add br0 type bridge之后,默认stp_state取决于内核配置,如果 STP 是开启状态,新加入的端口会先进入 Listening、Learning 状态,要等几十秒才会到 Forwarding。这期间所有数据帧都会被丢弃。
排查方式很直接,查看端口状态:
ip link show master br0 bridge link show如果发现端口 state 不是 FORWARDING,可以先关掉 STP:
ip link set br0 type bridge stp_state 0然后确认 Bridge 和所有端口都处于 up 状态。网桥没有IP照样能转发二层帧,但如果你希望在宿主机上访问桥接网络,必须给 br0 配IP。
4.2 虚拟机流量在物理机上抓不到
你从物理网卡 eth0 上tcpdump -i eth0,却看不到虚拟机和其他设备之间的二层帧,这不一定是配置错误。因为 Bridge 的转发行为发生在内核空间,流量可能根本不需要经过物理网卡。
如果帧的目的MAC就在 Bridge 内部的其他端口上,比如虚拟机A访问虚拟机B,那么包从 tap0 进来后直接就从 tap1 出去了,不经过 eth0。此时在 eth0 上抓包什么都抓不到。
建议分别在br0、tap0、物理网卡上同时抓包,对比一下:
tcpdump -i br0 tcpdump -i tap0 tcpdump -i eth0另外,如果虚拟机要和外网通信,物理接口通常需要开启混杂模式,否则物理网卡收到目的MAC为虚拟机 MAC 的帧时,会认为自己不该接收而忽略掉。
ip link set eth0 promisc on很多网桥管理工具会自动设置混杂模式,但纯手工配置时容易漏。
4.3 容器里能 ping 通网关,却 ping 不通外网
这个现象一般有三层原因:IP转发未开启、iptables FORWARD 规则把转发流量拦截、NAT 缺失。
先检查内核转发开关:
sysctl net.ipv4.ip_forward如果结果是0,立刻开:
sysctl -w net.ipv4.ip_forward=1再检查 FORWARD 链:
iptables -L FORWARD -n -v很多发行版默认 FORWARD 策略是 DROP,这时即使开了转发,跨网桥的流量也会被防火墙拦截。临时的办法是:
iptables -P FORWARD ACCEPT最后检查 NAT。如果容器网段是10.0.0.0/24,物理出口是 eth0,需要添加:
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE我见过不少排障过程卡在这三条上。排查顺序建议:先看转发开关,再看防火墙,最后看NAT。不要一上来就怀疑网桥配置。
4.4 Veth 一端失联、Tun 读不到数据的快速验证
如果 veth 对的一端看起来不存在,先确认是否都在同一个命名空间里。执行:
ip netns exec ns1 ip link show ip link show确认两端接口都 up。Veth 的链路层状态是联动的:如果一端 down,另一端会显示 NO-CARRIER。所以排查时务必把两端都ip link set up。
Tun 设备读不到数据时,先确认是否给 tun0 配了正确网段的IP,且本机路由表确实把它作为出口:
ip route get 10.0.0.2看看走的是不是 tun0。如果走的是其他接口,自然不会有包进入 tun0。
快速排查命令整理如下:
| 症状 | 检查命令 | 观察点 |
|---|---|---|
| 网桥不通 | bridge link show | 端口是否都 UP,是否 FORWARDING |
| 无MAC转发 | bridge fdb show | 是否学到正确的 MAC + 端口 |
| 虚拟机不通外网 | sysctl net.ipv4.ip_forward | 是否为 1 |
| 跨网桥被拦截 | iptables -L FORWARD -n -v | 策略是否 ACCEPT/DROP 计数器是否增长 |
| NAT问题 | iptables -t nat -L POSTROUTING -n -v | 是否有对应的 MASQUERADE 规则 |
| veth链路异常 | ethtool vethX | Link detected 是否为 yes |
| tun无法读到包 | ip route get 10.0.0.2 | 下一跳是否指向 tun0 |
4.5 宿主机和容器互相 ping 不通的隐蔽原因
还有一个我踩过很多次的坑:给 br0 配了IP,但默认命名空间的路由优先级把它绕过去了。比如 eth0 也在网桥上,同时服务器上又配了多个地址段,内核在访问容器网段时可能选择从物理网卡直接发,而不是经过 br0。这种现象叫非对称路由,表现为容器能 ping 通宿主机,但宿主机 ping 容器总是不通。
最简单粗暴的做法是把容器网段路由显式指向 br0:
ip route add 10.0.0.0/24 dev br0这种问题在复杂网络环境里尤其常见。如果你用 tcpdump 在 br0 上抓到请求包,却抓不到回应包,基本就是回包没有走同一条路径,重点检查路由表和 rp_filter。
sysctl net.ipv4.conf.all.rp_filterrp_filter 开启时会丢弃这些“来路异常”的包,按需调整为 0 或 2,再观察是否恢复正常。
5. 一个更容易的验证思路:全部设备拉起来再看一遍数据流
对于刚开始接触这三个设备的人,我建议把前面提到的所有设备全部手工建一次,然后用tcpdump一步一步看包从哪进、从哪出。
比如同时保留 br0、tap0、veth 对、tun0,然后逐个启动,再用:
tcpdump -i any -nn -e host 192.168.100.50也能看到所有接口上的流量。这个命令在信息膨胀时可能比较乱,但它能让你直观地感受到“同一个包在不同虚拟设备上出现的顺序和往返方向”。这种直觉一旦建立起来,后续排查虚拟网络问题的效率会高很多。
补充一句关于性能的经验:Bridge 和 veth 都是内核模块,正常场景下转发性能很高。如果遇到性能瓶颈,优先检查是否因为tcpdump抓包、iptables 大量规则、或者开启了很多事件追踪导致的额外开销。虚拟网络设备本身并不是瓶颈,真正拖后腿的往往是用户态处理程序和额外的过滤链。
这套东西看着知识点零散,实际串起来就一条线:理解每个设备工作在二层还是三层、数据包从哪里进内核又从哪里出内核、哪些设备会把包交给用户态程序。把这条线想明白,再去看 KVM 网络模型、容器网络模型、甚至各种自定义隧道方案,基本都能一眼看穿它们使用的底层设备组合。
最后再分享一个我的习惯:每次搭完一套虚拟网络,我都会在关键路径上用tcpdump -i any抓一次包,然后把所有接口的状态、MAC 表、路由表都贴到笔记里。下次出现问题时,翻出这个“正常状态”的存档作对比,定位问题的时间能缩短一半。这比记忆任何命令都管用。