跳到主要内容

进程模型与沙箱生命周期:从 SDK 一行到微VM 启动、执行、退出

30 秒导读: 你在 Python/Rust 里写 Sandbox.start(...),底层不是去连某个守护进程,而是直接 fork+exec 出一个隐藏的 msb sandbox 子进程。这个子进程在自己的地址空间里拉起一台微型虚拟机(microVM),把配置(含密钥、含网络)通过固定文件描述符而非命令行传进去,然后把整个进程的控制权交给一个永不返回Vm::enter()。虚拟机里,agentd 作为 1 号进程先同步挂载好文件系统,再进入异步循环听候主机差遣。沙箱退出时,退出原因被归为固定几类,数据库落状态、socket 清理、进程 _exit。本章端到端讲清这条主干。

本章是理解 Microsandbox 全局的主干。协议帧格式与 relay 路由细节见 02-protocol-relay;镜像/根文件系统见 03-image-rootfs;客机文件系统实现见 04-guest-filesystem;网络栈与安全边界见 05-network-security。总览见 index


1. 这是什么(零基础也能懂)

一句话定义: Microsandbox 让你用一行代码,把不可信的代码(比如 AI 生成的 Python)扔进一台真·虚拟机里跑——比容器更强的隔离,却几乎和容器一样快启动。

给谁用、解决什么问题: 假设你在做一个 AI Agent,模型会生成代码要执行。直接在你机器上 exec 太危险(能读你的文件、能联网外传)。传统方案要么用容器(共享内核,逃逸风险)、要么用云上远程沙箱(慢、要联网、要运维一个服务)。Microsandbox 的答案是:本地一台微VM,秒起秒关,不需要你先跑起一个后台服务器

用起来什么样: SDK 侧就是"开一个沙箱、执行、关掉":

# 取自 README.md:161-178
import asyncio
from microsandbox import Sandbox

async def main():
sandbox = await Sandbox.create( # 起一台微VM
"my-sandbox",
image="python",
cpus=1,
memory=512,
)

output = await sandbox.exec("python", ["-c", "print('Hello from a microVM!')"])

print(output.stdout_text) # 拿到 stdout(属性,非方法)

await sandbox.stop() # 关掉 —— 微VM 消亡

asyncio.run(main())

一句话直觉:Sandbox.start() 想成 shell 里的 ./msb sandbox ... —— 它就是你的进程直接生了一个子进程,只不过这个子进程"体内"装着一台虚拟机。没有中间的 daemon,没有 socket 去连一个常驻服务;父进程在,沙箱在;父进程走,沙箱(默认)也跟着走。


2. 顶层全景(它大概怎么转)

2.1 三个进程、一条链

一个运行中的沙箱牵涉三个层次的执行体,理解它们的边界是理解全局的钥匙:

层次是什么在哪干什么
① 创建者进程你的应用 + Microsandbox SDK主机,你的代码里spawn_sandbox,fork+exec 出 ②,然后通过 relay socket 跟客机说话
msb sandbox 进程隐藏的沙箱宿主进程主机,SDK fork 出来的子进程本进程内配置并运行 VMM;跑 relay/心跳/超时等背景服务;控制权最终交给 Vm::enter() 永不返回
③ agentd(PID 1)客机里的 1 号进程microVM 内部先同步挂载文件系统,再跑异步 agent loop,通过 virtio-serial 听 ② 的差遣、执行用户命令

2.2 一张图:诞生到消亡

怎么读这张图:从上往下是时间顺序。左列是主机侧两个进程,右列是客机内部。虚线 = 进程边界;═══ = 那条"永不返回"的 VMM 主线。

┌───────────────────────── 主机 (host) ─────────────────────────┐ ┌──── microVM 客机 ────┐
│ │ │ │
│ ① 创建者进程 (你的 App + SDK) │ │ │
│ Sandbox.start() │ │ │
│ │ spawn_sandbox() │ │ │
│ │ · 配置拆成两半:少量标签走 argv │ │ │
│ │ · 大块(网络/含密钥 env)写进匿名文件 → CONFIG_FD │ │ │
│ │ fork + exec "msb sandbox --config-fd 96 ..." │ │ │
│ ▼ │ │ │
│ ················· 进程边界 ································· │ │ │
│ ② msb sandbox 进程 │ │ │
│ vm::enter(config) │ │ │
│ · 写 startup JSON {pid} → STARTUP_FD/stdout ──────►│───┐ ① 读到 pid,握手成功 │
│ · 起 tokio:relay / 心跳读取 / idle / max-duration │ │ │ │
│ · build_vm(): msb_krun::VmBuilder 配 CPU/内存/盘/网│ │ │ │
│ ═══ vm.enter() ← 永不返回,VMM 接管本进程 ═══════►│───┼─► 内核启动 │
│ │ │ │ agentd = PID 1 │
│ │ │ │ init::init() 同步 │
│ │ │ │ 挂 /proc /sys... │
│ │ │ │ (可选) PID1 handoff│
│ │ │ │ agent::run() 异步 │
│ ① relay socket 连上 ◄──── core.ready(boot 计时) ───────│───┘ │ 每秒写 heartbeat │
│ │ exec("python",...) ─── 帧 ──► │ │ │ 跑用户命令 │
│ ▼ │ │ │ │
│ stop()/idle/超时/父进程退出 │ │ │ │
│ └─► 触发 exit_handle ═══► on_exit 观察器:落库+清 socket│ │ │ │
│ → _exit() ② 消亡 │ │ └── 内核 poweroff ────┘
└────────────────────────────────────────────────────────────────┘

2.3 主线走一遍(高层,不进代码)

  1. 你调 Sandbox.start() → SDK 的 spawn_sandbox 组装参数、fork+exec 出 msb sandbox
  2. msb sandbox 一进来先报到:把自己的 PID 写成一行 JSON 交回父进程,父进程据此确认"孩子活着"。
  3. 它在本进程内起背景服务(agent relay、心跳读取器、idle/max-duration 定时器),再用 VmBuilder 配好一台微VM。
  4. vm.enter(),从此不再返回:VMM 占据这个进程,启动客机内核。
  5. 客机里 agentd 作 PID 1:先同步/proc/sys、根文件系统等挂好,再进异步 agent loop,发出 core.ready,开始每秒写心跳、听候执行命令。
  6. 父进程通过 relay socket 连上,此后 exec、读文件等都走这条通道(→02)。
  7. 退出:无论触发者是 stop()、idle 超时、达到最大时长、父进程死掉,还是收到信号,都归结到一个共享的退出原因枚举,由 VMM 的 on_exit 观察器同步落库、清理 socket,最后 _exit

3. 核心原理(逐个机制,由浅入深)

3.1 无守护进程模型:为什么直接 fork+exec 而不是连服务器

它要解决的小问题: 大多数"远程执行"系统有个常驻 daemon,客户端连它、它管进程。Microsandbox 偏偏没有。为什么?

思路/直觉: 沙箱的生死应该和创建它的进程绑定。你的 App 在,沙箱在;App 崩了,沙箱不该变成孤儿泄漏在系统里。达成这点最直接的办法就是父子进程关系——用操作系统本身的进程树来托管生命周期,而不是自己写一套注册-心跳-回收的 daemon。

所以 SDK 的做法就是最朴素的 fork+exec:

// sdk/rust/lib/runtime/spawn.rs:451 —— 就是 std 的进程 API,没有任何 RPC
let mut cmd = Command::new(&msb_path);
cmd.args(visible);
cmd.stdin(Stdio::null()); // 别继承父进程的终端,否则 VMM 的隐式 console 会把终端弄成 raw 模式

spawn_sandbox(sdk/rust/lib/runtime/spawn.rs:259)干的就是:解析 msb 二进制路径 → 建目录 → 组装参数 → cmd.spawn() → 读孩子回报的启动 JSON。整个过程没有一行"连接服务器"。

握手:孩子必须先证明自己活着。 父进程 spawn 完不能马上就用,它要等孩子回报 PID:

// sdk/rust/lib/runtime/spawn.rs:577 —— 最多等 30 秒读一行启动 JSON
let line = tokio::time::timeout(
Duration::from_secs(30),
read_startup_line(&mut child, startup_pipe),
).await;
let startup: StartupInfo = serde_json::from_str(line.trim());
if startup.pid != _pid { // spawn.rs:614
// 报的 PID 跟 spawn 出来的对不上 → 判为异常,杀掉重来
}

这行 JSON 是子进程在 run()最先写出来的(见 §3.4),它同时充当"我已成功进入 msb sandbox、准备起 VM 了"的信号。对不上就说明子进程根本没进对代码路径,直接终止。

Attached vs Detached。 两种模式决定"父死子是否跟着死":

模式语义机制
SpawnMode::Attached父进程持有沙箱,父死则沙箱停建一个 parent-watchdog 管道,读端给子进程(见 §3.6)
SpawnMode::Detached沙箱要活过创建它的进程子进程 setsid() 脱离会话,启动 JSON 走专用 fd,stdout/stderr 从出生就切断

枚举定义见 sdk/rust/lib/runtime/spawn.rs:173SpawnMode

3.2 配置越过 argv:固定 fd + LaunchConfig(issue #997)

它要解决的小问题: 一个沙箱的配置很大——网络配置一大坨、环境变量里可能含 API 密钥、还有一堆挂载路径。如果全塞进命令行参数,那么系统里任何人 ps auxcat /proc/<pid>/cmdline 都能看到你的密钥。这是 issue #997 要堵的洞。

思路/直觉: 把配置一分为二。只有"给运维看的无害标签"(vcpus、内存、sandbox 名字等)留在 argv;真正的大块和敏感内容序列化成 JSON,通过一个继承来的文件描述符传给子进程。fd 不出现在 ps,也不出现在 /proc/<pid>/cmdline

拆分就发生在这里:

// sdk/rust/lib/runtime/spawn.rs:391 —— visible 上 argv,launch 走 fd
let (mut visible, launch) = sandbox_cli_args();

launch 是一个 LaunchConfig(crates/runtime/lib/launch.rs:26),它几乎装下了一切:db 路径、日志目录、agent socket、libkrunfw 路径、启动命令、Lifecycle(生命周期上下限)、metrics、rootfs、mounts、env、以及 network。文件头的注释一句话点破了它的定位:

它把网络配置和含密钥的 env 挡在 ps/proc/<pid>/cmdline 之外——见 issue #997。(crates/runtime/lib/launch.rs:1-9)

怎么传? Unix 上用一个匿名临时文件(创建即 unlink,没有路径可清理、可竞争),写完 JSON、rewind 到 0,再 dup2 到固定的约定 fd 号:

// sdk/rust/lib/runtime/spawn.rs:775 write_launch_config_fd
let mut file = tempfile::tempfile()?; // 匿名,已 unlink
let json = serde_json::to_vec(launch)?;
file.write_all(&json)?; file.flush()?;
file.seek(SeekFrom::Start(0))?; // 让子进程从头读

三个固定 fd 号是主机与子进程之间的硬约定:

常量方向用途
CONFIG_FD96父 → 子承载整个 LaunchConfig JSON
PARENT_WATCH_FD97父 → 子attached 模式的父进程看门狗管道
STARTUP_FD98子 → 父detached 模式下回报启动 JSON

定义见 crates/runtime/lib/vm.rs:65-71。SDK 在 pre_exec 回调里用 dup2 把真实 fd 精确搬到这几个号上(sdk/rust/lib/runtime/spawn.rs:471),还特意处理了"父运行时(如 Vitest、Go test)打开的文件正好占了这几个号"的边角情况(move_reserved_source_fd,spawn.rs:869)。

另一端怎么读? msb sandbox 命令(crates/cli/lib/sandbox_cmd.rs)从 --config-fd 96 把 JSON 读回来、反序列化成 LaunchConfig,再摊平成 VMM 用的 VmConfig 和运行时用的 Config,最后调 vm::enter:

// crates/cli/lib/sandbox_cmd.rs:213 load_launch_config —— fd 优先,--config-file 兜底(手动调试用)
let bytes = match (args.config_fd, &args.config_file) {
(Some(fd), _) => read_config_fd(fd)?,
(None, Some(path)) => std::fs::read(path)?,
(None, None) => return Err("missing --config-fd or --config-file …"),
};

Windows 没有 Unix 的匿名 fd 交接,于是退化成"写一个短命名文件、把路径放 argv、父进程留着句柄直到孩子报到"(write_launch_config_file,spawn.rs:791)。

3.3 msb sandbox 进程内部:一个进程里既是宿主又是 VMM

它要解决的小问题: VMM(虚拟机监视器,这里是 msb_krun,libkrun 的封装)必须在某个进程里运行。Microsandbox 不为它单起进程,而是msb sandbox 这个进程自己变成 VMM

关键设计:enter() 的类型是 -> !(永不返回)。 这不是修辞,是 Rust 类型系统层面的承诺:

// crates/runtime/lib/vm.rs:409
pub fn enter(config: Config) -> ! {

let result = run(config); // run 返回 RuntimeResult<Infallible>
match result {
Ok(infallible) => match infallible {}, // Infallible 没有值,这个分支不可达
Err(e) => { // 只有出错才走到这
… boot_error.write_atomic … // 写结构化 boot-error.json 供父 CLI 展示
std::process::exit(1); // vm.rs:428
}
}
}

正常情况下,run() 内部最后调 vm.enter()(msb_krun 的),VMM 接管进程直到客机关机时 _exit();所以 run() 的成功返回类型是 Infallible——"成功"永远不会真的从这里返回。唯一能从 enter 掉出来的路径是失败,此时写一份 boot-error.json 让父进程能内联展示真实原因,再 exit(1)

run() 里的编排顺序(crates/runtime/lib/vm.rs:433),按发生先后:

  1. 最先写 startup JSON 并重定向输出(vm.rs:436)——必须赶在任何 tracing 之前,否则日志会污染要交回父进程的那行 JSON,也会喷到终端。
  2. 建 tokio 多线程运行时(2 worker),给 relay、心跳、定时器用。
  3. heartbeat::clear_stale:心跳是每次 boot 一份,而 runtime 目录跨多次启动持久,所以先清掉上一轮的残留(vm.rs:466)。
  4. 并发建 relay 和连数据库,插入一条 run 记录。
  5. build_vm(...):用 VmBuilder 配 CPU/内存/根文件系统/额外挂载/磁盘/网络/console,并挂上退出观察器(见 §3.5)。
  6. 起一串背景 tokio 任务:relay 的 wait_ready + accept 循环、shutdown 监听、可选的 startup 命令、idle 监视器max-duration 定时器
  7. std::mem::forget(tokio_rt):故意泄漏 tokio 运行时,好让背景任务在主线程进 VM 后继续活着(vm.rs:977)。
  8. vm.enter(),永不返回(vm.rs:981)。

build_vm 里能看到微VM 硬件是怎么"拼"出来的:

// crates/runtime/lib/vm.rs:1030 —— 建造者模式配一台机器
let mut builder = VmBuilder::new()
.machine(|m| m.vcpus(vm.vcpus).memory_mib(vm.memory_mib as usize))
.kernel(|k| { … k.init_path(init_path)}); // 根文件系统/磁盘/网络/console 随后逐个 .fs/.disk/.net/.console

3.4 startup JSON:进程刚活过来就先报到

这一步小但关键,把 §3.1 的握手补全。子进程 run() 一开头(crates/runtime/lib/vm.rs:436):

let pid = std::process::id();
let startup_json = serde_json::to_string(&StartupInfo { pid })?;
#[cfg(unix)] write_startup_info(config.startup_fd.as_ref(), &startup_json)?; // vm.rs:442
setup_log_capture(&config.log_dir, config.forward_output)?; // 之后 tracing 才安全

write_startup_info(vm.rs:1441)的逻辑:有专用 startup fd(detached)就写 fd,否则写 stdout(attached)。这正好对上父进程 read_startup_line 的两条读法(spawn.rs:805):detached 从管道读、attached 从子进程 stdout 读。一行 {"pid":...} 就是整个握手协议。

3.5 退出原因枚举:所有死法归为七类

它要解决的小问题: 沙箱可以因为很多原因结束。要把结局如实记进数据库、要对不同结局做不同清理,就得先有一套统一的"死因分类"

机制:一个共享的 AtomicU8 各背景任务在触发退出前,先把死因原子地写进这个共享变量;VMM 的退出观察器再把它读出来映射成数据库里的 TerminationReason。死因常量(crates/runtime/lib/vm.rs:50-60):

常量触发者含义
EXIT_REASON_COMPLETED0默认 / 客机正常关机正常完成
EXIT_REASON_IDLE_TIMEOUT1idle 监视器空闲超时被回收
EXIT_REASON_MAX_DURATION2max-duration 定时器达到最大寿命
EXIT_REASON_SIGNAL3网络安全钩子 / watchdog 出错信号/异常终止
EXIT_REASON_PARENT_EXIT4parent-watchdog创建者进程退出
EXIT_REASON_AGENT_UNRESPONSIVE5relay wait_ready 超时客机没能在启动窗口内就绪
EXIT_REASON_SHUTDOWN_REQUESTED6core.shutdown 转发请求关机
(另有 STARTUP_COMMAND_FAILED = 7)7startup 命令失败启动命令非零退出

触发退出的统一动作是 exit_handle.trigger(),例如 idle 监视器(vm.rs:934):

heartbeat_reason.store(EXIT_REASON_IDLE_TIMEOUT, Ordering::SeqCst);
match request_guest_shutdown(&heartbeat_shared) {} // 先请客机优雅 poweroff、给 flush 窗口
heartbeat_exit_handle.trigger(); // 再让 VMM 收摊

退出观察器在 VMM 线程上、_exit() 之前同步跑(vm.rs:543build_vm 传入的 on_exit 闭包):读死因 → 把 run 标记为 Terminated 并写 reason/exit_code → 把 sandbox 标记为 Stopped → 若是 ephemeral 沙箱则自清(删行删目录)→ 释放 metrics slot → remove_file(agent.sock)。为什么这些清理不放在 relay 的异步析构里?因为 _exit() 会跳过所有 Rust 的 Drop 和 tokio 任务收尾——所以凡是必须发生的清理,都得塞进这个同步观察器里手动做(vm.rs:634 注释点破了这点)。

3.6 生命周期边界:三个独立的"回收闸门"

沙箱能被回收有三条独立路径,分别对应 Lifecycle(crates/runtime/lib/launch.rs:92)里的两个上限和一个父进程绑定:

  • idle 超时 —— idle 监视器每秒读客机写的心跳,判断是否空闲够久(§3.7)。仅当配置了 idle_timeout_secs 才生效(vm.rs:908)。
  • max-duration —— 一个简单的 tokio::time::sleep(max_secs),到点就 trigger(vm.rs:962)。硬上限,不管忙不忙。
  • 父进程看门狗(attached) —— 一个专门的 OS 线程死等 PARENT_WATCH_FD 管道(spawn_parent_watchdog,vm.rs:1563)。父进程一旦退出,管道读到 EOF(read 返回 0),线程判定 ParentExited,存 EXIT_REASON_PARENT_EXIT 并请客机关机:
// crates/runtime/lib/vm.rs:1603 read_parent_watchdog_signal
match std::io::Read::read(file, &mut buf) {
Ok(0) => return Ok(ParentWatchdogSignal::ParentExited), // 管道写端全关 = 父进程没了
Ok(_) if buf[0] == PARENT_WATCH_DETACH =>Detached, // 主动写一个字节 = 请解绑,别停沙箱

}

注意还有第四条"软"路径:relay 的 wait_ready 若在启动窗口内没等到客机 core.ready,就判 AGENT_UNRESPONSIVE 回收——这是开机失败的兜底,专门从心跳监视器里挪出来,好让心跳监视器只管 idle 检测(vm.rs:796-807)。

3.7 心跳:客机每秒写、主机每秒读

它要解决的小问题: 主机怎么知道客机是"忙"还是"闲"?

机制:一份跨 virtiofs 的 JSON 文件。 客机侧 agentd 用一个专用 OS 线程(不是 tokio 任务,防止异步运行时被打满时心跳被饿死)每秒原子地写 /.msb/heartbeat.json:

// crates/agentd/lib/heartbeat.rs:28 write_heartbeat —— 先写 tmp 再 rename,保证原子
std::fs::write(HEARTBEAT_TMP_PATH, json)?;
std::fs::rename(HEARTBEAT_TMP_PATH, HEARTBEAT_PATH)?; // /.msb/heartbeat.json

这个 /.msb 目录是通过 virtiofs 共享给主机的运行时目录(→04),所以主机侧 HeartbeatReader(crates/runtime/lib/heartbeat.rs:24)直接读同一份文件。

判定逻辑很克制(heartbeat.rs:128check):它只回答"是不是 idle",绝不把"心跳停了"本身当成杀死理由。三条规则:

  1. 还没见过任何心跳 → PendingBoot(还在开机,交给 relay 超时管)。
  2. 有活跃 exec 会话 → Active(忙,再"安静"也不算 idle)。
  3. 仅当 activity_seq(活动序号)停滞超过 idle_timeoutIdle

单元测试把这份克制钉死了:一个跑着 exec、心跳却卡住一小时的沙箱不能被杀——"忙碌的 agent 就是健康的 agent"(heartbeat.rs:315stale_heartbeat_with_running_exec_is_not_killed)。


4. 客机侧启动:agentd 作 PID 1 的两阶段

客机内核起来后运行的第一个用户态程序就是 agentd,它是 1 号进程(init)。它的 main 分成同步 init异步 agent loop 两阶段,顺序严格(crates/agentd/bin/main.rs)。

4.1 开机第一件事:抓启动计时

// crates/agentd/bin/main.rs:24
let boot_time_ns = clock::boottime_ns(); // CLOCK_BOOTTIME:从内核 boot 起算

boottime_ns(crates/agentd/lib/clock.rs:24)读 CLOCK_BOOTTIME——它从内核启动起算、含 suspend 时间,最适合量"VM 内核起来多久了"。后面 init 阶段的耗时也用它掐(main.rs:5161),最终随 core.ready 一起回报给主机,于是你能在日志里看到 boot_time_ms / init_time_ms / ready_time_ms(sdk/rust/lib/sandbox/mod.rs:776)。

4.2 读配置:BootParams(一次性)vs AgentdConfig(长命)

agentd 的配置全部来自 MSB_* 环境变量,一次读完,并按生命周期拆成两个结构(crates/agentd/lib/config.rs):

结构生命周期装什么构造
BootParams一次性,init 用完即弃(按值 move 进 init)块设备根、各类挂载 spec、tmpfs、网络、rlimits、安全 profile、handoff-initBootParams::from_env (config.rs:274)
AgentdConfig长命,活过 init、传给 agent loop默认 guest user、安全 profileAgentdConfig::from_env (config.rs:343)
// crates/agentd/bin/main.rs:29
let mut boot = BootParams::from_env()?; // 一次性
let config = AgentdConfig::from_env()?; // 长命
let handoff_spec = boot.take_handoff_init(); // 在 init 按值吃掉 boot 之前先把 handoff 抠出来

from_env 集中读取 + 校验,任何一个值解析失败就在产生任何副作用之前干净地中止开机(config.rs:10-12 的设计说明)。把 BootParams 设计成"按值 move 进 init"是刻意的——用类型系统表达"这批数据只吃一次,init 之后不该再读"。

4.3 阶段一:同步 init(把文件系统挂起来)

init::init(crates/agentd/lib/init.rs:20)按固定顺序做完 PID 1 该做的脏活,全程同步、无 async:

// crates/agentd/lib/init.rs:24-49 节选,顺序即依赖
rlimit::apply_baseline(&params.rlimits)?; // 先抬 rlimit,后续所有进程继承这个基线
linux::mount_filesystems()?; // /dev /proc /sys /sys/fs/cgroup /dev/pts /dev/shm
linux::mount_runtime()?; // virtiofs 把 /.msb 挂上(心跳/脚本/TLS 的控制通道)
if let Some(spec) = &params.block_root { linux::mount_block_root(spec)?; } // 组装真正的 rootfs
before_user_mounts()?; // 回调:开 serial 口 + 上报 init 上下文
… apply_dir_mounts / apply_file_mounts / apply_disk_mounts … // 用户卷
network::apply_network_config(...)?; tls::install_ca_cert()?;

几个值得注意的点:

  • rlimit 必须最先:PID 1 抬高的资源上限会被之后 fork 出的每个客机进程继承,所以要抢在一切之前。
  • mount_block_root 里藏着 OCI 根文件系统的组装:EROFS 只读下层 + 可写 upper + overlayfs,再 pivot_to_newroot()/newroot 换成 /(init.rs:201319)。细节属于 03/04
  • before_user_mounts 回调打开 virtio-serial 端口并 report_init_context——即"我 init 到这一步了"的第一次对主机喊话(main.rs:52-57)。

4.4 阶段一点五:可选的 PID 1 handoff(交棒给 systemd)

有些工作负载需要一个真正的 init(systemd/sysvinit)当 PID 1。Microsandbox 支持在 init 完成后把 PID 1 让给用户指定的 init,自己退居幕后继续跑 agent loop。这就是 handoff(crates/agentd/lib/handoff.rs)。

手法是经典的 fork + execve(handoff.rs:69do_handoff):

agentd (PID 1, init 已完成)
│ fork()
┌────────────┴────────────┐
父进程 (仍是 PID 1) 子进程 (变成普通孙子进程)
│ reset_signals() │ setsid() 脱离 init 的会话
│ execve(systemd, ...) │ 把 stderr 重定向到日志文件
│ └─► 变成新 init │ return Ok(()) ──► 回到 main,继续跑 agent loop
│ (永不返回本函数) │
  • 父进程execve 变身用户 init;成功则永不返回本函数。若 execve 失败,它写一行诊断到 console 然后 exit(127)——PID 1 一退出内核就 panic、VMM 随之拆机,所以前面特意先做了 preflight 可执行性检查把这种失败挡在 fork 之前(handoff.rs:64 的注释)。
  • 子进程成为普通孙子进程,setsid() 隔离出去(免得新 init 管理会话时误伤 agent relay),重定向 stderr,然后 return Ok(()) 回到 main,继续走 §4.5 的 agent loop。

为什么 handoff 点选在"init 之后、tokio 之前、开 serial 之前"? 因为 fork() 要在单线程、无异步运行时、无已开 serial fd 的状态下做才安全;而且注释警告:agentd 的 RSS 要保持极小(<5MB),否则 fork 的写时复制(COW)页表复制成本会随映射内存线性上涨(handoff.rs:18-24)。所以 init 要"轻",handoff 点绝不能后移。

4.5 阶段二:异步 agent loop

handoff 结束(或压根没配 handoff)后,main 才建起 tokio 运行时进入长期循环:

// crates/agentd/bin/main.rs:73 —— 单线程 current-thread 运行时
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
rt.block_on(async {
match agent::run(boot_time_ns, init_time_ns, &config,
port_file.expect("serial port opened during init")).await {
Ok(()) | Err(AgentdError::Shutdown) => {} // 正常收摊
Err(e) => { eprintln!("agentd: agent loop error: {e}"); process::exit(1); }
}
});
process::exit(0);

agent::run(crates/agentd/lib/agent.rs:138)复用 init 阶段已打开的 serial 口,先发 core.ready(带上 §4.1 那三个计时),起心跳线程,然后进主 select 循环处理 serial I/O、进程输出与心跳。这个 core.ready 正是主机侧 relay wait_ready 在等的信号——收到它,§3.6 的"开机失败兜底"才解除。循环内的协议与会话细节属于 02


5. 巧妙之处(可借鉴的技术)

  • 用进程树当生命周期管理器,而非自建 daemon。 attached 模式下父进程一死,PARENT_WATCH_FD 管道自动 EOF,沙箱跟着优雅关机——不需要注册表、不需要保活心跳去发现"孤儿"。零额外状态。(crates/runtime/lib/vm.rs:1603)

  • 敏感配置走 fd 而非 argv。 一个 dup2 到固定 fd 号的匿名文件,就把密钥/网络配置彻底挡在 ps/proc/<pid>/cmdline 之外。这是最小改动堵最实际的信息泄漏。(crates/runtime/lib/launch.rs:1-9,issue #997)

  • enter() -> ! 用类型编码"永不返回"。 成功路径返回 Infallible,编译器帮你保证"正常情况不会从 VMM 掉出来";唯一的返回是错误路径,于是错误处理代码天然只需要写一处。(crates/runtime/lib/vm.rs:409)

  • _exit 语义下,清理必须手动前置。 作者清醒地知道 _exit() 会跳过所有 Drop/异步收尾,于是把落库、删 socket、释放 metrics slot 全塞进同步的退出观察器里显式做。这是"知道自己的运行时会被暴力终结"后的正确写法。(crates/runtime/lib/vm.rs:634)

  • 心跳跑在专用 OS 线程,判 idle 却极其克制。 心跳线程独立于 tokio,保证异步被打满时脉搏不停;而 idle 判定坚持"忙碌=健康",绝不因心跳变旧就杀一个正在跑 exec 的沙箱。机制与策略分得很干净。(crates/agentd/lib/heartbeat.rs:28crates/runtime/lib/heartbeat.rs:128)

  • handoff 点卡在单线程、小 RSS 的窗口里。 把"fork 必须单线程"和"COW 成本随 RSS 涨"两条约束显式写进注释,并据此约束 init 保持轻量——是把隐性前提变成可维护约定的好例子。(crates/agentd/lib/handoff.rs:18-24)


6. 边界与局限

  • agentd 只支持 Linux 客机。 非 Linux 目标的 main 直接报错退出(crates/agentd/bin/main.rs:15-19)——微VM 里跑的就是 Linux 内核。
  • 父子握手有 30 秒硬超时。 子进程若 30 秒内没回报启动 JSON,父进程判定失败并终止它(sdk/rust/lib/runtime/spawn.rs:589)。开机极慢的场景会撞这条线。
  • handoff 后若 execve 失败,代价是整机崩。 PID 1 一退内核就 panic;靠 fork 前的 preflight 尽量避免,但仍有 fork 后才暴露的失败(如目标二进制损坏)会导致 VMM 拆机(crates/agentd/lib/handoff.rs:102-111)。
  • 本章不覆盖:协议帧格式与 relay 路由(→02);OCI 镜像拉取/缓存/层合并与只读格式(→03);virtio-fs 后端与写时复制(→04);smoltcp 网络栈与 TLS 拦截(→05)。

7. 代码地图(导航索引)

主题文件路径符号名
SDK 高层 start 流程sdk/rust/lib/sandbox/mod.rsstart_local / create_inner_local
fork+exec msb sandboxsdk/rust/lib/runtime/spawn.rsspawn_sandbox
attached/detached 模式sdk/rust/lib/runtime/spawn.rsSpawnMode
配置写进匿名 fdsdk/rust/lib/runtime/spawn.rswrite_launch_config_fd
读子进程启动 JSONsdk/rust/lib/runtime/spawn.rsread_startup_line
fd 精确搬号sdk/rust/lib/runtime/spawn.rsmove_reserved_source_fd / dup_inherited_fd
典型 fd 约定常量crates/runtime/lib/vm.rsCONFIG_FD / PARENT_WATCH_FD / STARTUP_FD
退出原因枚举crates/runtime/lib/vm.rsEXIT_REASON_COMPLETEDEXIT_REASON_AGENT_UNRESPONSIVE
沙箱进程入口(永不返回)crates/runtime/lib/vm.rsenter / run
沙箱进程完整配置crates/runtime/lib/vm.rsConfig / VmConfig
构建 VMMcrates/runtime/lib/vm.rsbuild_vm(用 msb_krun::VmBuilder)
写 startup JSONcrates/runtime/lib/vm.rswrite_startup_info
父进程看门狗线程crates/runtime/lib/vm.rsspawn_parent_watchdog / read_parent_watchdog_signal
主机侧心跳读取crates/runtime/lib/heartbeat.rsHeartbeatReader / check
客机侧心跳写入crates/agentd/lib/heartbeat.rswrite_heartbeat
典型 launch 契约crates/runtime/lib/launch.rsLaunchConfig / Lifecycle
CLI 读 config-fd → entercrates/cli/lib/sandbox_cmd.rsrun / load_launch_config
agentd 入口(两阶段)crates/agentd/bin/main.rsmain
客机配置(生命周期分拆)crates/agentd/lib/config.rsBootParams::from_env / AgentdConfig::from_env / take_handoff_init
同步 initcrates/agentd/lib/init.rsinit
PID 1 handoffcrates/agentd/lib/handoff.rsdo_handoff
启动计时crates/agentd/lib/clock.rsboottime_ns
异步 agent loopcrates/agentd/lib/agent.rsrun(发 core.ready)