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 Runtime 与 cuDNN,驱动由宿主机提供,二者通过 libcuda.so 的 ABI 对接。因此镜像的 CUDA 版本必须 ≤ 宿主机驱动支持的最高版本。

二、镜像分层与依赖锁定策略

一个可复现的医学影像镜像,建议按“变化频率从低到高”分层构建,这样重建时能最大化复用缓存:

  1. 基础层:选定 nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 这类官方镜像,锁定 CUDA 与 cuDNN 版本。
  2. 系统工具层:安装 FSL、ANTs 所需的系统包,并固定 apt 源快照。
  3. 工具链层:安装 FSL(通过 fslinstaller.py 指定版本)与 ANTs(指定 release tag 编译或使用预编译包)。
  4. Python 层:用锁文件安装 PyTorch 与影像处理包。
  5. 实验代码层:最后 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 数匹配,否则会出现线程争抢导致性能反而下降。

医学影像实验容器化:依赖分层与运行形态 宿主机(HPC 节点 / 工作站) NVIDIA 内核驱动 libcuda.so (Driver API) Slurm / 调度器 共享存储 /home /scratch --nv 注入 容器镜像(Docker 构建 → Singularity SIF 运行) ① 基础层 nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 — 锁定 CUDA Runtime 与 cuDNN libcudart.so · cuDNN ② 系统工具层 apt 固定快照:libgomp1 · libhdf5 · zlib · python3.10 系统动态库 ③ 工具链层 FSL 6.0.7.7(fslinstaller 指定版本) · ANTs 指定 release tag FSLDIR · ANTSPATH ④ Python 层 requirements.lock:torch==2.1.2+cu121 · nibabel==5.1.0 · SimpleITK==2.3.1 精确版本锁定 ⑤ 实验代码层(最后 COPY,代码改动不触发上层重建) Docker:本地开发 / CI 构建 Singularity:集群 Slurm 执行(--nv 透传 GPU)

五、可复现性验证清单

镜像构建完成后,不要只看“能跑”,而应逐项验证。以下清单可直接用于实验前的环境自检:

检查项命令 / 方法通过判据
CUDA 可用性torch.cuda.is_available()返回 True
版本一致性torch.version.cuda 对比镜像 CUDA主版本相同
FSL 可用bet --version输出 6.0.7.7
ANTs 可用antsRegistration --version输出指定 tag
输出格式echo $FSLOUTPUTTYPENIFTI_GZ
线程匹配echo $OMP_NUM_THREADS≤ Slurm 分配核数
常见陷阱:在 Dockerfile 中用 pip install torch 而不指定 +cuXXX,会默认拉取 CPU 版本或与镜像 CUDA 不匹配的版本。务必在锁文件中写全本地版本号,并在构建后执行上面的验证命令。