BCM 集群纳管实战:从 software image 到 category 的裸金属生命周期
一句话:PXE + Ansible 管的是"把机器装起来"这一次动作,BCM 管的是"这台机器应该长什么样"这一份持续状态——镜像、分类、内核参数、磁盘布局都是可继承、可覆盖、可批量下发的对象,而不是散落在几十个 playbook 里的变量。
一、为什么装机脚本到了千卡规模会失效
几十台机器时,一套 PXE + Kickstart/Autoinstall + Ansible 是够用的:装机是一次性事件,配错了重装一遍。到了几百上千台 DGX/HGX 级别节点,问题换了形态:
- 状态没有单一来源。某台机器
/etc/sysctl.conf被手工改过、某台nouveau没拉黑、某台磁盘阵列布局和邻居不一样——这些差异不会自己暴露,只会在跑 NCCL 或复现性能基线时变成一个无法解释的个例; - 基线无法整批变更。要给全部计算节点加一个内核参数,Ansible 得跑一遍全量、还得自己处理"哪些机器已经重启生效、哪些没有";
- 装机与调度割裂。Slurm/Kubernetes 部署在装机脚本里做,一旦换镜像,没人保证新镜像里那套 Slurm 配置还在;
- 厂商交付形态。NVIDIA DGX SuperPOD 参考架构里的标准做法就是 BCM:
bcm-pod-setup、BaseOS 镜像、category 级的 kernel args 与磁盘布局,都是官方交付路径的一部分。
BCM(NVIDIA Base Command Manager,前身 Bright Cluster Manager)把上面这些收进一个带数据库的管理层:节点文件系统被抽象成镜像、节点被归类、属性被继承。官方文档对它的定义很直白:BCM 把计算节点的本地文件系统当作一个 software image 来管理,镜像是头节点上的一个目录,里面是一份完整的 Linux 根文件系统;节点启动时,置备系统用镜像的一份拷贝把它装起来。
二、对象模型:先搞清谁继承谁
BCM 的六个核心对象决定了"一条配置该写在哪里":
| 对象 | 定义 | 典型用途 |
|---|---|---|
| device | 集群中的一个组件:物理节点、虚拟节点、GPU、交换机 | 单台机器的最终覆盖 |
| category | 同构节点分组,成员继承属性与镜像 | 计算节点、登录节点、存储节点各一个 |
| nodegroup | 任意的运维分组,不要求同构 | "今晚要重启的这一批" |
| role | 功能角色,如 provisioning node、storage node、Slurm client | 由角色带出对应服务的配置 |
| rack | 物理机架 | 按机柜整批上下电 |
| softwareimage | 一份完整根文件系统,位于 /cm/images/<name> | 镜像基线 |
官方给了一张很实用的能力表,说明每种对象能做什么:
| 对象 | 重启 | 电源操作 | 分配镜像 | 内核参数 | 内核模块 |
|---|---|---|---|---|---|
| softwareimage | N/A | N/A | N/A | 支持 | 支持 |
| device | 支持 | 支持 | 支持 | 支持 | 支持 |
| category | 支持 | 支持 | 支持 | 支持 | 支持 |
| nodegroup | 支持 | 支持 | 不支持 | 不支持 | 不支持 |
| role | 支持 | 支持 | 不支持 | 不支持 | 不支持 |
| rack | 支持 | 支持 | 不支持 | 不支持 | 不支持 |
两个必须记住的语义:
- 内核参数不是合并,而是替换。category 上的参数会整体替换 image 上的,device 上的参数又会整体替换 category 或 image 上的。所以给某个节点单独加一个参数时,要小心它把整条
kernelparameters覆盖成只剩这一个——官方给的做法是用append而不是set; - category 与 nodegroup 不是一回事。category 的成员共享同一套配置、磁盘布局和镜像;nodegroup 只是"运维时想一起操作的一批机器",成员可以硬件不同、镜像不同。把 nodegroup 当 category 用,是新手最常见的概念错位。
三、cmsh:会话语义比命令本身更重要
cmsh 是 BCM 的管理 shell,可以交互式进入,也可以把命令串一次性丢给它:
# 交互式
$ cmsh
[headnode]% softwareimage
[headnode->softwareimage]% list
# 批处理:分号分隔,等价于逐层进入
$ cmsh -c "softwareimage;list"层级之间用 exit 返回上一层,Ctrl+D 直接退出 shell。最容易踩的一条:提示符里出现星号(如 [headnode->softwareimage*]%)表示改动还只存在于当前 cmsh 会话,没有写进数据库;必须 commit。忘了 commit,你会看到"命令明明成功了、节点行为没变"这种最耗时间的现象。
[headnode->softwareimage]% set kernelversion 5.15.0-1078-nvidia
[headnode->softwareimage*]% commit四、镜像就是根文件系统
镜像存放在头节点 /cm/images/<name> 下,default-image 是随 BCM 一起提供的默认基座(与头节点同发行版,带节点管理软件),没有单独分配镜像的节点就用它。实践中不会直接改它,而是克隆一份按角色定制:
[headnode->softwareimage]% clone default-image compute-image
[headnode->softwareimage]% use compute-image三个与镜像生命周期直接相关的动作:
imageupdate:节点已经在运行,把镜像里的改动同步到节点本地文件系统,不需要重启。适合"只改了某个配置文件或装了个小包"的场景;grabimage:反向操作,把某台已经调好的运行节点的现场改动回抓进镜像。适合"在样机上验证完、再固化成基线",具体参数以cmsh的 Tab 补全和help grabimage为准;lock:把镜像锁住,任何节点都取不到它,直到解锁。发布新镜像前的保险丝——防止某个节点半夜重启把半成品镜像拉走。
五、改镜像的四个动作
BCM 管镜像的方式不是让你 chroot 进去手工改(虽然也能),而是把影响启动的关键配置抽成镜像属性。
5.1 内核版本:换内核必须重新登记
计算节点是通过 PXE 从头节点加载内核的,不是从本地磁盘。所以镜像里的内核一变,BCM 必须知道,并重新生成 initramfs:
$ cmsh -c "softwareimage;list"
# Name Path Kernel version Nodes
# default-image /cm/images/default-image 6.8.0-51-generic-64k 0
[headnode->softwareimage]% use baseos-image
[headnode->softwareimage]% set kernelversion 5.15.0-1078-nvidia
[headnode->softwareimage*]% commitcommit 之后要等到日志出现类似 Initial ramdisk for image dgx-os-6.3.2-h100-image was generated successfully 才算完成——这一步可能要几分钟,别急着退出 shell 也别急着重启节点。
5.2 内核参数:set 是替换,append 是追加
# 查看当前
$ cmsh -c "softwareimage; use baseos-image; get kernelparameters"
# 整体替换
$ cmsh -c "softwareimage; use baseos-image; set kernelparameters \
\"nouveau.modeset=0 iommu.passthrough=1 systemd.unified_cgroup_hierarchy=0 \
init_on_alloc=0 numa_balancing=disable\"; commit"
# 只追加(注意前导空格)
$ cmsh -c "softwareimage; use baseos-image; append kernelparameters \" nvme_core.multipath=n\"; commit"append 的值必须带前导空格(除非原值为空或以空格结尾),否则两个参数会粘成一个。多个参数要用双引号整体包住,写进脚本时注意转义。
5.3 内核模块:要进 initramfs 才在启动时可用
bonding、multipath 这类模块如果是根文件系统挂载或网络起来之前就要用的,必须显式塞进镜像的初始内存盘:
$ cmsh -c "softwareimage; use baseos-image; kernelmodules; add bonding; commit"5.4 磁盘布局:用 XML 定义 RAID 与挂载点
BCM 用一份 XML 描述分区与阵列,再把它挂到 category 上:
<diskSetup>
<device><blockdev>/dev/nvme0n1</blockdev>
<partition id="slash1"><size>max</size><type>linux raid</type></partition>
</device>
<device><blockdev>/dev/nvme1n1</blockdev>
<partition id="slash2"><size>max</size><type>linux raid</type></partition>
</device>
...
<raid id="slash">
<member>slash1</member><member>slash2</member>
<level>1</level><filesystem>ext4</filesystem><mountPoint>/</mountPoint>
<mountOptions>defaults,noatime,nodiratime</mountOptions>
</raid>
<raid id="raid">
<!-- nvme2n1 ~ nvme9n1,level 0,挂 /raid -->
</raid>
</diskSetup>关键点不是模板本身,而是这份布局成为 category 的属性,新节点首次装机自动照此分区:
[headnode->category]% use compute
[headnode->category*[compute*]]% set disksetup /cm/local/apps/cmd/etc/htdocs/disk-setup/compute-disksetup.xml
[headnode->category*[compute*]]% commit官方示例里 8 块盘 RAID 0 做 /raid、2 块盘 RAID 1 做 /——这个布局的取向很清楚:根系统要冗余(坏了能开机),数据盘要容量与带宽(可以重建)。
六、把一个新节点纳进集群
标准流程是"克隆模板 → 指到临时 category → 让它先跑起来 → 认网卡 → 配 bond → 切正式 category"。这不是绕,是因为物理网卡在操作系统里枚举成 eth2 还是 eth7,事先无法确定,必须让节点先起来看一眼。
$ cmsh
[headnode]% device
[headnode->device]% clone template-default-node oem-node001 --next-ip
[headnode->device*[oem-node001*]]% set category default
[headnode->device*[oem-node001*]]% commit--next-ip 让克隆出来的节点自动取下一个可用 IP,避免手工填错。然后让节点 PXE 启动:认不出 MAC 时,Cluster Manager Node Installer 会进入交互流程,选 Manually select node 挑刚建的那台,Accept 之后等它装完。
节点起来后,用 BCM 的远程 shell 进去核对网卡:
[headnode->device]% use oem-node001
[headnode->device[oem-node001]]% rshell
root@oem-node001:~# ip a # 哪个口拿到了内网网段地址?
root@oem-node001:~# dhclient # 把还没起来的口拉起来
root@oem-node001:~# ip a
root@oem-node001:~# exit拿到网口后,回到 cmsh 改接口配置——这一步是把"两块物理口做成一个 LACP bond 并作为置备口":
[headnode->device[oem-node001]]% set category compute
[headnode->device*[oem-node001*]]% interfaces
[headnode->device*[oem-node001*]->interfaces*]% remove bootif
[headnode->device*[oem-node001*]->interfaces*]% add physical eth2
[headnode->device*[oem-node001*]->interfaces*[eth2*]]% exit
[headnode->device*[oem-node001*]->interfaces*]% add physical eth3
[headnode->device*[oem-node001*]->interfaces*[eth3*]]% exit
[headnode->device*[oem-node001*]->interfaces*]% add bond bond0
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% set dhcp yes
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% clear ip
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% set network internalnet
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% set interfaces eth2 eth3
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% set mode 4
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% set options "miimon=100 xmit_hash_policy=layer3+4"
[headnode->device*[oem-node001*]->interfaces*[bond0*]]% exit
[headnode->device*[oem-node001*]->interfaces*]% exit
[headnode->device*[oem-node001*]]% set provisioninginterface bond0
[headnode->device*[oem-node001*]]% commit
[headnode->device[oem-node001]]% reboot四个参数值得停一下:
set mode 4是 LACP(802.3ad),需要交换机侧同步做链路聚合;miimon=100是 100 ms 一次的链路检测周期,决定故障切换的感知速度;xmit_hash_policy=layer3+4让流量按 IP + 端口散列,多流场景分布更均匀;provisioninginterface bond0指定置备流量走 bond,否则 PXE/镜像下发仍然走单口。
节点重启后再次 rshell 进去 ip a,看到 bond0 UP 且有内网地址,才算这一步交付完成。确认无误后,把这份配置克隆给其余节点:
[headnode->device]% clone oem-node001 oem-node002 --next-ip
[headnode->device]% clone oem-node001 oem-node003 --next-ip
[headnode->device]% commit七、批量操作:三种选择器记住就够
# 按节点名区间重启
$ cmsh -c "device; reboot -n node-[002-031]"
# 按 category
$ cmsh -c "device; reboot -c compute"
# 按 nodegroup
$ cmsh -c "device; reboot -g tonight-batch"
# 电源操作同选择器
$ cmsh -c "device; power -c compute off"按名字区间批量重启是灰度上线的核心动作:先把新镜像只给一台节点,验收完再放宽到区间,最后才整 category。BCM 文档里也明确建议这个顺序。
另外两个交付细节:
- 如果节点是 DGX B200 这类机型,PXE 拿到 NBP 文件后可能出现掉电,需要把 category 的 bootloader 显式设为 GRUB:
set bootloader grub; - BCM 通过 IPMI/BMC 控电源,前提是它知道 BMC 的凭据:
category(或 device)下的bmcsettings子模式里set password,否则power/reboot会在没有带外权限时失败。
八、换镜像与原地更新:两条完全不同的路
这是整套流程里后果最重的一个分叉,官方文档写得很明确:
路径一:原地更新(in-place)。你在现有镜像上装包、改配置、调内核参数,然后 imageupdate 把差异推到运行中的节点,或者下次重启自然生效。这条路上,Slurm/Kubernetes 的部署、你自己装的东西、配置改动都还在。
路径二:换成新镜像。你新建(或从别处导入)了一份镜像,把它 set softwareimage 指给 category 或 device。那么——
- 所有额外的软件与配置都要在新镜像上重新做一遍;
- Slurm 与 Kubernetes 必须重新部署:
$ cm-wlm-setup -c cm-wlm-setup.conf
$ cm-kubernetes-setup -c cm-kubernetes-setup.conf配置文件里如果新增了 category,还要把它加进 cm-wlm-setup.conf / cm-kubernetes-setup.conf 的 categories 列表,否则新节点不会被纳入调度域。
一次性后安装工具 bcm-pod-setup 只用于初次部署。它做的事很重:写入 DGX 机型专用的内核参数与模块、按机型配 sysctl(例如 A100/H100/B100 关闭 arp forwarding、GB200 配 net.ipv4.* 流式路由)、按机型下发磁盘布局与 udev 规则(GB200 用 plain 布局,其余默认 persistent 布局)。
$ apt-get install bcm-post-install && module load bcm-post-install
$ bcm-pod-setup --dgx-type H100 --dgx-image baseos-image集群已经跑起来之后再用它,等于把一套"初装假设"重新糊到生产镜像上——官方明确说不要在部署后使用,后续调整走镜像属性(kernel args 等)。
九、上线验收清单
cmsh -c "softwareimage;list"里目标节点数为预期值,Kernel version 与内核实况一致;commit后等到了 initramfs 生成成功的日志,没有中途退出 shell;kernelparameters用get回读确认是"替换"还是"追加"的预期结果(尤其是单独给某节点加参数那次);category的disksetup指向正确 XML,抽一台新装节点lsblk核对/与/raid的 RAID 级别;ip a确认bond0UP、模式为 LACP、置备口是 bond 而非单物理口;- 灰度顺序记录在案:单节点 → 名字区间 → 整 category;
- 换镜像的那次发布,Slurm/K8s 重装命令与配置文件改动有记录,队列与分区能正常接受作业。
收口三句
- BCM 交付的是状态,不是脚本:镜像、category、内核参数三者构成"这台机器应该长什么样",任何一次性脚本都替代不了这份可继承的声明;
- 两条语义必须背下来:内核参数是替换不是合并,nodegroup 只做批量操作不承载配置;
- 换镜像 ≠ 换内核:原地更新可以只推差异,换代镜像则意味着 Slurm/K8s 重新部署——这一步漏了,集群会在"装好了但调不动"的状态里停很久。