状态:草案(2026-10-07 更新,2026-10-09 补充规模测试实测数据)。 本文以官方文档的描述为基础,其中**第 8 节的隔离环境测试以及文末的“kopf Operator 直接测试”(包含规模测试)和“LWS 直接测试”**是亲自验证过的数据。其余部分暂未实测。 请先阅读文末的“确认过的版本和日期”以及“未确认的事项”。 编写规则:以实测数据为基础,未能测量的内容标注为“未实测”。
目录
- 1. 为什么单一 Deployment 不够用
- 2. LWS、KServe、Dynamo 的 API 结构
- 3. Operator 的核心模式
- 4. Go (controller-runtime/kubebuilder) 对比 Python (kopf)
- 5. CRD Schema 设计原则
- 6. 滚动更新策略
- 7. 与 GitOps (Argo CD) 的冲突
- 8. 在隔离环境中确认的行为
- 9. 动手尝试
- 10. 面试常见问题
- 11. 常见误解
- 外部依据
- 参考资料(实际查阅过的内容)
- 确认过的版本和日期 (2026-10-05)
LLM 推理服务部署有许多 Deployment 无法表达的需求:跨多个节点的 Leader 与 Worker、权重加载这一前置步骤、必须遵守比例的滚动更新、GPU 拓扑结构,以及以 Pod 组而不是单个 Pod 为单位的扩展。Kubernetes 通过 CRD(Custom Resource Definition)和 Operator 扩展了这些能力。本文整理了这些 API 的当前形态与 Operator 的核心模式,并结合在隔离实验环境中验证的行为作为依据。首先说明一点:我本人没有生产环境规模的 Go Operator 运维经验。
读完本文后,你将在面试中能够解释:
- 为什么难以用 Deployment 表达 LLM 推理服务,以及 LeaderWorkerSet (LWS)、KServe 和 Dynamo 分别增加了什么样的抽象。
- 调谐(reconcile)循环的幂等性、基于级别的触发、所有者引用与 Finalizer、状态条件(Status Conditions)、退避(Backoff)以及 Leader 选举为什么会这样设计。
- CRD 版本转换流程、滚动更新策略,以及解决 Argo CD 与 Operator 争夺同一字段问题的方法。
1. 为什么单一 Deployment 不够用
- 扩展单位是组(Group)。 当一个模型副本跨越多个节点的 GPU 时,该副本就是 Leader Pod 和 Worker Pod 的集合。如果一个 Pod 死亡,集合通信就会破坏,因此 LWS 将整个组重建策略(
RecreateGroupOnPodRestart)设为 CRD 默认值。 - 前置步骤漫长且开销巨大。 正如第 8 篇中所见,权重加载需要几分钟。在只有单张 GPU 的环境中,有时会使用
strategy: Recreate,因为优先启动新 Pod 的滚动更新需要额外的 GPU。 - 调整后的滚动更新。 分离 Prefill(预填充)和 Decode(解码)的推理服务在整个更新过程中必须保持这两种角色的容量比例,LWS 的 DisaggregatedSet 就是为此而设计的 API。
- 拓扑与 Gang 调度。 组内的 Pod 必须在同一个拓扑域中一起调度。Dynamo 文档指导在多节点上组合使用 Grove 与 KAI Scheduler(默认),或 LWS 与 Volcano。
2. LWS、KServe、Dynamo 的 API 结构
| 项目(确认的版本) | CRD(API 组/版本) | 值得关注的字段 |
|---|---|---|
| LWS v0.11.1 | LeaderWorkerSet (leaderworkerset.x-k8s.io/v1) |
replicas(组数), leaderWorkerTemplate.size, restartPolicy, rolloutStrategy.rollingUpdateConfiguration(maxUnavailable 默认 1, maxSurge 默认 0, partition), groupIdentity(不可变),scale 子资源 |
| LWS v0.11.1 | DisaggregatedSet (disaggregatedset.x-k8s.io/v1) |
roles[]每个对应一个 LWS,在整个角色中协调滚动更新 |
| KServe v0.21.0 | InferenceService(v1beta1), ServingRuntime(v1alpha1) |
predictor 等,运行时声明支持的模型格式 |
| KServe v0.21.0 | LLMInferenceService(v1alpha1,存储为 v1alpha2) |
model, template, worker(如果是多节点则为 LWS), prefill, router, rolloutStrategy, kvCacheOffloading(仅在 v1alpha2 中) |
| Dynamo v1.5.0 | DynamoGraphDeployment(nvidia.com,存储为 v1beta1) |
components[](在 v1alpha1 中是 services 映射), restart, topologyConstraint |
| Dynamo v1.5.0 | DynamoComponentDeployment, DynamoGraphDeploymentRequest, DynamoGraphDeploymentScalingAdapter, DynamoModel |
组件部署,基于 SLA 的请求(性能分析后生成 DGD),用于 HPA 的 scale,模型注册 |
KServe 文档指出, InferenceService 用于传统的预测模型,而 Prefill/Decode 分离、多节点以及前缀感知路由则由 LLMInferenceService 负责(查阅了 0.20 版本的页面)。Dynamo 的 CRD 名称和字段直接从 v1.5.0 标签的 CRD YAML 中读取。
DynamoGraphDeployment (dgd) ← 사용자가 선언: 컴포넌트 목록, 복제 수, 재시작 id
│ 오퍼레이터가 만듦
▼
DynamoComponentDeployment (dcd) ← 컴포넌트마다, 이름에 worker hash 접미사
│ 배포 모드에 따라
├─▶ Deployment (단일 노드)
└─▶ Grove PodCliqueSet 또는 LWS (다중 노드, 갱 스케줄링)
3. Operator 的核心模式
API Server ─watch▶ Informer(로컬 캐시) ─▶ WorkQueue(키 중복 제거, 백오프) ─▶ Reconcile(키)
▲ │
└────── 변경(자식 생성, status 기록) ◀── 관측 → 비교 → 행동 → 보고 ◀────┘
基于级别的调谐(Level-based reconciliation)。 controller-runtime 源码(v0.25.2)的注释指出,调谐是由 apiserver 或缓存中读取的实际状态驱动的,而不是由事件驱动的。 Request 中只包含名称和命名空间,并没有包含具体发生了什么变化,因此即使收到 Pod 删除事件,调谐函数也是通过读取“Pod 不存在”来得知的。kubebuilder 一书的“良好实践”章节也警告说,如果为每个事件编写逻辑,资源就会卡住而需要人工干预。kopf 采用的是根据生成、修改、删除等原因分别调用处理程序的模型,因此对未监视的子项的变化需要用定时器来弥补(第 9 节)。不过,对于停机期间的更改,它会在启动时列出所有对象并只处理最终状态,从而以基于级别的方式进行处理。
幂等性。 编写代码时应遵循“使之呈现所需状态”而非“执行创建”的原则。如果状态已经正确,则不应写入任何内容,这样才能保持子对象的 resourceVersion 不变,且不会产生多余的监视事件。即使存在 Leader 选举,也可能会瞬间出现两个 Leader,因此这一点至关重要。
所有者引用与 Finalizer。 根据 Kubernetes 文档,子对象的 ownerReferences 指向同命名空间中所有者的名称和 UID,跨命名空间的引用在设计上是被禁止的,垃圾回收器会留下 OwnerRefInvalidNamespace 警告事件。如果存在 Finalizer,删除请求将只会设置 deletionTimestamp ,必须由控制器完成清理并移除 Finalizer 后才会真正删除。kubebuilder 一书指出,由于删除会变成更新,因此编写 Finalizer 的添加和清理逻辑时必须都具备幂等性。
状态与条件。 status.conditions 和 observedGeneration 以标准格式公开状态。Dynamo 的 DGD 也会将 conditions, observedGeneration, state, rollingUpdate 放在 status 中。Argo CD 文档指出,由于 CRD 的 status 格式各不相同,因此需要自定义健康检查,使用 observedGeneration 可以防止在调谐结束前做出错误判断。
重试与退避。 如果调谐函数返回错误,工作队列(Workqueue)会通过指数退避进行重新入队, RequeueAfter 会在指定时间后重新入队,而 Requeue 字段已被弃用(deprecated)。 TerminalError 不会重新入队。“尚未就绪”不应视为错误,而应通过延迟重新入队来表达。
Leader 选举。 controller-runtime 管理器通过 LeaderElection, LeaderElectionID, LeaderElectionNamespace 选项获取 Lease(在 Dynamo 的 main.go 中得到了确认,LMCache Operator 的安装 YAML 中也有 leader-election Role)。Kubernetes 文档解释说,Lease 用于节点心跳和控制平面 Leader 选举。
Webhook 验证。 简单的规则使用 CRD Schema 的 CEL(x-kubernetes-validations)就足够了,复杂的规则和注入则需要 Webhook。Dynamo 在验证 Webhook 中记录了通过 field.ErrorList 收集并返回所有独立错误的规则,LMCache Operator 则通过突变 Webhook(Mutating Webhook)将连接配置注入到 vLLM Pod 中,并通过 cert-manager 签发 Webhook 证书。
4. Go (controller-runtime/kubebuilder) 对比 Python (kopf)
| 项目 | Go: controller-runtime v0.25.2, kubebuilder v4.16.0 | Python: kopf 1.44.6 (2026-06-03) |
|---|---|---|
| 模型 | 带类型的客户端、Informer 缓存、工作队列、管理器 | 装饰器处理程序、将状态存储在对象的注解或 status 中 |
| 代码生成 | controller-gen 使用 生成 CRD(Dynamo 使用 v0.17.3) |
直接编写 CRD YAML |
| 防重复 | Lease Leader 选举 | KopfPeering与优先级 |
| 生态系统 | Dynamo Operator 的 README 说明它是用 Kubebuilder 构建的,转换通过 conversion.Convertible 接口实现。LWS、KServe、LMCache CRD 也都是通过 controller-gen 生成的 |
将状态放在对象中并提供诸如 kopf.adopt 之类的工具,kopf 自身声明它不是一个 Kubernetes 客户端库 |
这是我个人的判断。对于需要转换 Webhook 和大规模监视的上游级 CRD,Go 生态系统是默认选择;而对于一两个团队使用的内部自动化,团队最熟练的语言则是收益最高的。由于我没有运营 Go Operator 的经验,上述对比是阅读文档和代码得出的结果。
5. CRD Schema 设计原则
- Spec 要小,一次性命令放入 Status。
restartNow: true诸如之类命令式的字段在执行后其语义会变得模糊。Dynamo 的 DGD 在更改restart.id时会重启,通过 CEL 转换规则oldSelf.hasValue() || !has(self.restart)使得在创建时可以不填 restart。 - 默认值放在 Schema 中。 LWS 将
size默认设为 1,将restartPolicy默认设为RecreateGroupOnPodRestart放在 CRD 中。相反,LMCacheLMCacheEngine的isolatedIPC根据 GPU 厂商而异,没有静态默认值,需由 Operator 计算,因此文档警告说 Operator 的升级可能会改变默认行为并导致引擎 Pod 重启。 - 使用 CEL 进行验证。 Dynamo 的 DCD 使用 CEL 对
minAvailable <= replicas和minAvailable,type的不可变性进行限制,KServe 的LLMInferenceService禁止同时指定replicas和scaling。不破坏现有对象的验证棘轮机制(ratcheting)从 Kubernetes 1.33 开始成为稳定特性(stable)。 - 版本与转换。
served(是否接收请求)和storage(写入 etcd 的表现形式,刚好一个)是不同的维度。如果 Schema 相同,转换策略None就足够了;如果形态发生了变化,则需要转换 Webhook。Dynamo 在从 v1alpha1(已弃用)过渡到 v1beta1(存储版本)的过程中,将services映射更改为了components列表,并在CONVERSION.md中记录了转换 Webhook、Hub-Spoke 规则以及将无法表达的字段保留在稀疏注解中的规则。KServe 的LLMInferenceService在 CRD 中没有转换配置,因此采用默认策略None,且kvCacheOffloading仅存在于 v1alpha2 中(通过对比 Schema 进行了确认)。关于存储版本迁移,存在StorageVersionMigrationAPI,但 Kubernetes 文档从 1.35 开始将其标记为 Beta,且文档中关于是否默认启用的说法不一,因此未能确认。Dynamo 直接借鉴了 Cluster API 的 migrator。
6. 滚动更新策略
- LWS: 滚动更新以组为单位,
maxUnavailable,maxSurge基于组的数量(两者不能同时为 0)。partition进行部分序数上线到新模板的金丝雀发布,在 Hash 模式下不支持。maxSurge需要额外的 GPU 组。DisaggregatedSet 维持角色之间的容量比例并一起发布,但不支持 partition。 - Dynamo: 将 worker 输入的哈希值放在 DGD 注解(
nvidia.com/current-worker-hash-v2)中,当哈希改变时,创建带有新后缀的 DCD 进行发布,只有在完成后才提交注解。还有一个从 v1 迁移到 v2 的规则,以确保 Operator 升级不会引发发布。status.rollingUpdate仅支持单节点、非 Grove 部署。 - KServe:
canaryTrafficPercent使用将部分流量发送到新版本并记住最后一个健康版本以进行回滚的方式,不会发送到不健康的版本。仅在无服务器(Serverless)部署模式下支持。 - 模型版本切换与蓝绿发布: 将新版本作为独立组启动,在确认模型加载和 readiness 通过后转移流量的顺序。这是我总结的上述工具的共同原则,并非引用自单个文档。
7. 与 GitOps (Argo CD) 的冲突
当 Operator 或 HPA 修改 Git 中记录的字段时,就会产生冲突。 replicas 如果写在 Git 中,就会与 HPA 发生冲突(HPA 扩容后 Argo 会将其恢复,然后 HPA 再次扩容)。解决方法如下。
ignoreDifferences:jsonPointers,jqPathExpressions,managedFieldsManagers在比较中排除。系统整体指定为argocd-cm的resource.customizations.ignoreDifferences.<그룹>_<종류>。RespectIgnoreDifferences=true: 默认情况下仅应用于比较,在同步时期望的状态会直接应用,因此若要在同步时也排除,则需要此选项。它仅对已存在的资源有效。- 服务端 diff (从 v3.1.0 开始稳定):由于与服务端应用(Server-side Apply)演练(dry-run)的结果进行比较,因此准入控制器会参与 diff 的计算。初次创建资源时不执行此操作。
- 自定义健康检查 (Lua):将 Operator CR 的 status 反映到应用健康状态中。
经常遇到的陷阱是健康检查。在应用的健康由下级资源的状态决定且同步等待该健康状态的结构中,如果内置检查将某个资源(例如:失败过一次的 CronJob)判定为 Degraded,同步可能会一直等待健康状态而失败,从而导致新的更改无法应用。可以通过使用 Lua 重新定义检查规则来解决此问题(未确认此行为出现在哪个版本)。在 ApplicationSet 中也会发生两个控制器的冲突,即关闭了子应用程序的自动化,但父应用程序模板又将其重新打开,解决方法是通过名称和路径将范围缩小到单个字段来设置例外。
8. 在隔离环境中验证的行为
以下是通过隔离的实验环境(kwok 集群或个人 VM)中的实际 Kubernetes 控制器确认的内容,并非我的生产环境故障记录。
- 所有权引用 UID 错误。 uid 错误的子资源会被垃圾收集器认为父资源已被删除而在几秒钟内删除。如果控制器缓存了父资源的 uid,并且父资源以相同的名称重新创建,那么用旧 uid 创建的子资源在创建后会立即消失。跨命名空间的引用会使 apply 通过并仅留下警告事件,该事件默认在一小时后消失。如果先删除 Operator 再删除 CR,它会停留在 Terminating 状态,但手动移除 finalizer 会跳过外部清理,因此必须先删除 CR 再清理 Operator。
- 存储版本迁移。 在策略
None中,新字段的默认值在存储时会被填充,因此旧表示的对象在重新写入之前是不可见的,这可能导致控制器将同一 Kind 视为两种类型。storedVersions中剩余的版本从spec.versions中移除会导致 API Server 拒绝请求,这一点已在实验 Pod 中得到验证。由于无法启动 Webhook 服务器,未能测试转换 Webhook。 - 调谐循环。 Go 컨트롤러를 실행할 수단이 없어 셸 스크립트 조정기로 관측, 비교, 행동, 보고와 멱등성(
resourceVersion对不可变内容进行评分,且不模拟 informer 缓存和工作队列。leader 选举演练通过展示当两个实例试图写入相同时字段时,服务端应用会提示冲突,从而说明存在双 leader 区间的风险。
局限性。 以上是实验验证的 Kubernetes 自身的行为,并非特定 Operator 实现或 Go 生产环境 Operator 的故障响应。
9. 动手尝试
仅在单个本地 kind 集群中进行。所有命令都明确指定了 kind-op-lab 上下文,因此不会触及其他集群。结束后通过最后一行将整个集群删除。
kind create cluster --name op-lab
kubectl --context kind-op-lab create ns op-lab
cat <<'EOF' | kubectl --context kind-op-lab apply -f -
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: modelcaches.labs.example.dev}
spec:
group: labs.example.dev
scope: Namespaced
names: {kind: ModelCache, plural: modelcaches, singular: modelcache, shortNames: [mc]}
versions:
- name: v1
served: true
storage: true
subresources: {status: {}}
additionalPrinterColumns:
- {name: Phase, type: string, jsonPath: .status.phase}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [model]
properties:
model: {type: string}
replicas: {type: integer, default: 1, minimum: 0, maximum: 8}
x-kubernetes-validations:
- {rule: "self.model.size() > 0", message: "model must not be empty"}
status:
type: object
x-kubernetes-preserve-unknown-fields: true
EOF
最小调谐循环(op.py)将一个 ConfigMap 通过所有权引用绑定到一个 CR。kopf 通过 pip install kopf kubernetes 安装。
import kopf, kubernetes as k8s
@kopf.on.resume("labs.example.dev", "v1", "modelcaches")
@kopf.on.create("labs.example.dev", "v1", "modelcaches")
@kopf.on.update("labs.example.dev", "v1", "modelcaches")
@kopf.timer("labs.example.dev", "v1", "modelcaches", interval=30, idle=10)
def reconcile(spec, name, namespace, patch, **_):
cm = {"apiVersion": "v1", "kind": "ConfigMap",
"metadata": {"name": f"{name}-cfg"},
"data": {"model": spec["model"], "replicas": str(spec.get("replicas", 1))}}
kopf.adopt(cm) # ownerReferences: 이 CR의 이름과 uid
api = k8s.client.CoreV1Api()
try:
api.create_namespaced_config_map(namespace, cm)
except k8s.client.ApiException as e:
if e.status != 409:
raise
api.patch_namespaced_config_map(f"{name}-cfg", namespace, cm) # 멱등
patch.status["phase"] = "Ready"
@kopf.on.delete("labs.example.dev", "v1", "modelcaches")
def cleanup(name, logger, **_):
logger.info("%s: 외부 자원 정리 자리", name) # 삭제 핸들러가 있으면 kopf 가 finalizer 를 붙인다
kubectl config current-context # kind-op-lab 인지 확인(kind 가 만들면서 바꿉니다)
kopf run --standalone --namespace op-lab op.py &
kubectl --context kind-op-lab -n op-lab apply -f - <<'EOF'
apiVersion: labs.example.dev/v1
kind: ModelCache
metadata: {name: demo}
spec: {model: tiny-llm}
EOF
kubectl --context kind-op-lab -n op-lab get mc,cm -o wide
kubectl --context kind-op-lab -n op-lab get cm demo-cfg -o jsonpath='{.metadata.ownerReferences}'
# 에지 대 레벨 관찰: 자식을 지워도 CR 이벤트가 없으므로 타이머(30초 주기)가 되살립니다
kubectl --context kind-op-lab -n op-lab delete cm demo-cfg && sleep 40 && kubectl --context kind-op-lab -n op-lab get cm
# CEL 검증: spec.model 을 빈 문자열로 넣은 CR 을 적용하면 "model must not be empty" 로 거절됩니다
# 정리
kill %1; kind delete cluster --name op-lab
接下来要尝试的内容: ownerReferences 观察故意写错 uid 的 ConfigMap 是否被删除,并在删除 CR 期间检查 deletionTimestamp 和 finalizer 列表。此骨架不监视子资源的更改,因此是使用定时器进行补充的学习用途。
10. 面试中可能出现的问题
- LLM 服务中不使用 Deployment 而使用 LWS 的原因是什么? 因为副本是 leader 和 worker 的集合,所以必须以组为单位进行扩缩容、滚动更新、故障恢复和拓扑部署。如果一个 Pod 失败,集合通信就会中断,因此默认策略是重新创建整个组。
- 调谐循环即使漏掉事件也没关系的原因是什么? 因为请求中没有事件内容,每次都是读取当前状态来弥补差异。即使控制器死后复活,它也会从头开始重新运行,只完成尚未结束的部分,因此所有的写操作必须是幂等的。
- ownerReference 和 finalizer 的区别是什么? 前者是子对象指向父对象、交由垃圾回收器处理的机制,若 uid 错误则子对象会被删除。后者是为了清理外部资源而延迟删除的机制,若在没有控制器的情况下剥离它,则会跳过清理过程。
- 将 CRD 从 v1alpha1 升级到 v1beta1 的步骤是什么? 添加新版本作为 served,根据需要附加转换 Webhook,迁移 storage 并重写所有现有对象后,
storedVersions清理该项并关闭旧版本。策略None仅在字段结构相同时才安全。 - 如果 Argo CD 还原了操作员更改的字段会怎样? 从 Git 中移除该字段,如果较难做到,则在
ignoreDifferences中将其排除在比较之外,在RespectIgnoreDifferences中将其排除在同步之外。健康状态通过针对 CR 的 Lua 检查来observedGeneration反映。
11. 常见误解
- 调谐函数是一个事件处理器:它是一个只接收键并重新读取当前状态的函数。
- 如果有领导者选举,就不会同时在两个地方运行:瞬时可能会出现两个。
- 剥离 finalizer 后,Terminating 就能被安全解决:这会跳过外部清理。
- 添加字段总是兼容的:在策略
None中,旧的表达方式无法获得新的默认值。 - 如果 Argo CD 显示绿色,说明模型正在提供服务:CR 的健康状态必须有自定义检查才能反映出来。
外部依据
| 主张 | 资料 | 确认的内容 |
|---|---|---|
| 调谐由当前状态驱动,而非事件 | controller-runtime pkg/reconcile/reconcile.go 的 Request·Reconciler 注释 |
Request 只包含名称和命名空间,没有事件内容。注释指出调谐是基于级别的(level-based),由从 apiserver 或缓存中读取的实际状态驱动,并举例说明了 Pod 被删除这一事实也不是通过删除事件、而是在读取状态时得知的。指出 Requeue 字段已被弃用,建议使用 RequeueAfter 。 |
| 按事件编写逻辑是反模式 | kubebuilder 书籍,Good practices | 警告称,按事件编写调谐逻辑违背了操作员模式,可能导致资源阻塞并需要人工干预,要求编写多次重新调谐也能产生相同结果的代码。 |
| 控制器不知道它关闭了多久 | Kubernetes 社区文档 (sig-api-machinery, controllers.md) | 应该观察当前状态而非状态转换,informer 会周期性重新同步,工作队列中只放入键以去重,错误则通过指数退避进行重新入队。 |
| kopf 的监听范围与定时器 | kopf 文档 (handlers, timers, continuity, errors) | 处理器仅对通过 diff 检测到的本质变化作出反应,没有关于未监听的子对象变化的描述。定时器在对象存在期间按周期运行,关闭期间的更改会在重启时列出并仅处理最后的状态,普通错误重试间隔默认值为 60 秒。 |
| 「level-triggered」术语的出处 | 上述 controller-runtime 注释与社区文档 | kubernetes.io 的控制器概念页面描述了控制循环和「使当前状态接近期望状态」,但没有使用该术语本身。本文将该术语归属于 controller-runtime 与 kubebuilder 书籍。 |
参考资料(实际打开过的页面)
- https://lws.sigs.k8s.io/docs/overview/ , /concepts/leaderworkerset/ , /concepts/leaderworkerset/failure-handling/ , /concepts/leaderworkerset/rollout-strategy/ , /concepts/disaggregatedset/ , /reference/disaggregatedset.v1/ 以及 https://github.com/kubernetes-sigs/lws (v0.11.1 CRD YAML)
- https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview , /concepts/architecture/control-plane-llmisvc , /model-serving/predictive-inference/rollout-strategies/canary 以及 https://github.com/kserve/kserve (v0.21.0 CRD YAML)
- https://docs.nvidia.com/dynamo/latest/ , https://github.com/ai-dynamo/dynamo (v1.5.0: operator 目录中的
README.md,api/CONVERSION.md,internal/dynamo/worker-hash.md, CRD YAML,docs/fern/pages/kubernetes/installation/multinode-orchestration.md) - https://docs.lmcache.ai/mp/operator.html
- https://book.kubebuilder.io/reference/good-practices.html , /reference/using-finalizers.html , /multiversion-tutorial/tutorial.html
- https://github.com/kubernetes-sigs/controller-runtime (v0.25.2
pkg/reconcile/reconcile.go) - https://kopf.readthedocs.io/en/stable/ , /peering/ , /errors/ , /continuity/ , /configuration/
- https://kubernetes.io/docs/concepts/extend-kubernetes/operator/ , /concepts/overview/working-with-objects/owners-dependents/ , /finalizers/ , /concepts/architecture/leases/ , /tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ , /custom-resource-definition-versioning/ , /tasks/manage-kubernetes-objects/storage-version-migration/ , /reference/command-line-tools-reference/feature-gates/
- https://argo-cd.readthedocs.io/en/stable/user-guide/diffing/ , /sync-options/ , /diff-strategies/ , /operator-manual/health/
亲自测试 kopf Operator
第 9 节中的最小调谐循环(op.py,未作任何修改)在一次性单节点 k3s(v1.36.5,4 个 vCPU 虚拟机)上通过 kopf 1.44.6 运行,以验证第 3 节的模式。为了计算调用次数,仅增加了一行日志的版本(op2.py)被用于重复执行和错误测试。
| 验证内容 | 结果 |
|---|---|
从创建 CR 到生成子 ConfigMap, status.phase=Ready 直到 |
分别为 0.1 秒 |
| 子对象的属主引用 | CR 的名称和 UID, controller: true, blockOwnerDeletion: true |
| 架构默认值 | replicas 如果不填写则填充为 1,并反映在 ConfigMap 中 "replicas":"1" |
| CEL 验证 | 空的 model 为 model must not be empty, model 缺失会导致 Required value, replicas: 99 因不符合不大于 8 的规则而被拒绝(API Server 阶段) |
| finalizer | kopf.zalando.org/KopfFinalizerMarker 被附加 |
| 删除子 ConfigMap 时的恢复时间(5 次,打乱初始阶段) | 2.5、15.9、24.9、17.8、26.9 秒 |
| 修改子对象内容时恢复为原值 | 29.9 秒 |
| 从 CR 规格变更反映到 ConfigMap 中 | 0.1 秒 |
在调谐运行三次期间(95 秒),子对象和 CR 的 resourceVersion |
两者均不变(幂等) |
| 在 Operator 关闭期间更改规格并删除子对象,重启后 | 0.6 秒内恢复 ConfigMap(反映 replicas=5) |
| 在没有 Operator 的情况下删除 CR | CR 仅带着 deletionTimestamp 留存,子 ConfigMap 也保持原样。启动 Operator 后,0.6 秒后 CR 消失,子对象通过垃圾回收在 0.1 秒后消失 |
- 恢复延迟的大小由实现方式决定。 删除子对象时恢复时间分散在 2.5~26.9 秒之间,是因为 kopf 的 CR 处理器不会被未监控的子对象删除所唤醒,而仅通过定时器(本测试中的
interval=30,idle=10)进行恢复。controller-runtime 的Owns()会通过子对象删除事件立即重新调谐属主,因此延迟接近监控传播时间(官方文档描述,未直接测量)。测试证实,这两种方式都是读取当前状态以弥补差异的基于级别的调谐,即使错过事件也能恢复。 - 如果没有 Operator,finalizer 会阻止删除。 CR 删除无法完成是正常行为,在移除 Operator 之前必须先删除 CR 或有意清理 finalizer。本次测试也在清理阶段因相同原因暂停了一次(在 Operator 关闭的状态下执行了整体 CR 删除)。
- 重复执行(无领导者选举)。 两个 Operator 同时调谐同一个 CR。在 50 秒内,实例 A 执行了 6 次,实例 B 执行了 6 次,在同一时间做相同的事情。开启 kopf 的对等互联(
KopfPeering)并将优先级分别设为 100 和 50 后,A 执行了 6 次,B 执行了 2 次,且 B 留下了Pausing operations in favour of日志并停止。优雅地终止优先级较高的 A 后,B 在 0.2 秒内重新开始工作(未测量异常终止时的接管延迟,推测对等互联lifetime的上限为 60 秒)。
错误处理与重试间隔。 使调谐函数始终失败的 CR 放置 100 秒并记录调用时间(kopf 默认值)。
| 抛出的异常 | 100 秒内的调用次数 | 间隔 |
|---|---|---|
普通异常(RuntimeError) |
4 次 | 创建处理器失败并在 60 秒后重试,定时器也单独失败并在 60 秒后重试(调用时间 +0、+10、+60、+70 秒) |
kopf.TemporaryError(delay=5) |
20 次 | 每 5 秒 |
kopf.PermanentError |
4 次 | 创建处理器不重试(0 succeeded; 1 failed)。然而, 定时器持续运行 并每 30 秒产生相同的错误(+10、+40、+70 秒) |
- 普通异常的重试间隔 60 秒与 kopf 文档的默认值一致。「尚未准备就绪」应当用
TemporaryError来表示以缩短间隔。 PermanentError仅会停止该处理器。 如果同一个 CR 上附加了定时器,由于规格错误将每 30 秒被持续重新评估,因此应当将永久错误设计为保留在status条件中并在定时器中予以跳过。- 局限性。 之前在虚拟机中进行规模测试时在 20 个 CR 处停止,并非因为磁盘或并发执行,而是由于测试脚本。在同一 Shell 的后台启动 Operator 后,每 20 个调用一次无参数的
wait,从而一直在等待不会结束的 Operator。在其他环境中重新测量的结果写在下方的「规模测试」中。调用时间为日志中的时间,未与 Go Operator 进行直接比较。
规模测试(10–300 个 CR)。 在笔记本电脑(Apple M1 Max)的 Docker Desktop(虚拟机 CPU 10 个,内存 8GB)上创建单节点 kind 集群(Kubernetes v1.36.1),将上述 op.py 在不修改的情况下通过 kopf 1.44.6 作为集群外部进程运行。针对每个 CR,测量了从监视器首次看到 CR 到收到载有 status.phase=Ready 的第一个事件的时间作为延迟,每个条件剔除 1 次预热后重复 3 次取中位数。
| CR 数量 | 通过单个清单应用:总体处理 | 延迟中位数 / p95 / 最大值 | kubectl 20개씩 병렬: 전체 처리 | 延迟中位数 / p95 / 最大值 |
|---|---|---|---|---|
| 10 | 0.17秒 | 0.030 / 0.041 / 0.043秒 | 0.22秒 | 0.082 / 0.095 / 0.096秒 |
| 50 | 0.56秒 | 0.029 / 0.043 / 0.044秒 | 1.08秒 | 0.127 / 0.187 / 0.196秒 |
| 100 | 1.96秒 | 0.049 / 0.064 / 0.086秒 | 2.10秒 | 0.073 / 0.127 / 0.180秒 |
| 300 | 3.61秒 | 0.032 / 0.063 / 0.115秒 | 7.28秒(2次) | 0.102 / 0.188 / 0.254秒 |
- 在最多 300 个时,Operator 并不是瓶颈。 总体处理时间几乎全都是 kubectl 提交 CR 的时间(300 个清单提交耗时 3.57 秒,直到最后一个 Ready 耗时 3.61 秒),单个 CR 的延迟与数量无关,中位数在 0.03–0.13 秒之间,各运行的最大值也低于 0.36 秒。
- Operator 的 CPU 消耗与数量成正比,每个 CR 约为 7.5–8.6 毫秒。 处理区间的平均值为单核心的 31–64%,以 0.1 秒为单位的峰值为 55–85%。RSS 从等待状态下的约 100MiB 到 100 个 CR 时的 101–109MiB,在 300 个时为 111–115MiB。全部处理完毕后,仅运行 30 秒定时器的状态下占用率为 1.4–1.6%。
- kubectl 을 20개씩 동시에 띄우는 쪽이 오히려 느렸습니다. 提交 300 个时分别为 7.2 秒和 3.6 秒。启动 kubectl 进程的开销大于单个请求,当 20 个请求同时到达时,它们会在 Operator 内部排队等待,导致延迟中位数高出 2–4 倍。决定延迟的与其说是总数,不如说是请求到达的集中程度。
- 最初在虚拟机中停在 20 个的原因是测试脚本。 脚本在相同的 Shell 中通过
setsid nohup kopf run … &启动 Operator 后,每 20 个调用一次不带参数的wait,而无参数的wait会等待该 Shell 的所有子进程,即永不退出的 Operator。在 Linux 容器(bash 5.2, util-linux 2.41)和 kind 中重新运行相同的结构时,两者都同样卡住了(在 kind 中保持 20 个 CR 的状态长达 60 秒),而在将等待目标限定为 kubectl 的 PID 后,直到 300 个再也没有发生过阻塞。 - 限制。 在相同的 Docker 环境中由于其他集群一起运行,导致出现了 etcd 延迟警告和 API Server 重启,生成期间出现过任何警告的运行都被作废剔除(这就是并行 300 个进行 2 次的原因)。由于是单节点且将 Operator 作为 Pod 运行,因此未能测量 300 个时的正常状态 CPU 以及 1000 个以上的情况(未实际测量)。
LWS 直接测试
在一次性单节点 k3s(v1.36.5, 4 vCPU, 4GiB 虚拟机)上安装 LWS v0.11.1,并通过 busybox sleep Pod 验证了组的结构与恢复、滚动更新和扩缩容。由于没有使用 GPU 和模型,因此不包含模型加载时间,时间以 0.1 秒的间隔进行测量。
| 已确认内容 | 结果 |
|---|---|
| 安装 | 从应用清单到控制器就绪耗时 17 秒。CRD 与 LeaderWorkerSet 之外的 DisaggregatedSet, DisaggregatedSetRoleScaler 也一并安装 |
| 创建组 | replicas: 2, size: 3 的话,6 个 Pod 在 4.8 秒内全部进入 Running 状态。名称为leader demo-0、worker demo-0-1, demo-0-2、第二个组 demo-1, demo-1-1, demo-1-2 |
| 创建的子资源 | 一个 leader StatefulSet(demo,按组的数量), 每个组一个 worker StatefulSet(demo-0, demo-1),所有组共享的一个 Headless Service(demo)。Pod 的所有者为 leader StatefulSet/demo,worker 为 StatefulSet/demo-0 |
| Pod 标签 | leaderworkerset.sigs.k8s.io/ 下的 name, group-index, worker-index, group-key(每个组都有唯一的哈希值), template-revision-hash |
| 注入的环境变量 | LWS_LEADER_ADDRESS(例如: demo-1.demo.default,worker 也是 其所在组 leader的地址), LWS_GROUP_SIZE=3, LWS_WORKER_INDEX(leader 为 0,worker 从 1 开始) |
| 默认值 | rolloutStrategy: RollingUpdate, maxSurge: 0, maxUnavailable: 1, partition: 0(与第 6 节的描述一致)。 startupPolicy: LeaderCreated, networkConfig.subdomainPolicy: Shared, restartPolicy: RecreateGroupOnPodRestart |
-
只要有一个 worker 死亡,整个组就会重新创建(默认策略)。强行删除组 0 的一个 worker 后,大约 2.0 秒后同组的 3 个 Pod(1 个 leader 和 2 个 worker)都获得了新的 UID ,而组 1 的 3 个 Pod 的 UID 保持不变。这种以组为单位重启的行为,反映了多节点推理中只要少了一个 rank,其余部分就失去作用的架构特性。
-
restartPolicy: None的话,只会重新生成被删除的 Pod(约 1.9 秒)。在同样的测试中,只有那个被删除的 worker 拥有新 UID。自动复活整个组究竟是划算还是负担,取决于模型加载时间,而由于本次测试没有模型加载环节,因此无法展现这部分成本(未实际测量)。 -
滚动更新按照序数从大到小的组依次进行。 修改模板值后,在第 1 秒时
demo-1组以新修订版本进入 Pending,第 3 秒时demo-1为 Running 且demo-0正在更换为新修订版本,第 4 秒时全部以新修订版本进入 Running(直到观测循环结束共耗时 8 秒)。由于没有模型加载,单个组在几秒钟内就完成了,但在实际服务中,由于必须等待前一个组准备就绪才会处理下一个组,因此耗时会大幅延长(推测)。 -
Scale 子资源可以正常工作。 将 replicas 提高到 3 时,9 个 Pod 在 2.5 秒内变为 Running 状态;如果降到 1,则
demo-0,demo-0-1,demo-0-2留下(优先移除编号较大的组)。 -
LWS 不执行组调度(gang scheduling)。 在 CPU 剩余容量为 0.8 的节点上创建两个大小为 4 的组(共 8 个 Pod,每个 Pod 请求 1 个 CPU)时,只有 1 个 leader 处于 Running 状态,其余 7 个则处于
Insufficient cpu状态并显示为 Pending。即使将组数减少到 1,也只有 leader 处于 Running 状态,3 个 worker 则一直处于 Pending 状态。这呈现出 leader 占有资源并等待 worker 的形态,组调度工具(Kueue、Volcano)存在的必要性也正源于此。在第 10 部分的测试中,这两个工具都只接收一个任务,从而避免了这种死锁。 -
partition进行金丝雀发布。 在 4 个组(size: 2)中,将模板值更改为partition: 2后,只有序号大于或等于 2 的组(can-2,can-3)在 5 秒内更新为新修订版本,而序号 0 和 1 则保持旧修订版本(哈希值 7596)。接着将其降为partition: 0后,其余两个组也在 4 秒内更新为新修订版本。这与第 6 节的描述(从序号较大的组开始,仅更新大于或等于 partition 的组)相符。 -
maxSurge: 1时,组的数量会临时增加一个,然后减少。 在 4 个组中更改模板时,can-4(2 个 Pod)被额外创建,Pod 总数从 8 个增加到最多 10 个,替换完成后又恢复为 8 个(整个过程约 9 秒)。maxUnavailable: 0时也是这种形态,并且在替换过程中,Running 组的数量每次都保持在 4 个或以上。通过 1 到 2 秒间隔的观察,未能区分出maxUnavailable为 1 与为 0 时的差异。额外组的出现意味着在实际的 GPU 环境中,必须有相当于一个组的空闲 GPU(此处为sleepPod,因此没有成本)。 -
如果两者都为 0,准入 Webhook 将会拒绝。
maxSurge: 0从maxUnavailable: 0尝试更改为spec.rolloutStrategy.rollingUpdateConfiguration.maxUnavailable: Invalid value: 0: must not be 0 when maxSurge is 0时,因
错误而被阻止。 sleep局限性:单台虚拟机,Pod 执行的任务是 busybox,镜像是已经拉取好的
,且没有就绪状态检查。未测试由于节点故障导致的组恢复和 DisaggregatedSet(未实际测量)。验证的版本和日期 (2026-10-05)
LWS v0.11.1(2026-10-01), KServe v0.21.0(2026-09-25, 文档为 0.20 页), Dynamo v1.5.0(2026-09-21), controller-runtime v0.25.2(2026-10-01), kubebuilder v4.16.0(2026-09-10), kopf 1.44.6(2026-06-03), Argo CD v3.5.3(2026-09-14), LMCache v0.5.5, Kubernetes v1.37.1(2026-09-23)。
未确认的事项。 StorageVersionMigration 的默认激活状态(Kubernetes 文档中的描述相互矛盾)、kopf 在 1000 个以上 CR 及多节点环境下的性能极限(多达 300 个已在上述规模测试中测量)、控制器单元测试工具(envtest)的细节、ValidatingAdmissionPolicy 与 Webhook 的优劣、KServe 最新文档与 0.21.0 之间的差异、Dynamo 的 DGD 部署模式下的准确子资源创建路径(通过 CRD 说明和文档绘制了图表,未追踪代码路径)。第 9 节的 kopf 骨架是我根据文档编写的,未经实际集群运行验证。