跳到主要内容

核心数据模型:Sandbox CRD 的 Spec/Status 与 Blueprint

30 秒导读: 本章讲透 Sandbox 这个自定义资源(CRD)的类型定义——它的期望态(Spec)、观测态(Status)、共享工作负载蓝图(Blueprint),以及一族系统保留的 label/annotation 键。这些是理解后面所有控制器(reconcile、adoption、热池领取、路由)的词汇表。本章只讲"数据长什么样、字段是什么意思",不讲"控制器拿它做什么"(那是 第 2 章)。


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

一句话定义: Sandbox 是一条 Kubernetes 自定义资源(CRD),它把"给一个 AI Agent 用的、可暂停、可自动到期的运行环境"抽象成一个你可以 kubectl apply 的对象。

它要解决的问题。 AI Agent 经常需要一个隔离的沙箱来跑代码、开浏览器、执行工具调用。裸用 Kubernetes 的 Pod 有几处不趁手:

  • Pod 没有"暂停/恢复"语义——你要么开着烧钱,要么删掉丢状态。
  • Pod 没有"到点自动关"的生命周期——Agent 会话结束后容易变成僵尸。
  • 每次都要手写一大坨 PodSpec + Service + PVC,重复且易错。

Sandbox 把这三件事一次打包:一个 Sandbox ≈ 一个带暂停开关、带到期时间、由蓝图描述的 Pod

用起来什么样。 一个最小的 Sandbox 长这样(教学示意,字段名与 api/v1beta1/sandbox_types.go 一致):

apiVersion: agents.x-k8s.io/v1beta1
kind: Sandbox
metadata:
name: demo
spec:
# ↓↓↓ 这一整块是 "Blueprint"(工作负载蓝图),Sandbox 和 SandboxTemplate 共用同一套字段
podTemplate:
spec:
containers:
- name: agent
image: ghcr.io/example/agent:latest
service: true # 让控制器帮我建一个 headless Service
# ↑↑↑ Blueprint 到此为止
operatingMode: Running # 运行时开关:Running / Suspended
shutdownTime: "2026-07-14T18:00:00Z" # 到点自动收
shutdownPolicy: Retain # 到期后保留 Sandbox 对象(只删底层 Pod/Service)
status:
serviceFQDN: demo.default.svc.cluster.local # 控制器回填:怎么连到它
podIPs: ["10.1.2.3"]
conditions: [ { type: Ready, status: "True", reason: DependenciesReady } ]

一句话直觉/类比:Sandbox 想成"带定时关机和睡眠键的一次性工作站"。Spec 是你下的单(要什么机器、几点关机、现在是开着还是睡着),Status 是前台回给你的取件信息(在哪、连得上没)。

本节到此不碰底层。下面开始逐层拆类型。


2. 顶层全景:一个 Sandbox 类型的四个部分

一个 Sandbox 类型由四块拼成。先看结构,再逐块深入。

Sandbox (api/v1beta1/sandbox_types.go:278)
├── TypeMeta / ObjectMeta ← K8s 通用头(name、labels、annotations)

├── Spec 期望态 (:191) ─────────────┐
│ ├── SandboxBlueprint (内联,:199) │ "工作负载长什么样"
│ │ ├── podTemplate │ → 与 SandboxTemplate 共享
│ │ ├── volumeClaimTemplates │
│ │ └── service *bool │
│ ├── Lifecycle (内联,:203) ───────┤ "什么时候、怎么关"
│ │ ├── shutdownTime │
│ │ └── shutdownPolicy │
│ └── operatingMode ───────────────┘ "现在是开还是睡" Running/Suspended

└── Status 观测态 (:240)
├── serviceFQDN / service ← 怎么连
├── podIPs / nodeName ← 落在哪
├── conditions[] ← Ready / Suspended / Finished
└── selector ← 给 /scale 子资源用的 pod 选择器

怎么读这张图: 从上到下是"头 → 我想要什么(Spec) → 实际怎么样(Status)"。Spec 内部又分成两类字段——「可共享的工作负载描述」(Blueprint)和「只属于这个运行实例的运行时开关」(Lifecycle + operatingMode)。这条分界线是本章最重要的一条设计线。

各部分职责一句话:

部分干什么定义位置
SandboxBlueprint描述工作负载本身(Pod、PVC、要不要 Service),可被 SandboxTemplate 复用api/v1beta1/sandbox_types.go:164
Lifecycle到期时间 + 到期后删不删 Sandbox 对象api/v1beta1/sandbox_types.go:226
operatingMode运行时电源开关:Running / Suspendedapi/v1beta1/sandbox_types.go:210
SandboxStatus控制器回填的观测态:连接信息、条件、落点api/v1beta1/sandbox_types.go:240
保留 label/annotation 键一族常量键,是 adoption/传播/热池逻辑的"暗号"api/v1beta1/sandbox_types.go:57-84

3. Blueprint:被刻意抽出来共享的那半个 Spec

3.1 它要解决的小问题

Sandbox(单个实例)和 SandboxTemplate(可复用的模板,见 第 3 章)都需要描述"要跑一个什么样的 Pod"。如果两边各写各的 PodSpec 字段,就会漂移:模板加了个字段,Sandbox 忘了跟,行为就不一致。

解法是把"工作负载描述"这半部分单独抽成一个类型 SandboxBlueprint,再用 Go 的内联嵌入(json:",inline")分别塞进两边的 Spec。这样两边共用同一份字段定义,天然不漂移。

3.2 Blueprint 里有什么

SandboxBlueprint 只有三个字段(api/v1beta1/sandbox_types.go:164-188):

字段类型含义
podTemplatePodTemplate(必填)沙箱要跑的 Pod;PodTemplate = spec: PodSpec + metadata(只用 labels/annotations),定义在 :127
volumeClaimTemplates[]PersistentVolumeClaimTemplate会为沙箱创建的 PVC 模板;标了 +listType=atomic,整表替换而非合并(:172-178)
service*bool(三态)是否自动建 headless Service。niltrue/false 语义不同(见下)

service 用指针类型是为了表达"三态":

  • nil——未设置。控制器为了向后兼容,保留已有 Service 但不新建
  • true——建/保留 Service。
  • false——显式禁用并删除 Service。

源码里对这个"三态、不做 enum"的取舍有一行明确说明://nolint:kubeapilinter ... Enum not used to avoid duplicating the Service API(api/v1beta1/sandbox_types.go:185),并挂了 issue #746。

3.3 两条关键设计约束(源码注释即契约)

Blueprint 的两段注释本身就是给维护者的硬约束,读懂它们就读懂了这个类型为什么长这样。

约束一:Blueprint 故意不含运行时字段。 SandboxBlueprint 的文档注释写明它"deliberately excludes runtime-only fields (operatingMode, lifecycle)"(api/v1beta1/sandbox_types.go:162-163)。原因很直观:operatingMode(开/睡)和 lifecycle(几点关)是某一次运行的状态,不该被"可复用的模板"带着走——模板只描述"长什么样",不描述"这一台现在开着没、几点关"。

这解释了图里那条分界线:

SandboxSpec = [ 可共享:Blueprint ] + [ 不共享:Lifecycle + operatingMode ]
podTemplate shutdownTime
volumeClaimTemplates shutdownPolicy
service operatingMode

约束二:改 Blueprint 字段要同步改两处别的代码。 类型定义正上方有一段醒目注释(api/v1beta1/sandbox_types.go:157-160):

增删/改名 SandboxBlueprint 的字段,必须同步更新 sandboxwarmpool_controller.go 里的 compareSandboxBlueprint()——否则热池的陈旧检测(staleness check)不会把这个字段算进去,字段变了也检测不到"预热的沙箱已过时"。

也就是说 Blueprint 不只是个数据结构,它还被 第 3 章 的热池控制器逐字段比对(extensions/controllers/sandboxwarmpool_controller.go:515,541 调用 compareSandboxBlueprint)。仓库里甚至有一个测试 TestSandboxBlueprintFieldsAreCompared(extensions/controllers/sandboxwarmpool_controller_test.go:2238)专门在字段漏比时报错。这是一个"用测试给数据模型的演进上锁"的巧妙做法。

Blueprint 被复用的实证: SandboxTemplateSpec 也是直接内联同一个类型——sandboxv1beta1.SandboxBlueprint json:",inline"(extensions/api/v1beta1/sandboxtemplate_types.go:94),并复述了"一旦加到这里就同时提升给 Sandbox 和 SandboxTemplate"的约定。


4. 运行时字段:Lifecycle 与 OperatingMode

这两块是 Blueprint 不含的部分——它们描述"这一台沙箱现在/将来的状态"。

4.1 OperatingMode:电源开关(Running / Suspended)

operatingMode 是一个字符串枚举,只有两个值(api/v1beta1/sandbox_types.go:147-155, 205-210):

含义
Running沙箱应处于活动运行状态(默认值,+kubebuilder:default=Running)
Suspended沙箱应被暂停

字段上挂了 kubebuilder 校验标记 +kubebuilder:validation:Enum=Running;Suspended,API server 会拒绝非法值。默认值也由 marker 生成到 CRD schema 里,所以你不写 operatingMode,存进 etcd 时就是 Running

"Suspended"具体怎么落地成"Pod 被终止"由控制器负责,是 第 2 章 的内容;这里只需知道期望态里的开关就是这一个字段

4.2 Lifecycle:到期时间与到期策略

Lifecycle(api/v1beta1/sandbox_types.go:226-237)也是内联进 Spec 的,两个字段:

字段类型含义
shutdownTime*metav1.Time绝对到期时刻(不是"多久后",是"几点");格式 date-time
shutdownPolicy*ShutdownPolicy到期后要不要删 Sandbox 对象本身;默认 Retain

ShutdownPolicy 也是个两值枚举(:213-223,marker +kubebuilder:validation:Enum=Delete;Retain):

到期后
Delete连 Sandbox 对象一起删掉
Retain(默认)保留 Sandbox 对象,Status 标记为 Expired;只删底层 Pod/Service

一个容易忽略的细节写在字段注释里:无论选哪个策略,底层的 Pod/Service 到期都一定会被删(api/v1beta1/sandbox_types.go:232-233)。shutdownPolicy 只决定"那张 Sandbox 卡片本身留不留",不决定"里面的 Pod 留不留"。Retain 的价值是:你事后还能 kubectl get sandbox 看到它、看到它为什么结束(条件/原因),而不是查无此物。


5. Status:控制器回填的观测态

Spec 是"你想要的",SandboxStatus(api/v1beta1/sandbox_types.go:240-266)是"控制器观察到的、并回填给你的"。字段:

字段含义
serviceFQDN完整域名,给客户端连沙箱用;域名默认 cluster.local,可由控制器 --cluster-domain 改(:241-244)
serviceService 名
conditions[]标准 metav1.Condition 数组,是"沙箱现在到底啥状态"的权威信号(见 §6)
selectorpod 的 label selector 字符串;配合 /scale 子资源用(:254-256)
podIPs[]底层 Pod 的 IP;双栈集群下可能多个(:258-261)
nodeNamePod 被调度到的节点名(:263-265,v1beta1 新增,v1alpha1 没有)

podIPs 值得单独点一句:它是 第 4 章 里 sandbox-router "Pod-IP 缓存快路"的数据来源——路由器靠 Status 里回填的 IP 直连沙箱,而不必每次都去解析 Service。


6. 条件与原因常量族:沙箱状态的"官方词汇"

conditions[] 里每条 Condition 有 type(哪一维度)和 reason(为什么)。这两组字符串在 api/v1beta1/sandbox_types.go:28-55 定义成常量,是全系统统一的词汇。

三个条件类型(ConditionType):

Condition Type常量表示
ReadySandboxConditionReady (:37)沙箱是否就绪可用
SuspendedSandboxConditionSuspended (:30)是否被管理性暂停
FinishedSandboxConditionFinished (:48)底层 Pod 是否已到终态

各条件对应的 Reason(填进 condition.reason):

归属条件Reason 常量触发场景
ReadySandboxReasonDependenciesReady (:39)依赖全部就绪、完全可用
ReadySandboxReasonDependenciesNotReady (:42)期望运行但依赖还没配好
ReadySandboxReasonSuspended (:45)因被暂停而不 Ready
SuspendedSandboxReasonSuspendedPodTerminated (:32)Pod 已终止(暂停完成)
SuspendedSandboxReasonSuspendedPodNotTerminated (:34)Pod 还没终止(暂停进行中)
FinishedSandboxReasonPodSucceeded (:50)Pod 成功结束
FinishedSandboxReasonPodFailed (:52)Pod 失败结束
(通用)SandboxReasonExpired (:55)沙箱已到期

读法示意:一个"正在被暂停、Pod 还没停下"的沙箱,会同时带 Ready=False/reason=SandboxSuspendedSuspended=True/reason=PodNotTerminated。控制器怎么算出并写入这些条件是 第 2 章 的事;本章只确立"这些字符串是什么、分别指什么"。


7. 系统保留的 label / annotation 键族(第 2、3 章的暗号表)

api/v1beta1/sandbox_types.go:57-84 定义了一批带 agents.x-k8s.io/ 前缀的保留键。它们不是给用户随便写的,而是控制器之间通信、以及 adoption/传播/热池逻辑赖以工作的暗号。这里先建立词汇,具体消费在后续章节。

Annotation 键:

常量作用
SandboxPodNameAnnotation (:57)agents.x-k8s.io/pod-name记录从热池领养(adopt)来的 Pod 名
SandboxTemplateRefAnnotation (:59).../sandbox-template-ref记录来源 SandboxTemplate 引用
SandboxPropagatedLabelsAnnotation (:74).../propagated-labels记录从 Sandbox spec 显式传播到 Pod 的 label
SandboxPropagatedAnnotationsAnnotation (:76).../propagated-annotations同上,针对 annotation

Label 键:

常量作用
SandboxLaunchTypeLabel (:61).../launch-type冷创建还是来自热池;值为 cold(SandboxLaunchTypeCold,:66)/ warm(SandboxLaunchTypeWarm,:68)
CreatedByLabel (:63).../created-by哪个组件创建的(client / controller…)
SandboxTemplateHashLabel (:72).../sandbox-template-hashBlueprint 的哈希(旧键 DeprecatedSandboxPodTemplateHashLabel,:69,已弃用)
SandboxAdoptableLabel (:78).../adoptable授权一个 Sandbox 去领养某个当前无属主的资源
SandboxWarmPoolLabel (:80).../warm-pool-sandbox标记该 Sandbox 属于哪个 WarmPool
SandboxTemplateRefHashLabel (:82).../sandbox-template-ref-hash标识 Sandbox 源自哪个 SandboxTemplate

三个键值得先记住,因为它们是后两章的主角:

  • launch-type: cold|warm——第 3 章 冷/热启动的判定标记。
  • adoptable——adoption(领养已存在资源)的授权开关;没有它,控制器不会去接管一个不是自己建的资源。这是 第 2 章 所有权逻辑的安全闸。
  • propagated-labels / propagated-annotations——控制器记住"我把用户 spec 里的哪些 label 主动搬到了 Pod 上",以便下次能精确地增删,而不会误删 Pod 上别人加的 label。

8. 类型级 kubebuilder 标记:CRD 的"元数据"

Sandbox 类型上方(api/v1beta1/sandbox_types.go:268-277)挂了一排注释标记,controller-gen 会据此生成 CRD YAML 和客户端代码。逐条含义:

标记作用
+genclient生成类型化客户端
+kubebuilder:object:root=true这是一个顶层 API 对象(有 List)
+kubebuilder:subresource:status开启 /status 子资源(status 与 spec 分开写)
+kubebuilder:resource:scope=Namespaced,shortName=sandbox命名空间级;kubectl get sandbox 简称
+kubebuilder:printcolumn:...Ready (:272)kubectl get 里加一列 Ready,取自 conditions[type==Ready].status
+kubebuilder:printcolumn:...Reason (:273)加一列 Reason,取自同一条件的 reason
+kubebuilder:printcolumn:...Age (:274)加 Age 列
+kubebuilder:storageversion (:275)本版本(v1beta1)是 etcd 存储版本
+kubebuilder:conversion:strategy=Webhook (:276)多版本之间用转换 webhook

前两个 printcolumn 让 kubectl get sandbox 直接把 §6 的 Ready 条件平铺成两列——不用 -o yaml 翻 conditions 就能一眼看状态。后两个标记(storageversion + conversion webhook)引出下一节的多版本共存。


9. 多版本共存:v1alpha1 与 v1beta1 + 转换

集群里可能同时存在老对象(v1alpha1)和新对象(v1beta1)。Kubernetes 的做法是:选一个版本作为"存储版本"(Hub),其余版本在读写时通过转换 webhook 与 Hub 互转。本项目里:

  • v1beta1 是 Hub / 存储版本。 v1beta1.Sandbox 实现空方法 Hub()(api/v1beta1/sandbox_conversion.go:18)来声明自己是中心版本,配合 §8 的 +kubebuilder:storageversion
  • v1alpha1 是"辐条"(spoke)。 它实现 ConvertTo / ConvertFrom(api/v1alpha1/sandbox_conversion.go:29,63)来和 Hub 互转。

这里有一处很值得学的语义迁移:v1alpha1 用 replicas *int32(0=暂停、1=运行,api/v1alpha1/sandbox_types.go:156)表达开关,而 v1beta1 改成了更语义化的 operatingMode(Running/Suspended)。转换函数把两者对应起来:

  • ConvertSpecTo(v1alpha1→v1beta1):replicas==0 → Suspended,否则 Running(api/v1alpha1/sandbox_conversion.go:144-148)。
  • ConvertSpecFrom(v1beta1→v1alpha1):Suspended → replicas=0,否则 1(:180-186)。

无损往返的技巧: 单纯把 replicas 折叠成两个枚举值会丢信息(比如 replicas=3 这种历史值)。为此 ConvertTo 在转成 v1beta1 时,把整个原始 v1alpha1 对象序列化成 JSON塞进注解 api.agents.x-k8s.io/v1alpha1-sandbox-state(api/v1alpha1/sandbox_conversion.go:45-57);ConvertFrom 再从该注解还原原始 replicas 等字段,并把注解删掉以免泄漏给客户端(:88-113)。这是 CRD 版本迁移里经典的"用注解背一份原文实现 lossless round-trip"手法。本章只点明存在与思路,逐行走读留给读源码的人。


10. 边界与本章不涉及的

  • 本章只讲"数据长什么样",不讲控制器怎么消费它。"Suspended 如何真的停掉 Pod""条件如何被计算并写回""adoptable 如何触发领养"——都在 第 2 章第 3 章
  • 转换只点到为止。 §9 说明了 Hub/spoke、存储版本、以及 replicas↔operatingMode 的语义迁移与无损往返思路;完整字段映射(PodTemplate/PVC/Lifecycle 各 helper)见 api/v1alpha1/sandbox_conversion.go:118-279
  • SandboxTemplate / WarmPool / Claim 等扩展类型只在"Blueprint 被谁复用"处出现,细节属于 第 3 章

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

主题文件路径符号名
Sandbox 顶层类型 + kubebuilder 标记api/v1beta1/sandbox_types.goSandbox
期望态api/v1beta1/sandbox_types.goSandboxSpec
共享工作负载蓝图api/v1beta1/sandbox_types.goSandboxBlueprint
Pod 模板 / 元数据api/v1beta1/sandbox_types.goPodTemplate / PodMetadata
PVC 模板api/v1beta1/sandbox_types.goPersistentVolumeClaimTemplate / EmbeddedObjectMetadata
运行时开关api/v1beta1/sandbox_types.goSandboxOperatingMode / SandboxOperatingModeRunning / SandboxOperatingModeSuspended
生命周期 / 到期策略api/v1beta1/sandbox_types.goLifecycle / ShutdownPolicy / ShutdownPolicyDelete / ShutdownPolicyRetain
观测态api/v1beta1/sandbox_types.goSandboxStatus
条件类型api/v1beta1/sandbox_types.goSandboxConditionReady / SandboxConditionSuspended / SandboxConditionFinished
条件原因api/v1beta1/sandbox_types.goSandboxReasonDependenciesReady / SandboxReasonSuspended / SandboxReasonPodSucceeded / SandboxReasonExpired
保留 label/annotation 键api/v1beta1/sandbox_types.goSandboxLaunchTypeLabel / SandboxAdoptableLabel / SandboxPropagatedLabelsAnnotation / SandboxTemplateHashLabel
Hub 声明(存储版本)api/v1beta1/sandbox_conversion.goHub
版本转换(spoke)api/v1alpha1/sandbox_conversion.goConvertTo / ConvertFrom / ConvertSpecTo / ConvertSpecFrom
Blueprint 被模板复用extensions/api/v1beta1/sandboxtemplate_types.goSandboxTemplateSpec
Blueprint 漂移检测(约束二的消费方)extensions/controllers/sandboxwarmpool_controller.gocompareSandboxBlueprint