跳到主要内容

核心控制器:reconcile 主循环、子资源与所有权

30 秒导读: 用户写下一个 Sandbox 对象(见 01-sandbox-api-model), 谁来把它变成一个真正跑着的 Pod?就是本章的 SandboxReconciler。它是整个项目的主线: 一个不断被触发的 Reconcile 函数,每次都把"用户想要的样子"和"集群里现在的样子"对齐—— 该建的建、该删的删、该领养的领养,并把结果写回 .status。全部逻辑在 controllers/sandbox_controller.go(约 1370 行,单文件)。


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

一句话定义: 这是一个 Kubernetes 控制器(controller)——一段常驻程序,盯着 Sandbox 这种自定义资源,负责把它"落地"成真实的 Pod / Service / PVC。

它要解决的问题。 用户只想说一句"给我一个沙箱,用这个镜像"。但 Kubernetes 里一个能用的 沙箱其实是一堆东西:一个跑容器的 Pod、一个给它稳定网络名字的 Service、可能还有几块持久化 磁盘(PVC)。有人得把这句"想要"翻译成这一堆资源,还得在它们出问题时修好——这就是控制器。

控制器的核心心法:reconcile(调和)。不是"收到创建请求就建一次"的那种命令式代码。 它是一个幂等的对齐函数:不管当前是什么状态,每次运行都朝"用户声明的目标状态"推一步。

用户改了 Sandbox / Pod 挂了 / 定时到点
│ (事件触发)

┌──────────────────────────────┐
│ Reconcile(req) │ ← 每次都问:现在长啥样?想要啥样?
│ 1. 读 Sandbox 对象 │
│ 2. 到期了吗? ── 是 ─► 清理 │
│ 3. 否则:拉齐 Pod/Svc/PVC │
│ 4. 算 conditions,写回 status │
└──────────────┬───────────────┘

返回 RequeueAfter(什么时候再来看一眼)

一句话直觉/类比: 把它想成一个恒温器。你设定"我要 22℃"(Sandbox spec),恒温器不停地 测当前温度、开关空调,直到达到目标——你从不直接命令空调"开 3 分钟",你只声明目标,它负责收敛。

本章之后的部分,就是逐层拆开这个"恒温器"的内部。


2. 顶层全景(它大概怎么转)

2.1 一次 Reconcile 的骨架

真正的入口是 Reconcile(controllers/sandbox_controller.go:154-233,符号 SandboxReconciler.Reconcile)。 它的主流程可以一眼读完:

Reconcile(ctx, req)

├─① Get(Sandbox) 找不到? → 说明已删,直接返回,不报错 :158-164

├─② 起 trace span + 注入 traceID(可观测性,inline 不重排队) :166-195

├─③ DeletionTimestamp 非零? → "正在删除",啥也不做直接返回 :178-181
│ (真正的级联删除交给 K8s 的 ownerReference 垃圾回收)

├─④ 到期检查 checkSandboxExpiry(sandbox, now) :202
│ ├─ 已到期 & 还没标记 → 打 Expired 条件、写 status、1ms 后再来 :203-210
│ ├─ 已到期 & 已标记 → handleSandboxExpiry(删子资源+看关机策略) :212-213
│ └─ 没到期 → reconcileChildResources(拉齐三件套) :215
│ 再算一次到期,决定 RequeueAfter :216-221

└─⑤ 若 sandbox 未被删 → updateStatus(只在 status 变了才写) :224-230
return {RequeueAfter}, err

怎么读这张图: 从上往下是短路优先——先处理"不存在 / 正在删 / 已到期"这些特殊态, 把它们挡掉;只有"活着且没到期"的正常沙箱,才会走到④的 reconcileChildResources,那才是主戏。

2.2 谁调谁:核心函数地图

层级函数职责位置(符号)
入口Reconcile主循环,分派:154
主戏reconcileChildResources依次拉齐 PVC→Pod→Service,再算 conditions:235
子资源reconcilePVCs建/领养持久卷声明:1139
子资源reconcilePod建/查/领养 Pod;Suspended 时删 Pod:685
子资源reconcileService按需建/删/领养 headless Service:513
元数据updatePodMetadata把标签/注解安全地同步到已存在的 Pod:973
到期checkSandboxExpiry / handleSandboxExpiry判定到期 / 到期清理:1301 / :1220
条件computeConditions算 Ready/Suspended/Finished 条件:278
装配SetupWithManager注册索引、watch、并发度:1346

2.3 三件套的顺序与 status 拼装

reconcileChildResources(:235-276)是把"想要"变"现有"的地方,顺序固定:

nameHash = NameHash(sandbox.Name) ← 全流程的"身份标签值",见 §7

├─ reconcilePVCs ── 建卷,失败不中断(errors.Join 累积) :242
├─ reconcilePod ── 返回 pod;pod==nil 则清空 PodIPs/NodeName :246-255
└─ reconcileService── 返回 svc :258


computeConditions(sandbox, allErrors, svc, pod) → 写入 .status.Conditions :262-273

注意一个容错设计:三件套的错误用 errors.Join 累积而不是遇错即返(:243/247/259), 所以即使 PVC 建失败,Pod 逻辑仍会跑,status 也仍会算——控制器尽量多做事,再把所有错一起上报。


3. 子资源三件套(逐个走读)

这一节是本章的肉。三个 reconcileXxx 函数长得像,但各有各的坑。先建立一个共同心智模型: 每个函数都在回答同一组问题——它在不在?在的话归谁管?该建、该改、该删、还是该领养?

3.1 reconcilePVC:最简单的那个

reconcilePVCs(:1139-1217)遍历 spec.VolumeClaimTemplates,对每个模板算出名字 pvcName = <模板名>-<sandbox 名>(:1148),然后:

  • 已存在 → 走所有权判定(见 §4),owned 就跳过、unowned 且授权就领养、别人的就报错。
  • 不存在 → 用模板 Spec 建一个新 PVC,盖上 sandboxLabel=nameHash 追踪标签,并 SetControllerReference 认领(:1196-1211)。

PVC 是最干净的样板:没有删除逻辑(卷通常要留住数据),只有"建"和"领养"。它是理解另外两个复杂函数的起点。

3.2 reconcilePod:主角,冷/热两条路

reconcilePod(:685-971)是全文件最核心、也最长的函数。它做的第一件事很讲究——不是 直接按名字去 Get Pod,而是先用缓存字段索引列出所有带本沙箱追踪标签的 Pod:

// controllers/sandbox_controller.go:695-702 —— 靠 SetupWithManager 注册的索引做 O(1) 查找
podList := &corev1.PodList{}
r.List(ctx, podList,
client.InNamespace(sandbox.Namespace),
client.MatchingFields{podSandboxNameHashIndex: nameHash}, // 见 §7
)

接着确定"该找哪个 Pod 名"。这里是热池领养的关键:普通沙箱的 Pod 名就等于 sandbox.Name; 但如果这个沙箱是从热池(WarmPool)领来的,它领到的 Pod 名字是别的,记在注解里——由 resolvePodName 解出(:89-94,符号 resolvePodName):

// controllers/sandbox_controller.go:89-94 —— 有 pod-name 注解就用注解里的名字,否则用 sandbox 名
func resolvePodName(sandbox *sandboxv1beta1.Sandbox) string {
if name, ok := sandbox.Annotations[sandboxv1beta1.SandboxPodNameAnnotation]; ok && name != "" {
return name // = "agents.x-k8s.io/pod-name",指向领养来的热池 Pod
}
return sandbox.Name
}

冷启动(自己建 Pod)vs 热启动(领养预热好的 Pod)的完整机制,是 03-extensions-warmpool-claim 的主题;本章只讲控制器侧 "解析名字 → 找到 → 领养"这一半。

reconcilePod 的三条出口:

情形行为位置
OperatingMode == Suspended删掉 owned 的 Pod,清 pod-name 注解,返回 nil(见下):731-760
找到了 Pod(自己建的 / 领养的 / 已存在的)reconcileExistingPod:判所有权、同步元数据、确保注解:789-854
没找到 PodPodTemplate 构造并 Create;撞 AlreadyExists 就转去走"已存在"分支:856-970

Suspended = 优雅暂停。 挂起态下控制器主动删 Pod(:737-738)但保留 Sandbox 对象和 PVC—— 相当于"关机但不销毁",磁盘还在,以后能再拉起。删之前照样要过所有权检查(:733-751):不是自己 owned 的 Pod 绝不删。

建新 Pod 的清洗步骤(安全关键,§5 详述): 从用户的 PodTemplate 拷标签/注解到新 Pod 时, 会逐个丢弃系统保留键(:861-869isSystemLabel 过滤),再在合并之后盖上系统标签 podLabels[sandboxLabel] = nameHash(:871)——顺序很重要,保证用户永远盖不掉系统标签。

3.3 reconcileService:按需存在的 headless Service

reconcileService(:513-656)比 Pod 多一个维度:Service 可有可无,由 spec.Service(*bool) 决定。它是一个 headless Service(ClusterIP: "None",:535)——不做负载均衡,只提供一个稳定 DNS 名,靠 selector: {sandboxLabel: nameHash} 把流量指到那个 Pod。

三态真值表(desired = spec.Service):

Service 现状desired=truedesired=falsedesired=nil
不存在创建 headless Service :524-552不建,清 status :555不建,清 status :555
存在 · 自己 owned修正 selector/label 漂移 :627-651删除 :566-571保持 :627
存在 · unowned授权则领养 :586-625清 status :573不领养,清 status :587-591
存在 · 别人 owned报错拒用 :579-584清 status :573报错拒用 :579-584

领养 unowned Service 时还有一道额外闸门:ClusterIP 必须是 None 或空(:602-608)。因为 ClusterIP 是不可变字段,若领来一个普通(有 IP 的)Service 再改 selector 也没意义,直接拒绝。


4. 所有权与领养模型(项目的安全地基)

这是本控制器最值得学的一处设计。多个控制器(核心控制器、WarmPool、Claim)可能同时看到同一批 Pod/Service/PVC,凭什么判断"这个资源归我管、我能改能删"?答案是 Kubernetes 的 controllerRef (ownerReference 中 controller: true 的那一条)。

4.1 三态枚举

resourceOwnership(:59-69)把所有权收敛成三种情况,由 checkOwnership 判定(:75-84):

// controllers/sandbox_controller.go:75-84 —— 只看 controllerRef 的 UID 是否等于本 sandbox
func checkOwnership(obj client.Object, sandbox *sandboxv1beta1.Sandbox) (resourceOwnership, *metav1.OwnerReference) {
controllerRef := metav1.GetControllerOf(obj)
if controllerRef == nil {
return resourceUnowned, nil // 没主人
}
if controllerRef.UID == sandbox.UID {
return resourceOwnedBySandbox, controllerRef // 我的
}
return resourceOwnedByOther, controllerRef // 别人的
}
状态含义Pod/Service/PVC 的统一处置
resourceOwnedBySandboxcontrollerRef 的 UID == 本 sandbox正常管理:修漂移、到期时删
resourceUnowned完全没有 controllerRef可能领养——但要过授权门槛(下)
resourceOwnedByOthercontrollerRef 指向别的控制器拒绝:报错或跳过,绝不碰

三个 reconcileXxx 都用同一套 switch ownership 结构(Pod 在 :801-833,Service 在 :578-652, PVC 在 :1152-1183),这种一致性让"别人的资源绝不动"成为一条贯穿全文件的不变量。

4.2 领养的授权门槛

resourceUnowned 不等于"随便领"。控制器绝不能把一个恰好同名、但不属于本系统的孤儿资源认领过来 (那会造成误管甚至越权)。所以领养前必须至少满足一个授权条件(Pod 版 :816-824,Service 版 :593-601,PVC 版 :1161-1169,逻辑一致):

  • SandboxAdoptableLabel(agents.x-k8s.io/adoptable: "true")——热池预置的授权标签,表示"这个 资源就是造出来等人领的";或
  • tracking-label 命中:资源已带 sandboxLabel == nameHash,说明它本就是给这个沙箱名准备的。

两者都没有 → 报错拒绝领养。这道门槛是"安全领养热池 Pod"与"不误吞任意孤儿资源"之间的平衡点。


5. 安全关键:元数据传播与清洗

这是本章第二个必须学的点,直接关系到多租户下的流量隔离

5.1 为什么用户绝不能设置系统标签

回忆 §3.3:headless Service 靠 selector: {sandboxLabel: nameHash} 把流量导向 Pod。如果一个租户 能在自己的 PodTemplate 里塞一个 agents.x-k8s.io/sandbox-name-hash: <别人的hash> 标签,那么他的 Pod 就会命中别人 Service 的 selector,劫持到别的沙箱的网络流量。源码里的注释把这点说得很直白:

// controllers/sandbox_controller.go:485-491 —— isSystemLabel 的存在理由
// Such keys must never be settable through a user-supplied PodTemplate, otherwise a
// tenant could override security-critical labels (e.g. the headless Service selector
// label) and hijack another Sandbox's network traffic.
func isSystemLabel(key string) bool { return hasSystemReservedPrefix(key) }

判定很简单:凡是 agents.x-k8s.io/extensions.agents.x-k8s.io/ 前缀的键,都是系统保留 (hasSystemReservedPrefix,:480-483)。注解版 isSystemAnnotation(:495-498)还额外把 trace 注解 TraceContextAnnotation 也纳入保留。用户模板里出现这些键,一律被静默丢弃(建 Pod 时 :863-866,同步时 :987-990)。

5.2 updatePodMetadata:同步 + 反向清洗

updatePodMetadata(:973-1137)负责把 Sandbox 上的标签/注解同步到一个已存在的 Pod(领养后 或漂移后)。它做的远不止"拷过去",还包含几层清洗,返回是否有改动(用于决定要不要 Patch):

  1. 强制系统标签:sandboxLabel = nameHash 必须在(:979-982)。
  2. 传播用户键:拷 PodTemplate 的非系统标签/注解(:986-996:1082-1092)。
  3. 删除已移除的键:用 Pod 上记录的 SandboxPropagatedLabelsAnnotation / ...AnnotationsAnnotation 两个"清单注解",算出模板里已删掉的键并从 Pod 上清除(:1000-1023:1097-1119)。
  4. 反向修补历史漏洞(巧妙):如果清单里混进了系统键——那是旧版本(有漏洞的)控制器曾把系统 键当普通键传播进去的——这里会主动把它们从 Pod 上清掉(:1007-1016),但保留控制器自管的 name-hash 标签(:1008-1010)和 isControllerManagedPodAnnotation 名单里的注解(:503-511:1105-1107)。

这层"自愈"意味着:即便集群里存在被老控制器污染过的 Pod,升级后新控制器也会在下次 reconcile 时把 不该有的系统标签擦干净——把安全修复"追溯"到存量对象上。

5.3 清单注解为什么存在

SandboxPropagatedLabelsAnnotation(agents.x-k8s.io/propagated-labels)记录的是"这些键是我从 模板传过来的",排序后逗号拼接(:907-914:1124-1134)。有了它,控制器才能区分 Pod 上 哪些标签是它加的(该跟着模板删)、哪些是别人加的(不该碰)——否则"同步"就无从知道该删谁。


6. 条件计算(status 怎么算出来)

用户 kubectl get sandbox 看到的 Ready/Suspended/Finished,由 computeConditions(:278-292) 汇总,内部拆成三个纯函数——都是只读的:输入当前 pod/svc/err,输出 condition,不产生副作用。

条件计算函数关键规则
SuspendedcomputeSuspendedCondition :294-316仅当 OperatingMode==Suspended 才出现;Pod 已删=True,未删=False
FinishedcomputeFinishedCondition :412-435Pod 处于 Succeeded/Failed 终态才出现,否则 nil
ReadycomputeReadyCondition :318-410见下方优先级链

computeReadyCondition 的判定像一条短路链:

有 reconcile 错误? → Ready=False, Reason=ReconcilerError :327-331
挂起态? → Ready=False, Reason=Suspended :333-342
Pod 终态(成功/失败)? → Ready=False, 对应 Reason :344-355
否则看 Pod 是否 Running+Ready+有 PodIP,且 Service(若需要)存在
两者都满足 → Ready=True, Reason=DependenciesReady :404-407

一个向后兼容的细节:svcRequired 的判断——若 spec.Service 显式指定就按它;若没指定但集群里 已经存在 Service,则仍要求它 ready(:384-390),避免老对象升级后突然变"不需要 Service"。

reconcileChildResources 末尾还有一处收尾:若这轮没算出 Finished 条件,会主动把它从 status 里 移除(:271-273),防止 Pod 从终态被重建后 status 还残留旧的 Finished。


7. 底层机制:NameHash、缓存索引与装配

7.1 NameHash —— 为什么要哈希名字

标签值有长度和字符限制,而 sandbox 名字可能不合规或过长。所以控制器不直接用名字做标签,而是用 FNV-1a 哈希(一种快速非加密哈希)把名字压成 8 位十六进制:

// controllers/sandbox_controller.go:465-476 —— GetNumericHash + NameHash
func GetNumericHash(input string) uint32 { h := fnv.New32a(); h.Write([]byte(input)); return h.Sum32() }
func NameHash(objectName string) string { return fmt.Sprintf("%08x", GetNumericHash(objectName)) }

这个 nameHash 就是贯穿全章的 sandboxLabel(agents.x-k8s.io/sandbox-name-hash,:51)的值: Pod/Service/PVC 都打它,Service selector 认它,缓存索引也按它建。它是"这些资源同属一个沙箱"的身份纽带

7.2 Pod 缓存字段索引 + 装配

reconcilePod 里的 client.MatchingFields{podSandboxNameHashIndex: nameHash}(§3.2)之所以是 O(1), 是因为 SetupWithManager(:1346-1371)预先在 controller-runtime 的缓存上建了字段索引:

// controllers/sandbox_controller.go:1347-1350 —— 把 Pod 按 sandboxLabel 标签值建索引
mgr.GetFieldIndexer().IndexField(ctx, &corev1.Pod{}, podSandboxNameHashIndex, podSandboxNameHashIndexer)

podSandboxNameHashIndex 定义为 .metadata.labels[<sandboxLabel>](:52-54),索引函数 podSandboxNameHashIndexer(:1338-1343)抽取每个 Pod 的该标签值。装配还做两件事:

  • watch 三类资源并过滤:Owns(&Pod{}) / Owns(&Service{}) 都挂 labelSelectorPredicate (:1352-1368)——只对sandboxLabel 标签的 Pod/Service 事件触发 reconcile,过滤掉集群里 无关的 Pod,大幅减少无谓唤醒。
  • 并发度:MaxConcurrentReconciles: concurrentWorkers(:1369)控制同时调和多少个沙箱。

7.3 到期与 requeue 时序

checkSandboxExpiry(:1301-1317)只看 spec.ShutdownTime:没到点就返回"下次多久后再来看"—— max(剩余时间, 2s)(:1315),即最早在到期时刻、最晚 2 秒后必再唤醒。到点了则返回 expired=true

到期后的 requeue 用一个特殊的 immediateRequeueDelay = time.Millisecond(:56):当刚打上 Expired 条件、需要立刻再跑一轮去做清理时,用 1ms 近乎立即重排队(:209:220),而不是傻等 2 秒。

通用 TTL(含"完成后存活 N 秒"这类)计算不在本控制器里,而在 internal/lifecycle/expiry.go (ExpireAt 等),供 03-extensions-warmpool-claim 的 Claim 复用; 本章的 checkSandboxExpiry 只处理最基础的 ShutdownTime


8. 巧妙之处(可带走的技术)

  • 所有权三态是安全的地基。 把"我的 / 没主 / 别人的"收敛成一个枚举 resourceOwnership (:59-69),让 Pod/Service/PVC 用同一套 switch 处置,"绝不碰别人资源"成为可审计的不变量。
  • 顺序即防御。 建 Pod 时"先过滤用户键、后盖系统标签"(:861-871),用代码顺序保证用户 无法覆盖系统标签——比事后校验更难出错。
  • 清单注解让"同步"可逆。 propagated-labels/annotations(:907-914)记录控制器传播过哪些键, 从而能精确删除模板里已移除的键,而不误删别人加的标签。
  • 安全修复能追溯到存量对象。 updatePodMetadata 会主动擦掉旧版本控制器错误传播进来的系统标签 (:1007-1016),把漏洞修复"回填"到升级前就存在的 Pod 上。
  • 错误累积而非早退。 errors.Join 让一个子资源失败不阻断其余调和与 status 更新(:239-259)。
  • 索引 + predicate 降噪。 字段索引让 pod 查找 O(1)(:1347),label predicate 让只有相关事件 才唤醒 reconcile(:1352-1368)。

9. 边界与局限(诚实)

  • "一沙箱一 Pod"不是强约束。 代码里承认这点:列表 >1 时只打日志"这不该发生" (:704-706),并有 // TODO: find a better way to make sure one sandbox has at most one pod (:694)。真正的唯一性靠命名 + 索引约定,而非硬保证。
  • 不强制已存在 Pod 的 spec。 若 Pod 已存在,控制器不会PodTemplate 的 spec 变更强推上去, 只同步元数据——:846-847 留了 // TODO - Do we enforce (change) spec if a pod exists ?。改镜像等 需要重建 Pod 才生效。
  • 级联删除依赖 K8s GC。 删除 Sandbox 时控制器本身不逐个删子资源(:178-181 直接返回),靠 ownerReference 的垃圾回收级联删除;handleSandboxExpiry 里的显式删除只用于"到期但沙箱本体保留" 的场景。
  • 到期状态更新是有条件的。 handleSandboxExpiry 仅当清理全部成功(allErrors == nil)才把 status 重置为 Expired(:1281-1293),否则保留旧 status 等下轮重试。

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

用符号名 grep 比行号更抗漂移。全部位于 controllers/sandbox_controller.go(除注明外)。

主题文件路径符号名
主循环入口controllers/sandbox_controller.goSandboxReconciler.Reconcile
拉齐三件套 + 算 conditionscontrollers/sandbox_controller.goreconcileChildResources
Pod 建/查/领养/挂起删controllers/sandbox_controller.goreconcilePod / reconcileExistingPod
热池 Pod 名解析controllers/sandbox_controller.goresolvePodName / SandboxPodNameAnnotation
headless Service 建/删/领养controllers/sandbox_controller.goreconcileService
PVC 建/领养controllers/sandbox_controller.goreconcilePVCs
所有权三态判定controllers/sandbox_controller.goresourceOwnership / checkOwnership
领养授权标签controllers/sandbox_controller.goSandboxAdoptableLabel
系统标签/注解清洗controllers/sandbox_controller.goisSystemLabel / isSystemAnnotation / hasSystemReservedPrefix
Pod 元数据同步 + 反向清洗controllers/sandbox_controller.goupdatePodMetadata / isControllerManagedPodAnnotation
Ready/Suspended/Finished 条件controllers/sandbox_controller.gocomputeConditions / computeReadyCondition / computeSuspendedCondition / computeFinishedCondition
名字哈希controllers/sandbox_controller.goNameHash / GetNumericHash
缓存索引 + 装配controllers/sandbox_controller.goSetupWithManager / podSandboxNameHashIndexer / podSandboxNameHashIndex
基础到期判定controllers/sandbox_controller.gocheckSandboxExpiry / handleSandboxExpiry / immediateRequeueDelay
通用 TTL(供 Claim 复用)internal/lifecycle/expiry.goExpireAt / NeedsCleanup / FinishedTime
数据模型(上一章)api/v1beta1/sandbox_types.goSandbox / SandboxSpec / SandboxStatus

相邻章节: 数据模型见 01-sandbox-api-model;冷/热启动与热池领取的另一半见 03-extensions-warmpool-claim;数据面反向代理见 04-sandbox-router;全景与阅读地图见 index