引言
作为一个比较传统的资源管理调度软件,slurm 的典型范式,是把所有的计算资源固定的写入 slurm.conf
里(包括其 ip,硬件 spec 等等),这种范式也和 on-prem 的 HPC 集群的基础设施相符,毕竟机房里的服务器,不会平白无故的频繁增减。但至少有两方面的需求,使得我们需要一个更加弹性的资源管理范式。第一种是节能的需要,假设整个集群的空置率一直比较高,也即始终会有不少 IDLE 状态的节点,那么一个比较自然的想法可能是将这些没有任务的节点关机或休眠,等到 slurm 接受的任务量超过开机节点总数时,再唤醒这些节点,从而需要 slurm 有一定程度的“弹性”。第二种需求则是云计算时代的需求,无论是公有云还是私有云,甚至只是简单的虚拟机资源池,我们一方面希望这些资源统一受到 slurm 的调度和管理;另一方面又希望这些资源在不用的时候可以自动取消,以防止硬件资源或者成本的无故消耗;这就对 slurm 的弹性管理功能提出了要求。
事实上,现在的 slurm 就具有这种弹性按需调整集群的功能,本文将通过一系列的展示来演示该功能的配置。为了完成这些实验,既不需要花钱购买公有云资源,也不需要复杂耗时地搭建配置 openstack 之类的全方位私有云方案;本文只利用 virsh 和 KVM 虚拟机,就可以简洁地达成演示弹性集群和资源调度的实验。本文假设读者已具有一定的 slurm 配置,集群管理和虚拟机使用的基础。
基础
实现这一弹性调度的基础,就是 slurm 自带的 elastic computing 功能,这一功能脱胎于引言中第一个需求——节能。事实上,这两个需求的解决方案是完全一致的,最简单的讲,就是在 slurm.conf 添加以下两个配置项:
SuspendProgram=/home/ubuntu/sus.sh
ResumeProgram=/home/ubuntu/res.sh
也就是说,当向 slurm 提交任务,按照需求分配之后,发现需要云端节点时,slurm 会自动执行 res.sh。当任务执行完毕,云端节点进入 IDLE 状态后,超过 SuspendTime 后,slurm 则会自动调用 sus.sh,用来关闭实例,回收资源。需要注意的是,以上两个脚本都是运行在 slurmctld 节点的,因此你需要找到在该节点上执行任务,使得远程的机器 provision 的方案(通常对应了云提供商的 API 或 SDK 套件)。简单讲 resume 程序需要做的事包括,创建/提供/找到一个开启 slurmd,配置了和主节点同样 slurm.conf 和 munge.key 的机器/虚拟机/云端实例;并且执行 scontrol update nodename=test1 nodeaddr=1.2.3.4
。这里的重点就在于通知 slurmctld 该新节点的 ip 地址。而 sus.sh,唯一需要做的就是关闭 VM,收回资源,或者只要你不担心资源泄露,也可以什么也不做。
同时,还需要添加一批云端的节点做占位符,虽然它们还不存在,但根据 slurm 的静态配置特性,还是需要先把位置占上,唯一不需要的就是提前在 slurm.conf 里指定 ip 地址了。需要注意的是,对于这批云端资源的硬件参数,比如 CoresPerSocket,ThreadsPerCore 等等,还是需要在 slurm.conf 中指定,无法动态更新,scontrol update 不支持这些参数的线上更新(注意这些硬件参数的配置是必须的,不然 slurm 默认只利用该节点的一个 cpu core)。当然为了可以同时利用多种不同硬件的实例,可以多加几行不同配置的占位符就好了,反正写上不真用又不会花钱。比如,slurm.conf 节点可以如下所示:
SuspendExcNodes=node[1-10]
NodeName=node[1-10] State=UNKNOWN Weight=10 CoresPerSocket=2 Sockets=1 ThreadsPerCore=2
NodeName=test[1-10] State=CLOUD feature=cloud Weight=30 CoresPerSocket=2 Sockets=1 ThreadsPerCore=2
NodeName=large[1-10] State=CLOUD feature=cloud Weight=30 CoresPerSocket=20 Sockets=1 ThreadsPerCore=2
注意到 node[1-10] 是本地节点,不存在弹性 provision 和 relinquish 的逻辑,因此需要 SuspendExcNodes 选项,将它们从弹性计算的逻辑排除。nodelist 中,test 和 large 都是将来可能用到的云计算资源,只不过对应的实例配置不同,其 spec 也同时列了出来。
其他更多的用来控制弹性计算的参数包括
ResumeFailProgram=blah.sh
# 当时间超过 ResumeTimeout,对应的节点的 slurmd 还没有正确回复时,则认为弹性启动失败,开始执行 blah.sh。这一选项尽在较新版本的 slurm 支持。
SuspendTime=300
# 秒数,当云计算节点 IDLE 状态,超过该秒数时,开始执行 SuspendProgram,用来关闭实例。
TreeWidth=300
# 每个节点,向外通讯的节点数,由于混合计算的拓扑特殊性,建议每个节点都和所有节点通讯,也即该数值需要大于集群最大可能的节点数。
ResumeTimeout=600
# 秒数,resume 允许的最大时间。如果超过该时间,相应节点的 slurmd 还没有正确相应,则启动失败。这一数值应设置的足够长,以允许相应的实例完成启动和配置。默认值 60s 很可能是不够的。
SuspendRate=0
ResumeRate=0
# 一分钟之内可以启动和关闭的最大节点数,0 代表没有限制。
完成了以上设置,我们下面具体来看如何在本地,利用虚拟机完成弹性 slurm 的调试和实验。
所有以下的实验,都利用了我们之前搭建的虚拟HPC集群的基础设施,详情可参考之前的文章。
Level 0
我们先不真的重启虚拟机,或者创建虚拟机,只利用我们本来就有的,早就配置好 slurm 的虚拟机 master 和 node1 做最基本的验证。需要注意的是,slurm 本身对于 resume 程序执行成功的判断逻辑,当执行完 resume 程序之后,slurmctld 会不断尝试和对应 ip 的 slurmd 通讯,并通过 slurmd 获取对应机器的 boot time,如果该 boot time 是在执行 res.sh 之后,那么认为该节点 resume 成功。相反,如果始终无法和该节点上的 slurmd 通讯(要么是节点网络问题,要么是 slurmd 未配置或未启动),或者通讯后拿到的 slurmd 给出的 boot time 比 resume program 的开始时间早,则继续尝试,直至成功或者 ResumeTimeout,后者被视为该节点 resume 失败。
在开始前,还有两个小坑:第一,resume 和 suspend program 对应的地址,一定要有可执行权限 chmod u+x res.sh
;第二,两个 program,一定不能有 o 的 w 权限,也即其他用户无法修改,否则 slurm 出于安全原因将拒绝执行程序。对于这种坑,我的建议只有,把 slurm 各组件的 debug level 开到最大,然后慢慢看 log,总能发现蛛丝马迹的。
按照我们之前所说的 slurm 弹性计算的机制,我们首先在 slurm.conf 中把 node1 的 state 改为 CLOUD。而对于 resume 和 suspend program,在最初始的调试例子里,我们可以什么都不做,比如
$ cat res.sh
#! /bin/bash
echo "resume start: $(date)" >> /tmp/elastic-slurm.log
$ cat sus.sh
#! /bin/bash
echo "suspend start: $(date)" >> /tmp/elastic-slurm.log
此时命令行输入 sinfo
查看可用节点是看不到 node1 的,事实上,所有 state 标记为 cloud 的节点,都无法通过 sinfo 看到。此时我们在 master 提交任务 srun -w node1 hostname
,然后程序就开始了等待。可想而知,此时 slurmctld 找到 node1 上的 slurmd,发现机器启动时间早于 srun,因此检查不通过,开始了反复的检查。此时我们只要自己动手,在 node1 上执行 sudo slurmd -b
即可发现,master 上的 srun 返回了结果。slurmd 的 b 选项,会改变 slurmd 记录的机器启动时间到执行 slurmd 的时候,主要用于 debug。在这里的情形,恰好满足了 resume 成功的条件,因此 srun 运行成功。此时再次 sinfo
,可以看到 node1 已经出现,状态为 IDLE。如果不再继续向 node1 提交任务,SuspendTime 之后,sus.sh 执行,node1 从 sinfo 中再次消失。此时查看 log,可以看到 resume start 和 suspend start 的记录,证明了所有操作和设定都符合预期。
Level 1
没有人会选择 Level 0 这种 resume 程序什么都不写,然后手动重启 slurmd 的方案,这实在是太尴尬了。那么同样的,我们还是不真的重启虚拟机,也不创建虚拟机,而是还是操纵本来就配置好了的 node1。只不过这次把在 node1 上应该执行的 slurmd -b
写进 res.sh 里。注意到 res.sh 是在 master 上执行的,因此我们需要一个远程执行命令的方案,这里我们选择 ansible。当然,对于这么轻量的任务,也许 pdsh 之类的更合适。
我们修改 res.sh 为
#! /bin/bash
path=/tmp
echo "resume start: $(date)" >> /tmp/elastic-slurm.log
scontrol nodename=node1 nodeaddr=192.168.48.20
/usr/bin/ansible -i ${path}/hosts tn[0] -m command -a "slurmd -b" --become
echo "resume end: $(date)" >> /tmp/elastic-slurm.log
其中利用的最简单的 ansible hosts 文件为
[tn]
node1 ansible_sudo_pass=<sudo pass on node1>
这样一个组合已经足以自动化的重启 node1 上的 slurmd。再次执行 srun -w node1 hostname
,即可自动等到返回结果。
不过如果你足够细心的话,就会发现 /tmp/elastic-slurm.log 之中,只有 “resume start” 的记录,而没有 “resume end” 的行。更进一步的通过 ps aux|grep ansible
和 ps aux|grep res.sh
我们发现,ansible 会变成 defunct 的僵尸进程,只有手动强制 kill -9
杀掉 res.sh,才行。不过这里是不是手动 kill 掉,不影响 slurm 的弹性分配功能的运行。顶多是反复提交任务,会积累大量的僵尸进程罢了。而这一问题,似乎是 slurm 在 resume program 调用 ansible 会出现的问题,直接在 bash 脚本执行 res.sh 并不会出现该问题。在1这一项目中,也提到了该问题:
We also had an issue with Ansible playbook threads not exiting when called by the provisioning step.
不过无所谓了,我们并不依赖于这一方案,反正 level 1 还只是很简单的测试。但这提醒我们,在更复杂的环境下,尽量避免直接在 resume program 中调用 ansible,防止出现奇怪的情况。
Level 2
说了这么多,我们甚至都没有重启过 node1。让我们进一步探索,使得整个环境和 elastic slurm 更匹配。也即让 resume program 接管虚拟机 node1 的重启。这一情景比较类似弹性 slurm 的需求一,也即电脑是本来存在,配置好 slurm 的,只不过需要开一下机而已。
那么怎么通过 master 里的 resume 脚本,实现控制 node1虚拟机的开关机呢?我们知道,开关机控制起来最容易的办法,当然是在宿主机执行 virsh start/shutdown node1
。那么我们就这么办。只不过我们这次有了 ansible defun 的教训,采取更轻量级的方式来实现 res.sh。其想法是在宿主机建一个简单的 API 服务,用来控制虚拟机的开关机,而在 master VM 中我们只需要
$ cat res.sh
#!/bin/bash
path=/tmp
echo "resume start: $(date)" >> /tmp/elastic-slurm.log
scontrol nodename=node1 nodeaddr=192.168.48.20
curl http://192.168.122.1:8080/start/$1
echo "resume end: $(date)" >> /tmp/elastic-slurm.log
$ cat sus.sh
#!/bin/bash
curl http://192.168.122.1:8080/shutdown/$1
echo "$(date): shutdown $1">>/tmp/elastic-slurm.log
其中的 $1
是 slurm 默认传给 resume 和 suspend program 的参数,为待操作的 nodename,这里即是 node1。可以看到,当需要使用 node1 时,slurm 调用 res.sh node1
,由此我们向宿主机发出一个 GET 请求,其地址代表了我们想做的操作 /start/node1
也即 virsh start node1
之意。
为此,我们可以通过 bash 在宿主机实现一个超简单的 API server,其代码如下
#!/bin/bash
# ss.sh
RESPONSE="HTTP/1.1 200 OK\r\nConnection: keep-alive\r\n\r\n"OK"\r\n"
while query=$({ echo -en "$RESPONSE"; } | nc -l 192.168.122.1 8080|grep GET); do
path=$(echo $query|awk '{print $2}')
action=$(echo $path|awk -F/ '{print $2}')
echo $action
node=$(echo $path|awk -F/ '{print $3}')
echo $node
echo "$(date)"
virsh $action $node
done
其中的 node 和 action 分别是对于 GET 地址解析得到的,在宿主机运行该 server,./ss.sh
,则在 master VM 中可以通过简单的 curl,来遥控其他虚拟机的状态与开闭。这一 bash server 的灵感来源于2,只不过我在上面进一步加了简易的路由。
完成了这些配置,可以重新在 master VM 中,检验 srun -w node1 hostname
了。结果符合预期,可以通过 master 中的 log,host ss.sh 的 stdout 等相互验证。
Level 3
到 level 2 为止的模拟,都不像云计算的场景。真正的弹性云计算,可不是早就配置好的虚拟机拿来开关机那么简单。相反,应该是一个一切从无到有的 provision 过程。具体的说,当 slurm 执行 res.sh 时,我们应该从 OS 原始镜像创建出虚拟机,并且完成初始化配置,安装好 slurm 并且 nfs mount master 的相应目录(这些初始化操作还有更多细节,包括保持和 master 一样的用户,保持和 master 一样的 munge.key,保持和 master 同步的系统时间,安装和 master 一样的 mpi 等等)。为了实现这些,我们分成两步走,初始的 ssh key 和用户等信息,我们在虚拟机的安装阶段,就通过 cloud init 来完成配置;其他更复杂和精细的调整,我们在 ssh 链接通了之后,通过宿主机中执行 ansible 来完成。这一分野的好处是:第一,cloudinit 是在很难执行精细化的配置,因此早晚需要 CM 软件,那么 ansible 接管的越多越好,除了在安装 VM 必需的初期配置以外,其他部分全部利用 ansible 而非 cloud init 的 user-data 来配置。第二,我有现成的管理集群的 ansible-playbooks,只需要进行微调就可以直接用于配置新的 VM,大概需要 network,basic,slurm,mpi 这几个 roles 即可。(一些微调的备忘,host 中虽然没有 ln 分组的 host,但为了逻辑,需要有一个占位 host,其并不需要 reachable。此外,slurm 的配置文件由模版渲染,改为 copy,并直接把 master VM 的配置文件放置在宿主机 ansible playbooks slurm role 中的 files 里。)
而对于 VM 的安装过程,当然不能用 virt-install --cd-rom *.iso
这种方案,这种方式自动化程度低。相反,我们直接选取 ubuntu 官方打包好的 cloud image,这种 image 原生支持 cloud init,同时本来就是 qcow2 格式,可以直接 provision 虚拟机。cloud image 可从 tuna 镜像源下载,具体 provision 虚拟机的过程参考3,里面也提到了如何制作一个 iso 文件,来提供 cloud init 的 data source。我将整个这些过程,合并为了一个脚本 vm-create.sh
如下:
#!/bin/bash
no=$1
muser=ubuntu
cat << END > meta-data
instance-id: test${no}
local-hostname: test${no}
END
cat << END > user-data
#cloud-config
users:
- name: ${muser}
groups: sudo
shell: /bin/bash
ssh_authorized_keys:
- ssh-rsa AAAABblahblah
sudo: ALL=(ALL) NOPASSWD:ALL
END
cp ubuntu-18.04-server-cloudimg-amd64.img test${no}.qcow2
genisoimage -o config${no}.iso -V cidata -r -J meta-data user-data
virt-install -n test${no} -r 512 --disk test${no}.qcow2 --import --disk path=config${no}.iso,device=cdrom --network bridge=mybr0,mac=52:54:00:7d:23:$(printf "%02x" $no) --graphics vnc,listen=0.0.0.0,password=123456 --noautoconsole
while [ $(nc -zv 192.168.48.1$(printf "%02d" $no) 22 2>&1|grep succeeded|wc -l) == 0 ];do
sleep 10
done
sleep 30 ## avoid cloud init is still configuring, may conflict with later ansible deployment
cat << END > hpc-build-ansible-playbooks/hosts
[cn]
test${no} ansible_ssh_host=192.168.48.1$(printf "%02d" $no) ansible_ssh_user=${muser} ansible_sudo_pass=
[ln]
placeholder
END
cd hpc-build-ansible-playbooks/ && ansible-playbook site.yml
相对应的,还有一个关机,删盘,删除虚拟机的脚本 vm-destroy.sh
:
#!/bin/bash
no=$1
virsh shutdown test${no}
virsh undefine test${no}
rm -f test${no}.qcow2
rm -f config${no}.iso
注意以上脚本都很简陋,因为只是个简单实验,我并无意去提升它们的通用性,读者可以自己取舍,以逐句学习为主,直接使用由于系统或文件结构不同,可能有些问题。稍微需要注意的细节包括:新虚拟机网络的设置,这一部分我们没在 cloud init 的 user data 中配置,那么怎么保证 testn 的虚拟机一定拿到 192.168.48.1n 的 ip 呢。我的做法是,修改 master VM 中 dnsmasq 的 map.hosts,因为 master VM 是 48 网段的 dhcp,那么把这些 mac 地址和 ip 的对应,加到 master dnsmasq 的设置中,就保证了新的虚拟机可以拿到预期的IP(因为 cloud init 的默认网络配置是 DHCP)。此外还有 virt-install 中的 --noautoconsole
选项,如果不加这个选项,virt-install 会卡在安装过程,不返回(但实际上 VM 已安装完毕),影响后续命令的执行。还有一个细节就是,循环探测 VM 是否启动成功时,以 ssh 端口是否打开为标准,因为只有 ssh 服务正常运行了,才可以进行后续的 ansible 管理。但这里还是需要耐心,即使 ssh 端口打开了,也不代表虚拟机启动彻底完成,可能 cloudinit 或其他服务还在进一步配置,如果这时候就早早的开始 ansible 配置,其前边的配置很有可能又被启动过程中的一些程序给覆盖了,由此产生问题,因此即使 ssh 服务已经开启了,还需要再等上一段时间再开始配置。
对应上面的脚本,我们还有进化版的 ss.sh 作为 bash server,运行在宿主机为 VM 提供简单的虚拟机建立和销毁 API:
#!/bin/bash
RESPONSE="HTTP/1.1 200 OK\r\nConnection: keep-alive\r\n\r\n"OK"\r\n"
while query=$({ echo -en "$RESPONSE"; } | nc -l 192.168.122.1 8080|grep GET); do
path=$(echo $query|awk '{print $2}')
action=$(echo $path|awk -F/ '{print $2}')
echo $action
node=$(echo $path|awk -F/ '{print $3}')
echo $node
nodeno=${node:4:1}
echo "$(date)"
if [ "$action" == "start" ]; then
./vm-create.sh $nodeno
fi
if [ "$action" == "shutdown" ]; then
./vm-destroy.sh $nodeno
fi
echo "=============="
done
启动 ./ss.sh
之后,虚拟机里的 res 和 sus 程序基本不需要变化(当然如果想兼容多个云端节点的话,还是需要添加一句从 nodename,比如 test2 到 ip 的解析逻辑,比如 192.168.48.102.),就可以实现 slurm 控制的真正的弹性集群了。因为此时 resume 和 suspend 发送的 API,已经分别用来执行建立配置 VM 和销毁 VM,而不只是简单的重启和关闭配置好的 VM 了。
至此,我们的实验告一段落。下一步,就是利用真实的云资源和云提供商的 API,来探索混合云计算,和弹性 slurm 的混合云集群了。这种混合范式的例子不是特别多,但是 pure cloud solution for slurm cluster 已经有很多探索和例子了,比如456。