StratoVirt中MicroVM启动过程
0x01 microvm vs 标准vm
1.1 什么是MicroVM微虚拟机?
微虚拟机正是相对于标准虚拟机而言的。标准虚拟机是模拟真正的物理主机,乃至PC,所以它会拥有完整的外设(键盘、USB、串口、PCI设备、显示器等)。在云原生技术兴起之前,AWS已经在布局FaaS(函数即服务),也即serverless业务,AWS为了实现强隔离不同的函数实例,同时支持快速、低消耗启动多个实例,推出了Firecracker的微虚拟机技术。
microvm与传统的Qemu/KVM虚拟机的区别是,它只实现了用于服务端计算服务所需要的最小硬件集。按照文章[https://aws.amazon.com/cn/blogs/china/in-depth-analysis-of-aws-firecracker/]中所描述的,Firecracker只实现了虚拟网络、虚拟磁盘和虚拟sock、串口、和单个键的键盘。Firecracker的MicroVM可以在微秒级别内启动,而且内存底躁小于5MB(虚拟机运行时的需要总内存减去虚拟机配置内存)。以上两个参数都是传统虚拟机不能比拟的。
1.2 MicroVM为什么启动快?
传统虚拟机的外设比较多,外设在启动时都需要内核的处理,如加载驱动、硬件初始化等;存在多个网卡时,还需要为每个网卡配置网络;虚拟机内的操作系统没有经过专门处理,所以要启动很多服务。而MicroVM直接定制了虚拟机内核(Linux),最少的外设,抛弃传统的BIOS加载启动过程,所以它可以快速启动。
与Firecracker对应的国内项目就是openEuler社区的Stratovirt项目了。除了支持MicroVM,Stratovirt还支持标准虚拟机。对其系统的介绍见Rust社区文章 华为 | 基于Rust的下一代虚拟化平台-StratoVirt
下面跟随实例去探索下MicroVM启动过程,领略下其实现过程。
0x02 Stratovirt中MicroVM的启动过程
如下,我们按照文档中描述的步骤启动一个openEuler的MicroVM。
stratovirt -machine type=microvm -kernel /home/ubuntu/vmlinux.bin -smp 1 -m 1024 -append 'console=ttyS0 pci=off reboot=k quiet panic=1 root=/dev/vda' -drive file=/home/ubuntu/rootfs.img,id=rootfs,readonly=off,direct=off -device virtio-blk-device,drive=rootfs -qmp unix:/home/ubuntu/stratovirt.sock,server,nowait -serial stdio
逐个解释下参数的含义。
- -machine是用来指定虚拟机的属性的。其中的type=microvm,指明我们要启动的是一个MicroVM。
- -kernel用来指定内核二进制文件的。这个文件会由Stratvirt直接加载到虚拟机内存。
- -smp 1 指定虚拟机的核心数为1。
- -m 1024 指定的是虚拟机的内存,默认大小为MB。
- –append 是传递给内核的参数。
- -drive 指定根分区。内核启动后会挂载此分区,开启shell。
- -device 指定虚拟设备列表。
- -qmp qmp的监听socket地址,qmp是Qemu支持的虚拟机管理协议,Stratovirt兼容它。
- -serial 指定串口是当前进程的标准输入。
熟悉Qemu的同学可以看出,这里面没有指定BIOS,是因为MicroVM不需要。
对比传统虚拟机,我们可以提出以下疑问:
Q1. 没有BIOS和GRUB引导,X86下如何从实模式跳转到保护模式呢?
Q2. 没有引导过程,内核是如何加载到内存的?
带着疑问去学习是个好习惯,我们就带着上面3个问题,看下StratoVirt代码和设计中是如何处理的。第一次过,主要是为了理清整体流程,不会拘泥于细节。
先根据自己之前的学习OS时写小操作系统myos的经验解答一下上面2个问题:
有GRUB时,BIOS只是加载了GRUB,内核的加载由GRUB完成;而guest机运行前的内存由VMM管理,可以直接将内核加载到guest机内存中。X86中内核的实模式启动地址是_start函数,初步猜测“只要将内核的起始位置加载到vcpu启动后访问的地址就行了”。同样,append的参数只要放到guest机存的指定位置,内核启动时就能找到它们了。
2.1 整体流程
入口位为src/main.rs中。目前只看x86_64的。但没有找到main
函数,根据经验猜测应该是quick_main!
宏,排查了main.rs中有引入宏的两个库,发现是error-chain
,这个宏会自己打印错误日志。比较方便。找到入口是run函数。
run之后是调用real_main,真正的main函数。调用之前,要根据命令行参数,构建出虚拟机的配置信息。保存在vm_config中。流程图如下。
通过流程图并不能看出具体做了什么还要展开看。按照经验来判断,新建虚拟机实例
时应该进行了一些操作,同时MachineOps的realize过程
中也会有些操作。
2.2 新建虚拟机实例
machine/src/micro_vm/mod.rs
中定义的LightMachine::new。
write_gdt_table
- 创建内存地址空间。AddressSpace。
- 创建IO内存地址空间。只有X86需要。
- 初始化free_irqs,mmio_region范围。
- 创建sysbus 系统总线。
- 设置vm_state
- 创建power_button 电源按键
新建虚拟机只是初始化一些对象,并没有看到对应的问题的答案。那么一定是在realize中了。
2.3 realize过程
realize的调用涉及到rust的一些知识。Trait T被struct S实现时,可以通过s.m方式调用,也可以通过T::m(s)来调用。所以代码中的MachineOps::realize
其实就是调用的vm实例的realize方法,这里不能使用前一种方式调用,因为首个参数是&Arc<Mutex<Self>>
类型的,也就是说不是&self类型,之所以用这种类型,是因为vm实例需要在不同线程之间共享,所以使用了Arc
原子引用计数和Mutex
锁。
传入的参数下面说下其具体做了什么操作:
- init_memory 初始化内存。
- 创建vCPU fd列表和初始化中断控制器。AARCH64和X86相同顺序不同。
- 创建各种device,下面会细说具体有什么设备。
load_boot_source
加载启动文件。init_vcpu
初始化vCPU,传入的有boot_config,这里应该是要加载内核了。- 针对AARC64,写入FDT(设备树表,Linux下Arm的设备管理方式)。
- 注册电源事件。
猜测内核的加载应该是在步骤4中。需要在看到内存是如何初始化的之后,再看第4步。
2.3.1 初始化内存
一开始没找到init_memory函数的定义,后来想到rust的trait中可以定义函数实现。
整个函数分以下步骤。
- 为sys_mem注册KVMMemory监听回调。处理添加移除region,添加移除ioeventfd。这两个概念具体是什么,暂时还不清楚。
- x86下添加sys_io内存的监听回调。
- 调用mmap,申请内存并初始化。
- 将sys_mem与虚拟机迁移关联。
重点看下第3步。arch_ram_ranges函数中,根据MEM_LAYOUT中定义的布局,为guest申请内存。0-4GB中间一段内存不能给guest机使用,是由VMM也就是StratoVirt来管理的。create_host_mmaps真正通过mmap分配内存,返回分配的内存区域列表。
初始化完成后,guest机内存已经准备好了。可以向其中写内容了。看下init_vcput和load_boot_source中是如何处理。
2.3.2 load_boot_source 加载引导源
其中调用load_linux加载内核。看到一个参数:prot64_mode=true
,表示直接从64位保护模式启动。
load_linux的流程如下:
- 调用load_kernel_image,最终调用
load_image
加载内核到sys_mem,非压缩内核
加载到0x100_0000的位置。也是在这里设置了boot_ip配置。sp固定是0x0000_8ff0,同时也返回了一个RealModeKernelHeader实例,只是默认参数没有使用。 - 之后惊奇地看到有设置page_table和gdt,有看过x86_64中内核实地址模式下的启动流程,实地址模式在进入保护模式前也做了这些工作,至此不用细看,也明白了核心操作是如下过程。
microVM正是通过VMM执行一些启动过程中要做的处理,提前准备内存和寄存器,直接在启动内核时进入保护模式,节省了内核做这些处理的时间,最大限度地提升内核的启动速度。至此也解答了问题Q1 Q2。
Q1 内核没有经过实模式,直接进入保护模式;
Q2 内核在load_linux时,以读取文件的方式加载到sys_mem中。
2.3.3 初始化vCPU
传入的cpu列表是fd列表,底层的vCPU,会在此过程中转换为封装的CPU实例。
- 转换为CPU实例列表。
- 调用各CPU的realize方法,底层是调用ArchCPU的set_boot_config方法,最终是设置了各个寄存器,上述的boot_ip被赋值给rip。
设置寄存器时,并没有通过ioctl将寄存器设置到vCPU中,只是保存到了ArchCPU实例中。以X86为例,设置eip等指令寄存器,同时设置了一系列的段寄存器。
Q3. 疑惑点是为什么没直接设置到内核的vCPU中,什么时候设置的?
既然还没有看到,还要继续看。下面唯一可能就是vCPU的启动过程了。
2.4 启动vCPU的过程
入口是vm实例的run方法。
vm.run
-> vm.vm_start
-> CPU::start
-> CPUThreadWorker.handle // 每个CPU一个线程
-> thread_cpu.reset // 重置CPU
-> arch_cpu.reset_vcpu
-> 真正设置sregs和regs到内核vCPU中。
-> arch_cpu.kvm_vcpu_exec
-> 启动vCPU。
以上的代码流程理清了init_vcpu时设置的寄存器是什么时机设置到内核vCPU的。即回答了Q3,在vCPU启动前完成的。
3 总结
- 以X86为例,StratoVirt中通过提前加载内核到guest机内存中,同时为内核准备好GDT、各个寄存器。vCPU启动时,
内核直接进入了保护模式,通过这种方式,最大限度地提高了虚拟机的开机效率。 - 通过梳理这个流程,也学习到了
rust
的一些语言特性:trait中可以定义默认函数;trait方法的灵活调用方式等。