English

文章

keelOS:让虚拟机的时钟走得准一点,到底有多难?

暂停 24 秒,时钟就慢 24 秒

keelOS 的宿主系统(root)可以随时升级、重启,虚拟机不关机:重启前把 guest 冻结,重启后原地恢复。在开发机上,一次冻结大约 24 秒。

冻结期间,guest 里什么都不走,包括它的时钟。什么都不做的话,恢复之后 guest 的时间就慢 24 秒,重启 10 次就慢 4 分钟。

慢几秒看上去不大,但日志时间对不上,定时任务错过,证书和 Kerberos 这类对时间敏感的东西会出错。数据库和集群还会因为时间回退或者跳变出问题。所以我们要求:root 重启多少次,guest 的时间都要准。

背景:虚拟机的时间为什么跑不准

真机上,时钟芯片是硬件,一直在走,到点就准时发中断。虚拟机里的时钟芯片大多是软件模拟的,它和 guest 都要等宿主有空才运行。误差就从这里来:

  • 中断会晚到:模拟的定时器靠宿主的定时器触发,宿主忙的时候就晚几十微秒到几毫秒。如果下一次是从“这次实际发生的时刻”开始算,每次的晚就会累加起来。
  • 中断会丢:vCPU 没轮到运行,或者 guest 还没处理完上一个时钟中断,两个就合成一个。靠数中断计时的系统少一拍就慢一拍。
  • 整台虚拟机会被暂停:快照、挂起、迁移,以及 keelOS 的 root 重启。暂停期间 guest 不运行,恢复后它不知道外面过了多久。
  • 计数器是个虚拟的数:guest 读到的 TSC = 宿主的 TSC + 一个偏移量。恢复或迁移时这个偏移量怎么设,决定了 guest 看到的时间是跳一下、停一下,还是正好连上。

前两条是平时一点点漂,后两条是暂停时一下子跳。keelOS 两种都碰到了,下面讲的办法也分别对应这几条。

guest 靠什么计时

难点在于,每种系统计时的办法都不一样。按办法分,大致有两类:

办法 典型的 guest 暂停之后
读计数器:随时读一个一直在涨的计数器(TSC、HPET),换算成时间 FreeBSD、Linux、新版 Windows 计数器走了多少,时间就走多少;把计数器往前拨,时间就跟上了
数中断:每来一次时钟中断(RTC 或 PIT 的周期中断),就加一个固定的量 Windows XP、Server 2003 少来一次中断就少一拍;拨计数器对它们没有用

还有一个细节:FreeBSD 默认用的 TSC-low 只取 TSC 的一部分位,大约 2.7 秒就回绕一次。计数器一次拨太多,多出来的整圈会被回绕吞掉。

别人怎么做:多数不修,交给 guest 自己

主流 hypervisor 默认都不在宿主这边修 guest 的墙上时钟,让它从暂停时的值接着走,再靠 guest 里的 agent 或 NTP 改回来。

谁 暂停或迁移之后怎么办
QEMU/KVM stop/cont 时 kvmclock 原样写回,不算停机时间;靠 qemu-guest-agent 的 guest-set-time 或 NTP
VMware 定时器落后时加快投递中断来追赶,积压超过 60 秒就放弃,由 VMware Tools 改对
Hyper-V 时间同步集成服务发现是从保存状态回来的,一步设对(要 VMBus 驱动)
GCE 热迁移 最多 5 秒的跳变,更长的由 guest agent 同步
Firecracker 文档说应该在 guest 里更新;可选的 clock_realtime 推进 kvmclock,并警告会跳变
Cloud Hypervisor v53 恢复后把 kvmclock 推进经过的时间,只对用 kvmclock 的 guest 有效
Oxide Propolis(illumos bhyve) 迁移时 guest 的 TSC 和所有设备定时器都把停机时间算进去

靠 guest 自己修的路,对 keelOS 不够:我们要跑 FreeBSD、Linux、Windows,一直到 XP,不能要求每个 guest 都装 agent、开 NTP。半虚拟化时钟也走不通:kvmclock 在 bhyve 上没有,virtio-rtc 的驱动要 Linux 6.16,FreeBSD 没有,而且它没有“刚才暂停过”的通知。最接近我们的是 Oxide:同样是 bhyve,在宿主这边把时间补上。

keelOS 做了什么

1. 把暂停的时间分步补回去

恢复之后,hyp 把 guest 的 TSC 和 HPET 一起往前拨,每步 0.5 秒,小于 TSC-low 的回绕周期。

  • 每一步都在所有 vCPU 停在 guest 外面时做,各自加上同样的量再一起回去。guest 在任何时刻看到的各个时钟源都是一致的。
  • 两步之间至少等 150 毫秒,并且等 guest 收到 3 次时钟中断,让它把这一步“消化”掉。开发机上 24 秒的缺口要走 48 步。
  • 暂停的长度用宿主的 TSC 量,它跨 root 重启连续计数。root 的墙上时钟每次启动从 RTC 读,只有秒级精度。

为什么不一步拨到位?试过了:FreeBSD guest 暂停 11 秒,一步拨完后只补回了 0.3 秒,整圈被 TSC-low 的回绕吞了(11 mod 2.68 ≈ 0.3)。

2. 补上 FreeBSD 快照的几个漏洞

keelOS 用 FreeBSD 自带的 bhyve 快照来保存和恢复 guest。追时钟的过程中,我们在它里面找到了几个和时间有关的漏洞:

  • 虚拟 RTC 存错了时间:它存的是 guest 上一次访问 RTC 时的时间,不是暂停那一刻的。FreeBSD guest 第一轮就丢了 80 秒。现在在暂停时读出 RTC,恢复时设成“当时的值 + 经过的时间”。
  • 到点但还没发出的定时器中断丢了:Linux 丢了这一次就不再设定时器,那个 CPU 的定时器全停。现在存快照时当场触发它。
  • 已被接受但还没送进 guest 的中断丢了:恢复后这个中断的服务位一直挂着,定时器和所有设备中断都被挡住。现在多存三个 VMCS 字段。

3. 真机上才暴露的问题

QEMU 里第一版就全过了,上了开发机才发现:

  • 起点算错了:vCPU 停住到存快照之间,guest 的 TSC 还跟着宿主走。这段在 QEMU 里不到 2.5 秒,真机上平时 2–3 秒,磁盘忙时到过 27 秒。改成从 vCPU 停住那一刻算。
  • HPET 不能先跳到位:Linux 用 TSC 计时、HPET 当看门狗。两者追赶期间不一致,看门狗就判 TSC 不稳定并切换时钟源。
  • 每一步前移要先结算 HPET 的当前计数:第一版每一步丢掉 0.17 秒。
  • Windows 跨重启卡死:Windows 用 HPET 的单次比较器发时钟中断。一步跳 0.5 秒越过了比较器,中断就被排到 32 位计数回绕之后,最长 256 秒。现在被越过的比较器当场补发一次。

XP 和 2003:一次评审引出来的 3.6%

上面这套办法,测试的全是读计数器的 FreeBSD 和 Debian。一次外部评审指出:XP 和 2003 靠数时钟中断计时,拨计数器对它们可能完全没用。我们就在第二台开发机上量了一下:从宿主用 ICMP timestamp 请求读 guest 的时间(往返 0.2–0.6 毫秒),每轮暂停 60 秒。

评审说对了:2003 每轮正好丢掉整段暂停(误差 0.05 秒以内),恢复后不再追回来。但还有一个没想到的发现:不暂停的时候,它们也一直慢 3.6%,每 120 秒丢 4.2–4.5 秒。

原因在 FreeBSD 的虚拟 RTC。它每发一次周期中断,就把下一次排在“现在 + 一个周期”。处理函数总是晚一点才运行,这点晚就一个周期接一个周期地累加下去,正是前面背景里说的第一条。同一份代码里的虚拟 PIT 就没有这个问题,它用的是绝对时间。

修法和 PIT 一样:下一次到点 = 上一次应该到点的时刻 + 一个周期;落后超过一个周期才从现在重新算。这样单次仍然可以晚,但不会把后面的也推晚。这个修正没有针对哪台机器调的常数,长期速率跟着宿主的单调时钟走。

示意图 · 虚拟 RTC 周期中断的两种排法
示意图 · 虚拟 RTC 周期中断的两种排法

每次晚的量一样,差别只在下一次从哪里算;实测到的累加是每 120 秒丢 4 秒多。

实测结果

读计数器的 guest,几十次 root 重启之后时钟偏差在 ±0.4 秒以内;XP/2003 平时不再变慢。每轮重启后隔着串口或 ICMP 读 guest 的时间,和宿主比。

guest 场景 不修正 keelOS
FreeBSD(TSC-low),带 TCP 和磁盘负载 开发机,每次暂停约 24 秒,10 轮 每轮慢一整段暂停 十轮都是 +0.0 秒;另一次十轮 −0.1 到 −0.4 秒
Debian 13,带磁盘负载 开发机,15 + 20 轮 每轮 −24.5 秒 +0.0 到 +0.2 秒
Windows Server 2003 第二台开发机,不暂停,489 秒 慢 3.6%(同样时间约 −17.6 秒) 偏差一直在 +38 到 +47 毫秒之间
Windows XP、Server 2003 每次暂停 60 秒 丢掉整段暂停 还是丢掉整段暂停(还没做,见下一节)

guest 的 dmesg 里没有和时钟有关的报错,TCP 连接、磁盘写入和后台循环都正常。

还没做的

  • 给数中断的 guest 补发丢掉的中断:恢复后在虚拟 RTC 和 PIT 上稍快一点地把少掉的中断补上。QEMU 的 -rtc driftfix=slew 和 VMware 的追赶就是这个思路。做完 XP/2003 才能在暂停后也保持准确。
  • 忙的时候合并掉的中断:虚拟机很忙时,两个时钟中断合成一个的情况还会出现,补发也一并解决它。
  • 其他定时器跟着前移:LAPIC 定时器、PIT、ACPI PM timer 现在不往前拨。FreeBSD 和 Linux 平时不用它们计时,所以排在后面。
  • Windows 7/10 还没量。
  • 超过一小时的暂停不补,这是故意的上限,再长的交给 guest 里的 NTP。

结论

虚拟机的时间没有一个开关能修好。每种 guest 计时的办法不同,每个模拟的时钟设备都可能有自己的漏洞,很多问题还只在真机、真负载下才出现。

keelOS 的做法是不靠 guest 自己:在宿主这边把计数器分步拨准,补上 FreeBSD 快照里丢时间的地方,让模拟的时钟按绝对时间走。每一步都在真机上量过。今天,FreeBSD 和 Linux guest 跨几十次 root 重启,时钟误差在半秒以内;XP 和 2003 平时不再漂。下一步是给它们补发暂停时丢掉的中断。guest 里的 NTP 可以照常开着,当最后一道保险。

← 文章 · 为什么选 keelOS