GPUStack 模型启动报 Errno 99:NAT 后面 worker 的地址绑定陷阱
概述
GPUStack 里加了一台位于防火墙 NAT 后面的 worker,注册一切正常,但部署模型必挂 OSError: [Errno 99] Cannot assign requested address。本文记录从报错到根因(vLLM 的 --host 绑了防火墙地址)、再到正确配置(--worker-ip + --advertise-address)的完整过程,最后附上多 worker 场景的端口段规划方法。
现象
GPUStack 集群里新加了一台 worker,在 Web 控制台看一切正常:worker 在线、READY、GPU 也识别到了。但一部署模型就挂,日志最后一行是:
1 | |
往前翻,vLLM 的启动命令是 GPUStack 自动拼的:
1 | |
Cannot assign requested address 最典型的含义就是:要绑定的 IP 不在本机的网卡上。而这里的 192.168.0.158 确实不在 worker 机器上——它是防火墙(pfSense)的地址。
环境拓扑
1 | |
worker 在一台 PVE 上,处在 pfSense 后的 192.168.1.0/24 内网。master 在 192.168.0.0/24,两边通过 pfSense 互通。
根因:--host 来自 worker 自己上报的地址
GPUStack 给 vLLM 拼命令行时,绑定地址用的不是用户配置,而是 worker 记录里的 ip 字段。翻源码(v2.2.3):
1 | |
而 worker 的注册参数是:
1 | |
也就是说:当初部署 worker 时,--worker-ip 填了 pfSense 的 WAN 地址(大概是想让 master 从这个地址访问 worker)。结果 GPUStack 认为”worker 的 IP 就是 0.158”,于是:
- vLLM 被要求绑
192.168.0.158:40040; - worker 容器是
network_mode: host,直接继承宿主机网络栈; - 宿主机只有
192.168.1.202,没有0.158; bind()失败 →Errno 99。
数据库里也能印证:
1 | |
注册成功 ≠ 能跑模型:心跳是 worker 主动出站上报的,走 NAT 出去完全没问题;但模型容器要绑死一个本机地址,这个地址一旦填错就必然失败。
定位过程
1. 看 worker 容器怎么起的
1 | |
host 网络确认了”容器里没有独立网卡,绑定就是绑定宿主机网卡”。
2. 看 worker 上实际有哪些地址
1 | |
只有 192.168.1.202,没有 192.168.0.158。
3. 看实例记录
1 | |
实例的 worker_ip 是调度时从 worker 记录复制过来的(scheduler.py 里 model_instance.worker_ip = candidate.worker.ip),所以光改 worker 还不够,旧实例不会自动更新。
第一次尝试:给 master 加静态路由(可行但不必要)
master 的默认网关是 192.168.0.1,并不认识 192.168.1.0/24。当时为了先跑通,在 master 上临时加了一条:
1 | |
然后让 worker 用真实地址注册:
1 | |
再删掉旧实例让 controller 重建,模型确实起来了:vLLM 绑 192.168.1.202:40019,master 通过路由直连 1.202:40019 一切正常。
但这条路由只是”能跑”,并没有回答一个问题:防火墙那边是不是本来就有更好的办法?
最终方案:pfSense DNAT + --advertise-address
登录 pfSense 把配置导出(Diagnostics → Backup & Restore 下载 config.xml),发现里面早就有一堆到 worker 的端口转发:
| WAN 端口(192.168.0.158) | 内部目标 | 用途 |
|---|---|---|
| 10150 | 192.168.1.202:10150 | worker API(healthz) |
| 10151 | 192.168.1.202:10151 | worker 指标 |
| 30000-60000 | 192.168.1.202:30000 | 整段 1:1 映射,覆盖 GPUStack 动态分配的实例端口 |
| 20020 | 192.168.1.202:22 | SSH |
| 18443 / 15000 / 15021 / 15090 / 9876 … | 同名端口 | 其他服务 |
也就是说,管理员原本的设计就是”master 走 192.168.0.158:<端口> 访问 worker”。问题只在于 worker 把外部地址当成了绑定地址。
GPUStack 正好为这种场景提供了两个独立参数:
| 参数 | 含义 | 本例取值 |
|---|---|---|
--worker-ip |
本机地址,给容器/推理引擎绑定用 | 192.168.1.202 |
--advertise-address |
对外广播地址,给 server 访问用 | 192.168.0.158 |
最终 docker-compose 配置(省略无关项):
1 | |
改完 docker compose up -d 重建 worker,数据库里的记录变成:
1 | |
实例必须重建
这是容易漏的一步:实例的 worker_advertise_address 是调度那一刻从 worker 复制过来的:
1 | |
已有的实例不会跟着 worker 记录自动更新,所以要在控制台把实例删掉,让 controller 按 replicas 重新拉起。新实例拿到 worker_advertise_address=192.168.0.158 后,master 访问它就会走 pfSense 的转发,不再需要任何路由。
验证
模型起来后,先从 master 直接访问 pfSense 的转发地址(端口以实例实际分配为准):
1 | |
再通过 GPUStack 的 OpenAI 兼容接口发一次请求(需要带 API Key,或复用登录后的会话 Cookie):
1 | |
确认实例状态:
1 | |
返回正常、实例状态 RUNNING 后,master 上的静态路由和 systemd unit 一并撤掉。
延伸:多 worker 时怎么规划端口段
上面是一台 worker 的场景,pfSense 把 30000-60000 整段转给一台机器就够用。一旦同一台 pfSense 后面接多台 worker,同一个 WAN IP 的同一个端口只能指向一台机器,就必须把端口段拆开分配。这一节适用于 WAN 只有单个 IP 的情况;如果 WAN 能再加地址,也可以走后面的 VIP 方案。
端口相关参数
| 参数 | 默认值 | 作用 |
|---|---|---|
--worker-port |
10150 |
worker API(master 健康检查、代理入口) |
--worker-metrics-port |
10151 |
指标端口(Prometheus 抓取) |
--service-port-range |
40000-40063(64 个) |
模型实例端口,worker 在段内随机挑空闲端口,绑在 worker_ip 上 |
--ray-port-range |
41000-41999 |
分布式 vLLM(Ray)使用 |
端口由每个 worker 自己分配,在段内随机取(等价于 get_free_port(port_range, host=worker_ip)),既不顺序递增,也不跨 worker 协调——所以两个 worker 用同一段时,外部根本没法判断某个端口属于谁。
按 worker 拆段
| worker | --worker-port |
--worker-metrics-port |
--service-port-range |
pfSense 转发 |
|---|---|---|---|---|
| worker-A (192.168.1.202) | 10150 | 10151 | 40000-40063 | 10150/10151/40000-40063 → 1.202 同号 |
| worker-B (192.168.1.205) | 10160 | 10161 | 40064-40127 | 10160/10161/40064-40127 → 1.205 同号 |
| worker-C (192.168.1.206) | 10170 | 10171 | 40128-40191 | 10170/10171/40128-40191 → 1.206 同号 |
worker-B 的 compose 片段:
1 | |
几条硬约束:
- 段不能重叠:pfSense 的范围转发是”外部段 → 内部同号段”,重叠时只会命中其中一条。
- 避开 Ray 段(默认
41000-41999):服务段放在40000-40999最稳妥,1000 个端口约等于 15 个 worker(每台 64 个)。 - 段的长度 = 该 worker 的并发实例上限:默认 64 个,不够就放大(例如
40064-40227)。 worker-port也要错开:否则192.168.0.158:10150只能指向一台机器,其余 worker 的健康检查过不去。
怎么确认每台 worker 用的段
1 | |
实例实际端口在 GET /v2/model-instances 的 port 字段里,可以用来验证转发是否生效。
另一种做法:每个 worker 一个 WAN VIP
前提:WAN 上还能再加 IP。上游只给了一个地址时,只能用上面的拆段方案。
worker 多到端口段不够分、或不想改默认段时,可以在 pfSense 上给每个 worker 配一个 Virtual IP(例如 192.168.0.160/161/162),每个 VIP 都转发 10150、10151、40000-40063,worker 的 --advertise-address 填各自的 VIP。代价是每台多占一个地址、规则数量翻倍。
漏配转发的典型症状
实例在 GPUStack 里是 RUNNING(容器起来了,worker 本地 curl 也通),但 master 侧请求超时或 500——因为 advertise_address 指向的端口没有转发到该 worker。看到”实例正常但接口不通”,先去 pfSense 核对端口段。
踩坑清单
Errno 99先查地址归属:ip addr看目标 IP 是否在本机,没有就是绑定地址填错了。- worker 在 NAT/防火墙后面时,绑定地址和对外地址要分开:
--worker-ip填本机网卡地址,--advertise-address填外部可达地址。 - 实例不会自动跟随 worker 的地址变化:
worker_advertise_address在调度时复制,改完 worker 要重建实例。 - 动态端口要预留映射段:GPUStack 的实例端口默认在
40000-40063内随机分配,pfSense 里那条30000-60000的整段转发就是为它准备的(多 worker 时要按段收窄,见上一节);如果只映射单个端口,第二次部署换个端口就又不通了。 - 加路由之前先看防火墙配置:能通过 DNAT 解决的,不要在主机上堆静态路由;路由方案还依赖 master 每次都记得加,换机器、重装系统就丢失。
network_mode: host会放大地址错误:容器不是桥接,没有自己的 IP,worker 上报什么地址,容器就会去绑什么地址。
小结
这类问题的迷惑点在于”注册正常”——worker 的心跳是出站连接,NAT 环境下永远能通;但推理引擎的监听端口要求绑在本机地址上,两者用的是同一个 worker.ip 字段,一旦把外部地址填进去,注册和推理就会一个成功、一个失败。把”绑定地址”和”广播地址”拆开,再配合防火墙的端口转发,才是 NAT 后面 worker 的正确打开方式。