Inspecting Linux Network Virtualization
Virtual NetworkingTOC
前言
随着云计算、容器化技术和微服务架构的快速发展,虚拟化技术在现代计算领域中扮演着越来越重要的角色。在此背景下,了解 Linux 虚拟化网络设备的工作原理,将会为热门前沿技术的学习提供有力支撑。本文将结合具体场景,对 Linux 虚拟网络设备的工作流程进行观察与讨论,希望能够帮助读者理解其内部机制,从而更好地应用在开发实践当中。
Linux 内核如何对网络设备进行抽象
Linux 内核通过网络设备结构体 struct net_device 对网络设备进行抽象。该结构体包含了与网络设备相关的各种属性和方法,以便内核能够统一管理和操作不同类型的网络设备。struct net_device 包含了设备名称、设备状态等信息,同时也提供了一组设备操作,包括打开、关闭设备、发送数据包等。这些操作由设备驱动程序实现,可以通过 netdev_ops 字段进行访问。
struct net_device 的主要组成部分如下:
- 设备名称:设备名称(如
eth0,wlan0等)是用于标识网络设备的字符串。它可以通过name字段进行访问。 - 设备状态:设备状态表示网络设备当前的工作状态,如是否已启用、是否已连接等。它可以通过
state字段进行访问。 - 硬件地址:硬件地址(如 MAC 地址)是网络设备在数据链路层上的唯一标识符。它可以通过
dev_addr字段进行访问。 - 设备操作:设备操作是一组由驱动程序实现的方法,用于对网络设备执行特定的操作,如打开设备、关闭设备、发送数据包等。这些方法被封装在
struct net_device_ops结构体中,并通过netdev_ops字段进行访问。 - 其他属性:
struct net_device还包含了其他与网络设备相关的属性,如 MTU(最大传输单元)、设备类型(以太网、无线局域网等)、设备特性(如支持校验和卸载、散列等)等。
同时,为了正确地管理和操作网络设备,内核在 net/core/dev.c 定义了大量用于操作 struct net_device 的函数,值得关注的有:
dev_queue_xmit: 将数据包传输到设备驱动程序以进行发送。dev_hard_start_xmit: 通过设备驱动程序的ndo_start_xmit函数发送数据包。netif_rx: 将设备驱动程序接收到的数据包传递给内核网络子系统。netif_receive_skb: 处理从设备驱动程序接收到的数据包,并将其传递给适当的协议处理程序。
等等。
当设计新的虚拟网络设备时,内核开发人员将为其实现对应的驱动,如 drivers/net/veth.c,drivers/net/macvtap.c 等。在实例化虚拟网络设备时,设备驱动将在其对应的 struct net_device 变量中注册设备的操作方法:
1//drivers/net/veth.c
2static const struct net_device_ops veth_netdev_ops = {
3 .ndo_init = veth_dev_init,
4 .ndo_open = veth_open,
5 .ndo_stop = veth_close,
6 .ndo_start_xmit = veth_xmit,
7 ...
8}
L1/L2 层 TX/RX 流程
前文提到,为了管理和操作网络设备,内核在 net/core/dev.c 定义了一系列用于操作 struct net_device 的函数。而其中 dev_queue_xmit 函数与 netif_receive_skb 函数分别对应了 TX 与 RX 流程中 L2 层的处理逻辑,也是我们对 L1/L2 层 TX/RX 流程讨论的起点与终点。
在 TX 流程中,当数据包需要从内核网络子系统发送到网络设备时,它将首先从 dev_queue_xmit 函数出发,经过一系列 L2 层的处理,如 QoS(服务质量)、流控制等,到达网络设备的 Qdisc(排队规则)。如果该网络设备没有发送队列,则其 Qdisc 将允许数据包 bypass,并立刻使用设备驱动程序的 ndo_start_xmit 函数发送数据包。否则,Qdisc 会根据其策略对数据包进行排队、调度等处理,最后直接或使用 NET_TX_SOFTIRQ 软中断间接对数据包进行发送。
在 RX 流程中,当网络设备接收到数据包时,其驱动程序将首先触发硬中断。对于不同类型的设备,其中断处理的方式也存在差异:
-
若该设备支持 NAPI 收包方式,则其中断处理程序会先屏蔽该设备的接收中断,并调用
napi_schedule函数将对应 NAPI 实例加入到内核的轮询队列中,随后触发NET_RX_SOFTIRQ软中断,内核将在软中断的处理程序中调用设备驱动程序的napi->poll()函数来从 ringbuffer 中接收数据包; -
若该设备不支持 NAPI 收包方式,则需要在硬中断的处理程序中从 ringbuffer 接收数据包,并借助
netif_rx函数将数据包添加到内核的输入队列input_pkt_queue中,最后触发NET_RX_SOFTIRQ软中断,内核将在软中断处理程序中处理输入队列中的数据包。
但无论以哪种方式接收数据,所有数据包都将被软中断上下文中调用的 netif_receive_skb 函数传入 L2 层。
Bridge 如何与其他设备协同工作
Bridge 是 Linux 中一种特殊的虚拟网络设备,其功能类似于一个软件实现的交换机,但在细节上又有所差异。Bridge 可以用来将多个物理或虚拟设备连接在一起,通常被用于在主机上的虚拟机以及网络名称空间之间转发数据包。在本节,我们将简述 bridge 如何与其他设备协同工作。
Bridge被定义在 net/bridge/br_if.c 中,使用 struct net_bridge 表示。同时,该文件还定义了一系列用于操作或实现 bridge 的函数,其主要功能与调用关系如下图(图中省略了部分包装函数):
其中:
br_add_if: 将一个网络设备以struct net_bridge_port的形式加入 bridge 的端口列表中。br_handle_frame: 接收并处理从端口设备 RX 流程中传入的数据包。br_handle_local_finish: 目的地为本机接口的数据包将由该函数传递给主机的协议栈。br_handle_frame_finish: 更新转发数据库,并为数据包寻找正确的目标端口。br_forward: 将目标端口对应的网络设备更新为数据包的发送设备。br_forward_finish: 启动发送设备的 TX 流程。
在 br_add_if 中,当一个设备被设置为 bridge 的端口时,其 rx_handle 将被设置为 br_handle_frame 函数,因此该设备 RX 流程内原本在 netif_receive_skb 函数中上送网络栈的数据包将会交由 bidge 处理。同时,当 bridge 选定一个网络设备作为转发端口时,将在 br_forward_finish 函数中调用 dev_queue_xmit 函数,触发该设备的 TX 流程。
案例研究
QEMU 网络虚拟化中的 TAP 设备
TUN/TAP 设备通常用于将协议栈中待发送的数据包传递到用户空间程序,或从用户空间程序注入数据包至协议栈:
TUN 设备 与TAP 设备的设计非常相似,它们共享了打开、关闭、数据发送等设备操作方法:
1//drivers/net/tun.c
2static const struct net_device_ops tun_netdev_ops = {
3 .ndo_init = tun_net_init,
4 .ndo_uninit = tun_net_uninit,
5 .ndo_open = tun_net_open,
6 .ndo_stop = tun_net_close,
7 .ndo_start_xmit = tun_net_xmit,
8 ...
9};
10static const struct net_device_ops tap_netdev_ops = {
11 .ndo_init = tun_net_init,
12 .ndo_uninit = tun_net_uninit,
13 .ndo_open = tun_net_open,
14 .ndo_stop = tun_net_close,
15 .ndo_start_xmit = tun_net_xmit,
16 ...
17};
它们的区别在于,TUN 设备用于处理 IP 数据包,而 TAP 设备用于处理以太网数据包。在 TUN/TAP 设备初始化时,将依据用户提供标志位来区别其类型,同时通过一个特殊的字符设备与用户空间相关联。
当 TUN/TAP 设备的 TX 流程被触发时,tun_net_xmit 函数最终将会被调用,该函数将数据写入对应字符设备的缓冲区,并唤醒因读取该字符设备而阻塞的用户进程。而当用户进程向字符设备写入数据时,则会通过 tun_get_user 函数以及 tap_get_user 函数触发 TUN/TAP 设备的 RX 流程,将数据送往协议栈。
基于以上特性,QEMU 选择使用 TAP 设备搭配 bridge 来实现虚拟机网络:
当增加一个网络设备至虚拟机时,QEMU 首先创建一个 TAP 设备,并将其设置为 bridge 的一个端口,而该 TAP 设备对应的字符设备则被配置为虚拟机内部的一个网络接口。
当虚拟机的网络栈通过该网络接口发送数据时,将写入数据至字符设备,同时触发 tap 设备的 RX 流程,数据包在 netif_receive_skb 函数中被传递给 bridge 进行转发。
而当 bridge 需要将数据转发至该虚拟机时,将触发该 TAP 设备的 TX 流程,数据被写入对应的字符设备,经过QEMU处理,最后由虚拟机内部的网络栈进行接收。
Docker 容器网络中的 VETH 设备
VETH 设备通常用于实现不同网络命名空间之间的通信,该设备总是被成对创建,且其中某一设备发出的数据将立刻被另一设备接收。
要实现这样的功能,需要在 VETH 设备的 TX 流程中将数据包传递给对端设备,并触发对端的 RX 流程。VETH 驱动使用内核提供的 dev_forward_skb 函数来实现这一功能:
VETH 的驱动被定义在 drivers/net/veth.c 中:
1//drivers/net/veth.c
2static const struct net_device_ops veth_netdev_ops = {
3 .ndo_init = veth_dev_init,
4 .ndo_open = veth_open,
5 .ndo_stop = veth_close,
6 .ndo_start_xmit = veth_xmit,
7 ...
8}
9static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev){
10 struct veth_priv *rcv_priv, *priv = netdev_priv(dev);
11 struct veth_rq *rq = NULL;
12 struct net_device *rcv;
13 ...
14 rcv = rcu_dereference(priv->peer);
15 ...
16 dev_forward_skb(rcv, skb) ...
17 ...
18}
当 VETH 设备的某一端使用 veth_xmit 函数发送数据时,将内部将调用 dev_forward_skb 函数将数据传递给对端设备,并最终调用 netif_rx_internal 函数触发对端设备的 RX 流程。
Docker 基于 VETH 与 bridge 实现了其默认的容器网络。在创建容器时,一对 VETH 设备中的一端将被设置为 bridge 的一个端口,而另一端则置于容器的网络命名空间内部,并被容器作为网络接口使用:
当 Container0 发送数据时,veth0b 的 TX 流程以及 veth0a 的 RX 流程将被触发,且由于 veth0a 被设置为 docker0 的端口,数据最终将被送入 docker0 进行转发。
当数据包的目的地为 Container1 时,veth1a 将作为转发端口,其 TX 流程以及 veth1b 的 RX 流程将被触发,从而将数据包再次送往协议栈进行处理。而当数据包的目的地位于宿主机外部时,由于其目标 MAC 地址为宿主机自身接口,因此 docker0 先将数据包送往主机协议栈,在经过 NAT 等处理后,触发 eth0 的 TX 流程发送数据包。