数据截至 (上游 commit 9194847b2cf8)
Worker 与容器生命周期
上一章请求被派到了某台 worker 的 Redis 队列。这一章讲那台 worker 怎么把它变成一个真正在跑的容器,再到容器退出后的清理。代码主要在
pkg/worker/worker.go、pkg/worker/lifecycle.go。
1. Worker 的主循环:一条 gRPC 长流接活
Worker 启动后 Run()(worker.go:452)拉起几个后台 goroutine(监听关机、管容量、处理停止事件),然后进入主循环:
Run()
│
├─ go listenForShutdown 监听 SIGINT/SIGTERM
├─ go manageWorkerCapacity 容器完成后归还容量
├─ go processStopContainerEvents
│
└─ for { GetNextContainerRequest 流式接收 }
│ 每收到一个请求
▼
handleContainerRequest → go runContainerRequest
注意它用的是 gRPC server-streaming(workerRepoClient.GetNextContainerRequest,worker.go:467):worker 开一条长连接,服务端那边其实是不断 LPop 那个 Redis 队列把请求推过来。连接断了就重连。
每收到一个请求(worker.go:495):记一笔「队列接收延迟」生命周期事件,然后 handleContainerRequest。它还会在每次循环检查 shouldShutDown——非持久 worker 空闲超过 5 分钟(defaultWorkerSpindownTimeS=300)且没有容器在跑,就自己关机。这就是「scale to zero」在 worker 侧的体现:没人用,机器自己消失。
2. 接到请求后的三道闸
handleContainerRequest(worker.go:612)很短,但有三道关:
dropCancelledContainerRequest:起之前先查容器状态,如果状态已经是Stopping或干脆没了(用户已经取消),直接丢弃、归还,别白起。reserveContainerInstance:在本地containerInstancesmap 里占坑(加锁),防止同一个容器被起两次。go runContainerRequest:每个容器一个 goroutine,互不阻塞。
runContainerRequest(worker.go:624)里有一个启动超时保护(非构建请求):把 RunContainer 丢进 goroutine,配一个计时器(默认 defaultContainerStartupTimeout=5min,可由 Failover.MaxSchedulingLatencyMs 调整)。超时就 cancel,避免一个卡住的启动占着坑不放。源码里还特意 clamp 了这个值防止 int64 溢出变负数(worker.go:49-53 的注释讲了这个坑)。
3. 核心:RunContainer 的分阶段启动
RunContainer(lifecycle.go:581)是整个 worker 最重要的函数。它把「起一个容器」拆成十几个被逐一计时的阶段——每个阶段都 RecordWorkerStartupPhase + recordStartupLifecycle,所以 Beam 能精确告诉你「冷启动的 800ms 花在哪了」。
按顺序(省略计时样板):
RunContainer:
能力门控 runtime 不支持 CRIU 就关检查点;不支持 GPU 但请求要 GPU 直接报错
① set_worker_address 把 worker 地址写进 container 状态(供路由)
② loadContainerImage ★ CLIP 懒加载镜像(见第 03 章)—— 失败且是 build 请求则先 build
③ port_allocation allocateBindPorts 抢宿主机随机端口
④ read_bundle_config 读镜像里的 OCI config(v2 镜像则从 CLIP 元数据推导)
⑤ setup_mounts SetupContainerMounts(卷、workspace 存储)
⑥ spec_from_request ★ 生成动态 runc spec
└─ go spawn(...) 真正起容器(下一节)
第 ②、⑥ 两步是重头,分别在第 03 章和下面讲。
specFromRequest:动态拼 OCI spec
specFromRequest(lifecycle.go:1105)从一个 base 模板(newSpecTemplate 按运行时取 base_runc_config.json 或 base_runsc_config.json)开始,往里填:
- entrypoint:优先用请求里的,pod 类型回退到镜像初始 spec,再回退到 stub 默认(
fallbackEntrypoint,会拼出python -m beta9.runner.endpoint这类)。 - 资源限制:CPU/内存限额(受
ContainerResourceLimits.CPUEnforced开关控制;GPU 容器和构建不限流)。 - 环境变量、GPU 环境注入。
- 挂载:用户卷(
addRequestMounts)、resolv.conf、上传目录、检查点信号目录等。
这一步是「把声明式的 ContainerRequest 编译成 runc 能吃的 config.json」。
4. spawn:真正把容器跑起来
spawn(lifecycle.go:1534)在自己的 goroutine 里,用一长串 defer 保证清理(这是 Go 风格的资源管理范式),然后逐步:
spawn:
AddContainerToWorker 注册到 worker 容器索引
createOverlay + Overlay.Setup ★ overlayfs:镜像只读层 + 可写上层
network Setup 容器网络命名空间/设备
GPU AssignGPUDevices + CDI注入 (需要 GPU 时)
ExposePorts 把容器端口映射到宿主机
runtime.Prepare 运行时特定的 spec 微调(seccomp 等)
写 config.json 到 overlay 顶层
┌─ containerStartSem 信号量 ★ 限制并发启动数
│ go { 等 PID → sandbox 进程管理器就绪等后续 }
│ go { 等 PID → OOM watcher + 指标采集 }
└─ runContainer(...) 阻塞直到容器进程退出
退出后:归一化 exit code、记事件、deleteRuntimeContainer
几个值得注意的设计:
overlayfs(lifecycle.go:1569):容器根文件系统 = 镜像只读层(FUSE 挂载的那个)+ 一个可写上层。spec.Root.Path = overlay.TopLayerPath()。这让多个容器能共享同一份只读镜像,写入互不干扰——也是懒加载能工作的前提。
启动并发信号量 containerStartSem(lifecycle.go:1975-1985):容器生成(spawn)可以无限并发,但「真正调用 runtime 启动」这一步被信号量限流。容量来自 containerStartLimitForPoolRuntime,runc 和 gVisor 默认不同(gVisor 启动更重、并发数更低)。进程一旦起来(拿到 PID)就立刻释放槽位(releaseStartupSlot),不等它跑完。这避免「同一台机器上一堆容器同时冷启动把 IO/CPU 打爆」。
PID 驱动的两个旁路 goroutine:容器进程一启动就把 PID 发到 startedChan,触发:(a) sandbox 类型去等「进程管理器就绪」并应用延迟的 CPU 限额;(b) 起 OOM watcher 和指标采集。这种「等 PID 信号再挂监控」的模式贯穿全文件。
5. 容器退出与清理
容器进程退出后,spawn 末尾归一化退出码(normalizeContainerExitCode:SIGTERM → 0、OOM 有专门标记),记一条 ContainerEventRuntimeExited 事件,删运行时容器。
而 finalizeContainer(lifecycle.go:360)→ clearContainer(lifecycle.go:368)负责善后:
- 设置 exit code 落库;
- 释放 GPU 设备、拆容器网络、删上传目录;
- 把请求塞回
completedRequests通道 →manageWorkerCapacity(worker.go:933)消费它,调UpdateWorkerCapacity(AddCapacity)把资源还给调度器(这步对应第 01 章派发时的RemoveCapacity,账目闭环); - 留一个
TerminationGracePeriod的宽限期再彻底删,方便最后看日志/事件。
归还容量带重试(processCompletedRequest,worker.go:951):如果遇到 Redis 锁没拿到(IsRedisLockNotObtained)就退避重试,否则报错。
6. 优雅停机
收到 SIGTERM(listenForShutdown,worker.go:744):cancel 上下文 + DisableWorker(让调度器别再派活)。然后 shutdown()(worker.go:1067):
waitForActiveContainersBeforeShutdown等在跑的容器自己结束(限时);- 还没结束就
stopActiveContainersForShutdown先 SIGTERM 再(超时)SIGKILL; - 清缓存、网络、卷、镜像挂载;
- 非持久 worker
RemoveWorker(从池里摘掉),持久 worker 只 disable。
停机时间预算被精细切分(workerShutdownDrainTimeout/workerContainerStopGrace,worker.go:1242):从总的 TerminationGracePeriod 里扣掉 drain、force-wait、cleanup 各段,确保在被 K8s 强杀前留足清理时间。
7. 巧妙之处
- 每个启动阶段都计时(
RecordWorkerStartupPhase遍布lifecycle.go):把冷启动变成可观测、可优化的「瀑布图」,这是性能工程的基础设施。 - 生成无限并发、runtime 启动限流(
containerStartSem):精准卡在最吃资源的那一步,而不是粗暴限制整体并发。 - PID 信号驱动的监控挂载:用 channel 在「进程刚起来」这个精确时刻挂 OOM/指标/CPU 限额。
- 容量账目的 Remove/Add 闭环:派发扣、完成还,配合 Redis 锁和回滚,保证不漂。
- 空闲自关机(
shouldShutDown):worker 侧的 scale-to-zero,无需外部 GC。
8. 边界与局限
- 同一容器的状态分散在「本地 map + Redis」,靠心跳
updateContainerStatus(worker.go:790) 对账,发现「孤儿容器」(运行中但 Redis 无状态)会杀掉——说明一致性是最终一致而非强一致。 - 启动超时是固定上限,重 IO 的首请求(懒加载触发大量回源)可能误伤。
- 检查点恢复(CRIU)由运行时能力
caps.CheckpointRestore门控(lifecycle.go:631),但这是个通用闸:本仓里 runc 和 gVisor 两个运行时都把该能力声明为true(runc.go:64、runsc.go:92)。所以「能否拿到 CRIU 热启动」真正取决于池是否开了CRIUEnabled以及缓存/GPU 状态(见第 03 章),而不是简单地由「选了 gVisor」决定。
9. 横向对比
比起「直接 docker run 一个 agent 工具容器」的朴素做法,Beam 的 worker 把启动拆成可计时阶段、做了 overlay 共享、启动限流和容量闭环——这些都是为「一台机器上反复秒起秒停大量容器」这个工作负载专门做的工程。第 03 章会讲它最独特的部分:镜像根本不「下载」,而是懒挂载。
10. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| worker 主循环 | pkg/worker/worker.go | Worker.Run |
| 接活流 | pkg/worker/worker.go | GetNextContainerRequest(调用处) |
| 三道闸 | pkg/worker/worker.go | handleContainerRequest、dropCancelledContainerRequest、reserveContainerInstance |
| 启动超时 | pkg/worker/worker.go | runContainerRequest |
| 分阶段启动 | pkg/worker/lifecycle.go | RunContainer |
| 生成 spec | pkg/worker/lifecycle.go | specFromRequest、newSpecTemplate、fallbackEntrypoint |
| 真正起容器 | pkg/worker/lifecycle.go | spawn |
| overlay | pkg/worker/lifecycle.go | createOverlay、Overlay.Setup |
| 启动限流 | pkg/worker/lifecycle.go | containerStartSem、containerStartLimitForPoolRuntime |
| 清理与归还容量 | pkg/worker/lifecycle.go / worker.go | clearContainer、manageWorkerCapacity、processCompletedRequest |
| 优雅停机 | pkg/worker/worker.go | shutdown、stopActiveContainersForShutdown |
| 运行时接口 | pkg/runtime/runtime.go | Runtime、Capabilities |