KVM QEMU运行硬盘中的系统

KVM QEMU运行硬盘中的系统

最近老师购入一台工作站,老师要做一些分子动力学的仿真计算和深度学习,同时老师要我做这台机器的管理员,我自己可以在上面做一些我比较有兴趣的工作,比如灵巧手和机器人相关的仿真强化学习,或者一些用于当前项目的深度学习工作。因为老师用的 Ansys 和我使用的 SolidWorksAutoDesk 等软件只有Windows版本,而做分子动力学的 VASP 又只有 Linux 版本,所以购买的工作站就安装了双系统。同时,为了能够尽可能地使机器长时间开机,便于远程连接或长时间计算,我决定在Linux中通过 KVM 和 QEMU 启动硬盘中的Windows, 以此实现Windows和Linux两个系统的软件能够同时使用。

设备配置

  • CPU:单颗 Intel Xeon Scalable Platinum 8488C CPU,最新 Products
    formerly Sapphire Rapids 架构,共48个计算物理核心,96个线程
  • RAM:三星服务器 128G DDR5 ECC 5600 GHZRegistered Shared Memory
  • GPU:NVIDIA RTX5090 32G显存
  • 硬盘:1块1TB,一块8TB

双系统安装

工作站发来前,已经由厂家安装了 Windows10 和提前列出的Windows平台软件,所以我们只需要安装 Linux 即可。本来我的计划是安装 Ubuntu22,但是最终还是选择了更加熟悉的 ArchLinux。因为厂家已经安装了Windows平台的一些软件,为了方便,我们就不删掉他们,而是直接在QEMU里面加载Windows。

KVM

基于内核的虚拟机(英语:Kernel-based Virtual Machine,缩写为KVM)是一种用于Linux内核中的虚拟化基础设施,可将Linux内核转化为一个虚拟机监视器。KVM于2007年2月5日被并入Linux 2.6.20核心中。KVM需要支持硬件虚拟化拓展特性的处理器。

详细介绍可以参考 Wikipedia/KVM

Linux系统启动后,查看设备可以看到有一个 kvm 设备:

ls kvm device

为了能够让当前用户使用 kvm,需要将当前用户添加进 kvm

1
2
3
4
sudo usermod -aG kvm $(whoami)
# 注销后重新登入
id # 输出如下
uid=1000(username) gid=1000(username) groups=1000(username),967(docker),992(kvm),998(wheel)

QEMU

Installation

QEMU(Quick Emulator)是一款免费开源模拟器,由法布里斯·贝拉(Fabrice Bellard)等人编写。其与BochsPearPC类似,但拥有高速(配合KVM)、跨平台的特性。

详细介绍可查看 Wikipedia/QEMU

Arch Linux 安装 QEMU 命令:

1
2
paru extra/qemu-full -S # 完整安装
paru extra/edk2-ovmf -S # UEFI 固件,支持安全启动

直接安装全部软件包即可

Configuration

由于默认 QEMU 连接的是用户模式的机器,而我们把Windows运行在系统层级,以便所有用户均可访问,所以需要配置一下,让用户能够默认访问系统的 QEMU

1
2
3
4
mkdir -pv ${HOME}/.config/libvirt/
cat >> ${HOME}/.config/libvirt/libvirt.conf << EOF
uri_default = "qemu:///system"
EOF

Libvirt

Installation

libvirt是一套用于管理硬件虚拟化开源API守护进程与管理工具。[3]此套组可用于管理KVMXenVMware ESXiQEMU及其他虚拟化技术。libvirt内置的API广泛用于云解决方案开发中的虚拟机监视器编排层(Orchestration Layer)。

安装命令:

1
2
paru extra/libvirt -S # 安装 libvirt, 此时已经可以使用
paru extra/virt-manager -S # 这是一个图形化的 libvirt 管理工具,在配置初期是需要的

Configuration

需要把用户添加进 libvirt 组,同时启动 libvirtd 系统服务:

1
2
sudo systemctl enable --now libvirtd # 启动系统服务
sudo usermod -aG libvirt $(whoami) # 把用户添加入 libvirt 组

只有配置的管理员用户需要添加到 libvirt 组,最终连接的其他普通用户不需要添加到该组。

准备工作

Windows 10 ISO

为了防止虚拟机出现问题,损坏Windows的启动,我们在虚拟机中不使用 Windows 的 EFI, 只使用Windows的C,D盘,并重新生成一个新的 EFI 用于在虚拟机中启动 Windows。

这个新的 EFI 需要使用 Windows 的一个工具生成,该工具在 Windows ISO 中有,所以我们从 Windows 网站下载一个 Windows 10 的启动镜像。

下载地址

download windows iso

硬盘线性阵列排布

介绍

由于Arch Linux 安装时,在 1T 的硬盘分出一个分区用来安装系统,从 8T 硬盘分出一个分区用来存储软件、数据和用户目录,而同一块设备只能有一方在使用,因此我们需要手动重新排列分区,以分离各个分区,防止两方共同使用同一设备。我们可以使用 losetupmdadm 重新排列硬盘分区成一个虚拟设备,并让虚拟机使用这个虚拟设备进行启动。

losetup命令用来设置循环设备,查看回环设备的状态。循环设备可把文件虚拟成区块设备,籍以模拟整个文件系统,让用户得以将其视为硬盘驱动器,光驱或软驱等设备,并挂入当作目录来使用。
原文链接:https://www.linuxcool.com/losetup

mdadm命令来自英文词组multiple devices admin的缩写,其功能是管理RAID设备。作为Linux系统下软RAID设备的管理神器,mdadm命令可以进行创建、调整、监控、删除等全套管理操作。
原文链接:https://www.linuxcool.com/mdadm


原始的硬盘排布为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
sudo fdisk -l /dev/nvme0n1 /dev/sda    
Disk /dev/nvme0n1: 931.51 GiB, 1000204886016 bytes, 1953525168 sectors
Disk model: Samsung SSD 980 1TB
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 16384 bytes / 131072 bytes
Disklabel type: gpt
Disk identifier: 5E0BA9DE-7C92-478F-ADE5-F178A7B6506B

Device Start End Sectors Size Type
/dev/nvme0n1p1 2048 206847 204800 100M EFI System
/dev/nvme0n1p2 206848 239615 32768 16M Microsoft reserved
/dev/nvme0n1p3 239616 1322747224 1322507609 630.6G Microsoft basic data
/dev/nvme0n1p4 1951893504 1953521663 1628160 795M Windows recovery environment
/dev/nvme0n1p5 1322747904 1323747327 999424 488M EFI System
/dev/nvme0n1p6 1323747328 1325893631 2146304 1G EFI System
/dev/nvme0n1p7 1325893632 1951893503 625999872 298.5G Linux root (x86-64)

Partition table entries are not in disk order.


Disk /dev/sda: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk model: HGST HUS728T8TAL
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
Disklabel type: gpt
Disk identifier: DAB5F193-5F0A-4513-AE7C-FC6AC785D92D

Device Start End Sectors Size Type
/dev/sda1 34 32767 32734 16M Microsoft reserved
/dev/sda2 32768 4890632191 4890599424 2.3T Microsoft basic data
/dev/sda3 4890632192 11333083135 6442450944 3T Linux filesystem
/dev/sda4 11333083136 15628053134 4294969999 2T Linux filesystem

Partition 1 does not start on physical sector boundary.

其中 /dev/nvme0n1p1 是 Windows 实际的 EFI 分区,我们保留不动,防止操作失误而导致无法启动。/dev/nvme0n1p3 是Windows的 C盘,/dev/sda2 是 Windows 的D盘。

根据 GPT 分区规范,我们可以创建一个新的线性阵列作为虚拟设备

ideal linear layout

但是!!! 沟槽的厂家给 8T 硬盘第一个分区没有对齐扇区!!!

这种情况会导致后面分区工具自动计算比较麻烦,一般分区工具像是cfdisk会自动对齐扇区,导致我们线性阵列分区与实际分区的扇区错位,导致分区不可挂载。所以我们最终实际建立的线性阵列为

actual linear layout

图中,C盘D盘以外的区域均为我们自己创建的全新的扇区。

准备扇区

假设qemu虚拟机的名称为 windows10,为了方便管理,我们把所有相关文件放在机器的文件夹中。首先创建文件夹

1
2
3
4
mkdir -pv /etc/libvirt/hooks/qemu.d/windows10/
cd /etc/libvirt/hooks/qemu.d/windows10/
mkdir -pv md0 md1 # for /dev/md0 and /dev/md1
cd md0 # 本文只写了 /dev/md0, /dev/md1 同样的流程

有上图,我们可以计算 Windows 的 EFI 分区和 GPT 分区表保留的分区共101M,C盘后面也应该留1M保留分区,于是我们可以使用 dd 创建这些扇区:

1
2
3
sudo dd if=/dev/zero of=loop-efi0 bs=1M count=101 # 前面的
sudo dd if=/dev/zero of=loop-efi1 bs=1M count=1 # 后面的
ls -lah

输出应该为

1
2
3
4
5
6
7
8
9
10
11
101+0 records in
101+0 records out
105906176 bytes (106 MB, 101 MiB) copied, 0.0641618 s, 1.7 GB/s
1+0 records in
1+0 records out
1048576 bytes (1.0 MB, 1.0 MiB) copied, 0.00157278 s, 667 MB/s
total 102M
drwxr-xr-x 1 root root 36 Jun 20 01:04 .
drwxr-xr-x 1 root root 80 Jun 20 01:00 ..
-rw-r--r-- 1 root root 101M Jun 20 01:04 loop-efi0
-rw-r--r-- 1 root root 1.0M Jun 20 01:04 loop-efi1

创建启动线性阵列的脚本 start-md0.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
cat << EOF | sudo tee start-md0.sh > /dev/null
#!/usr/bin/env bash

WIN_PART=/dev/nvme0n1p3 # Windows C盘设备
EFI_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)" # 获取到这个脚本的路径
VDEV=/dev/md0 # 要创建的路径,应该符合 POSIX 规范,以数字结尾

# 检查是否已经运行了该脚本
if [[ -e \$VDEV ]]; then
echo "\$VDEV already exists" > /dev/kmsg 2>&1
exit 1
fi

# 检查 Windows 分区是否被mount,防止被两方同时使用
if mountpoint -q -- "\$WIN_PART"; then
echo "Unmounting \$WIN_PART..." > /dev/kmsg 2>&1
umount \$WIN_PART
fi

modprobe loop
modprobe linear
eval "EFI_DIR=\$EFI_DIR" # 立即解析路径变量
LOOP0=\$(losetup -f "\$EFI_DIR/loop-efi0" --show) # 1M Reserved
LOOP1=\$(losetup -f "\$EFI_DIR/loop-efi1" --show) # 1M reserved
mdadm --build --verbose \$VDEV --chunk=512 --level=linear --raid-devices=3 \$LOOP0 \$WIN_PART \$LOOP1
chown \$USER:disk \$VDEV
echo "\$LOOP0 \$LOOP1" > "\$EFI_DIR/.win10-loop-devices"
EOF

sudo chmod +x ./start-md0.sh # 加权限
sudo ./start-md0.sh # 测试
ls /dev/md*

输出应该是

1
2
mdadm: array /dev/md0 built and started.
/dev/md0

再创建一个脚本用来停止线性阵列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
cat << EOF | sudo tee stop-md0.sh > /dev/null
#!/usr/bin/env bash

EFI_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
VDEV=/dev/md0

# 停止 RAID 设备
if [[ -e "\$VDEV" ]]; then
mdadm --stop "\$VDEV"
else
echo "Device \$VDEV not found. Maybe it's already stopped."
fi

# 卸载 loop 设备
LOOP_DEV_FILE="\$EFI_DIR/.win10-loop-devices"
if [[ -f "\$LOOP_DEV_FILE" ]]; then
xargs -r losetup -d < "\$LOOP_DEV_FILE"
rm -f "\$LOOP_DEV_FILE"
else
echo "Loop device list \$LOOP_DEV_FILE not found."
fi
EOF

sudo chmod +x ./stop-md0.sh # 加权限
sudo ./stop-md0.sh # 测试
ls /dev/md* # 应该不会输出任何东西

输出应该是

1
2
mdadm: stopped /dev/md0
zsh: no matches found: /dev/md*

此时,我们需要参照 fdisk 输出的扇区信息,对 /dev/md0 进行分区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sudo parted /dev/md0
(parted) unit s # 单位换成扇区
(parted) mktable gpt # 创建 gpt 分区表
(parted) # mkpart primary 格式 起始 结束
(parted) mkpart primary fat32 2048 206847 # 取决于loop-efi0文件大小,2048 + 204800 - 1
(parted) mkpart primary ntfs 206848 1322714456 # 取决于Win10物理分区扇区数 206848 + 1322507609 - 1
(parted) set 1 boot on # 允许EFI分区启动
(parted) set 1 esp on # 设置 esp标签
(parted) set 2 msftdata on
(parted) name 1 EFI
(parted) name 2 Windows
(parted) quit

ls /dev/md0*

此时应该多出分区的设备

1
/dev/md0  /dev/md0p1  /dev/md0p2

此时确认一下分区是否正确。如果分的扇区正好对应了 Windows C盘的起始和结束扇区,那么不需格式化即可直接挂载。

1
2
3
sudo mount /dev/md0p2 /mnt
ls /mnt
sudo umount /mnt

应该是能够正常挂载读取的。如果挂载失败,大概率是扇区计算错误,重新使用 partedcfdisk 进行分区

格式化 EFI

1
2
paru core/dosfstools -S
sudo mkfs.msdos -F 32 -n EFI /dev/md0p1

按照上述步骤继续操作,可以继续为 D 盘创建线性阵列。完成后应该是这样 (我最终是把两块硬盘上保留分区也添加了进来,但实际上保留分区是不需要的)

1
2
ls /dev/md*
/dev/md0 /dev/md0p1 /dev/md0p2 /dev/md0p3 /dev/md0p4 /dev/md1 /dev/md1p1

启动 libvirt

打开 virt manager,在首选项打开 XML 编辑

virt manager

enable xml editing

创建虚拟机

使用 ISO 文件创建虚拟机,并配置适当的CPU与内存,不需要为虚拟机启用存储!! 因为我们直接使用了硬盘。

disable storage

最后在安装前自定义设置

customize before install

自定义设置

set uefi

新建存储,设置为我们创建的线性阵列。可以把两个阵列都添加上去。

add storage

之后就可以启动虚拟机了。进入虚拟机后,不要进行安装操作,否则有可能会覆盖系统安装!!!

enter iso

在这个界面使用 Shift + F10 打开 CMD

opened cmd

在 CMD 中输入命令

1
2
3
4
5
6
7
diskpart
DISKPART> list disk
DISKPART> select disk 0 # 选择/dev/md0在VM中对应的硬盘
DISKPART> list volume # 在分区列表中记下EFI分区序号
DISKPART> select volume 2 # 选择EFI分区
DISKPART> assign letter=Z # 为EFI分区分配驱动器号(Z:)
DISKPART> exit

diskaprt in cmd

如果这一步找不到正确的硬盘(Disk)或正确的卷(Volume),大概率还是扇区分配不正确,回去重新计算,并尝试挂载

最后将驱动器C:中的系统启动信息写入到驱动器Z:(EFI分区):

1
bcdboot C:\Windows /s Z: /f ALL /v

你需要观察上述 diskpart 中的 list volume 输出,根据分卷大小来确认Windows系统卷标。或者使用 dir 命令检查硬盘分区的内容,来确定 Windows 系统盘的分卷。

命令执行完成后,可以进入 Z: 检查内容,但是注意不要修改,否则要重新运行上述命令!

check EFI

之后就可以退出安装程序,关闭虚拟机,进入虚拟机详情,修改启动顺序。

set boot order

之后启动虚拟机,就能发现正是硬盘里安装的 Windows了

自动启停线性阵列

安装 libvirt qemu hook helper

这是个脚本,在 libvirt 启动、关闭虚拟机时自动运行 hook,安装命令:

1
paru aur/libvirt-hook-helper-git -S

Libvirt hook提供一种在libvirt服务某个生命周期执行特定脚本的能力,hook脚本放置在/etc/libvirt/hooks目录,关于VM管理的脚本入口文件是/etc/libvirt/hooks/qemu,其默认用法如下:

1
/etc/libvirt/hooks/qemu $vm_name $hook_name $sub_name $extra

更多信息请查阅:Libvirt文档。要使用这个hook,需要判断VM实例名称、hook名称、子动作名称等参数,颇为不便。我在这里使用VFIO-Tools Hook Helper对hook使用流程进行简化。Libvirt hook helper实际上是一个脚本,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env bash
#
# Author: SharkWipf
#
# Copy this file to /etc/libvirt/hooks, make sure it's called "qemu".
# After this file is installed, restart libvirt.
# From now on, you can easily add per-guest qemu hooks.
# Add your hooks in /etc/libvirt/hooks/qemu.d/vm_name/hook_name/state_name.
# For a list of available hooks, please refer to https://www.libvirt.org/hooks.html
#

GUEST_NAME="$1"
HOOK_NAME="$2"
STATE_NAME="$3"
MISC="${@:4}"

BASEDIR="$(dirname $0)"

HOOKPATH="$BASEDIR/qemu.d/$GUEST_NAME/$HOOK_NAME/$STATE_NAME"

set -e # If a script exits with an error, we should as well.

# check if it's a non-empty executable file
if [ -f "$HOOKPATH" ] && [ -s "$HOOKPATH" ] && [ -x "$HOOKPATH" ]; then
eval \"$HOOKPATH\" "$@"
elif [ -d "$HOOKPATH" ]; then
while read file; do
# check for null string
if [ ! -z "$file" ]; then
eval \"$file\" "$@"
fi
done <<< "$(find -L "$HOOKPATH" -maxdepth 1 -type f -executable -print;)"
fi

简单来说就是优化了hook脚本的管理方式,安装完hook helper后重启libvirtd服务,即可通过如下结构管理VM hook:

1
/etc/libvirt/hooks/qemu.d/$vm_name/$hook_name/$sub_name/*

例如名称为windows10的VM,其prepare hook、begin子动作要执行的脚本是setup.sh,则将脚本放在如下位置:

1
/etc/libvirt/hooks/qemu.d/win10/prepare/begin/setup.sh

Hook数量不限,类型不限定是shell脚本,在hashbbang中指定任何解释器均可。较为重要的几个hook类型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Before a VM is started, before resources are allocated:
/etc/libvirt/hooks/qemu.d/$vm_name/prepare/begin/*

# Before a VM is started, after resources are allocated:
/etc/libvirt/hooks/qemu.d/$vm_name/start/begin/*

# After a VM has started up:
/etc/libvirt/hooks/qemu.d/$vm_name/started/begin/*

# After a VM has shut down, before releasing its resources:
/etc/libvirt/hooks/qemu.d/$vm_name/stopped/end/*

# After a VM has shut down, after resources are released:
/etc/libvirt/hooks/qemu.d/$vm_name/release/end/*

以上述Windows10 VM为例,创建启动和停止hook,用来线性阵列启停操作自动化。

自动启停线性阵列 Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env bash
#
# Author: Neolux Lee (https://github.com/neoluxis)
#
# This file creates and distroys /dev/md0 and /dev/md1 for booting physical Windows drive.
#
echo "[HOOK] Triggered with args: $1 $2 $3" >> /tmp/hook.log
date >> /tmp/hook.log

VM_ACTION="$2/$3"
#SCRIPT=/etc/libvirt/hooks/qemu.d/win10
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if [[ "$VM_ACTION" == "prepare/begin" ]]; then
$SCRIPT/md0/start-md0.sh
$SCRIPT/md1/start-md1.sh
elif [[ "$VM_ACTION" == "release/end" ]]; then
$SCRIPT/md0/stop-md0.sh
$SCRIPT/md1/stop-md1.sh
fi

整理 hook 结构

将创建的所有hooks整理一下,利用软链接的形式存放到Win10 VM对应的生命周期目录中,最后得到如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
tree -ahl          
[ 72] .
├── [ 419] manage-vdisks.sh
├── [ 186] md0
│   ├── [1.0M] loop-efi0
│   ├── [1.0M] loop-efi1
│   ├── [100M] loop-winefi
│   ├── [795M] loop-winrec
│   ├── [ 16M] loop-winres
│   ├── [ 955] start-md0.sh
│   ├── [ 199] stop-md0.sh
│   └── [ 55] .win10-loop-devices
├── [ 120] md1
│   ├── [1.0M] loop-efi0
│   ├── [1.0M] loop-efi1
│   ├── [ 696] start-md1.sh
│   ├── [ 199] stop-md1.sh
│   └── [ 23] .win10-loop-devices
├── [ 10] prepare
│   └── [ 50] begin
│   ├── [ 22] 00-manage-vdisks.sh -> ../../manage-vdisks.sh
│   ├── [ 9] md0 -> ../../md0 [recursive, not followed]
│   └── [ 9] md1 -> ../../md1 [recursive, not followed]
└── [ 6] release
└── [ 50] end
├── [ 22] 00-manage-vdisks.sh -> ../../manage-vdisks.sh
├── [ 9] md0 -> ../../md0 [recursive, not followed]
└── [ 9] md1 -> ../../md1 [recursive, not followed]

11 directories, 16 files

完成后,就可以启动关闭虚拟机进行测试。

1
2
3
4
5
6
7
# 测试启动 
virsh start windows10
ls /dev/md*

# 测试关机
virsh shutdown windows10
ls /dev/md*