临时沙箱的 控制面与内核
sandboxd 是临时沙箱开发环境的控制面——单一 Go 二进制跑在 Docker 容器内,与每个沙箱内的 runtimed 进程对话。核心信条:SQLite 是唯一真相源,Docker 状态向 SQLite 收敛,绝不反向。
# tastyeffectco/sandboxd · Go · control-plane/cmd/sandboxd/main.go
两进程架构
宿主级 sandboxd 控制生命周期;沙箱内的 runtimed 监管开发服务器与编程任务。两者通过 Unix 域套接字上的 HTTP/1.1 通信。
┌─────────────────────────────────────────────────────────┐
│ HOST (sandboxd container) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ sandboxd (cmd/sandboxd/main.go) │ │
│ │ • HTTP API server (127.0.0.1:9000) │ │
│ │ • SQLite store (single-writer goroutine) │ │
│ │ • Reconciler.Once (boot-time, runs once) │ │
│ │ • Idle Reaper (30 s ticker) │ │
│ │ • Pressure Reaper (10 s ticker) │ │
│ │ • Access-log tailer │ │
│ │ • Wake handler │ │
│ │ • runtime.Client (Unix socket → runtimed) │ │
│ └────────────────┬────────────────────────────────┘ │
│ │ docker start / docker stop │
│ │ docker exec (inflight tracking) │
│ │ docker inspect │
│ ┌────────────────▼────────────────────────────────┐ │
│ │ sandbox container (sandboxd-base:1.0.0) │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ runtimed (cmd/runtimed/main.go) │ │ │
│ │ │ • Supervises dev server (pnpm dev) │ │ │
│ │ │ • GET /status over Unix socket │ │ │
│ │ │ • POST /tasks (coding agent runner) │ │ │
│ │ │ • SSE /tasks/{id}/events │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Workspace loopback mount: │ │
│ │ /home/sandbox/workspace/ ←── host: │ │
│ │ /var/lib/sandboxed/workspaces/{id}.mnt │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
sandboxd(control-plane main.go)
宿主进程是唯一的守护进程。它独占 SQLite 数据库、管理所有 Docker 容器、暴露 HTTP API。关键职责:
- 绑定到
0.0.0.0:9000(OSS 默认)或环境变量配置的地址。 - 在 HTTP 监听器接受任何请求之前,运行一次启动期 Reconciler。
- 独占
Store——单写者 goroutine,把所有写操作通过容量 64 的缓冲通道串行化,避免 SQLite "database is locked" 错误。 - 在 reconciler 返回后,把 Idle Reaper 与 Pressure Reaper 作为后台 goroutine 启动。
- 按需为每个沙箱构建
runtime.Client,使用宿主侧 Unix 套接字路径:<workspaces>/<id>.mnt/.runtimed/sock。 - 支持 SIGHUP 重载鉴权配置而无需重启。
runtimed(cmd/runtimed/main.go)
runtimed 是沙箱内的监管者,作为容器主进程运行(PID 1 或其子进程)。当前 slice 1 的范围:
- 启动并监管 Vite 开发服务器(默认
pnpm dev)。 - 在工作区 loopback 上的 Unix 域套接字(
/home/sandbox/.runtimed/sock)暴露GET /status。 - 通过轮询开发服务器 HTTP 端口报告预览健康度。
- 检测入口资源编译错误(空白页检测)。
- 管理编程任务(POST /tasks、事件流、cancel)——后续 slice。
套接字绑定在 /home/sandbox/.runtimed/ 下,位于 loopback 工作区镜像上。这意味着同一个套接字 inode 既能从容器内的 /home/sandbox/.runtimed/sock 访问,也能从宿主的 <workspaces>/<id>.mnt/.runtimed/sock 访问。没有网络端口,没有跨租户可达性。
通信协议
sandboxd 与 runtimed 通过 Unix 域套接字上的 HTTP/1.1 通信。协议类型定义在 control-plane/internal/runtime/protocol.go 和 control-plane/internal/runtime/tasks.go,被双方导入:
GET /status→runtime.Status—— runtimed 快照(开发服务器状态 + 活动任务)POST /tasks→runtime.StartTaskRequest—— 启动一个编程任务GET /tasks/{id}/events→runtime.Event的 SSE —— 任务进度流POST /tasks/{id}/cancel—— 取消任务
runtimed 的 probeLoop 每 3 秒轮询开发服务器 HTTP 端口。status() 方法据此推导 runtime.PreviewState:
| 开发服务器状态 | PreviewState.Status | 说明 |
|---|---|---|
| 未运行 | "down" | |
| 运行中,HTTP ≠ 200 | "starting" | |
| HTTP 200,无资源错误 | "ready" | |
| HTTP 200,资源编译错误 | "error" | 空白页 —— 构建错误 |
runtime.Client 维护两个 http.Client 实例——一个 5 秒超时用于 status/start/cancel,另一个无超时用于 SSE 任务事件流。这样既避免短超时杀掉事件流,又能在命令操作上防住挂死连接。启动期协调器
源码:control-plane/internal/reconcile/reconcile.go。Reconciler 在进程启动时恰好运行一次,在 HTTP 监听器接受任何请求之前。这是硬性顺序要求:半截的 reconcile 绝不能把半填充的 egress 集合发布给客户端。
协调器做什么
它把 Docker 容器状态向 SQLite 收敛。对每个状态为 running、creating 或 stopped 的沙箱行:
- 重挂 loopback(Phase 5 V11 修复):内核 mount namespace 不会在重启后存活。如果磁盘上的
.img仍存在且该行不处于error状态,在判定容器状态前先重新配置 loopback 挂载。Loopback.Provision()是幂等的——已存在的挂载为 no-op。 - 处理陈旧的
creating行:任何超过 5 分钟的creating行被标记为error(记录、标错、保留挂载留待人工审查)。 - inspect Docker 容器(
s-<id>):- 容器不存在 →
MarkStopped(不自动重建)。 - 容器存在但未运行 →
MarkStopped。 - 容器运行中 → 重新推导 cgroup 路径并重写
memory.high(幂等)。把行更新为running。
- 容器不存在 →
- 重建 egress 来源(Phase 6):从 Docker inspect 推导实时 bridge IP(真相源——Docker 可在守护进程重启时重新分配 IP)。拆除任何陈旧条目并以当前 IP 重新添加。
- 记录缺少
external_user_id的遗留行(Phase 8):运行中的沙箱本应有一个。记录警告,建议sandboxd backfill-legacy。绝不自动销毁。
孤儿容器与挂载扫描
处理完所有已知行后,reconciler 扫描孤儿:
- 孤儿容器:任何名称前缀为
s-但无匹配 SQLite 行的 Docker 容器。这些仅被记录——绝不销毁。Reconciler 不会自行收养或剔除。 - 孤儿挂载:任何
workspaces/下以.mnt结尾、其基 ID 无 SQLite 行的挂载。同样仅记录。 - 工作区属主孤儿(Phase 8):任何
workspace_owner行,其背后的.img已不在磁盘上。记录;处置由运维决定。一个 6 小时 ticker 在启动后重新检查。
reconcile.Once() 返回 reconcile.Result 结构,供 main.go 用于日志与 Prometheus 指标:
| 字段 | 含义 |
|---|---|
Duration | Reconciler 总墙钟时间。 |
Rows | 处理的沙箱行总数。 |
Reapplied | 成功的 memory.high 重写次数。 |
Stopped | 标记为 stopped 的行(容器消失或未运行)。 |
Errored | 标记为 error 的行(egress 重建失败)。 |
EgressAdded | 重新安装的 egress 来源条目数。 |
Orphans | 孤儿种类 → 计数的映射(container、mount、workspace_owner)。 |
memory.high 重应用
当 SetMemoryHigh 启用(可移植 OSS 构建默认关闭)时,reconciler 从容器当前 PID 重新推导 cgroup 路径并重写 memory.high。这处理了 Phase 1 遗留问题:"memory.high 不会被 docker start 自动恢复"。写入是幂等的;写失败被记录但不再把沙箱标错——一个正在运行、正在服务的容器,绝不能仅因软限流没能重应用就被拔出服务。
孤儿扫描细节
孤儿扫描刻意非破坏。CLAUDE.md 不变量:"SQLite 是真相源。Reconciler 把 Docker 向 SQLite 收敛,绝不反向。" 孤儿容器与挂载为运维知情而被记录,系统从不自动删除。
孤儿容器扫描
Reconciler 调用 Docker.ListByNamePrefix(ctx, "s-") 获取所有带 s- 前缀的容器名。它从处理过的 SQLite 行构建 knownIDs 映射。任何 ID 不在 knownIDs 中的容器即为孤儿:
for _, name := range names {
id := strings.TrimPrefix(name, "s-")
if knownIDs[id] { continue }
log.Warn("reconcile: orphan container (no matching sandbox row)",
"container", name, "sandbox_id", id)
res.Orphans["container"]++
}
孤儿挂载扫描
Loopback.ListMounts() 返回 <workspacesRoot>/ 下所有以 .mnt 结尾的挂载路径。Reconciler 提取基 ID(.mnt 之前的部分)并与 knownIDs 比对:
for _, mnt := range mounts {
base := mnt[strings.LastIndexByte(mnt, '/')+1:]
id := strings.TrimSuffix(base, ".mnt")
if knownIDs[id] { continue }
log.Warn("reconcile: orphan mount (no matching sandbox row)",
"mount", mnt, "sandbox_id", id)
res.Orphans["mount"]++
}
Phase 7 崩溃残骸清扫
在孤儿挂载扫描之前,reconciler 调用 Snapshot.SweepStale(),移除两类崩溃残骸:
*.img.zst.tmp—— 被中断的快照操作留下的半写压缩快照。*.img.bak-*—— 被中断的恢复操作留下的备份文件。
两者都被记录,而非静默删除。计数通过 stale_tmp 和 stale_bak 指标字段报告。
工作区属主孤儿检查(6 小时 ticker)
启动后,main.go 启动一个带 6 小时 ticker 的 goroutine,调用 CheckWorkspaceOwnerOrphans()。它遍历所有 workspace_owner 行,记录任何背后 .img 已不在磁盘上的行。该函数从不删除——处置由运维决定。检查运行时机:
- 启动时由
reconcile.Once()运行一次。 - 之后每 6 小时由后台 goroutine 运行。
空闲回收器
源码:control-plane/internal/reaper/idle.go。Idle Reaper 是一个长生命周期 goroutine,在可配置的不活动周期后停止沙箱。属于 roadmap Phase 5。
| 环境变量 | 默认值 | 含义 |
|---|---|---|
SANDBOXD_IDLE_THRESHOLD_SECONDS | 2100(35 分钟) | 不活动截止——无任何活动信号超过此时长即视为空闲。 |
SANDBOXD_IDLE_REAP_INTERVAL_SECONDS | 30 | 回收器多久 tick 一次检查候选。 |
SANDBOXD_WAKE_GRACE_SECONDS | 60 | 唤醒后 / SSE 断连后的宽限窗口,之后空闲时钟才恢复。 |
跳过规则(内存中,逐候选)
停止任何沙箱前,回收器按顺序应用这些检查:
- 在飞 exec:若
activity.InflightExec.Active(sb.ID)返回 true,跳过。exec 是用户活动的直接信号。 - 显式 keepalive:若
sb.KeepaliveUntil已设置且keepalive_until > now.Unix(),跳过。上游可在长操作期间延长沙箱寿命。 - 活动编程任务:若
Store.SandboxHasRunningTask(sb.ID)返回 true,跳过。任务中途回收会打断 agent 运行。 - 开放连接信号:由 access-log tailer / poller 在 WakeGrace 窗口内 bump
last_active_at处理。DB 已通过ListIdleCandidates截止比较反映之。 - 时钟漂移二次确认:若
!sb.LastActiveAt.IsZero()且sb.LastActiveAt.After(cutoff),跳过。时钟漂移的安全网。
tick 循环
func (i *Idle) tick(ctx context.Context) error {
metrics.IdleReaperRuns.Inc()
now := time.Now().UTC()
cutoff := now.Add(-i.Cfg.Threshold)
candidates, err := i.Store.ListIdleCandidates(ctx, cutoff)
// SQL: ... WHERE status='running' AND last_active_at < cutoff
// ORDER BY last_active_at ASC
for _, sb := range candidates {
// apply skip rules...
// docker stop s-,10s timeout
// egress.Remove + Store.ClearContainerIP
// Store.MarkStoppedAt
metrics.IdleReaperStops.WithLabelValues("idle").Inc()
}
}
阻止空闲的活动信号
last_active_at 由三个来源 bump:
- Access-log tailer —— 监视
traefik-access.log中匹配预览域名的请求,为匹配的沙箱 bumplast_active_at。 - 开放连接 poller —— 若设置了
SANDBOXD_POLLER_METRIC_RE,轮询指标端点并为有活动连接的沙箱 bump last_active_at。env 缺失时回退到仅 tailer。 - Wake handler —— 每次成功唤醒都 bump。
- 在飞 exec 跟踪 —— 活动 docker exec 调用期间 bump。
停止时的 egress 清理
当 idle reaper 停止一个容器时,它还会:
- 调用
Egress.Remove(ctx, sb.ID, sb.ContainerIP.String)把 bridge IP 从 nftablessandbox_sources_v4集合中移除。 - 调用
Store.ClearContainerIP(ctx, sb.ID)把行上的container_ip列置 NULL。这是 roadmap §1 DoD 的"每个 stopped 沙箱该列必为 NULL"规则。
内存压力回收器
源码:control-plane/internal/reaper/pressure.go。Pressure Reaper 在三个内存带防御宿主内存,在每个阈值停止最老的空闲运行沙箱。属于 Phase 5,运行在 10 秒 ticker 上。
| 环境变量 | 默认值 | 含义 |
|---|---|---|
SANDBOXD_PRESSURE_INTERVAL_SECONDS | 10 | tick 间隔。 |
SANDBOXD_MEM_HEADROOM_PCT | 15 | "健康"下限——在此之上不动作。 |
SANDBOXD_MEM_REFUSE_WAKES_PCT | 10 | 低于此:停止最老空闲 AND 拒绝新唤醒请求。 |
SANDBOXD_MEM_EMERGENCY_PCT | 5 | 紧急——无视活动停止 RSS 最重的沙箱。 |
内存带
MemAvailable%
│
│ ≥15% ┌────────────────────────────────────── HEALTHY (no action)
│ │
│ 10–15% │ ┌─────────────────────────────────── STOP oldest idle-running
│ │ │ (10–15% band — advisory, doesn't refuse wakes)
│ 5–10% │ │ ┌─────────────────────────────── STOP oldest idle-running
│ │ │ │ WARN: wakes refused
│ │ │ │
│ <5% │ │ │ ┌─────────────────────────── STOP heaviest RSS
│ │ │ │ │ CRITICAL: emergency stop, even if active
└─────────┴─┴─┴───────────────────────────────────────▶0%
20 15 10 5 0
唤醒拒绝的滞回
Refused 原子布尔与唤醒准入代码共享。它用滞回避免边界抖动:
- 当
avail < RefuseWakesPct(落入 5–10% 或 <5% 带的下降沿)时置true。 - 当
avail ≥ RefuseWakesPct + 2(升过 12% 的上升沿,略高于 HeadroomPct 的 10% 触发点)时清false。
stopOldestIdle
从 ListIdleCandidates(ctx, now)(cutoff = now 意为"任何当前未被标记为活动的行")挑选 last_active_at 最老的沙箱。应用与 idle reaper 相同的跳过规则。每 tick 停一个——下一 tick 重读 MemAvailable 再决定。若无候选,本 tick 不动作。
stopHeaviestRSS(紧急 <5%)
当 MemAvailable 跌破 5% 时,回收器调用 stopHeaviestRSS():
- 列出所有
running沙箱。 - 对每个有有效
cgroup_path的沙箱,通过ReadCgroupMemoryCurrent()读取/sys/fs/cgroup{path}/memory.current。 - 挑选
memory.current最高的(RSS 代理)。 - 对它执行
docker stop,无视活动。 - 以完整行身份记录一条
CRITICAL事件。
memory.high=4G 与 memory.swap.max=0 下,内核可能在协作型分配器跨越 memory.max=10G 之前就把它们卡在 D-state。Pressure reaper 不依赖 memory.events.oom_kill——它按 cgroup.memory.current(RSS,非 OOM 事件)排序。一个 D-state 卡死的分配器仅凭这些信号即成为击杀候选。ReadMemInfo 与 cgroup 内存读取
ReadMemInfo()(在 control-plane/internal/reaper/meminfo.go)解析 /proc/meminfo 计算 MemAvailable 及其百分比。ReadCgroupMemoryCurrent()(在 control-plane/internal/reaper/cgmem.go)读取给定 cgroup 相对路径的 /sys/fs/cgroup{path}/memory.current。当路径不存在(cgroup 已拆除的 stopped 沙箱)时返回 (0, nil)。
TaskResult 生命周期
编程任务子系统建立在每沙箱一行的 task 表(定义于 control-plane/internal/store/tasks.go)和来自 runtimed 的 SSE 事件流之上。核心设计目标:任务的权威结果比沙箱活得久。
| 状态 | 含义 |
|---|---|
running | 任务在 runtimed 中活动。 |
succeeded | 任务以干净的 done 事件完成。 |
failed | 任务以错误完成。 |
cancelled | 上游通过 POST /tasks/{id}/cancel 取消任务。 |
生命周期流程
POST /v1/sandboxes/{id}/tasks
上游提交任务。API handler 检查沙箱为 running(若 stopped,先委派到唤醒路径——B1 "wake-on-task-submit")。生成 ULID 作为 taskID,调用 runtimeClientFor(id).StartTask(),经 Unix 套接字向 runtimed 发 POST /tasks。
runtimed 确认
HTTP 202 Accepted 或 409 Conflict。若已有任务在跑,runtimed 返回 ErrTaskInProgress,API 返回 409 "task_in_progress"。"一次一个任务"由 runtimed 的 taskMu 互斥锁强制。
持久化任务行
Store.CreateTask()。sandboxd 把一个 running 状态的 task 行写入 SQLite。行的 result_json 初始为 NULL。这确保任务结果在沙箱停止与销毁后仍存活。
启动结果监视者
go s.watchTask(id, taskID)。后台 goroutine 经 runtimeClientFor(id).TaskEvents(taskID, 0) 打开 SSE 流,消费事件并经 Store.FinishTask() 把终态 done 事件的 TaskResult 持久化到行。done 到达或 context 取消时关闭。
事件流
上游经 GET /v1/sandboxes/{id}/tasks/{taskId}/events 消费。sandboxd 代理来自 runtimed 的 SSE 流。客户端用 Last-Event-ID 头做重连续传。sandboxd 把 runtimed 的换行分隔 JSON 事件翻译成 SSE 格式(id: N\nevent: type\ndata: JSON\n\n)。
终态 done 事件
携带 TaskResult。done 是唯一的终态事件,包含完整的 TaskResult:status、failure_reason、files_changed、agent_message_final、build_ok、preview_status_after、checkpoint_id、duration_ms、tokens 及时间戳。
持久化结果
Store.FinishTask(ctx, taskID, status, resultJSON) 更新行:status → 终态,result_json → JSON 编码的 TaskResult,finished_at → time.Now().Unix()。
读权威结果
GET /v1/sandboxes/{id}/tasks/{taskId} 从 sandboxd 的 SQLite 读取——无论沙箱在跑、已停还是已销毁都可用。若 result_json 为 NULL(任务仍在跑),只返回 running 状态。
| 字段 | 类型 | 描述 |
|---|---|---|
id | string | 任务 ULID。 |
status | TaskStatus | succeeded | failed | cancelled |
failure_reason | string | 失败的人类可读原因(agent 级)。 |
error_message | string | 来自编程 agent 的结构化错误。 |
files_changed | []string | 任务期间修改的文件列表。 |
agent_message_final | string | 最终 agent 摘要消息。 |
build_ok | bool | pnpm build 是否成功。 |
build_error_message | string | pnpm build 的输出。 |
preview_status_after | PreviewStatus | 任务后开发服务器状态。 |
preview_error_message | string | 构建干净但应用渲染失败时的实时开发服务器错误。 |
checkpoint_id | string | 任务结束时拍的快照 ID(若有)。 |
duration_ms | int64 | 任务墙钟时间(毫秒)。 |
tokens | TokenUsage | 输入/输出/推理/缓存 token 计数与成本。 |
created_at / started_at / finished_at | time.Time | 任务时间戳。 |
| 类型 | 来源 | 可靠性 |
|---|---|---|
status | runtimed | 可靠 |
message | provider-derived | 尽力而为 |
tool | provider-derived | 尽力而为 |
build | runtimed | 可靠 |
done | runtimed | 可靠——终态事件,携带 TaskResult。 |
取消流程
POST /v1/sandboxes/{id}/tasks/{taskId}/cancel 向 runtimed 发 POST /tasks/{taskId}/cancel。runtimed 的 taskMu 确保一次只跑一个任务。取消是幂等的——对已完成的任务调用 cancel 返回 200 OK。
启动期任务恢复
sandboxd main.go 和 runtimed main.go 都在启动时调用 recoverInterruptedTasks()。被中断的任务被置为 failed(绝不恢复运行)。sandboxd 调用 server.ReconcileTasks(ctx),后者调用 Store.ListRunningTasks(),在 idle reaper 启动前把任何陈旧的 running 任务标记为 failed。
SQLite 存储
源码:control-plane/internal/store/store.go。Store 是 SQLite 支撑的沙箱生命周期真相源。所有沙箱、任务、工作区属主都存于此。
并发模型
单个写者 goroutine 从一个容量 64 的写操作缓冲通道读取。读者直接用 db.QueryRow / db.Query。这避免了 "database is locked",又不把互斥锁散落到代码各处。结构:
type Store struct {
db *sql.DB
writes chan writeOp // buffered channel, capacity 64
doneCh chan struct{}
closeCh chan struct{}
}
| 状态 | 含义 |
|---|---|
creating | 行存在,容器正在创建。若超过 5 分钟,reconciler 标错。 |
running | 容器在运行并服务中。 |
stopped | 容器已停止(由 reaper、wake 或运维)。container_ip 必为 NULL。 |
error | 不可恢复错误。运维须手动调查。 |
关键读查询
Get(ctx, id)—— 按 ID 取单个沙箱。List(ctx)—— 所有沙箱,按created_at DESC排序。ListByStatuses(ctx, statuses...)—— reconciler 使用。ListIdleCandidates(ctx, cutoff)——WHERE status='running' AND last_active_at < cutoff ORDER BY last_active_at ASC。这里不加载端口(热路径优化)。ListFiltered(ctx, externalUserID, externalProjectID)—— Phase 8 外部身份过滤。ListAllWorkspaceOwners(ctx)—— 孤儿检查使用。GetTask / CreateTask / FinishTask / ListRunningTasks—— task 表。SandboxHasRunningTask(ctx, sandboxID)—— idle reaper 跳过规则使用。
关键写操作
所有写都经单写者 goroutine 通过 writes 通道漏斗化。每个写是一个 writeOp 闭包,在写者循环内对单个 DB 连接执行。这串行化了所有变更,同时允许并发读。
活动列(Phase 5)
last_active_at(unix 秒,0 = 从未观测)由 tailer/poller bump。stopped_at(unix 秒,运行中为 NULL)记录 reaper 何时停止沙箱。keepalive_until(unix 秒,NULL = 无覆盖)是来自上游的 idle-reaper 跳过信号。
工作区属主绑定(Phase 8)
workspace_owner 是一个独立表,在 DELETE /sandbox/{id} 后仍存活。它保存 sandbox_id → external_user_id 绑定,用于按用户清除与 Phase 8 孤儿检查。
唤醒处理器
源码:control-plane/internal/wake/handler.go。Wake handler 把一个 stopped 沙箱带回生命。它有两个入口:
- Traefik catch-all:Host 头匹配
s-<id>-<port>.preview.<domain>的 HTTP 请求。返回一个 HTML meta-refresh,让浏览器持续重试直到沙箱就绪。 - 程序化 JSON:loopback API 上的
POST /wake/{id}。返回 JSON。被任务提交使用(wake-on-task-submit)。
唤醒流程(逐步)
- 每 id 互斥去重:若同一 ID 的唤醒已在飞行中,第二个调用者等待并复用第一个调用者的结果。这阻止双启动竞态。
- 每 id 锁(Phase 7):整个唤醒期间获取共享的
idlock.Registry。这排斥同一沙箱的并发 snapshot/restore/destroy。 - 行查找 + 状态守卫:
running→ 成功(no-op)。error→ 错误响应。creating→ 拒绝(半建行)。stopped→ 继续。 - 私有沙箱闸门(Phase 8):对
visibility='private'沙箱的 HTML 路径唤醒,先验证预览 token cookie。服务/运维鉴权的调用者绕过此闸门。 - 准入检查:
Admit()读取宿主内存。若拒绝,返回 503 带Retry-After: 30。在 5–10% 或 <5% 带唤醒被拒。 - docker start:15 秒超时。幂等——对已运行容器调用安全(虽然上面的状态守卫通常已截获该情况)。
- inspect + memory.high 重应用:从容器 PID 重新推导 cgroup 路径并重写
memory.high。失败被记录但不使唤醒失败——--memory上限仍生效。 - egress 策略(Phase 6):把(可能是新的)bridge IP 注册到 nftables
sandbox_sources_v4集合。先拆除任何陈旧条目。若 nftables 失败,宁可中止行更新也不在无 egress 策略下运行。 - TCP-ready 探测:对 catch-all 路径(已知端口),轮询开发服务器直到 HTTP 200 或超时。超时是信息性的,不是硬失败——meta-refresh 继续重试。
- 行更新:
Store.MarkRunningWoke()把行更新为running,设置container_id、cgroup_path,并 bumplast_active_at。
Egress 注册
Egress(nftables)在可移植 OSS 构建中禁用(egressMgr == nil)。每个消费者(reconciler、reapers、wake、API)把 nil egress manager 当作"无 egress 策略"——正是默认放行行为。这就是把那一切关掉的单一开关。
runtimed 详解
runtimed 是沙箱内的监管者,一个作为容器主进程启动的单一 Go 二进制。当前 slice 的职责:
- 监管开发服务器进程(默认
pnpm dev)。 - 每 3 秒探测开发服务器 HTTP 端口。
- 检测入口资源编译错误(空白页检测)。
- 在 Unix 域套接字上暴露
GET /status。 - 启动时恢复被中断的任务。
app struct
type app struct {
dev *devServer // supervisored dev server
previewPort int
appDir string // /home/sandbox/workspace/app
runtimeDir string // /home/sandbox/.runtimed
log *slog.Logger
bootedAt time.Time
mu sync.Mutex
lastCode int // last HTTP status from probe
lastAssetErr string // entry-asset compile error
lastChecked time.Time
taskMu sync.Mutex // guards task; serializes start (one task at a time)
task *task // current / last task, or nil
}
devServer 监管
devServer.supervise() 启动开发服务器进程,监视退出,并以指数退避(封顶)重启。每次重启使 PreviewState 中的 restarts 计数器递增。
启动保证
runtimeDir(/home/sandbox/.runtimed)以 0o755 创建。appDir(/home/sandbox/workspace/app)以 0o755 创建。这自愈了在此修复前配置的沙箱(它们可能只有~/workspace)。recoverInterruptedTasks()在接受新工作前调用。来自上次停止/崩溃的任何陈旧任务状态被清理。
信号处理
runtimed 用 signal.NotifyContext 处理 SIGTERM/SIGINT。收到信号时,开发服务器在 runtimed 退出前被干净停止。该 context 也用于取消探测循环与开发服务器监管。
启动序列
源码:sandboxd main.go。启动序列被精心排序,以确保不变量从接受第一个请求起就成立。
Env + dirs
解析环境变量,创建 stateDir。
打开 SQLite
store.Open() 应用迁移,启动单写者 goroutine。
回填 last_active_at
为遗留 running 行回填——迁移默认值 0 会让每个现有行成为空闲候选。
构建协作者
docker 客户端、loopback manager、snapshot manager、idlock registry、egress manager(OSS 构建中为 nil)。
Reconciler.Once
在监听器打开前运行至完成。所有容器状态收敛到 SQLite。孤儿挂载/容器被记录。指标被发布。
压力回收器预检
若 MemAvailable 已低于 headroomPct,在打开监听器前同步运行一次 pressure.Tick()。
任务协调
server.ReconcileTasks(ctx) 在 idle reaper 启动前把任何陈旧 running 任务标记为 failed。
启动后台 goroutine
idle reaper、pressure reaper、access-log tailer、connection poller、workspace_owner 孤儿 ticker。
HTTP 监听器
httpSrv.ListenAndServe()。Reconciler 已完成,故第一个请求看到完全收敛的状态。
信号处理
SIGHUP 重载鉴权配置;SIGINT/SIGTERM 触发优雅关停(排空 HTTP 服务器、取消后台 context、关闭 store)。
优雅关停
- 信号处理器调用
gcancel()—— 停止所有后台 goroutine。 httpSrv.Shutdown(shutCtx),10 秒超时。store.Close()—— 排空写通道,关闭 DB。
完整系统架构
把前面各节的组件放回它们在系统中的位置——宿主侧 sandboxd 的全部子系统,与沙箱内 runtimed 经 Unix 套接字相连。
┌──────────────────────────────────────┐
│ sandboxd (host container) │
│ │
│ HTTP API (127.0.0.1:9000) │
│ ├── /v1/sandboxes │
│ ├── /v1/sandboxes/{id}/tasks │
│ ├── /wake/{id} │
│ └── catch-all (preview domain) │
│ │
│ ┌──────────── Store ───────────────┐ │
│ │ Single-writer goroutine │ │
│ │ SQLite (WAL mode, FK enabled) │ │
│ └─────────────────────────────────│ │
│ │
│ ┌──────── Reconciler.Once ────────┐ │
│ │ Boot-time, runs once │ │
│ │ Converges Docker → SQLite │ │
│ │ Orphan scan (logged, not del) │ │
│ └────────────────────────────────│ │
│ │
│ ┌──────────── Idle Reaper ───────┐ │
│ │ 30 s ticker │ │
│ │ last_active_at < threshold │ │
│ │ Skip: inflight, keepalive, │ │
│ │ running task │ │
│ └────────────────────────────────│ │
│ │
│ ┌──────── Pressure Reaper ───────┐ │
│ │ 10 s ticker │ │
│ │ Bands: ≥15% / 10-15% / │ │
│ │ 5-10% / <5% │ │
│ │ Emergency: heaviest RSS │ │
│ └────────────────────────────────│ │
│ │
│ ┌──────── Wake Handler ──────────┐ │
│ │ docker start + inspect │ │
│ │ egress.Add (Phase 6) │ │
│ │ memory.high re-apply │ │
│ │ TCP-ready probe │ │
│ └────────────────────────────────│ │
│ │
│ ┌──── Activity ─────────────────┐ │
│ │ Access-log tailer │ │
│ │ Connection poller (optional) │ │
│ │ Inflight exec tracker │ │
│ └────────────────────────────────│ │
└──────────────────────────────────────┘
│
docker start/stop/exec/inspect
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ sandbox container (sandboxd-base) │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ runtimed (cmd/runtimed/main.go) │ │
│ │ ├── Dev server supervisor (pnpm dev) │ │
│ │ ├── Health probe loop (3 s, HTTP port3000) │ │
│ │ ├── GET /status → PreviewState │ │
│ │ ├── POST /tasks → Start coding task │ │
│ │ ├── GET /tasks/{id}/events → SSE event stream │ │
│ │ └── POST /tasks/{id}/cancel │ │
│ └────────────────────────────┬────────────────────────────────────────┘ │
│ │ Unix domain socket │
│ │ /home/sandbox/.runtimed/sock │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Workspace loopback │ │
│ │ /home/sandbox/workspace│ │
│ │ ↕ mount │ │
│ │ <workspaces>/{id}.mnt │ │
│ └──────────────────────────┘ │
│ │
│ Workspace image (.img): │
│ /var/lib/sandboxed/workspaces/{id}.img │
└─────────────────────────────────────────────────────────────────────────────┘
关键设计决策
把贯穿全文的原则收束成七条不可妥协的设计约束——它们共同解释了 sandboxd 为何这样收敛、这样回收、这样持久化。
SQLite 是真相源
每次变更(create、wake、stop、error)都经 sandboxd 与 SQLite。Docker 是执行引擎,不是真相源。Reconciler 的职责是检测漂移(有容器无行,或行的容器消失)并校正行状态。它从不删除自己没创建的 Docker 对象。
单写者 SQLite
SQLite 在文件层只处理一个连接。单写者 goroutine 通过缓冲通道串行化所有写,避免 "database is locked" 而无需把互斥锁散落到代码库。读直接走读连接池。
处处幂等
docker start 幂等。Loopback.Provision() 幂等。Egress Add/Remove 优雅处理陈旧条目。Reconciler 每次启动重写 memory.high。同一沙箱的并发唤醒由内存互斥去重。
孤儿扫描不自动删
Reconciler 记录孤儿容器、孤儿挂载、workspace_owner 孤儿,而非删除它们。这保护了开发者的调试工作流,使系统在与手动 Docker 使用并存的开发环境中更安全。
回收器跳过规则防中途打断
idle reaper 与 pressure reaper 在停止容器前都检查 Store.SandboxHasRunningTask()。活动 tailer 与 poller bump last_active_at,使已连接的浏览器不会在会话中途被回收。显式 keepalive 可在长操作期间延长沙箱寿命。
任务结果比沙箱活得久
task 行在 sandboxd 的 SQLite 中,而非 runtimed 里。结果由结果监视者 goroutine 在 SSE 流关闭前写入。上游即使在沙箱已停止销毁后,仍可经 GET /tasks/{id} 读权威结果。
if egressMgr != nil。系统在不接入任何这些组件时仍正确工作(带默认放行行为)。