2. 为什么这类部署我坚持走容器化路线
先交代一下背景。我最近在给团队做一套 AI 模型的推理服务,模型文件好几个 GB,代码依赖又乱,Python 环境动不动就崩。前前后后踩了不少坑之后,我把整套流程固定成了“镜像构建 -> GPU 透传 -> 模型挂载 -> 服务编排”这条标准流水线。这篇文章就是把这条流水线完完整整拆开讲,适合正在搞大模型部署、想上 Docker/K8s 又怕踩坑的工程师看。
先说一个反直觉的结论:真正让部署变难的,往往不是模型本身,而是运行环境的一致性。你今天在本机跑通了一个 PyTorch 推理脚本,明天换台机器,CUDA 版本不对、cuDNN 缺符号、Python 依赖冲突,直接给你颜色看。容器化解决的核心问题,就是把这堆环境差异全部固化成一个镜像,让模型在哪儿跑都一样。
我见过很多人对容器化部署有误解,觉得“不就是写个 Dockerfile 把模型 COPY 进去嘛”。如果你只是拿个小模型做 demo,这么干确实没问题。但当你面对的是动不动几个 GB 甚至上百 GB 的大模型权重,再加上 GPU 驱动、推理引擎、并发服务这些要素时,“把一切塞进镜像”的方案会立刻变得笨重且不可维护。正确的做法是把容器当成一个轻量进程包装器,让镜像只负责运行环境和代码,数据(权重文件)单独挂载进来。这套思路在后面每一节都会反复出现。
这篇文章适合这几类读者:
- 还在用裸机 + 虚拟环境部署模型,想迁到 Docker 但不知道从哪下手的人;
- 已经在用 Docker 部署普通 Web 服务,但没跑过 GPU 容器,对
--gpus透传、驱动匹配一知半解的人; - 准备上 K8s 跑大模型推理,想提前搞明白容器本地编排和集群编排差异的人。
我会尽量把每一步背后的“为什么”讲清楚,而不是只丢给你一串能跑的命令。因为部署这件事,能跑只是起点,出了问题能排查才是能力。
3. 镜像构建的取舍:从 CUDA 基础镜像到依赖分层
构建一个 AI 模型服务的镜像,看似简单,实际上有好几个决策点。这些决策直接决定了你后续维护这个镜像的时候,是清爽还是痛苦。
3.1 基础镜像选型:为什么我不用 devel 版
先看一个最简单的 Dockerfile 示例:
dockerfile复制FROM nvidia/cuda:12.4.1-base-ubuntu22.04
# 设置非交互模式,避免 apt 卡在时区选择
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Asia/Shanghai \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 \
python3-pip \
curl \
&& rm -rf /var/lib/apt/lists/*
# 先拷贝依赖清单,利用构建缓存
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
# 再拷贝代码
COPY app/ /app/
WORKDIR /app
# 使用非 root 用户运行
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["python3", "serve.py"]
这里有个非常关键的选型:基础镜像我用的是 base 而不是 devel,也不是 runtime。很多初学者会一上来就选 nvidia/cuda:12.4.1-devel-ubuntu22.04,因为它是全家桶,里面带编译器、带头文件,感觉什么都不会缺。但这是典型的“为了 1% 的需求承担 100% 的体积成本”。
devel 镜像包含完整的 CUDA 工具链(nvcc 编译器、头文件等),体积往往比 base 大 2GB 以上。而绝大多数推理服务在运行时只需要 CUDA 的运行时库,并不需要编译 CUDA 代码。真正需要编译的是 PyTorch、TensorFlow 这类深度框架的安装过程,但它们在安装时已经通过 wheel 包把预编译好的 CUDA 库绑定进去了。也就是说,跑 PyTorch 推理,一个带 CUDA 运行时库的基础镜像就足够了。
那为什么不用 runtime 而非要 base?因为 runtime 是在 base 基础上加了 cuDNN 等库。如果你的模型需要 cuDNN(PyTorch 默认就依赖),你可以选择带 cuDNN 的 runtime 镜像。我选 base 是因为 PyTorch 的官方 wheel 会捆绑它需要的 CUDA/cuDNN 库到 Python 包目录里,这时候基础镜像带不带 cuDNN 影响不大。判断标准很简单:在容器里跑一下 python -c "import torch; print(torch.cuda.is_available())",如果能显示 True 就说明库都齐了。如果发现缺了某个 .so 文件,再往上层加对应的 runtime 镜像也不迟。
3.2 把 requirements.txt 单独 COPY,是一场构建缓存优化
Dockerfile 里的 COPY 顺序,会深切影响你的日常开发体验。上面那个例子,我把 requirements.txt 单独 COPY 出来执行 pip install,然后再 COPY 代码。这么写是有讲究的。
Docker 构建时,每一层都会做缓存校验。如果 requirements.txt 的内容没变,那么 pip install 那一层就不会重新执行,直接复用之前的缓存。而你的代码是高频变更的,单独放后面,改一行代码重建镜像时,只需要重新构建代码层,几分钟的依赖安装过程完全跳过。
这套分层策略在本地开发时感受还不明显,推到 CI 上效果立竿见影。我见过有人图省事,一条 COPY . /app 直接把整个项目拷进去再装依赖,结果每次改一行业务代码,全部依赖重新装一遍,一次构建奔着二三十分钟去。你要是饱受过这种痛苦,就知道分层构建有多香。
还有一点值得说:如果团队用了 PyPI 私有源,或者有固定的国内源,先把 pip 源地址写进镜像的配置文件里,能显著提高构建速度和稳定性。我一般会放一个 /etc/pip.conf:
ini复制[global]
index-url = https://mirrors.cloud.tencent.com/pypi/simple
trusted-host = mirrors.cloud.tencent.com
3.3 依赖锁定:别让“懒人写法”毁掉你的环境
凡是跑过 AI 模型的人都知道,transformers、torch、numpy 之间有着极其微妙的版本依赖关系。今天你 pip install 出来一套能跑的版本,跟明天重新装一套,可能因为某个补丁版本变化,推理结果就变了甚至直接报错。
所以 requirements.txt 里我强烈建议锁定到小版本号,而不是用 >= 这种范围写法:
code复制torch==2.3.1
transformers==4.41.2
numpy==1.26.4
safetensors==0.4.3
fastapi==0.111.0
uvicorn==0.30.1
pydantic==2.7.4
一个小经验:transformers 和 tokenizers 这两个库经常出现 C 扩展层面的不匹配,锁版本时建议一起锁。你如果只动了 transformers 没动 tokenizers,下次重新构建镜像时 pip 可能会解析出一个不兼容的组合。多花两分钟把版本写死,未来能省好几个小时的排障时间。
依赖的安装阶段还有个常被忽略的点:--no-cache-dir 一定要带上,否则 pip 会把下载的 wheel 缓存到镜像里,白白增加体积。模型服务的镜像最终要推到私有仓库,体积每大一点,拉取时间就长一点。多个副本同时拉大镜像的时候,这种浪费会被放大。
