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 缓存到镜像里,白白增加体积。模型服务的镜像最终要推到私有仓库,体积每大一点,拉取时间就长一点。多个副本同时拉大镜像的时候,这种浪费会被放大。
4. GPU 容器不是装上就能用:驱动透传与验证
容器相比虚拟机最妙的一点是:它和宿主机共享内核,GPU 的透传并不是通过模拟硬件实现的,而是通过一组运行时钩子把宿主机的 GPU 驱动库注入到容器里。很多人以为“容器里能看到 GPU”是理所当然的,其实背后依赖了一整套工具链。
4.1 nvidia-container-toolkit 在干什么
我直接说结论:要在 Docker 里用 GPU,光装 NVIDIA 驱动是不够的。你需要装 nvidia-container-toolkit 这个工具,它会在容器运行时层面做拦截,把宿主机的 GPU 设备文件和驱动库映射进去。
安装步骤简单过一遍:
bash复制# 以 Ubuntu/Debian 为例
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
安装完以后,要配置 Docker 的运行时:
bash复制sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
nvidia-ctk runtime configure 这条命令做了什么?它会在 Docker 的 daemon.json 里注册一个名为 nvidia 的运行时。你执行 docker run --gpus all 时,Docker 底层会调用这个 runtime,由它来完成 GPU 设备注入。这个过程对用户是透明的,但它不是魔法——如果哪一天你把 Docker 重装了或者把 daemon.json 覆盖了,发现 GPU 用不了,第一反应就应该是检查 nvidia runtime 是否还在。
验证是否配置成功:
bash复制docker info | grep -i runtime
正常输出里应该能看到 nvidia。然后跑一个最小测试:
bash复制docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
能打印出 GPU 信息,说明透传成功。
4.2 宿主机驱动与容器内 CUDA 的匹配逻辑
这里有个频繁出现的误区,值得单独拎出来讲。很多人以为容器里的 nvidia-smi 显示的是容器自己的驱动版本。实际上,容器的内核态 GPU 驱动完全来自宿主机,容器内只是被注入了用户态的 CUDA 库。所以如果你在容器里跑 nvidia-smi,看到的 Driver Version 和宿主机的一模一样,这不是偶然,这是设计使然。
这就引出一个重要的匹配原则:宿主机驱动版本必须 >= 容器内 CUDA 版本所需的最低驱动版本。举例来说,宿主机驱动是 535.xx,容器里装 CUDA 12.4 没问题,因为 CUDA 12.4 要求的最低驱动是 535.xx 附近;但如果你宿主机驱动还是 470.xx,而容器里放了 CUDA 12.x 的库,运行时会直接报“CUDA driver version is insufficient”。在 AI 模型推理场景最常见的就是 PyTorch 报 CUDA error: no kernel image is available for execution on the device,排查了半天发现是驱动太老。
所以在镜像选型阶段,不要一昧追求最新的 CUDA 版本。先确认宿主机驱动的支持范围,再去挑对应 CUDA 版本的基础镜像。两者的对应关系在 NVIDIA 官方文档里有张表,但记个大概就够了:想要 CUDA 12.x,驱动至少 525 以上;CUDA 11.8 大概需要 450 以上。驱动向下兼容的策略是:新驱动能跑旧 CUDA,旧驱动跑不了新 CUDA。
验证容器内 CUDA 是否真正可用,不要只看 nvidia-smi 就完事。更可靠的做法是跑一个小型的 PyTorch 张量运算:
bash复制docker run --rm --gpus all -v /path/to/your/app:/app \
your-image python3 -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"
如果输出 True 并且能看到设备名,才说明整个链路是通的。
4.3 多 GPU 场景怎么控制设备可见性
当你宿主机有多张卡,比如 8 卡 A100,你可能希望不同的服务各用几张卡。Docker 提供了 NVIDIA_VISIBLE_DEVICES 环境变量来做细粒度控制。
bash复制# 只让容器看到第 0、1 号 GPU
docker run --rm --gpus '"device=0,1"' your-image nvidia-smi
# 或者用环境变量方式
docker run --rm -e NVIDIA_VISIBLE_DEVICES=0,1 your-image nvidia-smi
这个变量的取值可以是 GPU 的 UUID、PCI 总线地址,也可以是索引。需要注意的是,索引是从 0 开始按 nvidia-smi 的展示顺序编排的。如果你把这个变量设成了 all,容器就会看到宿主机上所有的 GPU。多租户共用一台 GPU 服务器时,如果没有正确设置设备可见性,某个服务就可能把其他服务的显存挤爆。这块一定要做约束。
顺带提一句 NVIDIA_DRIVER_CAPABILITIES 这个环境变量,它决定了容器内可用的驱动能力。默认是 compute,utility,也就是支持 CUDA 运算和 nvidia-smi 工具。如果你的服务还需要用 NVENC 做视频编码(比如跑视觉模型顺带要处理视频流),需要显式加 video 能力:
bash复制docker run --rm --gpus all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,video your-image
如果没加,调用硬件编码接口时会报功能不可用,很难排查。
5. 模型权重不能打进镜像:数据与代码分离的挂载设计
这个部分是我最想强调的。把模型权重直接 COPY 进镜像,短期看着省事,长期就是灾难。一个 70B 的模型光权重文件就有 140GB 左右,如果打进镜像,镜像仓库存储、镜像拉取、版本更新都会变成一场灾难。镜像的 Layer 是不可变的,这意味着权重哪怕只更新一个字节,构建出来的新镜像也要重新 push 和 pull 整个 Layer。
5.1 为什么镜像仓库会“爆掉”
很多团队初期图省事,把模型权重打包进镜像。等模型迭代到第二版、第三版,私有镜像仓库的磁盘占用会呈倍数增长。更麻烦的是,CI/CD 流水线的构建时长会飙升——每次拉取一个十几个 GB 的镜像,网络慢一点的机房直接让人崩溃。
我现在的习惯是:Docker 镜像里只装运行环境和代码,模型文件一律通过 Volume 或 bind mount 挂载。这样当模型更新时,不需要重新构建镜像,只需要替换宿主机上对应的权重文件,再重启容器即可。镜像仓库的负担降到最低,部署的灵活性大幅提高。
下面是一个 docker-compose 的挂载示例片段:
yaml复制services:
llm-server:
image: registry.example.com/llm-server:1.4.2
ports:
- "8000:8000"
volumes:
- /data/models:/models:ro
- model-cache:/root/.cache
environment:
- MODEL_PATH=/models/Qwen2-7B-Instruct
/data/models 是宿主机上的共享模型目录,挂载到容器的 /models,并以只读方式挂载(:ro)。这样做还有一个额外好处:多个容器副本可以共享同一份权重文件,避免了每副本各存一份的物理磁盘浪费。模型在一个共享目录里统一管理,用符号链接来切换版本:
bash复制/data/models/
├── Qwen2-7B-Instruct -> /data/models/.store/Qwen2-7B-Instruct-v1.2
├── .store/
│ ├── Qwen2-7B-Instruct-v1.0/
│ ├── Qwen2-7B-Instruct-v1.2/
│ └── Qwen2-14B-Chat-v2.1/
需要切版本时,把软链重新指一下,重启容器,完成。整个过程不改镜像、不动代码。
5.2 挂载方式的选择:bind mount vs volume
Docker 有两种数据持久化方式,常常让人困惑。这里做个直白的区分:
- bind mount:直接把宿主机的一个目录映射进容器。优点是路径直观、宿主机上就能读文件,适合模型这种需要人工管理和替换的大文件;缺点是跨主机迁移时不方便,也没有隔离性。
- named volume:由 Docker 管理,实际数据在
/var/lib/docker/volumes/之下。好处是 Docker CLI 可以直接操作,适合放缓存等动态数据;坏处是你很难直接到宿主机目录里翻文件,容易觉得“文件不知道藏哪了”。
我的经验是:模型权重用 bind mount,缓存目录(比如 Hugging Face 的 ~/.cache/huggingface)用 named volume。因为权重属于需要人为管理的静态资产,放在一个你自己熟悉的目录下操作更舒心;缓存则完全是 Docker 内部的东西,该谁管谁管。
你们注意看上面那个 compose 文件,我加了一个 model-cache:/root/.cache 挂载。这个细节很微妙。模型推理引擎在加载模型时,通常要读 tokenizer 文件、配置文件,有时还会下一些额外的资源。如果不挂缓存目录,容器每次重启都可能重新下载这些零碎文件,白白消耗时间。挂上缓存目录后,这些内容一次下载,永久复用。
5.3 文件权限这关,躲不过去
bind mount 最容易埋坑的就是文件权限。容器内默认以 root 用户运行,或者像我们前面 Dockerfile 里那样创建了一个 appuser。宿主机挂载进来的文件,所有者是宿主机的 UID/GID。如果容器内用户对文件没有读权限,启动时就会出现 Permission denied。
我的处理方式很朴素:把所有模型文件在宿主机上统一 chown 到一个固定 UID,容器内也用同一个 UID 运行。比如宿主机上模型目录属主 UID 是 1000,那 Dockerfile 里的用户也设成 UID 1000:
dockerfile复制RUN useradd -m -u 1000 appuser
USER appuser
反正宿主机用户 UID 1000 在容器里表现为同 UID 的 appuser,权限完全对得上。如果你不想动宿主机的文件属主,还有一个思路是容器启动时用一段入口脚本临时调整权限或切换用户,但这样做会把启动过程搞复杂,不如一开始就把 UID 对齐。
6. 编排与上线的几个细节:Compose 配置、健康检查、并发控制
如果你只部署一个服务,手动 docker run 就够了。但一个真实可用的模型推理服务,往往还有其他配套组件,比如存储、日志、反向代理。当多个容器要协同工作时,就需要一个编排工具来把它们组织起来。
6.1 Docker Compose 资源上限怎么配
docker-compose.yml 是本地编排最舒服的方式。它能让你把容器的启动参数、端口映射、卷挂载写在一份 YAML 里,然后通过 docker compose up -d 一键拉起。
模型推理服务有一个特殊性:它对显存的需求是刚性的,配置不够直接 OOM,配置多了又浪费。Docker Compose 里可以通过 deploy 字段来约束资源。注意,deploy 在默认的 docker compose 下能对资源做一定限制,但完整的 swarm 资源调度特性需要配合 Swarm 或 K8s 才有意义。
yaml复制services:
llm-server:
image: registry.example.com/llm-server:1.4.2
ports:
- "8000:8000"
volumes:
- /data/models:/models:ro
environment:
- MODEL_PATH=/models/Qwen2-7B-Instruct
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
这段配置的含义是:给这个容器预留 1 块 GPU。如果没有这段 reservations 配置,有的容器运行时的 --gpus 参数就找不到对应的接口,服务起不来。用 count: 1 而不是 count: all,在多人共用服务器的时候尤为重要,能精确控制每个服务占用的卡数。
CPU 和内存限制也不要省。模型推理不是纯 CPU 密集型,但加载模型、数据预处理、后处理都要吃 CPU。内存方面则需要预留足够空间给模型权重和推理过程的中间张量。一般建议最大内存不要超过物理内存的 70%,避免容器之间互相争抢导致整机卡死。
6.2 健康检查:不要用 curl 去探 GPU 推理接口
给容器加健康检查是好习惯,但模型服务有自己的脾性。我见过很多人照抄 Web 服务的健康检查方式,用 curl 每 5 秒去请求一次 /health 接口。这放在普通 Web 服务上没问题,但放在大模型推理服务上就是个隐患。
原因在于,不少推理引擎在处理请求时会占用显存,如果 /health 的实现不当,或者刚好撞上模型正在执行推理,频繁的探测请求可能干扰甚至拉长推理时间。如果你的 /health 端点内部没有独立于推理逻辑,它就是一个 FastAPI 路由,本身开销很小,问题不大。但如果你用的是那些“探测即验证模型可推理”的高级检查,比如每次 health check 都往模型里塞一个空请求验证推理链路——那千万收敛频率,默认 30 秒间隔太密集,建议调成 60 秒以上。
更稳妥的做法是:健康检查只看进程存活性,不触发真正的推理。例如检查一个标志文件是否存在,或者检查一个轻量的内存指标接口。
yaml复制healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)"]
interval: 60s
timeout: 10s
retries: 3
start_period: 120s
注意这里我特意写了 start_period: 120s。模型服务启动时要加载大量权重,即使 SSD 上也可能需要一两分钟。没有 start_period 的话,容器会在这段时间里被反复判定为 unhealthy 并尝试重启,造成死循环。如果权重在机械硬盘上,start_period 恐怕要调到 300s 以上。
6.3 显存规划:估算与实测
模型推理服务的显存消耗,主要是模型参数占用的权重显存加运行时的激活/KV Cache。很多人问:我的卡能不能跑这个模型?给你一个简化公式做前期估算:
模型显存(GB)≈ 参数量(B)× 每个参数字节数
参数精度与字节数的关系:
- FP32: 4 字节
- FP16/BF16: 2 字节
- INT8: 1 字节
- INT4: 0.5 字节
以 7B 模型为例,如果用 FP16(2 字节)加载,权重本身约占 14GB,再加上推理时的激活值和 KV Cache,实际需要约 16~18GB。这个估算没有算上 CUDA context 的开销,实际在 24GB 的卡上会比较紧张。如果再考虑并发请求,每多一个并发就多一份 KV Cache 开销。
我的建议是:先在单卡上跑通一个最小 demo,用 nvidia-smi 观察显存占用,再根据观察值按 1.3 倍冗余去做资源预留。初期的理论估算只能帮你缩小选型范围,实际负载测试才是确定部署方案的黄金标准。
6.4 从 Compose 到 K8s:设备插件要注意什么
当服务规模超过单机、需要多副本和自动伸缩时,就该考虑上 K8s。但 K8s 对 GPU 的暴露方式和 Docker 完全不同。在 Docker 里,你通过 --gpus 让容器能看到 GPU;在 K8s 里,你要先部署 NVIDIA 的 device plugin,让 kubelet 能感知到节点上的 GPU 资源,并在 Pod 的 YAML 里以 nvidia.com/gpu 这种扩展资源的形式申请。
yaml复制resources:
limits:
nvidia.com/gpu: 1
如果你已经在 Docker 阶段把镜像、挂载、环境变量这些基础工作做扎实了,迁到 K8s 只是把 容器编排语言 从 Compose YAML 改写成 Pod/Deployment YAML,工作量并不会太大。反而是存储方案要重新设计:Docker 阶段的 bind mount 路径在 K8s 多节点场景下不适用,需要引入共享存储(如 NFS、S3 挂载或 PV/PVC)。这也是为什么我在前面反复强调不要“模型打包进镜像”——在 K8s 多节点集群里,用独立的存储资源管理权重是天然合理的解耦方式。
7. 大模型推理特有的几个坑:实测中容易翻车的地方
前面讲的都是通用部署命题,但大模型推理服务有几个特有的问题,几乎每个人都会遇到,我用我自己的踩坑经历来讲,帮你提前避掉。
7.1 加载时间太长?可能是没接对权重格式
如果你部署的是 Hugging Face 格式的模型,加载时用 safetensors 比 pytorch_model.bin 快不少。safetensors 是一种零拷贝的序列化格式,不需要反序列化整个 Python 对象图,加载速度和内存占用都比 bin 格式有优势。你在下载模型时,建议优先选 safetensors 版本。
还有个更快的方案是直接把权重转换成推理引擎的专用格式,比如用 vLLM 或者 TensorRT-LLM 部署时,可以先用离线脚本把模型权重转成引擎格式,运行时加载只需要读取已经优化好的引擎文件。缺点是多一道转换流程,模型更新时要重新转。取舍原则:如果服务要长期稳定跑一个模型,值得转换;如果经常换模型试探,直接用 safetensors + PyTorch 加载更灵活。
7.2 并发拉高后的 OOM,不只是显存的事
你会遇到一种情况:单请求推理一切正常,并发稍微一上来,容器直接被杀。查 nvidia-smi,显存似乎还有剩余。这种崩溃很多时候不是显存不够,而是 CPU 内存不足或达到了容器内存 limit。大模型推理引擎默认会在显存里缓存 KV Cache,但请求排队时,输入 token 的 prefill 也会在 CPU 侧产生大量中间张量。如果容器的内存上限设得太低,进程就会被 OOM Killer 干掉。
解决方案分几个层面:
- 给容器设置合理的内存上限,给推理引擎留出 CPU 内存余量;
- 在推理引擎侧限制最大并发数。vLLM 里有
--max-num-seqs,Ollama 里有OLLAMA_NUM_PARALLEL,SGLang 也有对应的参数。把并发数限制在显存能承受的范围内,好过让负载无限制涌入然后整体崩溃; - 设置请求排队机制,超出容量的请求等待而不是直接开跑。
7.3 镜像仓库与本地缓存:别把时间浪费在传大文件上
模型推理服务镜像因为装了 CUDA 库和 Python 推理框架,体积通常不会小。如果镜像本身有 5GB,每次版本更新都要重新推拉一遍,在带宽有限的场景里消耗很大。
我建议做两件事。第一,镜像和权重文件分开管理,这在前文已经反复提到了;第二,建立局部的镜像缓存或使用更高效的镜像仓库。一个比较实用的本地化方案是搭建一个 Docker Registry 作为内网镜像源,节点从内网拉镜像,速度会有质的提升。
在你需要在多台机器上跑同一个模型镜像时,还可以用 docker save 和 docker load 的组合做离线搬运:
bash复制docker save your-image:1.4.2 | gzip > image.tar.gz
scp image.tar.gz user@target-server:/data/
ssh user@target-server "zcat /data/image.tar.gz | docker load"
第一次迁移时,直接传内网拉取或离线导入,比在每台机器上慢慢 docker pull 靠谱得多。
7.4 启动脚本的优雅退出与信号处理
最后一个容易被忽略的细节是容器的优雅退出。模型服务加载了几 GB 的权重,如果收到 SIGKILL 被强制杀掉,下次重启又得重新加载,白白浪费几分钟。正确做法是给容器发 SIGTERM,让服务先停止接受新请求、处理完手头请求、保存必要状态,然后退出。
FastAPI + Uvicorn 这类服务对 SIGTERM 的处理比较成熟,但你在自定义入口脚本时要注意不要用 shell 的 exec 把它吞掉。正确的启动方式是用 exec 让 Python 进程成为容器的主进程(PID 1),这样 Docker 发送的 SIGTERM 能被 Python 进程直接接收到:
bash复制#!/bin/bash
exec python3 serve.py
如果你写的是 python3 serve.py 而没有 exec,bash 会成为一个中间父进程,信号转发行为在复杂场景下容易出偏差,会导致容器停止超时、被强制 SIGKILL,优雅退出形同虚设。
8. 几个真实场景的部署速写
说了一堆理论,最后分享三个我实际配置过的场景。这些案例能帮你把前面的知识点串起来,照着抄作业也行。
8.1 场景一:单卡 7B 模型的 OpenAI 兼容 API 服务
这是最经典的需求——把本地模型暴露成 OpenAI 兼容接口,方便接各类 Chat 应用。我推荐直接用 vLLM 容器,官方镜像自带 OpenAI 兼容服务。
yaml复制services:
vllm:
image: vllm/vllm-openai:v0.5.4
command:
- --model
- /models/Qwen2-7B-Instruct
- --served-model-name
- qwen2-7b
- --max-num-seqs
- "8"
- --gpu-memory-utilization
- "0.9"
volumes:
- /data/models:/models:ro
ports:
- "8000:8000"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
这里 --gpu-memory-utilization 0.9 表示 vLLM 最多使用 90% 的显存作为 KV Cache 池。不要设成 1.0,你需要留一点显存给 CUDA context 和偶尔的碎片开销。设成 1.0 之后,有时候会在并发稍高时莫名报 OOM,实际是预留给 CUDA 的 context 无处安放。--max-num-seqs 控制的是并行序列数,别太小也别太大。我一般按每张卡一个序列约 1.5~2GB KV Cache 的量级来估,24GB 卡跑 7B 模型时,8 个并发是比较稳的起点。
8.2 场景二:全托管 LLM 推理的本地化 GitOps 管道
这个场景更接近生产环境:模型管理、数据集、API 网关全都统一纳入一套 CI/CD 管道。镜像只负责从产物仓库拉代码和权重,部署过程通过 Git 仓库里的环境配置来声明。
大致流程是:
- 研究员把模型 checkpoint 推到对象存储或模型仓库(如 Hugging Face Hub 的私有仓库、MinIO);
- CI 流水线里只做两件事:构建应用镜像、把模型文件的版本号和 URL 写入部署配置文件;
- CD 组件(如 ArgoCD)在目标环境发布时,通过 initContainer 或 sidecar 下载指定版本的权重到共享存储;
- 主容器启动时只关心本地路径是否存在权重文件。
这种方案的好处是权重文件不经过 Git 仓库,也不经过镜像仓库,而是单独走大文件分发通道。像 14B 以上的模型,动辄几十 GB,走 Git 不现实,走镜像仓库又太“伤”,绑定对象存储或模型仓库是最稳妥的路线。
8.3 场景三:多个模型共存一台 GPU 服务器
你手头只有一张 A100 或几台卡,但想同时跑多个小模型(比如一个翻译模型 + 一个向量化模型 + 一个小参数对话模型)。最简单的做法不是把三个模型塞进同一个 Python 进程,而是每个模型起一个容器,各占一部分显存,通过端口区分服务。
这里要精确控制每块儿的显存占用。举个例子:80GB 的 A100,跑一个 7B 的对话模型(约 20GB),跑一个 embedding 模型(约 2GB),再跑一个 reranker(约 2GB),余量给系统和其他进程。每个容器只分配它需要的显存区间,物理上用 NVIDIA_VISIBLE_DEVICES 约束到同一张卡,逻辑上用不同的 CUDA context 隔离。
bash复制docker run -d --name chat-server \
-e NVIDIA_VISIBLE_DEVICES=0 \
-e CUDA_DEVICE_ORDER=PCI_BUS_ID \
-p 8000:8000 \
-v /data/models:/models:ro \
your-chat-image
docker run -d --name embedding-server \
-e NVIDIA_VISIBLE_DEVICES=0 \
-e CUDA_DEVICE_ORDER=PCI_BUS_ID \
-p 8001:8001 \
-v /data/models:/models:ro \
your-embedding-image
如果你发现某个模型启动时把显存全占了(比如某些推理引擎默认会吃掉所有空闲显存),就要去它的配置里把显存上限调一下。Transformers 里可用 max_memory 参数,vLLM 用 --gpu-memory-utilization,Ollama 则靠 OLLAMA_MAX_LOADED_MODELS 和 KV Cache 的量化开关间接控制。核心思路是:服务间显存隔离必须显式设置,不能靠自觉。
9. 最后的排查清单与个人体会
我把日常排查会用到的命令和思路集中放在这里。遇到问题,按这个顺序过一遍,能解决十有八九的问题。
9.1 一套自检顺序
先确认最底层的基础设施是否正常。在宿主机上跑 nvidia-smi,确认驱动在,卡没被其他进程占满;再 docker info | grep -i runtime,确认 nvidia runtime 在;接着 docker run --rm --gpus all <cuda-base> nvidia-smi,确认容器能透传 GPU。如果这三步都过不了,后面就不用查了。
然后检查容器内日志。模型服务一般会打印加载进度和报错堆栈,docker logs <container> 能看到。如果容器反复重启,用 docker logs --tail 200 <container> 看最后 200 行,重点找 CUDA、OOM、Permission 字样。
如果服务起来了但请求报错,先分清楚是“模型前处理/后处理问题”还是“推理引擎问题”。简单粗暴的分法是:直接给服务发一个最简单的请求,如果简单请求都失败,大概率是模型加载或配置问题;如果简单请求能通、复杂请求失败,再检查上下文长度、KV Cache 这些偏引擎侧的参数。
9.2 容器模型部署常见问题表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 容器启动即退出 | 模型路径不存在或权限不足 | 检查挂载路径、文件属主 |
| 容器内跑不了 GPU 程序 | nvidia runtime 未配置 | 重跑 nvidia-ctk runtime configure |
CUDA error: no kernel image |
驱动版本过旧、CUDA 版本不兼容 | 对比宿主机驱动与容器 CUDA 要求 |
| 并发一上来就 OOM | 内存 limit 太紧或并发数过大 | 调高内存上限、限制 max-num-seqs |
| 模型加载到一半被杀 | CPU 内存不足 | 查看 dmesg 中的 OOM 记录 |
| 服务启动极慢 | 权重文件在机械盘、冷启动无缓存 | 用 SSD、预热磁盘缓存、加长 start_period |
| 切换模型版本不生效 | 镜像打了旧路径或缓存未失效 | 确认环境变量指向的模型路径、重启容器 |
9.3 我个人的几条铁律
从这几年的部署经验里,我提炼了几条自己一直遵守的原则。这些东西每一条几乎都是踩坑换来的。
第一,镜像永不保存权重,代码永不硬编码路径。模型路径一律通过环境变量注入,这样同一个镜像在测试环境、生产环境之间任意穿梭,不需要改任何文件。硬编码路径会把你绑死在某一台机器上,迁移时就懂这句话的分量。
第二,小步快跑,先验证后铺开。拿到一个新模型,先在单机单卡容器里把推理链路跑通,再用压测工具看延迟和吞吐,最后才考虑多副本和负载均衡。跳过验证直接上集群,出了问题会同时面对“模型问题”和“基础设施问题”两个变量,排障效率会低很多。
第三,所有版本显式锁定。不仅是 Python 库,还有 CUDA 版本、推理引擎版本、基础镜像的 tag。凡是没锁定版本的地方,重建环境时就是不确定因素的来源。把 tag 写成 latest 或者干脆不写,等于随身带了一颗定时炸弹,说不准哪天 docker pull 就拉到一个行为变化的新镜像。
第四,能观测才能优化。容器化部署的一个重要收益,是你能用标准化的方式接入日志、指标、链路追踪。至少在服务里暴露一个 metrics 接口,记录请求延迟、Token 吞吐、显存占用。如果连这些基础数据都没有,所谓“优化”就是盲人摸象。
我早期踩过的最惨的一次坑,是宿主机驱动升级后忘了重启 Docker,导致所有 GPU 容器要么起不来、要么疯狂报错。从那以后,“改完驱动必重启 Docker 并跑一次 GPU 透传验证”就写进了我的操作清单。这类基础设施层面的问题,光靠代码很难兜住,必须靠流程和习惯来兜底。
容器化部署从来不是一锤子买卖。每一次镜像构建、每一次 GPU 资源分配、每一份模型权重的挂载路径,都在为后续的运维复杂度做加减法。如果你现在正准备把模型搬进容器,我的建议是从最小的镜像和最简单的挂载开始跑,跑通了再逐步加编排、加多副本、加自动伸缩。想清楚每个环节为什么这么做,比背下一堆命令重要得多。
