前面几篇主要是在铺垫 Kubebuilder、CRD、Controller、Reconciler 和 Reconcile 控制循环。这一篇开始进入真正的 Operator 编排逻辑:用户创建一个 VLLMService 自定义资源后,Operator 自动在 Kubernetes 集群里创建或更新一个对应的 Deployment,然后由这个 Deployment 启动 vLLM 推理服务。也就是说,从这一篇开始,VLLMService 不再只是一个 CRD 里的数据结构,而是开始真正驱动 Kubernetes 创建工作负载。
github项目地址:https://github.com/bolin-dai/vllmservice-operator

一、这一篇要解决什么问题

这一篇要解决的问题很明确:用户创建一个 VLLMService CR,Operator 根据这个 CR 自动创建或更新一个同名 Deployment,让这个 Deployment 去启动 vLLM 容器。整体链路可以先这样理解:

VLLMService CR
        ↓
Reconcile 控制循环
        ↓
Deployment
        ↓
Pod
        ↓
vLLM 容器

在 Kubernetes 里,用户通过 YAML 声明“期望状态”。比如创建一个 VLLMService,本质上是在告诉集群:我希望运行一个 vLLM 模型服务。Controller 的职责就是不断观察当前集群状态,并把实际状态调整到用户声明的期望状态。对于当前这个 Operator 来说,用户声明的是 VLLMService.Spec,Controller 要创建的是 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.ClientScheme 是很正常的,这是 Kubebuilder / controller-runtime 写 Controller 时非常常见的结构。

四、RBAC Marker:Controller 需要哪些权限

Controller 运行在 Kubernetes 集群里时,不是天然就能操作所有资源。它通常会绑定一个 ServiceAccount,然后通过 RBAC 控制它能操作哪些资源。Kubebuilder 里常见做法是在 controller 代码上方写 RBAC marker,然后执行 make manifests 生成对应的 RBAC YAML。

当前代码里的 RBAC marker 是:

// +kubebuilder:rbac:groups=aiinfra.example.com,resources=vllmservices,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=aiinfra.example.com,resources=vllmservices/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=aiinfra.example.com,resources=vllmservices/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;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 YAML:

make 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 对象内容。比如本次请求只是告诉 Controller:ai-demo/qwen-demo 这个对象需要处理一下。至于这个对象的 spec.imagespec.modelPathspec.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 临时错误等其他错误,就返回 error:

return ctrl.Result{}, err

返回 error 后,controller-runtime 后续会重新入队重试。这样做符合 Reconcile 的基本写法:主资源不存在就正常结束,其他错误就返回给控制循环处理。

六、核心逻辑:使用 CreateOrUpdate 同步 Deployment

读取到 VLLMService 后,代码开始准备同名 Deployment:

deployment := &appsv1.Deployment{
    ObjectMeta: metav1.ObjectMeta{
        Name:      vllmService.Name,
        Namespace: vllmService.Namespace,
    },
}

这里 Deployment 的名字和命名空间都直接跟 VLLMService 保持一致。例如 VLLMServiceai-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 里。否则用户一改 label,Operator 如果试图同步 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 固定表示这是 vllmservice,app.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() 生成 PodTemplate:

func 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 挂到容器上,设置 schedulerNameruntimeClassNamenodeSelector,最后返回完整的 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.requestsresources.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 转发流量。
--port:vLLM 服务监听端口。
--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:如何挂载模型 PVC

vLLM 启动时需要读取模型文件。当前代码支持通过 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.pvcNamestorage.mountPath 标成了 Required,并且加了 MinLength=1。所以正常通过 API Server 创建 CR 时,如果不填 pvcNamemountPath,应该会被 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() 设置了 OwnerReference,Kubernetes 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 状态回写到 CR

Operator 不应该只创建资源,还应该把当前运行状态反馈给用户。当前代码里有:

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 是否真的发生变化。如果 PhaseReadyReplicasDeploymentNameMessage 都没有变化,就直接返回 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,
    )
}

当前状态分为三类:

failed:Deployment 出现 ReplicaFailure。
Running:ReadyReplicas 已经达到期望副本数。
Pending:还没有达到期望副本数,仍在启动中。

需要注意,Deployment 刚创建出来时,deployment.Status 可能还没来得及被 Deployment Controller 更新,所以第一次 Reconcile 看到的状态可能还是 Pending。后续 Deployment 状态变化后,因为 Controller 配置了 Owns(&appsv1.Deployment{}),Deployment 的变化会继续触发对应 VLLMService 的 Reconcile,然后 status 会继续更新。

当前 failed 只基于 Deployment 的 ReplicaFailure=True 判断,适合捕获“副本创建失败”这一类问题,但它不能覆盖所有运行时失败。例如镜像拉取失败、容器启动后退出、vLLM 参数错误、模型路径错误等问题,不一定都会被这个逻辑识别成 failed。后续如果要做得更完善,可以继续读取 Pod 状态,结合 containerStatuseswaiting.reasonterminated.exitCode 等信息,进一步区分 ImagePullBackOff、CrashLoopBackOff、Error 等状态

当前 status 字段比较简单,但已经具备了 Operator 的基本状态回写能力。后续如果要更标准,可以把 Phase 扩展成 Kubernetes 常见的 Conditions 形式,例如 AvailableProgressingDegraded,这样更适合被其他系统读取和判断。

十四、用一张流程图总结当前 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 已经完成了最核心的一步:把用户声明的 VLLMService CR 转换成真正运行模型服务的 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 推理服务编排器。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐