|
| 1 | +--- |
| 2 | +title: "如何设计虚拟机的关机流程" |
| 3 | +summary: "从 revm 的一次关机流程重构出发,讨论虚拟机运行时里 graceful shutdown、force shutdown、等待语义和宿主侧服务生命周期应该如何拆分。" |
| 4 | +description: "从 revm 的一次关机流程重构出发,讨论虚拟机运行时里 graceful shutdown、force shutdown、等待语义和宿主侧服务生命周期应该如何拆分。" |
| 5 | +date: 2026-05-16 |
| 6 | +draft: false |
| 7 | +categories: |
| 8 | + - "virtualization" |
| 9 | +tags: |
| 10 | + - "design" |
| 11 | + - "virtualization" |
| 12 | + - "revm" |
| 13 | +--- |
| 14 | + |
| 15 | +虚拟机的关机流程很容易被写成一团:收到用户中断,发一个信号,关几个服务,杀掉虚拟机,然后退出。 |
| 16 | + |
| 17 | +这看起来能工作,但一旦运行时变复杂,就会暴露出许多问题:日志丢失、磁盘没来得及 sync、guest-agent 没跑完整、宿主侧代理提前退出、第二次 Ctrl-C 也不能立刻结束。 |
| 18 | + |
| 19 | +这次 revm 的关机流程重构,本质上不是修一个 Go 代码问题,而是重新思考一个虚拟机运行时应该如何表达“关机”。 |
| 20 | + |
| 21 | +<!--more--> |
| 22 | + |
| 23 | +## 关机不是一个动作 |
| 24 | + |
| 25 | +很多 bug 都来自一个错误抽象:把“关机”当成一个动作。 |
| 26 | + |
| 27 | +实际上,对于一个虚拟机运行时来说,关机至少包含三种完全不同的语义。 |
| 28 | + |
| 29 | +第一种是 **请求 guest 自己关机**。 |
| 30 | + |
| 31 | +这是一种 polite request。host 告诉 guest:“请你开始收尾。” guest-agent 可能会 flush 日志、停止服务、sync filesystem,最后 reboot 或 poweroff。 |
| 32 | + |
| 33 | +第二种是 **等待 guest 退出**。 |
| 34 | + |
| 35 | +这是 host 侧的等待行为。host 不一定在做什么,只是在等 VMM、hypervisor、子进程或者某个阻塞调用返回。 |
| 36 | + |
| 37 | +第三种是 **放弃等待并强制收尾**。 |
| 38 | + |
| 39 | +这意味着 host 已经不再相信 guest 能及时退出,或者用户已经不想等了。此时运行时要中断等待、停止宿主侧代理、释放 socket、关闭转发器,必要时 kill 子进程。 |
| 40 | + |
| 41 | +这三件事如果混成一个 cancel 或一个 signal,流程就会变得很脆。 |
| 42 | + |
| 43 | +## 第一次 Ctrl-C 应该是什么 |
| 44 | + |
| 45 | +对于交互式命令,Ctrl-C 往往有两种语义。 |
| 46 | + |
| 47 | +第一次 Ctrl-C 通常应该是: |
| 48 | + |
| 49 | +> 我想结束它,请正常退出。 |
| 50 | +
|
| 51 | +第二次 Ctrl-C 才是: |
| 52 | + |
| 53 | +> 我不想等了,立刻停。 |
| 54 | +
|
| 55 | +这个习惯很重要。它给运行时提供了一个自然的两阶段退出协议。 |
| 56 | + |
| 57 | +对于虚拟机来说,第一次 Ctrl-C 不应该直接解释成“host 不再等待 VM”。它更适合解释成: |
| 58 | + |
| 59 | +```text |
| 60 | +request guest shutdown |
| 61 | +``` |
| 62 | + |
| 63 | +也就是通知 guest-agent 进入关机路径。guest 内部应该有机会执行: |
| 64 | + |
| 65 | +```text |
| 66 | +stop services |
| 67 | +flush logs |
| 68 | +sync filesystem |
| 69 | +reboot / poweroff |
| 70 | +``` |
| 71 | + |
| 72 | +host 此时仍然应该继续等待 VM 自己退出。 |
| 73 | + |
| 74 | +如果第一次 Ctrl-C 直接打断 host 的等待路径,那么 guest-agent 可能还没来得及跑完整,宿主侧就已经开始 teardown。用户看到的现象通常是日志顺序混乱、shutdown 日志缺失,或者某些资源像是被硬切掉。 |
| 75 | + |
| 76 | +## 第二次 Ctrl-C 应该是什么 |
| 77 | + |
| 78 | +第二次 Ctrl-C 的语义应该非常明确: |
| 79 | + |
| 80 | +```text |
| 81 | +force shutdown |
| 82 | +``` |
| 83 | + |
| 84 | +它不再是一个 polite request,而是 host 侧的决策: |
| 85 | + |
| 86 | +```text |
| 87 | +stop waiting for guest |
| 88 | +tear down host services |
| 89 | +return to user as soon as possible |
| 90 | +``` |
| 91 | + |
| 92 | +这里的重点是“stop waiting”,而不是“再请求一次 guest 关机”。 |
| 93 | + |
| 94 | +如果 guest 能正常关,第一次 Ctrl-C 已经足够。如果第二次 Ctrl-C 发生了,说明用户已经表达了不愿意继续等待。此时运行时应该把控制权还给用户。 |
| 95 | + |
| 96 | +## 等待本身也需要被设计 |
| 97 | + |
| 98 | +虚拟机运行时里经常有一个阻塞点: |
| 99 | + |
| 100 | +```text |
| 101 | +start VM and wait until it exits |
| 102 | +``` |
| 103 | + |
| 104 | +这个阻塞点可能来自不同实现: |
| 105 | + |
| 106 | +- 当前进程内的 VMM 调用 |
| 107 | +- libkrun / qemu / firecracker 之类的 backend |
| 108 | +- 一个子进程 |
| 109 | +- 一个 RPC session |
| 110 | +- 一个管理 socket |
| 111 | + |
| 112 | +不管实现细节是什么,host 都需要表达一个问题: |
| 113 | + |
| 114 | +> 我还要不要继续等这个 VM 自己退出? |
| 115 | +
|
| 116 | +这和“请求 guest 关机”不是同一个问题。 |
| 117 | + |
| 118 | +请求 guest 关机是发给 guest 的消息;停止等待是 host 自己的控制流。 |
| 119 | + |
| 120 | +所以设计上最好给“等待 VM 退出”一个独立的 abort signal。它可以是 context,可以是 channel,可以是 eventfd,也可以是 supervisor 内部的状态转换。具体机制不重要,重要的是语义: |
| 121 | + |
| 122 | +```text |
| 123 | +abort VM wait != request guest shutdown |
| 124 | +``` |
| 125 | + |
| 126 | +这个区分能避免许多隐性 bug。 |
| 127 | + |
| 128 | +## 宿主侧服务也有自己的生命周期 |
| 129 | + |
| 130 | +一个现代虚拟机运行时通常不只是启动 VM。它还会启动一堆 host-side services: |
| 131 | + |
| 132 | +- 网络栈 |
| 133 | +- 端口转发 |
| 134 | +- 管理 API |
| 135 | +- ignition / metadata server |
| 136 | +- SSH proxy |
| 137 | +- container API proxy |
| 138 | +- 日志转发 |
| 139 | +- 文件系统共享 |
| 140 | + |
| 141 | +这些服务依附于 VM,但不等同于 VM。 |
| 142 | + |
| 143 | +它们的生命周期应该是: |
| 144 | + |
| 145 | +```text |
| 146 | +VM 还在运行 -> host services 应该活着 |
| 147 | +VM 已经退出 -> host services 应该收掉 |
| 148 | +强制退出 -> host services 应该尽快收掉 |
| 149 | +host service 自己失败 -> VM run 应该进入失败/强制收尾路径 |
| 150 | +``` |
| 151 | + |
| 152 | +这意味着 host services 也需要一个独立的生命周期控制。 |
| 153 | + |
| 154 | +如果把 host services 的生命周期和 VM wait 混成一个信号,第一次 Ctrl-C 时就很容易出现错误:guest 还没关完,metadata server 或网络代理先被停掉了。 |
| 155 | + |
| 156 | +对于某些 guest 关机路径来说,这些服务甚至可能仍然是必要的。例如 guest-agent 需要通过 virtio port、vsock、网络或管理通道完成最后一次通信。host 提前 teardown 会破坏 graceful shutdown 本身。 |
| 157 | + |
| 158 | +## 一个更稳的状态机 |
| 159 | + |
| 160 | +我更倾向于把虚拟机运行时的退出流程设计成下面这个状态机: |
| 161 | + |
| 162 | +```text |
| 163 | +running |
| 164 | + | |
| 165 | + | first interrupt |
| 166 | + v |
| 167 | +shutdown requested |
| 168 | + | |
| 169 | + | guest exits |
| 170 | + v |
| 171 | +finished |
| 172 | +
|
| 173 | +shutdown requested |
| 174 | + | |
| 175 | + | second interrupt / timeout / parent disappeared |
| 176 | + v |
| 177 | +forcing |
| 178 | + | |
| 179 | + | host resources released |
| 180 | + v |
| 181 | +finished |
| 182 | +``` |
| 183 | + |
| 184 | +这里有几个关键点。 |
| 185 | + |
| 186 | +`running -> shutdown requested` 是 graceful path。它应该通知 guest,而不是中断 host 的等待。 |
| 187 | + |
| 188 | +`shutdown requested -> finished` 是 guest 自己退出。此时 host 停止附属服务并返回。 |
| 189 | + |
| 190 | +`shutdown requested -> forcing` 是放弃等待。它可以由第二次 Ctrl-C 触发,也可以由超时、父进程退出、后台 supervisor 取消任务触发。 |
| 191 | + |
| 192 | +`forcing -> finished` 是 host 侧强制收尾。它的目标不是优雅,而是 bounded cleanup。 |
| 193 | + |
| 194 | +## 超时是否必要 |
| 195 | + |
| 196 | +两次 Ctrl-C 之外,很多运行时还会加一个 timeout。 |
| 197 | + |
| 198 | +例如: |
| 199 | + |
| 200 | +```text |
| 201 | +first Ctrl-C |
| 202 | + -> request guest shutdown |
| 203 | + -> wait up to 30 seconds |
| 204 | + -> force shutdown |
| 205 | +``` |
| 206 | + |
| 207 | +这是否应该做,取决于产品语义。 |
| 208 | + |
| 209 | +对于交互式 CLI,我更喜欢不默认加很短的超时,而是提示用户: |
| 210 | + |
| 211 | +```text |
| 212 | +waiting for guest shutdown; press Ctrl-C again to force |
| 213 | +``` |
| 214 | + |
| 215 | +原因是用户就在终端前,可以自己决定要不要等。 |
| 216 | + |
| 217 | +对于 daemon、CI、系统服务,timeout 更有必要。因为没有人在旁边按第二次 Ctrl-C,运行时必须保证最终能回收资源。 |
| 218 | + |
| 219 | +所以 timeout 不是关机设计的核心,而是策略层。核心仍然是区分: |
| 220 | + |
| 221 | +```text |
| 222 | +request graceful shutdown |
| 223 | +abort waiting |
| 224 | +cleanup host resources |
| 225 | +``` |
| 226 | + |
| 227 | +## 父进程消失时不必装作 graceful |
| 228 | + |
| 229 | +还有一种特殊情况:launcher 或 parent process 消失了。 |
| 230 | + |
| 231 | +这和用户第一次 Ctrl-C 不一样。 |
| 232 | + |
| 233 | +第一次 Ctrl-C 时,用户还在,运行时还被某个前台交互流程拥有。此时等待 guest 优雅退出是合理的。 |
| 234 | + |
| 235 | +但如果 parent process 已经消失,运行时通常应该尽快 force shutdown。因为 owning process 已经没了,继续长时间等待会让后台资源悬挂。 |
| 236 | + |
| 237 | +所以 parent exit 更像: |
| 238 | + |
| 239 | +```text |
| 240 | +force shutdown |
| 241 | +``` |
| 242 | + |
| 243 | +而不是: |
| 244 | + |
| 245 | +```text |
| 246 | +request guest graceful shutdown and wait forever |
| 247 | +``` |
| 248 | + |
| 249 | +这是一个 ownership 问题,不是 guest 是否支持 graceful shutdown 的问题。 |
| 250 | + |
| 251 | +## 不要让机制吞掉语义 |
| 252 | + |
| 253 | +这次重构里最容易误导人的地方是 `context`。 |
| 254 | + |
| 255 | +在 Go 里,`context.Context` 是一个很方便的取消机制。但机制本身不携带业务语义。你把它叫 `ctx`,它就什么都能表示: |
| 256 | + |
| 257 | +- 用户取消 |
| 258 | +- VM wait abort |
| 259 | +- host services teardown |
| 260 | +- request guest shutdown |
| 261 | +- parent process exit |
| 262 | +- backend failure |
| 263 | + |
| 264 | +一旦这些语义都塞进同一个 `ctx`,代码就很难回答一个问题: |
| 265 | + |
| 266 | +> cancel 这个 ctx 到底是在请求 guest 关机,还是在放弃等待 guest? |
| 267 | +
|
| 268 | +这个问题不只存在于 Go。 |
| 269 | + |
| 270 | +换成其他语言也是一样。一个 channel、一个 promise cancellation、一个 cancellation token、一个 eventfd、一个 unix signal,如果名字和状态机不清楚,都可能变成“万能退出按钮”。 |
| 271 | + |
| 272 | +万能退出按钮的坏处是:它太容易工作了,直到你需要 graceful shutdown。 |
| 273 | + |
| 274 | +## 可迁移的设计原则 |
| 275 | + |
| 276 | +我从这次重构里总结出的原则是: |
| 277 | + |
| 278 | +### 1. 把 request 和 abort 分开 |
| 279 | + |
| 280 | +请求 guest 关机是 request。 |
| 281 | + |
| 282 | +停止 host 等待是 abort。 |
| 283 | + |
| 284 | +它们可以先后发生,但不应该是同一个动作。 |
| 285 | + |
| 286 | +### 2. 把 VM 生命周期和 host services 生命周期分开 |
| 287 | + |
| 288 | +VM 退出后,host services 应该停止。 |
| 289 | + |
| 290 | +但请求 VM 退出时,host services 不一定应该马上停止。 |
| 291 | + |
| 292 | +### 3. 第二次中断必须有明确语义 |
| 293 | + |
| 294 | +第一次中断 request graceful shutdown。 |
| 295 | + |
| 296 | +第二次中断 force shutdown。 |
| 297 | + |
| 298 | +不要让第二次中断只是重复发送同一个 shutdown signal。 |
| 299 | + |
| 300 | +### 4. force path 必须能尽快返回 |
| 301 | + |
| 302 | +force shutdown 的价值在于 bounded behavior。 |
| 303 | + |
| 304 | +如果 force path 仍然可能无限等待,那它就不是 force。 |
| 305 | + |
| 306 | +### 5. parent exit 是 ownership 结束 |
| 307 | + |
| 308 | +父进程消失通常应该触发 force cleanup。 |
| 309 | + |
| 310 | +这条路径不应该和用户第一次 Ctrl-C 使用同一套 graceful wait 语义。 |
| 311 | + |
| 312 | +### 6. 命名要暴露状态机 |
| 313 | + |
| 314 | +好的名字应该让读者看到控制流: |
| 315 | + |
| 316 | +```text |
| 317 | +requestGuestShutdown |
| 318 | +abortVMWait |
| 319 | +stopHostServices |
| 320 | +forceVMRun |
| 321 | +finishVMRun |
| 322 | +``` |
| 323 | + |
| 324 | +坏的名字会隐藏设计: |
| 325 | + |
| 326 | +```text |
| 327 | +ctx |
| 328 | +stop |
| 329 | +shutdown |
| 330 | +cancel |
| 331 | +done |
| 332 | +``` |
| 333 | + |
| 334 | +这些词不是不能用,而是不能在复杂生命周期里单独使用。 |
| 335 | + |
| 336 | +## 小结 |
| 337 | + |
| 338 | +虚拟机的关机流程不是“收到信号然后退出”这么简单。 |
| 339 | + |
| 340 | +更稳的设计是把它拆成三层: |
| 341 | + |
| 342 | +```text |
| 343 | +guest graceful shutdown request |
| 344 | +host-side VM wait control |
| 345 | +host services lifecycle |
| 346 | +``` |
| 347 | + |
| 348 | +第一次 Ctrl-C 只进入第一层。第二次 Ctrl-C 才进入后两层。 |
| 349 | + |
| 350 | +这样设计后,guest 有机会完整执行自己的 shutdown path,host 又保留了强制退出的能力。无论底层是 libkrun、qemu、firecracker,还是一个自研 VMM 子进程,这个设计都可以迁移。 |
| 351 | + |
| 352 | +真正重要的不是用了哪种语言、哪个 context、哪个 channel,而是状态机本身足够诚实:它清楚地区分了“请你关机”和“我不等了”。 |
0 commit comments