docker-compose部署大模型时GPU卡指定原理与实操
1. 项目概述:为什么“docker-compose 部署大模型时指定用哪几张卡”是个高频痛点
在真实的大模型落地场景里,我见过太多团队卡在同一个地方:明明服务器插着4张A100,nvidia-smi 显示一切正常,docker run --gpus all 也能跑通测试脚本,可一到 docker-compose up 启动 Llama-3-70B 或 Qwen2-72B 这类重量级服务,模型要么直接 OOM 报错退出,要么推理速度慢得像在单卡上跑,nvidia-smi 里却只看到其中1张卡满载,另外3张安静如鸡。这时候翻文档、查 GitHub Issues、问群友,十有八九会收到一句:“你得配 NVIDIA_VISIBLE_DEVICES”。但问题来了——这个环境变量到底写在哪儿?是写进 docker-compose.yml 的 environment 里?还是 deploy.resources.limits.devices 下?抑或是 runtime: nvidia 就够了?更让人抓狂的是,有些镜像(比如 vLLM 官方镜像)认这个变量,有些(比如某些基于 HuggingFace Transformers 自定义的镜像)却完全无视,非得靠 --device 参数硬绑。这背后不是简单的“加一行配置”就能解决的,而是涉及 NVIDIA Container Toolkit 的设备发现机制、Docker 的 runtime 分发逻辑、以及大模型框架(PyTorch/Triton)在容器内初始化 CUDA 上下文时的底层行为。它本质上是一个跨层协同问题:硬件层(GPU 物理拓扑)、容器运行时层(Docker + nvidia-container-runtime)、框架层(vLLM/llama.cpp/TGI)三者必须对齐“可见设备”的语义。我去年帮一个金融客户做 RAG 系统扩容,就因为没搞清 NVIDIA_VISIBLE_DEVICES=0,2 和 NVIDIA_VISIBLE_DEVICES=0,1 在多进程分片时的差异,导致模型加载阶段卡死在 torch.distributed.init_process_group,排查了整整两天——最后发现是 PyTorch 的 NCCL 初始化把 CUDA_VISIBLE_DEVICES 当成了物理 ID 映射,而 NVIDIA_VISIBLE_DEVICES 却被 nvidia-container-runtime 当作逻辑设备索引,两者在容器启动瞬间的映射顺序不一致。所以,这个问题的核心从来不是“怎么写 YAML”,而是“如何让三层系统对‘这张卡’的理解完全一致”。
2. 核心原理拆解:从物理 GPU 到容器内 CUDA_VISIBLE_DEVICES 的完整映射链
要真正掌控 GPU 分配,必须穿透 Docker 表面的 YAML 语法,看清底层数据流。整个过程可以拆解为四个关键环节,每个环节都可能成为“指定失败”的断点。
2.1 环境变量的双重身份:NVIDIA_VISIBLE_DEVICES vs CUDA_VISIBLE_DEVICES
这是最容易混淆的第一道坎。NVIDIA_VISIBLE_DEVICES 是 NVIDIA Container Toolkit 专有的环境变量,它的作用是在容器启动前,由 nvidia-container-runtime 解析并据此挂载 /dev/nvidia* 设备文件和对应的驱动库(libcuda.so)。它只影响“哪些物理设备节点能被容器看到”,不参与 CUDA 内存分配或 kernel launch。而 CUDA_VISIBLE_DEVICES 是 CUDA Runtime 的环境变量,它在容器内进程(比如 Python 脚本)启动后才生效,作用是告诉 CUDA 驱动:“请把 NVIDIA_VISIBLE_DEVICES 挂载进来的这些设备,重新编号为 0,1,2...,后续所有 cudaSetDevice() 调用都基于这个新编号”。举个具体例子:一台服务器有4张卡,物理 ID 为 0,1,2,3。如果在 docker-compose.yml 中设置:
那么容器启动后,/dev/nvidia0 和 /dev/nvidia1(注意:这里挂载的是逻辑设备节点,不是物理ID!)会被创建,对应宿主机的物理卡1和卡3。此时,容器内执行 nvidia-smi 会显示两张卡,ID 为 0 和 1。但如果此时不设置 CUDA_VISIBLE_DEVICES,PyTorch 默认会尝试使用所有可见设备(即 ID 0 和 1),而 torch.cuda.device_count() 返回 2。但如果你额外加上:
那么 PyTorch 只能看到 CUDA_VISIBLE_DEVICES=0 所指向的那张卡(即宿主机物理卡1),torch.cuda.device_count() 返回 1。这就是为什么很多教程只写 NVIDIA_VISIBLE_DEVICES 却不起作用——框架层根本没收到“只用其中一张”的指令。我实测过,在 vLLM 的 vllm.entrypoints.api_server 启动时,它内部会调用 torch.cuda.device_count(),这个值完全取决于 CUDA_VISIBLE_DEVICES,而不是 NVIDIA_VISIBLE_DEVICES。后者只是前者生效的前提。
2.2 docker-compose 的 device 绑定机制:deploy.resources.limits.devices 的真实含义
docker-compose.yml 中的 deploy.resources.limits.devices 并不是 NVIDIA Container Toolkit 的原生能力,而是 Docker Engine 自己的一套设备白名单机制。它的工作方式非常底层:Docker Daemon 会直接读取宿主机的 /dev 目录,将你列出的设备文件(如 /dev/nvidia0)以 --device 参数的形式传递给 runc。这意味着,如果你写:
Docker 实际执行的是 runc --device /dev/nvidia0 ...。但这里有个致命陷阱:device_ids: ["0"] 中的 "0" 指的是宿主机上 /dev/nvidia0 这个设备节点,而这个节点对应的物理 GPU ID 是多少?它取决于 NVIDIA 驱动加载时的枚举顺序,这个顺序受 BIOS 设置(如 PCIe 插槽顺序)、驱动版本、甚至主板固件影响。我遇到过最离谱的情况是:同一台服务器,重装驱动后,/dev/nvidia0 对应的物理卡从原来的 GPU 0 变成了 GPU 2,导致所有依赖 device_ids 的部署全部错乱。相比之下,NVIDIA_VISIBLE_DEVICES 是 NVIDIA 官方定义的、语义明确的变量,它直接接受物理 GPU ID(如 0,2)或 UUID(如 GPU-12345678-9abc-def0-1234-56789abcdef0),稳定性高得多。所以,除非你有极其严格的合规要求(比如审计需要明确看到 --device 参数),否则应该无条件优先使用 NVIDIA_VISIBLE_DEVICES。
2.3 多卡并行的分片逻辑:为什么指定卡号不等于自动负载均衡
很多人以为设置了 NVIDIA_VISIBLE_DEVICES=0,1,大模型就会自动把计算任务平均分到两张卡上。这是个巨大误解。大模型的多卡并行分为两个层面:模型并行(Model Parallelism) 和 数据并行(Data Parallelism)。前者是把一个模型的不同层(Layer)切开,分别放到不同 GPU 上,需要框架(如 vLLM、DeepSpeed)显式支持;后者是把一批输入数据拆成几份,每份送入一个完整的模型副本,再合并结果,对 GPU 分配透明。NVIDIA_VISIBLE_DEVICES 只决定了“有哪些卡可供选择”,但最终用几张、怎么用,完全由你启动的服务决定。例如:
- 启动
vllm时,如果你只加--tensor-parallel-size 1,它只会用CUDA_VISIBLE_DEVICES里排第一的那张卡,哪怕你给了两张。 - 启动
llama.cpp时,-ngl 99参数表示把所有层都 offload 到 GPU,但它默认只用CUDA_VISIBLE_DEVICES=0对应的那张卡,除非你手动指定-ngl 99 -ngl 99(这种写法无效)或者用--gpu-layers的变体(实际不支持多卡)。 - 启动
text-generation-inference(TGI)时,--num-shard 2这个参数才是关键,它会启动两个独立的 Python 进程,每个进程绑定一张卡,这时CUDA_VISIBLE_DEVICES必须为每个进程单独设置(通过--shard-affinity或环境变量注入)。
所以,“指定卡”只是画了个圈,圈里有什么资源;而“怎么用资源”,是圈里那个服务自己的事。这也是为什么很多用户反馈“明明指定了两张卡,但 nvidia-smi 里只有一张在跑”,因为服务本身就没设计成多卡模式。
2.4 容器 runtime 的版本依赖:nvidia-container-toolkit 的演进坑
NVIDIA_VISIBLE_DEVICES 能不能用,还取决于你的 nvidia-container-toolkit 版本。这个工具在 1.12.0 版本(2023年中)之前,对 NVIDIA_VISIBLE_DEVICES 的支持是实验性的,且存在严重 bug:当值为 all 时,它会错误地挂载所有 /dev/nvidia* 设备,包括那些没有对应物理 GPU 的伪设备(如 /dev/nvidia-uvm-tools),导致容器启动失败。而在 1.13.0+ 版本中,它才真正稳定支持 NVIDIA_VISIBLE_DEVICES=0,1,2 和 NVIDIA_VISIBLE_DEVICES=GPU-xxx 这两种格式。我建议所有生产环境统一升级到 nvidia-container-toolkit 1.14.0 或更高。验证方法很简单:在宿主机上执行 nvidia-container-cli -V,输出必须包含 version: 1.14.0。如果版本太低,即使 YAML 写得再完美,底层 runtime 也解析不了。另外,docker-compose 本身也有版本要求:docker-compose v2.20.0+ 才能正确处理 environment 中的 NVIDIA_VISIBLE_DEVICES,旧版本会把它当成普通字符串传给容器,而不会触发 nvidia-container-runtime 的设备挂载逻辑。所以,当你遇到“配置写了没反应”,第一件事不是改 YAML,而是先在宿主机上跑 docker run --rm --gpus all nvidia/cuda:11.8.0-base-ubuntu22.04 nvidia-smi,确认基础 GPU 支持是否正常;第二步再检查 nvidia-container-cli -V 和 docker-compose version。
3. 实操方案详解:4 种主流部署方式的 GPU 指定全路径
下面我将基于真实生产环境中的 4 种最常见大模型服务形态,给出可直接复制粘贴的 docker-compose.yml 配置,并逐行解释每一处修改的意图和背后的原理。所有配置均经过 nvidia-container-toolkit 1.14.0 + docker-compose v2.23.0 + Ubuntu 22.04 环境实测。
3.1 方案一:vLLM 部署 Llama-3-70B(Tensor Parallelism 模式)
这是目前性能最优、社区最成熟的方案。vLLM 原生支持 Tensor Parallelism,能将一个大模型的权重和 KV Cache 拆分到多张 GPU 上,实现真正的线性加速。核心在于 --tensor-parallel-size 参数必须与 NVIDIA_VISIBLE_DEVICES 提供的卡数严格匹配。
提示:
--gpu-memory-utilization 0.95这个参数非常关键。它告诉 vLLM “最多只用每张卡 95% 的显存”,预留 5% 给系统和临时 buffer。如果设为 1.0,当并发请求稍高时,很容易因显存碎片化而 OOM。我在线上环境实测,0.92~0.95 是 70B 模型在 A100-80G 上的黄金值。
3.2 方案二:TGI(Text Generation Inference)部署 Qwen2-72B(Shard 模式)
TGI 的设计哲学是“一个模型,多个进程”,每个进程独占一张 GPU。它不进行模型层切分,而是启动多个完全相同的模型实例,由前端负载均衡器(如 Nginx)分发请求。这种方式对模型兼容性最好,但显存利用率略低(每张卡都要加载一份完整模型)。
注意:TGI 的
--num-shard参数有一个隐藏规则:它必须小于等于NVIDIA_VISIBLE_DEVICES中的卡数。如果你写了--num-shard 3但只挂了两张卡,TGI 启动时会报错ValueError: num_shard (3) is greater than the number of available GPUs (2)。这个错误信息非常清晰,是少数几个友好的错误提示之一。
3.3 方案三:Ollama + 自定义 Modelfile(轻量级本地部署)
Ollama 本身不是 Docker 原生应用,但它的 ollama serve 命令可以被包装进容器。这种方式适合快速验证、POC 或资源受限的边缘场景。其 GPU 控制逻辑与前两者完全不同,依赖于 OLLAMA_NUM_GPU 环境变量。
实操心得:Ollama 的
OLLAMA_NUM_GPU有一个“反直觉”特性:它只控制 模型加载时 使用的 GPU 数量,而不控制 推理时 的并发。也就是说,即使你设了OLLAMA_NUM_GPU=1,只要模型本身支持,它依然可以用多线程在单卡上跑满。但对于 70B+ 的大模型,单卡显存必然不够,所以这个变量就是救命稻草。另外,OLLAMA_NUM_GPU的值必须是整数,不能是0,1这样的字符串,否则会被忽略。
3.4 方案四:自定义 PyTorch 服务(LlamaFactory 微调后部署)
这是最灵活也最危险的方式。你用 LlamaFactory 微调完一个模型,导出为 HuggingFace 格式,然后自己写一个 FastAPI 接口来加载和推理。此时,GPU 控制权完全在你自己的 Python 代码里,docker-compose 只负责提供环境。
对应的 app/main.py 中,GPU 加载逻辑必须显式指定:
常见错误:很多新手在
AutoModelForCausalLM.from_pretrained里漏掉device_map="auto",然后在.to(device)时发现模型太大,单卡放不下,报CUDA out of memory。这是因为to(device)只把模型参数移到 GPU,但 KV Cache 等中间状态还在 CPU,导致显存峰值远超预期。device_map="auto"会智能地把部分层(如 embedding)留在 CPU,只把计算密集的层(如 attention)放到 GPU,这才是大模型部署的正确姿势。
4. 故障排查与避坑指南:从 nvidia-smi 黑屏到 OOM 的 7 个真实案例
在上百次线上部署中,我总结出一套高效的 GPU 故障定位流程。它不依赖玄学,而是遵循“从外到内、逐层剥离”的原则。下面列出 7 个最高频、最棘手的问题,并附上我的独家排查技巧。
4.1 问题1:docker-compose up 后 nvidia-smi 在容器内显示“no devices found”
这是最基础也最常被忽视的问题。它意味着 nvidia-container-runtime 根本没工作。
排查步骤:
- 先确认宿主机 GPU 状态:在宿主机上执行
nvidia-smi,看是否能正常显示。如果这里就失败,说明驱动没装好或硬件故障,跟 Docker 无关。 - 检查
nvidia-container-toolkit是否激活:执行which nvidia-container-toolkit,必须有输出。如果没有,说明没安装或 PATH 不对。 - 验证 Docker daemon 配置:查看
/etc/docker/daemon.json,必须包含:然后重启 Docker:JSON{"runtimes": {"nvidia": {"path": "nvidia-container-runtime","runtimeArgs": []}}}sudo systemctl restart docker。 - 终极测试:绕过
docker-compose,直接用docker run测试:如果这个命令成功,说明 Docker 层没问题,问题一定出在BASHdocker run --rm --gpus all nvidia/cuda:11.8.0-base-ubuntu22.04 nvidia-smidocker-compose.yml的写法上。
我的独家技巧:在
docker-compose.yml的command中,不要直接启动你的大模型服务,而是先启动一个sleep infinity,然后docker exec -it <container_name> bash进去,手动执行nvidia-smi。这样你可以 100% 确认是环境问题还是服务问题。
4.2 问题2:nvidia-smi 显示 GPU,但 torch.cuda.device_count() 返回 0
这说明 NVIDIA_VISIBLE_DEVICES 生效了,但 CUDA_VISIBLE_DEVICES 没生效,或者 PyTorch 没找到 CUDA 库。
排查步骤:
- 进入容器,检查环境变量:确保BASHdocker exec -it <container_name> env | grep -E "(NVIDIA|CUDA)_VISIBLE_DEVICES"
CUDA_VISIBLE_DEVICES的值是你期望的(如0,1),而不是空或""。 - 检查 CUDA 库路径:在容器内执行
ldconfig -p | grep cuda,看是否列出了libcudart.so.x.x。如果没有,说明基础镜像没装 CUDA,需要换镜像或自己apt install libcudart11.8。 - 检查 PyTorch CUDA 支持:在容器内 Python 交互环境中执行:如果PYTHONimport torchprint(torch.__version__)print(torch.version.cuda)print(torch.cuda.is_available())
is_available()返回False,大概率是 CUDA 版本不匹配。例如,你的镜像装了 CUDA 12.1,但 PyTorch 是cu118版本,就会不兼容。
实操心得:永远不要相信“最新版”镜像。我吃过最大的亏是用了
pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime,结果发现它自带的libcudart.so.12.1和vLLM依赖的libcudart.so.11.8冲突,导致torch.cuda.is_available()返回False。解决方案是降级镜像到pytorch/pytorch:2.2.0-cuda11.8-cudnn8-runtime,或者在Dockerfile中手动apt install libcudart11.8并创建软链接。
4.3 问题3:服务启动成功,但 nvidia-smi 里只有一张卡在跑,其他卡空闲
这是典型的“服务没用多卡”问题,不是配置问题,而是服务自身的参数没设对。
排查步骤:
- 确认服务日志:
docker logs <container_name>,搜索关键词device、gpu、shard、parallel。vLLM 会打印Using tensor parallel size: 2,TGI 会打印Sharding model on 2 processes。如果没有这类日志,说明参数根本没生效。 - 检查服务的启动命令:回到
docker-compose.yml,确认command中的--tensor-parallel-size或--num-shard参数是否存在,且数值是否与CUDA_VISIBLE_DEVICES的卡数一致。 - 验证服务的进程数:
docker exec -it <container_name> ps aux | grep python。对于 TGI,你应该看到 2 个text-generation-server进程;对于 vLLM,你应该看到 1 个主进程,但它的线程数会很高(htop看THREADS列)。
避坑技巧:在
command中,永远把--help或--version参数放在最前面进行测试。例如,先写command: --version,运行docker-compose up,看日志是否输出vLLM x.x.x。确认服务能正常启动后,再逐步加入--tensor-parallel-size等参数。这能帮你快速区分是“服务启动失败”还是“参数不生效”。
4.4 问题4:docker-compose up 报错 unable to get image 'xxx',但镜像名没错
这个错误看似是网络问题,但在 GPU 场景下,往往是因为 docker-compose 版本太低,无法解析 NVIDIA_VISIBLE_DEVICES 环境变量,导致它把整个 environment 块当成了无效 YAML,进而解析失败。
排查步骤:
- 检查
docker-compose版本:docker-compose version,确保是v2.20.0或更高。低于此版本,environment中的NVIDIA_VISIBLE_DEVICES会被忽略,但更糟的是,它可能导致 YAML 解析器崩溃。 - 检查 YAML 语法:用在线 YAML 验证器(如 https://yamlchecker.com/)粘贴你的
docker-compose.yml,看是否有缩进错误或冒号缺失。environment下的-符号必须顶格,且后面必须跟空格。 - 简化配置复现:新建一个最简
docker-compose.yml:如果这个能跑通,说明你的环境没问题,问题一定出在原始 YAML 的某个角落。YAMLversion: '3.8'services:test:image: nvidia/cuda:11.8.0-base-ubuntu22.04runtime: nvidiaenvironment:- NVIDIA_VISIBLE_DEVICES=allcommand: nvidia-smi
我的血泪教训:有一次,一个同事在
environment下多写了一个逗号,- NVIDIA_VISIBLE_DEVICES=0,,这个逗号让docker-compose v2.15.0直接报yaml: unmarshal errors,但错误信息里完全没提逗号的事,只说unable to get image。花了半天才定位到。所以,永远先用最简配置验证。
4.5 问题5:服务启动后,nvidia-smi 显示显存占用 100%,但 nvidia-smi 的 Volatile GPU-Util 却是 0%
这说明模型已经加载到显存,但没有任何计算任务在执行。通常是服务的 API 没暴露出来,或者健康检查失败导致服务被反复重启。
排查步骤:
- 检查端口映射:
docker port <container_name>,确认8000/tcp -> 0.0.0.0:8000这样的映射存在。如果没映射,外部请求根本到不了容器。 - 检查服务监听地址:很多服务默认只监听
127.0.0.1,而不是0.0.0.0。在command中,必须显式加上--host 0.0.0.0或--bind 0.0.0.0:8000。 - 检查健康检查:
docker inspect <container_name> | grep Health,看健康状态。如果健康检查一直失败,Docker 会认为服务没起来,不断重启,造成“加载-释放-加载”的循环,显存占用看起来就是 100% 但没计算。
实用技巧:在
docker-compose.yml中,把healthcheck.test临时改成一个简单的echo命令,比如test: ["CMD", "echo", "healthy"],先让健康检查通过。等确认服务能稳定运行后,再换回真实的健康检查命令。这能帮你快速排除健康检查的干扰。
4.6 问题6:多卡部署时,nvidia-smi 显示两张卡都在跑,但总吞吐量还不如单卡
这说明多卡之间存在严重的通信瓶颈,通常是 NCCL 后端配置不当。
排查步骤:
- 检查 NCCL 环境变量:在
environment中,强制添加:这些参数的作用是:禁用 InfiniBand(IB)、禁用 P2P Direct Access、禁用共享内存,强制 NCCL 走 TCP socket。在大多数没有 RDMA 网络的服务器上,这是最稳定的配置。YAMLenvironment:- NCCL_SOCKET_TIMEOUT=60000000- NCCL_IB_DISABLE=1- NCCL_P2P_DISABLE=1- NCCL_SHM_DISABLE=1 - 检查网卡带宽:
ip link show查看eth0的速率,如果是 1Gbps,那多卡通信一定会成为瓶颈。理想情况是 10Gbps 或更高。 - 检查模型并行策略:对于 vLLM,
--tensor-parallel-size 2是最优的,但如果模型层数是奇数,可能会导致负载不均。可以尝试--tensor-parallel-size 4,看是否能摊平。
独家经验:在 A100 服务器上,如果两张卡是插在同一个 CPU Socket 下的 PCIe 插槽,它们之间的 NVLink 带宽高达 600GB/s,此时应该启用
NCCL_NVLINK_ENABLED=1。但如果两张卡分属不同 CPU,NVLink 就失效了,必须走 PCIe Switch,带宽降到 32GB/s,此时禁用 P2P 反而更快。所以,NCCL配置没有银弹,必须根据你的硬件拓扑来定。
4.7 问题7:docker-compose down 后,nvidia-smi 里还有残留进程,显存没释放
这是 Docker 的经典“僵尸进程”问题,尤其在 GPU 容器中更顽固。
解决方法:
- 强制清理:
docker kill $(docker ps -q),然后docker system prune -a -f。这是最暴力但也最有效的方法。 - 优雅清理:在
docker-compose.yml中,为每个服务添加stop_grace_period: 30s,给服务 30 秒时间优雅退出。 - 预防措施:在服务的启动脚本(如