一、问题起源
系统从 CentOS7 升级到 Rocky8 后由于 intel 2016 版的 mpi 依赖的 glibc 与系统的 2.28 版的 ABI 不兼容,导致之前在 CentOS7 下编译出的 vasp 在 Rocky8 下 mpirun 运行时出错,一运行就报 *** buffer overflow detected *** 错误。虽然串行运行时正常,但是无法并行就相当于无法使用。这样就带来了两个问题:
- intel 2016 版编译器与 Rocky8 的 glibc ABI 不兼容,而且所依赖的 gcc 头文件版本也不对,导致编译出错。并不是一定无法编译,而是当涉及与高版本不兼容的内容时会出错。比如编译一个简单的源文件时通常没什么问题,但是编译 vasp 就会出错!同样的设置在 CentOS7 下就能正常编译。可见在 Rocky8 下直接用 intel 2016 编译大型程序通常会遇到问题。
- 即使是之前在 CentOS7 下编译好的程序,在 Rocky8 下 mpirun 并行运行时也会出错!
那么就彻底不能用了吗? 也不是!
二、容器
1. podman 容器中编译
上面第一个问题可通过 podman 容器解决,用 podman 创建一个 CentOS7 的容器,映射宿主机上的 intel 2016 及 vasp 源文件,然后在容器中编译。或者干脆在别的装有 CentOS7 的机器上用 intel 2016 编译好拷过来。总之,就是还是要在 CentOS7 下编译。如果不需要重新编译的话,那么已有的程序不用动。
2. apptainer 容器中运行
podman 容器更像 docker,虽然不需要后台服务程序,但它仍不方便在计算集群上供多人使用。因为文件权限、个人设置等等各种原因吧。而 apptainer (epel 源里就有)则是专门针对高性能计算集群用户量身定做的容器方案,比 podman 更轻量级且更透明。它不像 podman 或 docker 一样区分镜像与容器的概念,它只有镜像(当然叫容器也行,反正都一样),用户不能修改,只能直接调用。总之,apptainer 就是专门为特定程序提供运行环境打包而设计的。
(1) 创建镜像
通常通过 .def 文件创建镜像,比如 centos7_base.def 内容如下:
Bootstrap: docker
From: centos:7
%post
# 使用 USTC 科大 vault 源代替已失效的官方源
sed -i 's|mirrorlist=|#mirrorlist=|g' /etc/yum.repos.d/CentOS-Base.repo
sed -i 's|#baseurl=http://mirror.centos.org/centos/|baseurl=https://mirrors.ustc.edu.cn/centos-vault/|g' /etc/yum.repos.d/CentOS-Base.repo
yum -y clean all
yum -y makecache
yum -y install \
which tar gzip bzip2 \
gcc gcc-c++ gcc-gfortran \
numactl numactl-devel \
environment-modules \
openssh-clients
# 用于挂载 /public
mkdir -p /public
%environment
export MODULEPATH=/public/software/modules
source /usr/share/Modules/init/bash
然后用如下命令就可以创建镜像文件 centos7.sif 了
apptainer build centos7.sif centos7_base.def
有了镜像文件后普通用户就可以直接使用该镜像文件运行了,其中 xxxx 是用户想在容器里运行的命令,--bind 用于映射文件或目录,类似 docker 里的 -v。
apptainer exec --bind /public:/public centos7.sif xxxx
不过,上面创建镜像的过程会失败,因为他要到 docker-hub 上去下载,然而却连不上。好在 apptainer 可以直接导入 podman 或 docker 导出的镜像文件。Rocky 8 下可先用 podman 拉取镜像,然后再导出为 centos7.tar:
podman pull quay.io/centos/centos:7
podman save -o centos7.tar centos:7
有了 centos7 的镜像,可以如下创建 apptainer 的镜像 centos7.sif
apptainer build centos7.sif docker-archive://centos7.tar
但是这样的 centos7.sif 里的内容是未经配置的,通常并符合我们的需求,但它又无法修改了。所以,最理想的办法是先创建一个 “沙箱” 镜像,其实就是一个目录,用于存放镜像的文件:
apptainer build --sandbox centos7_sbox docker-archive://centos7.tar
然后就有了一个 centos7_sbox 目录。此时可以通过如下命令进入该沙箱,对其进行修改(需加 --writable 选项)。
apptainer shell --writable centos7_sbox
执行上述命令后会进入一个以 Apptainer> 开头的 shell 中,这其实就是 centos7_sbox 所代表的沙箱容器(或镜像)的 shell。先修改 yum 源,改为科大的 http://mirrors.ustc.edu.cn/centos-vault,这样就可以 yum 安装所需程序了,比如 gcc gcc-c++ gcc-gfortran environment-modules openssh-clients 等。然后设置 module,在 /etc/profile.d 目录下创建 module.sh 文件,内容如下(此处需根据自己实际目录调整):
export MODULEPATH=/public/software/modules
source /usr/share/Modules/init/bash
再装一些可能用到的程序就基本差不多了。需注意:对于镜像中本没有的路径,apptainer 会自动映射宿主机的路径!比如,Apptainer> 下的 /root 路径就是我真实宿主机的 /root!所以千万不要不小心删了宿主机的东西!沙箱镜像设置完后就可以退出了,然后就可根据此沙箱镜像创建 .sif 只读镜像了:
apptainer build centos7.sif centos7_sbox
(2) 运行镜像
把 centos7.sif 放到一个普通用户能访问的地方,比如 /public/apptainers/,然后普通用户就可以运行容器中的命令了,比如运行 lsb_release -a 命令会输出 CentOS7 信息(前提是在配置沙箱时安装了 redhat-lsb-core 包)
apptainer exec --bind /public:/public /public/apptainers/centos7.sif lsb_release -a
LSB Version: :core-4.1-amd64:core-4.1-noarch
Distributor ID: CentOS
Description: CentOS Linux release 7.9.2009 (Core)
Release: 7.9.2009
Codename: Core
需注意的是,若运行的命令是 uname -a 则显示的是宿主机的内核信息,这一点和 podman 以及 docker 等是一样的。有一点需要注意,虽然 moudle 已经安装并配置好了,但如果直接运行
apptainer exec --bind /public:/public /public/apptainers/centos7.sif module list
会提示 FATAL: "module": executable file not found in $PATH 错误,这是因为 apptainer exec 进入的是一个 非登录的 shell,并不会触发加载 /etc/profile.d 下的脚本,需使用 bash -l 主动登录才行,并将运行的命令以 -c 参数传入,所以应该如下使用:
apptainer exec --bind /public:/public /public/apptainers/centos7.sif bash -lc "module list"
Currently Loaded Modulefiles:
1) compiler/intel/2016u3 2) apps/vasp/5.4.4-i16
它会自动从宿主机上我的用户目录下读取 ~/.bashrc 设置,容器中的工作目录就是宿主机里的当前工作目录。所以将上述命令中的 "module list" 直接替换为 "mpirun -n 16 vasp_std" 它运行的就是 intel 2016 版在 CentOS7 下编译出的 vasp_std 文件,可以正常运行!为了便于使用,将 apptainer 命令封装在 runi16 脚本里,内容如下(这样运行有问题,见第(3)部分):
#!/bin/bash
apptainer exec --bind /public:/public /public/apptainers/centos7.sif bash -lc "$*"
在每个节点上都装上 apptainer,把 runi16 拷到每个节点的 /usr/local/bin 下,就可以直接通过 runi16 vasp_std 直接通过容器提供的 CentOS7 环境来运行之前的 intel 2016 编译的 vasp_std 了。如果是在脚本中提交并行任务,只需如下运行即可
mpirun -n 64 runi16 vasp_std #核数根据实际情况调整
注意需使用 mpirun(实际调用的是 mpiexec.hydra,不要使用基于 mpd 的 mpiexec,也不要使用 slurm 的 srun,它用的应该是 mpiexec,会提示 mpd 进程未运行等问题)。然后我到一个计算节点上去运行试一下,结果发现提示如下错误:
INFO: Cleanup error: while unmounting /var/lib/apptainer/mnt/session/final directory:
no such file or directory, while unmounting /var/lib/apptainer/mnt/session/rootfs directory: no such file or directory
FATAL: container creation failed: open /etc/resolv.conf: no such file or directory
原来是因为计算节点上没有 /etc/resolv.conf 文件,apptainer 会自动挂载宿主机的该文件,若宿主机没有则报此错误。解决办法也很简单,用 root touch 一个空的 /etc/resolv.conf 即可。然后再运行就正常!
至此,通过 apptainer 部署 CentOS7 运行环境的容器完美运行之前 CentOS7 遗留下来的由 intel 2016 编译的程序。用户只需在相应程序前用 runi16 脚本封装一下来运行即可,不用关心容器的细节!
(3) 并不完美
明明早上已成功运行 mpirun -n 64 runi16 vasp_std,而且还提交了任务试了一下,成功算完。但是,下午再试同样的命令又不能运行了。此时不论是上面命令还是 runi16 mpirun -n 64 vasp_std 都报同样的 buffer overflow 错误,显然是又出现了 glibc ABI 不兼容的问题!可我明明已经用了 apptainer 容器了啊!系统设置也没有变!到了晚上,莫名其妙的 runi16 mpirun -n 64 vasp_std 又能运行了!看来这样运行并不稳定!问 AI,它说:
Apptainer 采用 “集成而非隔离” 的理念,默认情况下,容器内的应用程序会直接使用宿主机系统的动态链接库,包括GLIBC。这是 Apptainer 针对高性能计算(HPC)环境的一项核心设计特性。Apptainer 容器的设计使其默认优先使用宿主机的 GLIBC,这带来了出色的性能和兼容性,但也需要注意宿主机与容器内软件对GLIBC版本的依赖关系。
由此可见,默认情况下 apptainer 并不能很好的隔离 glibc。但奇怪的是为啥早上的时候能运行?是环境变量变了还是什么变了?而且一阵好一阵坏的,不得而知!
我的目的就是要隔离宿主机的 gblic,好在 apptainer 提供了 --contailall 选项,它是一个非常有用的安全与隔离选项,它能为 apptainer 容器运行环境提供一个最高级别的隔离。
| 功能类别 | 具体控制项 | 默认情况 (无--containall) |
使用 --containall 后 |
|---|---|---|---|
| 文件系统挂载 | 自动挂载宿主机的$HOME、/tmp、/proc等目录 |
自动挂载 | 禁止自动挂载 |
| 环境变量 | 继承宿主机的环境变量 | 全部继承 | 不继承 |
| 临时文件系统 | 在容器内挂载/tmp和/var/tmp
|
使用宿主机的目录 | 使用容器内隔离的临时目录 |
使用 --containall 参数后可以屏蔽宿主机环境变量可能带来的干扰,让容器的运行环境更加纯净和一致,完全使用容器内的 gblic 而与宿主机无关,这样才能解决我的问题。不过,使用该参数后默认不会保持工作目录,需要手动传递工作目录,此时将 runi16 脚本内容改为如下:
#!/bin/bash
apptainer exec --containall --pwd="$(pwd)" --bind /public:/public /public/apptainers/centos7.sif bash -lc "$*"
而且,即使这样修改后依然无法 mpirun -n 64 runi16 vasp_std 这样计算,这样用时虽然不会报 buffer overflow 错误了,但是无法正常并行,看到的是四个独立的 vasp_std 进程在计算,这就是完全隔离所需的代价,系统内外的消息传递、网络模式、内存共享等等无法保持一致。此时只能在容器内部进行 mpi 并行, 即 runi16 mpirun -n 64 vasp_std,这样带来的一个限制就是 只能单个节点并行,不能跨节点并行! 不过好在单个节点核数也不少,通常情况下也够用了。
由于使用了 --containall 后,宿主机环境变量无法直接传入容器中,若没有在 ~/.bashrc 中设置 module,在脚本中 runi16 之前的设置将不管用。此外,即使 ~/.bashrc 中有设置,但与所需设置冲突也会失效。最保险的办法是在运行 runi16 时清除原有设置并重新设置,即:
runi16 "
module purge
module load xxx yyy
mpirun -n 64 vasp_std # 或把核数替换成自动的 $SLURM_NTASKS_PER_NODE
"
上面 runi16 运行的命令可以放在一行并以 ; 分隔,也可以使用 SLURM 的环境变量,但前提是两边得用双引号 ",这样在传给 runi16 时就会替换为实际值;若用单引号 ' 则直接将 $SLURM_NTASKS_PER_NODE 以字面形式传给 runi16, 而 runi16 的容器内部是没有 slurm 的,这些变量的值就是空的。