VLLMService Operator 开发第七篇:设计 gatewayRef 并梳理 HTTPRoute 调谐流程 前言上一篇文章中给 VLLMService Operator 增加了 Service 自动创建能力。到这个阶段用户只需要创建一个 VLLMServiceOperator 就可以自动创建 Deployment、Pod 和 Service模型服务已经有了一个稳定的集群内访问入口。不过 Service 主要解决的是集群内稳定访问问题。如果我们希望通过统一网关、域名和 Host 规则访问模型服务还需要继续接入 Gateway API。Gateway API 中HTTPRoute 用来描述 HTTP 请求从 Gateway listener 转发到后端 Service 的路由规则它的核心字段包括parentRefs、hostnames和rules.backendRefs。parentRefs用来指定要绑定的 Gatewayhostnames用来匹配 HTTP 请求中的 Host 头backendRefs用来指定请求最终转发到哪个后端 Service。这一篇不做完整访问验证而是先把 HTTPRoute 相关的 API 设计和 Reconcile 调谐流程讲清楚。当前目标是当用户在 VLLMService 中声明gatewayRef时Operator 能够识别用户想要接入 Gateway并在真正创建 HTTPRoute 前检查 Gateway、listener 和协议是否满足要求。本文对应的目标链路是VLLMService - Deployment - Pod - Service - HTTPRoute - Gateway当前 GitHub 项目地址https://github.com/bolin-dai/vllmservice-operator一、为什么 Service 之后还需要 HTTPRouteService 能够为一组 Pod 提供稳定入口但是它通常是集群内访问入口。如果只是集群内部访问模型服务ClusterIP Service已经够用但如果希望通过统一入口、域名、Gateway listener 暴露模型服务就需要在 Service 之上再增加路由层。Gateway API 中的 Gateway 可以理解为“网关入口资源”它通过 listener 定义监听端口、协议、hostname、TLS 配置以及允许哪些 Route 绑定到这个 listener。对模型服务来说我们最终希望用户不要直接写 HTTPRoute而是继续使用 VLLMService 这个更高层的业务资源。用户只需要在 VLLMService 里声明“我要挂到哪个 Gateway、哪个 listener、使用哪个域名”Operator 就负责把这些信息转换成真正的 HTTPRoute。例如用户最终希望写的是这种 VLLMServiceapiVersion: 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 port: 8000 gatewayRef: name: llm-gateway namespace: ai-demo sectionName: http host: llm.example.local resources: requests: cpu: 2 memory: 8Gi volcano.sh/vgpu-number: 1 volcano.sh/vgpu-memory: 6144 volcano.sh/vgpu-cores: 50 limits: cpu: 4 memory: 16Gi volcano.sh/vgpu-number: 1 volcano.sh/vgpu-memory: 6144 volcano.sh/vgpu-cores: 50 storage: pvcName: qwen-model-pvc mountPath: /data/models readOnly: true这里的gatewayRef就是本篇文章的核心。它不是让 Operator 创建 Gateway而是让 Operator 引用一个已经存在的 Gateway。Gateway 通常由平台侧、集群管理员或网关管理员提前创建业务侧更多是创建 HTTPRoute 这类路由规则。Gateway API 官方文档也说明HTTPRoute 通过parentRefs表达自己要绑定到哪个 Gateway并且目标 Gateway 需要允许该 Route 所在 namespace 的 HTTPRoute 绑定绑定才会成功。二、先搞清楚 Gateway、listener 和 HTTPRoute 的关系在写代码之前先把 Gateway API 里这几个对象的关系理清楚。Gateway 是入口资源可以理解为“网关入口的声明”listener 是 Gateway 下面的监听器可以理解为“这个网关监听哪个端口、哪个协议、哪个域名”HTTPRoute 是 HTTP 路由规则可以理解为“哪些 Host、哪些路径、哪些请求转发到哪个后端 Service”。一个简单的 Gateway 大概长这样apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: llm-gateway namespace: ai-demo spec: gatewayClassName: eg listeners: - name: http protocol: HTTP port: 80 hostname: llm.example.local这里最关键的是 listenerlisteners: - name: http protocol: HTTP port: 80 hostname: llm.example.local后面 HTTPRoute 要绑定这个 listener就要在parentRefs里写parentRefs: - name: llm-gateway sectionName: httpGateway API 官方文档中也说明HTTPRoute 可以通过parentRefs.name绑定到 Gateway通过parentRefs.sectionName绑定到 Gateway 的某个指定 listener。HTTPRoute 还会通过hostnames匹配 HTTP 请求里的 Host 头。例如 HTTPRoute 里写了hostnames: - llm.example.local那么访问时通常需要带上对应的 Hostcurl -H Host: llm.example.local http://127.0.0.1:8888/v1/modelshostnames用来匹配 HTTP 请求中的 Host header请求会先根据 hostnames 做匹配再继续匹配 HTTPRoute rules。HTTPRoute 最后还需要通过backendRefs指向后端 Service例如rules: - backendRefs: - name: qwen-demo port: 8000这表示匹配到的请求会被转发到qwen-demo这个 Service 的 8000 端口。backendRefs用来描述匹配请求应该被发送到哪些service对象。HTTPRoute 的核心字段转换关系就是gatewayRef.name - HTTPRoute spec.parentRefs.name gatewayRef.namespace - HTTPRoute spec.parentRefs.namespace gatewayRef.sectionName - HTTPRoute spec.parentRefs.sectionName gatewayRef.host - HTTPRoute spec.hostnames Service name/port - HTTPRoute spec.rules.backendRefs三、在 VLLMService API 中设计 gatewayRef为了让用户通过 VLLMService 声明路由接入信息我们先在 API 类型中增加一个VLLMServiceGatewayRef结构体。当前代码中已经增加了name、namespace、sectionName和host四个字段其中name、sectionName、host是必填字段namespace是可选字段。代码如下type VLLMServiceGatewayRef struct { // name 是要引用的 Gateway 名称 // kubebuilder:validation:Required // kubebuilder:validation:MinLength1 Name string json:name // Namespace 是 Gateway 所在命名空间。如果不填写默认认为 Gateway 和当前 VLLMService 在同一个命名空间。 // optional // kubebuilder:validation:MinLength1 Namespace string json:namespace,omitempty // SectionName 是要绑定的 Gateway listener 名称。后面创建 HTTPRoute 时会写入 parentRefs.sectionName。 // kubebuilder:validation:Required // kubebuilder:validation:MinLength1 SectionName string json:sectionName // Host 是 HTTPRoute 要匹配的域名。后面创建 HTTPRoute 时会写入 spec.hostnames。 // kubebuilder:validation:Required // kubebuilder:validation:MinLength1 Host string json:host }然后在VLLMServiceSpec中增加// GatewayRef 表示当前 VLLMService 要挂载到哪个 Gateway 上。 // 不填写 gatewayRef 时Operator 只创建 Deployment 和 Service不创建 HTTPRoute。 // optional GatewayRef *VLLMServiceGatewayRef json:gatewayRef,omitempty这里最容易疑惑的是为什么GatewayRef本身是可选的但是它里面的name、sectionName、host又是必填的其实这个设计是合理的可以理解成“整个功能开关可选但一旦启用就必须写完整参数”。也就是说不写 gatewayRef表示当前 VLLMService 不接入 Gateway只创建 Deployment 和 Service。 写了 gatewayRef表示当前 VLLMService 要创建 HTTPRoute这时必须写清楚 Gateway 名称、listener 名称和 Host。这种写法在 Kubernetes API 设计里很常见。外层指针字段*VLLMServiceGatewayRef用来表达“是否启用某个功能”内部 required 字段用来表达“启用这个功能后必须提供哪些参数”。Kubebuilder 官方文档说明kubebuilder:validation:Required、optional、MinLength等 marker 会影响生成的 CRD OpenAPI schemaAPI Server 会基于生成的 schema 对自定义资源进行校验。所以 VLLMService 中 gatewayRef 的合法写法是spec: gatewayRef: name: llm-gateway sectionName: http host: llm.example.local不写gatewayRef也合法spec: image: docker.m.daocloud.io/vllm/vllm-openai:latest modelPath: /data/models/Qwen2.5-1.5B-Instruct modelName: qwen2.5-1.5b-instruct但是写了gatewayRef又不写host就不能通过校验spec: gatewayRef: name: llm-gateway sectionName: http另外namespace是可选字段。如果用户不写代码里就默认使用当前 VLLMService 所在 namespace如果用户显式填写就使用用户指定的 namespace。当前代码中的gatewayRefNamespaceFor方法就是这个逻辑。四、增加 Gateway API 的 RBAC 权限Operator 要读取 Gateway、创建和维护 HTTPRoute就必须先有对应的 RBAC 权限。当前 controller 文件中已经增加了两条 Gateway API 相关 RBAC markergateways只需要get/list/watch因为这里是引用已有 Gateway不负责创建 Gatewayhttproutes需要get/list/watch/create/update/patch/delete因为 HTTPRoute 是当前 Operator 要创建和维护的子资源。对应 marker 如下// kubebuilder:rbac:groupsgateway.networking.k8s.io,resourcesgateways,verbsget;list;watch // kubebuilder:rbac:groupsgateway.networking.k8s.io,resourceshttproutes,verbsget;list;watch;create;update;patch;delete修改 RBAC marker 后需要重新生成 manifestsmake manifests生成后可以检查config/rbac/role.yaml。当前仓库中已经能看到gateways和httproutes的权限。- apiGroups: - gateway.networking.k8s.io resources: - gateways verbs: - get - list - watch - apiGroups: - gateway.networking.k8s.io resources: - httproutes verbs: - create - delete - get - list - patch - update - watch这里要注意一点如果忘记执行make manifestsconfig/rbac/role.yaml不会更新。即使 Go 代码里写了 marker部署到集群里的 Operator 仍然可能没有权限。到时候 Reconcile 创建 HTTPRoute 或读取 Gateway 时就会出现forbidden之类的权限错误。五、整体 Reconcile 流程当前主 Reconcile 的顺序是先读取 VLLMService然后创建或更新 Deployment再创建或更新 Service接着调用reconcileHTTPRoute同步 HTTPRoute最后统一调用updateVLLMServiceStatus更新状态。也就是说HTTPRoute 是在 Service 创建完成之后再处理的因为 HTTPRoute 的 backendRefs 要指向前面创建出来的 Service。【图 1整体 Reconcile 流程图】reconcileHTTPRoute的返回值设计也比较关键httpRoute, routeMessage, requeueAfter, err : r.reconcileHTTPRoute(ctx, vllmService, service)这几个返回值可以这样理解httpRoute成功创建或更新 HTTPRoute 时返回该对象没有创建时返回 nil。 routeMessageHTTPRoute 相关的提示信息用于写入 VLLMService status。 requeueAfter是否需要延迟重试例如 Gateway 暂时不存在时可以一分钟后再试。 err真正的系统错误或 API 调用错误返回后交给 controller-runtime 处理。不是所有失败都应该返回 error。例如 Gateway 不存在、listener 不存在这些更像“依赖资源暂时未准备好”或“用户配置暂时不满足要求”可以写入 status 并根据情况重试而 API Server 调用失败、RBAC 权限失败、删除失败这类问题才更适合返回 error。六、HTTPRoute 调谐流程从 gatewayRef 判断到依赖校验进入reconcileHTTPRoute后第一步不是创建 HTTPRoute而是先判断当前 VLLMService 是否配置了spec.gatewayRef。如果gatewayRef不存在说明用户不希望当前模型服务接入 Gateway如果gatewayRef存在才说明用户希望 Operator 创建 HTTPRoute。当前代码中gatewayRefEnabled就是用来判断vllmService.Spec.GatewayRef ! nil。【图 2HTTPRoute 局部调谐流程图】6.1 gatewayRef 不存在删除旧 HTTPRoute如果gatewayRef为 nilOperator 不仅不会创建 HTTPRoute还会调用deleteOwnedHTTPRouteIfExists删除当前 VLLMService 拥有的旧 HTTPRoute。这个设计很重要因为用户可能之前配置过gatewayRefOperator 已经创建过 HTTPRoute后来用户把gatewayRef删除了如果 Operator 不清理旧 HTTPRoute旧路由仍然可能继续暴露模型服务。新的期望状态已经变成“不接入 Gateway”所以旧 HTTPRoute 应该被删除。对应代码逻辑如下if !gatewayRefEnabled(vllmService) { if err : r.deleteOwnedHTTPRouteIfExists(ctx, vllmService); err ! nil { return nil, , 0, err } return nil, , 0, nil }6.2 deleteOwnedHTTPRouteIfExists只删除自己管理的资源deleteOwnedHTTPRouteIfExists的安全性很重要因为 Kubernetes 里可能存在同名 HTTPRoute。如果直接根据 name 和 namespace 删除 HTTPRoute就可能误删用户手工创建的资源或者误删其他控制器管理的资源。当前代码的做法比较稳妥先查询同名 HTTPRoute如果不存在就直接返回如果存在则通过metav1.IsControlledBy(httpRoute, vllmService)判断它是否真的由当前 VLLMService 控制。只有存在 Controller OwnerReference 关系时才调用r.Delete(ctx, httpRoute)删除。核心逻辑如下httpRoute : gatewayv1.HTTPRoute{} err : r.Get(ctx, client.ObjectKey{ Name: vllmService.Name, Namespace: vllmService.Namespace, }, httpRoute) if apierrors.IsNotFound(err) { return nil } if err ! nil { return err } if !metav1.IsControlledBy(httpRoute, vllmService) { logger.Info(发现同名HTTPRoute, 但它不是当前VLLMService控制的资源,跳过删除) return nil } if err : r.Delete(ctx, httpRoute); err ! nil { return err }这段逻辑体现了 Operator 里一个很重要的原则只管理自己拥有的资源不要误删别人创建的资源。后面创建 HTTPRoute 时也会通过controllerutil.SetControllerReference(vllmService, httpRoute, r.Scheme)建立 VLLMService 和 HTTPRoute 的 OwnerReference 关系。controller-runtime 的SetControllerReference用来把 owner 设置为对象的 controller ownerCreateOrUpdate则用于在对象不存在时创建、存在时按 MutateFn 更新到期望状态。6.3 gatewayRef 存在先解析 Gateway 引用如果gatewayRef存在说明用户希望当前 VLLMService 接入已有 Gateway。此时不能马上创建 HTTPRoute而是要先检查几个前置条件Gateway 是否存在、指定 listener 是否存在、listener 协议是否适合 HTTPRoute。当前代码中这些逻辑集中在resolveGatewayRef方法里。resolveGatewayRef会先根据gatewayRef.namespace计算 Gateway 所在命名空间。如果用户没有写 namespace就默认使用当前 VLLMService 的 namespace如果用户写了 namespace就使用用户指定的 namespace。然后根据 Gateway namespace 和 name 调用r.Get查询 Gateway。代码如下gatewayNamespace : gatewayRefNamespaceFor(vllmService) gatewayName : vllmService.Spec.GatewayRef.Name sectionName : vllmService.Spec.GatewayRef.SectionName gateway : gatewayv1.Gateway{} err : r.Get(ctx, client.ObjectKey{ Name: gatewayName, Namespace: gatewayNamespace, }, gateway)如果 Gateway 不存在当前代码不会直接返回 error而是返回gatewaynil、提示信息和time.Minuteif apierrors.IsNotFound(err) { message : fmt.Sprintf(引用的Gateway不存在: %s/%s, gatewayNamespace, gatewayName) return nil, message, time.Minute, nil }这样做的原因是Gateway 可能是平台侧稍后创建的资源也可能是部署顺序上暂时还没创建完成。此时写入 status 告诉用户“引用的 Gateway 不存在”同时一分钟后重新 Reconcile是比较合理的处理方式。6.4 Gateway、listener 和 protocol 的三类异常处理如果 Gateway 存在下一步要查找gatewayRef.sectionName对应的 listener。当前代码通过findGatewayListener遍历gateway.Spec.Listeners判断 listener 的 name 是否等于用户配置的 sectionName。func findGatewayListener(gateway *gatewayv1.Gateway, sectionName string) (gatewayv1.Listener, bool) { for _, listener : range gateway.Spec.Listeners { if string(listener.Name) sectionName { return listener, true } } return gatewayv1.Listener{}, false }如果找不到 listener当前代码也不会直接返回 error而是返回提示信息和time.Minute。这里也可以重试因为 listener 可能稍后被管理员补上。当然也可能是用户把sectionName写错了所以 status message 一定要写清楚让用户能从kubectl get vllmservice -o yaml或 describe 信息中看到具体原因。listener, found : findGatewayListener(gateway, sectionName) if !found { message : fmt.Sprintf(引用的Gateway存在, 但找不到listener: gateway %s/%s sectionName%s, gatewayNamespace, gatewayName, sectionName) return nil, message, time.Minute, nil }如果 listener 存在还要继续判断 listener 的协议。因为当前要创建的是 HTTPRoute所以它应该绑定到 HTTP 或 HTTPS listener。如果用户把sectionName指向了 TCP、TLS、UDP 这类 listener那么 HTTPRoute 不适合绑定到这个 listener。当前代码是这样判断的if listener.Protocol ! gatewayv1.HTTPProtocolType listener.Protocol ! gatewayv1.HTTPSProtocolType { message : fmt.Sprintf( 引用的Gateway listener协议不是HTTP/HTTPS: gateway%s/%s sectionName%s protocol%s, gatewayNamespace, gatewayName, sectionName, listener.Protocol, ) return nil, message, 0, nil }这里和 Gateway 不存在、listener 不存在不一样协议不匹配通常不是临时状态而是配置错误。比如用户把sectionName写成了一个 TCP listener 的名字Operator 每隔一分钟重试也不会改变结果。只有用户修改 VLLMService 的gatewayRef.sectionName或者管理员修改 Gateway listener 的协议问题才会消失。所以这里返回requeueAfter0不主动定时重试是更合理的选择。这一步的最终效果可以总结成Gateway 不存在删除当前 VLLMService 拥有的旧 HTTPRoute写 status一分钟后重试。 listener 不存在删除当前 VLLMService 拥有的旧 HTTPRoute写 status一分钟后重试。 listener 协议不是 HTTP/HTTPS删除当前 VLLMService 拥有的旧 HTTPRoute写 status不主动定时重试。 Gateway 存在、listener 存在、协议正确继续创建或更新 HTTPRoute。七、依赖校验通过后还需要关注 HTTPRoute Status当resolveGatewayRef返回的 gateway 不为 nil 时只能说明三件事Gateway 对象存在、指定 listener 存在、listener 协议适合 HTTPRoute。它还不能说明 HTTPRoute 最终一定会被 Gateway 接受也不能说明流量一定能转发成功。原因是 Gateway API 里还有一些运行时条件会影响最终绑定结果。例如Gateway listener 需要允许当前 namespace 下的 HTTPRoute 绑定HTTPRoute 的backendRefs需要能正确解析到后端 ServiceGateway Controller 还会把接受结果写到 HTTPRoute status 里。官方文档中也说明当 HTTPRoute 添加了指向 Gateway 的parentRefs后管理 Gateway 的 controller 应该在 HTTPRoute status 的 parents 中写入对应父资源的状态例如AcceptedTrue表示该 HTTPRoute 已被 Gateway 接受。所以本文当前做的是 Operator 自己能做的“前置校验”检查 Gateway 是否存在、listener 是否存在、protocol 是否适合 HTTPRoute。真正的绑定结果还要等 Gateway Controller 处理 HTTPRoute 后通过 HTTPRoute status 来判断。后续完整验证时需要重点看kubectl -n ai-demo describe httproute qwen-demo kubectl -n ai-demo get httproute qwen-demo -o yaml重点关注 status 中的 conditions例如Accepted ResolvedRefs如果AcceptedFalse通常说明 Gateway 没有接受这个 Route例如 listener 的allowedRoutes不允许当前 namespace 的 Route 绑定。如果ResolvedRefsFalse通常要继续检查 backendRef 指向的 Service、端口、权限或跨命名空间引用问题。八、本篇总结本文主要完成了 HTTPRoute 自动创建前的 API 设计和调谐流程梳理。到目前为止VLLMService 的能力已经从只管理 Deployment 和 Service开始向 Gateway API 路由接入扩展。这一篇重点讲了几个核心点1. Service 提供稳定的集群内访问入口但如果要通过统一 Gateway 和域名访问还需要 HTTPRoute。 2. Gateway 通常由平台侧提前创建当前 Operator 只引用已有 Gateway不负责创建 Gateway。 3. VLLMService 中新增 gatewayRef用来声明 Gateway 名称、Gateway namespace、listener 名称和 Host。 4. gatewayRef 本身是可选的不写就不创建 HTTPRoute一旦写了 gatewayRef内部 name、sectionName、host 必须填写。 5. Operator 需要增加 Gateway API RBAC 权限其中 Gateway 只需要读权限HTTPRoute 需要创建、更新、删除等管理权限。 6. reconcileHTTPRoute 会先判断 gatewayRef 是否存在不存在就删除当前 VLLMService 拥有的旧 HTTPRoute。 7. deleteOwnedHTTPRouteIfExists 只删除当前 VLLMService 控制的 HTTPRoute避免误删用户手工创建的同名资源。 8. resolveGatewayRef 会检查 Gateway 是否存在、listener 是否存在、listener 协议是否为 HTTP/HTTPS。 9. Gateway 不存在和 listener 不存在属于可能恢复的依赖问题可以写 status 并延迟重试。 10. listener 协议不是 HTTP/HTTPS 更像配置错误应该写 status但不需要主动定时重试。到这里我们已经把 HTTPRoute 创建前最重要的逻辑讲清楚了VLLMService 如何通过gatewayRef表达路由接入意图Operator 又如何在创建 HTTPRoute 前判断 Gateway 依赖是否可用。下一篇文章会继续讲真正的 HTTPRoute 创建逻辑包括parentRefs、hostnames、backendRefs、OwnerReference、status 回写以及如何通过 Gateway 访问 vLLM 的 OpenAI-compatible API。本人水平有限欢迎各位大佬批评指正。