HPC 集群容器化:Apptainer 的 SIF、GPU 注入与 Slurm 落地
一句话:容器在云上是"打包应用",在 HPC 集群上是"绕开 root 的环境隔离"。这两件事的技术栈不是一套:集群里不能给每个用户一个 Docker daemon,于是有了 Apptainer 这种"用户态运行、单文件镜像、调度器直接调用"的路线。它解决的从来不是"怎么打包",而是"在 500 个人共用一台机器、你没有 root 的前提下,怎么把一个固定的软件环境跑起来"。
一、为什么共享集群不给用户 Docker
Docker 的模型是守护进程 + root:dockerd 以 root 持有镜像层与 overlay 挂载,用户通过 socket 下指令。放到共享集群上有三个直接后果:
| 问题 | 具体表现 |
|---|---|
| 权限集中 | 能访问 docker socket 的人等于拥有 root(可以 -v /:/host 挂整个宿主根) |
| 状态共享 | 所有用户的镜像、容器、网络在同一份 daemon 状态里,互相可见、互相影响 |
| 与调度器不兼容 | 作业退出时容器由谁清理、GPU 与 IB 设备怎么按作业分配,daemon 模型里没有天然答案 |
所以集群的通行做法是换一种运行时:没有常驻 daemon,容器由用户进程自己启动,退出即结束,镜像是一个可以放在共享目录里的只读文件。这就是 Apptainer(Singularity 的后继)的设计前提。它和 Docker 的关系不是替代,而是分工:Docker 用来构建,Apptainer 用来在集群上运行。
下面这张表是两种模型的差异核心:
| 维度 | Docker | Apptainer |
|---|---|---|
| 运行时形态 | 常驻 daemon,客户端下指令 | 无 daemon,apptainer exec 就是一次进程启动 |
| 身份 | 容器内 root 即宿主 root(除非 rootless 配置) | 容器内默认就是你自己;需要"像 root"时用 --fakeroot |
| 镜像 | 分层,存于本地存储驱动 | 默认单文件 SIF(压缩只读 squashfs),可直接放共享存储 |
| GPU | 依赖 NVIDIA Container Toolkit | --nv 从宿主注入驱动与基础 CUDA 库 |
| 调度集成 | 需要额外封装 | 直接在 srun / mpirun 命令行里调用 |
二、SIF:一个文件就是一套环境
apptainer build 默认产出 SIF(Singularity Image Format):压缩、只读、单文件。这个设计对集群特别重要——它天然适合放在 NFS / 并行文件系统上,一次分发、全网可用,不需要每台机器 docker pull 一遍。
# 从 Docker Hub / OCI registry 直接转成 SIF
apptainer build pytorch-24.05.sif docker://nvcr.io#nvidia/pytorch:24.05-py3
# 从定义文件构建(可复现的正规路径)
apptainer build myapp.sif myapp.def
# 需要交互式改东西时用 sandbox(可写目录),改完再转回 SIF
apptainer build --sandbox myapp/ myapp.def
apptainer build myapp.sif myapp/几个常被忽略的构建参数:
| 参数 | 用途 | 什么时候用 |
|---|---|---|
--arch | 拉取非本机架构的镜像(如 amd64 机器拉 arm64) | 跨架构交付、边缘节点 |
--notest | 跳过 %test 段 | 构建机没有 GPU,而 %test 里写了 GPU 自检 |
--reproducible | 用源镜像的创建时间做 SOURCE_DATE_EPOCH、不写 UUID | 同一份定义要产出逐字节一致的镜像 |
--mksquashfs-args | 换压缩算法与级别 | 大镜像的启动时间/体积权衡 |
压缩算法值得单独说一句,因为它直接影响每次启动的解压开销:
| 算法 | 默认级别 | 优化方向 |
|---|---|---|
| gzip | 9 | 兼容性(默认) |
| lz4 | 1 | 速度最快 |
| zstd | 15 | 体积最小 |
一个 8 GB 的框架镜像,用 zstd 换来的是更小的分发体积和更高的启动 CPU 开销;节点 CPU 本来就紧张时,选 lz4 往往比选 zstd 更划算。这是工程取舍,不是"越小越好"。
无 root 构建的三条路径
集群里用户没有 root,那镜像怎么构建?三条路,按可行性排序:
- 特权构建机 / 构建节点:在你有 root 或 user namespace 的机器上
apptainer build,产物 SIF 拷到集群。最常见,也最干净; --fakeroot(无特权构建):用 user namespace 造出"像 root"的身份。对非特权用户构建定义文件时自动隐含该选项;- 远端构建 / 从已有产物转换:先从 Docker 宿主导出
docker save,再apptainer build x.sif docker-archive:x.tar;或走远端构建服务。
--fakeroot 的三种实现方式,能力差别很大,必须知道自己在哪一种:
| 模式 | 前置条件 | 能力边界 |
|---|---|---|
rootless(/etc/subuid、/etc/subgid 映射) | 管理员预先配置 UID/GID 区间,并启用 user namespace | 最完整的模拟,容器内可改文件属主、可 su/sudo |
| root-mapped user namespace | 只需 user namespace 可用 | 只把 root 映射到你自己,能力弱于 rootless |
仅 fakeroot 命令 | 宿主有 fakeroot 命令 | 只能"骗过"部分安装脚本,很多包仍会失败 |
这里有一条硬边界:fake root 永远碰不到它在宿主上没有权限的文件。容器里你是 root,出了容器你仍然是普通用户;容器内 root 新建的文件,在宿主上属于你本人。指望 --fakeroot 去改 /etc/shadow、去 mount、去加载内核模块,都不成立——容器不是虚拟机,它共享宿主内核。
三、挂载模型:默认绑进来的东西比你想的多
Apptainer 启动容器时会自动把一批宿主路径绑进去。默认清单是:
$HOME- 当前工作目录(CWD,路径含符号链接解析不一致时除外)
/dev、/proc、/sys/tmp、/var/tmp/etc/hosts、/etc/localtime- 以及按容器配置改写后挂入的
/etc/passwd、/etc/group、/etc/resolv.conf
这个默认对交互式使用很友好,对集群作业却有两个副作用:
第一,$HOME 被绑进来会盖住镜像里的同名目录。 如果镜像在 $HOME 下放了数据或配置,运行时会被宿主的 home 遮住。要保留镜像内容,用 --no-home(等价于 --no-mount home):
apptainer exec --no-home myapp.sif bash -lc 'ls -la $HOME'第二,--containall 会把 $HOME、/tmp、/var/tmp 换成内存临时目录。 这既是隔离手段,也是坑:作业往 /tmp 写几个 GB 的中间文件,实际吃的是内存;而且这些文件作业结束就消失,出问题时无从查证。要用 --containall 又想看到镜像里的 home 内容,得再加 --no-home。
集群作业的常见写法是只绑数据盘,不绑多余的东西:
apptainer exec \
--bind /lustre/project/teamA:/data:ro \
--bind /lustre/scratch/$USER:/scratch \
--env PYTHONPATH=/data/lib \
myapp.sif python /data/train.py几个要点:
src:dest:opts三段式,opts支持ro/rw(默认rw);- 路径里含
:或,时--bind解析不了,改用--mount type=bind,src=...,dst=...; - 目标目录不必预先存在,Apptainer 会用 overlay 自动补上(但
--writable模式下不允许这么干); - 批量作业里用环境变量
APPTAINER_BINDPATH比命令行更稳,尤其是 runscript 方式启动时; - 想整体关掉系统挂载用
--no-mount,可以按名字(home、tmp、sys、dev)或按路径(/data2)精确关闭。
四、GPU:--nv 到底做了什么
很多人以为 --nv 是"打开 GPU 开关",其实它做三件具体的事:
- 把宿主的
/dev/nvidiaX设备节点放进容器; - 从宿主绑定基础 CUDA 库进容器(按配置里的库清单,通过
ldconfig缓存解析路径); - 设置容器内的
LD_LIBRARY_PATH,让应用用上这些绑进来的库。
这带来两条必须接受的约束:
- 容器里的 CUDA 版本必须被宿主驱动支持。镜像再新,宿主驱动太旧一样跑不起来——这和在裸机上装 CUDA 是同一条规则;
- 容器与宿主的 libc 版本不能差太远。库是从宿主绑进去的,容器 libc 明显旧于(或远新于)宿主时会出现 mismatch 错误。
限制可见的卡
默认情况下容器里能看到宿主所有 GPU。要按作业分配,用 CUDA_VISIBLE_DEVICES,注意注入方式:
# 通过 APPTAINERENV_ 前缀注入到容器内
APPTAINERENV_CUDA_VISIBLE_DEVICES=0,1 apptainer exec --nv myapp.sif nvidia-smi注意 CUDA_VISIBLE_DEVICES 只是让 CUDA 程序"看不见"其余卡,设备节点仍然在。需要真正的设备级隔离时,走实验性的 --nvccli:它调用 nvidia-container-cli,支持只把指定 GPU / MIG 实例传进容器(NVIDIA_VISIBLE_DEVICES,可用 UUID 或 MIG 标识),也支持按 NVIDIA_DRIVER_CAPABILITIES 决定注入 compute、utility、graphics 哪些能力(Apptainer 下默认是 compute,utility)。代价是:它是实验特性,需要可写层(--writable-tmpfs 会被隐含),且不能与 setuid 安装的特权模式共用。
还有一个反直觉的组合:--contain 配 --nvccli 时,如果不设 NVIDIA_VISIBLE_DEVICES,容器里一张卡都没有,只会给你一条警告。想恢复默认行为就显式写 NVIDIA_VISIBLE_DEVICES=all 或 0。
那个经典报错:CUDA_ERROR_UNKNOWN
明明宿主 nvidia-smi 正常、镜像也带 CUDA,容器里一跑就 CUDA_ERROR_UNKNOWN。原因不在容器,而在驱动栈没被初始化全:
CUDA 依赖多个内核模块,其中一部分不是开机加载,而是首次使用时由一个 setuid root 的二进制触发加载。容器里提权被禁止,这个二进制跑不起来,于是模块缺一半。
修复顺序很明确:先在宿主上把驱动栈拉起来——跑一个 CUDA 程序,或 root 执行 modprobe nvidia_uvm;再配上 nvidia-persistenced 防止驱动在空闲时卸载。这也是集群交付时应该写进基线的一条:计算节点开机后先跑一次 GPU 自检,别把首次初始化留到用户作业里。
五、环境变量:谁覆盖谁
容器里的环境变量是五层叠加的结果,优先级从低到高:
- 基础镜像里定义的变量(Dockerfile 的
ENV); - 定义文件
%environment段; %post段写入$APPTAINER_ENVIRONMENT的变量(优先级高于%environment);APPTAINERENV_前缀、--env、--env-file注入的变量;- 宿主环境(只补空缺,不覆盖已存在的,且
--cleanenv时整体不导入)。
两个高频坑:
- module 环境串味:集群上
module load一堆变量(PYTHONPATH、LD_LIBRARY_PATH、各种*_ROOT)会漏进容器,让"可复现的镜像"变成"取决于你 load 了什么"。作业脚本里加--cleanenv(-e)最稳,需要什么变量就显式--env传; LD_LIBRARY_PATH反向规则:宿主的不导入;但--nv绑进来的 CUDA 库会被追加到LD_LIBRARY_PATH,也就是容器内自带的同名库优先。想让宿主驱动库优先,得在容器里设PREPEND_LD_LIBRARY_PATH=/.singularity.d/libs。
对 Slurm 作业,最省事的做法是把变量写进一个 env 文件,随作业一起提交,既避免命令行过长,也让复用和排障有据可查:
# job.env
NCCL_DEBUG=INFO
OMP_NUM_THREADS=8
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
# 提交时
apptainer exec --env-file job.env --nv myapp.sif python train.py六、MPI 与 Slurm:两种模型,一种对齐原则
MPI 程序装进容器只有两条路,选错了会得到一堆"rank 全是 0"的诡异输出:
| 模型 | 做法 | 优点 | 代价 |
|---|---|---|---|
| Hybrid | 宿主的 mpirun / srun 启动容器,容器内也装 MPI,通过 PMI 回连宿主 | 与调度器天然集成、性能接近裸机 | 容器内 MPI 必须与宿主版本兼容、且支持同一套 PMI2/PMIx |
| Bind | 容器不装 MPI,把宿主的 MPI 目录 --bind 进去 | 镜像小、版本天然一致 | 依赖用户可自定义 bind;编译时的 MPI 仍需与宿主一致 |
# Hybrid:宿主的 mpirun 拉起容器
mpirun -n 8 apptainer exec --nv myapp.sif /opt/mpitest
# Slurm 里等价写法(srun 逐任务拉起容器)
srun --ntasks=8 --gres=gpu:1 apptainer exec --nv myapp.sif /opt/app判断是否对齐,看三个点:MPI 版本是否兼容、进程管理接口是否一致(宿主用 PMIx,容器里的 MPI 只支持 PMI2 就不会正常工作)、互连库是否在容器里可用(IB 需要 ucx / verbs 库;--contain 后 /dev 是最小集,必要时手动绑 /dev/infiniband)。
两个已经被记录在案的坑:
- 非特权 user namespace 下 UCX 有问题:表现为通信异常,绕法是
UCX_TLS=sysv,ib; - 每个
apptainer命令各自一个 user namespace 时 MPI 会乱,用--sharens让同一批进程进同一个 namespace,前提是它们由同一个父进程(如mpirun)拉起、且在同一节点。
排障特征很好认:如果输出里 每个 rank 都是 rank 0,几乎一定是 MPI 栈装错了(宿主与容器不是同一套 MPI),而不是调度器的问题。
七、镜像分发:别让 200 个节点同时 pull
这一点是集群和单机最大的差别,也是最容易在第一天翻车的地方。
错误做法:作业脚本里写 apptainer pull docker://...。200 个节点同时解压同一个镜像,出口带宽瞬间打满,作业集体超时,还会在共享存储上留下 200 份缓存。
正确做法分三步:
- 集中构建一次,产物落到共享路径(如
/lustre/apps/images/),命名带版本与校验信息; - 分发用校验而非重拉:节点侧只
sha256sum校验,不一致才补;大文件走scp/rsync或树内分发; - 把缓存目录指到本地盘:
APPTAINER_CACHEDIR指向节点本地 NVMe,别让缓存写进 NFS——否则解压产生的海量小文件会把元数据服务拖垮。
# 节点侧:先校验再使用,避免半截文件
sha256sum -c /lustre/apps/images/pytorch-24.05.sif.sha256 && \
apptainer exec --nv /lustre/apps/images/pytorch-24.05.sif python -c "import torch;print(torch.cuda.device_count())"还有两个性能层面的经验判断(属工程经验,不是官方结论):
- SIF 放并行文件系统时,首次启动的随机读会走网络,启动时间明显长于本地盘;镜像大、作业短、节点多时,本地盘缓存 + 定期同步通常更划算;
- squashfs 解压吃 CPU。镜像压缩比越高、节点 CPU 越紧张,启动越慢。作业只需要跑几十秒时,这部分开销占比会很难看。
八、落地清单与边界
一份可以直接照着做的清单:
| 环节 | 要点 |
|---|---|
| 命名 | 应用-框架版本-CUDA版本-日期.sif,同目录放 .sha256 |
| 构建 | 定义文件进 Git;--reproducible;%test 里做 CPU 自检,GPU 自检放到 --notest 之外单独跑 |
| 分发 | 一次构建、共享存储落地、节点校验;缓存目录指本地盘 |
| 运行 | --cleanenv + --env-file;--bind 只给需要的数据盘;--nv 按作业给卡 |
| 审计 | 镜像内写 %labels(构建时间、来源、提交号),apptainer inspect 可查 |
边界也要写清楚:
- 容器不是虚拟机:共享宿主内核,不能加载内核模块、不能改宿主 sysctl、
--fakeroot不等于 root; - 容器不自动无损:NFS 上的 mmap、压缩镜像的解压、
--containall的内存 tmp,都会改变性能画像,跑分前先在容器里复测一遍基线; --writable不能乱用:共享目录里的 SIF 被改成可写,等于全网共用一份可变状态;临时改动一律--writable-tmpfs;- 签名与加密是可选但值得的:需要防篡改时用
apptainer sign/verify,敏感镜像可用加密容器(运行时在内存解密,不落盘)。
收口三句
- Docker 负责造,Apptainer 负责跑:集群侧要的是"无 daemon、单文件、调度器可调用",不是另一套镜像仓库;
--nv是注入不是开关:驱动与基础 CUDA 库都来自宿主,所以"镜像里的 CUDA 版本"必须服从"宿主的驱动版本";- 镜像分发是集群工程问题,不是容器问题:一次构建、校验分发、缓存落本地盘,这三条做不到,容器化会在规模上还债。