Skip to content

Commit 0de941a

Browse files
committed
add revm dockerd docker api proxy doc
Signed-off-by: ihexon <14349453+ihexon@users.noreply.github.com>
1 parent 56722ec commit 0de941a

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

  • content/posts/revm-shutdown-design
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
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

Comments
 (0)