状态:草稿(2026-10-16),基于我们的代码 + 官方文档/源码 + 小玩具实际测量。尚未对黄金 VM 进行实际测量。 黄金磁盘虽然已经融入了代码和运维工具中,但在撰写本文时,集群中既没有应用黄金 PVC,也没有应用读取它的权限(通过只读查询确认)。因此,本文中的黄金 VM 运行情况是通过查阅 KubeVirt v1.9.0 和 CDI v1.66.0 的文档与源码确认的,相关数据则是在笔记本电脑上运行的小玩具和容器磁盘(基于相同原理)中测量的。编写规则:实测数据为基础,未能测量的数据标记为“未实测”,在文档中未找到的内容标记为“未确认”。
学生 VM 启动之前 4:从 PVC 视角的叠加
目录
- 1. 想要实现的目标
- 2. 原理:基础与写层
- 3. KubeVirt 的 ephemeral 卷:在 PVC 之上实现这一点
- 4. 从 PVC 的视角来看
- 5. 为什么不使用 CSI 克隆或快照
- 6. 会话结束时:仅写层消失
- 7. 代价与限制
- 参考
第 3 篇以两种途径的空白告终。容器磁盘虽然能在 20 秒内启动,但根目录有 2.4GiB;DataVolume 的根目录虽然很大,但每个 VM 都要重新拉取镜像,首次启动时间长达 195 秒。本文将探讨填补这一空白的第三条途径: 黄金 PVC + 写层。
1. 想要实现的目标
- 为每个学生分配 巨大的根目录(10Gi 或 20Gi)。防止像 kubeadm 这样大量占用根目录的实验因空间不足而失败。
- 快速启动。 每次创建 VM 时,不拉取或复制数 GB 的数据。
- 低空间消耗。 如果有三十名学生,则不创建三十个 10Gi 的副本。
- 会话结束后不留痕迹。 下一位学生可以获得一个干净的磁盘。
有一种历史悠久的方法可以同时实现这四个目标: 在一个只读基础之上,为每个人叠加一层只包含更改部分的轻量层。容器镜像的层、虚拟化的链接克隆(linked clone)以及 qcow2 的后备文件(backing file)都遵循相同的理念。
2. 原理:基础与写层
qcow2 磁盘镜像可以指向一个 后备文件。 qemu-img create -f qcow2 -b <바탕> -F raw <쓰기 층> 创建后,新镜像只会记录与基础 不同的部分 (qemu-img 文档)。此时,磁盘被划分为固定大小的块(cluster),规则只有两条:
- 读。 如果写层中有该块,则从写层读取;如果没有,则从基础读取(qcow2 格式规范:未分配的块从后备文件中读取)。
- 写。 首次修改的块会从基础中拉取到写层中并在那里重新分配和修改。基础永远不会被改变。
因此,写层 从空开始,并且只随着客体(guest)写入的块的大小而增长。 由于基础不会被修改,因此即使多个磁盘同时指向同一个基础,它们也不会相互干扰。
通过小玩具进行测量
由于笔记本电脑上没有 qemu-img(没有重新下载),我们用 Python 模拟了上述两条规则,并在相同条件下进行了测量。基础为 512MiB(随机字节),客体对随机位置进行 1,000 次 4KiB 的写入。这是“整体复制后写入”与“写入空的写层”各运行五次的中位数(第 0 轮作为预热已排除)。通过确认五轮中两种方式的磁盘整体哈希值完全相同,也验证了客体看到的磁盘是完全一样的。
| 块大小 64KiB | 块大小 2MiB | |
|---|---|---|
| 整体复制 (512MiB) | 0.217秒 | 0.151秒 |
| 创建空写层 | 0.0002秒 | 0.0002秒 |
| 1000次写入后的写层大小 | 59MiB | 504MiB (基础的 98%) |
环境:Apple M1 Max 笔记本电脑,内置 SSD,Python 3.12,仅使用标准库。代码和原始数据位于 docs/experiments/2026-10-16-cow-overlay-toy/ 中。
从这些数字中可以得出三个结论:
- 创建实际上是免费的。 无论磁盘大小如何,空的写层只需 0.2 毫秒。复制则与磁盘大小成正比。这里的 0.2 秒是因为在同一个 SSD 内复制且基础在文件缓存中,因此无法用这个数字来代表从网络另一端的存储中复制 10Gi 的时间(未实测)。粗略计算一下,在 1Gbps 的线路上,10GiB 理论上大约需要 86 秒。
- 写层的增长取决于“触及的块数”,而不是“写入的量”。 实际写入的数据为 3.9MiB,但由于触及了 945 个 64KiB 的块,导致大小达到了 59MiB(约 15 倍)。分散的小写入会按块大小导致体积膨胀。
- 当块很大且写入分散时,写层会变得和基础一样大。 在 2MiB 的块大小下,512MiB 的磁盘只有 256 个块,因此 1,000 次写入触及了几乎所有的块。写层之所以能够保持轻量,是建立在 客体只修改磁盘的一小部分这一假设之上的。如果是升级整个操作系统之类的实验,这个假设就会被打破。
在实际的 VM 中情况会怎样呢?检查了一个具有相同结构的容器磁盘 VM(用于运维,已运行 9 天),发现基础(约 596MiB)之上的写层约为 1.10GiB(实测,2026-10-14)。由于这是一个运行了 9 天、安装了软件包并积累了日志的 VM,其写入量远大于一个小时的实验会话。黄金 VM 在启动后以及实验结束后的写层大小目前处于 未实测状态。
3. KubeVirt 的 ephemeral 卷:在 PVC 之上实现这一点
KubeVirt 中已经存在一个能够基于 PVC 实现此架构的卷源。即用户指南中的 ephemeral 卷。总结如下:
- 网络卷 (PVC) 用作 只读基础,基础永不更改。
- 所有写操作都发送到 本地写层镜像。
- 写层在 VM 启动时创建,在 VM 停止或结束时丢弃。
- 目前基础仅支持 PVC。
TT Lab 后端创建的黄金 VM 的根卷只有这一行(backend/app/vms.py 中 vm_manifest).
volumes:
- name: root
ephemeral:
persistentVolumeClaim:
claimName: golden-ubuntu-24.04-10gi # OS 와 크기로 정해진 이름
readOnly: true # 빼지 않는다
跟踪 KubeVirt v1.9.0 源码可以发现,这一行代码执行了以下操作:
- 当 virt-controller 创建 virt-launcher Pod 时,将此 PVC 原封不动地(
readOnly复制为 Pod 卷,并附加另一个用于存放写层的空目录 (emptyDir)(pkg/virt-controller/services/rendervolumes.go). - 在 Pod 内部,PVC 挂载到
/var/run/kubevirt-private/vmi-disks/root/,基础是其中的disk.img(raw)。 - 启动时使用
qemu-img create -f qcow2 -b <바탕 disk.img> -F raw <쓰기 층 disk.qcow2>创建写层(pkg/ephemeral-disk/ephemeral-disk.go)。写层的虚拟大小与基础相同。 因此,基础的大小就是客体根目录的大小。
有趣的是,容器磁盘也遵循相同的原理。唯一的区别仅在于基础是来自镜像仓库的节点缓存,还是由存储类(StorageClass)提供的 PVC(第 3 篇图示)。
4. 从 PVC 的视角来看
如果按“谁拥有哪个 PVC、什么东西在何时创建和消失”来总结,如下所示:
| 黄金 PVC | 写层 | |
|---|---|---|
| 内容 | 每个 OS/大小一个(名称 golden-<OS>-<크기>) |
每个 VM 一个 disk.qcow2 文件 |
| 位置 | 共享存储(CSI 驱动程序)、RWX、文件系统模式 | virt-launcher Pod 的 emptyDir — 节点本地,不是 PVC |
| 谁创建 | 运维人员通过 CDI DataVolume 创建一次(镜像仓库源) | KubeVirt 每次启动 VM 时 |
| 谁拥有 | 无(VM 不拥有它) | virt-launcher Pod |
| 挂载形态 | 所有 VM 以 readOnly: true 挂载 |
仅该 VM 的 QEMU 挂载 |
| 何时消失 | 仅由运维人员删除时 | 当 VM 结束且 Pod 被删除时 |
做出某些决定是有原因的。
- 创建为 RWX。 学生 VM 分布在不同的节点上,并且必须 同时 挂载到同一个黄金镜像。由于 RWO 只能挂载到一个节点,因此后端将 RWO 黄金镜像判定为“不可用”。之所以是 RWX 而不是 ROX,是因为 CDI 在导入时需要进行写入。
- 真正的保护是
readOnly: true。 正如在第 3 篇中所看到的,访问模式并不能阻止写入。如果基础哪怕被更改了一次,其上的所有写层都将指向错误的磁盘。因此,不仅保留只读挂载,测试还会检查清单中是否存在以读写方式挂载的黄金镜像。 - 必须是从未启动过的原始镜像。 如果将启动过一次的磁盘用作黄金镜像,SSH 主机密钥、machine-id 以及 cloud-init 记录将会被完全复制到所有学生的 VM 中。因此,黄金镜像会直接导入与容器磁盘 相同的镜像,并在 PVC 上保留原始镜像地址作为注解(annotation),供后端进行验证。
- 立即绑定。 遵循第 3 篇中提到的“推迟到第一个用户”会导致导入操作在第一个学生 VM 到来时才开始。由于黄金镜像必须在此之前完全就绪,因此会请求立即绑定。
- 后端只进行读取。 后端对黄金镜像能做的事情只有
get已知名称的 PVC。由于没有创建和删除权限,因此不会因为回收代码中的一个小失误而导致所有人的基础被擦除(第 6 篇)。
判定与替代
后端在创建 VM 之前会读取黄金镜像,以判定其当前是否可用。因为指向不存在的 PVC 的 VM 会导致 KubeVirt 无法创建 Pod 从而永远无法启动(v1.9.0 的 rendervolumes.go 中的 PVC 不存在错误)。
| 情况 | 结果 |
|---|---|
| 黄金 PVC 不存在 · 没有读取权限 · API 错误 | 使用容器磁盘打开 |
| 正在删除中 · 尚未绑定 | 使用容器磁盘打开 |
| 仅挂载到一个节点的卷 | 使用容器磁盘打开 |
| CDI 仍在导入中 | 使用容器磁盘打开 |
| 从其他镜像导入的黄金镜像 | 使用容器磁盘打开 |
| 课程的值拼写错误 | 错误(课程测试会先捕获到这一点) |
当被替代时,学生的终端显示:“根目录约为 2.4GiB,比平时小, df -h / 会提示“请通过 ... 进行确认”。该提示仅在交互式 Shell 中打印。因为评分代理也是通过登录 Shell 运行命令的,否则提示会混入评分输出中。之所以不使用 DataVolume 而是回退到容器磁盘,是为了不重新赋予后端创建 PVC 的权限,并且因为 195 秒的首次启动时间超出了黄金实训的准备时间上限,反而会导致失败。因此, 黄金镜像是优化项,而非前提条件。 开启黄金镜像的实训也必须能够在容器磁盘上通过。
5. 为什么不使用 CSI 克隆或快照
Kubernetes 中本来就有两种扩充卷的官方方法。
- CSI 卷克隆。 在新 PVC 的
dataSource中填入原始 PVC,就会生成一个原始镜像的副本,而不是空卷。副本是与原始卷 独立的卷。它仅适用于 CSI 驱动,必须在同一个命名空间中,且卷模式需与原始卷相同,大小需大于或等于原始卷。驱动是否实现了此功能因驱动而异(Kubernetes 文档)。 - 卷快照。 将某一时刻的卷创建为快照,并从中创建新的 PVC。这需要 CSI 驱动的快照功能、快照控制器与 CRD,以及 VolumeSnapshotClass (Kubernetes 文档)。
当 CDI 使用 DataVolume 复制 PVC 时,会自动在这两者之间进行选择。如果可以使用快照,则采用“快照 → 还原 → 删除快照”的方式(smart clone);如果可以使用 CSI 克隆,则采用该方式;如果两者都不行,则会回退到 将数据从源流向目标的主机复制(CDI 复制·StorageProfile 文档)。这种选择会作为 cloneStrategy 记录在存储类的 StorageProfile 中。
在撰写本文当天查看 TT Lab 的共享存储类时,发现虽然安装了快照 CRD,但 没有任何 VolumeSnapshotClass,且 StorageProfile 的复制策略是 copy(主机复制) (2026-10-16 查询)。因此,如果在此存储库中通过 PVC 克隆为每个学生创建 10Gi,则每个虚拟机都会通过网络完整复制 10Gi。根本没有快照这条路可走。
即使存储发生更改,写入层的优势依然存在。
| PVC 克隆·快照还原 | 黄金镜像 + 写入层 | |
|---|---|---|
| 每个虚拟机生成的 PVC | 一个 | 无 |
| 对于 PVC 配额(每个虚拟机命名空间 12 个) | 并发虚拟机数量受限于 PVC 数量 | 仅计算黄金镜像的数量 |
| 后端所需的权限 | 创建和删除 PVC | 一个 PVC 读取权限 |
| 创建所需的时间 | 取决于存储的复制实现(此处为完整复制) | 一个空文件 |
| 会话结束时 | 必须删除 PVC(删除失败 = 空间泄漏) | 随 Pod 一起消失 |
| 关闭并重新启动学生虚拟机时 | 磁盘保留 | 写入层消失 |
最后一行是这种方法所付出的代价。由于实训会话只开启一次就会结束,所以我们接受了这一点。如果作业要求学生连续几天继续使用同一个磁盘,那么这种方法就不太合适。
6. 会话结束时:只有写入层消失
黄金虚拟机清单中没有 dataVolumeTemplates 。由于虚拟机不拥有黄金镜像,因此即使删除虚拟机,黄金镜像也会保留。回收器删除的只是虚拟机和 cloud-init Secret(第 5 部分),虚拟机删除后,virt-launcher Pod 随之结束,其 emptyDir(即写入层)也会一同消失。没有任何内容会传递给下一个学生。
当需要更换黄金镜像本身(更新镜像)时,将其删除并使用相同名称重新创建。如果有正在使用的虚拟机,由于 Kubernetes 的“正在使用的存储对象保护”机制,PVC 会一直保留到这些虚拟机结束运行,在此期间后端会看到“正在删除”并使用容器磁盘打开新的虚拟机。虽然也可以通过在名称中加入版本号在旁边创建新的黄金镜像(CDI 的 DataImportCron 所采用的方式),但这会增加后端解析名称所需的权限,并且学生环境会自动变成未经测试的镜像。考虑到只有六个操作系统规模,我们认为手动更换成本最低。
7. 代价与限制
- 写入层没有大小上限。 由于写入层所在的 emptyDir 没有大小限制,因此上限仅为底层虚拟大小。如果学生将根目录填满,就会占用相应的节点本地磁盘。当节点面临磁盘压力时,kubelet 会优先驱逐占用空间最多的 Pod,而那恰好就是 该学生的虚拟机 。因此,我们将大小打包为 10Gi 和 20Gi 的选项。目前尚未确认如何向调度程序告知虚拟机的临时存储请求和限制。
- 每次读取未使用的块时,都会访问共享存储。 多个虚拟机同时读取相同底层的速度因存储而异,且 未进行实测。
- 黄金虚拟机是否能实际启动尚未进行测试验证。 在以只读方式挂载的文件系统 PVC 作为底层的写入路径中,如果 KubeVirt 试图调整磁盘文件的所有权或修改大小时写入底层,可能会失败。测试的第一轮将确认这一点。
- 客户机根目录大小的计算结果存在出入。正如第 3 部分中所见,根据 CDI 文档,10Gi 黄金镜像的客户机磁盘应为 10Gi,而设计文档预计约为 9.4GiB。 未进行实测。
测量对象与测量方法
测量脚本(scripts/measure_vm_disks.py)会在同一节点上一台接一台地创建容器磁盘、DataVolume 和黄金镜像,进行测量并将其删除。在开始前以及每轮测试中检查节点状态和剩余内存,第 0 轮作为预热单独记录,从第 1 轮开始计算中位数、最小值和最大值。测量的项目包括:准备时间(从请求到代理准备就绪)、达到 VMI Running 的时间、客户机根目录的大小与剩余空间、写入层的实际大小(启动后立即以及写入 128MiB 后)以及删除时间。由于涉及创建和删除虚拟机,因此由拥有集群写入权限的人员运行。
python3 deploy/vms/golden.py manifest --os ubuntu-24.04 --storage-class <공유 스토리지 클래스> # 골든 하나(적용은 사람)
python3 scripts/measure_vm_disks.py --preflight --node <노드> # 아무것도 만들지 않고 본다
python3 scripts/measure_vm_disks.py --node <노드> --rounds 4 # 예열 1 + 3바퀴
是否在实训中开启黄金镜像将根据此结果决定。如果准备时间的中位数不超过容器磁盘的 1.5 倍,根目录达到 9GiB 以上,且启动后的写入层在几百 MB 以内,则从 kubeadm 类实训开始开启。
TT 寄语。 为三十个人分别提供 10Gi,但实际创建的只有一个 10Gi 和三十个空文件。不过要记住,“绝对不要碰底层”是生死攸关的原则,因此
readOnly: true务必附加好,同时也要记住写入会按块进行扩展!
参考资料
文档和源码均于 2026-10-16 查阅。
- KubeVirt 用户指南,磁盘与卷(ephemeral): https://kubevirt.io/user-guide/storage/disks_and_volumes/
- KubeVirt v1.9.0 源码:
pkg/ephemeral-disk/ephemeral-disk.go(底层路径vmi-disks/<볼륨>/disk.img,qemu-img create -f qcow2 -b … -F raw) https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/ephemeral-disk/ephemeral-disk.go,pkg/virt-controller/services/rendervolumes.gohttps://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/services/rendervolumes.go - QEMU 文档,qemu-img(后端镜像): https://www.qemu.org/docs/master/tools/qemu-img.html,qcow2 格式规范(读取未分配的集群): https://www.qemu.org/docs/master/interop/qcow2.html
- Kubernetes 文档,CSI 卷克隆: https://kubernetes.io/docs/concepts/storage/volume-pvc-datasource/,卷快照: https://kubernetes.io/docs/concepts/storage/volume-snapshots/,持久卷(使用中保护): https://kubernetes.io/docs/concepts/storage/persistent-volumes/
- CDI 文档,DataVolume 克隆: https://github.com/kubevirt/containerized-data-importer/blob/main/doc/clone-datavolume.md,smart clone: https://github.com/kubevirt/containerized-data-importer/blob/main/doc/smart-clone.md,StorageProfile(
cloneStrategy): https://github.com/kubevirt/containerized-data-importer/blob/v1.66.0/doc/storageprofile.md - TT Lab 代码与记录:
backend/app/vms.py(golden_unusable_reason·choose_root_disk·vm_manifest),deploy/vms/golden.py,deploy/vms/README.md(黄金磁盘章节),scripts/measure_vm_disks.py,docs/plans/2026-10-14-vm-golden-overlay.md - 玩具实测:
docs/experiments/2026-10-16-cow-overlay-toy/(cow_toy.py,output.jsonl)