蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #17:k8s 资源纳管:Pod/Deployment/Service 统一管理

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #17:k8s 资源纳管:Pod/Deployment/Service 统一管理

云原生时代,Kubernetes 已经成为事实标准。但企业内部的 CMDB 仍然需要把 k8s 资源纳入资产视野:哪个集群在哪台机器?哪个命名空间承载着哪个业务?哪些 Pod 漂移到了哪个 Node?

如果用 kubectl 一条条查,再手动录入 CMDB,既慢又容易出错。如果让 CMDB 直连 kube-apiserver,又会引入 Kubernetes 强依赖——CMDB 不该为了管容器而必须先懂整个 k8s 体系。

蓝鲸 CMDB 的解决方案是:将 k8s 资源抽象为 CMDB 实例,复用既有的 inst 增删改查链路,但通过独立的 kube/types、kube/orm 和 topo_server/service/kube 模块,对 k8s 专属语义(Workload 类型分类、shared 集群跨业务共享、Host ↔ Node 关联)做精细化建模。

本文从 SRE 痛点出发,结合 3.14.6 源码,讲清楚 CMDB 是如何用 "类型驱动 + shared 命名空间 + 反射更新" 三件套,把 k8s 资源平滑纳入 CMDB 体系。

蓝鲸CMDB k8s纳管 Pod Deployment Workload shared集群 反射更新 SRE场景

src/kube/types/types.go                  ← WorkloadType 类型常量与 Validate/Table/Fields/NewInst 四个核心方法 [L53-L98]
    src/kube/types/cluster.go                ← Cluster/ClusterBase 类型定义 + BKTableNameBaseCluster 表名常量
    src/kube/types/node.go                   ← Node/BKNodeIDField 节点类型 + 删除前置校验(必须没有 Pod) [L107-L150]
    src/kube/types/namespace.go              ← Namespace/NsCreateOption/NsUpdateOption
    src/kube/types/workload.go               ← WorkloadInterface 接口(GetWorkloadBase/SetWorkloadBase)
    src/kube/types/deployment.go             ← Deployment 类型 + DeploymentFields
    src/kube/types/statefulset.go            ← StatefulSet 类型 + StatefulSetFields
    src/kube/types/daemonset.go              ← DaemonSet 类型 + DaemonSetFields
    src/kube/types/cronjob.go                ← CronJob 类型 + CronJobFields
    src/kube/types/job.go                    ← Job 类型 + JobFields
    src/kube/types/gamedeployment.go         ← GameDeployment 类型(游戏服专用)
    src/kube/types/gamestatefulset.go        ← GameStatefulSet 类型
    src/kube/types/pods_workload.go          ← PodsWorkload 类型(裸 Pod 工作负载)
    src/kube/types/k8s_types.go              ← 通用 k8s 字段(Labels/Spec 等)
    src/kube/types/spec.go                   ← 资源规格描述
    src/kube/types/topo.go                   ← 拓扑关联(HostPathOption/NodePath/PodPath/BizIDWithName)
    src/kube/types/validate.go               ← 通用 Validate() 入口
    src/kube/types/validate_test.go          ← Validate 测试
    src/kube/orm/tool.go                     ← 反射工具:GetUpdateFieldsWithOption + FieldOption.AddIgnoredFields
    src/scene_server/topo_server/service/kube/service.go      ← service 构造函数 + 路由注册
    src/scene_server/topo_server/service/kube/cluster.go      ← cluster CRUD + shared 集群查询条件生成
    src/scene_server/topo_server/service/kube/node.go         ← Node 增删改查 + FindNodePathForHost
    src/scene_server/topo_server/service/kube/namespace.go    ← Namespace 增删改查 + shared 命名空间条件
    src/scene_server/topo_server/service/kube/pod.go          ← Pod 增删改查 + FindPodPath + buildPodPaths
    src/scene_server/topo_server/service/kube/workload.go     ← Workload 通用 CRUD(按 kind 路由)
    src/scene_server/topo_server/service/kube/public.go       ← 通用辅助(业务校验、权限前缀)
    src/scene_server/topo_server/logics/kube/kube.go          ← 业务编排:BatchDeleteNode/BatchCreateNode/Pod 等
            

SRE 痛点:业务方突然来一句"我们 k8s 集群的资源也归 CMDB 管"

假设你是某互联网公司的 CMDB owner。某天,运维总监走过来提了个需求:

"业务方接入了 k8s,但每次定位故障,运维都得先在 ArgoCD / kubectl 里翻资源,再回到 CMDB 查这台机器归哪个业务、管机人是谁,流程太碎了。能不能把 k8s 资源也纳入 CMDB?业务视图里能看到集群、命名空间、Deployment 这些。"

你一看需求,脑子里立刻冒出一堆问题:

  1. k8s 资源类型那么多(Pod、Deployment、StatefulSet、DaemonSet、CronJob……),每种资源的字段都不一样,难道每种都要写一套 CRUD?
  2. 很多公司一个物理 k8s 集群是多个业务共用的(共享集群),但 CMDB 的业务是隔离的,资源怎么"打标签"?
  3. Node 上漂移着 Pod,Pod 又属于 Deployment,Deployment 属于命名空间,命名空间又属于集群——如果 CMDB 不能反向从 HostID 找出 Node、从 PodID 找出完整链路,故障定位就还是割裂的。
  4. 更新 Deployment 时,更新字段那么多(replicas、image、labels 几十个字段),后端到底更新了哪些?万一漏了某个字段,前端看不到变化。

这就是 3.14.6 中 src/kube/ 这套模块要解决的问题。本文会逐一拆解。

一、k8s 资源纳管的设计哲学:复用 inst 链路 + k8s 专属增强

1.1 Why — 为什么不直接复用标准拓扑的 inst 模型?

CMDB 资源纳管的本质矛盾:标准的 inst 增删改查 vs k8s 资源的多类型语义

CMDB 现有的 "业务-集群-模块-主机" 四级模型(标准拓扑),把每种对象都当作同一类 Inst 处理,差异由 ObjectID(对象 ID)区分。这种模型很优雅,但它隐含了三个假设

  1. 对象名是有限的、可枚举的(业务、集群、模块、主机、Set……)
  2. 每种对象只有一个 "表" (主机只有 cc_HostBase
  3. 对象之间是严格的层级关系,不会出现同一种"对象"有多种子类型

但 k8s 不一样:

  • Workload 不是一种对象,而是 Deployment / StatefulSet / DaemonSet / CronJob / Job / GameDeployment / GameStatefulSet / PodWorkload 至少 8 种,每种都有自己的表(cc_KubeDeploymentcc_KubeStatefulSet 等)
  • Workload 的字段集差异极大:Deployment 有 replicas,CronJob 有 schedule,StatefulSet 有 serviceName
  • Pod 和 Node 之间的关系是 "漂移" 而非 "绑定" (kubelet 调度,Pod 可以随时从一个 Node 飘到另一个 Node),这跟主机-模块的强绑定完全相反
  • 集群可以是共享集群(多个业务共用一个物理集群),不同业务的 Namespace 不能冲突,但查询时要带上业务过滤

所以 CMDB 选择了一条很务实的路:底层复用 inst 链路(CRUD、权限、审计),但开一个独立的 src/kube/ 模块,专门处理 k8s 独有的语义

1.2 What — 三个新模块的边界划分

从目录结构可以看到,3.14.6 把 k8s 纳管拆成了三个清晰的模块:

模块目录职责关键产物
kube 类型层 src/kube/types/ 所有 k8s 资源的结构体定义、表名常量、Validate() 入口 WorkloadTypeClusterNodeNamespacePod、各类 Deployment 字段集
kube ORM 工具 src/kube/orm/ 基于反射的字段提取工具,只导出已赋值字段给 MongoDB GetUpdateFieldsWithOptionFieldOption.AddIgnoredFields
topo_server service 层 src/scene_server/topo_server/service/kube/ k8s 资源的 HTTP 入口、权限校验、事务编排、审计日志 CreateWorkloadFindPodPathFindNodePathForHostSearchClusters

这种分层跟标准拓扑的 inst/object/service/ 是一一对应的:

  • inst/ 对应 kube/types/:业务对象实例的 CRUD
  • object/ 对应 topo_server/service/kube/:业务对象的 HTTP 路由
  • 新增的 kube/orm/:k8s 独有的 "只更新指定字段" 工具(标准 inst 没有这个需求,因为 inst 的字段集是固定的)

1.3 How — 三层协作的完整调用链

下面用 "创建一个 Deployment" 为例,展示三层是怎么串起来的:

用户请求 POST /api/v3/kube/workload/deployment [L33-77]
            │
            ↓
        topo_server/service/kube/workload.go → CreateWorkload(ctx)
            │
            ├─ 1. 路径参数 kind=deployment → WorkloadType("deployment").Validate() [L36-L37]
            │       ← 返回 nil 才会继续,否则返回 400
            │
            ├─ 2. ctx.DecodeInto(&req) → req = WlCreateOption{Kind:"deployment", BizID:2, Data:[{...}]}
            │
            ├─ 3. req.Validate() → 检查 bizID/namespaceID/name/labels 合法性 [L48-L51]
            │
            ├─ 4. Authorize(&AuthResource{Type:KubeWorkload, Action:Create, BizID:req.BizID}) [L54-L59]
            │       ← 权限层会查用户对该业务的写权限
            │
            ├─ 5. txn := AutoRunTxn(func() error {
            │       res := s.createWorkload(kit, kind, req) [L62-L69]
            │       ├─ ClientSet.CoreService().Kube().CreateWorkload(...) [L82-L86]
            │       │       ← 走 apimachinery 调到 coreservice 写 cc_KubeDeployment
            │       ├─ req.Data[idx].SetWorkloadBase(...) → 回填 ID 和 SupplierAccount [L91-L97]
            │       └─ audit.GenerateWorkloadAuditLog(...) → SaveAuditLog [L99-L108]
            │   })
            │
            └─ 6. 返回 {"data":{"ids":[12345]}, ...}
            

这里的几个关键设计:

  • URL 路径携带 kind/workload/deployment)而不是放 body,是因为 Workload 种类多,路径路由更清晰
  • 事务包整个数据写入 + 审计AutoRunTxn),保证 Deployment 没写进去时审计日志也不会写到一半
  • 回填 IDSetWorkloadBase)是用反射回写指针,所以审计日志里能拿到真实的 bk_id

二、Workload 类型驱动:一种接口,八种实现

2.1 What — WorkloadType 的四个核心方法

k8s 的 Workload 不是单一对象,而是一类对象的统称。3.14.6 用 WorkloadType 这个字符串类型 + 四个核心方法,把 "路由识别"(Validate 判断 URL 里的 kind 合不合法)和 "行为分发"(Table/Fields/NewInst 按 kind 把请求路由到不同的表、字段集和实例类型)封装在一起。

src/kube/types/types.go 第 53-130 行:

// src/kube/types/types.go 第 53-98 行
    type WorkloadType string
    
    func (t WorkloadType) Validate() error {
        switch t {
        case KubeDeployment, KubeStatefulSet, KubeDaemonSet,
            KubeGameStatefulSet, KubeGameDeployment, KubeCronJob,
            KubeJob, KubePodWorkload:
            return nil
        default:
            return fmt.Errorf("can not support this type of workload, kind: %s", t)
        }
    }
    
    func (t WorkloadType) Table() (string, error) {
        switch t {
        case KubeDeployment:
            return BKTableNameBaseDeployment, nil
        case KubeStatefulSet:
            return BKTableNameBaseStatefulSet, nil
        case KubeDaemonSet:
            return BKTableNameBaseDaemonSet, nil
        // ... 其他 5 种类似
        default:
            return "", fmt.Errorf("can not find table name, kind: %s", t)
        }
    }
    
    func (t WorkloadType) Fields() (*table.Fields, error) {
        switch t {
        case KubeDeployment:
            return DeploymentFields, nil
        case KubeStatefulSet:
            return StatefulSetFields, nil
        // ...
        }
    }
    
    func (t WorkloadType) NewInst() (WorkloadInterface, error) {
        switch t {
        case KubeDeployment:
            return new(Deployment), nil
        // ...
        }
    }

四个方法各自干一件事:

方法作用为什么不放在 struct 里
Validate() 检查 URL 里的 kind 是不是合法值 必须先识别类型,才能选对表——典型 switch
Table() 返回这种 Workload 对应的 MongoDB 表名 每种 Workload 表不一样,无法用多态表名
Fields() 返回字段集合(用于 coreservice 校验) 字段集差异大,必须按类型分发
NewInst() 返回对应类型的零值实例 需要向下转型到具体类型才能取字段

2.2 Why — 为什么要"按类型分支"而不是"统一表 + JSON 字段"?

用一张大表存所有 Workload 可不可以?

看起来很省事:一个 cc_KubeWorkload 表,所有 Workload 往里塞,差异字段全部用 JSON 序列化丢 spec 字段。这样的系统一年后会变成这样:

  • 前端展示 Deployment 时要拆 JSON;展示 CronJob 又要拆 JSON;展示逻辑分散在每个前端模块里,没人能说清"所有 Workload 字段"
  • 查询"replicas 大于 5 的 Deployment"——spec 是 JSON 字符串,MongoDB 要做 regex 匹配或全文索引,性能暴跌
  • 审计日志里看到 "field replicas changed from 3 to 5",但代码里完全查不到 "replicas" 是被哪段代码改的——因为它藏在 JSON 里
  • 想做"按字段搜索 Workload"时(业务方场景:找所有带 app=payment label 的 Deployment),必须建专门的 JSON 索引,schema 演进很痛

3.14.6 的实际做法:每种 Workload 一张表

  • cc_KubeDeploymentcc_KubeStatefulSetcc_KubeDaemonSetcc_KubeCronJobcc_KubeJobcc_KubeGameDeploymentcc_KubeGameStatefulSetcc_KubePodsWorkload 共 8 张表
  • 每张表的字段集是固定的,可以建精确索引(如 Deployment 上对 replicas 建索引)
  • 前端可以针对每种类型用同一套渲染组件(DeploymentFields 一次定义,前后端共用)

接口抽象保证上层代码不被类型差异污染

虽然表分开了,但上层 service 不应该感知这种差异——它应该只跟"Workload"打交道。这就是 src/kube/types/workload.goWorkloadInterface 的作用:

// src/kube/types/workload.go 核心接口
    type WorkloadInterface interface {
        GetWorkloadBase() WorkloadBase
        SetWorkloadBase(base WorkloadBase)
    }

所有 Workload 类型(Deployment、StatefulSet、DaemonSet、CronJob……)都实现了这两个方法。service 层拿到的统一是 WorkloadInterface,调 SetWorkloadBase 回填 ID 后,再调 GetWorkloadBase 取审计字段。

2.3 How — 一个并发审计日志回填的真实例子

src/scene_server/topo_server/service/kube/workload.go 第 79-111 行的 createWorkload 是这套机制的活标本:

// src/scene_server/topo_server/service/kube/workload.go 第 79-111 行
    func (s *service) createWorkload(kit *rest.Kit, kind types.WorkloadType, req types.WlCreateOption) (*metadata.RspIDs,
        error) {
    
        data, err := s.ClientSet.CoreService().Kube().CreateWorkload(kit.Ctx, kit.Header, kind, req.Data)
        if err != nil {
            blog.Errorf("create workload failed, data: %v, err: %v, rid: %s", req, err, kit.Rid)
            return nil, err
        }
    
        // audit log.
        audit := auditlog.NewKubeAudit(s.ClientSet.CoreService())
        auditParam := auditlog.NewGenerateAuditCommonParameter(kit, metadata.AuditCreate)
        for idx := range req.Data {
            wlBase := req.Data[idx].GetWorkloadBase()
            wlBase.BizID = req.BizID
            wlBase.ID = data.IDs[idx]
            wlBase.SupplierAccount = kit.SupplierAccount
            req.Data[idx].SetWorkloadBase(wlBase)
        }
    
        auditLogs, err := audit.GenerateWorkloadAuditLog(auditParam, req.Data, kind)
        if err != nil {
            blog.Errorf("generate audit log failed, ids: %v, err: %v, rid: %s", data.IDs, err, kit.Rid)
            return nil, err
        }
        if err = audit.SaveAuditLog(kit, auditLogs...); err != nil {
            return nil, err
        }
        return data, nil
    }

注意几个细节:

  1. data.IDs[idx] 是 coreservice 返回的自增 ID,在 coreservice 写成功后才会有值,所以审计回填必须在 CreateWorkload 返回之后
  2. 回填用的是接口方法 SetWorkloadBase,不是直接赋值——因为 req.Data[idx] 是接口类型,要更新里面的字段必须走 setter
  3. SupplierAccount 也回填进去——多租户场景下,审计日志要带租户信息

2.4 时序图:从 HTTP 到 MongoDB 再到审计

HTTP 请求
            │ POST /api/v3/kube/workload/deployment
            ↓
        [service/kube/workload.go] CreateWorkload(ctx)
            │
            ├─ kind = ctx.Request.PathParameter("kind")  → "deployment"
            │
            ├─ kind.Validate() [L36]
            │     switch "deployment" {…} → return nil ← 合法
            │
            ├─ ctx.DecodeInto(&req)
            │     req = WlCreateOption{Kind:"deployment", BizID:2, Data:[{NamespaceID:18, Name:"payment", Labels:{app:"pay"}}]}
            │
            ├─ req.Validate()  → 检查 BizID>0, Name 非空, Labels 不超长
            │
            ├─ AuthManager.Authorize(KubeWorkload, Create, BizID=2)
            │     ← 查用户对业务 2 的写权限
            │
            ├─ AutoRunTxn [L62]
            │   │
            │   ↓ createWorkload(kit, kind, req) [L64]
            │       │
            │       ├─ ClientSet.CoreService().Kube().CreateWorkload(ctx, kit.Header, "deployment", req.Data)
            │       │     → coreservice.Kube.CreateWorkload
            │       │     → workloaddao.Create → workloaddao 根据 kind 选表
            │       │     → mongo.Insert(cc_KubeDeployment, doc) → 返回自增 ID [12345]
            │       │
            │       ├─ for idx in req.Data: 回填 ID/SupplierAccount [L91-L97]
            │       │
            │       ├─ audit.GenerateWorkloadAuditLog(auditParam, req.Data, "deployment")
            │       │     → 遍历 req.Data,对每个 Workload 生成变更前/后记录
            │       │
            │       └─ audit.SaveAuditLog(kit, auditLogs...)
            │             → 写入 cc_AuditLog
            │
            └─ ctx.RespEntity({ids:[12345]})
            

三、KubeOperation:跨场景的 "业务编排" 层

3.1 What — KubeOperationInterface 的四大职责

如果只把 k8s 资源当 inst 增删改查,那所有逻辑都能直接放在 service 层。但 3.14.6 单独抽出 src/scene_server/topo_server/logics/kube/kube.go,定义 KubeOperationInterface,统一编排 "跨 service 的复合操作" :

// src/scene_server/topo_server/logics/kube/kube.go 第 40-50 行
    type KubeOperationInterface interface {
        // 1. 集群删除(含依赖校验)
        DeleteCluster(kit *rest.Kit, bizID int64, option *types.DeleteClusterOption) error
    
        // 2. 节点批量删除(含前置检查)
        BatchDeleteNode(kit *rest.Kit, bizID int64, option *types.BatchDeleteNodeOption) error
    
        // 3. 节点批量创建
        BatchCreateNode(kit *rest.Kit, data *types.CreateNodesOption, bizID int64) ([]int64, error)
    
        // 4. Pod 批量创建
        BatchCreatePod(kit *rest.Kit, data *types.CreatePodsOption) ([]int64, error)
    
        // 5. shared 集群查询条件生成
        GenSharedClusterListCond(kit *rest.Kit, bizID int64, cond *filter.Expression) (mapstr.MapStr, error)
    
        // 6. shared 命名空间查询条件生成
        GenSharedNsListCond(kit *rest.Kit, objID string, bizID int64, cond *filter.Expression) (mapstr.MapStr, error)
    
        // 7. 共享命名空间跨业务校验
        CheckPlatBizSharedNs(kit *rest.Kit, bizNsMap map[int64][]int64) error
    }

注意方法 5、6 都返回 mapstr.MapStr(MongoDB 过滤条件),不是返回数据——这是非常关键的设计:logics 层只生成查询条件,具体的查询由调用方(service 层)执行。

3.2 Why — 为什么要单独搞一个 KubeOperationInterface?

service 层只关心 HTTP 协议,logics 层处理业务编排

乍一看,BatchDeleteNode 完全可以写在 service/kube/node.go 里。但 CMDB 团队把这种"涉及多步业务校验"的操作抽到 logics/kube/,有三个原因:

  1. service 层的职责要单一:每个 service 函数只做一件事(参数解析、权限校验、调 coreservice、回写响应),不应该在里面写业务编排逻辑("删除前要先检查节点上有没有 Pod")。否则 service 文件会膨胀到几千行,难以维护
  2. 业务编排天然可复用:假设未来有"批量迁移节点"功能,也要走"删除前先检查 Pod"的逻辑,如果写在 service 里就得复制粘贴;写在 logics 里就只是调一个方法
  3. Mock 测试友好:logics 是接口,单元测试时可以 mock 掉 logics,只测试 service 层的参数解析和权限校验

3.3 How — BatchDeleteNode 的"删除前检查"机制

src/scene_server/topo_server/logics/kube/kube.go 第 107-150 行的 BatchDeleteNode 是一个典型的"业务编排"案例:

// src/scene_server/topo_server/logics/kube/kube.go 第 107-150 行
    func (k *kube) BatchDeleteNode(kit *rest.Kit, bizID int64, option *types.BatchDeleteNodeOption) error {
        nodes, err := k.getDeleteNodeInfo(kit, option.IDs, bizID)
        if err != nil {
            return err
        }
    
        if len(nodes) == 0 {
            return nil
        }
    
        // 1、check whether these nodes exist, they must all exist before they can be deleted,
        // otherwise an error will be returned.
        podCond := []map[string]interface{}{
            {
                types.BKNodeIDField: map[string]interface{}{common.BKDBIN: option.IDs},
            },
        }
        // 2、check if there is a pod on the node.
        counts, err := k.clientSet.CoreService().Count().GetCountByFilter(kit.Ctx, kit.Header,
            types.BKTableNameBasePod, podCond)
        if err != nil {
            blog.Errorf("count nodes failed, cond: %#v, err: %v, rid: %s", podCond, err, kit.Rid)
            return kit.CCError.CCErrorf(common.CCErrTopoInstDeleteFailed)
        }
    
        if counts[0] > 0 {
            blog.Errorf("count nodes failed, option: %#v, err: %v, rid: %s", option, err, kit.Rid)
            return kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, errors.New("no pods can exist under the node"))
        }
    
        // 3、batch delete nodes
        err = k.clientSet.CoreService().Kube().BatchDeleteNode(kit.Ctx, kit.Header, &option.BatchDeleteNodeByIDsOption)
        if err != nil {
            blog.Errorf("delete node failed, option: %#v, err: %v, rid: %s", option, err, kit.Rid)
            return err
        }
    
        // ... 审计日志(与上文 workload 类似,省略)
    }

三步走:

  1. 存在性校验getDeleteNodeInfo 根据 ID 查节点是否存在,同时校验所有节点都在同一个 bizID 下(防止误删别业务的节点)
  2. 依赖性校验Count().GetCountByFiltercc_KubePod 表里 bk_node_id IN option.IDs 的记录数。如果大于 0,说明这些 Node 上还有 Pod,直接返回错误
  3. 实际删除:调用 coreservice 的 BatchDeleteNode

为什么不在 coreservice 层做这个校验?

理论上,"删除 Node 前必须先驱逐 Pod" 也可以放在 coreservice。但 CMDB 把这种业务校验放在 topo_server(服务层),coreservice 只做纯粹的 CRUD——原因是:业务校验升级频繁(今天要求"无 Pod 才能删",明天可能要"无工作负载才能删"),而 coreservice 是给所有调用方共用的,频繁动会影响面太大。把业务校验放在 service 层,让 coreservice 保持稳定,是经典的"分层保护"思想。

3.4 getDeleteNodeInfo 的"跨业务"防护

更细节一点,看 src/scene_server/topo_server/logics/kube/kube.go 第 67-105 行 getDeleteNodeInfo

// src/scene_server/topo_server/logics/kube/kube.go 第 67-104 行
    func (k *kube) getDeleteNodeInfo(kit *rest.Kit, ids []int64, bizID int64) ([]types.Node, error) {
    
        query := &metadata.QueryCondition{
            Condition: mapstr.MapStr{
                common.BKFieldID: mapstr.MapStr{
                    common.BKDBIN: ids,
                },
            },
            Page: metadata.BasePage{
                Limit: common.BKNoLimit,
            },
        }
        result, err := k.clientSet.CoreService().Kube().SearchNode(kit.Ctx, kit.Header, query)
        if err != nil {
            blog.Errorf("search node failed, filter: %+v, err: %v, rid: %s", query, err, kit.Rid)
            return nil, err
        }
    
        if len(result.Data) == 0 {
            return result.Data, nil
        }
    
        bizMap := make(map[int64]struct{})
        for _, node := range result.Data {
            bizMap[node.BizID] = struct{}{}
        }
    
        if len(bizMap) > 1 {
            blog.Errorf("node ids exist in different businesses, filter: %+v, rid: %s", query, kit.Rid)
            return nil, errors.New("node ids exist in different businesses")
        }
    
        if _, ok := bizMap[bizID]; !ok {
            blog.Errorf("node ids not in biz %d, filter: %+v, rid: %s", bizID, query, kit.Rid)
            return nil, fmt.Errorf("node ids not in biz %d", bizID)
        }
    
        return result.Data, nil
    }

这段做了两层防护

  1. 查到的节点全部必须属于同一个 bizID(len(bizMap) > 1 检查)
  2. 这些节点必须属于当前请求参数里的 bizID(bizMap[bizID] 检查)

这种"双重校验"看似冗余,实则是为了防止 "同一集群多个业务共享" 场景下的越权——如果只校验第 1 层,业务 A 拿到业务 B 的 Node ID 直接调删除 API,仍然能删到;只有两层都校验,才能确保 "传的 bizID 参数" 和 "节点所属的 bizID" 严格一致。

四、shared 集群:跨业务共享 k8s 资源的查询条件生成

4.1 Why — 为什么需要 shared 集群场景?

共享集群是企业的常态,不是特殊场景

很多公司一个物理 k8s 集群会承载多个业务:

  • 公司有 5 个业务,全部部署在同一个生产 k8s 集群上(共享节点、共享网络插件,只是命名空间隔离)
  • 运维同事说:"这个集群是我的,但是里面的 Namespace 1 给业务 A,Namespace 2 给业务 B,你们各自管各自的"
  • 业务 A 在 CMDB 里要查"我业务下有多少集群",结果应该看到 1 个(这个 shared 集群),并能过滤只看自己的 Namespace;业务 B 同理

这就引出一个根本问题:

一个集群实体(Cluster)记录在 CMDB 中只有一行(属于"平台业务"或"运维业务"),但允许多个业务"看到"它,并在它下面创建各自的 Namespace。

如果不支持 shared 场景,每个业务要单独建一个集群记录(集群 ID 不一致),Pod/Node 的关联关系会全部错乱;如果支持 shared,就必须让每个业务的查询能"穿透"集群边界,只看自己有权限的部分。

4.2 What — 共享集群查询条件生成的协议

src/scene_server/topo_server/service/kube/cluster.go 第 51-58 行 SearchClusters

// src/scene_server/topo_server/service/kube/cluster.go 第 51-58 行
    // compatible for shared cluster scenario
    filter, err := s.Logics.KubeOperation().GenSharedClusterListCond(ctx.Kit, searchCond.BizID, searchCond.Filter)
    if err != nil {
        ctx.RespAutoError(err)
        return
    }

关键调用是 GenSharedClusterListCond:传入当前业务 ID 和用户的原始过滤条件,返回一个扩充后的 MongoDB 过滤 map。这个 map 会确保两个条件至少满足其一:

  1. Cluster 本身的 bk_biz_id 等于查询参数里的业务 ID(最常见,独占集群)
  2. Cluster 在共享关系表里有这个业务的关联(shared 集群)

具体实现代码在 src/scene_server/topo_server/logics/kube/kube.go 第 47-48 行声明,本篇不展开,但核心是 GenSharedClusterListCond / GenSharedNsListCond 这对方法构成了 "shared 场景的查询条件协议" :

// src/scene_server/topo_server/logics/kube/kube.go 第 47-48 行
    GenSharedClusterListCond(kit *rest.Kit, bizID int64, cond *filter.Expression) (mapstr.MapStr, error)
    GenSharedNsListCond(kit *rest.Kit, objID string, bizID int64, cond *filter.Expression) (mapstr.MapStr, error)

为什么是 objID(Cluster/Namespace 等对象类型)参数?因为 shared 逻辑对每种 kube 资源都适用(Cluster、Namespace 都有 shared 场景),用 objID 参数化。

4.3 How — shared 条件是怎么被 service 使用的

src/scene_server/topo_server/service/kube/cluster.go 第 51-89 行:

// src/scene_server/topo_server/service/kube/cluster.go 第 51-89 行(精选)
    func (s *service) SearchClusters(ctx *rest.Contexts) {
        searchCond := new(types.QueryClusterOption)
        if err := ctx.DecodeInto(searchCond); err != nil {
            ctx.RespErrorCodeOnly(common.CCErrCommJSONUnmarshalFailed, "")
            return
        }
        if cErr := searchCond.Validate(); cErr.ErrCode != 0 {
            ctx.RespAutoError(cErr.ToCCError(ctx.Kit.CCError))
            return
        }
    
        // 权限校验
        authRes := acmeta.ResourceAttribute{...}
        if resp, authorized := s.AuthManager.Authorize(ctx.Kit, authRes); !authorized {
            ctx.RespNoAuth(resp)
            return
        }
    
        // 【关键】生成 shared-aware 的过滤条件
        filter, err := s.Logics.KubeOperation().GenSharedClusterListCond(ctx.Kit, searchCond.BizID, searchCond.Filter)
        if err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        // 计数分支
        if searchCond.Page.EnableCount {
            cond := []map[string]interface{}{filter}
            counts, err := s.ClientSet.CoreService().Count().GetCountByFilter(ctx.Kit.Ctx, ctx.Kit.Header,
                types.BKTableNameBaseCluster, cond)
            if err != nil {
                blog.Errorf("count cluster failed, cond: %#v, err: %v, rid: %s", filter, err, ctx.Kit.Rid)
                ctx.RespAutoError(err)
                return
            }
            ctx.RespEntityWithCount(counts[0], make([]types.Cluster, 0))
            return
        }
    
        // 列表分支
        query := &metadata.QueryCondition{
            Condition:      filter,
            Page:           searchCond.Page,
            Fields:         searchCond.Fields,
            DisableCounter: true,
        }
        result, err := s.ClientSet.CoreService().Kube().SearchCluster(ctx.Kit.Ctx, ctx.Kit.Header, query)
        if err != nil {
            blog.Errorf("search cluster failed, err: %v, rid: %s", err, ctx.Kit.Rid)
            return
        }
        ctx.RespEntityWithCount(0, result.Data)
    }

注意几个细节:

  1. shared 条件生成在权限校验之后——权限没过的话直接 403,不会泄露数据
  2. 计数和列表两条分支共用同一个 filter——保证返回的 count 和数据列表是匹配的
  3. DisableCounter: true——列表分支自己已经走 coreservice 拿 count,关闭 coreservice 内部的 count 避免重复查询

4.4 shared 在 Pod 查询中的体现

同样的模式也用在 Pod 上(src/scene_server/topo_server/service/kube/pod.go 第 56-66 行):

// src/scene_server/topo_server/service/kube/pod.go 第 56-66 行
    // compatible for shared cluster scenario
    podIDCond := filtertools.GenAtomFilter(common.BKFieldID, filter.In, req.PodIDs)
    cond, err := s.Logics.KubeOperation().GenSharedNsListCond(ctx.Kit, types.KubePod, req.BizID, podIDCond)
    if err != nil {
        ctx.RespAutoError(err)
        return
    }

关键差别:Pod 这边调的是 GenSharedNsListCond(共享命名空间列表条件),因为 Pod 在 shared 集群下是用 Namespace 隔离的。

五、kube/orm:反射驱动的 "只更新指定字段"

5.1 Why — 为什么需要 "只更新指定字段" ?

标准 inst 的更新是"全字段替换",但 k8s 资源天然是"局部更新"

CMDB 标准 inst 的更新语义是:你给我什么,我就存什么——前端的 PATCH 请求里只带修改过的字段,后端直接覆盖整个文档。

但 k8s 资源特别多:

  • Deployment 有 30+ 字段:replicas、selector、template.spec.containers[]、strategy……
  • Pod 更夸张:spec.containers[] 里每个容器有 image、env、ports、resources、volumeMounts 等几十个字段

如果让前端一次性把整个资源回传给后端更新,会引出一堆问题:

  1. 网络开销大:每次 PATCH 一个字段都得回传 30+ 字段,几 KB 的请求
  2. 并发更新丢失:用户 A 改 replicas,用户 B 几乎同时改 image,后端只能存一份——A 的更新把 B 的更新覆盖了
  3. 字段保护难:有些字段不允许前端改(bk_idcreate_time),全量覆盖就得在 service 层每次写 10 行白名单

所以 3.14.6 用反射做了 "只提取赋值过的字段" + "可选黑名单字段" 的工具。

5.2 What — GetUpdateFieldsWithOption 的 56 行代码

src/kube/orm/tool.go 第 28-56 行的 GetUpdateFieldsWithOption

// src/kube/orm/tool.go 第 28-56 行
    func GetUpdateFieldsWithOption(data interface{}, opts *FieldOption) (map[string]interface{}, error) {
        if data == nil {
            return nil, errors.New("can not get update fields with option, data is nil")
        }
    
        if opts == nil {
            return nil, errors.New("can not get update fields with option, opts is nil")
        }
    
        updateFields, err := getUpdateFields(data)
        if err != nil {
            return nil, err
        }
    
        toUpdate := make(map[string]interface{})
        for tag, value := range updateFields {
            if opts.NeedIgnored(tag) {
                // this is a field which is need to be ignored,
                // which means do not need to be updated.
                continue
            }
    
            toUpdate[tag] = value
        }
    
        return toUpdate, nil
    }

真正干活的 getUpdateFields(第 65-118 行):

// src/kube/orm/tool.go 第 65-118 行(精简)
    func getUpdateFields(v interface{}) (map[string]interface{}, error) {
        if v == nil {
            return map[string]interface{}{}, nil
        }
    
        value := reflect.ValueOf(v)
        if value.Kind() == reflect.Ptr {
            v = value.Elem().Interface()
            value = reflect.ValueOf(v)
        }
    
        if value.Kind() != reflect.Struct {
            return nil, fmt.Errorf("unsupported value type: %v", value.Kind())
        }
    
        kv := make(map[string]interface{})
        for i := 0; i < value.NumField(); i++ {
            field := value.Type().Field(i)
            name := field.Name
            tag := field.Tag.Get("bson")
            if tag == "" {
                return nil, fmt.Errorf("field: %s do not have a 'bson' tag", name)
            }
    
            fieldVal := value.FieldByName(name)
            if tag == ",inline" {
                val := fieldVal.Interface()
                mapper, err := getUpdateFields(val)
                if err != nil {
                    return nil, err
                }
    
                for k, v := range mapper {
                    kv[k] = v
                }
                continue
            }
    
            if fieldVal.Kind() != reflect.Ptr {
                blog.V(4).Infof("field is not pointer type, type: %v, field: %s, skip", fieldVal.Kind(), name)
                continue
            }
    
            if fieldVal.IsNil() {
                blog.V(4).Infof("field %s value is nil, skip", name)
                continue
            }
    
            val := fieldVal.Elem().Interface()
            kv[tag] = val
        }
    
        return kv, nil
    }

这个函数四条规则(注释里也写了):

  1. 输入必须是 struct 或 *struct,其他类型报错
  2. 字段必须有 bson tag,没有 tag 直接报错(防止字段命名混乱)
  3. tag 为 ,inline 的字段会被递归展开(内嵌的 Base struct 会被摊平)
  4. 非指针字段会被跳过(零值无法判断是否"被赋值"过);nil 指针会被跳过(没赋值的字段不更新)

5.3 FieldOption:可配置的黑名单

src/kube/orm/tool.go 第 120-150 行:

// src/kube/orm/tool.go 第 120-150 行
    type FieldOption struct {
        ignored map[string]struct{}
    }
    
    func NewFieldOptions() *FieldOption {
        return &FieldOption{
            ignored: make(map[string]struct{}),
        }
    }
    
    func (f *FieldOption) NeedIgnored(field string) bool {
        _, ok := f.ignored[field]
        return ok
    }
    
    func (f *FieldOption) AddIgnoredFields(fields ...string) *FieldOption {
        for _, one := range fields {
            f.ignored[one] = struct{}{}
        }
    
        return f
    }

这是一个非常典型的builder 模式

opts := orm.NewFieldOptions().
        AddIgnoredFields("bk_id", "create_time", "supplier_account")
    
    updateFields, err := orm.GetUpdateFieldsWithOption(deployment, opts)
    // updateFields 里自动排除了 bk_id、create_time、supplier_account

三个特点:

  • map[string]struct{} 而非 map[string]bool 做集合——struct{} 是零字节类型,纯当占位符,节省内存
  • 每个方法返回 *FieldOption,支持链式调用
  • 变参 fields ...string 支持一次添加多个黑名单字段

5.4 How — 一次更新 Deployment 的真实对比

假设前端要改 Deployment 的 replicas 从 3 到 5,其他字段不变。请求 JSON 是:

{
      "replicas": 5,
      "namespace_id": 18,
      "name": "payment"
    }

经过 GetUpdateFieldsWithOption 处理后:

  1. reflect.ValueOf(data) 拿到 Deployment 的反射对象
  2. 遍历 Deployment 所有字段,只挑赋值过(非 nil)的指针字段
  3. 得到 {"replicas":5, "bk_namespace_id":18, "name":"payment"}(注意 key 是 bson tag,不是字段名)
  4. 再用 FieldOption.AddIgnoredFields("bk_id","create_time") 过滤掉黑名单
  5. 最终下推到 MongoDB 的 $set 操作符是 {"$set":{"replicas":5, "bk_namespace_id":18, "name":"payment"}}

关键差别:没有 $set: {selector:..., template:..., strategy:...} 这些没传的字段——它们在 MongoDB 里保持原值,不会被前端"回写"成零值。

并发更新丢失场景

如果用"全字段替换":

  • 用户 A 在 t=0 改 replicas,后端完整落库
  • 用户 B 在 t=1 改 image,前端 GET 一次(拿到 A 改完的 replicas=5),改 image,再 POST 完整资源回去
  • 但如果 A 改完马上 B 也 GET 一次(拿到的是旧版 replicas=3),B 改完 image 再 POST,replicas 就又被覆盖回 3——A 的更新丢失

GetUpdateFieldsWithOption

  • 用户 B 的请求只下推 $set:image=…,replicas 不在更新范围里,仍然是 5——A 的更新保留

这是 k8s 资源纳管必须用"局部更新"的根本原因。

六、Kube 拓扑关联:Host ↔ Node ↔ Pod 反向追溯

6.1 Why — 为什么需要 "反查拓扑" ?

SRE 故障定位的核心动作:从告警回溯

某天监控告警:"10.0.0.5 这台机器上的 payment pod OOM 重启了"。SRE 第一反应是:

  1. 10.0.0.5 这台机器的 hostname → 在 CMDB 里查 bk_host_id 对应的业务、机房、机柜——但 10.0.0.5 在 k8s 里是 Node!这台机器跑的是 kubelet,不属于传统主机视图
  2. 需要根据 HostID 反查 CMDB 里的 Node 记录、再查到 Node 上漂的所有 Pod、再查到 Pod 属于哪个 Deployment、Deployment 的业务是谁——这才是 SRE 想要的"这台机器的完整画像"
  3. 反过来,告警如果写的是 "Pod payment-7d8c9-xxx 重启失败",SRE 也要能从 PodID 反推到 "它在哪个 Node 上、Node 归哪个业务"

这就是 src/scene_server/topo_server/service/kube/node.goFindNodePathForHostsrc/scene_server/topo_server/service/kube/pod.goFindPodPath 要解决的事。

6.2 What — FindNodePathForHost 的查询路径

src/scene_server/topo_server/service/kube/node.go 第 35-112 行:

// src/scene_server/topo_server/service/kube/node.go 第 35-112 行(精选)
    func (s *service) FindNodePathForHost(ctx *rest.Contexts) {
        req := new(types.HostPathOption)
        if err := ctx.DecodeInto(&req); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if rawErr := req.Validate(); rawErr.ErrCode != 0 {
            ctx.RespAutoError(rawErr.ToCCError(ctx.Kit.CCError))
            return
        }
    
        relation, err := s.getHostNodeRelation(ctx.Kit, req.HostIDs)
        if err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if relation == nil {
            ctx.RespEntity(types.HostPathData{
                Info: []types.HostNodePath{},
            })
            return
        }
    
        // authorize
        authRes := make([]acmeta.ResourceAttribute, len(relation.BizIDs))
        for i, bizID := range relation.BizIDs {
            authRes[i] = acmeta.ResourceAttribute{Basic: acmeta.Basic{Type: acmeta.KubeNode, Action: acmeta.Find},
                BusinessID: bizID}
        }
        if resp, authorized := s.AuthManager.Authorize(ctx.Kit, authRes...); !authorized {
            ctx.RespNoAuth(resp)
            return
        }
    
        bizIDWithName, err := s.getBizIDWithName(ctx.Kit, relation.BizIDs)
        if err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        hostsPath := make([]types.HostNodePath, len(req.HostIDs))
        for outerIdx, hostID := range req.HostIDs {
            nodes := relation.HostWithNode[hostID]
            paths := make([]types.NodePath, 0)
            uniqueMap := make(map[string]struct{})
            for _, node := range nodes {
                clusterID := node.ClusterID
                bizID := node.BizID
                unique := strconv.FormatInt(bizID, 10) + ":" + strconv.FormatInt(clusterID, 10)
                if _, ok := uniqueMap[unique]; ok {
                    continue
                }
                uniqueMap[unique] = struct{}{}
    
                path := types.NodePath{
                    BizID:       bizID,
                    BizName:     bizIDWithName[bizID],
                    ClusterID:   clusterID,
                    ClusterName: relation.ClusterIDWithName[clusterID],
                }
                paths = append(paths, path)
            }
    
            hostsPath[outerIdx] = types.HostNodePath{
                HostID: hostID,
                Paths:  paths,
            }
        }
    
        ctx.RespEntity(types.HostPathData{
            Info: hostsPath,
        })
    }

6.3 三个数据结构

src/kube/types/topo.go

// src/kube/types/topo.go 第 32-79 行
    type HostPathOption struct {
        HostIDs []int64 `json:"ids"`
    }
    
    type HostNodePath struct {
        HostID int64      `json:"bk_host_id"`
        Paths  []NodePath `json:"paths"`
    }
    
    type NodePath struct {
        BizID       int64  `json:"bk_biz_id"`
        BizName     string `json:"biz_name"`
        ClusterID   int64  `json:"bk_cluster_id"`
        ClusterName string `json:"cluster_name"`
    }

注意几个特点:

  • 一次请求支持多 HostIDHostIDs []int64),背后批量查 MongoDB
  • 返回的是 "多对一" 映射——一台机器可能承载多个 kube 集群(很少见但可能),所以 Paths []NodePath 是数组
  • 不返回 Node 本身的字段(IP、CPU、内存)——只返回 ClusterID 和 BizID,让前端知道"这台机器属于哪个业务下的哪个集群",要进一步信息再去查 Node 详情

6.4 How — 完整的反向追溯时序

SRE 输入 主机 bk_host_id=10086
            │
            ↓
        POST /api/v3/kube/find/host/node/path
            │
            ↓
        [service/kube/node.go] FindNodePathForHost(ctx)
            │
            ├─ req = {ids:[10086]}
            │
            ├─ getHostNodeRelation(ctx.Kit, [10086])  [L48]
            │     ↓ 批量查 cc_HostBase + cc_KubeNode(JOIN)
            │     ↓ 返回 { HostWithNode:{10086:[Node{bizID:2,clusterID:8,...}]},
            │                 BizIDs:[2], ClusterIDWithName:{8:"prod-k8s"}, ... }
            │
            ├─ Authorize(KubeNode, Find, BizID=2)  [L68]
            │     ← 注意:用了变长 [L63-L67],因为可能有多个业务
            │
            ├─ getBizIDWithName([2])  [L73]
            │     ↓ 返回 {2:"游戏业务"}
            │
            ├─ 去重:uniqueMap[bizID:clusterID]  [L88-L92]
            │     ← 一台机器虽然只属于一个 Node,但理论上 Node 可能被删了又重建,
            │         导致 relation 列表里有重复记录——这里按 bizID+clusterID 去重
            │
            └─ 返回 [{bk_host_id:10086, paths:[{bk_biz_id:2, biz_name:"游戏业务",
                                                  bk_cluster_id:8, cluster_name:"prod-k8s"}]}]
            

6.5 FindPodPath:反向追溯 Pod 完整链路

src/scene_server/topo_server/service/kube/pod.go 第 36-91 行的 FindPodPath 是更复杂的反向追溯:

// src/scene_server/topo_server/service/kube/pod.go 第 36-91 行(精选)
    func (s *service) FindPodPath(ctx *rest.Contexts) {
        req := new(types.PodPathOption)
        if err := ctx.DecodeInto(req); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if rawErr := req.Validate(); rawErr.ErrCode != 0 {
            ctx.RespAutoError(rawErr.ToCCError(ctx.Kit.CCError))
            return
        }
    
        // authorize
        authRes := acmeta.ResourceAttribute{Basic: acmeta.Basic{Type: acmeta.KubePod, Action: acmeta.Find},
            BusinessID: req.BizID}
        if resp, authorized := s.AuthManager.Authorize(ctx.Kit, authRes); !authorized {
            ctx.RespNoAuth(resp)
            return
        }
    
        // shared 命名空间过滤
        podIDCond := filtertools.GenAtomFilter(common.BKFieldID, filter.In, req.PodIDs)
        cond, err := s.Logics.KubeOperation().GenSharedNsListCond(ctx.Kit, types.KubePod, req.BizID, podIDCond)
        if err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        fields := []string{common.BKFieldID, common.BKAppIDField, types.BKClusterIDFiled, types.BKNamespaceIDField,
            types.NamespaceField, types.RefField}
        query := &metadata.QueryCondition{
            Condition: cond,
            Fields:    fields,
        }
        resp, err := s.ClientSet.CoreService().Kube().ListPod(ctx.Kit.Ctx, ctx.Kit.Header, query)
        if err != nil {
            blog.Errorf("find pod failed, cond: %v, err: %v, rid: %s", query, err, ctx.Kit.Rid)
            ctx.RespAutoError(err)
            return
        }
        if len(resp.Info) == 0 {
            ctx.RespEntity(types.PodPathData{Info: []types.PodPath{}})
            return
        }
    
        paths, rawErr := s.buildPodPaths(ctx.Kit, req.BizID, resp.Info)
        if err != nil {
            ctx.RespAutoError(rawErr)
            return
        }
    
        ctx.RespEntity(types.PodPathData{
            Info: paths,
        })
    }

关键差别:

  • 用的是 GenSharedNsListCond 而不是 GenSharedClusterListCond——因为 Pod 在 shared 集群里靠 Namespace 隔离
  • 先 bulk 查 Pod,再用 buildPodPaths 拼出完整链路(后文)
  • fields 限定只查必要列——Pod 文档可能很大(包含 spec.containers[] 等),限定字段减少数据传输和排序开销

6.6 buildPodPaths:拼出 Pod 完整链路

src/scene_server/topo_server/service/kube/pod.go 第 93-154 行的 buildPodPaths 是个典型的"批量查 + 聚合"模式:

// src/scene_server/topo_server/service/kube/pod.go 第 93-154 行(精简)
    func (s *service) buildPodPaths(kit *rest.Kit, bizID int64, pods []types.Pod) ([]types.PodPath, error) {
        paths := make([]types.PodPath, 0)
        clusterIDs := make([]int64, 0)
        allBizIDs := make([]int64, 0)
    
        // 第一次遍历:收集所有需要的 clusterID 和 bizID
        for _, pod := range pods {
            id := pod.ID
            clusterIDs = append(clusterIDs, pod.ClusterID)
            allBizIDs = append(allBizIDs, pod.BizID)
            // ...
        }
    
        // 批量查 Cluster 名称
        clusterInfo, err := s.findClustersByClusterIDs(kit, clusterIDs)
        if err != nil {
            return nil, err
        }
    
        // 批量查 Node 名称
        nodeInfo, err := s.findNodesByHostIDs(kit, hostIDs)
        // ...
    
        // 批量查 Pod 关联的 Workload
        refInfo, err := s.findWorkloadByWorkloadIDs(kit, workloadIDs)
        // ...
    
        // 第二次遍历:组装最终结果
        for _, pod := range pods {
            path := types.PodPath{
                PodID:       pod.ID,
                BizID:       pod.BizID,
                BizName:     bizNameMap[pod.BizID],
                ClusterID:   pod.ClusterID,
                ClusterName: clusterInfo[pod.ClusterID].Name,
                Namespace:   pod.Namespace,
                NodeID:      pod.HostID,
                NodeName:    nodeInfo[pod.HostID].Name,
                Workload:    refInfo[pod.Ref],
            }
            paths = append(paths, path)
        }
    
        return paths, nil
    }

这是一个三次批量查模式:

  1. 先遍历所有 Pod,收集所有用到的 clusterID、bizID、nodeID、workloadID
  2. IN 操作符分别批量查 Cluster、Node、Workload 三张表(每张表一次查询,避免 N+1)
  3. 第二次遍历 Pod,把查到的名称信息组装到最终的 PodPath 结构里

为什么不用 $lookup JOIN?

MongoDB 4.x 之后支持 $lookup 做跨表 JOIN,看起来更优雅。但 CMDB 选"应用层批量查"是因为:

  • $lookup 在大表关联时性能不稳定,特别是右表无索引时会触发 COLLSCAN + 笛卡尔积
  • 应用层批量查可以用上每张表的精确索引(clusterID、nodeID 都有索引),单表查询可控
  • 出错时定位简单:第 X 张表查失败了,重试这一张表即可;$lookup 出错无法定位

七、shared 校验:跨业务共享的源头控制

7.1 CheckPlatBizSharedNs:防止跨业务误用

共享集群下,Namespace 是隔离边界的源头

shared 集群场景下,GenSharedClusterListCond 是 "查询时" 过滤; "创建/更新时" 仍然要防止一个业务误把 Namespace 录到另一个业务的名下。

这正是 src/scene_server/topo_server/logics/kube/kube.go 第 49 行声明的 CheckPlatBizSharedNs 的作用:

// src/scene_server/topo_server/logics/kube/kube.go 第 49 行声明
    CheckPlatBizSharedNs(kit *rest.Kit, bizNsMap map[int64][]int64) error

它的输入是 map[int64][]int64——key 是业务 ID,value 是该业务的所有 Namespace 列表。

比如业务 A 录了 Namespace [1, 2, 3],业务 B 录了 Namespace [4, 5],那传入 {2:[1,2,3], 3:[4,5]}CheckPlatBizSharedNs 会检查:

  1. Namespace [1, 2, 3] 是否真的属于业务 2(不存在"1 属于业务 3 但被 2 录入"的情况)
  2. Namespace [4, 5] 是否真的属于业务 3
  3. 是否有重名(业务 2 和业务 3 都录了 Namespace "default")
  4. 是否有 Namespace 同时被两个业务认领(极少但理论上可能在误操作下发生)

这样做的根本目的不是"严格一致"——那是底层表设计要保证的——而是"防御性兜底",在写入前把可能的数据污染挡住。

八、整体架构图与总结

8.1 三层架构图

                    HTTP/HTTPS
       POST /api/v3/kube/workload/deployment
       POST /api/v3/kube/find/host/node/path
                          │
                          ▼
  ┌───────────────────────────────────────────────────────────────────────────┐
  │  scene_server/topo_server/service/kube/  — HTTP 路由与权限校验            │
  │      workload.go / cluster.go / node.go / namespace.go / pod.go / public  │
  │      调用 KubeOperation 编排业务校验                                      │
  └───────────────────────────────────────────────────────────────────────────┘
                          │
                          ▼
  ┌───────────────────────────────────────────────────────────────────────────┐
  │  scene_server/topo_server/logics/kube/kube.go  — 业务编排                 │
  │      BatchDeleteNode / BatchCreateNode / BatchCreatePod                   │
  │      GenSharedClusterListCond / GenSharedNsListCond / CheckPlatBizSharedNs│
  └───────────────────────────────────────────────────────────────────────────┘
                          │
                          ▼
  ┌───────────────────────────────────────────────────────────────────────────┐
  │  core_service 调用层                                                       │
  │      s.ClientSet.CoreService().Kube()                                     │
  │      .CreateWorkload / SearchCluster / BatchDeleteNode / ListPod / ...     │
  │      ──→  按 kind 路由到 8 张 Workload 表                                  │
  │      ──→  表名由 WorkloadType.Table() 决定                                │
  └───────────────────────────────────────────────────────────────────────────┘
              │                                          │
              ▼                                          ▼
  ┌──────────────────────────────┐        ┌──────────────────────────────────┐
  │  kube/types/  — 数据结构     │        │  kube/orm/  — 反射工具           │
  │  Cluster / Node / Namespace  │        │  GetUpdateFieldsWithOption       │
  │  Pod / Deployment /          │        │  FieldOption.AddIgnoredFields    │
  │  StatefulSet / DaemonSet /   │        │                                  │
  │  CronJob / Job /             │        │                                  │
  │  GameDeployment / ...        │        │                                  │
  └──────────────────────────────┘        └──────────────────────────────────┘
              │                                          │
              └────────────────────┬─────────────────────┘
                                   ▼
  ┌───────────────────────────────────────────────────────────────────────────┐
  │  coreservice.Kube / workloaddao / clusterdao / nodedao  — MongoDB CRUD    │
  │  cc_KubeDeployment / cc_KubeStatefulSet / cc_KubeCronJob / cc_KubePod /…  │
  └───────────────────────────────────────────────────────────────────────────┘
        

8.2 这一篇讲了哪些源码点(按目录索引)

源码文件核心知识点
src/kube/types/types.go [L53-L130] WorkloadType 四个核心方法:Validate/Table/Fields/NewInst
src/kube/types/workload.go WorkloadInterface 接口,统一的 GetWorkloadBase/SetWorkloadBase
src/kube/types/deployment.go 8 种 Workload 类型各自的字段集定义
src/kube/types/cluster.go / node.go / namespace.go CRUD 接口数据结构和表名常量
src/kube/types/topo.go 拓扑关联结构(HostPathOption / NodePath / PodPath
src/kube/types/k8s_types.go / spec.go k8s 公共字段(Labels / Spec / 资源规格)
src/kube/orm/tool.go [L28-L150] GetUpdateFieldsWithOption + FieldOption 反射字段提取 + 黑名单
src/scene_server/topo_server/service/kube/service.go service 构造函数 + 注册到主 service 实例
src/scene_server/topo_server/service/kube/workload.go [L33-L111] CreateWorkload / createWorkload 事务 + 审计回填
src/scene_server/topo_server/service/kube/cluster.go [L51-L89] SearchClusters + shared 集群过滤
src/scene_server/topo_server/service/kube/node.go [L35-L112] FindNodePathForHost + 跨 bizID 校验
src/scene_server/topo_server/service/kube/pod.go [L36-L154] FindPodPath + buildPodPaths 三次批量查
src/scene_server/topo_server/service/kube/namespace.go Namespace CRUD + shared ns 过滤
src/scene_server/topo_server/logics/kube/kube.go [L40-L150] KubeOperationInterface 编排 + shared 协议 + 删除前置校验

8.3 给 SRE 的话:怎么用好这套机制

💡 三条落地建议

  1. 新加 Workload 类型时,先在 src/kube/types/ 加结构体 + 字段集,再在 WorkloadType 的四个 switch 里加分支,service 层一行代码不用改——这就是接口抽象的红利
  2. 所有写操作都用 GetUpdateFieldsWithOption:哪怕只改一个字段,也走"反射提取 + 黑名单过滤"路径,并发安全由 MongoDB 的 $set 操作符兜底
  3. 跨业务场景必须走 shared 协议:查询用 GenSharedClusterListCond / GenSharedNsListCond,写入前用 CheckPlatBizSharedNs——任何一个业务误把 Namespace 录错,整个 CMDB 的视图就乱了

九、下一篇预告

纳管了 k8s 资源之后,下一个 SRE 关心的问题就是 "跨层反向追溯"

告警说 "host 10086 上的 payment pod 重启",SRE 要从 Host → Node → Pod → Workload → Namespace → Cluster → Biz 全链路拼出来。

下一篇 #18:容器拓扑关联 — Pod ↔ Host ↔ 机柜全链路映射,会详细拆解 src/scene_server/topo_server/service/kube/ 中 FindNodePathForHost / FindPodPath 的完整反向追溯链,以及 cc_KubeHostRelationship 等关联表的设计。


posted @ 2026-07-07 19:57  左扬  阅读(4)  评论(0)    收藏  举报