♪ 点击加载音乐播放器
OpenSandbox 源码走读(一):CRD Controller 与 Reconcile 循环 最近在看 OpenSandbox(阿里的沙箱开源项目)的控制面实现。它把"沙箱"建模成 K8s CRD,用一个 controller-runtime 算子驱动整个生命周期。这篇是我对自己 fork 里 kubernetes/ 目录的走读笔记:从 POST /sandboxes 的参数怎么落进 BatchSandbox spec,到 Reconcile 每一步在干什么,再到失败之后靠什么重试。代码都来自本地 clone(controller-runtime v0.21.0)。
全景:三个 CRD,两条回路 算子一共定义了 3 个 CRD(API 组 sandbox.opensandbox.io/v1alpha1),对应 3 个 Reconciler:
CRD Reconciler 并发默认 干什么 BatchSandbox(bsbx) BatchSandboxReconciler 32 沙箱本体:建 Pod、跑任务、pause/resume Pool PoolReconciler 16 预热 Pod 池:分配/回收/滚动更新/驱逐 SandboxSnapshot(sbxsnap) SandboxSnapshotReconciler 1 rootfs 快照提交(pause/resume 的底层依赖)
整个控制面就两条互相咬合的回路:
1
2
3
4
任务回路:BatchSandbox reconcile → TaskScheduler → HTTP 推任务到 pod 内 task-executor(:5758)
→ 轮询任务状态 → 写回 status
分配回路:Pool reconcile → Allocator → 写 alloc-status 注解 + 内存 store
→ 注解变化经 watch 触发对方 reconcile → 闭环
注意第二条回路的关键设计:分配状态的持久化真相源是 BatchSandbox 上的注解,不是 Pool 的 status 。Pool 的内存 store 只是个可重建的缓存。这个设计后面还会反复出现——很多"奇怪"的代码都是在为注解契约服务。
create 请求怎么变成 BatchSandbox 业务侧调 POST /sandboxes,server(Python)把它翻译成一个 BatchSandbox CR。池化模式的核心在 server/opensandbox_server/services/k8s/batchsandbox_provider.py 的 _create_workload_from_pool:
1
2
3
4
5
6
7
8
9
10
11
def _create_workload_from_pool ( self , batchsandbox_name , namespace , labels , pool_ref , expires_at , entrypoint , env , ... ):
entrypoint = entrypoint or DEFAULT_ENTRYPOINT
spec = {
"replicas" : 1 ,
"poolRef" : pool_ref , # ← 池化模式的开关,指向预热池
}
if env or entrypoint != DEFAULT_ENTRYPOINT or self . execd_run_as_init : # needs_task_template
spec [ "taskTemplate" ] = self . _build_task_template ( entrypoint , env , batchsandbox_name )
if expires_at is not None :
spec [ "expireTime" ] = expires_at . isoformat () # ← timeout 参数的归宿
# 再包一层 apiVersion/kind/metadata,create_custom_object 直接落一个 BatchSandbox CR
几个 create 参数的映射关系值得记一下:
API 参数 落到 CR 的哪里 备注 extensions.poolRefspec.poolRef特殊值 * = 让控制器自动选池 env / entrypointspec.taskTemplate非空即触发 taskTemplate 生成timeoutspec.expireTime不传 = 不自动到期,靠手动 delete metadatametadata.labels用于 GET /sandboxes?metadata=... 过滤 image / resourceLimits被忽略 池化 pod 预创建,模板锁死在 Pool 上
env 这条最有意思:server 会把 env 包进一个 shell 包装命令(_build_task_template 里 shlex.quote 转义后拼进 /bin/sh -c "/opt/opensandbox/bootstrap.sh <entrypoint> &"),再把 OPENSANDBOX_ID 追加进 env 列表,最终挂到 taskTemplate.spec.process。
这里有个容易踩的坑:不传 env 也不传 entrypoint 时走"快路径" ,不生成 taskTemplate——池 pod 继续跑自己的 warm entrypoint,代价是任何分配时的 env 都注入不进去。所以"为什么我的环境变量没生效"十有八九是这个分支判断没过。
最终生成的 CR 大概长这样:
1
2
3
4
5
6
7
8
9
10
kind: BatchSandbox # apiVersion : sandbox.opensandbox.io/v1alpha1
spec :
replicas : 1
poolRef : my-pool # 二选一:poolRef(池化)或 template(直建)
taskTemplate : # 可选;决定要不要走任务调度
spec :
process :
command : [ "/bin/sh" , "-c" , "/opt/opensandbox/bootstrap.sh python /app/main.py &" ]
env : [ {name: OSB_USER_ID, value : user-12345}]
expireTime : "2026-09-06T12:00:00Z" # timeout 的归宿
注意 spec 里 template 和 poolRef 互斥:直建模式自己带 PodTemplateSpec,池化模式只有 poolRef,Pod 的长相完全由 Pool 的模板决定。server 还会把一批参数(volumes、networkPolicy、snapshotId+poolRef 组合等)直接 400 拒掉,因为池化 pod 是预创建的,事后没法改。
BatchSandbox 的 spec/status 长什么样 apis/sandbox/v1alpha1/batchsandbox_types.go,关键字段摘出来:
1
2
3
4
5
6
7
8
9
10
11
12
type BatchSandboxSpec struct {
Replicas * int32 // 默认 1
PoolRef string // 与 Template 互斥
Template * corev1 . PodTemplateSpec
ShardPatches [] runtime . RawExtension // 按 index 给每个 pod 打 strategic merge patch
ExpireTime * metav1 . Time // 到了就删,控制器负责执行
TaskTemplate * TaskTemplateSpec // 非 nil = 需要 TaskScheduler
Pause * bool // pause/resume 意图,Server 写、Controller 执行
}
// +kubebuilder:validation:Enum=Pending;Succeed;Pausing;Paused;Resuming;Failed
type BatchSandboxPhase string
phase 一共 6 个:Pending / Succeed 是稳态,Pausing / Paused / Resuming 是暂停链路,Failed 是终态。status 侧除了 replicas/ready 计数,还有一组任务计数和两个特殊字段:
1
2
3
4
5
6
7
8
type BatchSandboxStatus struct {
ObservedGeneration int64
Replicas , Allocated , Ready int32
TaskRunning , TaskSucceed , TaskFailed , TaskPending , TaskUnknown int32
Phase BatchSandboxPhase
PauseObservedGeneration int64 // pause/resume 幂等闸门:最近一次 ACK 的 generation
Conditions [] BatchSandboxCondition // Ready/Progressing/Paused/PauseFailed/ResumeFailed/PodFailed
}
PauseObservedGeneration 这个设计我第一次看有点绕:spec.pause 只是个布尔意图,Controller 靠"generation 是否前进 + 我 ACK 到哪个 generation 了"来判断这是不是一次新请求。这是把 K8s 原生的 observedGeneration 惯例复用到了 pause/resume 这条命令通道上,避免了额外的 ACK 子资源。
Reconcile 主循环:一轮到底干了什么 核心在 internal/controller/batchsandbox_controller.go 的 Reconcile,按执行顺序拆开:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
func ( r * BatchSandboxReconciler ) Reconcile ( ctx context . Context , req ctrl . Request ) ( ctrl . Result , error ) {
batchSbx := & sandboxv1alpha1 . BatchSandbox {}
if err := r . Get ( ctx , client . ObjectKey { ... }, batchSbx ); err != nil {
if errors . IsNotFound ( err ) {
return ctrl . Result {}, nil // 对象没了,不用管
}
return ctrl . Result {}, err // 其他错误 → 交给退避重试
}
// 1. ExpireTime:过期就删,没过期把剩余时间记进 requeue 计划
if expireAt := batchSbx . Spec . ExpireTime ; expireAt != nil {
if expireAt . Time . Before ( now ) && batchSbx . DeletionTimestamp == nil {
r . Delete ( ctx , batchSbx )
} else if ! expireAt . Time . Before ( now ) {
DurationStore . Push ( key , expireAt . Time . Sub ( now ))
}
}
// 2. poolRef="*":自动选池,List 全 namespace 的 Pool 挑一个 patch 回 spec.poolRef
if updated , err := r . assignPool ( ctx , batchSbx , profileName ); updated {
return ctrl . Result {}, nil // ← spec 变了,generation 前进,等下一轮
}
// 3. finalizer:有 taskTemplate 才挂 task-cleanup,防删除时残留任务
// 4. pause/resume 分发:优先级最高的命令通道
if result , handled , err := r . dispatchPauseResume ( ctx , batchSbx ); handled {
return result , err
}
// 5. 列出 pod(两种模式实现完全不同,见下)
pods , err := r . listPods ( ctx , poolStrategy , batchSbx )
// 池化模式下分配的 pod 消失了 → failClosedOnMissingAllocatedPods:fail-closed 标 Failed
// 6. 扩容(池化和 Paused 状态跳过;只有扩没有缩)
if ! poolStrategy . IsPooledMode () && batchSbx . Status . Phase != Paused {
r . scaleBatchSandbox ( ctx , batchSbx , batchSbx . Spec . Template , pods )
}
// 7. 从 pod 现状推 phase
runtimeView := buildRuntimeView ( batchSbx , pods )
// 8. 任务调度(taskTemplate != nil 才进),结果写进 status 任务计数
if taskStrategy . NeedTaskScheduling () && batchSbx . Status . Phase != Paused {
ts , err := r . reconcileTasks ( ctx , batchSbx , pods )
...
}
// 9. 落盘:endpoints/runtime-id 注解 + status patch
requeue , persistErrors := r . persistRuntimeView ( ctx , batchSbx , runtimeView )
// 10. requeue:多来源取最短(Pop 返回各处 Push 的最短值,再与 persist 阶段合并)
requeueAfter := DurationStore . Pop ( req . String ())
return reconcile . Result { RequeueAfter : requeueAfter }, gerrors . Join ( aggErrors ... )
}
第 6 步的 guard 值得细品:池化模式直接跳过 scale——池化 sandbox 没有 template,pod 的生死由 Pool 的分配/回收决定,控制器自己建删只会打架。Paused 跳过是因为暂停后的沙箱 pod 应该保持不存在,等 resume 重写镜像再拉起来。
listPods 在两种模式下的实现完全不同:直建模式按 ownerRefUID 的 field index 一次 List 拿齐;池化模式先解析 alloc-status 和 alloc-released 两个注解做差集,再对剩下的 pod 逐个 Get ——代码里就带着 // TODO maybe performance is problem 的原话注释。池化 sandbox 的 replicas 一大,这每轮 R 次深拷贝 Get 的成本就线性往上叠。
状态机:phase 不是存的,是算的 走读里印象最深的设计:phase 没有独立的状态存储,每轮 reconcile 都从 pod 现状重新推导 (batchsandbox_status.go 的 buildRuntimeView):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func buildRuntimeView ( batchSbx , pods ) runtimeView {
newStatus := batchSbx . Status . DeepCopy ()
for _ , pod := range pods {
newStatus . Replicas ++
if utils . IsAssigned ( pod ) {
newStatus . Allocated ++ // 池化模式下"分配了"的 pod
}
if podReady ( pod ) {
newStatus . Ready ++ // Running + Ready 且未在删除
}
}
switch batchSbx . Status . Phase {
case Pausing , Paused :
// 生命周期稳态,不动
case Resuming :
applyResumingRuntimePhase ( newStatus , pods )
default :
applySteadyRuntimePhase ( batchSbx , newStatus , pods )
}
}
func applySteadyRuntimePhase ( ... ) {
if summary , hasFailures := summarizePodFailures ( pods ); hasFailures {
// CrashLoopBackOff / ImagePullBackOff / ErrImagePull / CreateContainerConfigError → Failed
status . Phase = sandboxv1alpha1 . BatchSandboxPhaseFailed
return
}
if status . Ready > 0 {
status . Phase = sandboxv1alpha1 . BatchSandboxPhaseSucceed
return
}
status . Phase = sandboxv1alpha1 . BatchSandboxPhasePending
}
状态流转画出来:
stateDiagram-v2
[*] --> Pending: 创建
Pending --> Succeed: ready > 0
Pending --> Failed: pod 进入 BackOff 类状态
Succeed --> Failed: pod 故障
Succeed --> Pausing: spec.pause=true 且 generation 前进
Pausing --> Paused: 内部 Snapshot Succeed,删 Pod、去 finalizer
Pausing --> Failed: Snapshot Failed 且 Pod 丢失
Paused --> Resuming: spec.pause=false 且 generation 前进
Resuming --> Succeed: 快照镜像重建的 pod ready
Resuming --> Failed: resume pod 失败 “状态是推导出来的而不是记录下来的”,这是 K8s 控制器的正统做法(level-triggered),好处是任何状态丢失都能自愈。代价是 Failed 这种"终态"必须靠条件分支保护——代码里到处是 if phase != Failed 的短路判断,不然一个 ready pod 的短暂波动就会把 Failed 翻回去。另外 PauseFailed/ResumeFailed 两个 condition 由 mergeLifecycleConditions 单独保护,不被运行时推导覆盖。
扩容:确定性命名 + 期望机制防重复 scaleBatchSandbox 是直建模式下 pod 的唯一出生点,两个细节都值得学:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func ( r * BatchSandboxReconciler ) scaleBatchSandbox ( ctx , batchSandbox , podTemplateSpec , pods ) error {
// 1. 对现有 pod 做 ObserveScale,然后查期望是否满足
if satisfied , unsatisfiedDuration , _ := BatchSandboxScaleExpectations . SatisfiedExpectations ( key ); ! satisfied {
// 上一轮的 Create 还没被 informer 观察到 → 跳过本轮扩容,防重复建 pod
DurationStore . Push ( key , expectations . ExpectationTimeout - unsatisfiedDuration )
return nil
}
// 2. 算缺哪些 index
var needCreateIndex [] int
// TODO var needDeleteIndex []int ← 缩容压根没实现,只留了个空注释
for i := 0 ; i < int ( * batchSandbox . Spec . Replicas ); i ++ {
if _ , ok := indexedPodMap [ i ]; ! ok {
needCreateIndex = append ( needCreateIndex , i )
}
}
for _ , idx := range needCreateIndex {
pod , _ := utils . GetPodFromTemplate ( podTemplateSpec , batchSandbox , controllerRef )
// 3. 按 index 打 shard patch(每个副本可以不一样)
podBytes , _ := json . Marshal ( pod )
modifiedPodBytes , _ := strategicpatch . StrategicMergePatch ( podBytes , batchSandbox . Spec . ShardPatches [ idx ]. Raw , & corev1 . Pod {})
pod . Name = fmt . Sprintf ( "%s-%d" , batchSandbox . Name , idx ) // 4. 确定性命名,天然幂等
BatchSandboxScaleExpectations . ExpectScale ( key , expectations . Create , pod . Name )
r . Create ( ctx , pod ) // 失败则 ObserveScale 回滚期望 + 记 Warning Event
}
}
确定性命名 + ScaleExpectations 是一对组合拳:名字确定让"建了又看不见"(informer 滞后)不会变成重复创建,期望机制让这个等待变得显式。但缩容那句 TODO var needDeleteIndex []int 很扎眼——spec.replicas 调小不会删任何 pod,这可是 ReplicaSet 十年前的基本功。想缩副本只能删整个 sandbox,或者走 Pool 模式。
与 K8s 原生机制的耦合点 这个控制器重度依赖 K8s 原生设施,汇总成一张表:
机制 用法 ownerReference + Owns Pod/Snapshot 挂 BatchSandbox 为 owner,Owns(Pod) watch 级联 Finalizer task-cleanup(停任务)/ pool-allocation(等 pod 归还池)/ cleanup(清 commit Job)Annotation alloc-status / alloc-release / alloc-released / endpoints / runtime-id,五条内部契约 Generation spec 变更自增,配合 observedGeneration / pauseObservedGeneration 做幂等 Subresource status Status().Patch 单独写状态,与 spec 写路径隔离
注解契约里最容易混淆的是池化三件套,语义上有时序:
1
2
3
alloc-status (Pool 写):"这些 pod 分给这个 sandbox 了" —— 已生效
alloc-release (BatchSandbox 写):"我请求释放这些 pod" —— 释放请求
alloc-released (Pool 写):"确认已回收" —— 释放完成
releasePods 的实现就是把释放请求 MergePatch 到 BatchSandbox 的 alloc-release 注解上(batchsandbox_controller.go),然后靠 Pool 侧的 watch 谓词感知这次变化。Pool 侧的 watch 装配(pool_controller.go 的 SetupWithManager):
1
2
3
4
5
6
7
8
return ctrl . NewControllerManagedBy ( mgr ).
For ( & sandboxv1alpha1 . Pool {}, builder . WithPredicates ( predicate . GenerationChangedPredicate {})).
Owns ( & corev1 . Pod {}). // ← 无谓词,任何 pod 事件都全量 reconcile
Watches ( & sandboxv1alpha1 . BatchSandbox {}, handler . EnqueueRequestsFromMapFunc ( findPoolForBatchSandbox ),
builder . WithPredicates ( filterBatchSandbox )). // 只放行 release 注解变化/replicas 变化/进入删除
Watches ( & sandboxv1alpha1 . BatchSandbox {}, enqueueOldPoolForDetachedBatchSandbox ,
builder . WithPredicates ( filterBatchSandboxDetached )). // poolRef 非空→空:通知旧池重新平衡
Complete ( r )
这个谓词设计其实很精细:普通 alloc-status 变化不会惊动 Pool(不然分配回路就是死循环),只有 release/replicas/删除这三类"需要 Pool 动手"的信号才入队。对比之下 For(BatchSandbox) 连谓词都没有——status 更新会自己触发自己的 reconcile,每笔状态变更约等于两次 reconcile。一边抠得极细,一边完全裸奔,观感有点割裂。
失败怎么重试:三层 requeue 机制 控制器里的重试其实分三层。第一层:返回 error,走 controller-runtime 默认指数退避。 任何 return ctrl.Result{}, err 都进 workqueue 的 rate limiter。最省事但也最不可控——网络抖动和业务逻辑失败在这里没有区别。
第二层:RequeueAfter 定时轮询,多来源取最短。 这是最重的一层,配套了一个小工具 DurationStore:
1
2
3
4
5
6
// internal/utils/requeueduration —— 多个来源都往里 Push,取最短值
func ( rd * Duration ) Update ( newDuration time . Duration ) {
if newDuration > 0 && ( rd . duration <= 0 || newDuration < rd . duration ) {
rd . duration = newDuration
}
}
往里 Push 的来源我数了一下,至少有这几类:| 场景 | 间隔 | 出处 |
|—|—|—|
| 任务调度轮询 | 3s,无条件 | reconcileTasks |
| ExpireTime 到期兜底 | 剩余时间 | Reconcile 开头 |
| ScaleExpectations 未满足 | 剩余超时窗口 | scaleBatchSandbox |
| pause/resume 各阶段推进 | 1s | batchsandbox_pause_resume.go |
| Snapshot 状态轮询 | 100ms / 1s / 5s | sandboxsnapshot_controller.go |
| Pool 缺 pod 补货 | 5s | pool_controller.go |
其中 3s 任务轮询是这个设计的命门:
1
2
3
4
5
6
func ( r * BatchSandboxReconciler ) reconcileTasks ( ... ) ( * taskScheduleResult , error ) {
...
// Because tasks are in-memory and there is no event mechanism, periodic reconciliation is required.
DurationStore . Push ( key , 3 * time . Second ) // ← 注释写得明明白白:没事件机制,只能轮询
...
}
第三层:RetryOnConflict,只管写冲突。 Pool 控制器把整个 reconcilePool 包在 retry.RetryOnConflict(retry.DefaultBackoff, ...) 里,冲突就整段重跑。
三层里最要命的是第二层的两个特性叠加:任务型 sandbox 不管有没有变化每 3s 必 reconcile 一次 ,而 RequeueAfter 走 AddAfter 直接入队,完全绕过 rate limiter (默认指数退避只保护返回 error 的路径)。1000 个任务型 sandbox 就是稳态 333 次 reconcile/s,退避机制对它一筹莫展。任务状态纯内存、task-executor 又没有状态上报事件,这个 3s 是代码注释里自己承认的无奈之举。pause/resume 期间还有一堆 1s 轮询在推进中间态,控制器一过载这些中间态就会集体变慢——轮询式状态机的通病。
整体看下来,这个控制器的骨架是很标准的 controller-runtime 姿势,状态推导、finalizer、期望机制都用得中规中矩;真正让我犹豫的是它把注解当 IPC 用(五个注解键、JSON 整表覆盖式写入、跨控制器 watch 级联)和 3s 无条件轮询这两个选择——单机 demo 没问题,规模上去之后这两处就是最先崩的地方。下一篇拆 Pool 的分配/回收/滚动更新,那里能看到注解契约的完整写路径。(下一篇:Lease 与生命周期)