在 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,把你的 --served-model-name 填成 provider_model_id

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