前面几篇主要是在铺垫 Kubebuilder、CRD、Controller、Reconciler 和 Reconcile 控制循环。这一篇开始进入真正的 Operator 编排逻辑用户创建一个VLLMService自定义资源后Operator 自动在 Kubernetes 集群里创建或更新一个对应的Deployment然后由这个 Deployment 启动 vLLM 推理服务。也就是说从这一篇开始VLLMService不再只是一个 CRD 里的数据结构而是开始真正驱动 Kubernetes 创建工作负载。github项目地址https://github.com/bolin-dai/vllmservice-operator一、这一篇要解决什么问题这一篇要解决的问题很明确用户创建一个VLLMServiceCROperator 根据这个 CR 自动创建或更新一个同名 Deployment让这个 Deployment 去启动 vLLM 容器。整体链路可以先这样理解VLLMService CR ↓ Reconcile 控制循环 ↓ Deployment ↓ Pod ↓ vLLM 容器在 Kubernetes 里用户通过 YAML 声明“期望状态”。比如创建一个VLLMService本质上是在告诉集群我希望运行一个 vLLM 模型服务。Controller 的职责就是不断观察当前集群状态并把实际状态调整到用户声明的期望状态。对于当前这个 Operator 来说用户声明的是VLLMService.SpecController 要创建的是 Deployment。Deployment 里需要包含镜像、端口、启动参数、资源限制、PVC 模型挂载、调度器、RuntimeClass、nodeSelector 等配置。假设用户创建的 CR 大致如下apiVersion: aiinfra.example.com/v1alpha1 kind: VLLMService metadata: name: qwen-demo namespace: ai-demo spec: image: docker.m.daocloud.io/vllm/vllm-openai:latest modelPath: /data/models/Qwen2.5-1.5B-Instruct modelName: qwen2.5-1.5b-instruct replicas: 1 port: 8000 runtimeClassName: nvidia schedulerName: volcano nodeSelector: kubernetes.io/hostname: master-01 resources: requests: cpu: 2 memory: 8Gi volcano.sh/vgpu-number: 1 limits: cpu: 4 memory: 16Gi volcano.sh/vgpu-number: 1 storage: pvcName: qwen-model-pvc mountPath: /data/models readOnly: true那么当前 Controller 要做的事就是读取这个VLLMService然后生成一个名字同样叫qwen-demo的 Deployment并把这些字段转换成 PodTemplate 里的配置。二、先看当前 controller.go 的整体职责当前internal/controller/vllmservice_controller.go主要做了几件事定义VLLMServiceReconciler声明 RBAC 权限在Reconcile()里读取VLLMService使用CreateOrUpdate()创建或更新 Deployment根据VLLMService.Spec构造 Deployment构造 PodTemplate构造 vLLM 容器挂载模型 PVC设置 OwnerReference根据 Deployment 状态回写VLLMService.Status。如果用一句话概括当前 Controller 的职责就是监听VLLMService读取用户在spec里声明的期望状态然后创建或更新同名 Deployment并把 Deployment 当前运行状态写回VLLMService.Status。当前代码的主流程可以概括为收到 Reconcile 请求 ↓ 读取 VLLMService ↓ 根据 VLLMService 构造 Deployment ↓ CreateOrUpdate 同步 Deployment ↓ 根据 Deployment 状态更新 VLLMService.Status ↓ 本次 Reconcile 结束这里要注意当前第四篇只讲VLLMService - Deployment这一步。当前代码还没有自动创建 Service、HTTPRoute、ServiceMonitor、PrometheusRule这些可以放到后面的文章继续扩展。三、VLLMServiceReconciler为什么需要 Client 和 Scheme当前代码里定义了这个结构体type VLLMServiceReconciler struct { client.Client Scheme *runtime.Scheme }client.Client是 controller-runtime 提供的 Kubernetes API 客户端。后面代码里用到的r.Get()、r.Status().Update()以及controllerutil.CreateOrUpdate()内部执行的创建和更新动作本质上都依赖这个 Client 和 Kubernetes API Server 交互。简单说Controller 想读VLLMService、创建 Deployment、更新 status就必须通过这个 Client 完成。Scheme用来让 controller-runtime 识别 Go 结构体和 Kubernetes API 资源之间的对应关系。比如aiinfrav1alpha1.VLLMService{}对应的是aiinfra.example.com/v1alpha1这个 API Group 下的VLLMService资源appsv1.Deployment{}对应的是 Kubernetes 内置的 Deployment 资源。后面调用controllerutil.SetControllerReference(vllmService, deployment, r.Scheme)时就需要通过Scheme识别 owner 和 dependent 的资源类型关系。可以简单理解成Client负责操作 Kubernetes 资源比如 Get、Create、Update、Status().Update。 Scheme负责识别 Kubernetes 资源类型比如 VLLMService 和 Deployment 分别是什么资源。所以VLLMServiceReconciler里放client.Client和Scheme是很正常的这是 Kubebuilder / controller-runtime 写 Controller 时非常常见的结构。四、RBAC MarkerController 需要哪些权限Controller 运行在 Kubernetes 集群里时不是天然就能操作所有资源。它通常会绑定一个 ServiceAccount然后通过 RBAC 控制它能操作哪些资源。Kubebuilder 里常见做法是在 controller 代码上方写 RBAC marker然后执行make manifests生成对应的 RBAC YAML。当前代码里的 RBAC marker 是// kubebuilder:rbac:groupsaiinfra.example.com,resourcesvllmservices,verbsget;list;watch;create;update;patch;delete // kubebuilder:rbac:groupsaiinfra.example.com,resourcesvllmservices/status,verbsget;update;patch // kubebuilder:rbac:groupsaiinfra.example.com,resourcesvllmservices/finalizers,verbsupdate // kubebuilder:rbac:groupsapps,resourcesdeployments,verbsget;list;watch;create;update;patch;delete第一行表示 Controller 拥有操作主资源VLLMService的权限。这里的get/list/watch用于读取和监听VLLMServiceupdate/patch用于更新对象。虽然当前代码里没有主动创建或删除VLLMService但是脚手架里经常会保留比较完整的权限声明。第二行表示 Controller 可以更新VLLMService的/status子资源。这个很重要因为当前代码里有return r.Status().Update(ctx, vllmservice)在 Kubernetes 里status通常是一个子资源不建议和spec混在普通 update 里更新。spec表示用户期望状态status表示系统观察到的实际状态所以更新VLLMService.Status时需要有vllmservices/status的权限。第三行表示允许更新VLLMService的 finalizers 子资源。不过这里要注意当前代码虽然声明了vllmservices/finalizers权限但还没有真正实现 finalizer 逻辑。当前代码里没有controllerutil.AddFinalizer()、没有controllerutil.RemoveFinalizer()也没有判断DeletionTimestamp。所以这行 RBAC 现在更像是预留能力不代表当前已经实现删除前自定义清理。第四行表示 Controller 可以管理 Deployment。因为当前 Operator 的核心动作就是根据VLLMService创建或更新 Deployment所以必须具备 Deployment 的get/list/watch/create/update/patch/delete权限。执行下面命令后Kubebuilder 会根据这些 marker 生成对应的 RBAC YAMLmake manifests五、Reconcile 第一步读取 VLLMService当前Reconcile()开头是这样写的vllmService : aiinfrav1alpha1.VLLMService{} if err : r.Get(ctx, req.NamespacedName, vllmService); err ! nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err }这里非常关键。req里只有本次触发 Reconcile 的对象标识也就是namespace/name它不包含完整的VLLMService对象内容。比如本次请求只是告诉 Controllerai-demo/qwen-demo这个对象需要处理一下。至于这个对象的spec.image、spec.modelPath、spec.resources具体是什么还是要通过r.Get()去读取。所以这行代码r.Get(ctx, req.NamespacedName, vllmService)作用就是根据namespace/name读取完整的VLLMService对象并把读取到的内容填充到vllmService这个变量里。只有读到了完整对象后面才能根据vllmService.Spec去构造 Deployment。如果r.Get()返回IsNotFound通常说明这个VLLMService已经不存在了最常见的情况就是用户已经执行了删除。这个时候 Controller 不应该继续往下创建或更新 Deployment而是直接返回return ctrl.Result{}, nil这里要特别注意这段代码不是在删除VLLMService。真正删除VLLMService的动作是用户执行kubectl delete vllmservice后由 Kubernetes API Server 处理的。Controller 只是监听到了事件再次 Get 时发现对象已经不存在于是正常结束。如果不是IsNotFound而是网络异常、权限异常、API Server 临时错误等其他错误就返回 errorreturn ctrl.Result{}, err返回 error 后controller-runtime 后续会重新入队重试。这样做符合 Reconcile 的基本写法主资源不存在就正常结束其他错误就返回给控制循环处理。六、核心逻辑使用 CreateOrUpdate 同步 Deployment读取到VLLMService后代码开始准备同名 Deploymentdeployment : appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: vllmService.Name, Namespace: vllmService.Namespace, }, }这里 Deployment 的名字和命名空间都直接跟VLLMService保持一致。例如VLLMService是ai-demo/qwen-demo那么生成的 Deployment 也是ai-demo/qwen-demo。这样做的好处是资源关系非常直观排查时一眼就能看出来哪个 Deployment 是哪个VLLMService生成的。接下来代码使用的是controllerutil.CreateOrUpdate()operation, err : controllerutil.CreateOrUpdate(ctx, r.Client, deployment, func() error { selectorLabels : selectorLabelsForVLLMService(vllmService.Name) objectLabels : labelsForVLLMService(vllmService) deployment.Labels objectLabels if deployment.Spec.Selector nil { deployment.Spec.Selector metav1.LabelSelector{ MatchLabels: selectorLabels, } } deployment.Spec.Replicas replicasFor(vllmService) deployment.Spec.Template buildPodTemplate(vllmService) deployment.Spec.RevisionHistoryLimit int32Ptr(10) deployment.Spec.ProgressDeadlineSeconds int32Ptr(600) return controllerutil.SetControllerReference(vllmService, deployment, r.Scheme) })这段代码没有手写“先 Get Deployment不存在就 Create存在就 Update”的逻辑而是交给CreateOrUpdate()处理。它的行为可以这样理解如果 Deployment 不存在就执行 mutate 函数然后创建 Deployment。 如果 Deployment 已经存在就执行 mutate 函数把 Deployment 调整成期望状态。 如果调整后对象发生变化就更新 Deployment。 如果对象已经符合期望状态就保持不变。operation可以用来判断本次操作结果常见值包括created创建了新资源。 updated更新了已有资源。 unchanged资源已经符合期望没有变化。所以这段日志是有意义的logger.Info( Deployment同步完成, operation, operation, namespace, deployment.Namespace, name, deployment.Name, )排查 Operator 时可以通过日志判断本次 Reconcile 到底是创建了 Deployment、更新了 Deployment还是发现 Deployment 已经符合期望状态。这里还有一个细节CreateOrUpdate()的 mutate 函数里只负责修改 Deployment 的期望状态不负责更新VLLMService.Status。status 更新放在后面的r.Status().Update()里单独处理这是更清晰的写法。七、Deployment 的 selector 为什么只能创建时设置当前代码里有这段selectorLabels : selectorLabelsForVLLMService(vllmService.Name) if deployment.Spec.Selector nil { deployment.Spec.Selector metav1.LabelSelector{ MatchLabels: selectorLabels, } }这里不能每次 Reconcile 都强行覆盖 selector。原因是 Deployment 的.spec.selector是不可变字段Deployment 创建之后不能随便修改 selector。如果强行修改Kubernetes API Server 会拒绝更新。所以当前代码先判断if deployment.Spec.Selector nil只有在 Deployment 第一次创建、selector 为空时才设置 selector。正常情况下一个已经存在的 apps/v1 Deployment 必须有 selector所以后续 Reconcile 再执行时不会反复覆盖 selector。这也是 Operator 开发里一个非常重要的经验Deployment selector 应该使用稳定标签不要把用户可能频繁修改的业务 label 放进 selector 里。否则用户一改 labelOperator 如果试图同步 selector就可能触发不可变字段更新失败。当前代码里的 selector 来自func selectorLabelsForVLLMService(name string) map[string]string { return map[string]string{ app.kubernetes.io/name: vllmservice, app.kubernetes.io/instance: name, } }这两个标签比较稳定app.kubernetes.io/name固定表示这是 vllmserviceapp.kubernetes.io/instance使用当前VLLMService的名字。只要VLLMService名字不变这组 selector 就不会变。八、selectorLabels 和 objectLabels 为什么要分开代码里有两个生成 label 的函数func selectorLabelsForVLLMService(name string) map[string]string和func labelsForVLLMService(vllmService *aiinfrav1alpha1.VLLMService) map[string]string它们看起来都是生成 label但用途不同。selectorLabelsForVLLMService()生成的是 Deployment selector 用的稳定标签return map[string]string{ app.kubernetes.io/name: vllmservice, app.kubernetes.io/instance: name, }这类 label 不应该频繁变化因为 Deployment selector 创建后不可变。labelsForVLLMService()生成的是 Deployment 和 Pod 上的普通对象标签func labelsForVLLMService(vllmService *aiinfrav1alpha1.VLLMService) map[string]string { labels : make(map[string]string) for key, value : range vllmService.Spec.Labels { labels[key] value } labels[app.kubernetes.io/name] vllmservice labels[app.kubernetes.io/instance] vllmService.Name labels[app.kubernetes.io/managed-by] vllmservice-operator return labels }这个函数会先合并用户在spec.labels里传入的业务标签然后再补充 Operator 自己管理用的标签。比如用户可以在 CR 里写labels: aiinfra.example.com/model: qwen2.5 aiinfra.example.com/runtime: vllm aiinfra.example.com/team: infra这些标签最终会进入 Deployment 和 Pod方便后续筛选、观测和排查。所以这两个函数分开的好处是selectorLabels稳定用于 Deployment selector。 objectLabels灵活用于资源标识、筛选和业务分类。这样既能保证 Deployment selector 稳定又能让用户通过spec.labels给 Deployment 和 Pod 增加自己的业务标签。九、buildPodTemplate从 VLLMService 生成 Pod 模板Deployment 真正运行什么 Pod主要由deployment.Spec.Template决定。当前代码通过buildPodTemplate()生成 PodTemplatefunc buildPodTemplate(vllmService *aiinfrav1alpha1.VLLMService) corev1.PodTemplateSpec { objectLabels : labelsForVLLMService(vllmService) container : buildVLLMContainer(vllmService) volumes, volumeMounts : buildModelVolumesAndMounts(vllmService) container.VolumeMounts volumeMounts schedulerName : corev1.DefaultSchedulerName if vllmService.Spec.SchedulerName ! { schedulerName vllmService.Spec.SchedulerName } podSpec : corev1.PodSpec{ Containers: []corev1.Container{container}, Volumes: volumes, RestartPolicy: corev1.RestartPolicyAlways, DNSPolicy: corev1.DNSClusterFirst, SchedulerName: schedulerName, TerminationGracePeriodSeconds: int64Ptr(30), EnableServiceLinks: boolPtr(true), HostIPC: true, } if vllmService.Spec.RuntimeClassName ! { podSpec.RuntimeClassName vllmService.Spec.RuntimeClassName } if len(vllmService.Spec.NodeSelector) 0 { podSpec.NodeSelector vllmService.Spec.NodeSelector } return corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: objectLabels, }, Spec: podSpec, } }这个函数主要做几件事生成 Pod labels生成 vLLM 容器生成模型 PVC volume 和 volumeMount把 volumeMounts 挂到容器上设置schedulerName、runtimeClassName、nodeSelector最后返回完整的corev1.PodTemplateSpec。SchedulerName的逻辑是如果用户没有配置spec.schedulerName就使用 Kubernetes 默认调度器如果用户配置了schedulerName: volcano生成出来的 Pod 就会交给 Volcano 调度器处理。这个设计对 AI 推理场景很重要因为后续如果要结合 Volcano、Kueue 或其他调度器做 GPU/vGPU 调度schedulerName就是入口。RuntimeClassName的逻辑是如果用户配置了runtimeClassName就写入 PodSpec。比如在 NVIDIA GPU Operator 场景下常见配置是runtimeClassName: nvidia这里要注意runtimeClassName: nvidia不是“申请 GPU 资源”。它表示这个 Pod 使用名为nvidia的 RuntimeClass也就是让 kubelet 使用对应的容器运行时配置来运行这个 Pod。真正的 GPU 或 vGPU 资源申请还是要通过容器的resources.requests和resources.limits表达。NodeSelector的逻辑是如果用户配置了spec.nodeSelector就写入 PodSpec。比如在单节点 GPU 测试环境里可以写nodeSelector: kubernetes.io/hostname: master-01这样 Pod 只会被调度到带有这个标签的节点上。相比直接写死nodeNamenodeSelector仍然会经过调度器更适合 Operator 编排场景。nodeName会绕过调度器通常只适合自定义调度器或非常特殊的高级场景。这里还有一个字段需要单独说明HostIPC: true这表示 Pod 使用宿主机的 IPC 命名空间。这个字段会带来更强的宿主机共享能力也意味着隔离性降低。当前代码里已经写了它但生产环境是否保留要结合实际需求评估如果 vLLM 或底层运行时不需要共享宿主机 IPC后续可以考虑去掉减少不必要的权限面。十、buildVLLMContainer如何生成 vLLM 容器PodTemplate 里最核心的是容器。当前代码通过buildVLLMContainer()生成 vLLM 容器func buildVLLMContainer(vllmservice *aiinfrav1alpha1.VLLMService) corev1.Container { port : portFor(vllmservice) return corev1.Container{ Name: vllm, Image: vllmservice.Spec.Image, ImagePullPolicy: corev1.PullIfNotPresent, Args: []string{ --model, vllmservice.Spec.ModelPath, --served-model-name, vllmservice.Spec.ModelName, --host, 0.0.0.0, --port, fmt.Sprintf(%d, port), --dtype, auto, --max-model-len, 4096, --gpu-memory-utilization, 0.75, --max-num-seqs, 8, }, Ports: []corev1.ContainerPort{ { Name: http, ContainerPort: port, Protocol: corev1.ProtocolTCP, }, }, Resources: vllmservice.Spec.Resources, } }这里容器名固定为vllm镜像来自spec.image镜像拉取策略是PullIfNotPresent。容器端口通过portFor()获取func portFor(vllmservice *aiinfrav1alpha1.VLLMService) int32 { if vllmservice.Spec.Port 0 { return 8000 } return vllmservice.Spec.Port }也就是说如果用户没有配置spec.port代码默认使用 8000如果用户配置了其他端口就使用用户指定的端口。当前 API 类型里Port还配置了默认值 8000所以正常通过 CRD 创建对象时API Server 也会帮忙默认成 8000。代码里再兜底一次是为了增强健壮性。vLLM 启动参数里比较重要的是--model模型路径当前来自 spec.modelPath。 --served-model-name对外暴露的模型名称当前来自 spec.modelName。 --host 0.0.0.0监听容器内所有网卡方便后续 Service 转发流量。 --portvLLM 服务监听端口。 --dtype auto让 vLLM 自动选择数据类型。 --max-model-len 4096限制最大上下文长度。 --gpu-memory-utilization 0.75限制当前 vLLM 实例使用的 GPU 显存比例。 --max-num-seqs 8限制单次迭代可处理的最大序列数量。这些参数比较适合小显存测试环境尤其是--max-model-len 4096、--gpu-memory-utilization 0.75、--max-num-seqs 8可以降低显存压力。后续如果要做成更通用的生产级 Operator建议把这些 vLLM 参数抽到VLLMServiceSpec里让用户在 CR 中自定义而不是写死在 controller.go 里。资源配置来自Resources: vllmservice.Spec.Resources所以用户在 CR 里写的 CPU、内存、GPU 或 vGPU 资源请求和限制最终都会进入容器的resources字段。例如resources: requests: cpu: 2 memory: 8Gi volcano.sh/vgpu-number: 1 limits: cpu: 4 memory: 16Gi volcano.sh/vgpu-number: 1这里要分清楚runtimeClassName决定使用哪种运行时配置resources才是表达资源申请的位置。对于 GPU 或 vGPU 场景最终能不能调度成功还要看节点资源、设备插件、调度器和运行时环境是否正常。十一、buildModelVolumesAndMounts如何挂载模型 PVCvLLM 启动时需要读取模型文件。当前代码支持通过 PVC 把模型目录挂进容器相关函数是func buildModelVolumesAndMounts(vllmservice *aiinfrav1alpha1.VLLMService) ([]corev1.Volume, []corev1.VolumeMount) { storage : vllmservice.Spec.Storage if storage.PVCName { return nil, nil } mountPath : storage.MountPath if mountPath { mountPath /data/models } readOnly : readOnlyFor(storage) volumeName : model-storage volumes : []corev1.Volume{ { Name: volumeName, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: corev1.PersistentVolumeClaimVolumeSource{ ClaimName: storage.PVCName, ReadOnly: readOnly, }, }, }, } volumeMount : corev1.VolumeMount{ Name: volumeName, MountPath: mountPath, ReadOnly: readOnly, } if storage.SubPath ! { volumeMount.SubPath storage.SubPath } volumeMounts : []corev1.VolumeMount{volumeMount} return volumes, volumeMounts }逻辑很清晰先读取spec.storage如果storage.pvcName为空就不挂载模型存储如果配置了 PVC就生成一个名为model-storage的 volume同时生成对应的 volumeMount。例如 CR 里写的是storage: pvcName: qwen-model-pvc mountPath: /data/models readOnly: true最终 Deployment 里的 Pod 大致会生成volumes: - name: model-storage persistentVolumeClaim: claimName: qwen-model-pvc readOnly: true容器里会生成volumeMounts: - name: model-storage mountPath: /data/models readOnly: true当前代码里还有几个兜底逻辑。第一个是pvcName为空时不挂载模型存储第二个是mountPath为空时默认使用/data/models第三个是readOnly没有配置时默认只读func readOnlyFor(storage aiinfrav1alpha1.VLLMServiceStorageSpec) bool { if storage.ReadOnly nil { return true } return *storage.ReadOnly }不过这里要注意一个细节当前vllmservice_types.go里已经把storage.pvcName和storage.mountPath标成了 Required并且加了MinLength1。所以正常通过 API Server 创建 CR 时如果不填pvcName或mountPath应该会被 CRD 校验拦住。controller.go 里继续保留空值判断和默认值是一种防御式写法可以避免单元测试、老版本对象或异常对象导致代码直接崩掉。ReadOnly使用的是*bool而不是普通的bool这点也很重要。普通bool的零值是false无法区分“用户没填”和“用户明确填了 false”。使用*bool后nil表示用户没填可以走默认只读如果用户明确写了readOnly: false代码也能识别出来。对于模型目录来说默认只读挂载是比较合理的。推理容器通常只需要读取模型文件不应该随意修改模型目录。十二、OwnerReference删除 VLLMService 时 Deployment 为什么会自动删除当前代码里有一行非常关键return controllerutil.SetControllerReference(vllmService, deployment, r.Scheme)这行代码会给 Deployment 设置 OwnerReference。简单理解就是告诉 Kubernetes这个 Deployment 是由这个VLLMService管理的。Deployment 的 metadata 里会出现类似这样的内容metadata: ownerReferences: - apiVersion: aiinfra.example.com/v1alpha1 kind: VLLMService name: qwen-demo uid: xxxxx controller: true这表示VLLMService 是 owner也就是主资源。 Deployment 是 dependent也就是子资源。当用户执行kubectl -n ai-demo delete vllmservice qwen-demo删除流程要分两部分理解。第一部分是删除VLLMService自己kubectl向 kube-apiserver 发送 DELETE 请求kube-apiserver 负责校验权限、检查对象是否存在、处理 finalizer 和删除策略。如果这个VLLMService没有 finalizer 阻塞删除kube-apiserver 会删除这个对象。当前 controller.go 里没有主动删除VLLMService的代码也不应该由 Reconciler 主动删除主资源。第二部分是删除 Deployment由于 Deployment 已经通过SetControllerReference()设置了 OwnerReferenceKubernetes garbage collector 会发现这个 Deployment 的 owner 已经不存在于是自动清理这个 Deployment。Deployment 被删除后它管理的 ReplicaSet 和 Pod 也会继续被清理。完整链路可以这样理解用户执行 kubectl delete vllmservice qwen-demo ↓ kubectl 向 kube-apiserver 发送 DELETE 请求 ↓ kube-apiserver 删除 VLLMService 对象 ↓ Controller 收到事件后再次 Reconcile ↓ r.Get() 查询 VLLMService发现对象已经不存在 ↓ 返回 IsNotFound本次 Reconcile 正常结束 ↓ garbage collector 根据 Deployment.ownerReferences 清理 Deployment ↓ Deployment 关联的 ReplicaSet / Pod 继续被清理所以当前代码没有写r.Delete(ctx, deployment)也没有写r.Delete(ctx, vllmService)VLLMService的删除由 kube-apiserver 处理Deployment 的删除由 OwnerReference 和 Kubernetes garbage collector 处理。当前 Controller 参与删除链路的地方主要有两处第一创建或更新 Deployment 时提前设置 OwnerReference第二主资源删除后再次 Reconcile 时通过apierrors.IsNotFound(err)判断对象已经不存在然后直接结束。还有一个容易混淆的点RBAC 里虽然声明了vllmservices/finalizers权限但当前代码没有实现 finalizer。没有 finalizer 的情况下删除VLLMService时不会进入“删除前自定义清理”逻辑。如果以后需要删除外部资源比如云厂商负载均衡、外部 DNS、对象存储目录、外部数据库实例就需要再实现 finalizer。十三、updateVLLMServiceStatus把 Deployment 状态回写到 CROperator 不应该只创建资源还应该把当前运行状态反馈给用户。当前代码里有if err : r.updateVLLMServiceStatus(ctx, vllmService, deployment); err ! nil { logger.Error(err, 更新VLLMService status失败) return ctrl.Result{}, err }updateVLLMServiceStatus()的代码是func (r *VLLMServiceReconciler) updateVLLMServiceStatus( ctx context.Context, vllmservice *aiinfrav1alpha1.VLLMService, deployment *appsv1.Deployment, ) error { phase, message : phaseAndMessageFromDeployment(deployment) if vllmservice.Status.Phase phase vllmservice.Status.ReadyReplicas deployment.Status.ReadyReplicas vllmservice.Status.DeploymentName deployment.Name vllmservice.Status.Message message { return nil } vllmservice.Status.Phase phase vllmservice.Status.ReadyReplicas deployment.Status.ReadyReplicas vllmservice.Status.DeploymentName deployment.Name vllmservice.Status.Message message return r.Status().Update(ctx, vllmservice) }这个函数会根据 Deployment 当前状态更新VLLMService.Status主要字段包括vllmservice.Status.Phase vllmservice.Status.ReadyReplicas vllmservice.Status.DeploymentName vllmservice.Status.Message代码在更新 status 之前会先判断 status 是否真的发生变化。如果Phase、ReadyReplicas、DeploymentName、Message都没有变化就直接返回 nil不再调用Status().Update()。这样可以减少无意义的 API 请求也能避免因为 status 更新触发更多不必要的 Reconcile。状态判断逻辑在func phaseAndMessageFromDeployment(deployment *appsv1.Deployment) (string, string) { desiredReplicas : int32(1) if deployment.Spec.Replicas ! nil { desiredReplicas *deployment.Spec.Replicas } for _, condition : range deployment.Status.Conditions { if condition.Type appsv1.DeploymentReplicaFailure condition.Status corev1.ConditionTrue { return failed, fmt.Sprintf( Deployment %s 副本创建失败 %s, deployment.Name, condition.Message, ) } } if desiredReplicas 0 deployment.Status.ReadyReplicas desiredReplicas { return Running, fmt.Sprintf( Deployment %s 已就绪 readyReplicas %d/%d, deployment.Name, deployment.Status.ReadyReplicas, desiredReplicas, ) } return Pending, fmt.Sprintf( Deployment %s 正在启动 readyReplicas %d/%d, deployment.Name, deployment.Status.ReadyReplicas, desiredReplicas, ) }当前状态分为三类failedDeployment 出现 ReplicaFailure。 RunningReadyReplicas 已经达到期望副本数。 Pending还没有达到期望副本数仍在启动中。需要注意Deployment 刚创建出来时deployment.Status可能还没来得及被 Deployment Controller 更新所以第一次 Reconcile 看到的状态可能还是 Pending。后续 Deployment 状态变化后因为 Controller 配置了Owns(appsv1.Deployment{})Deployment 的变化会继续触发对应VLLMService的 Reconcile然后 status 会继续更新。当前failed只基于 Deployment 的ReplicaFailureTrue判断适合捕获“副本创建失败”这一类问题但它不能覆盖所有运行时失败。例如镜像拉取失败、容器启动后退出、vLLM 参数错误、模型路径错误等问题不一定都会被这个逻辑识别成 failed。后续如果要做得更完善可以继续读取 Pod 状态结合containerStatuses、waiting.reason、terminated.exitCode等信息进一步区分 ImagePullBackOff、CrashLoopBackOff、Error 等状态当前 status 字段比较简单但已经具备了 Operator 的基本状态回写能力。后续如果要更标准可以把Phase扩展成 Kubernetes 常见的 Conditions 形式例如Available、Progressing、Degraded这样更适合被其他系统读取和判断。十四、用一张流程图总结当前 Reconcile 执行链路最后把当前 Reconcile 的整体流程串起来收到 Reconcile 请求 ↓ 根据 namespace/name 读取 VLLMService ↓ 如果 VLLMService 不存在 ↓ 说明资源已删除直接结束 ↓ 如果 VLLMService 存在 ↓ 构造同名 Deployment 对象 ↓ 调用 CreateOrUpdate ↓ 设置 Deployment labels ↓ 首次创建时设置 Deployment selector ↓ 设置 replicas ↓ 生成 PodTemplate ↓ 生成 vLLM 容器 ↓ 生成 PVC volume 和 volumeMount ↓ 设置 schedulerName / runtimeClassName / nodeSelector ↓ 设置 OwnerReference ↓ 创建或更新 Deployment ↓ 根据 Deployment 状态更新 VLLMService.Status ↓ 本次 Reconcile 结束到这里当前 Operator 已经完成了最核心的一步把用户声明的VLLMServiceCR 转换成真正运行模型服务的 Deployment。这一篇最重要的几个点第一req里只有namespace/name所以 Reconcile 开始要通过r.Get()读取完整的VLLMService第二当前代码使用controllerutil.CreateOrUpdate()同步 Deployment避免手写大量 Get/Create/Update 判断第三Deployment selector 创建后不可变所以 selector 只在首次创建时设置并且要使用稳定标签第四vLLM 容器参数、资源配置、PVC 挂载、调度配置都来自VLLMService.Spec第五SetControllerReference()会把VLLMService设置成 Deployment 的 owner从而让 Kubernetes 在删除主资源后自动级联清理 Deployment第六Status().Update()会把 Deployment 的运行状态回写到VLLMService.Status让用户能通过 CR 看到当前服务状态。从这一篇开始VLLMService就不再只是一个“能被 kubectl get 到的自定义资源”而是变成了一个可以驱动 Kubernetes 创建 vLLM 推理服务工作负载的声明式 API。后续继续扩展时可以在这个基础上增加 Service、HTTPRoute、ServiceMonitor、PrometheusRule让这个 Operator 从“只创建 Deployment”逐步演进成完整的 AI 推理服务编排器。