NAS 放模型,GPU 跑 vLLM:一次启动卡住的排查记录

上周我们把内部 AI 服务从"单机测试"挪到"小团队自托管"模式:NAS 统一放模型,GPU 服务器只跑 vLLM,前面接一个 OpenAI-compatible 网关。架构看起来很干净,实际启动时卡住了。

现象也很迷惑:镜像拉下来了,容器是 running,GPU 服务器的 nvidia-smi 也正常,但 /health 一直不通。网关侧只看到 upstream timeout,业务同学看到的则是"模型服务还没起来"。

这篇不是 vLLM 参数大全,而是一次排查记录。重点是我们怎么把"启动慢"拆成镜像、NAS、权限、GPU runtime 和健康检查几段,最后把链路固定下来。

背景:为什么要把模型放 NAS

团队里有几台 GPU 服务器,但模型版本越来越多:

  • 基础模型有多个尺寸。
  • RAG 服务和代码助手使用的 tokenizer、配置、量化版本不同。
  • 有些模型只是临时评测,不适合复制到每台机器。
  • GPU 服务器本机盘更想留给缓存、日志和短期实验数据。

所以我们选了一个折中方案:

部分 放在哪里 原因
模型源目录 NAS 统一管理版本,避免多台机器重复保存
vLLM 容器 GPU 服务器 贴近 GPU,减少推理链路复杂度
HF / Torch 缓存 GPU 本机 SSD 避免推理服务往 NAS 写临时文件
API 网关 内网服务节点 对上游隐藏模型加载和实例切换细节

这个方案能跑,但启动链路比单机复杂。排查时不能只盯着 vLLM 日志。

故障现场

当时服务启动命令大概是这样:

bash 复制代码
docker run -d \
  --name vllm-qwen \
  --gpus all \
  --ipc=host \
  -p 8000:8000 \
  -v /mnt/models:/models:ro \
  -v /data/vllm-cache:/cache \
  -e HF_HOME=/cache/hf \
  vllm/vllm-openai:latest \
  --model /models/Qwen3-32B \
  --served-model-name qwen3 \
  --host 0.0.0.0 \
  --port 8000

外部看到的问题:

bash 复制代码
curl -fsS http://127.0.0.1:8000/health
# curl: (7) Failed to connect to 127.0.0.1 port 8000

日志里没有一个很明确的"根因错误",更像是长时间停在模型加载阶段。

我们把排查过程整理成了这张表:

顺序 排查层 当时结论
1 镜像拉取 不是根因,但需要固定版本,避免不同节点环境不一致
2 NAS 挂载 宿主机能看到模型,容器内路径需要单独验证
3 权限与目录结构 有一台机器容器用户读不到部分文件
4 NAS 读速 首次加载慢,目录遍历和大文件读取都要测
5 GPU runtime 宿主机正常不等于容器正常
6 health 设计 冷启动窗口太短会造成误判和反复重启

1. 先把镜像层固定下来

我们先没有改 vLLM 参数,而是把镜像层单独验证。

bash 复制代码
docker pull vllm/vllm-openai:latest
docker image inspect vllm/vllm-openai:latest --format '{{.Id}} {{.Size}}'
docker image inspect vllm/vllm-openai:latest --format '{{json .RepoDigests}}'

再确认镜像内部能导入 vLLM 和 PyTorch:

bash 复制代码
docker run --rm --entrypoint python3 vllm/vllm-openai:latest -c \
'import torch, vllm; print(torch.__version__); print(torch.version.cuda); print(vllm.__version__)'

有一台新 GPU 服务器在 Docker Hub 拉取上不稳定。我们在这个阶段用毫秒镜像(1ms.run)的 Docker Hub 同名入口做了对照验证:

bash 复制代码
docker pull docker.1ms.run/vllm/vllm-openai:latest

这里的目的很窄:只排除"镜像入口不稳定、不同节点镜像不一致"这一层。它不会解决 NAS 权限、GPU runtime 或 vLLM ready 问题。排查记录里把这一点写清楚,后面讨论就不容易跑偏。

最终我们决定:生产启动不再裸用 latest,至少记录镜像 digest;关键环境固定版本标签。

2. NAS 路径要从容器视角看

宿主机上看模型目录是正常的:

bash 复制代码
findmnt -T /mnt/models
df -hT /mnt/models
ls -lah /mnt/models/Qwen3-32B | head

但这个结果只能说明宿主机看到 NAS。vLLM 真正运行在容器里,所以必须进容器看一次。

bash 复制代码
docker run --rm \
  -v /mnt/models:/models:ro \
  --entrypoint bash \
  vllm/vllm-openai:latest \
  -lc 'id; ls -lah /models; ls -lah /models/Qwen3-32B | head'

这一步发现一个小问题:有台机器的 NAS 挂载点是 /data/models,但 compose 里仍然写 /mnt/models。宿主机上人工 ls 查的是正确目录,容器挂进去的却是空目录。

这个问题很普通,但在大模型启动里会被放大。因为日志不一定直接写"你挂错目录了",有时会表现成 tokenizer、config、权重文件加载失败。

我们后来给上线检查加了一条容器内验证:

bash 复制代码
docker run --rm \
  -v /mnt/models:/models:ro \
  --entrypoint bash \
  vllm/vllm-openai:latest \
  -lc 'test -r /models/Qwen3-32B/config.json && echo model-path-ok'

只有输出 model-path-ok,才进入下一步。

3. 权限问题不要靠猜

NAS 目录来自同步任务,文件属主在不同机器上不完全一致。宿主机用户能读,不代表容器用户能读。

我们先看数字 UID:

bash 复制代码
stat /mnt/models/Qwen3-32B
ls -ln /mnt/models/Qwen3-32B | head

再在容器里测:

bash 复制代码
docker run --rm \
  -v /mnt/models:/models:ro \
  --entrypoint bash \
  vllm/vllm-openai:latest \
  -lc 'id; test -r /models/Qwen3-32B/config.json && echo readable'

问题出在一台机器的 NAS ACL 上:目录能列出来,但部分权重分片对容器用户不可读。vLLM 加载到对应分片时才卡住或失败。

我们没有把整个模型目录改成全局可写,而是给推理服务使用的用户组读权限:

bash 复制代码
sudo chgrp -R inference /mnt/models/Qwen3-32B
sudo chmod -R g+rX /mnt/models/Qwen3-32B

同时保持只读挂载:

bash 复制代码
-v /mnt/models:/models:ro

缓存单独放本机:

bash 复制代码
mkdir -p /data/vllm-cache/hf /data/vllm-cache/torch

这个取舍很关键:模型目录是共享资产,推理服务不应该顺手把临时文件、下载缓存或日志写进去。

4. NAS 慢不一定会报错

权限修完后,服务能起,但冷启动还是明显慢。这个阶段我们才开始测 NAS 读速。

先测大文件顺序读:

bash 复制代码
time dd if=/mnt/models/Qwen3-32B/model-00001-of-000xx.safetensors \
  of=/dev/null bs=64M count=16 status=progress

再测目录遍历和小文件读取:

bash 复制代码
time find /mnt/models/Qwen3-32B -type f | wc -l

time bash -lc 'for f in /mnt/models/Qwen3-32B/*.json; do head -c 1024 "$f" >/dev/null; done'

这一步的结论是:NAS 足够做模型统一管理,但不适合所有服务都从 NAS 做频繁冷启动。尤其是早上多人测试、服务重建、多个实例同时拉起时,读延迟会被放大。

我们最后做了分层:

场景 模型读取方式
临时评测 直接从 NAS 读,接受启动慢
部门试用 NAS 做源目录,启动前同步到本机 SSD
稳定在线服务 本机 NVMe 放当前模型,NAS 只做版本源

同步可以用很普通的方式:

bash 复制代码
rsync -a --info=progress2 /mnt/models/Qwen3-32B/ /data/models/Qwen3-32B/

然后把 vLLM 的模型路径改成本机目录:

bash 复制代码
--model /data/models/Qwen3-32B

这个改动比继续堆 vLLM 参数更有效。

5. GPU runtime 要独立验证

还有一个误区:宿主机 nvidia-smi 正常,就以为容器 GPU 一定正常。

我们用最小 CUDA 容器测了一次:

bash 复制代码
nvidia-smi
docker run --rm --gpus all nvidia/cuda:12.4.1-runtime-ubuntu22.04 nvidia-smi

其中一台机器宿主机正常,容器内失败。继续查 runtime:

bash 复制代码
docker info | grep -i runtime
nvidia-container-cli info

修复后重新配置 Docker runtime:

bash 复制代码
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

再用 vLLM 镜像测 PyTorch:

bash 复制代码
docker run --rm --gpus all \
  --entrypoint python3 \
  vllm/vllm-openai:latest \
  -c 'import torch; print(torch.cuda.is_available()); print(torch.cuda.device_count())'

只有这里返回 True 和正确卡数,才继续看模型规模、显存利用率和并行参数。

6. health 不能只看容器状态

最后一个问题出在健康检查。我们的编排脚本把容器 running 当成服务可用,网关又很快开始转请求。实际 vLLM 还在加载模型。

后来改成三段判断:

bash 复制代码
docker ps --filter name=vllm-qwen
curl -fsS http://127.0.0.1:8000/health
curl -s http://127.0.0.1:8000/v1/models

再加一个最小 chat completion:

bash 复制代码
curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen3",
    "messages": [{"role": "user", "content": "ping"}],
    "max_tokens": 8
  }'

Compose 里的 healthcheck 也给了冷启动窗口:

yaml 复制代码
healthcheck:
  test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 10
  start_period: 10m

这个 start_period 看起来保守,但对 NAS 冷启动和大模型加载很有必要。否则服务还没 ready,就被重启策略打断,最后变成"越重启越起不来"。

最后的启动模板

最后我们把启动命令收敛成这样:

bash 复制代码
docker run -d \
  --name vllm-qwen \
  --gpus all \
  --ipc=host \
  -p 8000:8000 \
  -v /data/models:/models:ro \
  -v /data/vllm-cache:/cache \
  -e HF_HOME=/cache/hf \
  -e TORCH_HOME=/cache/torch \
  vllm/vllm-openai:latest \
  --model /models/Qwen3-32B \
  --served-model-name qwen3 \
  --host 0.0.0.0 \
  --port 8000 \
  --gpu-memory-utilization 0.90

配套检查脚本只保留几个关键动作:

bash 复制代码
set -e

test -r /data/models/Qwen3-32B/config.json
docker run --rm --gpus all nvidia/cuda:12.4.1-runtime-ubuntu22.04 nvidia-smi >/dev/null

docker logs --tail=100 vllm-qwen
curl -fsS http://127.0.0.1:8000/health
curl -s http://127.0.0.1:8000/v1/models

复盘:这次真正改了什么

这次故障不是一个单独的 vLLM bug,最后改动分散在几层:

层级 改动
镜像 拉取单独验证,记录 digest,避免节点环境漂移
NAS 容器内验证模型路径,不只看宿主机
权限 按用户组给只读权限,不让服务写共享模型目录
存储 NAS 做模型源,高频服务启动前同步到本机 SSD
GPU 容器内单独验证 --gpus all 和 PyTorch CUDA
health /health/v1/models 和最小请求判断 ready

我比较认可这次排查里的一个原则:大模型服务启动慢时,不要急着调最后一层参数。vLLM 日志只是最后的表现,前面还有镜像、共享存储、权限、GPU runtime 和编排健康检查。

把这些边界拆清楚之后,后面换模型、扩 GPU 节点、接新网关,排查会轻很多。对于小团队自托管 AI 服务来说,这比一次性追求"架构漂亮"更实用。

相关推荐
颜酱23 分钟前
# 02 | 搭骨架:用 LangGraph 编排 12 步工作流(思路)
前端·人工智能·后端
颜酱23 分钟前
02 | 搭骨架:用 LangGraph 编排 12 步工作流
前端·人工智能·后端
嘉伟桑1 小时前
Python调用电价API返回JSON示例:分时电价、现货电价和字段解析
后端
武子康2 小时前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
我叫黑大帅2 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
AskHarries2 小时前
文件上传系统
后端
止语Lab3 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活3 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困3 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn3 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端