TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

使用 Kubernetes CRD 与 Operator 自动化部署 LLM 推理服务

状态:草案(2026-10-07 更新,2026-10-09 补充规模测试实测数据)。 本文以官方文档的描述为基础,其中**第 8 节的隔离环境测试以及文末的“kopf Operator 直接测试”(包含规模测试)和“LWS 直接测试”**是亲自验证过的数据。其余部分暂未实测。 请先阅读文末的“确认过的版本和日期”以及“未确认的事项”。 编写规则:以实测数据为基础,未能测量的内容标注为“未实测”。

目录

LLM 推理服务部署有许多 Deployment 无法表达的需求:跨多个节点的 Leader 与 Worker、权重加载这一前置步骤、必须遵守比例的滚动更新、GPU 拓扑结构,以及以 Pod 组而不是单个 Pod 为单位的扩展。Kubernetes 通过 CRD(Custom Resource Definition)和 Operator 扩展了这些能力。本文整理了这些 API 的当前形态与 Operator 的核心模式,并结合在隔离实验环境中验证的行为作为依据。首先说明一点:我本人没有生产环境规模的 Go Operator 运维经验。

读完本文后,你将在面试中能够解释:

  1. 为什么难以用 Deployment 表达 LLM 推理服务,以及 LeaderWorkerSet (LWS)、KServe 和 Dynamo 分别增加了什么样的抽象。
  2. 调谐(reconcile)循环的幂等性、基于级别的触发、所有者引用与 Finalizer、状态条件(Status Conditions)、退避(Backoff)以及 Leader 选举为什么会这样设计。
  3. CRD 版本转换流程、滚动更新策略,以及解决 Argo CD 与 Operator 争夺同一字段问题的方法。

1. 为什么单一 Deployment 不够用

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 设计原则

6. 滚动更新策略

7. 与 GitOps (Argo CD) 的冲突

当 Operator 或 HPA 修改 Git 中记录的字段时,就会产生冲突。 replicas 如果写在 Git 中,就会与 HPA 发生冲突(HPA 扩容后 Argo 会将其恢复,然后 HPA 再次扩容)。解决方法如下。

经常遇到的陷阱是健康检查。在应用的健康由下级资源的状态决定且同步等待该健康状态的结构中,如果内置检查将某个资源(例如:失败过一次的 CronJob)判定为 Degraded,同步可能会一直等待健康状态而失败,从而导致新的更改无法应用。可以通过使用 Lua 重新定义检查规则来解决此问题(未确认此行为出现在哪个版本)。在 ApplicationSet 中也会发生两个控制器的冲突,即关闭了子应用程序的自动化,但父应用程序模板又将其重新打开,解决方法是通过名称和路径将范围缩小到单个字段来设置例外。

8. 在隔离环境中验证的行为

以下是通过隔离的实验环境(kwok 集群或个人 VM)中的实际 Kubernetes 控制器确认的内容,并非我的生产环境故障记录。

局限性。 以上是实验验证的 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. 面试中可能出现的问题

  1. LLM 服务中不使用 Deployment 而使用 LWS 的原因是什么? 因为副本是 leader 和 worker 的集合,所以必须以组为单位进行扩缩容、滚动更新、故障恢复和拓扑部署。如果一个 Pod 失败,集合通信就会中断,因此默认策略是重新创建整个组。
  2. 调谐循环即使漏掉事件也没关系的原因是什么? 因为请求中没有事件内容,每次都是读取当前状态来弥补差异。即使控制器死后复活,它也会从头开始重新运行,只完成尚未结束的部分,因此所有的写操作必须是幂等的。
  3. ownerReference 和 finalizer 的区别是什么? 前者是子对象指向父对象、交由垃圾回收器处理的机制,若 uid 错误则子对象会被删除。后者是为了清理外部资源而延迟删除的机制,若在没有控制器的情况下剥离它,则会跳过清理过程。
  4. 将 CRD 从 v1alpha1 升级到 v1beta1 的步骤是什么? 添加新版本作为 served,根据需要附加转换 Webhook,迁移 storage 并重写所有现有对象后, storedVersions 清理该项并关闭旧版本。策略 None 仅在字段结构相同时才安全。
  5. 如果 Argo CD 还原了操作员更改的字段会怎样? 从 Git 中移除该字段,如果较难做到,则在 ignoreDifferences 中将其排除在比较之外,在 RespectIgnoreDifferences 中将其排除在同步之外。健康状态通过针对 CR 的 Lua 检查来 observedGeneration 反映。

11. 常见误解

外部依据

主张 资料 确认的内容
调谐由当前状态驱动,而非事件 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 书籍。

参考资料(实际打开过的页面)

亲自测试 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 秒后消失

错误处理与重试间隔。 使调谐函数始终失败的 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 秒)

规模测试(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秒

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

错误而被阻止。 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 骨架是我根据文档编写的,未经实际集群运行验证。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论