在 HPC 集群上提供模型服务

很多自建用户有 GPU 可用,但这些 GPU 并不归自己所有:硬件由批处理调度器(Slurm、PBS、LSF)统一管理,每次分给你一个计算节点,只能用几个小时。本页介绍如何在这种节点上运行 OpenAI 兼容的模型服务器,并把它接入网关。

和添加新的本地模型里的固定主机相比,这种情况有三点不同:

  • 节点是临时的。每次分配到的主机名都不一样,而且不管你用完没用完,作业都会结束;

  • 网关所在的机器通常访问不到这个节点,而且节点往往也连不上容器镜像仓库;

  • 只要你还占着这次分配,就一直有人在为它付钱或消耗排队优先级,所以干净地释放它是流程的一部分,而不是事后才想起来的收尾。

所以做法是:模型服务器在计算节点上只绑定 loopback,用一条 SSH 反向隧道把它接到网关主机上一个固定的 loopback 端口,网关的路由指向这个固定端口。这样,不断变化的节点主机名不会出现在模型注册表里;换了新的分配,只要重启隧道,不用改配置。

compute node (new hostname each allocation)        gateway host
┌──────────────────────────────┐                   ┌──────────────────────────┐
│ vLLM in a container          │  ssh -R           │ 127.0.0.1:8001           │
│ published on 127.0.0.1:8000  │ ────────────────► │   ▲                      │
└──────────────────────────────┘                   │   │ base_url             │
                                                   │ gateway                  │
                                                   └──────────────────────────┘

下面所有的调度器参数、路径和端口都是占位符。集群策略——分区名、GPU 资源名、walltime 上限、装的是哪种容器运行时——每个集群都不一样,没有通用的默认值。

1. 申请一个节点

用 Slurm 申请交互式节点大致如下,占位符按你们集群的文档填写:

salloc \
  --partition=<gpu-partition> \
  --gres=gpu:<count> \
  --cpus-per-task=<cpus> \
  --mem=<memory> \
  --time=<hh:mm:ss>

很多集群还在这之上封装了自己的命令,以你们文档里写的为准。记下作业 ID 和分配到的节点名——前者用来释放这次分配,后者用来登录:

squeue --me
ssh <allocated-node>

节点名每次分配都会变。不要把它写进模型注册表。

2. 让容器镜像可以离线取用

计算节点通常不能访问外网,在节点上直接拉取服务镜像会失败。应该先在能联网的机器上拉取一次,导出到计算节点能读到的共享存储,再在节点上导入。

要固定一个确切的 tag(或 digest),不要用 :latest。:latest 每次导出都可能是不同的镜像,「上周还好好的」就成了没法复现的问题。

# On a host with registry access
podman pull docker.io/vllm/vllm-openai:<version>
podman save --output /path/to/shared/vllm-openai-<version>.tar \
  docker.io/vllm/vllm-openai:<version>

# On the compute node
podman load --input /path/to/shared/vllm-openai-<version>.tar
podman images

如果集群提供的运行时是 Docker,docker save / docker load 的参数完全一样。

3. 启动模型服务器

podman run --rm \
  --name model-server \
  --device nvidia.com/gpu=all \
  --ipc=host \
  --publish 127.0.0.1:8000:8000 \
  --volume /path/to/model-weights:/models:Z \
  docker.io/vllm/vllm-openai:<version> \
  --model /models/<model-directory> \
  --served-model-name <served-model-name> \
  --host 0.0.0.0 \
  --port 8000 \
  --tensor-parallel-size <gpu-count> \
  --gpu-memory-utilization 0.95

下面几点要理解,不要照抄:

  • --publish 127.0.0.1:8000:8000 让服务器不暴露在集群内网上。只写 -p 8000:8000 会把端口发布到节点的所有网卡上,前面没有任何鉴权——而集群内网上的其他机器都能访问节点的内网接口,不管这次分配是否让你独占这个节点。下一步的反向隧道已经足够让网关访问到它。

  • --host 0.0.0.0 指的是容器的接口,不是节点的。进程必须监听容器的对外接口,上面那条只发布到 loopback 的端口映射才能把请求送进来;如果在容器内绑定 127.0.0.1,发布出去的端口不会有任何响应。

  • --tensor-parallel-size 必须与本次分配中可见的 GPU 数量一致。两块 GPU 就写 2。要求的分片数多于 GPU 数会在启动时失败。

  • --ipc=host 把宿主机的共享内存段交给 vLLM 的张量并行 worker 进程使用;容器默认的共享内存太小,不够它们用。如果集群不允许 --ipc=host,就改为设置一个较大的 --shm-size。

  • --device nvidia.com/gpu=all 是 Podman 的 CDI 写法。Docker 用 --gpus all。

  • :Z 加在卷上,用于给挂载重新打 SELinux 标签,是 Podman/Docker 特有的写法;没有启用 SELinux 的机器上去掉它。

  • --served-model-name 给模型一个稳定的短 id。不加它的话,对外提供的 id 就是 --model 里那个文件系统路径,网关随后就得把这个路径作为 provider_model_id 发出去。

继续之前,先在节点上确认:

curl -s http://127.0.0.1:8000/v1/models

4. 用隧道接到网关主机

从计算节点开一条反向隧道,连到网关主机上一个固定的 loopback 端口。记下 PID,以便之后关闭:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -R 127.0.0.1:8001:127.0.0.1:8000 \
  <user>@<gateway-host> &
echo $! > ~/model-tunnel.pid

# Confirm it is actually up: with ExitOnForwardFailure the ssh may already be
# gone, and the line above would have recorded a dead PID.
sleep 1 && kill -0 "$(cat ~/model-tunnel.pid)" && echo tunnel up
  • 远端要绑定到 127.0.0.1。-R 0.0.0.0:8001:... 等于要求网关主机把一个没有鉴权的模型服务器发布到它所在的每个网络上。而且只有网关主机的 sshd 设了 GatewayPorts yes 才会生效;保持这个选项关闭更安全。

  • ExitOnForwardFailure=yes 的作用是:如果网关主机的 8001 端口还被上一次分配的转发占着,隧道会直接报错退出,而不是连上后悄悄什么都不转发。出现这种情况时,占着端口的是上一次分配留下的 ssh,它在网关主机上,不在你当前的节点上——你本地的 PID 文件管不到它。要到网关主机上清掉它:

    # on the gateway host
    ss -lntp 'sport = :8001'      # or: lsof -nP -iTCP:8001 -sTCP:LISTEN
    kill <the sshd/ssh pid it names>
    
  • 隧道会随这次分配一起消失。要扛住网络抖动,可以把同一条命令放进 autossh、systemd 用户单元或 shell 重试循环里——但作业一结束,这些手段都保不住它。

要在网关主机上验证,因为网关正是在那里解析它的:

curl -s http://127.0.0.1:8001/v1/models

5. 注册路由

现在,网关看到的就是 http://127.0.0.1:8001/v1 上一个普通的 OpenAI 兼容服务器,注册它没有任何特别之处。注册表条目怎么写、openai_compat 路由有哪些字段、如何通过公开的 /v1 API 验证模型,都以添加新的本地模型为准。base_url 填固定的隧道端口,provider_model_id 填你的 --served-model-name。

6. 用完后释放所有资源

跳过这一步会留下三个问题:作业还在白白消耗 walltime,注册表里多了一条失效的路由,网关主机上还残留着一个监听,会导致下一次分配的隧道建立失败。

# 1. Remove or disable the route in the model registry, then restart the
#    gateway, so it stops sending traffic to a port that is about to close.
#    See add-local-model.md.

# 2. On the compute node: close the tunnel and stop the server.
kill "$(cat ~/model-tunnel.pid)" && rm ~/model-tunnel.pid
podman stop model-server

# 3. Release the allocation: exit the salloc shell, or from the login node
scancel <job-id>

然后从网关主机确认端口确实空了出来——下面这条现在应该连不上:

curl -s --max-time 5 http://127.0.0.1:8001/v1/models