状态:草稿(2026-10-16),我们的代码 + 事故记录 + 部分实际测量。 行为遵循了 TT Lab 后端的
reap_once·delete_vm_session以及设计与事故记录。配额和上限在同一天通过只读方式进行了查询,以确认其与存储库中的值一致。 编写规则:以实测数据为基础,无法测量的标记为「未实测」,在文档中未找到的标记为「未确认」。
学生 VM 启动直到 5:寿命与回收
目录
TT Lab 的一句话原则是「给学员真正的 root shell,但阻止其逃逸, 使用过的资源必须被回收」。在 VM 场景下,最后这句话尤为沉重。剩下一个实验 Pod 只会浪费几百 MB,但剩下一台 VM 会整整锁住 4GiB。
1. 寿命:60 分钟,最多可延长至三倍
- 打开会话时,过期时间被设定为 开始时间 + 60 分钟(默认值,可通过环境变量修改)。
- 学生可以点击延长。延长后过期时间会推迟到「当前时间 + 60 分钟」,但不能超过 开始时间 + 180 分钟。这是防止无限延长的上限。
- 剩余时间由屏幕上的倒计时和回收器使用 相同的函数计算得出。如果两者分别计算,就会出现屏幕上显示还剩 5 分钟而 VM 却消失了的情况。
- 因为是一人一会话,所以一个学生无法堆叠多台 VM(第 1 篇 ②)。
2. 回收器:每 60 秒三个方向
后端每 60 秒执行一轮回收。管理员界面上的「立即回收」按钮也使用相同的函数。观察的方向有三个。
| 方向 | 内容 | 为什么需要 |
|---|---|---|
| 过期 | 时间到期的会话 → 清理资源并将记录标记为 expired 结束 |
默认路径 |
| 孤儿 | 集群中存在但数据库中没有活跃会话的 VM → 删除 | 当后端在创建 VM 后立即崩溃等原因导致记录与资源不一致时 |
| 幽灵 | 数据库显示运行中但实际没有 VM 的会话 → 将记录标记为 failed 结束 |
节点重启或 OOM 导致 VM 消失时。如果没有这个,由于一人一会话的限制,学生将永远无法开启新的实验 |
单独扫描 VM 孤儿也是有原因的。回收器最初只查看实验 Pod 列表,而 VM 位于 不同的命名空间中,因此在该列表中永远不可见。数据库行消失的 VM 可能会在占用 4GiB 内存的同时处于谁也看不见的状态。
在删除孤儿之前,会再检查一次数据库。这是为了防止回收器在读取列表后,将刚刚创建的会话的 VM 误认为孤儿。
事故 1:其他环境的回收器删除了学生的 VM
2026-09-12,在生产环境中,通过学生 API 打开的 VM 会话未能完成准备而失败。核对记录后发现,在 VMI 启动后 26 秒 VM 被删除了。并非由测试工具删除。
原因是共享。开发环境和生产环境的后端使用不同的数据库,但 共用同一个 VM 命名空间 。当时的回收器读取了整个 VM 命名空间,并把「不在我的数据库中」的判定为孤儿。因此,在开发环境回收器的眼中,生产环境学生的 VM 就成了孤儿。在给定相同条件的测试中,重现了删除生产环境 VM 的调用(未从 Kubernetes 审计日志中提取实际删除请求者的身份)。
修复方法是一个标签。后端在创建 VM 和 Secret 时, 为其附加自身环境名称的标签,孤儿回收仅以标签与自身环境相同的 VM 为目标。不推测没有标签的旧 VM 的所有者。列表本身继续查看全体,因此不会将数据库中注册的旧 VM 误认为是「不存在的资源」。修复后通过学生 API 重新打开的会话在 90.991 秒时准备就绪,并确认在结束后 VM、VMI、Secret 都已消失(1 次)。
事故 2:单行失败导致整个回收过程停滞
这是在 2026-10-14 网站安全检查中发现的问题。在清理过期会话的循环中,如果某一次删除抛出了异常, 整个这一轮循环就会终止。 当同一行在每分钟的同一位置失败时,其后面其他的过期会话、孤儿、幽灵也都在无人收理的情况下堆积起来。
现在,失败被限制在每一行内部。
for r in rows:
try:
delete_session_resource(r)
except Exception as exc:
failed += 1 # 실패를 세고
print(f"[reaper] 만료 세션 회수 실패 {str(r['id'])[:8]}: {type(exc).__name__}", flush=True)
continue # 기록은 그대로 두어 다음 바퀴에 다시 본다
con.execute("update sessions set status='expired', finished_at=now() where id=%s", (r["id"],))
将记录更改为 expired 的时机是在 删除成功之后。如果顺序相反,未被删除的 VM 就会被记录为「已结束的会话」,从而再也不会成为回收的目标。失败次数会与其他回收状态一起保留在管理员界面上。
在同样的检查中,对于实操 Pod,我们还设置了截止时间(activeDeadlineSeconds = 60分钟 × 3 + 15分钟)。这是为了防止即使 Web Pod 和数据库同时停止导致回收器完全不工作,过期的 root shell 也不会永久存活。正常会话会在延长上限(180分钟)之前结束,因此不会触及这个截止时间。
3. 清理什么,按什么顺序清理
后端直接清理的只有 两个:
VirtualMachine对象- cloud-init Secret(包含评分 Token)
其余部分由 Kubernetes 和 KubeVirt 依次清理。顺序如下:
| 顺序 | 对象 | 执行者 | 依据 |
|---|---|---|---|
| 1 | 删除 VM | 后端 | delete_vm_session |
| 2 | 停止并删除 VMI | KubeVirt | VM 拥有 VMI |
| 3 | 终止 virt-launcher Pod | KubeVirt·Kubernetes | Pod 向 Guest 发送终止信号并等待 VM 关闭(30秒宽限期) |
| 4 | 删除写入层(emptyDir) | Kubernetes | emptyDir 的生命周期与 Pod 相同 |
| 5 | 删除 DataVolume 及其 PVC | Kubernetes 垃圾回收器 | VM 拥有由 dataVolumeTemplates 创建的 DataVolume(仅限持久盘实操) |
| — | 黄金 PVC | 无人删除 | 没有所有者,回收器筛选的标签中也没有它 |
后端无需直接手动调整顺序,Kubernetes 的 PVC 保护机制(Storage Object in Use Protection)在其中发挥了作用。根据 Kubernetes 文档,即使请求删除正在被 Pod 使用的 PVC,其实际删除也会延迟,直到该 Pod 不再使用它为止。因此,VM → Pod → 卷的顺序得以自然维持,不会发生 Guest 正在使用的磁盘先消失的情况。在删除黄金镜像时也会触发相同的保护,在所有正在使用它的 VM 结束之前,黄金镜像会一直保持「正在删除」状态(第 4 篇)。
对于包含三个节点的会话,如果删除代表性 VM,其余的 VM、Secret、会话专用网的策略以及服务都会通过标签被一同删除。实测显示,删除一个代表性节点 15 秒后,3 个 VM、3 个 Secret、服务和策略的数量全部变为 0;在人为制造第二个 Worker 创建失败的情况下,已经创建的部分也被全部回收(2026-09-24)。
为什么还要单独再删一次 Secret?
由于在 cloud-init Secret 中将 VM 记为所有者,因此当 VM 被删除时,垃圾回收器应该会将其一同删除。然而, 这种所有者关联可能会失败。 在创建 VM 后为 Secret 挂载所有者属于单独的修改请求,安全检查显示后端服务账号 没有 Secret 的修改权限 ,这导致该关联似乎总是失败,相关记录也有迹可循。实际上确实发现了一个在没有 VM 的情况下残留了 32 天的 cloud-init Secret。因此,后端在删除 VM 后,会再通过名称删除一次 Secret。因为如果不这样做,当再次出现相同的会话名称时,旧的 cloud-init 就会复活。记录显示,残留 Secret 的清理与权限整理需要由人工来处理。
4. 上限:限制故障的波及范围
无论回收机制多么健壮,都有可能失败。因此,我们将「最坏情况下会泄漏多少」通过上限约束起来。
| 上限 | 数值 | 来源 |
|---|---|---|
| 并发实操 VM | 8 台 | 后端(通过查询集群来统计) |
| VM 命名空间配额 — VM 数量 | 8 | ResourceQuota |
| 同上配额 — CPU 请求 / 内存请求 | 16 / 48Gi | ResourceQuota |
| 同上配额 — PVC 数量 | 12 | ResourceQuota(仅使用黄金镜像以及拉取过程中的临时存储) |
之所以采用双重保险,是因为当后端上限无法正确计算时(当 KubeVirt 发生波动而无法读取列表时,后端不会设置上限——因为如果在此处拦截,所有 VM 实操都会同时停摆),配额(Quota)会在最后进行拦截。
记录中也说明了为什么没有将数字 8 调高的原因。在创建多 VM 会话时(2026-09-24),实际提供 KVM 的节点的可用内存请求余量加起来也大约只有 17GiB,在测试过程中甚至还出现了一个节点因系统 OOM 导致约 1 分钟内变为 NotReady 的情况。 提高配额只会将拒绝原因从「没有位置」变成调度失败和节点压力。节点的内存会首先耗尽。VM 的内存请求没有折扣(第 2 篇),并且当节点陷入内存压力时,首先死去的不是 VM,而是同一节点上的实操 Pod,这也是将上限设定得比较保守的原因。
5. 残留的漏洞
- VM 没有像 Pod 截止时间那样最后的安全网。 如果回收器停止运行,过期的 VM 将维持原状留存。此时能够限制危害的只有上述上限(8 台,48Gi)。在 VM 对象中设置对应截止时间的方法目前 尚未确认。
- VM 孤儿对象的清理是以整体为单位失败的。 虽然过期环将失败限制在每个循环内,但遍历 VM 孤儿的循环只是为了在 KubeVirt 发生波动时防止整个回收过程崩溃而进行了整体包裹,因此如果无法删除某个孤儿,该轮循环中的其余 VM 孤儿会被推迟到下一轮。不过日志中会留下记录。
- 在学生会话中,从「过期到 VM 消失」所需时间的分布情况 尚未实测。回收周期为 60 秒,所以预计在 1 分钟左右,但加上虚拟机优雅关机时间(30 秒)后的实际数值并未测量。
TT 总结。 清理代码越短越好。后端只需清理两项,其余的交给“谁拥有谁”以及“正在使用则不清理”。此外,失败的行直接跳过,在下一轮循环中重试!因为不能因为一个错误而让整个流程停滞。
参考
所有文档均于 2026-10-16 查阅。
- Kubernetes 文档,持久卷(保护正在使用中的存储对象): https://kubernetes.io/docs/concepts/storage/persistent-volumes/
- KubeVirt 组件文档(virt-launcher 的终止处理): https://github.com/kubevirt/kubevirt/blob/main/docs/components.md
- TT Lab 代码:
backend/app/main.py(reap_once·reaper·delete_vm_session·delete_session_resource·extend_session·count_live_vm_sessions),backend/app/vm_session_scope.py,backend/app/vm_group.py,deploy/vms/rbac.yaml(ResourceQuota) - 故障与检查记录:
docs/plans/2026-09-12-vm-reaper-environment.md,docs/plans/2026-09-24-multi-vm-sessions.md,docs/SECURITY-REVIEW-2026-10-14.md