keelOS:CPU 可以超售,硬盘可以超售,但是内存呢?
为什么内存不能像 CPU 和硬盘那样超售
一台 16 核的主机跑 10 台 4 核的 guest 很平常:CPU 是分时的,一台 guest 不算时,核就给别人。硬盘也一样:给 guest 一块 100 GB 的盘,它只写了 10 GB,另外 90 GB 在主机上根本不存在(thin provisioning)。用户多分一点,主机多装几台,谁都不吃亏。
内存没有这个便宜。guest 拿到 4 GB,就认为这 4 GB 是自己的,随时可以写。主机想把没用的那部分借给别人,得先知道哪些页没用,还得在 guest 突然要用时立刻拿得回来。CPU 慢一点是慢一点,内存要不回来是崩溃。
更麻烦的是 guest 对内存的态度:操作系统把空闲内存当成免费的缓存,Linux 的 page cache、FreeBSD 的 ARC、Windows 的 SuperFetch 都会把内存填满。从主机看,一台 guest 开机一天以后几乎没有"空闲"的页,只有 guest 自己知道哪些是缓存、哪些是真的。
别人怎么做
大家都在超售,靠的是四套工具叠在一起,从便宜的到贵的。
| 手段 | 谁在用 | 怎么做 | 代价 |
|---|---|---|---|
| 气球驱动 | ESXi、KVM、Hyper-V 动态内存 | guest 里的驱动向自己的内核要内存,把拿到的页交给主机 | 需要 guest 配合;要回来慢,回收多了 guest 自己会开始换页 |
| 相同页合并 | KVM 的 KSM,ESXi 的 TPS | 主机扫描所有 guest 的页,内容相同的合并成一份,写时复制 | 扫描吃 CPU;大页和内存加密下几乎合不到;ESXi 因为侧信道风险默认关了跨 VM 合并 |
| 内存压缩 | ESXi,Linux zswap | 冷页压缩后放在主机内存里 | 压缩解压吃 CPU,压不动的页白费力气 |
| 换出到盘 | ESXi,KVM 靠主机 swap | 最后一道:页写到盘上 | guest 慢到不能用,但不会死 |
ESXi 把这四套排成一条流水线,换出文件的大小等于 VM 内存减去保留量,所以最坏情况下永远有地方放。KVM 没有这么周到,主机 swap 也满了就是 OOM killer 杀 VM。Hyper-V 只靠气球,不在底下偷偷超售,所以也不会出现要不回来。
这些手段有一个共同点:主机先把内存借给别的 VM,等出事了再想办法。keelOS 面向的是个人和小办公室的单台主机,没有人半夜盯着报警,所以我们想要的是一个出不了事的办法。
keelOS 的内存长什么样
keelOS 是一个很薄的 hypervisor(hyp)加一个 FreeBSD 的 root,guest 用 bhyve 跑。hyp 在固件阶段就把内存分好:root 拿总内存的 1/8(最少 8 GB),其余全部是 guest 的池,root 自己的页表里没有它。
池里的内存按 guest 的名字登记,比 root 活得长:root 升级内核时只是挂起 guest、重新加载自己,然后按名字把 guest 的内存找回来接着跑,guest 只感觉到停了几秒。这个结构对内存回收有一个好处:内存的主人是 hyp,root 里的 vmm.ko 只是拿着映射,换一个 vmm.ko 不用重启机器。
先看数据:guest 的内存里一半是零
我们没有先选手段,先量。keel serve 每 5 分钟把每台 guest 的内存只读扫一遍,数全零的页。开发机上 17 台 guest、共 44 GB(FreeBSD、Debian、NetBSD、Windows 10 / Server 2022 / XP、fnOS),开机一天后零页 21.5 GB,49%;其中整块 2 MiB 都是零的 15.3 GB,35%。
这个比例比预想的高。原因是现代内核都会把释放的页清零(安全:不让下一个进程看到上一个的数据),而一台 guest 开机后大部分内存根本没碰过。零页有个其他页没有的好处:不用保存内容,要回来时给一块新的零就行。不用压缩,不用写盘,不用 guest 里装驱动。
所以第一步只做零页,而且按 2 MiB 的块做(guest 的页表用大页,按 4 KiB 拆会让它变慢):
- vmm.ko 里一个低优先级线程每 5 分钟扫一遍所有 guest 的块。读一块是不是全零很快:非零的块通常第一个字就不是零。
- 连续两轮都是零的块,从 guest 的 EPT、bhyve 的地址空间、监控程序的映射里一起拆掉。拆完再查一遍,这期间被写过的不算。
- guest 再碰这块时缺页,vmm.ko 把它映射回去,记一次"被要回"。
这一步不放出任何内存:块还是那台 guest 的,只是拆了映射。目的是量两个数:能收多少,收了以后 guest 多久要回去。开发机上 7 小时的结果:回收 14.9 GB(总内存的 34%),回收 10721 次,被要回 551 次,约 5%,而且大半是我们自己的测试 guest 故意要的。
正确性在真机上测了 6 轮:guest 在 tmpfs 里写 1500 MB 零和 500 MB 随机数据,等两次扫描,读回校验,中间还经历了一次 root 重新加载。零文件里不为零的字节是 0,随机数据的 sha256 前后一致。
不超售:借给主机当缓存
回收了 34% 的内存,接下来的问题就是开头那个:拿去开新 guest,老 guest 要回去时怎么办?测试环境里 5% 的"被要回"是平均数,模拟不了真实负载:一台 guest 跑个大任务、数据库缓存涨满、Windows 做一次内存诊断,都会在几分钟里把全部要回去。到时候只能暂停 guest 或者换出到盘,又回到别人那条路上。
所以换一个借主:不借给别的 guest,借给主机当缓存。缓存的特点是丢了不痛:guest 要回去,主机就把那块缓存扔掉,代价只是下次多读一次盘。Xen 的 tmem 和 Linux 的 cleancache 是同一个思路。
guest 的 2 MiB 块 ──全零,拆掉映射──▶ vmm.ko 回收线程 ──借出──▶ keelcache0(L2ARC)
◀──guest 缺页,映射回去── ◀──收回:等 I/O 做完,清零──
具体的实现很小:
- hyp 不改。内存在账面上还是那台 guest 的,root 本来就能访问整个池,换一个 vmm.ko 就够。
- 主机的 ARC 在 root 自己的内存里,内核不能热加内存,所以借来的块接成 L2ARC:一个内存磁盘
keelcache0,zpool add zones cache keelcache0。写时借不到块就把这次写丢掉,读到已经被收回的块就返回零。ZFS 对 L2ARC 的每次读都校验 checksum,对不上就回主盘读,数据不会错。 - 收回时先等缓存那边正在进行的拷贝做完,清零 2 MiB(约 0.1 ms),再映射给 guest。清零两个原因:guest 那块本来就是零;缓存里是主机上文件的内容,可能是别的 guest 的盘,不能漏过去。
- guest 拿回去不用,块又是普通的零块,下两轮扫描后再次回收、再次借出。
在 QEMU 里故意让 guest 不停把刚借出去的块要回来:guest 10 轮校验全部通过,主机同时读回 1.2 GB 文件 158 次全部正确,期间从缓存收回了 353 块。挂起、销毁、恢复都测过。一个附带的好处:没有风险的前提下,可以在真实使用里继续攒"被要回"的数据,以后再决定要不要真的超售。
现在的数字,和接下来
今天(2026-09-30)下午这套机制上了开发机:换 vmm.ko 只用了一次 root 重新加载,17 台 guest 挂起再恢复,机器没有重启。重新加载后 20 分钟,44 GB 的 guest 内存回收了 11.2 GB,可以借给缓存的块 16 GB(含大 guest 在 3–4 GiB 地址上的空洞,那是另一个以后要修的浪费),L2ARC 开始借。
接下来要量的三件事:
- 缓存到底帮了多少。开发机的 guest 盘在 NVMe 上,ARC 命中率本来就有 91%,收益会小;机械盘上的 NAS 类用法才是它的主场。直通 HBA 给 NAS guest 的盘不经过主机的 ZFS,没有收益。
- ARC 能不能调小。有了 L2ARC,root 自己的 ARC 可以从 16 GB 往下调,省下的内存能进池。但不能太小:L2ARC 只收 ARC 淘汰的数据,每条 L2ARC 记录在 ARC 里还占一个头。先试 4–8 GB。
- 被要回的那些,多少只是读。到目前为止 38% 的缺页是读触发的。读一个零块不需要真的内存,以后可以让它们共享一页零,等真的写时才收回,缓存丢得更少。
至于真的超售,把回收的内存拿去开新 guest,现在不做。等真实使用里攒几个星期的"被要回"数据再看。内存和 CPU、硬盘不一样,它要不回来不是慢一点,所以我们先选了一个永远拿得回来的用法。