3. 实验环境容器化
医学影像实验的可复现性,往往不是败在算法本身,而是败在环境。FSL 依赖特定的系统库与 FSLDIR 环境变量,ANTs 需要 ITK 编译链与特定版本的 HDF5,PyTorch 又要求 CUDA 运行时、cuDNN 与 Python 包版本严格匹配。当这三者在同一台机器上共存时,最常见的现象是:同一个脚本在 A 机器上跑通,在 B 机器上因 libcudart.so 版本不符而崩溃。Conda 能解决 Python 层面的依赖,却无法锁定系统级库、GPU 驱动接口和编译工具链。容器化正是为补齐这一层而存在。
一、为什么 Conda 不够:三层依赖的边界
把医学影像工具链的依赖拆开看,实际存在三层:
- 系统层:glibc、libstdc++、HDF5、zlib、OpenMP 运行时,以及 FSL/ANTs 编译时链接的动态库。
- GPU 层:NVIDIA 驱动(宿主机内核态)、CUDA Driver API(libcuda.so,由驱动提供)、CUDA Runtime(libcudart.so,随工具链打包)、cuDNN。
- 语言层:Python 解释器版本、PyTorch/torchvision、nibabel、SimpleITK、numpy 等包。
Conda 只能覆盖第三层,偶尔通过 conda-forge 触及第二层的部分运行时,但无法控制宿主机驱动,也无法保证 FSL 这类非 Python 工具的系统库一致性。容器把前两层与第三层一起封装进镜像,只把 GPU 驱动留在宿主机,通过运行时注入的方式共享。
二、镜像分层与依赖锁定策略
一个可复现的医学影像镜像,建议按“变化频率从低到高”分层构建,这样重建时能最大化复用缓存:
- 基础层:选定
nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04这类官方镜像,锁定 CUDA 与 cuDNN 版本。 - 系统工具层:安装 FSL、ANTs 所需的系统包,并固定 apt 源快照。
- 工具链层:安装 FSL(通过 fslinstaller.py 指定版本)与 ANTs(指定 release tag 编译或使用预编译包)。
- Python 层:用锁文件安装 PyTorch 与影像处理包。
- 实验代码层:最后 COPY 进项目代码,保证代码改动不触发前面所有层重建。
Python 层的锁定不要只写 torch>=2.0,而应导出精确版本。推荐流程是:在开发环境中确定可用组合后,用 pip freeze 或 conda env export --no-builds 生成锁文件,并在 Dockerfile 中安装该锁文件。下面是一个可参考的片段(示例参数,非实测结果):
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive \
FSLDIR=/opt/fsl \
FSLOUTPUTTYPE=NIFTI_GZ \
ANTSPATH=/opt/ants/bin \
PATH=/opt/fsl/bin:/opt/ants/bin:$PATH
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.10 python3-pip libgomp1 libhdf5-103-1 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 工具链层:固定版本,避免 latest 漂移
RUN python3 /tmp/fslinstaller.py -d /opt/fsl --fslversion 6.0.7.7
COPY requirements.lock /tmp/requirements.lock
RUN pip3 install --no-cache-dir -r /tmp/requirements.lock
COPY . /workspace
WORKDIR /workspace
其中 requirements.lock 应包含类似 torch==2.1.2+cu121、nibabel==5.1.0、SimpleITK==2.3.1 的精确条目。注意 PyTorch 的 +cu121 本地版本号必须与基础镜像的 CUDA 主版本一致,否则会出现运行时找不到符号的错误。
三、Docker 与 Singularity 的取舍
Docker 适合本地开发与 CI,构建快、生态成熟,但多数 HPC 集群不允许普通用户使用 Docker daemon(需要 root 权限)。Singularity(现称 Apptainer)专为共享集群设计,以普通用户身份运行,镜像为单文件 SIF,天然适配 Slurm 作业调度。二者并非替代关系,而是同一镜像的两种运行形态:用 Docker 构建,用 Singularity 在集群执行。
从 Docker 镜像转换到 SIF 的典型命令是:
# 在可联网节点上构建并推送
docker build -t registry.local/mri-exp:fsl6077-cu121 .
docker push registry.local/mri-exp:fsl6077-cu121
# 在集群登录节点拉取并转为 SIF
singularity build mri-exp.sif docker://registry.local/mri-exp:fsl6077-cu121
# 提交作业时以 --nv 透传 GPU
singularity exec --nv mri-exp.sif python train.py --epochs 50
Singularity 默认以调用者身份运行,容器内 /home、/tmp 会自动绑定,因此数据路径通常无需额外映射;但 FSL 的 FSLOUTPUTTYPE 等环境变量需要在镜像中通过 ENV 固化,或在 singularity exec 时用 --env 显式传入,否则输出格式可能与预期不符。
四、GPU 与 CUDA 版本的精确锁定
GPU 环境最容易出问题的地方是“版本三角”:宿主机驱动版本、镜像 CUDA 版本、PyTorch 编译版本。判断逻辑是:
- 先用
nvidia-smi查看宿主机驱动支持的最高 CUDA 版本(右上角 “CUDA Version”)。 - 镜像的 CUDA Runtime 版本必须 ≤ 该值。
- PyTorch 的
+cuXXX后缀必须与镜像 CUDA 主版本一致。
例如宿主机显示 CUDA 12.4,则可选镜像 cuda:12.1.1,PyTorch 选 2.1.2+cu121。若误装 +cu118,容器内 torch.cuda.is_available() 会返回 False 或直接报符号错误。验证命令如下:
python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())"
# 期望输出形如:2.1.2+cu121 12.1 True
此外,FSL 与 ANTs 在容器内运行时,若涉及多线程(如 ANTs 的 ITK 线程池),需注意 OMP_NUM_THREADS 与 Slurm 分配的 CPU 数匹配,否则会出现线程争抢导致性能反而下降。
五、可复现性验证清单
镜像构建完成后,不要只看“能跑”,而应逐项验证。以下清单可直接用于实验前的环境自检:
| 检查项 | 命令 / 方法 | 通过判据 |
|---|---|---|
| CUDA 可用性 | torch.cuda.is_available() | 返回 True |
| 版本一致性 | torch.version.cuda 对比镜像 CUDA | 主版本相同 |
| FSL 可用 | bet --version | 输出 6.0.7.7 |
| ANTs 可用 | antsRegistration --version | 输出指定 tag |
| 输出格式 | echo $FSLOUTPUTTYPE | NIFTI_GZ |
| 线程匹配 | echo $OMP_NUM_THREADS | ≤ Slurm 分配核数 |
pip install torch 而不指定 +cuXXX,会默认拉取 CPU 版本或与镜像 CUDA 不匹配的版本。务必在锁文件中写全本地版本号,并在构建后执行上面的验证命令。