3. 容器化基础与医学影像AI推理服务封装

医学影像AI推理服务要在医院内网、边缘服务器或云主机上稳定运行,最大的障碍往往不是模型精度,而是运行环境。同一份模型权重,在开发机上用 PyTorch 2.1 + CUDA 12.1 能跑通,换到另一台装有旧驱动的 PACS 服务器上就可能报 CUDA driver version is insufficient for CUDA runtime version。容器化的价值,正是把“模型 + 依赖 + 运行时”打包成一个可复现的镜像,让推理服务在不同机器上表现一致。

一、镜像分层:把变化频率不同的内容分开

Docker 镜像由只读层叠加而成,每一层对应 Dockerfile 中的一条指令。构建医学影像推理镜像时,应按“变化频率从低到高”排列层,才能最大化缓存命中率。典型顺序是:基础镜像(CUDA runtime)→ 系统依赖(libgl1、libgomp1 等)→ Python 依赖(torch、monai、nibabel)→ 应用代码 → 模型权重。模型权重通常几百 MB 到数 GB,且每次更新模型都会变,放在最上层可避免重装依赖。

基础镜像选择上,若推理只用 GPU 且不需要编译 CUDA 算子,用 nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 即可,它比 devel 版小很多;若需在镜像内编译自定义算子,才用 devel 版并在多阶段构建中把产物复制到 runtime 阶段。下面是一个面向 CT 肺结节分割推理服务的 Dockerfile 骨架:

FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04

ENV DEBIAN_FRONTEND=noninteractive \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    python3.10 python3-pip libgl1 libglib2.0-0 libgomp1 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app/ /app/app/
COPY weights/lung_nodule_v3.pt /app/weights/

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

二、CUDA 与驱动的兼容关系

这是医学影像 AI 部署中最容易踩坑的地方。需要区分三个版本:宿主机 NVIDIA 驱动版本、镜像内 CUDA Runtime 版本、以及 PyTorch 编译时使用的 CUDA 版本。关键规则是:驱动向下兼容 CUDA Runtime。即宿主机驱动版本必须大于等于镜像内 CUDA Runtime 所要求的最低驱动版本,反之则不行。

例如 CUDA 12.1 要求 Linux 驱动 ≥ 530.30.02;若医院服务器驱动是 525.x,则只能使用 CUDA 11.8 或更低的镜像。此时若强行运行 CUDA 12.1 镜像,容器启动后 nvidia-smi 可能仍能显示 GPU,但 PyTorch 调用 torch.cuda.is_available() 会返回 False,或首次推理时报驱动版本不足。排查时应先在宿主机执行 nvidia-smi 查看 Driver Version,再对照 NVIDIA 官方兼容表选择镜像标签。

另一个常见问题是容器内缺少 NVIDIA Container Toolkit 的运行时注入。若 docker run 时未加 --gpus all,容器内看不到 GPU 设备。生产环境建议在 docker-compose.yml 或 K8s 的 resources.limits 中显式声明 GPU 资源,避免调度到无 GPU 节点。

CUDA 驱动兼容与镜像分层关系 宿主机(医院服务器) NVIDIA Driver 525.60 nvidia-smi 可见 GPU Docker + nvidia-container-toolkit 驱动版本决定 CUDA 上限 --gpus all Docker 镜像(分层) 基础镜像:nvidia/cuda:12.1.1-cudnn8-runtime 系统依赖:libgl1 / libgomp1 / python3.10 Python 依赖:torch 2.1+cu121 / monai / nibabel 兼容性判断规则 ✓ 驱动 ≥ CUDA Runtime 要求 525.60 ≥ 530.30?否 → 需降级镜像 ✗ 驱动 < CUDA Runtime 要求 torch.cuda.is_available() → False 推荐方案:镜像标签与驱动匹配 驱动 525.x 选 cuda:11.8.0-cudnn8 torch 2.1+cu118 驱动 530.x 选 cuda:12.1.1-cudnn8 torch 2.1+cu121 驱动 535+ 选 cuda:12.4-cudnn9 torch 2.4+cu124

三、模型权重与依赖打包策略

模型权重是否打进镜像,取决于交付方式。若镜像通过私有 Registry 分发,且权重文件在 2 GB 以内,直接 COPY 进镜像最省事,启动时无需额外下载。若权重超过 5 GB,或需要频繁更新模型而不想重建镜像,则应把权重放在宿主机卷或对象存储中,容器启动时挂载或下载。此时需在启动脚本中校验权重文件的 SHA256,防止加载到损坏或不匹配的权重。

依赖打包要特别注意两点:一是 requirements.txt 中应锁定精确版本,如 torch==2.1.2+cu121,避免 pip install 时解析到不兼容的新版本;二是医学影像常用的 SimpleITK、pydicom、nibabel 等库对系统库有依赖,需在 apt 层提前安装。建议在 CI 中执行一次 docker build 后运行 python -c "import torch; print(torch.cuda.is_available())" 作为冒烟测试,确保镜像内 GPU 可用。

四、推理服务 API 设计

医学影像推理服务的 API 与普通图像分类不同:输入通常是 DICOM 序列或 NIfTI 体积,单次请求可能包含数百张切片,响应时间从数秒到数十秒不等。因此 API 设计要兼顾同步与异步两种模式。

同步接口适合小体积、低延迟场景,例如单张 X 光片分类。请求体用 multipart/form-data 上传文件,响应返回 JSON 格式的标签与置信度。异步接口适合 CT/MR 体积分割:客户端先 POST 创建任务,服务端返回 task_id,客户端再轮询 GET /tasks/{task_id} 获取状态与结果下载链接。这样可避免长连接超时,也便于服务端做队列限流。

无论同步还是异步,响应中都应包含 model_version、inference_time_ms 和 preprocessing 字段,便于临床回溯时确认是哪个模型、用了什么窗宽窗位。对于分割任务,结果建议以 NIfTI 或 DICOM-SEG 格式返回,并附带与原始影像对齐的仿射矩阵信息。

实践建议:在 Dockerfile 中设置 HEALTHCHECK,定期调用 /health 接口,该接口应同时检查 GPU 可用性与模型是否已加载。K8s 的 livenessProbe 可据此判断容器是否需要重启,避免模型加载失败后容器仍处于 Running 状态却无法提供推理。

最后,镜像构建完成后应记录镜像的 digest(如 sha256:abc123...),而非仅记录 tag。tag 可被覆盖,digest 不可变,在交付验收和问题追溯时,digest 才是唯一可靠的镜像标识。