蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #08 服务实例管理:进程配置模板与批量部署

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #08 服务实例管理:进程配置模板与批量同步

SRE 在日常运维中最头疼的场景之一,就是大规模服务器集群的进程配置管理。

100 台 Tomcat 服务器要统一修改 JVM 内存参数,每台手工改太慢,而且容易出错——漏改一台就是生产事故。

蓝鲸 CMDB 通过"服务模板 + 进程模板 + 服务实例"三级体系,实现了进程配置的集中管理、模板复用、批量同步。CMDB 作为配置数据库,负责存储和管理配置数据;真正的机器执行(如改配置、重启进程)由作业平台(如 bk-job)调用 CMDB 的配置来完成。

本文从 SRE 痛点出发,深入解析源码中服务实例的创建、同步、编辑和删除全流程。

适用场景说明:本文描述的"服务模板 + 进程模板 + 服务实例"机制,适用范围是物理机 / 虚拟机场景。服务实例最终绑定的是bk_host_id(主机 ID),配置变更通过模板同步接口更新数据库记录,再由作业平台(如 bk-job)登录目标机器执行落地。k8s 场景下,Pod 属于独立的 Kube 模块,走 Cluster → Namespace → Workload → Pod 拓扑,不使用本篇描述的进程模板机制。

服务实例 服务模板 进程模板 批量同步 AutoRunTxn 模板同步

src/scene_server/proc_server/service/serviceinstance.go  ← CreateServiceInstances / upsertProcesses / SyncServiceInstanceByTemplate
    src/scene_server/proc_server/service/servicetemplate.go  ← CreateServiceTemplate
    src/scene_server/proc_server/service/processtemplate.go  ← CreateProcessTemplateBatch
    src/common/metadata/process.go                           ← ServiceInstance / ServiceTemplate / ProcessTemplate / CreateServiceInstanceInput
    src/common/definitions.go                                ← BKMaxUpdateOrCreatePageSize=100 / ServiceTemplateIDNotSet=0

学习重点提示

  • 必须掌握:CreateServiceInstances 的三段式事务链路(校验→创建→审计)
  • 必须掌握:upsertProcesses 中模板场景与非模板场景的分叉处理
  • 必须掌握:SyncServiceInstanceByTemplate 的分批任务调度(hostNum 算法)
  • 必须掌握:ProcessTemplate.Property 锁字段与同步策略的源码实现

一、SRE 场景:为什么需要服务实例管理?

在 CMDB 中,主机是载体,模块是归属,进程配置才是 SRE 真正关心的内容。一台 Web 服务器上跑了 Nginx、Java 两个进程,每个进程有自己的端口、启动命令、工作目录。当业务规模扩展到 100 台服务器时,手工维护每个进程配置的成本极高,而且无法保证一致性。

蓝鲸 CMDB 提出了"服务实例"概念,将进程配置抽象为可复用的模板。SRE 定义一次模板,绑定到模块,所有加入该模块的主机自动生成服务实例记录——配置数据存在 CMDB 中,模板改了,实例记录自动更新。

一个典型的 SRE 场景是:订单服务有 200 台主机,JVM 内存参数需要从 -Xms512m -Xmx1024m 调整为 -Xms1024m -Xmx2048m。在没有服务实例管理的时代,SRE 要登录 200 台机器逐台修改;在 CMDB 中,SRE 只需修改服务模板的进程模板,CMDB 会将所有 200 台主机的服务实例记录自动同步为新值——配置变更在数据库层面完成,审计日志全程记录。 真正的机器执行(如改配置、重启进程)由作业平台(如 bk-job)完成,CMDB 只提供配置源和可视化。

二、核心数据结构:从服务模板到服务实例的模型关系

在源码中,三者的关系非常清晰:服务模板包含进程模板,服务实例是服务模板在特定主机上的实例化。SRE 需要理解的是:改模板不影响已存在的服务实例内容,但可以通过 "同步"接口,将模板的最新状态更新到服务实例的数据库记录。

2.1 三个核心结构体

src/common/metadata/process.go ,可以看到三个核心结构体:

// ServiceInstance 服务实例:服务模板在具体主机上的实例化
    // process.go L2103-2118
    type ServiceInstance struct {
        BizID  int64           `field:"bk_biz_id" json:"bk_biz_id" bson:"bk_biz_id"`
        ID     int64           `field:"id" json:"id" bson:"id"`
        Name   string          `field:"name" json:"name" bson:"name"`
        Labels selector.Labels `field:"labels" json:"labels" bson:"labels"`
        // 模板ID,创建后不可更改;为0表示该服务实例不是从模板创建的
        ServiceTemplateID int64 `field:"service_template_id" json:"service_template_id" bson:"service_template_id"`
        HostID            int64 `field:"bk_host_id" json:"bk_host_id" bson:"bk_host_id"`
        ModuleID int64 `field:"bk_module_id" json:"bk_module_id" bson:"bk_module_id"`
        Creator         string    `field:"creator" json:"creator" bson:"creator"`
        Modifier        string    `field:"modifier" json:"modifier" bson:"modifier"`
    }
// ServiceTemplate 服务模板:包含多个进程模板的容器
    // process.go L1142-1159
    type ServiceTemplate struct {
        BizID int64 `field:"bk_biz_id" json:"bk_biz_id" bson:"bk_biz_id"`
        ID int64 `field:"id" json:"id" bson:"id"`
        Name string `field:"name" json:"name" bson:"name"`
        // 服务分类:两级分类标签,如"中间件/数据库"或"Web服务/Nginx"
        ServiceCategoryID int64 `field:"service_category_id" json:"service_category_id" bson:"service_category_id"`
        Creator          string    `field:"creator" json:"creator" bson:"creator"`
        Modifier         string    `field:"modifier" json:"modifier" bson:"modifier"`
        CreateTime       time.Time `field:"create_time" json:"create_time" bson:"create_time"`
        LastTime         time.Time `field:"last_time" json:"last_time" bson:"last_time"`
        SupplierAccount  string    `field:"bk_supplier_account" json:"bk_supplier_account" bson:"bk_supplier_account"`
        HostApplyEnabled bool      `field:"host_apply_enabled" json:"host_apply_enabled" bson:"host_apply_enabled"`
    }
// ProcessTemplate 进程模板:单个进程的标准化配置
    // process.go L1218-1234
    type ProcessTemplate struct {
        ID          int64  `field:"id" json:"id" bson:"id"`
        ProcessName string `field:"bk_process_name" json:"bk_process_name" bson:"bk_process_name"`
        BizID       int64  `field:"bk_biz_id" json:"bk_biz_id" bson:"bk_biz_id"`
        ServiceTemplateID int64 `field:"service_template_id" json:"service_template_id" bson:"service_template_id"`
        // 进程属性,包含锁字段和非锁字段
        Property *ProcessProperty `field:"property" json:"property" bson:"property"`
        Creator         string    `field:"creator" json:"creator" bson:"creator"`
        Modifier        string    `field:"modifier" json:"modifier" bson:"modifier"`
        CreateTime      time.Time `field:"create_time" json:"create_time" bson:"create_time"`
        LastTime        time.Time `field:"last_time" json:"last_time" bson:"last_time"`
        SupplierAccount string    `field:"bk_supplier_account" json:"bk_supplier_account" bson:"bk_supplier_account"`
    }

2.2 三者关系图

┌─────────────────────────────────────────────────────────────────────┐
    │                     ServiceTemplate(服务模板)                        │
    │  ID=10, Name="订单服务" , ServiceCategoryID=3                        │
    │  HostApplyEnabled=true                                               │
    │                                                                      │
    │  ┌───────────────────────────────────────────────────────────────┐   │
    │  │  ProcessTemplate(进程模板)                                    │   │
    │  │  ID=101, bk_process_name="java", Property=ProcessProperty{...}    │   │
    │  │  Property 中每个字段有 as_default_value 标志(控制是否锁定):      │   │
    │  │    bk_func_name         as_default_value=true  (锁定,继承模板值)   │   │
    │  │    bk_start_param_regex as_default_value=false (可定制,启动参数可改)  │   │
    │  └───────────────────────────────────────────────────────────────┘   │
    │  ┌───────────────────────────────────────────────────────────────┐   │
    │  │  ProcessTemplate(进程模板)                                    │   │
    │  │  ID=102, bk_process_name="nginx"                                  │   │
    │  └───────────────────────────────────────────────────────────────┘   │
    └─────────────────────────────────────────────────────────────────────┘
                                   │ 模块绑定服务模板
                                   ↓
    ┌─────────────────────────────────────────────────────────────────────┐
    │  Module(模块)  ID=200, Name="订单服务模块", ServiceTemplateID=10   │
    └─────────────────────────────────────────────────────────────────────┘
                                   │ 主机加入模块时实例化
                                   ↓
    ┌─────────────────────────────────────────────────────────────────────┐
    │  ServiceInstance(服务实例) × N                                      │
    │                                                                      │
    │  实例1: ID=1001, HostID=1,   ModuleID=200, ServiceTemplateID=10   │
    │         Name="java-192.168.1.1"                                    │
    │         → 进程: bk_process_name=java, bk_func_name=java                   │
    │                                                                      │
    │  实例2: ID=1002, HostID=2,   ModuleID=200, ServiceTemplateID=10   │
    │         Name="java-192.168.1.2"                                    │
    │         → 进程: bk_process_name=java, bk_func_name=java                   │
    └─────────────────────────────────────────────────────────────────────┘

ProcessProperty 中的 as_default_value 机制

进程模板的 ProcessProperty 中,每个字段有 as_default_value 标志。当 as_default_value=true 时,该字段在服务实例中不可修改,只能在模板层统一改,再通过"同步"推送到所有服务实例。当 as_default_value=false 时,该字段可以在单个服务实例上独立编辑,不影响模板。这是 CMDB 平衡"标准化管控""灵活配置"的核心设计。

三、服务实例创建链路:CreateServiceInstances 三段式事务

CreateServiceInstances 是服务实例的入口接口。它的设计非常清晰——分为参数校验、服务实例创建、审计日志三个独立阶段,全部包裹在AutoRunTxn事务中。任何一个阶段失败,所有变更回滚。

3.1 接口入口

src/scene_server/proc_server/service/serviceinstance.go 第 35-68 行:

// serviceinstance.go L35-68
    func (ps *ProcServer) CreateServiceInstances(ctx *rest.Contexts) {
        input := metadata.CreateServiceInstanceInput{}
        if err := ctx.DecodeInto(&input); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if len(input.Instances) == 0 {
            ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommParamsNeedSet, "service_instance_ids"))
            return
        }
    
        // 上限校验:单次最多创建 100 个服务实例(definitions.go L46)
        if len(input.Instances) > common.BKMaxUpdateOrCreatePageSize {
            ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommXXExceedLimit, "create service instances",
                common.BKMaxUpdateOrCreatePageSize))
            return
        }
    
        var serviceInstanceIDs []int64
        // AutoRunTxn:整个创建流程在一个事务内,任何一步失败全部回滚
        txnErr := ps.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
            var err error
            serviceInstanceIDs, err = ps.createServiceInstances(ctx, input)
            if err != nil {
                return err
            }
            return nil
        })
    
        if txnErr != nil {
            ctx.RespAutoError(txnErr)
            return
        }
        ctx.RespEntity(serviceInstanceIDs)
    }

3.2 createServiceInstances 内部逻辑

src/scene_server/proc_server/service/serviceinstance.go 第 70-131 行:

// serviceinstance.go L70-131
    func (ps *ProcServer) createServiceInstances(ctx *rest.Contexts, input metadata.CreateServiceInstanceInput) ([]int64,
        ccErr.CCErrorCoder) {
    
        if len(input.Instances) == 0 {
            return nil, ctx.Kit.CCError.CCErrorf(common.CCErrCommParamsNeedSet, "instances")
        }
    
        rid := ctx.Kit.Rid
        bizID := input.BizID
        moduleID := input.ModuleID
    
        // 阶段1:参数校验——检查主机是否在业务模块下,模块是否在业务下,是否是内置模块
        module, err := ps.validateCreateServiceInstancesInput(ctx.Kit, input)
        if err != nil {
            return nil, err
        }
    
        // 阶段2:创建服务实例——每个 CreateServiceInstanceDetail 对应一个主机上的服务实例
        serviceInstances := make([]*metadata.ServiceInstance, len(input.Instances))
        for idx, inst := range input.Instances {
            instance := &metadata.ServiceInstance{
                BizID:             bizID,
                Name:              inst.ServiceInstanceName,
                ServiceTemplateID: module.ServiceTemplateID,  // 继承模块绑定的服务模板ID
                ModuleID:          moduleID,
                HostID:            inst.HostID,
            }
            serviceInstances[idx] = instance
        }
    
        // 调用 proc_server 的 CreateServiceInstances API(在 apimachinery 中)
        serviceInstances, err = ps.CoreAPI.CoreService().Process().CreateServiceInstances(ctx.Kit.Ctx, ctx.Kit.Header,
            serviceInstances)
        if err != nil {
            blog.Errorf("create service instances(%+v) failed, err: %v, rid: %s", serviceInstances, err, rid)
            return nil, err
        }
    
        serviceInstanceIDs := make([]int64, 0)
        addedServiceInstances := make([]metadata.ServiceInstance, 0)
        for _, serviceInstance := range serviceInstances {
            serviceInstanceIDs = append(serviceInstanceIDs, serviceInstance.ID)
            addedServiceInstances = append(addedServiceInstances, *serviceInstance)
        }
    
        // 阶段3:写入进程——根据是否有服务模板,走 upsertProcesses
        if err := ps.upsertProcesses(ctx, serviceInstanceIDs, bizID, module.ServiceTemplateID,
            input.Instances); err != nil {
            return nil, err
        }
    
        // 阶段4:生成并保存审计日志
        audit := auditlog.NewSvcInstAudit(ps.CoreAPI.CoreService())
        generateAuditParameter := auditlog.NewGenerateAuditCommonParameter(ctx.Kit, metadata.AuditCreate)
        audit.WithServiceInstance(addedServiceInstances)
        if err := audit.WithProcBySvcInstIDs(generateAuditParameter, bizID, serviceInstanceIDs, nil); err != nil {
            return nil, err
        }
        auditLogs := audit.GenerateAuditLog(generateAuditParameter)
        if err := audit.SaveAuditLog(ctx.Kit, auditLogs...); err != nil {
            return nil, err
        }
    
        return serviceInstanceIDs, nil
    }

3.3 CreateServiceInstanceInput 结构体

src/common/metadata/process.go 第 111-115 行:

// process.go L111-115
    type CreateServiceInstanceInput struct {
        BizID     int64                         `json:"bk_biz_id"`
        ModuleID  int64                         `json:"bk_module_id"`
        Instances []CreateServiceInstanceDetail `json:"instances"`
    }
    
    // process.go L488-494
    type CreateServiceInstanceDetail struct {
        HostID              int64  `json:"bk_host_id"`
        ServiceInstanceName string `json:"service_instance_name"`
        // Processes 参数在"从原始数据创建"场景下使用
        Processes []ProcessInstanceDetail `json:"processes"`
    }
    
    // process.go L496-501
    type ProcessInstanceDetail struct {
        // ProcessTemplateID 标识使用哪个进程模板;若按模板创建则为0
        ProcessTemplateID int64                  `json:"process_template_id"`
        ProcessData       map[string]interface{} `json:"process_info"`
    }

四、进程写入:upsertProcesses 的模板/非模板分叉

upsertProcesses 是服务实例创建中最复杂的函数。它的核心逻辑是:根据 ServiceTemplateID 是否为 0,分叉为两种处理路径。这两种路径对应了 CMDB 服务实例的两种创建模式——"从模板创建"和"直接创建"。

src/scene_server/proc_server/service/serviceinstance.go 第 193-279 行:

// serviceinstance.go L193-279
    func (ps *ProcServer) upsertProcesses(ctx *rest.Contexts, serviceInstanceIDs []int64, bizID int64,
        serviceTemplateID int64, instances []metadata.CreateServiceInstanceDetail) ccErr.CCErrorCoder {
    
        instanceIDsUpdate := make([]int64, 0)
        instanceProcessesUpdateMap := make(map[int64][]metadata.ProcessInstanceDetail)
        for idx, inst := range instances {
            if len(inst.Processes) == 0 {
                continue
            }
    
            svcInstID := serviceInstanceIDs[idx]
            if serviceTemplateID == 0 {
                // 分叉A:无模板场景——直接创建原始进程实例
                createProcInput := &metadata.CreateRawProcessInstanceInput{
                    BizID:             bizID,
                    ServiceInstanceID: svcInstID,
                    Processes:         inst.Processes,
                }
                if _, err := ps.createProcessInstances(ctx, createProcInput); err != nil {
                    blog.ErrorJSON("create process failed, input: %s, err: %s, rid: %s", createProcInput, err, ctx.Kit.Rid)
                    return err
                }
    
                // 如果服务实例名为空,用第一个进程的名称自动填充
                if inst.ServiceInstanceName == "" {
                    err := ps.updateServiceInstanceName(ctx, svcInstID, inst.HostID, inst.Processes[0].ProcessData)
                    if err != nil {
                        blog.ErrorJSON("update service instance name failed, id: %s, hostID: %s, process: %s, err: %s, "+
                            "rid: %s", svcInstID, inst.HostID, inst.Processes[0].ProcessData, err, ctx.Kit.Rid)
                        return err
                    }
                }
            } else {
                // 分叉B:有模板场景——记录需要更新的服务实例,稍后批量更新
                instanceIDsUpdate = append(instanceIDsUpdate, svcInstID)
                instanceProcessesUpdateMap[svcInstID] = inst.Processes
            }
        }
    
        if len(instanceIDsUpdate) == 0 {
            return nil
        }
    
        // 有模板场景:从进程模板关联表中查到每个服务实例对应的进程ID
        relOpt := &metadata.ListProcessInstanceRelationOption{
            BusinessID:         bizID,
            ServiceInstanceIDs: instanceIDsUpdate,
            Page:               metadata.BasePage{Limit: common.BKNoLimit},
        }
        relRes, err := ps.CoreAPI.CoreService().Process().ListProcessInstanceRelation(ctx.Kit.Ctx, ctx.Kit.Header, relOpt)
        if err != nil {
            blog.ErrorJSON("list process relation failed, option: %s, err: %s, rid: %s", relOpt, err, ctx.Kit.Rid)
            return err
        }
    
        // 建立 ProcessTemplateID → (ServiceInstanceID → ProcessID) 的二级映射
        templateID2ProcessID := make(map[int64]map[int64]int64)
        for _, relation := range relRes.Info {
            if templateID2ProcessID[relation.ProcessTemplateID] == nil {
                templateID2ProcessID[relation.ProcessTemplateID] = make(map[int64]int64)
            }
            templateID2ProcessID[relation.ProcessTemplateID][relation.ServiceInstanceID] = relation.ProcessID
        }
    
        // 构造批量更新数据:按 ProcessTemplateID 匹配,找到对应 ProcessID 后更新
        processesUpdate := make([]map[string]interface{}, 0)
        for instanceID, processes := range instanceProcessesUpdateMap {
            for _, proc := range processes {
                if instProcMap, exist := templateID2ProcessID[proc.ProcessTemplateID]; exist {
                    if processID, exist := instProcMap[instanceID]; exist {
                        processData := proc.ProcessData
                        processData[common.BKProcessIDField] = processID  // 补充进程ID用于定位
                        processesUpdate = append(processesUpdate, processData)
                    }
                }
            }
        }
    
        if len(processesUpdate) > 0 {
            input := metadata.UpdateRawProcessInstanceInput{
                BizID: bizID,
                Raw:   processesUpdate,
            }
            if _, err = ps.updateProcessInstances(ctx, input); err != nil {
                blog.ErrorJSON("update process instances failed, input: %s, err: %s, rid: %s", input, err, ctx.Kit.Rid)
                return err
            }
        }
    
        return nil
    }

设计精髓:两种创建模式的数据流差异

无模板创建时,每个进程直接写入数据库,进程与服务实例的关系在创建时确定。有模板创建时,进程信息从模板继承,服务实例只记录引用关系,进程内容由模板通过同步接口更新数据库记录。这是 CMDB"模板驱动"设计哲学的核心体现。

五、服务模板创建:CreateServiceTemplate 的 IAM 注册

服务模板是服务实例管理的顶层实体。SRE 在创建服务模板时,CMDB 不仅写入数据库,还要向 IAM(蓝鲸权限中心)注册资源创建者权限。这保证了后续只有创建者或被授权的人才能操作该服务模板。

src/scene_server/proc_server/service/servicetemplate.go 第 33-80 行:

// servicetemplate.go L33-80
    func (ps *ProcServer) CreateServiceTemplate(ctx *rest.Contexts) {
        option := new(metadata.CreateServiceTemplateOption)
        if err := ctx.DecodeInto(option); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        newTemplate := &metadata.ServiceTemplate{
            BizID:             option.BizID,
            Name:              option.Name,
            ServiceCategoryID: option.ServiceCategoryID,
            SupplierAccount:   ctx.Kit.SupplierAccount,
            HostApplyEnabled:  option.HostApplyEnabled,  // 是否开启主机属性自动应用
        }
    
        var tpl *metadata.ServiceTemplate
        txnErr := ps.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
            var err error
            // 创建服务模板记录
            tpl, err = ps.CoreAPI.CoreService().Process().CreateServiceTemplate(ctx.Kit.Ctx, ctx.Kit.Header, newTemplate)
            if err != nil {
                blog.Errorf("create service template failed, err: %v", err)
                return err
            }
    
            // IAM 注册:向权限中心注册服务模板资源创建者动作
            if auth.EnableAuthorize() {
                iamInstance := metadata.IamInstanceWithCreator{
                    Type:    string(iam.BizProcessServiceTemplate),
                    ID:      strconv.FormatInt(tpl.ID, 10),
                    Name:    tpl.Name,
                    Creator: ctx.Kit.User,
                }
                _, err = ps.AuthManager.Authorizer.RegisterResourceCreatorAction(ctx.Kit.Ctx, ctx.Kit.Header, iamInstance)
                if err != nil {
                    blog.Errorf("register created service template to iam failed, err: %v, rid: %s", err, ctx.Kit.Rid)
                    return err
                }
            }
    
            return nil
        })
    
        if txnErr != nil {
            ctx.RespAutoError(txnErr)
            return
        }
        ctx.RespEntity(tpl)
    }

六、进程模板创建:CreateProcessTemplateBatch 的批量写入

注意:进程模板与进程实例的区别

进程模板(ProcessTemplate)定义的是"模板"——SRE 在模板层面配置的标准化进程;进程实例(Process)是实际运行在主机上的进程记录。进程模板通过 ProcessProperty 存储进程属性,其中每个字段可以设置 as_default_value 标志,控制是否允许在服务实例上编辑:as_default_value=true 时该字段锁定(只读),继承模板值;as_default_value=false 时该字段可定制。

src/scene_server/proc_server/service/processtemplate.go 第 25-77 行:

// processtemplate.go L25-77
    func (ps *ProcServer) CreateProcessTemplateBatch(ctx *rest.Contexts) {
        input := new(metadata.CreateProcessTemplateBatchInput)
        if err := ctx.DecodeInto(input); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if len(input.Processes) == 0 {
            ctx.RespEntity([]int64{})
            blog.Infof("no process to create, return")
            return
        }
    
        // 上限校验:单次最多创建 100 个进程模板
        if len(input.Processes) > common.BKMaxUpdateOrCreatePageSize {
            ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommXXExceedLimit, "create process template",
                common.BKMaxUpdateOrCreatePageSize))
            return
        }
    
        // 权限校验:检查用户是否有更新该服务模板的权限
        if err := ps.AuthManager.AuthorizeByServiceTemplateID(ctx.Kit.Ctx, ctx.Kit.Header, meta.Update, input.ServiceTemplateID); err != nil {
            ctx.RespErrorCodeOnly(common.CCErrCommCheckAuthorizeFailed, "authorize by service template id failed, id: %d, err: %+v", input.ServiceTemplateID, err)
            return
        }
    
        ids := make([]int64, 0)
        txnErr := ps.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
            for _, process := range input.Processes {
                t := &metadata.ProcessTemplate{
                    BizID:             input.BizID,
                    ServiceTemplateID: input.ServiceTemplateID,
                    Property:          process.Spec,
                }
    
                temp, err := ps.CoreAPI.CoreService().Process().CreateProcessTemplate(ctx.Kit.Ctx, ctx.Kit.Header, t)
                if err != nil {
                    blog.Errorf("create process template failed, template: %+v", *t)
                    return err
                }
    
                ids = append(ids, temp.ID)
            }
            return nil
        })
    
        if txnErr != nil {
            ctx.RespAutoError(txnErr)
            return
        }
        ctx.RespEntity(ids)
    }

七、模板同步:SyncServiceInstanceByTemplate 分批调度

当服务模板的进程配置发生变更后,SRE 需要将变更同步到所有已存在的服务实例。SyncServiceInstanceByTemplate 是这个场景的核心接口。它不直接执行同步,而是将任务拆分后交给 task_server 异步执行,将服务实例的数据库记录批量更新为模板最新值——这是 CMDB 内部的"任务下发 + 异步执行"模式,与机器层面的配置下发无关。

src/scene_server/proc_server/service/serviceinstance.go 第 1786-1881 行:

// serviceinstance.go L1786-1881
    func (ps *ProcServer) SyncServiceInstanceByTemplate(ctx *rest.Contexts) {
        syncOpt := metadata.SyncServiceInstanceByTemplateOption{}
        if err := ctx.DecodeInto(&syncOpt); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if rawErr := syncOpt.Validate(); rawErr.ErrCode != 0 {
            ctx.RespAutoError(rawErr.ToCCError(ctx.Kit.CCError))
            return
        }
    
        // 获取服务模板关联的进程模板数量,用于计算每批主机数量
        procCond := mapstr.MapStr{common.BKServiceTemplateIDField: syncOpt.ServiceTemplateID}
        counts, err := ps.CoreAPI.CoreService().Count().GetCountByFilter(ctx.Kit.Ctx, ctx.Kit.Header,
            common.BKTableNameProcessTemplate, []map[string]interface{}{procCond})
        if err != nil {
            blog.Error("get process template num by cond(%+v) failed, err: %v, rid: %s", procCond, err, ctx.Kit.Rid)
            ctx.RespAutoError(err)
            return
        }
    
        // 获取模块下所有主机ID,按模块聚合
        opt := &metadata.HostModuleRelationRequest{
            ApplicationID: syncOpt.BizID,
            ModuleIDArr:   syncOpt.ModuleIDs,
            Fields:        []string{common.BKHostIDField, common.BKModuleIDField},
        }
        hostRelRes, rawErr := ps.CoreAPI.CoreService().Host().GetHostModuleRelation(ctx.Kit.Ctx, ctx.Kit.Header, opt)
        if rawErr != nil {
            ctx.RespAutoError(rawErr)
            return
        }
    
        moduleHostMap := make(map[int64][]int64)
        for _, rel := range hostRelRes.Info {
            moduleHostMap[rel.ModuleID] = append(moduleHostMap[rel.ModuleID], rel.HostID)
        }
    
        // 分批算法:hosts num * processes num <= 1000
        // 即:进程模板越多,每批主机数越少
        hostNum := 1000
        if len(counts) == 1 && counts[0] != 0 {
            hostNum = hostNum / int(counts[0])
        }
        if hostNum == 0 {
            hostNum = 1
        }
    
        syncOneModuleOpt := metadata.SyncServiceTemplateOption{
            BizID:             syncOpt.BizID,
            ServiceTemplateID: syncOpt.ServiceTemplateID,
            IsSyncModule:      true,
        }
        tasks := make([]metadata.CreateTaskRequest, 0)
        for _, moduleID := range syncOpt.ModuleIDs {
            syncOneModuleOpt.ModuleID = moduleID
            syncOneModuleOpt.HostIDs = nil
            syncOneModuleOpt.IsSyncModule = true
    
            // 第一个任务:同步模块属性(名称、分类等)
            taskReq := metadata.CreateTaskRequest{
                TaskType: common.SyncModuleTaskFlag,
                InstID:   moduleID,
                Data:     []interface{}{syncOneModuleOpt},
            }
    
            syncOneModuleOpt.IsSyncModule = false
            // 按 hostNum 分批,每批创建一个子任务
            hostIDs := moduleHostMap[moduleID]
            for start := 0; start < len(hostIDs); start += hostNum {
                if len(hostIDs)-start >= hostNum {
                    syncOneModuleOpt.HostIDs = hostIDs[start : start+hostNum]
                } else {
                    syncOneModuleOpt.HostIDs = hostIDs[start:]
                }
                taskReq.Data = append(taskReq.Data, syncOneModuleOpt)
            }
    
            tasks = append(tasks, taskReq)
        }
    
        txnErr := ps.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
            // 创建异步任务,task_server 负责执行具体同步逻辑
            taskRes, err := ps.CoreAPI.TaskServer().Task().CreateBatch(ctx.Kit.Ctx, ctx.Kit.Header, tasks)
            if err != nil {
                blog.Errorf("create service template sync task(%#v) failed, err: %v, rid: %s", tasks, err, ctx.Kit.Rid)
                return err
            }
            blog.V(4).Infof("successfully created service template sync task: %#v, rid: %s", taskRes, ctx.Kit.Rid)
            return nil
        })
    
        if txnErr != nil {
            ctx.RespAutoError(txnErr)
            return
        }
        ctx.RespEntity(nil)
    }

分批调度的意义

假设有 500 台主机,每个服务模板有 5 个进程模板。按 hostNum = 1000 / 5 = 200,则每批 200 台主机,分 3 批调度。这样既避免了单次请求超时,又通过 task_server 保证了每个分批任务独立执行、失败重试。

八、服务实例删除:DeleteProcessInstance 流程

服务实例的删除分为两个维度:删除服务实例中的进程,以及删除整个服务实例。两者都在 src/scene_server/proc_server/service/processinstance.go 中实现。

src/scene_server/proc_server/service/processinstance.go 第 657-673 行:

// processinstance.go L657-673
    func (ps *ProcServer) DeleteProcessInstance(ctx *rest.Contexts) {
        input := new(metadata.DeleteProcessInstanceInServiceInstanceInput)
        if err := ctx.DecodeInto(input); err != nil {
            ctx.RespAutoError(err)
            return
        }
    
        if len(input.ProcessInstanceIDs) == 0 {
            ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommParamsNeedSet, common.BKProcessIDField))
            return
        }
    
        // 上限校验:单次最多删除 500 个进程实例(definitions.go L49)
        if len(input.ProcessInstanceIDs) > common.BKMaxDeletePageSize {
            ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommXXExceedLimit, "delete process instance",
                common.BKMaxDeletePageSize))
            return
        }
    
        listOption := &metadata.ListProcessInstanceRelationOption{
            BusinessID: input.BizID,
            ProcessIDs: input.ProcessInstanceIDs,
        }
        // ... 后续查询关联关系后批量删除
    }

九、源码视角:从源码读出三层设计思想

我理解源码的意思是说

蓝鲸 CMDB 的服务实例体系,从源码里可以读出三个设计原则。SRE 理解这些原则后,能更准确地预判系统行为。

源码视角一:三层分离(模板 → 实例 → 进程)

src/common/metadata/process.go 的 ServiceInstance 结构体(第 2103-2118 行),会发现服务实例只存储 HostID、ModuleID 和 ServiceTemplateID。实际的进程配置内容通过 ProcessInstanceRelation(进程实例关联表)关联到进程模板,而非直接存在服务实例里。这意味着服务实例是"引用"而非"复制"——模板变,实例不一定变,除非主动同步。

源码视角二:事务包裹一切写入操作

读 serviceinstance.go 的 CreateServiceInstances(第 54-61 行)、servicetemplate.go 的 CreateServiceTemplate(第 49-73 行)、processtemplate.go 的 CreateProcessTemplateBatch(第 53-70 行),会发现所有写入操作全部被 AutoRunTxn 包裹。创建服务模板时,数据库写入和 IAM 注册在一个事务内——任何一个失败都回滚。这是 CMDB 保证数据一致性的核心手段。

源码视角三:分页上限防雪崩

读 definitions.go 第 46 行和第 49 行:BKMaxUpdateOrCreatePageSize=100、BKMaxDeletePageSize=500。在 CreateServiceInstances、CreateProcessTemplateBatch、DeleteProcessInstance 的入口处都有对应校验。这是 CMDB 防止大批量写入压垮数据库的硬性保护。

源码视角四:as_default_value 字段实现管控与灵活的平衡

读 processtemplate.go 中 CreateProcessTemplateBatch 的 input.Spec ,会发现每个进程字段通过 ProcessProperty 定义,其中包含 as_default_value 字段。as_default_value=true 的字段在服务实例上只读(锁定),as_default_value=false 的字段可以自由编辑。这个设计让 SRE 既能强制某些字段(如进程名、端口)保持标准化,又允许实例级别的个性化配置(如启动参数)。

源码视角总结:3 个核心设计原则

  • 1 个模板引用模式:服务实例引用模板,变更通过同步而非自动覆盖
  • 1 个事务包裹原则:所有写操作在 AutoRunTxn 中执行,保证一致性
  • 1 个分页上限规范:Create≤100、Delete≤500,防止雪崩

避坑提醒(源码视角):

  • 不要绕过上限校验直接调用底层 API:serviceinstance.go L47 和 processtemplate.go L40 都校验了 BKMaxUpdateOrCreatePageSize,直接调 CreateServiceInstances 可能绕过此限制导致超时
  • 不要在服务实例创建后手动修改 ServiceTemplateID:ServiceTemplateID 创建后不可更改(process.go L2109 注释明确说明),硬改会破坏引用一致性
  • 不要跳过 validateCreateServiceInstancesInput 直接创建:该函数检查了模块是否为内置模块(module.Default != 0,包括空闲机池、故障机池等),跳过会导致数据异常

FAQ 20 组

以下 20 组 FAQ 覆盖服务实例、服务模板、进程模板的常见问题。结论直接给出,展开部分说明原理和源码依据。

Q1. 服务实例和服务模板有什么区别?

服务模板是定义,服务实例是实例化。 服务模板定义了"一类服务长什么样"(包含哪些进程模板),服务实例是"这个模板在某一台主机上的具体表现"。两者通过 ServiceTemplateID 关联。修改服务模板不影响已有服务实例的内容(除非主动触发同步)。

Q2. 为什么创建服务实例时,要先校验主机是否在模块下?

服务实例必须依附于"模块+主机"这对组合。 validateCreateServiceInstancesInput(serviceinstance.go L134-151)检查 bizID 和 module.BizID 是否一致,以及 module.Default != 0(内置模块不允许创建服务实例)。如果跳过此校验,可能创建出挂载关系错误的服务实例,导致拓扑查询和同步逻辑出错。

Q3. 进程模板的 as_default_value 字段有什么用?

as_default_value=true 的字段在服务实例上不可编辑,只能改模板后同步(更新服务实例的数据库记录)。这是 CMDB 平衡标准化和灵活性的设计。as_default_value=false 的字段可以在实例上直接修改,不同步回模板。SRE 可以对"进程名、端口"等关键字段设为 true,对"启动参数"保持 false。

Q4. 创建服务实例时 ServiceTemplateID 为 0 会怎样?

走非模板路径,直接创建原始进程。 upsertProcesses(serviceinstance.go L204-224)中,serviceTemplateID == 0 时走 if 分支,调用 createProcessInstances 创建原始进程实例。这种方式创建的服务实例不与任何模板关联,无法享受模板同步能力。

Q5. 为什么单次创建最多 100 个服务实例?

BKMaxUpdateOrCreatePageSize=100 是 CMDB 的硬性上限。 definitions.go L46 定义此常量。serviceinstance.go L47 和 processtemplate.go L40 都在入口处校验。大量创建应分批调用,每次不超过 100 个。

Q6. 服务模板能否删除?有什么限制?

可以删除,但模块绑定了服务模板时不能删。 SRE 需要先将绑定该模板的模块解除绑定,或者删除模块下所有服务实例。UI 的删除提示(cn.json L773)中明确写了"不可删除:模板已被应用不能删除,如需删除,请先清空模板下的实例"。

Q7. SyncServiceInstanceByTemplate 和手动修改服务实例有什么区别?

同步操作是"以模板为准"的全量/增量覆盖,手动修改只改单个实例。 同步时会对比每个进程模板的 Property,自动处理新增、修改、删除三种变化(serviceinstance.go L1970 syncSrvInstToAdd)。手动修改只影响单个服务实例的进程内容。

Q8. 同步任务的分批算法 hostNum=1000/procCount 是怎么来的?

经验值:1000 是安全阈值,procCount 控制每批主机数量。 serviceinstance.go L1827-1833 中,hostNum 的设计是让"主机数 × 进程模板数"不超过 1000。procCount=5 时每批 200 台,procCount=10 时每批 100 台,procCount=20 时每批 50 台。这个设计避免了单次同步操作写入量过大导致超时。

Q9. upsertProcesses 中的 templateID2ProcessID 二级映射是用来做什么的?

用来在批量更新时找到每个服务实例对应的进程 ID。 从 ListProcessInstanceRelation 查到 ProcessTemplateID→ProcessID 的关系后,建立 ProcessTemplateID → (ServiceInstanceID → ProcessID) 的二级映射。遍历 instanceProcessesUpdateMap 时,通过 ProcessTemplateID 匹配 ServiceInstanceID,找到对应的 ProcessID,再构造 UpdateRawProcessInstanceInput 批量更新(serviceinstance.go L247-266)。

Q10. 为什么服务实例创建后,还要调用 WithProcBySvcInstIDs?

为了生成完整的审计日志。 audit.WithProcBySvcInstIDs(serviceinstance.go L122)会查询新创建的服务实例关联的进程信息,将进程详情一起记录到审计日志中。这样审计日志不仅记录了服务实例本身,还记录了每个进程的具体配置值。

Q11. CreateServiceTemplate 时为什么要向 IAM 注册?

为了让权限中心知道"谁创建了这个资源",从而自动授予创建者操作权限。 servicetemplate.go L57-70 中,RegisterResourceCreatorAction 将服务模板的创建者和资源ID注册到 IAM。后续该用户操作此服务模板时,IAM 可以直接放行,无需额外授权。

Q12. 进程实例删除和进程模板删除是同一个接口吗?

不是同一个接口。DeleteProcessInstance 删除的是服务实例中的进程(processinstance.go L657),DeleteProcessTemplateBatch 删除的是服务模板中的进程模板(processtemplate.go L79)。删除进程模板后,对应的服务实例进程不受影响,但会失去模板同步能力。

Q13. 服务实例的名称是怎么确定的?

可以有三种来源:API 传入的 ServiceInstanceName、第一个进程的进程名、或者自动生成。 当 API 传入了名称时直接使用(serviceinstance.go L91)。当 ServiceInstanceName 为空但有进程时,upsertProcesses L217-223 会用第一个进程的 ProcessData 中的信息更新服务实例名称(通过 updateServiceInstanceName)。

Q14. AutoRunTxn 在服务实例创建中起了什么作用?

保证"服务实例创建 + 进程写入 + 审计日志"要么全成功,要么全回滚。 serviceinstance.go L54-61 中,AutoRunTxn 包裹了 createServiceInstances 的整个执行过程。如果 upsertProcesses 或 SaveAuditLog 失败,事务回滚,已创建的服务实例记录被撤销。这是 CMDB 保证数据一致性的核心机制。

Q15. 服务模板的 HostApplyEnabled 字段有什么作用?

控制该服务模板关联的模块是否开启主机属性自动应用。 servicetemplate.go L45 中,newTemplate 直接从 option.HostApplyEnabled 赋值到 ServiceTemplate 记录中。这个字段会影响模块绑定服务模板后,是否自动应用服务模板关联的属性规则到加入该模块的主机上。

Q16. 为什么 CreateProcessTemplateBatch 还要做权限校验?

因为创建进程模板属于修改服务模板内容,需要 Update 权限。 processtemplate.go L47 中,AuthorizeByServiceTemplateID 检查用户是否有更新该服务模板的权限。即使用户能创建服务实例,如果对服务模板没有 Update 权限,仍然不能为其添加进程模板。

Q17. 模块绑定服务模板后,能解除绑定吗?

可以解除绑定(Unbind),但影响不可逆。 UI 提示(cn.json L840)中明确写了"解除模板影响:服务实例不能再同步模板的更新,不能再使用模板新建服务实例"。解除后,服务实例仍然保留当时的进程配置内容,但失去模板引用关系,无法再同步。

Q18. upsertProcesses 中为什么对无模板场景单独调用 createProcessInstances?

因为无模板的进程是"独立创建"的,有模板的进程是"更新已有"的。 无模板场景(serviceinstance.go L206-224)直接调用 CreateRawProcessInstanceInput 创建原始进程记录,而有模板场景(L226-228)只是记录待更新的服务实例ID和进程数据,最后统一通过 updateProcessInstances 批量更新。两者写入模式完全不同。

Q19. 服务实例的同步状态(待同步/已同步)是怎么记录的?

通过对比服务实例的进程配置与进程模板的 Property 来判断差异。 SyncServiceInstanceByTemplate 触发后,doSyncServiceInstanceTask(serviceinstance.go L2321)会比较 ProcessTemplate.Property 和服务实例的进程 Process.Property,记录 Changed、Added、Removed 三种差异类型(process.go L456-465 的常量定义)。

Q20. 为什么服务模板的 ServiceCategoryID 是两级分类?

UI 要求两级分类(SRE 可以选择一级分类和二级分类)。 process.go L1149-1151 注释明确写明"the class must have two labels"。SRE 在创建服务模板时需要先选择服务分类(cn.json L786),服务分类本身支持两级树状结构,如"中间件/数据库"或"Web服务/Nginx"。

全篇总结

  • CreateServiceInstances 是服务实例创建的核心入口,通过 AutoRunTxn 事务包裹"校验→创建→进程写入→审计"四阶段
  • upsertProcesses 根据 ServiceTemplateID 是否为 0 分叉为模板/非模板两种处理路径
  • SyncServiceInstanceByTemplate 通过分批任务(hostNum=1000/procCount)实现大规模服务实例的异步同步
  • BKMaxUpdateOrCreatePageSize=100BKMaxDeletePageSize=500 是 CMDB 防止雪崩的硬性上限
  • ProcessTemplate.Property.as_default_value 字段实现了标准化管控与灵活配置之间的平衡

Roadmap:后续预告

后续预告

  • #09 主机属性自动应用(HostApply):模块绑定属性规则,主机进模块时自动应用,无需手动配置
  • #10 synchronize_server:外部数据源对接模式,定时同步云平台/AWS/VMware 主机数据
  • #11 数据质量保障:字段校验规则、脏数据检测与告警机制
  • #12 watch 事件订阅:变更实时推送机制,监控系统即时感知主机变化
posted @ 2026-07-05 17:22  左扬  阅读(18)  评论(0)    收藏  举报