其他分享
首页 > 其他分享> > 虚拟化技术实现 — KVM 的 I/O 虚拟化

虚拟化技术实现 — KVM 的 I/O 虚拟化

作者:互联网

目录

文章目录

前文列表

虚拟化技术实现 — 虚拟化技术发展编年史
虚拟化技术实现 — QEMU-KVM
虚拟化技术实现 — KVM 的 CPU 虚拟化
虚拟化技术实现 — KVM 的内存虚拟化

VirtIO

VirtIO 最初由 Rusty Russell 开发,他当时的目的是为了支持自己的虚拟化解决方案 lguest,后来通过开源的方式将其延伸至 KVM、QEMU、Xen 和 VMware,并在 QEMU-KVM 中成为主流。

VirtIO 是一种半虚拟化的 I/O 解决方案,作为半虚拟化类型 Hypervisor(e.g. KVM)的一组通用型 I/O 设备的抽象。其实现了一套 Guest Application 与 Hypervisor 之间进行 I/O 交互的通信框架和编程接口,减少跨平台所带来的兼容性问题,也大大的提升了驱动程序的开发效率。

为什么需要 VirtIO?

下图为在完全虚拟化的解决方案中,VM 访问硬件设备的路径模型:
在这里插入图片描述

  1. Guest(客户机)的设备驱动程序发起 I/O 请求操作请求。
  2. KVM 内核模块中的 I/O 操作捕获代码拦截这次 I/O 请求。
  3. KVM 经过加工处理后将本次 I/O 请求的信息放到 I/O 共享页(Sharing Page),并通知用户空间的 QEMU 程序。
  4. QEMU 程序获得 I/O 操作的具体信息之后,交由硬件模拟代码来模拟出本次 I/O 操作。(注:模拟设备可能会使用物理的设备,或者使用纯软件来模拟。)
  5. I/O 操作完成之后,QEMU 将结果放回 I/O 共享页,并通知 KVM 内核模块中的 I/O 操作捕获代码。
  6. KVM 模块的捕获代码读取 I/O 共享页中的操作结果,并把结果放回 Guest。

可见,在完全虚拟化 I/O 模式中,Hypervisor 必须为 Guest 模拟出完整的设备硬件,它是在会话的最低级别进行模拟的,例如:网络驱动程序。尽管在该抽象中的模拟很干净且完整,但它同时也是最低效、最复杂的。在整个 I/O 流程中,Guest 作为 QEMU 的一个 Thread 在等待 I/O 时可能被阻塞(Block)住。另外,当 Guest 通过 DMA 访问大块内存时,QEMU 模拟程序不会把操作结果放到 I/O 共享页中,而是通过内存映射的方式将结果直接写到 Guest 的内存中去,然后通过 KVM 告诉Guest 的 DMA 操作完成。

优点:

  1. 不用修改 GuestOS,使用原生的设备驱动。
  2. 可以模拟一些老式经典设备,解决因为手头没有足够设备而引入的调试开发问题。

缺点:
3. I/O 路径长,依赖 KVM 和 QEMU 来做中间的信息处理。
4. 多次进行数据拷贝。
5. Guest 和 Host 的内核态和用户态多次进行上下文切换(Context Switch)。

可见完全虚拟化方案最大的问题就是性能低下,VirtIO 就是通过实现半虚拟化的思路来解决了这一问题。在半虚拟化 I/O 模式中,GuestOS 和 Hypervisor 共同合作来完成模拟的模拟,是整个流程更加高效。但相对的,Guest 需要知道自己是一台虚拟机,所以就需要对 GuestOS进行修改(安装非原生驱动程序)。

在这里插入图片描述
在这里插入图片描述

优势:

  1. 标准化:VirtIO 实现了统一的设备接口,VirtIO 以及 virtio-ring 完成标准传输层队列的接口,GuestOS 内核协议栈上层可以对接各种类型设备的,如:blk、net、pci、scsi 等。
  2. 环形队列批量处理 I/O 请求。
  3. 优化内核态与用户态频繁切换、Guest 和 Host 陷入陷出带来的性能开销。

缺点:Guest 需要安装 VirtIO 前端驱动程序。

为了让半虚拟化技术的缺点不那么明显,VirtIO 明智的走上了开源之路,通过构建标准而统一的生态来争取最小化的兼容性排斥问题,反而让其逆转成为了一种优势。

VirtIO 使得 GuestOS 能够实现一组通用的接口,来完成前端(GuestOS VirtIO 内核驱动)和后端(运行在 Hypervisor 中的设备模拟程序)之间的通信交互,并且后端程序并不需要是通用的,因为它们只实现前端所需的行为。通过这种解耦的方式使得 GuestOS 具备了高性能 I/O 的同时也兼具了一定的跨平台特性。

例如,虚拟机的存储设备由 QEMU 模拟,可以分为几个部分:

在 OpenStack 环境中,我们经常会考虑制作安装了 VirtIO 驱动程序的 Guest 镜像。

在这里插入图片描述

VirtIO 的架构

VirtIO 是一种半虚拟化技术,配合前端驱动,虚拟化设备完全可以采用全新的事件通知和数据传递机制进而大幅提升性能,例如:在 virtio-blk 磁盘中,采用 io_event_fd、中断注入方式进行前端与后端之间的通知交互,并通过 IO 环(vring)进行数据的共享。

在这里插入图片描述

VirtIO 的架构可以分为四层:

  1. (驱动层)virtio-net front-end(Driver):运行在 Guest 中的各种 I/O 设备的驱动程序模块。例如:virtio-net、virtio-blk。

  2. (控制平面通信层)virtio(e.g. virtio-pci):用于 Host 与 Guest 之间进行协商交换,以建立/关闭数据平面。常见的协议有:PCI、PCIe、vhost、vhost-user。本质是虚拟队列接口,作为前、后端通信的桥梁(数据结构、notify 等通信机制)。例如:virtio-net 使用两个虚拟队列(接受、发送),virtio-blk 使用一个虚拟队列。

  3. (数据平面通信层)Transport(virtio-ring):用于 Host 与 Guest 之间进行数据交换。实现了两个环形缓冲区,实现了具体的通信机制和数据流交换。

  4. (设备层)virtio backend(Device):运行在后端 Hypervisor(e.g. QEMU)处理程序模块,或直接就是一个硬件设备。

NOTE:通信层之所以分开控制平面和数据平面是因为两者的侧重不同,控制平面追求尽可能的灵活以兼容不用的设备和厂商,而数据平面则追求由更高的转发效率以快速的交换数据包。

VirtIO 的实现原理

VirtIO 利用了 Guest 可以与 Host 共享内存的特性,作为 I/O 半虚拟化实现的底层支撑。其实现基于 devices 和 drivers 的软件架构,Hypervisor 通过多种方式将 devices 暴露给 Guest,对于 Guest 而言,这个 devices 就像是物理设备一样。

Guest 使用 VirtIO devices 最典型的方式是通过 PCI/PCIe 协议,PCI/PCIe 是 QEMU 和 Linux 中成熟且支持良好的总线协议。在物理环境中,PCI/PCIe 硬件设备会使用特定的物理内存地址范围,设备的驱动程序可以通过访问该内存范围来读取或写入设备的寄存器,也可以通过特殊的处理器指令来暴露其配置空间(Configuration Space)。基于这个原理,在虚拟化环境中,Hypevisor 可以通过捕获对该内存范围的访问并执行设备仿真。VirtIO 规范还定义了 PCI 配置空间的布局,因此实现起来非常简单。

我们假设 Linux 类型的 GuestOS Kernel 已经加载了 VirtIO 驱动,当 Guest 启动时 Linux 操作系统会自动完成 PCI/PCIe 设备的枚举,在此过程中,VirtIO Driver(virtio-pci)会使用 PCI Vendor ID 和 PCI Device ID 来标识一个 PCI/PCIe 设备,Kernel 再通过这些标识来知道使用什么驱动程序来处理这些设备。

可见,Virtio Driver 必须能够分配 Hypervisor 和 devices 都可以进行读/写入的内存区域,即:共享内存。我们将数据平面称为使用这些内存空间的数据通信部分,并在控制平面去设定它们。

VirtIO 的前后端通知机制

在这里插入图片描述

VirtIO 标准将其对于队列的抽象称为 Virtqueue,Vring 即是 Virtqueue 的具体实现。一个 Virtqueue 由一个 Available Ring 和 Used Ring 组成。前者用于前端向后端发送数据,而后者反之。而在 VirtIO 网络中的 TX/RX Queue 均由一个 Virtqueue 实现。

Virtqueues 是用于批量传输数据的机制,每个设备可以有 0 到多个 Virtqueues,它由 tuest 分配的缓冲队列组成,Host 可以通过读取或写入来与之交互。另外,VirtIO 规范还定义了双向通知:

在 PCI 场景中,Guest 通过写入指定的内存地址来发送 Available buffer notification。数据平面的通信(RX/TX)是通过专用的队列完成的。可以为每个 Guest 分配若干个 vCPU,每个 CPU 都可以创建 RX/TX 队列,如下图(简化所需,移除了控制平面):
在这里插入图片描述

这里再详细的描述下,当两个 Queue 都需要虚拟机填充 Buffer,ReceiveQueue 需要客户机的前端驱动提前填充分配好的空 Buffer,然后记录到 AvailRing,并在恰当的时机通知后端设备,当外部网络有数据包到达时,QEMU 后端就从 AvailRing 中获取一个 Buffer,然后填充数据,完事后记录 Buffer Head Index 到 UsedRing,最后在恰当的时机通知客户机(向客户机注入中断)驱动,客户机接收到信号便知道有数据包到达,这里只需要从 UsedRing 中获取到 Index,然后取 Data 数组的第 i 个元素即可。因为在客户机填充 Buffer 的时候把逻辑 Buffer 的指针保存在 Data 数组中了。

而 SendQueue 同样需要客户机去填充,只不过这里是当客户机需要发送数据包时,把数据包构造成逻辑 Buffer,然后填充到 Send Queue,并在恰当的时机通知 QEMU 后端,后端收到通知就知道那个队列有请求到达,如果当前没有处理其他数据包就着手处理这个数据包。具体就同样是从 AvailRing 中取出 Buffer Head Index,然后从描述符表中 Get 到 Buffer,这时就需要从 Buffer 中 Copy 数据了,因为要把数据包从 Host 发送出去,然后更新 UsedRing。最后同样要在恰当的时机通知客户机。注意这里客户机同样需要从 UsedRing 中 Get Index,但是这里主要是用于 Delay Notify,因为数据包由客户机构造,其占用的 Buffer 并不能重复使用,只是每次有数据包就把其构造成 Buffer 而已。

以上便是基本的使用 SendQueue 和 Receive 的原理。

IPC 技术

在连接 VirtIO 的具体实现之前,需要先了解一下 IPC 技术。IPC(Inter-Process Communication),即进程间通信技术,提供了各种进程间通信的方法。VirtIO 使用了一下几种类型 IPC 技术:

后两种 IPC 技术都公开了通信中的每个进程的文件描述符,使用 fcntl() 调用对该文件描述符执行不同的操作,它们都是非阻塞的,因此如果没有要读取的内容则立即返回。使用 ioctl() 调用遵循同样的模式,但是只实现了对特定的设备操作,如:发送命令。

VirtIO 的网络实现

virtio-net 驱动与设备

VirtIO 网络设备是一种虚拟的以太网卡,支持多队列的网络包收发。前端即是 virtio-net driver(网卡驱动),而后端的实现多种多样,下图中的后端为 QEMU 的实现版本,也是最原始的 virtio-net device(网卡设备,e.g. Tap 设备)。virtio-net 是最原始的 VirtIO 网络实现。

在这里插入图片描述
我们知道,所有的 I/O 通信层都有数据平面(virtio-ring)与控制平面(virtio-pci)之分。从代码上看,VirtIO 的代码主要有两个部分构成:

  1. QEMU 代码块:VirtIO 设备的模拟就是通过 QEMU 完成的,QEMU 代码在虚拟机启动之前,即为其创建虚拟设备。
  2. 内核驱动程序:虚拟机启动后检测到设备,调用内核的 VirtIO 设备驱动程序来加载这个设备。

在这里插入图片描述

对于 VirtIO 来说,通过 PCI 传输协议实现的 VirtIO 控制平面正是为了确保 Vring 能够用于前后端的正常通信,并且配置好自定义的设备特性。而数据平面正是使用这些通过共享内存实现的 Vring 来实现虚拟机与主机之间的通信。

举例来说,当 virtio-net driver 发送网络数据包时,会将数据放置于 Available Ring 中之后触发一次通知(Notification)。这时 QEMU 会接管控制,将此网络包传递到 virtio-net device(Tap 设备)。接着 QEMU 将数据放于 Used Ring 中,并发出一次通知,这次通知会触发虚拟中断的注入。虚拟机收到这个中断后,就会到 Used Ring 中取得后端已经放置的数据。至此一次发送操作就完成了。接收网络数据包的行为也是类似,只不过这次 virtio-net driver 是将空的 Buffer 放置于队列之中,以便后端将收到的数据填充完成而已。

为了发送数据包,前端驱动会向后端设备发送一个缓冲区,该缓冲区包含了元数据信息(e.g. 数据包所需的 offload)和要传输的数据包。这些缓冲区由驱动管理,并由设备映射。由于 Hypervisor 可以访问 Guest 的所有内存,因此它能够找到缓冲区并对其进行读写。另外,由于 Tap 设备支持 TX/RX 的多队列,所以空缓冲区被放置在 N 个虚拟队列中(virtqueues)中以接收数据包,而传出的数据包则被排入另外 N 个虚拟队列中以进行传输。其中的一个虚拟队列用于数据平面的驱动程序和设备间进行通信,完成比如:控制高级过滤功能,设定 MAC 地址或设定活动队列数等任务。

在这里插入图片描述
流程图显示了 virtio-net device 配置以及使用 virtio-net driver 发送数据包的流程,驱动通过 PCI 协议与设备进行通信,填充要发送的数据包后,驱动触发 “可用缓冲区通知”,将控制权交给 QEMU,以便它可以通过 Tap 设备发送数据包。

然后,QEMU 通知 Guest 该缓冲区操作(读/写)已完成,将数据放入虚拟队列中并发送 “已使用的通知”,从而触发 Guest vCPU 的中断并读取数据包。

接收数据包的过程类似于发送数据包的过程。唯一的区别是,在这种情况下驱动将空缓冲区预先分配给设备使用,以便设备可以将传入的数据写入其中。

vhost-net 处于内核态的后端

前面一种由 QEMU 实现的 virtio-net device 带来的网络性能并不如意,究其原因还是因为频繁的上下文切换,低效的数据拷贝、线程间同步等问题。于是,社区在 Linux Kernel 为 virtio-net device 后端实现了一个新的数据面,名为 vhost-net。
在这里插入图片描述

随之而来的是一套新的 vhost 协议。vhost 协议允许 Hypervisor 将 VirtIO 的数据面(virtio-ring)offload 到另一个组件上,从而更有效地执行数据转发。这个组件正是 vhost-net。

使用 vhost 协议,主服务器将以下配置信息发送到处理程序(vhost-net):

完成 vhost-net 的配置后,Hypervisor 将不再负责数据包处理(对虚拟队列的读/写操作)。取而代之的是数据平面将完全 offload 到 vhost-net,vhost-net 可以直接访问 Virtqueues 的内存区域,以及直接与 Guest 之间进行发送和接收通知。

在这套实现中,vhost 消息可以在任何 Host-Local 传输协议(e.g. 字符设备或 UNIX Socket)中交换,由 Hypervisor 作为协议的领导者,充当服务端或客户端。QEMU 和 vhost-net 内核驱动使用 ioctl 来交换 vhost 消息,并使用 eventfd 来实现前后端之间的通知交互。

vhost-net 的本质是一个 Host Kernel Driver。当 vhost-net 被内核加载后,它会暴露一个字符设备在 /dev/vhost-net。QEMU 进程会打开并初始化这个字符设备,并调用 ioctl 来与 vhost-net 进行控制面通信,其内容包含 VirtIO 的特性协商、将虚拟机内存映射传递给 vhost-net 等等。

当 QEMU 在 vhost-net 模式下启动时,它首先打开 /dev/vhost-net 并使用几个 ioctl 调用来初始化 vhost-net 实例,将 QEMU 进程与 vhost-net 实例相关联,为 VirtIO 的功能协商做好准备,并且将 Guest 的物理内存映射传递给 vhost-net 驱动程序。

在初始化过程中,vhost-net 会创建一个内核线程,名为 vhost-$pid(pid 是 QEMU 进程 PID)。这个线程称为 “vhost worker thread”,用于处理 I/O 事件。vhost worker thread 会轮询驱动通知或 Tap 事件,并转发数据。

QEMU 分配了一个 eventfd 并将其注册到 vhost 和 KVM 中,以实现通知旁路。vhost-$pid 内核线程会轮询它,并且当 Guest 写入特定的地址时,KVM 会对其进行写入,这种机制称为 ioeventfd。这样对特定 Guest 内存地址的简单读/写操作就不需要经过昂贵的 QEMU 进程唤醒了,并且可以直接路由到 vhost worker thread,同时也具有了异步的优势,无需停止 vCPU(因此无需立即进行上下文切换)。

另一方面,QEMU 分配另一个 eventfd 并将其再次注册到 KVM 和 vhost,以直接进行 vCPU 中断注入,这种机制称为 irqfd。它允许主机中的任何进程通过对其进行写入来将 vCPU 中断注入 Guest,也同样具有异步和无需停止 vCPU 的优势。

对比最原始的 virtio-net 实现,控制平面在原有的基础上转变为 vhost 协议定义的 ioctl 操作(对于前端而言仍是通过 PCI 传输层协议暴露的接口),基于共享内存实现的 Vring 转变为 virtio-net 与 vhost-net 共享(内存),数据平面的另一边转变为 vhost-net,并且前后端通知方式也转为基于 eventfd 的实现。

如上图所示,可以注意到 vhost-net 仍然通过读写 Tap 设备来与外界进行数据包交换,并引入了 OvS 虚拟交换机解决方案来实现虚拟网卡与外部的通信。

在这里插入图片描述

vhost-user 使用 DPDK 加速的后端

DPDK 社区一直致力于加速数据中心的网络数据平面,而 VirtIO 网络作为当今云环境下数据平面必不可少的一环,自然是 DPDK 进攻的方向。而 vhost-user 就是结合了 DPDK 各方面优化技术而得到的用户态 virtio-net device。这些优化技术包括:CPU 亲和性,大业内存,轮询模式驱动等。除了 vhost-user,DPDK 还有自己的 VirtIO PMD 作为高性能的前端,本文暂不展开。

在这里插入图片描述
基于 vhost 协议,DPDK 设计了一套新的用户态协议,名为 vhost-user 协议,这套协议允许 QEMU 将 virtio-net device 的网络包处理 offload 到 DPDK APP 中(e.g. OVS-DPDK)。

vhost-user 协议和 vhost 协议最大的区别其实就是通信信道的区别。vhost 协议通过对 vhost-net 字符设备进行 ioctl 实现,而 vhost-user 协议则通过 UNIX Socket 进行实现。通过 UNIX Socket,vhost-user 协议允许 QEMU 通过以下操作来配置数据平面的 offload:

OVS-DPDK 一直以来就对 vhost-user 提供了支持,可以通过在 OVS-DPDK 上创建 vhost-user 端口来使用这种高效的用户态 virtio-net device。

vDPA 使用硬件加速数据面

VirtIO 作为一种半虚拟化的 I/O 解决方案,其性能肯定不如 Pass-through(直通)设备(e.g. SR-IOV pNIC)。后者的优点在于数据平面是在虚拟机与硬件之间直通的,几乎不需要主机的干预。而 vDPA(vhost Data Path Acceleration)就是一种让 VirtIO 数据平面可以摆脱主机干预束缚的解决方案。

在这里插入图片描述

从图中可以看到 VirtIO 的控制平面仍需要 vDPA driver 进行传递,也就是说 QEMU 或者虚拟机仍然使用原先的控制平面协议作为接口,而这些控制信息被直接传递到硬件中,硬件会通过这些信息配置好数据平面。而在数据平面上,经过配置后的数据平面可以在虚拟机和网卡之间直通。鉴于现在后端的数据处理其实完全在硬件中,原先的前后端通知方式也可以几乎完全规避主机的干预。

以中断为例,原先中断必须由主机处理,主机通过软件交换机(vSwitch)得知中断的目的地之后,将虚拟中断注入到虚拟机中。而在 vDPA 方案中,网卡可以直接将中断发送到虚拟机中。总体来看,vDPA 的数据平面与 SR-IOV 设备直通的数据平面非常接近,并且在性能数据上也能达到后者的水准。更重要的是 vDPA 框架保有了 VirtIO 这套标准的接口,使云服务提供商在不改变 VirtIO 接口的前提下,得到更高的性能。

需要注意的是,vDPA 框架中利用到的硬件必须至少支持 virtio-ring 的标准,否则可想而知,硬件是无法与前端进行正确通信的。另外,原先软件交换机提供的交换功能,也转而在硬件中实现。这是一种更加彻底的数据面 offload 实现。

注:SR-IOV vs VirtIO-vDPA(http://wechat.mellanox.net.cn/PDF.aspx?id=186)。

标签:VirtIO,Guest,虚拟化,KVM,技术,vhost,virtio,net,QEMU
来源: https://blog.csdn.net/Jmilk/article/details/105899307