状态:草稿(2026-10-16),基于我们的代码 + 官方文档 + 部分实测。 动作说明完全遵循 TT Lab 后端代码(
backend/app/vms.py,backend/app/main.py)与设计文档。版本号为撰写本文当日读取并确认的值(Kubernetes v1.34.10、KubeVirt v1.9.0、CDI v1.66.0)。时间数据是在制作实验时测得的记录,并同时标注了样本量与日期。 编写规则:以实测数据为基础,未能测得的数据标记为“未实测”,在文档中未找到的内容标记为“未确认”。
学生虚拟机启动指南 1:从四层看整体架构
目录
1. 为什么不用 Pod 而用虚拟机
TT Lab 的实验大多数在单个 Kubernetes Pod 中运行。Pod 几秒钟即可启动,只需更换镜像环境即可改变,删除后也不会留下任何痕迹。然而,有一些实验是 Pod 无论如何也无法模拟的。
- 没有 systemd。 涉及关闭/重启服务或修改 unit 文件的实验,只能被迫简化为“检查语法”。
- 使用 kubeadm 搭建节点、备份并恢复 etcd 的工作无法在 Pod 中进行。虽然在 Pod 内部通过 kwok 模拟了 Kubernetes,但 kwok 并不会真正运行容器,而是将 Pod 伪装成 Ready 状态。
- 无法操作内核功能。 实验 Pod 的 capability 为 0,甚至连 rootless podman 都无法运行。由于这是 TT Lab 坚守的原则,因此不打算增加此权限。
因此,只有这类实验才会通过 虚拟机来提供。学生在虚拟机内部拥有真正的 root 权限,可以随心所欲地操作 systemd、内核和磁盘。不过本系列的主题在于:防止这个 root 权限逃逸出虚拟机,并确保用完的虚拟机能够被确实回收。虚拟机是通过 Kubernetes 上的 KubeVirt 启动的,因为它们使用与实验 Pod 相同的集群、相同的调度器和相同的存储。
2. 四层架构
本系列故意只关注四个层级: 学生(浏览器)、后端(Web Pod)、KubeVirt、Kubernetes 存储。网关、镜像仓库、监控等夹在中间的组件已从图中剔除,因为“分配虚拟机”这一工作的核心骨架正是这四个部分。
将每一层的作用概括为一句话如下:
| 层级 | 职责 | 在本系列中 |
|---|---|---|
| 学生 | 点击按钮,通过 Web 终端使用虚拟机内的 shell | 第 1 篇 |
| 后端 | 检查空闲资源、准备注入虚拟机的内容、选择根磁盘、创建虚拟机对象。使用完毕后进行清理 | 第 1、5、6 篇 |
| KubeVirt | 根据 VM 对象创建 Pod,并在该 Pod 内通过 QEMU 启动 Guest 操作系统 | 第 2 篇 |
| 存储 | 挂载将成为 Guest 磁盘的卷。多个虚拟机共享读取一个 Golden PVC | 第 3、4 篇 |
3. 十个步骤
① “开始实验”
当学生点击按钮时,浏览器发送 POST /api/sessions 。如果课程是 runtime: vm ,则此会话将创建虚拟机而不是 Pod。会话记录从一开始就会包含 runtime 。因为回收器稍后通过这一个值来决定“应该删除什么”。如果在此处错误判断了类型,系统会记录 Pod 已删除,而虚拟机则会继续占用 4GiB 的内存。
② 检查空闲资源
主要检查两个方面。首先是 一人一会话机制。如果已经打开了其他实验,系统会提示当前已开启的内容,而不会新建。由于在用户级锁内部同时进行了检查和记录,因此即使连续点击两次按钮也只会产生一个会话。
另一个是 **同时运行的虚拟机数量上限(默认 8 台)**。这个数字不是从数据库中获取,而是直接 向集群查询 得出的。因为数据库无法统计被回收器漏掉的“幽灵虚拟机”,而真正占用节点内存的是集群侧。如果触及上限,系统将返回提示:“当前已有 8 个虚拟机实验处于打开状态,没有空余名额”。为什么限制为 8 将在第 5 篇中探讨。
③ cloud-init Secret
虚拟机不会提前烘焙镜像,而是在公共云镜像的基础上,通过 首次启动时的 cloud-init 注入所需的内容。注入的内容主要有三项。
- 评分代理。 这是一个在 Guest 内部监听 8899 端口的小型 HTTP 服务器,它将接收到的命令交由 shell 执行来完成评分。为什么需要这样一个通道将在第 ⑧ 节中介绍。
- 每个虚拟机专属的 Token。 代理只接受包含正确 Token 的请求。由于学生是自己虚拟机中的 root,因此可以看到 Token。我们需要防范的不是学生,而是当网络策略配置错误时可能触达的其他 Pod(第 6 篇)。
- 实验文件与准备脚本。 即评分器和实验素材。如果准备脚本执行失败,系统将不会打上“已就绪”的标记。
不把这些内容直接写入虚拟机清单而是放入 Secret 中,是有其实际测量依据的。KubeVirt 对内联 cloud-init 的大小限制为 2048 字节,但如果加上评分代理,则会超过 7000 字节。因此,所有实验虚拟机都统一走 Secret 这条路径。如果根据大小分流,就会产生只在加入代理那一瞬间才会崩溃的隐蔽路径。
④ 选择根磁盘
这里是本系列的核心。如果课程指定了 vm_disk: "golden" ,后端会读取运维人员预先创建的 Golden PVC (get 之一),并判定当前是否可用。如果可用,则将该 PVC 用作 只读底层,并为每个虚拟机叠加一个轻量的 写时复制层。如果不存在或不可用(缺失、无权限、正在获取中、单节点卷、不同镜像、正在删除),则不会报错,而是直接用默认磁盘(容器磁盘)启动。因为 Golden 是一种优化手段而非强制前提。磁盘的三种分支将在第 3 篇中介绍,Golden 与写时复制层将在第 4 篇中详细讲解。
⑤ 创建 VirtualMachine
后端向 Kubernetes API 创建一个 VirtualMachine 对象。该对象会附加标签:实验会话标识、会话 id,以及 由哪个环境(开发/生产)的后端创建。最后一个标签源于一次事故:开发和生产后端查看的是同一个虚拟机命名空间,结果某一侧的回收器将数据库中没有记录的虚拟机误判为孤儿,并删除了另一侧学生的虚拟机(第 5 篇)。
我们将此虚拟机设为 cloud-init Secret 的所有者,以便在虚拟机被删除时 Secret 也一并被清理。不过,由于所有者关联可能会失败,在删除时还会额外单独清理一次 Secret。
⑥ KubeVirt:VM → VMI → Pod → QEMU
从这里开始由 KubeVirt 接管。它会根据 VM 创建运行中的实例(VMI),并为每个 VMI 创建一个 virt-launcher Pod 。当 Kubernetes 调度器将该 Pod 安排到某个节点上后,该节点的 virt-handler 会指示 Pod 内的 libvirt “启动此虚拟机”。Guest 操作系统则作为该 Pod 内的 QEMU 进程运行。虚拟机为什么要在 Pod 内运行以及付出的代价是第 2 篇的主题。
⑦ 挂载卷
在创建 virt-launcher Pod 时,将作为 Guest 磁盘的卷也会一并挂载。如果是 Golden 实验,Golden PVC 会以 只读方式挂载,KubeVirt 会在其上方将写时复制层文件创建在 Pod 的临时目录(emptyDir)中。在 Guest 看来,这二者合并成了一个磁盘(/dev/vda)。如果没有可挂载的 PVC,KubeVirt 甚至无法创建 Pod,虚拟机也将永远无法启动。这就是为什么要在 ④ 中提前进行判定的原因。
⑧ 确认就绪状态
后端分两阶段进行等待。首先等待 VMI 变为 Running (每个实验设定的上限,默认 150 秒),然后等待 Guest 内的代理回应“已就绪”。
KubeVirt 并没有类似 Pod 的 exec 那样“在 Guest 内部执行命令”的机制。它的子资源仅限于 console、vnc、portforward 等,这是刻意为之的设计。因此,就绪状态的确认和评分都需要通过 ③ 中注入的代理来完成。
Running 我们曾实际踩过坑:仅仅看到 就放学生进去是不行的。代理在 cloud-init 前期启动,而搭建实验环境的准备脚本则在之后运行。如果在两者之间让学生进入,从第一步就会因为“找不到文件”而失败,评分也只能通过 8 个步骤中的 4 个。现在,只有当 cloud-init 的最后一行 打印出就绪标志,且准备脚本成功执行时才算完成。否则,后端会因超时而 坦诚地
失败。
⑨ 控制台转发与 ⑩ Web 终端 console 后端通过 WebSocket 连接到 VMI 的
- 子资源(⑨),并在学生浏览器的 Web 终端与该子资源之间进行转发(⑩)。这里同样有两个陷阱。
plain.kubevirt.io以前用于 Pod 终端的 Kubernetes 客户端流处理函数会将第一个字节读取为通道号。而 KubeVirt 控制台是没有这种外壳的原始字节( ),如果用相同的函数去连接,就会出现“连接已打开但屏幕上什么都不显示”的情况。 由于没有任何报错,排查起来更加困惑。因此,这里需要直接通过 WebSocket 进行连接。 - 控制台终端连接的并非 shell,而是登录提示符(getty)。由于实验虚拟机故意不设置密码,因此 cloud-init 会在串口控制台上配置 root 自动登录。
在打开 WebSocket 时,后端会首先检查连接者是否为该会话的主人、会话是否正在运行。能够访问控制台子资源的 Kubernetes 身份只有后端服务账号(第 6 篇)。
4. 耗时情况 — 目前的实测数据
| 项目 | 数值 | 条件 | 样本 |
|---|---|---|---|
| VMI 阶段:Pending → Scheduled → Running | 0秒 → 10秒 → 20秒(以 10 秒为单位记录) | Ubuntu 24.04 容器磁盘,2 vCPU·4Gi,2026-08-29 | 1次 |
| Guest 内 cloud-init 完成 | 23.8秒 | 同上虚拟机 | 1次 |
| 使用 cloud-init 安装 Docker 的实践 | 约 76 秒(启动 25 + apt 索引 14 + 安装 37) | 2026-08 实践开发中 | 1次 |
| 通过学生 API 打开的会话,从请求到准备就绪 | 90.991秒 | 单个 Istio 实践,2026-09-12 | 1次 |
| 含三台 VM 的会话:三台 VM 均处于 Running 状态 | 17~23秒 | kubeadm 3节点实践,2026-09-23~24 | 多次测试的范围 |
| 相同会话:三个节点的准备脚本结束 | 约 300 秒 | 相同实践 | 相同 |
| 各项实践中设定的准备时间上限的依据 | k3s 82秒 · Istio 94秒 · k0s 150秒 · Cilium 269秒 | 各项实践的答案验证 | 每次实践 |
| 为每个 VM 重新获取磁盘的方式(DataVolume)的首次启动 | 最多 195 秒 | 第 3 篇 | 仅记录最大值 |
表格说明了两件事。 VM 本身在 20 秒左右启动,其余时间是在 Guest 内部搭建实践环境的时间。而且,如果为每个 VM 重新获取磁盘,光是这一项就要花费几分钟。第 4 篇中的黄金磁盘(Golden Disk)正是为了解决这最后一行的问题。
局限性也很明显。大多数值都是测量了一次的数值,整个学生会话准备时间的分布(中位数和离散程度)属于 未测量。由于会话记录中同时保留了开始时间和准备时间,只要收集这些记录就可以计算出来。
5. 本图故意省略的内容
- 网络策略。 对学生 VM 的入站连接默认拦截,仅后端可通过 8899 端口访问。出站方向仅开放互联网。将在第 6 篇中介绍。
- 回收。 当会话时间耗尽时,回收器会删除 VM,写入层随 Pod 一同消失。将在第 5 篇中介绍。
- 多 VM 会话。 像 kubeadm 实践这样需要一组节点的会话,会在每个 VM 上重复相同的步骤(②~⑧),并额外部署一个会话专用网络。本系列以单台 VM 为基准进行编写。
TT 的话。 按钮背后隐藏着十个步骤。然而,VM 启动只需 20 秒,其余大部分时间都在进行「在 Guest 中准备实践」和「准备磁盘」。因此,如果想加快速度,首先要看磁盘。第 4 篇见!
参考
所有文档均于 2026-10-16 查阅。
- KubeVirt 组件: https://github.com/kubevirt/kubevirt/blob/main/docs/components.md
- KubeVirt 磁盘与卷: https://kubevirt.io/user-guide/storage/disks_and_volumes/
- TT Lab 代码:
backend/app/vms.py(清单·cloud-init·黄金判定),backend/app/main.py(create_session·make_vm·vm_console·reap_once),backend/app/vm_agent.py(评分代理) - 设计与实测记录:
docs/plans/2026-08-29-vm-labs-design.md,docs/plans/2026-09-12-vm-reaper-environment.md,docs/plans/2026-09-24-multi-vm-sessions.md,docs/plans/2026-10-14-vm-golden-overlay.md