跳到主要内容

Worker 与容器生命周期

上一章请求被派到了某台 worker 的 Redis 队列。这一章讲那台 worker 怎么把它变成一个真正在跑的容器,再到容器退出后的清理。代码主要在 pkg/worker/worker.gopkg/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-streamingworkerRepoClient.GetNextContainerRequestworker.go:467):worker 开一条长连接,服务端那边其实是不断 LPop 那个 Redis 队列把请求推过来。连接断了就重连。

每收到一个请求(worker.go:495):记一笔「队列接收延迟」生命周期事件,然后 handleContainerRequest。它还会在每次循环检查 shouldShutDown——非持久 worker 空闲超过 5 分钟(defaultWorkerSpindownTimeS=300)且没有容器在跑,就自己关机。这就是「scale to zero」在 worker 侧的体现:没人用,机器自己消失。

2. 接到请求后的三道闸

handleContainerRequestworker.go:612)很短,但有三道关:

  1. dropCancelledContainerRequest:起之前先查容器状态,如果状态已经是 Stopping 或干脆没了(用户已经取消),直接丢弃、归还,别白起。
  2. reserveContainerInstance:在本地 containerInstances map 里占坑(加锁),防止同一个容器被起两次。
  3. go runContainerRequest:每个容器一个 goroutine,互不阻塞。

runContainerRequestworker.go:624)里有一个启动超时保护(非构建请求):把 RunContainer 丢进 goroutine,配一个计时器(默认 defaultContainerStartupTimeout=5min,可由 Failover.MaxSchedulingLatencyMs 调整)。超时就 cancel,避免一个卡住的启动占着坑不放。源码里还特意 clamp 了这个值防止 int64 溢出变负数(worker.go:49-53 的注释讲了这个坑)。

3. 核心:RunContainer 的分阶段启动

RunContainerlifecycle.go:252)是整个 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

specFromRequestlifecycle.go:685)从一个 base 模板(newSpecTemplate 按运行时取 base_runc_config.jsonbase_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:真正把容器跑起来

spawnlifecycle.go:978)在自己的 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:1009:容器根文件系统 = 镜像只读层(FUSE 挂载的那个)+ 一个可写上层。spec.Root.Path = overlay.TopLayerPath()。这让多个容器能共享同一份只读镜像,写入互不干扰——也是懒加载能工作的前提。

启动并发信号量 containerStartSemlifecycle.go:1202:容器生成(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 事件,删运行时容器。

finalizeContainerlifecycle.go:138)→ clearContainerlifecycle.go:158)负责善后:

  • 设置 exit code 落库;
  • 释放 GPU 设备、拆容器网络、删上传目录;
  • 把请求塞回 completedRequests 通道manageWorkerCapacityworker.go:933)消费它,调 UpdateWorkerCapacity(AddCapacity) 把资源还给调度器(这步对应第 01 章派发时的 RemoveCapacity,账目闭环);
  • 留一个 TerminationGracePeriod 的宽限期再彻底删,方便最后看日志/事件。

归还容量带重试(processCompletedRequestworker.go:951):如果遇到 Redis 锁没拿到(IsRedisLockNotObtained)就退避重试,否则报错。

6. 优雅停机

收到 SIGTERM(listenForShutdownworker.go:744):cancel 上下文 + DisableWorker(让调度器别再派活)。然后 shutdown()worker.go:1067):

  1. waitForActiveContainersBeforeShutdown 等在跑的容器自己结束(限时);
  2. 还没结束就 stopActiveContainersForShutdown 先 SIGTERM 再(超时)SIGKILL;
  3. 清缓存、网络、卷、镜像挂载;
  4. 非持久 worker RemoveWorker(从池里摘掉),持久 worker 只 disable。

停机时间预算被精细切分(workerShutdownDrainTimeout/workerContainerStopGraceworker.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」,靠心跳 updateContainerStatusworker.go:790)对账,发现「孤儿容器」(运行中但 Redis 无状态)会杀掉——说明一致性是最终一致而非强一致。
  • 启动超时是固定上限,重 IO 的首请求(懒加载触发大量回源)可能误伤。
  • 检查点恢复(CRIU)由运行时能力 caps.CheckpointRestore 门控(lifecycle.go:259),但这是个通用闸:本仓里 runc 和 gVisor 两个运行时都把该能力声明为 truerunc.go:61runsc.go:72)。所以「能否拿到 CRIU 热启动」真正取决于池是否开了 CRIUEnabled 以及缓存/GPU 状态(见第 03 章),而不是简单地由「选了 gVisor」决定。

9. 横向对比

比起「直接 docker run 一个 agent 工具容器」的朴素做法,Beam 的 worker 把启动拆成可计时阶段、做了 overlay 共享、启动限流和容量闭环——这些都是为「一台机器上反复秒起秒停大量容器」这个工作负载专门做的工程。第 03 章会讲它最独特的部分:镜像根本不「下载」,而是懒挂载。

10. 代码地图

主题文件符号
worker 主循环pkg/worker/worker.goWorker.Run
接活流pkg/worker/worker.goGetNextContainerRequest(调用处)
三道闸pkg/worker/worker.gohandleContainerRequestdropCancelledContainerRequestreserveContainerInstance
启动超时pkg/worker/worker.gorunContainerRequest
分阶段启动pkg/worker/lifecycle.goRunContainer
生成 specpkg/worker/lifecycle.gospecFromRequestnewSpecTemplatefallbackEntrypoint
真正起容器pkg/worker/lifecycle.gospawn
overlaypkg/worker/lifecycle.gocreateOverlayOverlay.Setup
启动限流pkg/worker/lifecycle.gocontainerStartSemcontainerStartLimitForPoolRuntime
清理与归还容量pkg/worker/lifecycle.go / worker.goclearContainermanageWorkerCapacityprocessCompletedRequest
优雅停机pkg/worker/worker.goshutdownstopActiveContainersForShutdown
运行时接口pkg/runtime/runtime.goRuntimeCapabilities