上一篇我们从网卡收包一路跟到
socket,把 Linux
网络栈的主干走了一遍。但那条路径处理的都是”真实”的物理网卡。问题来了:容器里的
eth0
并不对应任何物理硬件,它是内核虚拟出来的。虚拟机里的网卡也是。VPN
隧道的接口也是。
Linux 内核提供了至少五种主流的虚拟网络设备,每种解决不同的问题。Docker 默认用 veth + bridge,QEMU 用 tap,OpenVPN 用 tun,某些 CNI 插件用 macvlan 或 ipvlan。如果你搞不清它们的区别,调网络问题时就像在黑箱里摸索。
这篇文章的目标很明确:把这五种设备的内核实现原理、数据流路径、性能代价和适用场景讲透。最后我们会动手做一个综合实验,用
ip link 手工把 veth、bridge、多个 netns
连起来,验证 ARP、广播和单播的行为。
本文假设你已经理解 network namespace 的基本概念。如果没有,建议先读 Network Namespace:给你的进程接上虚拟网线。
一、全局视角:五种设备各自解决什么问题
先建立一个直觉。这五种设备可以按”它在哪一层工作”和”它连接的是谁”来分类:
| 设备 | 工作层 | 连接关系 | 一句话描述 |
|---|---|---|---|
| veth pair | L2 | netns <-> netns | 一根虚拟网线,成对出现,两头分别放在不同的 namespace |
| bridge | L2 | 多个 port <-> 多个 port | 软件交换机,像物理交换机一样学习 MAC 地址并转发 |
| tun | L3 | 内核协议栈 <-> 用户态程序 | 用户态程序通过 fd 读写 IP 包 |
| tap | L2 | 内核协议栈 <-> 用户态程序 | 用户态程序通过 fd 读写以太网帧 |
| macvlan | L2 | 物理网卡 <-> 虚拟子接口 | 一块网卡虚拟出多个 MAC 地址,每个子接口独立收发 |
| ipvlan | L2/L3 | 物理网卡 <-> 虚拟子接口 | 一块网卡虚拟出多个 IP,所有子接口共享同一个 MAC |
你可能注意到了:veth 和 bridge 经常搭配使用(Docker 的默认网络模型就是 veth + bridge);tun 和 tap 是同一个驱动的两种模式;macvlan 和 ipvlan 解决类似的问题但隔离层次不同。
下面逐个深入。
二、veth pair:内核里的”虚拟网线”
为什么总是成对出现
veth 是 Virtual Ethernet 的缩写。创建 veth 时,内核必然生成两个设备,称为一个 pair。你没法创建单独的一个 veth — 这在概念上就说不通,就像你没法只制造一根网线的一头。
# 创建 veth pair
ip link add veth0 type veth peer name veth1
# 查看:两个设备同时出现
ip link show type veth输出:
5: veth1@veth0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN
6: veth0@veth1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN
注意 veth1@veth0 这个标记 —
内核明确告诉你它们是一对。从 veth0 发出的帧,会直接出现在
veth1
上,反之亦然。没有物理线缆,没有电磁信号,就是内核里的一个指针跳转。
内核实现:veth_xmit 的转发逻辑
veth 的核心代码在 drivers/net/veth.c
里,关键路径非常短。当一个包从 veth0
的发送路径(veth_xmit)出去时,内核做的事情本质上就是:
// 简化版 veth_xmit(实际代码更复杂,但核心逻辑就这些)
static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
struct veth_priv *priv = netdev_priv(dev);
struct net_device *peer = rcu_dereference(priv->peer);
if (unlikely(!peer))
goto drop;
// 关键:把 skb 的设备从 veth0 改成 veth1
skb->dev = peer;
// 如果 peer 在另一个 netns,用 dev_forward_skb
// 否则用 netif_rx
if (likely(dev_forward_skb(peer, skb) == NET_RX_SUCCESS)) {
// 统计计数
struct pcpu_vstats *stats = this_cpu_ptr(dev->vstats);
u64_stats_update_begin(&stats->syncp);
stats->bytes += length;
stats->packets++;
u64_stats_update_end(&stats->syncp);
}
return NETDEV_TX_OK;
drop:
kfree_skb(skb);
return NETDEV_TX_OK;
}几个关键点:
priv->peer:每个 veth 设备的私有数据里保存了对端的指针。创建 pair 时内核把两者互相关联。dev_forward_skb():这个函数把 skb “转发”到对端设备。它会重新触发对端设备的接收路径 — 这意味着包会再走一遍 netfilter。- 没有真正的”发送”:物理网卡的
ndo_start_xmit会把数据 DMA 到硬件。veth 的发送就是把 skb 的dev指针换一下,然后调用对端的接收函数。
veth 的性能代价:每个包穿越两次 netfilter
这是 veth 最大的性能痛点,也是很多人不理解的地方。
一个包从容器发出到宿主机,经过的路径是:
容器进程 sendmsg()
-> 容器 netns 的 TCP/IP 协议栈
-> 容器 netns 的 OUTPUT chain(netfilter 第 1 次)
-> veth 容器端 xmit
-> dev_forward_skb()
-> veth 宿主机端 receive
-> 宿主机 netns 的 PREROUTING chain(netfilter 第 2 次)
-> 宿主机的路由决策
-> 宿主机的 FORWARD / OUTPUT chain(netfilter 第 3 次)
-> 物理网卡发出
对比一下:如果进程直接在宿主机上发包,只需要走一次 netfilter。veth 至少多了一次完整的 netfilter 遍历,在有大量 iptables 规则的 Kubernetes 集群里(Service 的 DNAT 规则可能成百上千条),这个开销非常可观。
这也是为什么 Cilium 等 CNI 要用 eBPF 短路(short-circuit)掉 netfilter 的原因 — 不是 eBPF 本身更快,而是它可以跳过那些不必要的规则遍历。关于性能量化数据,参见容器网络性能。
实验:创建 veth pair 连接两个 netns
# 创建两个 network namespace
ip netns add ns1
ip netns add ns2
# 创建 veth pair
ip link add veth-ns1 type veth peer name veth-ns2
# 把两端分别放进两个 namespace
ip link set veth-ns1 netns ns1
ip link set veth-ns2 netns ns2
# 配置 IP 地址并启动
ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth-ns1
ip netns exec ns1 ip link set veth-ns1 up
ip netns exec ns1 ip link set lo up
ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth-ns2
ip netns exec ns2 ip link set veth-ns2 up
ip netns exec ns2 ip link set lo up
# 测试连通性
ip netns exec ns1 ping -c 3 10.0.0.2输出:
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.038 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.032 ms
64 bytes from 10.0.0.2: icmp_seq=3 ttl=64 time=0.029 ms
延迟在微秒级 — 因为根本没有经过任何物理介质,就是内核里的一次函数调用。
# 查看 veth pair 的对端关系
ip netns exec ns1 ethtool -S veth-ns1NIC statistics:
peer_ifindex: 7
这个 peer_ifindex 就是对端设备在对端 netns
里的接口索引。调试容器网络时,这个信息至关重要 —
你可以通过它找到 veth 的另一头在哪个 netns 里。
# 清理
ip netns del ns1
ip netns del ns2三、Linux bridge:软件交换机
bridge 和物理交换机的区别
Linux bridge 在功能上等价于一台二层交换机。它维护一张 MAC 地址表(FDB,Forwarding Database),根据目的 MAC 地址决定把帧从哪个端口转发出去。未知目的 MAC 的帧会广播到所有端口。
但和物理交换机有几个重要区别:
bridge 本身有 IP 地址。物理交换机通常没有(管理口除外)。Linux bridge 可以配 IP,这意味着宿主机可以通过 bridge 的 IP 和容器通信。Docker 的
docker0就是这么干的。bridge 处理的包会经过内核协议栈。物理交换机在 ASIC 里做转发,不走 CPU。Linux bridge 的每个帧都经过 CPU,而且如果启用了
br_netfilter,帧还会经过 iptables — 这是为什么 Kubernetes 需要net.bridge.bridge-nf-call-iptables = 1的原因。性能天花板。物理交换机可以线速转发。Linux bridge 受 CPU 限制,在高 pps(packets per second)场景下是瓶颈。
内核实现要点
bridge 的核心代码在 net/bridge/
目录下。关键数据结构:
// 简化版
struct net_bridge {
struct net_device *dev; // bridge 自身的 net_device
struct net_bridge_port *port_list; // 端口链表
struct hlist_head hash[BR_HASH_SIZE]; // FDB 哈希表
u16 group_fwd_mask; // 转发组掩码
unsigned long flags; // STP、VLAN 相关标志
};
struct net_bridge_port {
struct net_bridge *br; // 所属 bridge
struct net_device *dev; // 端口对应的 net_device(通常是 veth 的一端)
struct net_bridge_fdb_entry *fdb; // 端口关联的 FDB 条目
u8 state; // STP 端口状态
};当一个帧到达 bridge 的某个端口时,处理流程是:
- 源 MAC 学习:把源 MAC + 入端口记录到 FDB
- 目的 MAC 查找:在 FDB 里查目的 MAC
- 找到了 -> 从对应端口发出(单播转发)
- 没找到 -> 从所有端口(除入端口)发出(泛洪)
- 如果目的 MAC 是广播/组播 -> 从所有端口发出
这和物理交换机的逻辑完全一样。
STP(生成树协议)
如果你在多个 bridge 之间连了冗余链路(虽然在容器场景里很少见),就可能形成环路。STP 的作用是检测并断开环路,确保网络拓扑是一棵树。
# 查看 bridge STP 状态
bridge link show
brctl showstp br0
# 启用/禁用 STP
ip link set br0 type bridge stp_state 1 # 启用
ip link set br0 type bridge stp_state 0 # 禁用在容器网络里,通常不需要 STP — 因为拓扑很简单,不会有环路。Docker 默认就是关闭 STP 的。但如果你在搞多主机 overlay 网络或者 nested bridge,了解 STP 可以帮你诊断一些奇怪的”端口 blocking”问题。
VLAN filtering
Linux bridge 从 3.9 内核开始支持 VLAN filtering,可以在 bridge 端口上配置 VLAN 标签,实现类似物理交换机的 VLAN 隔离。
# 启用 bridge 的 VLAN filtering
ip link set br0 type bridge vlan_filtering 1
# 给端口配置 VLAN
bridge vlan add vid 100 dev veth-host pvid untagged
bridge vlan add vid 200 dev veth-host2 pvid untagged
# 查看 VLAN 配置
bridge vlan showVLAN filtering 在多租户场景下很有用:同一个 bridge 上的不同容器可以分到不同的 VLAN,二层互不可达。但实际上 Kubernetes 集群里用得不多 — 因为 CNI 通常用 overlay 或 BGP 来做租户隔离,不依赖 VLAN。
实验:创建 bridge 并连接多个 netns
# 创建 bridge
ip link add br0 type bridge
ip link set br0 up
ip addr add 10.0.0.254/24 dev br0
# 创建三个 netns,每个通过 veth 接入 bridge
for i in 1 2 3; do
ip netns add ns${i}
# 创建 veth pair
ip link add veth-br${i} type veth peer name veth-ns${i}
# 宿主机一端接入 bridge
ip link set veth-br${i} master br0
ip link set veth-br${i} up
# 另一端放进 netns
ip link set veth-ns${i} netns ns${i}
ip netns exec ns${i} ip addr add 10.0.0.${i}/24 dev veth-ns${i}
ip netns exec ns${i} ip link set veth-ns${i} up
ip netns exec ns${i} ip link set lo up
ip netns exec ns${i} ip route add default via 10.0.0.254
done验证连通性:
# ns1 ping ns2(经过 bridge 转发)
ip netns exec ns1 ping -c 2 10.0.0.2
# ns1 ping ns3
ip netns exec ns1 ping -c 2 10.0.0.3
# ns1 ping bridge 本身
ip netns exec ns1 ping -c 2 10.0.0.254查看 FDB(MAC 地址表):
bridge fdb show br br0输出类似:
33:33:00:00:00:01 dev veth-br1 self permanent
ba:7c:12:3a:56:78 dev veth-br1 master br0
ae:91:cd:45:67:89 dev veth-br2 master br0
d2:f3:ab:12:34:56 dev veth-br3 master br0
这就是 bridge 学习到的 MAC 地址。master br0
表示这个条目是 bridge
学习来的(动态的),self permanent
是静态的。
# 清理
for i in 1 2 3; do ip netns del ns${i}; done
ip link del br0四、tun/tap:用户态网络栈的入口
tun vs tap:L3 vs L2
tun 和 tap
是同一个内核驱动(drivers/net/tun.c)提供的两种模式:
- tun(tunnel):工作在 L3,用户态程序读写的是 IP 包(没有以太网头)
- tap(network tap):工作在 L2,用户态程序读写的是以太网帧(包含 MAC 头)
它们的共同点是:在内核网络栈和用户态程序之间建立一条通道。内核把本该从某个接口发出的包,通过 tun/tap 设备递交给用户态程序;用户态程序也可以向 tun/tap 写入数据,内核会把这些数据当作”从该接口收到的包”来处理。
// 打开 tun/tap 设备的标准方式
int tun_alloc(char *dev, int flags) {
struct ifreq ifr;
int fd, err;
// /dev/net/tun 是 tun/tap 驱动的字符设备
fd = open("/dev/net/tun", O_RDWR);
if (fd < 0)
return fd;
memset(&ifr, 0, sizeof(ifr));
ifr.ifr_flags = flags; // IFF_TUN(tun 模式)或 IFF_TAP(tap 模式)
if (*dev)
strncpy(ifr.ifr_name, dev, IFNAMSIZ);
// TUNSETIFF ioctl 创建设备并绑定到这个 fd
err = ioctl(fd, TUNSETIFF, (void *)&ifr);
if (err < 0) {
close(fd);
return err;
}
strcpy(dev, ifr.ifr_name);
return fd;
}使用方式很直观:
// 从 tun 设备读取一个 IP 包(内核发给用户态)
char buf[2048];
int nread = read(tun_fd, buf, sizeof(buf));
// buf 里现在是一个完整的 IP 包(tun 模式)或以太网帧(tap 模式)
// 向 tun 设备写入一个 IP 包(用户态注入内核)
write(tun_fd, packet, packet_len);OpenVPN 怎么用 tun
OpenVPN 的工作原理可以用一句话概括:从 tun 设备读取 IP 包,加密后通过 UDP socket 发给对端;从 UDP socket 收到加密数据,解密后写入 tun 设备。
应用层数据
-> 内核路由到 tun0(因为路由表指向 tun0)
-> OpenVPN 进程从 tun0 fd read() 拿到明文 IP 包
-> 加密 + 封装成 UDP 包
-> 通过物理网卡 eth0 发送到 VPN 服务器
-> 服务器 OpenVPN 进程收到 UDP 包
-> 解密
-> write() 写入 tun0
-> 内核协议栈处理(路由转发到目标网络)
这就是为什么 VPN 连接后你的流量会”绕一圈”:本来内核可以直接从 eth0 发出的包,现在先到 tun0,被 OpenVPN 读出来,加密,再从 eth0 发出 UDP 封装后的版本。每个包都经历一次用户态和内核态的上下文切换,性能比 WireGuard(纯内核态实现)差不少。
QEMU/KVM 怎么用 tap
QEMU 用 tap 而不是 tun,因为虚拟机需要完整的 L2 网络 — 虚拟机里的操作系统需要看到以太网帧,需要做 ARP,需要有自己的 MAC 地址。
# 创建 tap 设备(通常由 libvirt/QEMU 自动完成)
ip tuntap add tap0 mode tap user qemu
ip link set tap0 up
# 把 tap 设备接入 bridge(让 VM 和宿主机在同一个 L2 网络)
ip link set tap0 master br0QEMU 启动时通过 -netdev tap,ifname=tap0 把
tap 设备绑定给虚拟机。数据流:VM 进程发包 -> VM
内核构造以太网帧 -> virtio-net 通过 virtqueue 传给 QEMU
-> QEMU write() 到 tap0 fd -> 宿主机内核当作”从 tap0
收到的帧”处理 -> bridge L2 转发 -> 物理网卡发出。
实验:创建 tap 设备并抓包
ip tuntap add tap0 mode tap
ip addr add 10.0.0.1/24 dev tap0
ip link set tap0 up
ip link show tap08: tap0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN
link/ether 2e:a1:b3:c4:d5:e6 brd ff:ff:ff:ff:ff:ff
注意 NO-CARRIER 状态 —
因为没有用户态程序打开 /dev/net/tun 并绑定这个
tap 设备,所以内核认为”线没接”。一旦有程序通过
ioctl(fd, TUNSETIFF, ...)
绑定了这个设备,状态就会变成 LOWER_UP。
# 简单的 tap 读取示例
import fcntl, struct, os
TUNSETIFF = 0x400454ca
IFF_TAP = 0x0002
IFF_NO_PI = 0x1000
fd = os.open('/dev/net/tun', os.O_RDWR)
ifr = struct.pack('16sH', b'tap0', IFF_TAP | IFF_NO_PI)
fcntl.ioctl(fd, TUNSETIFF, ifr)
while True:
data = os.read(fd, 2048)
dst, src, etype = data[0:6].hex(), data[6:12].hex(), data[12:14].hex()
print(f'frame: {len(data)}B dst={dst} src={src} type={etype}')ip link del tap0 # 清理五、macvlan:一块网卡,多个 MAC 地址
macvlan 允许你在一块物理网卡上创建多个虚拟子接口,每个子接口有自己独立的 MAC 地址。从外部网络看,好像这台机器插了多块网卡。
四种模式
macvlan 有四种工作模式,行为差异很大:
bridge 模式(最常用):
子接口之间可以直接通信,不需要经过外部交换机。内核在内部做 L2 转发。
ip link add macvlan0 link eth0 type macvlan mode bridgevepa 模式(Virtual Ethernet Port Aggregator):
所有流量都必须经过外部交换机,即使是同一个宿主机上的两个子接口之间的通信也要”出去绕一圈”。这需要外部交换机支持 hairpin(也叫 reflective relay)。
ip link add macvlan0 link eth0 type macvlan mode vepaprivate 模式:
和 vepa 类似,但子接口之间完全隔离 — 即使外部交换机做了 hairpin,同一宿主机的子接口之间也不能通信。最严格的隔离。
ip link add macvlan0 link eth0 type macvlan mode privatepassthru 模式:
直接把物理网卡”让”给一个子接口。一个物理网卡只能创建一个 passthru 的 macvlan 子接口。通常用于让容器/VM 直接控制物理网卡(但不想用 SR-IOV)。
ip link add macvlan0 link eth0 type macvlan mode passthrumacvlan 的坑:宿主机和容器不能直接通信
这是 macvlan 最让人困惑的地方:macvlan 子接口和父接口(宿主机的 eth0)之间不能直接通信。
为什么?因为 macvlan 的实现方式是在父接口的接收路径上拦截帧:如果目的 MAC 是某个子接口的 MAC,就把帧交给那个子接口;否则交给父接口。但宿主机自己从父接口发出的帧,不会经过这个”拦截”逻辑 — 发出去的帧直接走物理网卡了。
# 演示这个问题
ip link add macvlan0 link eth0 type macvlan mode bridge
ip netns add test
ip link set macvlan0 netns test
ip netns exec test ip addr add 192.168.1.100/24 dev macvlan0
ip netns exec test ip link set macvlan0 up
# 从宿主机 ping macvlan0 的 IP — 不通!
ping -c 1 192.168.1.100
# PING 192.168.1.100 (192.168.1.100) 56(84) bytes of data.
# --- 192.168.1.100 ping statistics ---
# 1 packets transmitted, 0 received, 100% packet loss解决办法通常是:在宿主机上也创建一个 macvlan 子接口,用它来和其他 macvlan 子接口通信。或者干脆用 bridge 模式 — 但这就失去了 macvlan 的性能优势。
适用场景
- 需要容器/VM 直接暴露在外部 L2 网络中(不经过 NAT)
- 外部交换机需要看到每个容器的独立 MAC 地址
- 对网络性能要求极高(macvlan 的转发路径比 veth + bridge 短得多)
- 不需要宿主机和容器之间频繁通信
macvlan 在某些 CNI 插件(如 Multus 配合 macvlan CNI)中用于提供第二块网卡给 Pod,常见于 NFV(网络功能虚拟化)场景。
六、ipvlan:L3 隔离,共享 MAC 地址
ipvlan 和 macvlan 很像,但有一个关键区别:所有子接口共享父接口的 MAC 地址。区分流量靠的不是 MAC,而是 IP 地址。
三种模式
L2 模式:
和 macvlan bridge 模式类似 — 子接口之间可以直接通信。但因为 MAC 地址相同,内核靠 IP 地址做”局部路由”来区分。外部网络看到的所有子接口的 MAC 都一样。
ip link add ipvlan0 link eth0 type ipvlan mode l2L3 模式:
内核在 L3 层做路由转发。子接口之间的通信完全靠路由表。没有 ARP,没有广播,没有组播。这意味着:
- 不需要 ARP 表维护,减少了广播风暴的风险
- 不支持 DHCP(因为 DHCP 依赖广播)
- 子接口之间的网段可以不同(因为是路由,不是交换)
ip link add ipvlan0 link eth0 type ipvlan mode l3L3S 模式(L3 + symmetric routing):
和 L3 模式类似,但增加了对 netfilter 连接追踪的支持。L3 模式下 netfilter 的某些功能(如 SNAT/DNAT)可能不工作,L3S 模式修复了这个问题。代价是性能稍低。
ip link add ipvlan0 link eth0 type ipvlan mode l3sipvlan vs macvlan:怎么选
| 维度 | macvlan | ipvlan |
|---|---|---|
| MAC 地址 | 每个子接口独立 MAC | 所有子接口共享父接口 MAC |
| 外部交换机 | 每个子接口注册独立 MAC | 只看到一个 MAC |
| L2 广播 | 正常 | L2 模式正常,L3/L3S 模式无广播 |
| DHCP | 可以 | L2 模式可以,L3/L3S 不行 |
| MAC 地址数量限制 | 受外部交换机限制(802.1x 端口安全策略可能阻止) | 无此问题 |
| 宿主机通信 | 不能直接和父接口通信 | 同样不能直接和父接口通信 |
| 性能 | 接近原生 | 接近原生(L3 模式可能更快,因为跳过 ARP) |
| 容器网络应用 | 传统 CNI、NFV | 云环境(AWS/GCE 限制 MAC 数量时) |
选择建议:
- 如果外部网络环境对 MAC 地址数量没有限制,优先 macvlan — 行为更直觉,和传统网络更兼容
- 如果在公有云环境(AWS VPC、GCE),由于虚拟交换机通常限制每个端口的 MAC 数量,ipvlan 更合适
- 如果需要完全隔离广播域,用 ipvlan L3 模式
- 如果需要 netfilter 完整功能,用 ipvlan L3S 模式
实验:ipvlan L2 vs L3 对比
# 使用 dummy 接口作为父接口(避免影响真实网络)
ip link add dummy0 type dummy
ip link set dummy0 up
ip addr add 10.0.0.254/24 dev dummy0
# ---- L2 模式 ----
for i in 1 2; do
ip netns add ipvlan-l2-${i}
ip link add ipvl${i} link dummy0 type ipvlan mode l2
ip link set ipvl${i} netns ipvlan-l2-${i}
ip netns exec ipvlan-l2-${i} ip addr add 10.0.0.${i}/24 dev ipvl${i}
ip netns exec ipvlan-l2-${i} ip link set ipvl${i} up
ip netns exec ipvlan-l2-${i} ip link set lo up
done
ip netns exec ipvlan-l2-1 ping -c 2 10.0.0.2 # L2 互通
ip netns exec ipvlan-l2-1 ip link show ipvl1 | grep ether # MAC 和 dummy0 相同
# ---- L3 模式(子接口可在不同网段,无 ARP / 无广播)----
for i in 1 2; do
ip netns add ipvlan-l3-${i}
ip link add ipvl-l3-${i} link dummy0 type ipvlan mode l3
ip link set ipvl-l3-${i} netns ipvlan-l3-${i}
ip netns exec ipvlan-l3-${i} ip addr add 10.0.${i}.1/32 dev ipvl-l3-${i}
ip netns exec ipvlan-l3-${i} ip link set ipvl-l3-${i} up
ip netns exec ipvlan-l3-${i} ip link set lo up
ip netns exec ipvlan-l3-${i} ip route add default dev ipvl-l3-${i}
done
ip netns exec ipvlan-l3-1 ping -c 2 10.0.2.1 # 跨网段也通(纯路由)三个要点:L2 模式下 MAC 地址完全相同(和 dummy0 一样);L3 模式下不同网段也能通信(内核路由转发);L3 模式没有 ARP 和广播。
# 清理
for i in 1 2; do ip netns del ipvlan-l2-${i}; ip netns del ipvlan-l3-${i}; done
ip link del dummy0七、综合实验:用 veth + bridge + 多个 netns 手工搭建容器网络
前面每种设备都单独演示了。现在我们来做一个综合实验:模拟
Docker 的默认网络模型,用纯 ip
命令搭建一个完整的容器网络,并验证
ARP、广播、单播的行为。
实验拓扑
宿主机 netns
+-------------------------+
| |
| br0 (10.88.0.1/24) |
| / | \ |
| veth-h1 veth-h2 veth-h3|
+---|--------|--------|---+
| | |
+---+--+ +---+--+ +---+--+
|veth-c1| |veth-c2| |veth-c3|
| | | | | |
| ns1 | | ns2 | | ns3 |
|10.88 | |10.88 | |10.88 |
|.0.11 | |.0.12 | |.0.13 |
+------+ +------+ +------+
搭建步骤
#!/bin/bash
# 综合实验:veth + bridge + 多个 netns
# 需要 root 权限
set -e
BRIDGE="br0"
SUBNET="10.88.0"
BRIDGE_IP="${SUBNET}.1"
# 1. 创建 bridge
ip link add ${BRIDGE} type bridge
ip addr add ${BRIDGE_IP}/24 dev ${BRIDGE}
ip link set ${BRIDGE} up
# 启用 IP 转发(让 bridge 能路由容器的流量到外网)
sysctl -w net.ipv4.ip_forward=1 > /dev/null
# 2. 创建三个 "容器"(network namespace)
for i in 1 2 3; do
NS="container${i}"
CONTAINER_IP="${SUBNET}.$((10 + i))"
VETH_HOST="veth-h${i}"
VETH_CONTAINER="veth-c${i}"
# 创建 netns
ip netns add ${NS}
# 创建 veth pair
ip link add ${VETH_HOST} type veth peer name ${VETH_CONTAINER}
# 宿主机端接入 bridge
ip link set ${VETH_HOST} master ${BRIDGE}
ip link set ${VETH_HOST} up
# 容器端放进 netns
ip link set ${VETH_CONTAINER} netns ${NS}
# 容器内配置
ip netns exec ${NS} ip link set lo up
ip netns exec ${NS} ip addr add ${CONTAINER_IP}/24 dev ${VETH_CONTAINER}
ip netns exec ${NS} ip link set ${VETH_CONTAINER} up
ip netns exec ${NS} ip route add default via ${BRIDGE_IP}
echo "Created ${NS}: ${CONTAINER_IP}"
done
echo "Network setup complete."验证 ARP 行为
ARP(Address Resolution Protocol)在 L2 网络里负责把 IP 地址解析为 MAC 地址。在我们的实验拓扑中,bridge 扮演交换机的角色,ARP 请求会被广播到所有端口。
# 清空 container1 的 ARP 缓存
ip netns exec container1 ip neigh flush all
# 从 container1 ping container2,同时在 container2 和 container3 抓包
# 终端 1:在 container2 上抓包
ip netns exec container2 tcpdump -i veth-c2 -nn -e arp &
TCPDUMP_PID2=$!
# 终端 2:在 container3 上抓包
ip netns exec container3 tcpdump -i veth-c3 -nn -e arp &
TCPDUMP_PID3=$!
sleep 1
# 从 container1 发起 ping
ip netns exec container1 ping -c 1 10.88.0.12
sleep 2
kill $TCPDUMP_PID2 $TCPDUMP_PID3 2>/dev/null你会看到:
# container2 上的抓包(目标,会收到 ARP 请求并回复)
tcpdump: ARP, Request who-has 10.88.0.12 tell 10.88.0.11, length 28
tcpdump: ARP, Reply 10.88.0.12 is-at ae:91:cd:45:67:89, length 28
# container3 上的抓包(旁观者,只收到广播的 ARP 请求,不会收到回复)
tcpdump: ARP, Request who-has 10.88.0.12 tell 10.88.0.11, length 28
这证实了 bridge 的广播行为:ARP 请求是广播帧(目的 MAC 为 ff:ff:ff:ff:ff:ff),bridge 会转发到所有端口。ARP 回复是单播帧,bridge 查 FDB 后只转发到 container1 对应的端口。
验证广播与单播转发
# 广播 ping — tcpdump 能看到帧到达所有端口
ip netns exec container2 tcpdump -i veth-c2 -nn -c 5 &
sleep 1
ip netns exec container1 ping -b -c 1 10.88.0.255
sleep 2
# 单播 ping 后观察 FDB 表
ip netns exec container1 ping -c 3 10.88.0.12
bridge fdb show br br0 | grep -v permanent输出会显示从各端口学习到的 MAC 地址。bridge
在收到帧后把源 MAC + 入端口记录在 FDB
里,后续单播转发就不需要泛洪了。FDB 条目默认老化时间 300
秒(可通过
ip link set br0 type bridge ageing_time
修改)。
清理
for i in 1 2 3; do ip netns del container${i}; done
ip link del br0八、性能对比与选型决策树
不同虚拟设备的性能差异主要来自数据路径的长度:
性能排序(同等条件下,吞吐量从高到低):
ipvlan L3 >= macvlan bridge > ipvlan L2 > veth + bridge >> tun/tap
原因:
- ipvlan L3:最短路径,纯路由转发,无 ARP,无广播
- macvlan bridge:接近原生 NIC 性能,只多了一次 MAC 匹配
- ipvlan L2:多了一层内核内部的"软 bridge"
- veth + bridge:每个包穿越两次协议栈,经过 bridge 的 FDB 查询
- tun/tap:每个包在用户态和内核态之间拷贝一次(最慢)
一个简化的选型决策树:
你在用容器还是 VM?
├── VM(需要完整 L2 网络)
│ └── tap + bridge
├── 容器
│ ├── 需要 NAT / 端口映射?
│ │ └── veth + bridge + iptables(Docker 默认模型)
│ ├── 需要直接 L2 网络接入?
│ │ ├── 外部交换机允许多 MAC?
│ │ │ └── macvlan bridge
│ │ └── 不允许(云环境 / 802.1x)
│ │ └── ipvlan L2 或 L3
│ └── Kubernetes CNI?
│ ├── 通用场景 -> Calico / Cilium(veth 为主)
│ └── 高性能 / NFV -> macvlan / ipvlan / SR-IOV
└── VPN 隧道
└── tun(OpenVPN)或 WireGuard(内核态,不需要 tun)
九、常见踩坑与排查技巧
veth 找不到对端
容器里的 eth0 是 veth
的一端,但你怎么找到另一端在宿主机的哪个接口?
# 方法一:通过 sysfs
cat /sys/class/net/eth0/iflink # 容器内执行,输出对端 ifindex
# 方法二:通过 ethtool
ethtool -S eth0 | grep peer_ifindex
# 在宿主机上用 ifindex 查找
ip link | grep "^7:"bridge 转发不工作
# 检查常见原因
ip link show br0 # bridge 本身是否 UP?
bridge link show # 端口是否 UP?
bridge link show | grep state # STP 是否阻塞了端口?
sysctl net.bridge.bridge-nf-call-iptables # br_netfilter 是否拦截了帧?如果 bridge-nf-call-iptables 为 1,bridge
上的帧也会经过 iptables 规则。某些 DROP 规则可能影响 bridge
内部转发。
macvlan/ipvlan 宿主机不能和子接口通信
前面讲过了,这是设计如此。解决办法:在宿主机上也创建一个 macvlan/ipvlan 子接口:
ip link add macvlan-host link eth0 type macvlan mode bridge
ip addr add 192.168.1.200/24 dev macvlan-host
ip link set macvlan-host up
ping -I macvlan-host 192.168.1.100tun/tap 设备 NO-CARRIER
没有用户态程序绑定这个设备。用
lsof /dev/net/tun 检查。
netns 里路由不通
最常见的遗漏:忘记配默认路由,或忘记在宿主机上启用 IP 转发。
ip netns exec ns1 ip route show # 检查路由表
ip netns exec ns1 ip route add default via 10.88.0.1 # 添加默认路由
sysctl -w net.ipv4.ip_forward=1 # 启用 IP 转发抓包定位:逐跳排查
当网络不通时,按数据路径逐步抓包,哪一步抓不到就定位到了问题环节:
ip netns exec container1 tcpdump -i veth-c1 -nn # 1. 容器内 veth
tcpdump -i veth-h1 -nn # 2. 宿主机端 veth
tcpdump -i br0 -nn # 3. bridge
tcpdump -i eth0 -nn # 4. 物理网卡十、总结
回顾一下这五种虚拟网络设备的核心差异:
veth pair:最基础的积木,用于连接两个 network namespace。每个包穿越两次 netfilter 是它的性能代价,但架构灵活性无可替代。Docker、Kubernetes 的默认网络模型都以 veth 为基础。
Linux bridge:软件交换机,把多个 veth 端口连在一起。FDB 学习、广播泛洪、STP 防环 — 行为和物理交换机一致。
docker0就是一个 bridge。tun/tap:用户态程序读写网络包的接口。tun 处理 IP 包,tap 处理以太网帧。OpenVPN 用 tun,QEMU 用 tap。性能最差(因为要在用户态和内核态之间拷贝数据),但灵活性最强。
macvlan:在物理网卡上虚拟出多个 MAC 地址。性能接近原生,但宿主机和子接口不能直接通信。适合需要容器直接接入外部 L2 网络的场景。
ipvlan:和 macvlan 类似,但所有子接口共享同一个 MAC。适合公有云等对 MAC 地址数量有限制的环境。L3 模式下没有广播和 ARP,最纯粹的路由模型。
理解这五种设备之后,你再看 Kubernetes 的 CNI 插件,就会发现它们无非是这些积木的不同组合方式:Flannel 默认用 veth + bridge + VXLAN overlay,Calico 用 veth + 路由(不需要 bridge),Cilium 用 veth + eBPF 短路。
下一篇我们进入 netfilter
与 iptables — Kubernetes Service 的 DNAT
规则、NetworkPolicy 的 DROP 规则,全都建立在 netfilter
之上。理解了 netfilter 的五链四表,你才能真正看懂
iptables-save
输出的那一大坨规则到底在干什么。
系列导航
- 上一篇:Linux 网络栈全景
- 下一篇:netfilter 与 iptables
- 相关:Network Namespace 实战
- 相关:容器网络性能测试