蓝鲸 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 这些。"
你一看需求,脑子里立刻冒出一堆问题:
- k8s 资源类型那么多(Pod、Deployment、StatefulSet、DaemonSet、CronJob……),每种资源的字段都不一样,难道每种都要写一套 CRUD?
- 很多公司一个物理 k8s 集群是多个业务共用的(共享集群),但 CMDB 的业务是隔离的,资源怎么"打标签"?
- Node 上漂移着 Pod,Pod 又属于 Deployment,Deployment 属于命名空间,命名空间又属于集群——如果 CMDB 不能反向从 HostID 找出 Node、从 PodID 找出完整链路,故障定位就还是割裂的。
- 更新 Deployment 时,更新字段那么多(replicas、image、labels 几十个字段),后端到底更新了哪些?万一漏了某个字段,前端看不到变化。
这就是 3.14.6 中 src/kube/ 这套模块要解决的问题。本文会逐一拆解。
一、k8s 资源纳管的设计哲学:复用 inst 链路 + k8s 专属增强
1.1 Why — 为什么不直接复用标准拓扑的 inst 模型?
CMDB 资源纳管的本质矛盾:标准的 inst 增删改查 vs k8s 资源的多类型语义
CMDB 现有的 "业务-集群-模块-主机" 四级模型(标准拓扑),把每种对象都当作同一类 Inst 处理,差异由 ObjectID(对象 ID)区分。这种模型很优雅,但它隐含了三个假设:
- 对象名是有限的、可枚举的(业务、集群、模块、主机、Set……)
- 每种对象只有一个 "表" (主机只有 cc_HostBase)
- 对象之间是严格的层级关系,不会出现同一种"对象"有多种子类型
但 k8s 不一样:
- Workload 不是一种对象,而是 Deployment / StatefulSet / DaemonSet / CronJob / Job / GameDeployment / GameStatefulSet / PodWorkload 至少 8 种,每种都有自己的表(cc_KubeDeployment、cc_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() 入口 | WorkloadType、Cluster、Node、Namespace、Pod、各类 Deployment 字段集 |
| kube ORM 工具 | src/kube/orm/ | 基于反射的字段提取工具,只导出已赋值字段给 MongoDB | GetUpdateFieldsWithOption、FieldOption.AddIgnoredFields |
| topo_server service 层 | src/scene_server/topo_server/service/kube/ | k8s 资源的 HTTP 入口、权限校验、事务编排、审计日志 | CreateWorkload、FindPodPath、FindNodePathForHost、SearchClusters |
这种分层跟标准拓扑的 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 没写进去时审计日志也不会写到一半
- 回填 ID(SetWorkloadBase)是用反射回写指针,所以审计日志里能拿到真实的 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_KubeDeployment、cc_KubeStatefulSet、cc_KubeDaemonSet、cc_KubeCronJob、cc_KubeJob、cc_KubeGameDeployment、cc_KubeGameStatefulSet、cc_KubePodsWorkload 共 8 张表
- 每张表的字段集是固定的,可以建精确索引(如 Deployment 上对 replicas 建索引)
- 前端可以针对每种类型用同一套渲染组件(DeploymentFields 一次定义,前后端共用)
接口抽象保证上层代码不被类型差异污染
虽然表分开了,但上层 service 不应该感知这种差异——它应该只跟"Workload"打交道。这就是 src/kube/types/workload.go 里 WorkloadInterface 的作用:
// 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
}
注意几个细节:
- data.IDs[idx] 是 coreservice 返回的自增 ID,在 coreservice 写成功后才会有值,所以审计回填必须在 CreateWorkload 返回之后
- 回填用的是接口方法 SetWorkloadBase,不是直接赋值——因为 req.Data[idx] 是接口类型,要更新里面的字段必须走 setter
- 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/,有三个原因:
- service 层的职责要单一:每个 service 函数只做一件事(参数解析、权限校验、调 coreservice、回写响应),不应该在里面写业务编排逻辑("删除前要先检查节点上有没有 Pod")。否则 service 文件会膨胀到几千行,难以维护
- 业务编排天然可复用:假设未来有"批量迁移节点"功能,也要走"删除前先检查 Pod"的逻辑,如果写在 service 里就得复制粘贴;写在 logics 里就只是调一个方法
- 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 类似,省略)
}
三步走:
- 存在性校验:getDeleteNodeInfo 根据 ID 查节点是否存在,同时校验所有节点都在同一个 bizID 下(防止误删别业务的节点)
- 依赖性校验:Count().GetCountByFilter 数 cc_KubePod 表里 bk_node_id IN option.IDs 的记录数。如果大于 0,说明这些 Node 上还有 Pod,直接返回错误
- 实际删除:调用 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
}
这段做了两层防护:
- 查到的节点全部必须属于同一个 bizID(len(bizMap) > 1 检查)
- 这些节点必须属于当前请求参数里的 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 会确保两个条件至少满足其一:
- Cluster 本身的 bk_biz_id 等于查询参数里的业务 ID(最常见,独占集群)
- 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)
}
注意几个细节:
- shared 条件生成在权限校验之后——权限没过的话直接 403,不会泄露数据
- 计数和列表两条分支共用同一个 filter——保证返回的 count 和数据列表是匹配的
- 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 等几十个字段
如果让前端一次性把整个资源回传给后端更新,会引出一堆问题:
- 网络开销大:每次 PATCH 一个字段都得回传 30+ 字段,几 KB 的请求
- 并发更新丢失:用户 A 改 replicas,用户 B 几乎同时改 image,后端只能存一份——A 的更新把 B 的更新覆盖了
- 字段保护难:有些字段不允许前端改(bk_id、create_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
}
这个函数四条规则(注释里也写了):
- 输入必须是 struct 或 *struct,其他类型报错
- 字段必须有 bson tag,没有 tag 直接报错(防止字段命名混乱)
- tag 为 ,inline 的字段会被递归展开(内嵌的 Base struct 会被摊平)
- 非指针字段会被跳过(零值无法判断是否"被赋值"过);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 处理后:
- reflect.ValueOf(data) 拿到 Deployment 的反射对象
- 遍历 Deployment 所有字段,只挑赋值过(非 nil)的指针字段
- 得到 {"replicas":5, "bk_namespace_id":18, "name":"payment"}(注意 key 是 bson tag,不是字段名)
- 再用 FieldOption.AddIgnoredFields("bk_id","create_time") 过滤掉黑名单
- 最终下推到 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 第一反应是:
- 查 10.0.0.5 这台机器的 hostname → 在 CMDB 里查 bk_host_id 对应的业务、机房、机柜——但 10.0.0.5 在 k8s 里是 Node!这台机器跑的是 kubelet,不属于传统主机视图
- 需要根据 HostID 反查 CMDB 里的 Node 记录、再查到 Node 上漂的所有 Pod、再查到 Pod 属于哪个 Deployment、Deployment 的业务是谁——这才是 SRE 想要的"这台机器的完整画像"
- 反过来,告警如果写的是 "Pod payment-7d8c9-xxx 重启失败",SRE 也要能从 PodID 反推到 "它在哪个 Node 上、Node 归哪个业务"
这就是 src/scene_server/topo_server/service/kube/node.go 的 FindNodePathForHost 和 src/scene_server/topo_server/service/kube/pod.go 的 FindPodPath 要解决的事。
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"`
}
注意几个特点:
- 一次请求支持多 HostID(HostIDs []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
}
这是一个三次批量查模式:
- 先遍历所有 Pod,收集所有用到的 clusterID、bizID、nodeID、workloadID
- 用 IN 操作符分别批量查 Cluster、Node、Workload 三张表(每张表一次查询,避免 N+1)
- 第二次遍历 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 会检查:
- Namespace [1, 2, 3] 是否真的属于业务 2(不存在"1 属于业务 3 但被 2 录入"的情况)
- Namespace [4, 5] 是否真的属于业务 3
- 是否有重名(业务 2 和业务 3 都录了 Namespace "default")
- 是否有 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 的话:怎么用好这套机制
💡 三条落地建议
- 新加 Workload 类型时,先在 src/kube/types/ 加结构体 + 字段集,再在 WorkloadType 的四个 switch 里加分支,service 层一行代码不用改——这就是接口抽象的红利
- 所有写操作都用 GetUpdateFieldsWithOption:哪怕只改一个字段,也走"反射提取 + 黑名单过滤"路径,并发安全由 MongoDB 的 $set 操作符兜底
- 跨业务场景必须走 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 等关联表的设计。

浙公网安备 33010602011771号