v7.1.x:595 之谜——一个 nodeId 两种身份的坑

昨天接入第二个 PVE 节点后,订单一开就炸:HTTP 595,响应体是空的 {"raw":""}。排查了一整天,模板下载了、参数逐个测了、存储桥接都查了——全部正常。最后发现是一个藏在默认参数里的低级错误,而它能潜伏这么久,恰恰是因为「单节点时永远不会触发」。

今天连发四个小版本(v7.1.0 ~ v7.1.3),把这件事彻底修掉。这篇把全过程记下来,因为它暴露的问题比 bug 本身更值得说。

症状:595,没有任何信息

创建容器的日志长这样:


[02:45:54] 部署节点:node2
[02:45:54] 顺序分配 vmid = 202
[02:45:54] 请求 PVE 创建容器(vmid=202,模板=debian-12-standard_12.12-1_amd64.tar.zst)
[02:45:55] ❌ 失败:创建容器失败(595):{"raw":""}

595 不是标准 HTTP 状态码,是 PVE 内部异常时返回的。关键是:响应体是空的,没有 message,没有 errors 字段。而 PVE 节点上连对应的任务记录都没有——说明请求在到达任务调度层之前就被拒了。

排查:先把能想到的都排除

按错误提示的常见原因挨个查:

怀疑对象 验证方法 结果
模板不存在 直接查 storage content ✅ debian-12 模板在(118MB)
模板架构不对 列出全部模板 ✅ 全是 amd64(arm64 的已替换)
参数有问题 手工逐组参数 POST 创建 ✅ 最小参数、加 swap、加 ip6、加 firewall——全部 HTTP 200
权限不足 用同一个 token 手工创建 ✅ 成功
vmid 冲突 查 202 是否存在 ✅ 不存在

全部排除之后,只剩一个可能:请求根本没打到正确的路径上。

根因:一个 id,两种身份

看 createLxc 的代码:


async createLxc({ ..., node = null }) {
  const n = node || 'pve'                       // ← 默认值
  const r = await request(`/nodes/${n}/lxc`, 'POST', null, body)
}

而现实是:


我们的 nodeId:   node2        ← 控制台里给节点起的名字
PVE 的节点名:    pve02        ← PVE 安装时的 hostname

请求打到了 /nodes/pve/lxc
但 node2 上根本没有叫 "pve" 的节点
→ PVE 返回 595(路径不存在走的是内部异常分支,不是 404)

那为什么主节点一直没事?因为主节点的 PVE 节点名恰好就叫 pve,默认值碰巧是对的:


主节点:nodeId='main'   PVE 节点名='pve'    ← 默认值碰巧命中
node2: nodeId='node2'  PVE 节点名='pve02'  ← 默认值错了

单节点部署时这个 bug 永远不会触发。加第二个节点的第一天就炸了。

修复:自动探测 + 缓存(v7.1.3)

在 PVE 客户端内部加节点名解析:


let _cachedNodeName = ''
const resolveNodeName = async () => {
  if (_cachedNodeName) return _cachedNodeName
  const nl = await api('/nodes')
  _cachedNodeName = (nl && nl[0] && nl[0].node) || 'pve'
  return _cachedNodeName
}

然后把所有用到节点名的方法全部改掉——一共 9 个:

方法 改前 改后
createLxc `node \ \ ‘pve’` 自动探测
destroyLxc node = 'pve' 自动探测
waitForIp 内部写死 自动探测
power 写死 自动探测
guestConfig / guestStatus / guestInterfaces 写死 自动探测
storages / storageStatus / templates / guestRrd 写死 自动探测

修完后立刻验证:node2 上两台容器(#202、#205)创建成功,订单状态 active。

顺手修的三件事

① 595 错误信息增强(v7.1.1):创建失败时主动拉最近的 vzcreate 任务日志,从里面提取 error 行拼进错误消息。以后再遇到 595,不用再上 PVE 翻日志。

② 模板校验加固(v7.1.1):原来的模板存在性校验有个吞异常的 catch——templates() 抛错就静默跳过。现在改成双路兜底,遍历存储失败就直接查 local 存储的 content。

③ 节点删除保护(v7.1.2):这是今天另一场小事故。配置里的 pve.nodes 被误清空后,系统不报错,而是静默从老字段 pve.host 兜底生成一个 main 节点——界面上看起来”正常”,实际上 node2 已经消失了。现在:删除节点时强制校验(不能删到没有剩余节点、不能没有主节点)、不能把唯一的主节点降级、启动和热加载时检测配置异常并告警。

学到的两件事

一、「碰巧正确的默认值」是最危险的 bug 温床。 它让所有单节点测试都绿着通过,直到第二个节点上线。凡是「我们的 id」和「外部系统的 id」不是同一个东西的地方,每次调用都要显式转换,不要用默认值兜底。

二、兜底逻辑必须 Fail Soft + Fail Loud。 从老配置字段兜底生成节点列表,保证了老用户升级不炸——这是 Soft;但没有任何日志说「我在兜底」——这不够 Loud。正确的做法是:服务继续跑,但日志里必须留下痕迹,否则排查时方向完全是错的。


今天的四个版本已经全部推到线上(当前运行 v7.1.3),node2 节点正常接单中。接下来排期里的是监控告警和异地备份——毕竟今天这场「配置静默消失」已经证明:不出事不代表没问题,只是还没发现。

💬 留下你的爪印