蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #15:云区域管理:多云/混合云账号与区域管理
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #15:云区域管理:多云/混合云账号与区域管理
对企业 SRE 来说,最难的不是管哪一台主机,而是 "同一个 IP 在两朵云里都出现过" 这种场景:腾讯云 VPC 里有一批机器内网 IP 是 192.168.10.10,AWS VPC 里恰好也有一批机器内网 IP 是 192.168.10.10,还有内网自建的私有云十几台物理机。
如果没有 "云区域" 这一层抽象,光靠 IP 是无法定位 "一台机器到底属于哪个云、哪个 VPC、哪个账号" 的。
蓝鲸 CMDB 的 "云区域(Cloud Area)" 就是为解决这个问题设计的。 它把 bk_cloud_id 作为一级 "命名空间",让bk_host_id + bk_cloud_id成为全球唯一的主机标识;外层再叠加 "云账户(CloudAccount)"和 "云同步任务(CloudSyncTask)",把外部云厂商的资源统一收口。
这篇博文我们不看抽象概念,而是直接看 CMDB 3.14.6 里云区域管理的真实源码:云区域 CRUD如何走事务 + 审计 + IAM 注册、内置区域保护如何防止误删默认管控区域、云账户数据模型如何做云厂商白名单校验、云区域 + 主机云区域字段如何在主机转移时一起改、云同步任务如何把外部云资源落到本地云区域。
src/scene_server/host_server/service/cloudarea.go ← 云区域 CRUD 入口
src/common/metadata/cloud.go ← CloudAccount / CloudArea / CloudSyncTask 元数据
src/common/metadata/hostserver.go ← CloudAreaSearchParam / CloudAreaHostCount
src/source_controller/coreservice/core/host/cloudarea.go ← UpdateHostCloudAreaField / FindCloudAreaHostCount
src/source_controller/coreservice/core/cloud/account.go ← 云账户 CRUD(CreateAccount / SearchAccount / UpdateAccount / DeleteAccount)
src/source_controller/coreservice/core/cloud/validator.go ← 云账户校验(validCreateAccount)
src/common/auditlog/cloud_area.go ← 云区域审计日志
src/common/definitions.go ← BKInnerObjIDPlat / BuiltInCloudAreaIDs / UnassignedCloudAreaID
src/scene_server/cloud_server/cloudsync/cloudsync.go ← 云资源同步(SyncCloudResource)
src/scene_server/cloud_server/cloudsync/hostsyncor.go ← 云主机同步器(HostSyncor.syncCloudHost)
src/ac/iam/adaptor.go ← CloudAreaInstance → SysCloudArea IAM 映射
src/ac/parser/cloud.go ← cloudRelated 权限路由
CloudArea bk_cloud_id CloudAccount CloudVendor CloudSyncTask IAM SysCloudArea AutoRunTxn AWS=1 TencentCloud=2 v3.14.6
必掌握
- 云区域 CRUD 链路:CreatePlat / UpdatePlat / DeletePlat 如何把"实例操作 + 审计日志 + IAM 注册"串成原子事务
- 内置区域保护:BKDefaultDirSubArea + UnassignedCloudAreaID 受 BuiltInCloudAreaIDs 保护,禁止更新/删除默认区域
- 云账户数据模型:CloudAccount 必填字段校验 + SupportedCloudVendors 白名单(仅 AWS="1"、TencentCloud="2")
- 主机云区域字段更新:UpdateHostCloudAreaField 走 bk_cloud_id + bk_host_innerip 唯一性校验防重复
- 云区域主机数查询:FindCloudAreaHostCount 用 goroutine 池并发查 N 个云区域的主机数
了解
- BKInnerObjIDPlat = "plat":云区域在 CMDB 内置对象模型里的 ObjectID
- UnassignedCloudAreaName = "未分配"、UnassignedCloudAreaID = 90000001:系统兜底的"未分配"云区域
- 云区域审计日志 CloudAreaRes 在 IAM 中的资源类型映射 SysCloudArea = "sys_cloud_area"
- ReservedCloudAreaStartID = 90000000 ~ ReservedCloudAreaEndID = 99999999 为 CMDB 保留的云区域 ID 段
目录
一、云区域在 CMDB 架构中的定位
What — 云区域在 CMDB 里扮演什么角色?
在蓝鲸 CMDB 中,CloudArea(管控区域 / 云区域)是bk_host_id之外最重要的"主机命名空间"。它不属于host/set/module 等业务拓扑内的资源,而是一个外层容器型抽象:每个 bk_cloud_id 代表一朵云(或一个网络平面),所有主机都挂在这个 ID 之下。对 SRE 来说,云区域相当于"主机在哪朵云"的归属标签,没有它就无法区分"两台 IP 相同的机器到底在哪朵云上"。
Why — 为什么需要云区域这一层?解决了什么问题?
问题一:同 IP 跨云冲突
不同云厂商通常都使用 10.0.0.0/8、192.168.0.0/16 这些私网网段,没有云区域这层抽象,同一私网 IP 在腾讯云、AWS、私有云都会撞名。bk_host_id + bk_cloud_id 的复合主键确保每台主机全局唯一。
问题二:多账号统一管理
公司通常一个云厂商有多个账号(开发账号、测试账号、生产账号)。CMDB 用 CloudAccount + bk_account_id 隔离,每个云区域绑定一个云账户,CMDB 才能用同一套 API 对接多个云厂商。
问题三:内网云区域兜底
未通过云同步进入 CMDB 的"野生主机"或纯内网机器,CMDB 默认挂到 UnassignedCloudAreaID = 90000001(未分配)的云区域下。这个内置区域不能被删除或更新,保证兜底可用。
没有云区域会发生什么?
- 没有命名空间:主机表里只剩 bk_host_id,跨云 IP 冲突无法定位
- 没有云厂商映射:不知道这台主机来自 AWS 还是腾讯云
- 没有同步对接:云同步任务 CloudSyncTask 没有"目标落点"概念
- 没有兜底区域:未同步进来的主机没有合适的归属位置
读 src/common/metadata/cloud.go 第 347-363 行,CloudArea 是云区域的元数据结构:
// CloudArea:管控区域,CMDB里云区域的元数据定义
// 注意:CloudArea 本身是个 metadata 结构体,作为plat模型实例化存在
type CloudArea struct {
CloudID int64 `json:"bk_cloud_id" bson:"bk_cloud_id"` // 云区域ID,全局唯一
CloudName string `json:"bk_cloud_name" bson:"bk_cloud_name"` // 云区域名称(如"腾讯云-广州一区")
Status string `json:"bk_status" bson:"bk_status"` // 云区域状态
CloudVendor string `json:"bk_cloud_vendor" bson:"bk_cloud_vendor"` // 云厂商标识(AWS=1/TencentCloud=2)
OwnerID string `json:"bk_supplier_account" bson:"bk_supplier_account"` // 开发商ID(多租户隔离)
VpcID string `json:"bk_vpc_id" bson:"bk_vpc_id"` // 关联VPC ID
VpcName string `json:"bk_vpc_name" bson:"bk_vpc_name"` // 关联VPC名称
Region string `json:"bk_region" bson:"bk_region"` // 区域(如ap-guangzhou)
AccountID int64 `json:"bk_account_id" bson:"bk_account_id"` // 关联的云账户ID
Creator string `json:"bk_creator" bson:"bk_creator"` // 创建者(服务端强制赋值)
LastEditor string `json:"bk_last_editor" bson:"bk_last_editor"` // 最后修改者
CreateTime time.Time `json:"create_time" bson:"create_time"` // 创建时间(UTC)
LastTime time.Time `json:"last_time" bson:"last_time"` // 最后修改时间(UTC)
Default int64 `json:"default" bson:"default"` // 是否为默认区域标记
}
这里 CloudID 是云区域 ID,CloudName 是云区域名称。注意它不是直接存表,CMDB 用内置对象模型 plat 来承载云区域,CloudArea 是传输用的结构体,落地存到 cc_PlatBase 集合。
读 src/common/definitions.go 第 132-133 行,可以确认云区域在 CMDB 内置对象模型里的 ObjectID:
// BKInnerObjIDPlat:内置对象ID"plat",云区域在CMDB中作为内置对象存储
// 出现在 coreservice / host_server / cloud_server / api-server 等多处
BKInnerObjIDPlat = "plat"
也就是说,所有云区域的增删改查,最终都走对象模型 plat 的实例化 API(如 CreateInstance、UpdateInstance、DeleteInstance、ReadInstance)。
读 src/common/definitions.go 第 1820-1838 行,看内置云区域的 ID 段和"未分配"区域:
// ReservedCloudAreaIDs:保留段中已使用的ID,新增保留ID需加入此数组
var ReservedCloudAreaIDs = []int64{UnassignedCloudAreaID}
// BuiltInCloudAreaIDs:内置管控区域,删除/更新时被forbidden
// 当前内置两个:BKDefaultDirSubArea=0(默认直连区域)、UnassignedCloudAreaID=90000001(未分配)
var BuiltInCloudAreaIDs = []int64{BKDefaultDirSubArea, UnassignedCloudAreaID}
const (
// 保留段起始/结束ID:CMDB保留 90000000~99999999 给特殊用途
ReservedCloudAreaStartID = 90000000
ReservedCloudAreaEndID = 99999999
// UnassignedCloudAreaID:未分配区域的固定ID
UnassignedCloudAreaID = 90000001
// UnassignedCloudAreaName:未分配区域的名称,显示在UI上
UnassignedCloudAreaName = "未分配"
)
这里 BuiltInCloudAreaIDs 是云区域"内置保护"的关键,下一节会看到 UpdatePlat 用它防止误改默认区域。
源码视角总结:4 层命名空间
- 1 层对象模型:plat(内置 ObjectID),承载所有云区域实例
- 1 个全局 ID:bk_cloud_id 作为云区域唯一标识
- 2 个内置保护:BKDefaultDirSubArea=0 和 UnassignedCloudAreaID=90000001
- 1 个保留段:[90000000, 99999999] 给 CMDB 系统保留
避坑提醒(源码视角):
- 不要硬编码 bk_cloud_id=0 当作"内网云":BKDefaultDirSubArea=0 是默认直连区域,本身有业务含义,不要假设它等同于私有云
- 注意 UnassignedCloudAreaID 是兜底 ID:UpdateHostCloudAreaField 在 src/source_controller/coreservice/core/host/cloudarea.go 第 484 行显式拒绝 CloudID == UnassignedCloudAreaID,避免误把有云的主机改到兜底区域
本节总结:云区域是 CMDB 的"主机命名空间"
- 不是业务拓扑(业务/集群/模块),而是网络拓扑(云/VPC/区域)
- 通过 bk_cloud_id 实现 bk_host_id + bk_cloud_id 全局唯一主机标识
- 通过 BuiltInCloudAreaIDs 实现内置区域保护
- 通过 UnassignedCloudAreaName="未分配" 给野主机提供兜底归属
二、创建链路:CreatePlatBatch 的原子操作
What — 创建链路在做什么?
云区域的创建分两个 API:CreatePlatBatch(批量创建,定义在 src/scene_server/host_server/service/cloudarea.go 第 101-211 行)和 CreatePlat(单次创建,第 216-288 行)。两者都通过 AutoRunTxn 把"实例创建 + 审计日志 + IAM 注册"打包成原子事务,唯一区别是 CreatePlatBatch 用 CreateManyInstance 一次插入多条。
Why — 为什么创建链路要做成原子事务?
问题一:数据一致性
云区域创建涉及三个写操作:向 cc_PlatBase 集合插入实例、生成审计日志、向 IAM 注册资源创建权限。如果中间某一步失败,就会出现"云区域存在但审计缺失"或"IAM 已注册但云区域未创建"的不一致状态。
问题二:审计合规要求
CMDB 的审计日志要求"每个创建操作都有记录"。如果创建云区域成功但审计日志写入失败,从合规角度看这个操作是"半成功",运维无法追溯。
问题三:IAM 权限预注册
云区域在 IAM 里对应 SysCloudArea 资源类型(src/ac/iam/adaptor.go 第 71 行 meta.CloudAreaInstance: SysCloudArea)。创建云区域时同步注册"创建者授权",保证创建者立即拥有该云区域的操作权限。
看 src/scene_server/host_server/service/cloudarea.go 第 101-211 行的完整代码:
// CreatePlatBatch:批量创建云区域,定义在 service/cloudarea.go L102-211
// 链路:JSON解码 → 填充Creator/LastEditor → AutoRunTxn原子事务(创建+审计+IAM注册)
func (s *Service) CreatePlatBatch(ctx *rest.Contexts) {
input := struct {
Data []mapstr.MapStr `json:"data"`
}{} // 匿名结构,只接收一个data数组字段
if err := ctx.DecodeInto(&input); nil != err { // 解码JSON请求体
ctx.RespAutoError(err)
return
}
if len(input.Data) == 0 { // 空数据直接报错
ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrCommHTTPBodyEmpty))
return
}
user := httpheader.GetUser(ctx.Request.Request.Header) // 从HTTP头提取User,注入创建者字段
for i := range input.Data {
input.Data[i][common.BKCreator] = user // 服务端强制覆盖bk_creator
input.Data[i][common.BKLastEditor] = user // 服务端强制覆盖bk_last_editor
}
instInfo := &meta.CreateManyModelInstance{Datas: input.Data} // coreservice通用实例创建参数
// 准备响应数组:每个元素对应一个创建成功的云区域
result := make([]metadata.CreateManyCloudAreaElem, len(input.Data))
// 核心:原子事务包裹"创建+审计+IAM注册"
txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
var err error
// 第一步:调用coreservice批量创建plat实例
res, err := s.CoreAPI.CoreService().Instance().CreateManyInstance(ctx.Kit.Ctx, ctx.Kit.Header,
common.BKInnerObjIDPlat, instInfo) // common.BKInnerObjIDPlat = "plat"
if nil != err {
return ctx.Kit.CCError.CCError(common.CCErrTopoInstCreateFailed)
}
// 检查重复创建 / 异常 / 无创建结果
if len(res.Repeated) > 0 { return ctx.Kit.CCError.CCError(common.CCErrCommDuplicateItem) }
if len(res.Exceptions) > 0 { return ctx.Kit.CCError.New(int(res.Exceptions[0].Code), res.Exceptions[0].Message) }
if len(res.Created) == 0 { return ctx.Kit.CCError.CCError(common.CCErrTopoCloudNotFound) }
// 第二步:组装响应,同时把platIDs传给审计/lambda闭包
platIDs := make([]int64, len(res.Created))
for i, created := range res.Created {
platIDs[i] = int64(created.ID)
result[i] = metadata.CreateManyCloudAreaElem{CloudID: int64(created.ID)}
}
// 第三步:生成审计日志(记录谁创建了哪些云区域)
audit := auditlog.NewCloudAreaAuditLog(s.CoreAPI.CoreService())
generateAuditParameter := auditlog.NewGenerateAuditCommonParameter(ctx.Kit, metadata.AuditCreate)
logs, err := audit.GenerateAuditLog(generateAuditParameter, platIDs)
if err != nil { return err }
if err := audit.SaveAuditLog(ctx.Kit, logs...); err != nil { return err }
// 第四步:向IAM注册plat类型的创建者权限(BatchRegister批量注册)
if auth.EnableAuthorize() {
iamInstances := make([]metadata.IamInstance, len(res.Created))
for index, created := range res.Created {
iamInstances[index] = metadata.IamInstance{
ID: strconv.FormatUint(created.ID, 10), // 转为string,对应IAM要求的实例ID
Name: util.GetStrByInterface(input.Data[created.OriginIndex][common.BKCloudNameField]),
}
}
iamInstancesWithCreator := metadata.IamInstancesWithCreator{
IamInstances: metadata.IamInstances{
Type: string(iam.SysCloudArea), // IAM资源类型:sys_cloud_area
Instances: iamInstances,
},
Creator: user,
}
_, err = s.AuthManager.Authorizer.BatchRegisterResourceCreatorAction(ctx.Kit.Ctx, ctx.Kit.Header,
iamInstancesWithCreator)
if err != nil { return err }
}
return nil
})
if txnErr != nil { ctx.RespAutoError(txnErr); return }
ctx.RespEntity(result)
}
这段代码的调用链非常清晰:
- 参数解码 + 服务端字段注入(第 107-122 行):ctx.DecodeInto 把 JSON 解码到匿名结构,循环注入 bk_creator/bk_last_editor。
- 原子事务执行(第 129-202 行):AutoRunTxn 包裹整个创建逻辑。
- IAM 批量注册(第 178-198 行):auth.EnableAuthorize() 判断总开关,开启时调用 BatchRegisterResourceCreatorAction 一次注册全部新云区域。
一个关键细节:云区域的 audit log 怎么生成?
读 src/common/auditlog/cloud_area.go 第 27-86 行,cloudAreaAuditLog.GenerateAuditLog 会先根据 platIDs 重新查云区域文档,再为每条生成 metadata.AuditLog:
// cloudAreaAuditLog.GenerateAuditLog:为云区域生成审计日志
// 关键:先 ReadInstance 拿真实数据,再为每个 cloudID 构造一条 AuditLog
func (h *cloudAreaAuditLog) GenerateAuditLog(parameter *generateAuditCommonParameter, platIDs []int64) (
[]metadata.AuditLog, error) {
if len(platIDs) == 0 { return make([]metadata.AuditLog, 0), nil } // 空ID直接返回
// 用 IN 查询,拿到所有云区域的最新数据(重新查询避免实例刚创建的数据读不到)
query := &metadata.QueryCondition{
Condition: mapstr.MapStr{
common.BKCloudIDField: mapstr.MapStr{common.BKDBIN: platIDs}, // bk_cloud_id IN platIDs
},
}
res, err := h.clientSet.Instance().ReadInstance(kit.Ctx, kit.Header, common.BKInnerObjIDPlat, query)
// 构建 cloudID → cloudData 映射,便于按ID查找
mutilCloudArea := make(map[int64]mapstr.MapStr)
for _, data := range res.Info {
cloudID, _ := data.Int64(common.BKCloudIDField)
mutilCloudArea[cloudID] = data
}
// 为每个云区域生成一条审计日志
logs := make([]metadata.AuditLog, 0)
for cloudID, cloudData := range mutilCloudArea {
cloudName, _ := cloudData.String(common.BKCloudNameField)
logs = append(logs, metadata.AuditLog{
AuditType: metadata.CloudResourceType, // 审计类型:云资源
ResourceType: metadata.CloudAreaRes, // 资源类型:云区域
Action: parameter.action, // 操作类型:Create/Update/Delete
ResourceID: cloudID, // 资源ID:bk_cloud_id
ResourceName: cloudName, // 资源名称:bk_cloud_name
OperateFrom: parameter.operateFrom,
OperationDetail: &metadata.InstanceOpDetail{
BasicOpDetail: metadata.BasicOpDetail{
Details: parameter.NewBasicContent(cloudData), // 完整数据快照
},
ModelID: common.BKInnerObjIDPlat, // 对象模型ID:plat
},
})
}
return logs, nil
}
注意 AuditType: metadata.CloudResourceType 和 ResourceType: metadata.CloudAreaRes:AuditType 是审计分类(云资源/主机/业务),ResourceType 是具体资源类型(云区域),二者组合给出"云区域操作"的完整审计维度。
本节总结:创建链路的 3 个关键设计
- 1 个事务:AutoRunTxn 保证"创建 + 审计 + IAM 注册"原子性
- 2 个强制字段:bk_creator / bk_last_editor 由服务端强制赋值
- 1 批 IAM 注册:BatchRegisterResourceCreatorAction 一次注册全部新云区域
三、内置区域保护:BuiltInCloudAreaIDs 与 UnassignedCloudAreaID
What — 内置区域保护在做什么?
CMDB 把 BKDefaultDirSubArea=0(默认直连区域)和 UnassignedCloudAreaID=90000001(未分配区域)定义为"内置云区域",任何更新/删除操作都会被拒绝。这条规则集中在 BuiltInCloudAreaIDs = []int64{BKDefaultDirSubArea, UnassignedCloudAreaID},由 src/common/definitions.go 第 1824 行统一管理。
Why — 为什么需要内置区域保护?
问题一:默认区域被改会导致全量数据不显示
每台主机都必须挂在一个云区域上。如果运维误把 bk_cloud_id=0 改名或者删除,所有"默认区域下的主机"在 UI 上立刻查不到,监控告警也会断片。
问题二:未分配区域被改会导致野主机无处可归
云同步过程中如果 UnassignedCloudAreaID=90000001 被改名或删除,新拉来的主机在初始化阶段无法落地,整个云同步链路会受影响。
问题三:保留段 ID 被占用会触发拒收
[90000000, 99999999] 是 CMDB 保留给"特殊用途"的 ID 段。src/source_controller/coreservice/core/instances/instance_crud.go 第 169-179 行的 coreservice 实现中,如果调用 CreateInstance 时传入的 ID 落在保留段,coreservice 会主动拒绝创建并返回错误,避免和未来的内置 ID 撞名。
第一道防线在 UpdatePlat:
读 src/scene_server/host_server/service/cloudarea.go 第 374-466 行,UpdatePlat 解析 bk_cloud_id 后立刻做内置校验:
// UpdatePlat:更新云区域,定义在 service/cloudarea.go L375-466
// 关键检查:内置云区域禁止更新(防止误改默认/未分配区域)
func (s *Service) UpdatePlat(ctx *rest.Contexts) {
// 第一步:从URL路径解析 bk_cloud_id
platIDStr := ctx.Request.PathParameter(common.BKCloudIDField)
platID, err := strconv.ParseInt(platIDStr, 10, 64)
if nil != err {
ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, common.BKCloudIDField))
return
}
// 第二步:内置云区域保护校验 ★
// BuiltInCloudAreaIDs = []int64{BKDefaultDirSubArea, UnassignedCloudAreaID}
if util.ContainsInt(common.BuiltInCloudAreaIDs, platID) {
blog.Infof("UpdatePlat failed, update built in cloud area forbidden, platID:%+v, rid:%s", platID, ctx.Kit.Rid)
ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrTopoUpdateBuiltInCloudForbidden))
return
}
// 第三步:解析请求体(只接受3个字段:CloudName / CloudVendor / Region)
input := struct {
CloudName string `json:"bk_cloud_name"`
CloudVendor string `json:"bk_cloud_vendor"`
Region string `json:"bk_region"`
}{}
if err := ctx.DecodeInto(&input); err != nil { ctx.RespAutoError(err); return }
// 第四步:组装 toUpdate map,只包含非空字段
user := ctx.Kit.User
toUpdate := mapstr.MapStr{
common.BKLastEditor: user, // 最后修改者强制覆盖
}
if len(input.CloudVendor) != 0 { toUpdate[common.BKCloudVendor] = input.CloudVendor }
if len(input.Region) != 0 { toUpdate[common.BKRegion] = input.Region }
if len(input.CloudName) != 0 { toUpdate[common.BKCloudNameField] = input.CloudName }
// 第五步:走 AutoRunTxn 事务执行"更新 + 审计"
audit := auditlog.NewCloudAreaAuditLog(s.CoreAPI.CoreService())
generateAuditParameter := auditlog.NewGenerateAuditCommonParameter(ctx.Kit,
metadata.AuditUpdate).WithUpdateFields(toUpdate) // 审计要记录"哪些字段被改了"
logs, _ := audit.GenerateAuditLog(generateAuditParameter, []int64{platID})
txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
_, err := s.CoreAPI.CoreService().Instance().UpdateInstance(ctx.Kit.Ctx, ctx.Kit.Header,
common.BKInnerObjIDPlat, updateOption)
if err != nil { return err }
if err := audit.SaveAuditLog(ctx.Kit, logs...); err != nil { return err } // 审计持久化
return nil
})
if txnErr != nil { ctx.RespAutoError(txnErr); return }
ctx.RespEntity(nil)
}
这一段最重要的是 util.ContainsInt(common.BuiltInCloudAreaIDs, platID) 这一行:
- 如果传入的 platID 在内置列表里(0 或 90000001),立刻返回 CCErrTopoUpdateBuiltInCloudForbidden 错误
- CMDB 拒绝所有"内置云区域被改名字 / 改云厂商 / 改区域"的请求
- UpdatePlat 同时只允许 3 个字段被改(bk_cloud_name、bk_cloud_vendor、bk_region),不允许通过 API 改 bk_account_id 或 bk_vpc_id——这些只能通过云同步任务来更新
第二道防线在 DeletePlat:
读 src/scene_server/host_server/service/cloudarea.go 第 290-371 行,DeletePlat 的内置保护 + 主机存在校验:
// DeletePlat:删除云区域,定义在 service/cloudarea.go L291-371
// 2道校验:(1) 防止删除默认云区域 (2) 防止删除有主机的云区域
func (s *Service) DeletePlat(ctx *rest.Contexts) {
platID, convErr := strconv.ParseInt(ctx.Request.PathParameter(common.BKCloudIDField), 10, 64)
if nil != convErr { ctx.RespAutoError(...); return }
// 第一道校验:禁止删除默认云区域(bk_cloud_id=0)
if 0 == platID {
blog.Errorf("DelPlat failed, can't delete default cloud area, input:%+v,rid:%s", platID, ctx.Kit.Rid)
ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrDeleteDefaultCloudAreaFail))
return
}
// 第二道校验:先查该云区域下是否还有主机
params := new(meta.QueryInput)
params.Fields = common.BKHostIDField
params.Condition = map[string]interface{}{
common.BKCloudIDField: platID,
}
hostRes, err := s.CoreAPI.CoreService().Host().GetHosts(ctx.Kit.Ctx, ctx.Kit.Header, params)
if nil != err { ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrHostGetFail)); return }
if 0 < hostRes.Count { // 还有主机,不允许删除
blog.Errorf("DelPlat plat [%d] has host data, can not delete,rid:%s", platID, ctx.Kit.Rid)
ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrTopoHasHostCheckFailed))
return
}
// 第三步:权限校验(IAM)
if err := s.AuthManager.AuthorizeByPlatIDs(ctx.Kit.Ctx, ctx.Kit.Header, authmeta.Delete, platID); err != nil {
ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrCommAuthorizeFailed))
return
}
// 第四步:AutoRunTxn 事务执行"删除 + 审计"
audit := auditlog.NewCloudAreaAuditLog(s.CoreAPI.CoreService())
generateAuditParameter := auditlog.NewGenerateAuditCommonParameter(ctx.Kit, metadata.AuditDelete)
logs, _ := audit.GenerateAuditLog(generateAuditParameter, []int64{platID})
delCond := &meta.DeleteOption{
Condition: mapstr.MapStr{common.BKCloudIDField: platID},
}
txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
_, err := s.CoreAPI.CoreService().Instance().DeleteInstance(ctx.Kit.Ctx, ctx.Kit.Header,
common.BKInnerObjIDPlat, delCond)
if err != nil { return ctx.Kit.CCError.Errorf(common.CCErrTopoInstDeleteFailed) }
if err := audit.SaveAuditLog(ctx.Kit, logs...); err != nil { return err }
return nil
})
if txnErr != nil { ctx.RespAutoError(txnErr); return }
ctx.RespEntity(nil)
}
注意 DeletePlat 在 UpdatePlat 的内置白名单校验(util.ContainsInt)之外,又单独加了 if 0 == platID 这道硬编码防线:直接拦截 bk_cloud_id=0 的删除请求,返回 CCErrDeleteDefaultCloudAreaFail。
为什么 UnassignedCloudAreaID=90000001 在 DeletePlat 里没有显式拦截?
因为 DeletePlat 的"先查后删"逻辑(GetHosts)会拦住:未分配区域默认下是没有主机的(野主机进它,但通常业务要立即迁出去)。从源码上看,DeletePlat 实际上仅仅依赖了"内置区域几乎不会有主机"这一现状前提来兜底——这是个隐含假设,并非源码显式校验。
本节总结:内置区域的两道防线
- 更新防线:util.ContainsInt(BuiltInCloudAreaIDs, platID) 拒改内置区域
- 删除防线:if 0 == platID 硬编码拦截默认区域 + 主机存在校验
- 允许字段:bk_cloud_name、bk_cloud_vendor、bk_region(其它字段不允许 API 直改)
四、更新与删除:UpdatePlat / DeletePlat 的差异化设计
What — UpdatePlat / DeletePlat 各自在做什么?
UpdatePlat(src/scene_server/host_server/service/cloudarea.go 第 374-466 行)只允许改 3 个字段(bk_cloud_name、bk_cloud_vendor、bk_region),走 AutoRunTxn 同时记录审计字段变更。DeletePlat(第 291-371 行)在删除前必须先查"该云区域下是否还有主机",走 IAM 权限校验 + 审计持久化。
Why — 为什么 update 和 delete 的设计不同?
问题一:update 只允许非关键字段
云区域的 bk_account_id、bk_vpc_id、bk_vpc_name 都属于"云同步数据",由云同步任务统一管理。如果 API 允许任意改这些字段,会和 CloudSyncTask 数据不一致。UpdatePlat 只开 bk_cloud_name/bk_cloud_vendor/bk_region 三个 UI 可改字段的权限。
问题二:delete 必须先查后删
如果一个云区域下还有主机,删除云区域会导致主机失去挂载点(bk_cloud_id 指向不存在的云区域)。DeletePlat 在删除前必须 GetHosts 确认主机数为 0。
问题三:delete 必须 IAM 校验
删除云区域是高风险操作(破坏性不可逆),必须由 IAM 二次校验用户是否真的拥有删除权限。update 也走 IAM 但权限粒度更低(默认业务编辑权限即可),delete 必须精确到 platID 维度。
读 src/scene_server/host_server/service/cloudarea.go 第 391-427 行,update 路径只组装 3 个允许字段:
// UpdatePlat 字段白名单:bk_cloud_name / bk_cloud_vendor / bk_region
// 禁止通过 UpdatePlat 改 bk_account_id / bk_vpc_id / bk_vpc_name 等云同步字段
if len(input.CloudVendor) != 0 { toUpdate[common.BKCloudVendor] = input.CloudVendor }
if len(input.Region) != 0 { toUpdate[common.BKRegion] = input.Region }
if len(input.CloudName) != 0 { toUpdate[common.BKCloudNameField] = input.CloudName }
// 这意味着:bk_account_id / bk_vpc_id / bk_vpc_name 只能通过云同步任务修改
// 在 CreatePlatBatch / CreatePlat 里,这些字段根本不允许传入
// 在 UpdatePlat 里也不出现 —— 这是双层防御
对比 delete 的"先查主机再删除":
// DeletePlat 的"先查主机"逻辑
params := new(meta.QueryInput)
params.Fields = common.BKHostIDField // 只查主机ID字段,省IO
params.Condition = map[string]interface{}{
common.BKCloudIDField: platID, // 按云区域过滤
}
hostRes, err := s.CoreAPI.CoreService().Host().GetHosts(ctx.Kit.Ctx, ctx.Kit.Header, params)
if nil != err { ctx.RespAutoError(...); return }
// only empty plat could be delete
if 0 < hostRes.Count { // 还有主机
ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrTopoHasHostCheckFailed))
return
}
注意三点设计:
- params.Fields = common.BKHostIDField:只查 bk_host_id 字段,避免加载完整主机数据
- common.BKCloudIDField: platID:精确匹配该云区域下的主机
- if 0 < hostRes.Count:只要 count > 0 就拒绝删除,不区分业务/资源池
关键设计原则:UpdatePlat 走"白名单"(只允许 3 个字段),DeletePlat 走"前置校验"(先查后删)。两个 API 都不会被云同步任务以外的入口污染关键字段。
本节总结:update vs delete 的差异
- update:白名单只允许改 3 个字段 + 内置保护 + 审计字段变更
- delete:硬编码拦截默认区域 + 主机数检查 + IAM 二次校验
- 共享:都走 AutoRunTxn 事务,都用 cloudAreaAuditLog 生成审计
五、云账户:CloudAccount 数据模型与云厂商白名单
What — 云账户在做什么?
云区域和云账户是两层正交抽象:云区域(bk_cloud_id)是"网络平面",云账户(bk_account_id)是"云厂商的身份"。一个云账户下通常承载多个云区域(CloudArea.AccountID 外键保持这个多对一关系),CMDB 用 CloudArea.AccountID 把外层云厂商身份和内层网络平面关联起来。CloudAccount 在 src/common/metadata/cloud.go 第 25-37 行定义。
Why — 为什么需要独立的云账户抽象?
问题一:云厂商多账号管理
同一云厂商通常有多个账号(开发/测试/生产),每个账号下都有自己的 VPC。如果云区域只能属于一个"匿名云厂商",SRE 难以切换云资源归属。云账户显式建一层抽象,让"腾讯云生产账号下的广州一区"这种语义清晰。
问题二:SecretID / SecretKey 安全管理
对接外部云平台 API 必须用 bk_secret_id + bk_secret_key。把凭据独立成 CloudAccount 行,可以做凭据集中管理:CMDB 在 src/scene_server/cloud_server/service/account.go 第 114 行的 CreateAccount 路径里调用 s.cryptor.Encrypt(account.SecretKey) 对 SecretKey 做加密存储;删除账户前还能做"该账户下所有同步任务存在性"检查。
问题三:云厂商白名单
CMDB 只对接 AWS 和腾讯云两个云厂商(src/common/metadata/cloud.go 第 87-94 行)。其他云厂商不能凭空创建账户,否则云同步任务会找不到对应的 vendor 插件。
读 src/common/metadata/cloud.go 第 25-37 行,CloudAccount 数据模型:
// CloudAccount:云账户,对应外部云厂商的一个账号
// 必填字段:bk_account_name、bk_cloud_vendor、bk_secret_id、bk_secret_key
type CloudAccount struct {
AccountName string `json:"bk_account_name" bson:"bk_account_name"` // 账户名(业务可见)
CloudVendor string `json:"bk_cloud_vendor" bson:"bk_cloud_vendor"` // 云厂商(AWS=1/TencentCloud=2)
AccountID int64 `json:"bk_account_id" bson:"bk_account_id"` // 服务端生成的唯一ID
SecretID string `json:"bk_secret_id" bson:"bk_secret_id"` // 访问密钥ID
SecretKey string `json:"bk_secret_key" bson:"bk_secret_key"` // 访问密钥Key(存储加密)
Description string `json:"bk_description" bson:"bk_description"` // 描述
OwnerID string `json:"bk_supplier_account" bson:"bk_supplier_account"` // 开发商ID(多租户隔离)
Creator string `json:"bk_creator" bson:"bk_creator"` // 创建者(服务端强制赋值)
LastEditor string `json:"bk_last_editor" bson:"bk_last_editor"` // 最后修改者
CreateTime time.Time `json:"create_time" bson:"create_time"` // 创建时间
LastTime time.Time `json:"last_time" bson:"last_time"` // 最后修改时间
}
这里 AccountID 是服务端生成的,SecretKey 在 API 返回时会被清空(看 src/source_controller/coreservice/core/cloud/account.go 第 53/69 行)。
读 src/common/metadata/cloud.go 第 87-94 行,云厂商枚举和白名单:
// 云厂商常量:和属性表中的 bk_cloud_vendor 值相对应
const (
AWS string = "1" // AWS亚马逊云
TencentCloud string = "2" // 腾讯云
)
// SupportedCloudVendors:支持的云厂商白名单
// 实现了相应云厂商插件的 vendor 才出现在这里
var SupportedCloudVendors = []string{AWS, TencentCloud}
注意:
- AWS = "1"、TencentCloud = "2":数字字符串作为云厂商 ID,不是整型
- SupportedCloudVendors 是[]string 切片:白名单校验时用 util.InStrArr 检查
- 新增云厂商时,需要在 SupportedCloudVendors 加常量 + 在 src/scene_server/cloud_server/cloudvendor/ 加对应插件
读 src/common/metadata/cloud.go 第 39-76 行,CloudAccount.Validate 校验:
// CloudAccount.Validate:云账户字段校验,失败返回 errors.RawErrorInfo
func (c *CloudAccount) Validate() (rawError errors.RawErrorInfo) {
if c.AccountName == "" { // 账户名必填
return errors.RawErrorInfo{
ErrCode: common.CCErrCommParamsNeedSet,
Args: []interface{}{"bk_account_name"},
}
}
if c.CloudVendor == "" { // 云厂商必填
return errors.RawErrorInfo{ErrCode: common.CCErrCommParamsNeedSet, Args: []interface{}{"bk_cloud_vendor"}}
}
if !util.InStrArr(SupportedCloudVendors, c.CloudVendor) { // 云厂商必须在白名单内
return errors.RawErrorInfo{ErrCode: common.CCErrCloudVendorNotSupport} // 错误码:云厂商不支持
}
if c.SecretID == "" { // 访问密钥ID必填
return errors.RawErrorInfo{ErrCode: common.CCErrCommParamsNeedSet, Args: []interface{}{"bk_secret_id"}}
}
if c.SecretKey == "" { // 访问密钥Key必填
return errors.RawErrorInfo{ErrCode: common.CCErrCommParamsNeedSet, Args: []interface{}{"bk_secret_key"}}
}
return errors.RawErrorInfo{}
}
校验有 4 个层次:账户名 → 云厂商 → 厂商白名单 → SecretID/Key 必填。Validate 抛 RawErrorInfo 而非 CCErrorCoder,方便上游 Convert 成对应 HTTP 错误码。
读 src/source_controller/coreservice/core/cloud/account.go 第 27-55 行,CreateAccount 写入路径:
// CreateAccount:创建云账户,定义在 core/cloud/account.go L28-55
func (c *cloudOperation) CreateAccount(kit *rest.Kit, account *metadata.CloudAccount) (*metadata.CloudAccount, errors.CCErrorCoder) {
if err := c.validCreateAccount(kit, account); nil != err { // 业务校验
return nil, err
}
id, err := c.dbProxy.NextSequence(kit.Ctx, common.BKTableNameCloudAccount) // 服务端生成自增ID
if nil != err { return nil, kit.CCError.CCErrorf(common.CCErrCommGenerateRecordIDFailed) }
account.AccountID = int64(id)
ts := time.Now()
account.OwnerID = kit.SupplierAccount // 服务端强制覆盖,强制多租户隔离
account.Creator = kit.User // 服务端强制覆盖CreateUser
account.LastEditor = kit.User // 服务端强制覆盖LastEditor
account.CreateTime = ts
account.LastTime = ts
err = c.dbProxy.Table(common.BKTableNameCloudAccount).Insert(kit.Ctx, account) // 写入 cc_CloudAccount 集合
if err != nil { return nil, kit.CCError.CCError(common.CCErrCommDBInsertFailed) }
// 不返回 bk_secret_key 的值(安全)—— 关键安全设计
account.SecretKey = ""
return account, nil
}
注意两个关键设计:
- util.InStrArr(SupportedCloudVendors, c.CloudVendor):白名单校验,拒绝任何不在 AWS / TencentCloud 中的厂商
- account.SecretKey = "":API 返回前清空 SecretKey,避免凭据泄露到 UI / 日志 / 客户端缓存
读 src/source_controller/coreservice/core/cloud/validator.go 第 26-58 行,validCreateAccount 三层防护:
// validCreateAccount:创建云账户时的业务校验
func (c *cloudOperation) validCreateAccount(kit *rest.Kit, account *metadata.CloudAccount) errors.CCErrorCoder {
// 第一层:云厂商白名单校验
if !util.InStrArr(metadata.SupportedCloudVendors, string(account.CloudVendor)) {
return kit.CCError.CCErrorf(common.CCErrCloudVendorNotSupport)
}
// 第二层:账户名唯一性校验
cond := mapstr.MapStr{common.BKCloudAccountName: account.AccountName}
count, err := c.countAccount(kit, cond)
if count > 0 {
return kit.CCError.CCError(common.CCErrCloudAccountNameAlreadyExist) // 账户名已存在
}
// 第三层:SecretID 全局唯一性校验(一个SecretID只能绑定一个账户)
option := &metadata.SearchCloudOption{
Condition: mapstr.MapStr{common.BKSecretID: account.SecretID},
}
multiAccount, err := c.SearchAccount(kit, option)
if len(multiAccount.Info) > 0 {
return kit.CCError.CCErrorf(common.CCErrCloudAccountSecretIDAlreadyExist, multiAccount.Info[0].AccountName)
}
return nil
}
三层校验分别是:① 云厂商白名单;② 账户名唯一;③ SecretID 全局唯一。CMDB 通过这层防护确保每个云账户的独立性。
本节总结:云账户的 4 层防护
- 1 个白名单:SupportedCloudVendors = []string{AWS, TencentCloud}
- 1 个 Validate:字段必填 + 厂商白名单 + 凭据必填
- 1 个服务端序列:NextSequence 自增 ID
- 1 个安全设计:API 返回前清空 SecretKey
六、主机云区域字段更新:UpdateHostCloudAreaField 的唯一性校验
What — UpdateHostCloudAreaField 在做什么?
主机迁移场景中,经常需要把一批主机的 bk_cloud_id 从一个云区域改到另一个云区域。UpdateHostCloudAreaField 就是专门做这件事的 API,定义在 src/scene_server/host_server/service/cloudarea.go 第 469-505 行(service 层)和 src/source_controller/coreservice/core/host/cloudarea.go 第 30-60 行(coreservice 层)。它在改 bk_cloud_id 之前,会做三层校验:bk_cloud_id 有效性 → 主机存在性 → 目标 bk_cloud_id + bk_host_innerip 唯一性。
Why — 为什么改一个字段要做这么复杂的校验?
问题一:避免 bk_cloud_id 指向不存在的区域
如果业务传一个不存在于 cc_PlatBase 的 bk_cloud_id,主机表里的 bk_cloud_id 会变成野指针,后续查询会出问题。
问题二:避免内网 IP 跨云冲突
同一个 bk_host_innerip 在不同云区域下可能存在。如果跨云改 bk_cloud_id 后,新的 bk_cloud_id + bk_host_innerip 组合会和已有主机冲突。
问题三:避免把主机改到"未分配"区域
UnassignedCloudAreaID = 90000001 是兜底区域,UpdateHostCloudAreaField 显式拒绝改到这个 ID(service 层第 484 行)。
读 src/scene_server/host_server/service/cloudarea.go 第 469-505 行,service 层入口:
// UpdateHostCloudAreaField:更新主机的云区域字段,service层 L470-505
func (s *Service) UpdateHostCloudAreaField(ctx *rest.Contexts) {
rid := ctx.Kit.Rid
// 第一步:解码请求体 { BizID, HostIDs, CloudID }
input := metadata.UpdateHostCloudAreaFieldOption{}
if err := ctx.DecodeInto(&input); err != nil { ctx.RespAutoError(err); return }
// 第二步:单次操作记录数限制(防止批量过大)
if len(input.HostIDs) > common.BKMaxRecordsAtOnce {
ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrExceedMaxOperationRecordsAtOnce,
common.BKMaxRecordsAtOnce))
return
}
// 第三步:兜底区域校验 ★ 服务层先拒绝
if input.CloudID == common.UnassignedCloudAreaID {
ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommParamsIsInvalid, common.BKCloudIDField))
return
}
// 第四步:AutoRunTxn 事务调用 coreservice
txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
ccErr := s.CoreAPI.CoreService().Host().UpdateHostCloudAreaField(ctx.Kit.Ctx, ctx.Kit.Header, input)
if ccErr != nil {
blog.ErrorJSON("update host cloud area failed, input: %s, err: %s, rid: %s", input, ccErr.Error(), rid)
return ccErr
}
return nil
})
if txnErr != nil { ctx.RespAutoError(txnErr); return }
ctx.RespEntity(nil)
}
读 src/source_controller/coreservice/core/host/cloudarea.go 第 30-152 行,coreservice 层的核心逻辑:
// UpdateHostCloudAreaField:coreservice层实现,core/host/cloudarea.go L31-60
// 三层校验链:validCloudID → validHost → 实际更新
func (hm *hostManager) UpdateHostCloudAreaField(kit *rest.Kit,
input metadata.UpdateHostCloudAreaFieldOption) errors.CCErrorCoder {
if len(input.HostIDs) == 0 {
return kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, "bk_host_ids")
}
input.HostIDs = util.IntArrayUnique(input.HostIDs) // 去重,防止同一主机被改两次
if err := validCloudID(kit, input.CloudID); err != nil { // 第一层:目标云区域是否存在
return err
}
if err := validHost(kit, input.CloudID, input.HostIDs); err != nil { // 第二层:主机 + 唯一性校验
return err
}
// 第三层:实际更新 cc_HostBase 表的 bk_cloud_id 字段
updateFilter := map[string]interface{}{
common.BKHostIDField: map[string]interface{}{
common.BKDBIN: input.HostIDs, // bk_host_id IN (input.HostIDs)
},
}
updateDoc := map[string]interface{}{
common.BKCloudIDField: input.CloudID, // SET bk_cloud_id = input.CloudID
}
if err := mongodb.Client().Table(common.BKTableNameBaseHost).Update(kit.Ctx, updateFilter, updateDoc); err != nil {
return kit.CCError.CCError(common.CCErrCommDBUpdateFailed)
}
return nil
}
// validCloudID:校验 bk_cloud_id 是否在 cc_PlatBase 集合中真实存在
// 定义 core/host/cloudarea.go L62-85
func validCloudID(kit *rest.Kit, cloudID int64) errors.CCErrorCoder {
// 通过 hooks 触发第三方校验(可被业务方自定义扩展)
if err := hooks.ValidHostCloudIDHook(kit, cloudID); err != nil { return err }
cloudIDFiler := map[string]interface{}{common.BKCloudIDField: cloudID}
count, err := mongodb.Client().Table(common.BKTableNameBasePlat).Find(cloudIDFiler).Count(kit.Ctx)
if err != nil { return kit.CCError.CCError(common.CCErrCommDBSelectFailed) }
if count == 0 { // 不存在
return kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, common.BKCloudIDField)
}
if count > 1 { return kit.CCError.CCError(common.CCErrCommGetMultipleObject) } // 多个,异常
return nil
}
validHost 在同一文件第 87-152 行做了最复杂的"防重复"校验:
// validHost:校验主机 + 目标云区域的 IP 唯一性
// 定义 core/host/cloudarea.go L88-152
func validHost(kit *rest.Kit, cloudID int64, hostIDs []int64) errors.CCErrorCoder {
// step1. 校验 bk_host_ids 全部存在
hostFilter := map[string]interface{}{
common.BKHostIDField: map[string]interface{}{common.BKDBIN: hostIDs},
}
hostSimplify := make([]metadata.HostMapStr, 0)
fields := []string{common.BKHostInnerIPField, common.BKHostInnerIPv6Field, common.BKCloudIDField,
common.BKHostIDField, common.BKAddressingField} // 仅查这5个字段,省IO
err := mongodb.Client().Table(common.BKTableNameBaseHost).Find(hostFilter).Fields(fields...).All(kit.Ctx, &hostSimplify)
// hostIDs 数量和实际查出来的不一致 → 有主机不存在
if len(hostIDs) != len(hostSimplify) {
return kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, common.BKHostIDField)
}
// step2. 仅对静态 IP(addressing="static")做唯一性校验(动态 DHCP IP 无需校验)
innerIPv4s := make([]string, 0)
ipv4Map := make(map[string]struct{})
innerIPv6s := make([]string, 0)
ipv6Map := make(map[string]struct{})
for _, item := range hostSimplify {
addressing, ok := item[common.BKAddressingField].(string)
if !ok { return kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, common.BKAddressingField) }
if addressing != common.BKAddressingStatic { continue } // 非静态分配,跳过
// 静态IP主机:检查请求体内IP是否重复(同一个 batch 内)
ipv4, ok := item[common.BKHostInnerIPField].(string)
if ok {
if _, ok := ipv4Map[ipv4]; ok {
return kit.CCError.CCErrorf(common.CCErrCommDuplicateItem, common.BKHostInnerIPField)
}
innerIPv4s = append(innerIPv4s, ipv4)
ipv4Map[ipv4] = struct{}{}
}
// IPv6 同样的去重逻辑
ipv6, ok := item[common.BKHostInnerIPv6Field].(string)
if ok {
if _, ok := ipv6Map[ipv6]; ok {
return kit.CCError.CCErrorf(common.CCErrCommDuplicateItem, common.BKHostInnerIPv6Field)
}
innerIPv6s = append(innerIPv6s, ipv6)
ipv6Map[ipv6] = struct{}{}
}
}
// step3. 校验数据库内是否已有同 IP 主机在目标云区域下(即新组合会冲突)
if err := validDuplicatedHostInDB(kit, hostIDs, cloudID, innerIPv4s, innerIPv6s); err != nil {
return err
}
return nil
}
校验链有 3 个步骤:
- step1:主机存在性:传入的主机 ID 必须全部在 cc_HostBase 中查得到
- step2:请求内 IP 去重:同一个 batch 内 IPv4/IPv6 不能重复
- step3:数据库内 IP 冲突:validDuplicatedHostInDB(同文件第 154-202 行)查询目标云区域下是否已经存在相同 IP 的主机
validDuplicatedHostInDB 的关键构造:
// validDuplicatedHostInDB:检查目标云区域下是否已有同IP主机(除自己外)
dbHostFilter := map[string]interface{}{
common.BKAddressingField: common.BKAddressingStatic, // 只查静态IP
common.BKHostIDField: map[string]interface{}{
common.BKDBNIN: hostIDs, // 排除本次要改的主机
},
common.BKCloudIDField: cloudID, // 目标云区域
common.BKDBOR: ipCond, // IP 在列表内
}
为什么用 BKDBNIN(not in)排除 hostIDs?
因为 批量场景里这些 hostIDs 就是本次要改云区域的主机,如果不排除,step2 已经去重过的 IP 还是会被自己的"未改前记录"误命中。排除掉本批次主机,step3 才是干净的"目标云区域下其它主机的 IP"查询。
本节总结:UpdateHostCloudAreaField 的 3 层校验链
- service 层:单次记录上限校验 + 拒绝改到未分配区域
- coreservice 层:validCloudID 校验目标云区域存在性
- 主机校验层:validHost 校验主机存在 + 内网 IP 唯一性
七、云区域主机数查询:FindCloudAreaHostCount 的并发聚合
What — FindCloudAreaHostCount 在做什么?
云区域列表页通常需要显示每个云区域下的主机数。CMDB 提供 FindCloudAreaHostCount,让前端一次请求就能拿到 N 个云区域的主机数。它的实现路径:src/scene_server/host_server/service/cloudarea.go 第 543-572 行(service 层)+ src/source_controller/coreservice/core/host/cloudarea.go 第 204-261 行(coreservice 层)。
Why — 为什么需要并发聚合主机数?
问题一:串行查询太慢
如果串行对 N 个云区域做 Count 查询(每次都是一次独立的 MongoDB Count),前端列表页会卡顿。CMDB 用并发能压缩总耗时。关于单次 Count 耗时具体多少毫秒,受索引命中、数据量、MongoDB 压力等影响,不在本文讨论范围。
问题二:需要对结果按请求顺序排序
前端传的 bk_cloud_ids 列表,CMDB 必须按这个原始顺序返回结果(src/source_controller/coreservice/core/host/cloudarea.go 第 252-258 行的 for idx, cloudID := range input.CloudIDs 用 input.CloudIDs 而不是去重后的 cloudIDs 来组装结果),否则前端需要重新对齐。
问题三:避免压垮 MongoDB
无限制的 goroutine 会把 MongoDB 压垮。FindCloudAreaHostCount 用了一个 pipeline := make(chan bool, 10) 做并发上限,最多同时跑 10 个 goroutine。
读 src/common/metadata/hostserver.go 第 826-854 行,请求/响应模型:
// CloudAreaHostCount:请求体,单次最多 50 个 bk_cloud_ids
type CloudAreaHostCount struct {
CloudIDs []int64 `json:"bk_cloud_ids"` // 要查主机数的云区域ID列表
}
// Validate:云区域数校验,1-50
func (c *CloudAreaHostCount) Validate() (rawError errors.RawErrorInfo) {
maxLimit := 50
if len(c.CloudIDs) == 0 || len(c.CloudIDs) > maxLimit {
return errors.RawErrorInfo{
ErrCode: common.CCErrArrayLengthWrong,
Args: []interface{}{"bk_cloud_ids", maxLimit},
}
}
return errors.RawErrorInfo{}
}
// CloudAreaHostCountResult:响应体
type CloudAreaHostCountResult struct {
BaseResp `json:",inline"`
Data []CloudAreaHostCountElem `json:"data"`
}
// CloudAreaHostCountElem:单个云区域返回的元素
type CloudAreaHostCountElem struct {
CloudID int64 `json:"bk_cloud_id"` // 云区域ID(和请求顺序保持一致)
HostCount int64 `json:"host_count"` // 该云区域下的主机数
}
read src/source_controller/coreservice/core/host/cloudarea.go 第 204-261 行,并发实现:
// FindCloudAreaHostCount:coreservice层实现,core/host/cloudarea.go L205-261
func (hm *hostManager) FindCloudAreaHostCount(kit *rest.Kit, input metadata.CloudAreaHostCount) (
[]metadata.CloudAreaHostCountElem, error) {
if len(input.CloudIDs) == 0 {
return nil, kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, "bk_cloud_ids")
}
cloudIDs := util.IntArrayUnique(input.CloudIDs) // 简单去重
// 关键并发原语:用 channel 当"信号量"限流
var wg sync.WaitGroup
var lock sync.RWMutex
var firstErr errors.CCErrorCoder
pipeline := make(chan bool, 10) // ★ 信号量:最大并发 10
cloudCountMap := make(map[int64]int64) // cloudID → hostCount
for _, cloudID := range cloudIDs {
pipeline <- true // ★ 进入临界区前占用槽位
wg.Add(1)
go func(cloudID int64) {
defer func() {
wg.Done()
<-pipeline // ★ goroutine退出时释放槽位
}()
filter := map[string]interface{}{common.BKCloudIDField: cloudID}
hostCnt, err := mongodb.Client().Table(common.BKTableNameBaseHost).Find(filter).Count(kit.Ctx)
if err != nil {
blog.ErrorJSON("UpdateHostCloudAreaField failed, db selected failed, ...",
common.BKTableNameBaseHost, filter, err.Error(), kit.Rid)
if firstErr == nil { // 只记录第一个错误
firstErr = kit.CCError.CCError(common.CCErrCommDBSelectFailed)
}
return
}
lock.Lock() // 写 map 需要互斥锁
cloudCountMap[cloudID] = int64(hostCnt)
lock.Unlock()
}(cloudID)
}
wg.Wait() // 等所有 goroutine 结束
if firstErr != nil { // 有错误立刻返回
return nil, firstErr
}
// 关键:按原始请求顺序组装结果
ret := make([]metadata.CloudAreaHostCountElem, len(input.CloudIDs))
for idx, cloudID := range input.CloudIDs {
ret[idx] = metadata.CloudAreaHostCountElem{
CloudID: cloudID,
HostCount: cloudCountMap[cloudID],
}
}
return ret, nil
}
这段代码有 4 个关键设计:
- channel 信号量(第 216 行):pipeline := make(chan bool, 10)。channel 的容量 10 就是最大并发数,pipeline <- true 占槽、<-pipeline 释放槽。
- 只记录第一个错误(第 233-236 行):if firstErr == nil 配合 firstErr 变量,保证只有一个错误被传播。
- 顺序保留(第 252-258 行):遍历 input.CloudIDs 而不是 cloudIDs(去重后的),保证按请求的原始顺序返回。
- 默认值 0(第 256 行):HostCount: cloudCountMap[cloudID] 即使 key 不在 map 里也返回零值,不会 NPE。
Service 层(第 543-572 行)的封装非常简单——主要做 Validate + 透传:
// FindCloudAreaHostCount:service层封装,service/cloudarea.go L544-572
func (s *Service) FindCloudAreaHostCount(ctx *rest.Contexts) {
input := new(metadata.CloudAreaHostCount)
if err := ctx.DecodeInto(input); nil != err { ctx.RespAutoError(err); return }
rawErr := input.Validate() // 校验:1-50个云区域
if rawErr.ErrCode != 0 {
ctx.RespAutoError(rawErr.ToCCError(ctx.Kit.CCError))
return
}
// 调用coreservice
res, err := s.CoreAPI.CoreService().Host().FindCloudAreaHostCount(ctx.Kit.Ctx, ctx.Kit.Header, *input)
if nil != err { ctx.RespAutoError(ctx.Kit.CCError.CCError(common.CCErrCommHTTPDoRequestFailed)); return }
if false == res.Result { ctx.RespAutoError(res.CCError()); return }
ctx.RespEntity(res.Data)
}
注意 rawErr.ToCCError(ctx.Kit.CCError):RawErrorInfo 在 service 层转换为 CCError 才能返回给客户端。
本节总结:FindCloudAreaHostCount 的 4 个设计要点
- 1 个信号量:chan bool 容量 10,限制最大并发数
- 1 个错误去重:firstErr 变量只记首个错误
- 1 个顺序保留:遍历原始 input.CloudIDs 而不是去重后的
- 1 个零值兜底:map 不存在的 key 不会出现 NPE
八、云区域管理的 5 个核心设计原则
读完 src/scene_server/host_server/service/cloudarea.go、src/common/metadata/cloud.go、src/source_controller/coreservice/core/cloud/account.go、src/source_controller/coreservice/core/host/cloudarea.go 这一组源码,可以把云区域管理的设计哲学总结为 5 个原则:
1. 内置 ID 双重保护
BKDefaultDirSubArea=0 和 UnassignedCloudAreaID=90000001 通过 BuiltInCloudAreaIDs 白名单 + if 0 == platID 硬编码双重防线,任何 update/delete 都会被拒绝。
2. 服务端字段强制覆盖
bk_creator、bk_last_editor、bk_supplier_account 都由服务端强制赋值,前端传入的相应字段会被忽略。CMDB 通过这种方式保证操作可追溯。
3. 字段白名单 + 事务审计
云区域更新只能改 3 个字段(bk_cloud_name/bk_cloud_vendor/bk_region),其它字段(如 bk_account_id、bk_vpc_id)只能通过云同步任务更新。所有写操作都走 AutoRunTxn,审计和 IAM 注册不会被跳过。
4. Secret 凭据安全设计
CloudAccount.SecretKey 在 API 返回前被清空(src/source_controller/coreservice/core/cloud/account.go 第 53/69 行)。SecretID 全局唯一校验(一个 SecretID 只能绑定一个账户)。CMDB 通过这两层设计防止凭据泄露。
5. 复合主键 + 并发聚合
bk_host_id + bk_cloud_id 作为全局唯一主机标识。修改云区域字段时要做 bk_cloud_id + bk_host_innerip 唯一性校验防冲突。主机数查询用 channel 信号量限制并发,最多 10 个 goroutine并发查 N 个云区域。
避坑提醒(源码视角):
- 不要硬编码 bk_cloud_id=0 当作"内网云":BKDefaultDirSubArea=0 是默认直连区域,UnassignedCloudAreaID=90000001 才是未分配区域
- 不要试图改 bk_account_id:它只能由云同步任务 CloudSyncTask 来更新,UpdatePlat 不接受这个字段
- 不要在 FindCloudAreaHostCount 中传超过 50 个 bk_cloud_id:Validate 会直接拒绝,最大 50
- 不要忽略 IAM 注册失败的影响:如果 auth.EnableAuthorize() == true 时 IAM 注册失败,整个创建/批量创建事务会回滚
全篇总结:云区域管理的 5 个核心设计
- 2 道防线:BuiltInCloudAreaIDs 白名单 + if 0 == platID 硬编码,护住内置区域
- 3 个允许字段:bk_cloud_name / bk_cloud_vendor / bk_region,其它字段只由云同步任务更新
- 1 个白名单:SupportedCloudVendors = []string{AWS, TencentCloud},云厂商只能在这两个里选
- 3 层校验链:validCloudID + validHost + validDuplicatedHostInDB,护住 bk_cloud_id 字段更新
- 1 套并发:chan bool 信号量限制 10 并发,FindCloudAreaHostCount 聚合 N 个云区域主机数
FAQ(常见 20 问)
Q1. CMDB 3.14.6 的云区域在哪个数据集合存储?
一句话结论:云区域以内置对象模型 plat 实例的形式存在,物理集合是 cc_PlatBase。
读 src/common/definitions.go 第 132-133 行,BKInnerObjIDPlat = "plat" 是云区域的内置 ObjectID。CRUD 操作最终走 CreateInstance / UpdateInstance / DeleteInstance / ReadInstance,数据落在 coreservice 内置对象实例集合 cc_PlatBase。
Q2. 云区域创建链路的关键原子操作是什么?
一句话结论:CreatePlatBatch 用 AutoRunTxn 把"实例创建 + 审计日志 + IAM 注册"打包成原子事务。
读 src/scene_server/host_server/service/cloudarea.go 第 129-202 行,AutoRunTxn 包裹 CreateManyInstance(批量创建实例)、audit.SaveAuditLog(保存审计日志)、BatchRegisterResourceCreatorAction(批量注册 IAM 创建者权限)三步,任一失败全部回滚。
Q3. CloudArea 数据模型包含哪些字段?
一句话结论:包含 14 个字段,其中 bk_cloud_id/bk_cloud_name/bk_cloud_vendor/bk_account_id/bk_vpc_id/bk_region 等 6 个是核心业务字段。
读 src/common/metadata/cloud.go 第 347-363 行,CloudArea 结构体字段:CloudID、CloudName、Status、CloudVendor、OwnerID、VpcID、VpcName、Region、AccountID、Creator、LastEditor、CreateTime、LastTime、Default。
Q4. CMDB 支持哪些云厂商?
一句话结论:当前仅支持 AWS("1")和腾讯云("2")两个云厂商。
读 src/common/metadata/cloud.go 第 87-94 行,AWS = "1"、TencentCloud = "2"、SupportedCloudVendors = []string{AWS, TencentCloud}。新增云厂商需要在这两个常量 + SupportedCloudVendors 添加,并在 src/scene_server/cloud_server/cloudvendor/ 加对应插件。
Q5. CMDB 内置的"未分配"云区域 ID 是多少?
一句话结论:UnassignedCloudAreaID = 90000001,名称是 "未分配"。
读 src/common/definitions.go 第 1833-1837 行,UnassignedCloudAreaID = 90000001、UnassignedCloudAreaName = "未分配"。这个 ID 在 src/scene_server/host_server/service/cloudarea.go 第 484 行被 UpdateHostCloudAreaField 显式拒绝(不允许把有云的主机改到兜底区域)。
Q6. CMDB 的云区域保留 ID 段是多少?
一句话结论:保留段是 90000000 ~ 99999999,包含 BuiltInCloudAreaIDs = {0, 90000001}。
读 src/common/definitions.go 第 1826-1838 行,ReservedCloudAreaStartID = 90000000、ReservedCloudAreaEndID = 99999999 为保留段。BuiltInCloudAreaIDs = []int64{BKDefaultDirSubArea, UnassignedCloudAreaID} 是内置保护区。
Q7. BuiltInCloudAreaIDs 是干什么用的?
一句话结论:是"内置云区域保护名单",UpdatePlat 用它判断拒绝修改内置区域。
读 src/common/definitions.go 第 1823-1824 行,var BuiltInCloudAreaIDs = []int64{BKDefaultDirSubArea, UnassignedCloudAreaID}。src/scene_server/host_server/service/cloudarea.go 第 385 行 util.ContainsInt(common.BuiltInCloudAreaIDs, platID) 在 UpdatePlat 中检查目标 ID。
Q8. UpdatePlat 允许改哪些字段?
一句话结论:仅允许改 bk_cloud_name、bk_cloud_vendor、bk_region 三字段 + bk_last_editor 四个。
读 src/scene_server/host_server/service/cloudarea.go 第 391-419 行,toUpdate map 只组装这 4 个字段。其它字段(bk_account_id、bk_vpc_id)只能通过云同步任务修改。
Q9. DeletePlat 在删除前必须检查什么?
一句话结论:必须检查 ①不是 bk_cloud_id=0 ②该云区域下没有主机 ③用户有 IAM 删除权限。
读 src/scene_server/host_server/service/cloudarea.go 第 290-371 行,DeletePlat 三道前置校验:硬编码拦截默认区域(第 299 行)、调用 GetHosts 校验主机数为 0(第 320 行)、AuthorizeByPlatIDs IAM 校验(第 327 行)。
Q10. FindManyCloudArea 支持哪些查询方式?
一句话结论:支持精确查询 + name 模糊搜索(正则)+ 默认 bk_cloud_id 排序 + 可选附加同步任务 ID 信息。
读 src/scene_server/host_server/service/cloudarea.go 第 33-98 行,FindManyCloudArea 接收 metadata.CloudAreaSearchParam(含 IsFuzzy 和 SyncTaskIDs),按 IsFuzzy=true 时把字符串字段包成 $regex 模糊匹配。如果 SyncTaskIDs=true 会调 addPlatSyncTaskIDs 把同步任务信息附加到响应。
Q11. UpdateHostCloudAreaField 的三重校验链是什么?
一句话结论:service 层校验业务参数、validCloudID 校验目标云区域存在、validHost 校验主机存在 + 唯一性。
读 src/source_controller/coreservice/core/host/cloudarea.go 第 30-152 行,三层校验调用链:UpdateHostCloudAreaField → util.IntArrayUnique 去重 → validCloudID(第 62 行) → validHost(第 87 行) → validDuplicatedHostInDB(第 154 行)。
Q12. CloudAccount 校验哪些字段?
一句话结论:校验账户名、云厂商、厂商白名单、SecretID、SecretKey 五个必填。
读 src/common/metadata/cloud.go 第 39-76 行,CloudAccount.Validate 5 步:bk_account_name 必填、bk_cloud_vendor 必填、util.InStrArr(SupportedCloudVendors) 白名单、bk_secret_id 必填、bk_secret_key 必填。
Q13. 云账户的 SecretKey 在哪里被清空?
一句话结论:在 CreateAccount 和 SearchAccount 返回前都会被清空。
读 src/source_controller/coreservice/core/cloud/account.go 第 53 行(CreateAccount)和第 68-70 行(SearchAccount 循环中),都执行 account.SecretKey = ""。CMDB 通过这种设计防止 SecretKey 泄露到 API 客户端和 UI。
Q14. FindCloudAreaHostCount 用什么方式做并发?
一句话结论:用 chan bool 当信号量限制最大 10 并发。
读 src/source_controller/coreservice/core/host/cloudarea.go 第 216 行,pipeline := make(chan bool, 10)。每个 goroutine 进入前 pipeline <- true 占槽位、退出时 <-pipeline 释放。这样避免无限制 goroutine 把 MongoDB 压垮。
Q15. FindCloudAreaHostCount 单次最多支持几个云区域?
一句话结论:最多 50 个,由 CloudAreaHostCount.Validate 强制校验。
读 src/common/metadata/hostserver.go 第 832-842 行,maxLimit := 50。超过 50 会返回 CCErrArrayLengthWrong 错误,超出范围的批量调用会被 coreservice 层主动拦截。
Q16. 云区域审计日志的 AuditType 是什么?
一句话结论:AuditType: metadata.CloudResourceType,ResourceType: metadata.CloudAreaRes。
读 src/common/auditlog/cloud_area.go 第 69-82 行,cloudAreaAuditLog.GenerateAuditLog 构造 metadata.AuditLog 时用 CloudResourceType 作审计分类、CloudAreaRes 作资源类型、ModelID: common.BKInnerObjIDPlat 表示对象模型 ID。
Q17. 主机云区域和云账户的对应关系是什么?
一句话结论:CloudArea.AccountID 字段外键关联 CloudAccount.AccountID,一个云账户可被多个云区域共享。
读 src/common/metadata/cloud.go 第 357 行,AccountID int64 在 CloudArea 里是外键。多对一关系:多个 bk_cloud_id 可以共享同一个 bk_account_id,CMDB 用 bk_cloud_vendor + bk_account_id 双键定位具体云厂商身份。
Q18. CloudArea 和 CloudSyncTask 的关系是什么?
一句话结论:CloudSyncTask 通过 SyncVpcs[].CloudID 字段指向具体云区域。addPlatSyncTaskIDs 反向查询补齐字段。
读 src/scene_server/host_server/service/cloudarea.go 第 507-541 行,addPlatSyncTaskIDs 通过 SearchSyncTask 反向查询所有同步任务,按 task.SyncVpcs[].CloudID 构建 cloudIDTasks[cloudID] = []int64{taskID} 映射,再写回响应。
Q19. 云区域创建事务里 IAM 注册的资源类型是什么?
一句话结论:iam.SysCloudArea = "sys_cloud_area",中文名"管控区域"。
读 src/ac/iam/types.go 第 209-210 行和 src/ac/iam/initial_resources.go 第 30 行,SysCloudArea TypeID = "sys_cloud_area",ResourceTypeIDMap[SysCloudArea] = "管控区域"。src/ac/iam/adaptor.go 第 71 行 meta.CloudAreaInstance: SysCloudArea 做对象模型到 IAM 类型的映射。
Q20. BuiltInCloudAreaIDs 如何扩展新的内置 ID?
一句话结论:在 src/common/definitions.go 第 1823-1824 行的数组里追加新 ID 常量。
读 src/common/definitions.go 第 1823 行注释"内置管控区域,后续如果有其他新增的 id,需要添加到这个数组里"。新增内置区域需要:① 在 definitions.go 常量区加新常量;② 在 BuiltInCloudAreaIDs 数组里追加;③ 在 admin_server/upgrader/ 加数据迁移脚本(参考 src/scene_server/admin_server/upgrader/y3.13.202404221100/add_unassigned_cloud_area.go)。
FAQ 全篇总纲
本 FAQ 覆盖了 CMDB 3.14.6 云区域管理的 5 大维度:
- 数据模型与内置常量:Q1(数据集合)、Q3(CloudArea 字段)、Q4(云厂商枚举)、Q5(未分配 ID)、Q6(保留 ID 段)、Q7(BuiltInCloudAreaIDs)
- 创建与更新:Q2(创建原子事务)、Q8(UpdatePlat 允许字段)、Q10(查询方式)
- 删除与修改:Q9(DeletePlat 检查)、Q11(UpdateHostCloudAreaField 校验链)、Q17(AccountID 外键)
- 云账户与白名单:Q12(CloudAccount 校验)、Q13(SecretKey 清空)、Q16(审计日志 AuditType)、Q19(IAM SysCloudArea)
- 并发聚合与扩展:Q14(并发信号量)、Q15(最大上限)、Q18(CloudSyncTask 关系)、Q20(BuiltInCloudAreaIDs 扩展)
Roadmap 后续预告
下一篇 #16:拓扑缓存加速 — 业务拓扑秒级查询的 Redis 缓存策略
将带你深入 src/source_controller/cacheservice/cache/topology/topology.go,看 biz-topo-cache 定时刷新业务拓扑到 Redis 的实现、Master 选举 + 分布式锁如何保证只有一个节点执行刷新、并发控制在 5 个 goroutine 的 pipeline := make(chan struct{}, 5) 设计。
后续专题预告:
- #17:k8s 资源纳管 — Pod/Deployment/Service 统一管理
- #18:容器拓扑关联 — Pod ↔ Host ↔ 机柜全链路映射
- #19:主机锁定 — 并发控制与分布式锁
- #20:批量导入导出 — Excel 模板与数据校验

浙公网安备 33010602011771号