Cloudflare 打不通我自己的服务器:error code 1003

前几天给站接 AI 生图,上游是一台自己的服务器。

(下面出现的 IP 都换成了 RFC 5737 的文档保留地址 203.0.113.42,不是真实地址 —— 把源站 IP 写在公开文章里,等于告诉别人怎么绕过 CDN 直接打它。) 本机 curl 一切正常,代码部署到 Cloudflare Pages Functions 上之后,一律 502

这个坑花了我不少时间,而真正的原因跟我最初的判断毫无关系。记一下过程。

第一个错误判断

看到 502,我第一反应是端口。Cloudflare 的 Workers 运行时确实对出网有限制, 而 :8001 明显不是标准端口——听起来非常合理。

于是我得出结论:平台禁止 fetch 非标准端口,改代码绕不过去。

然后我甚至在页面上写了一段说明,告诉用户「把服务挂到 443 端口就能用」。

这个判断是错的。

该早点注意到的信号

502 的响应体是这样的:

error code: 502

纯文本,没有 JSON。而我的代码里明明有 try/catch,任何异常都会被包成 JSON 返回。

这说明我的 catch 根本没执行到。

如果异常发生在 fetch 内部并被我捕获,我会看到自己写的错误信息。看到的却是 Cloudflare 自己的错误页——问题不在「上游返回了什么」,而在「fetch 这个动作 本身没能发出去」。

这个信号当时就在眼前,我没看懂。

让边缘节点自己说话

猜不出来就别猜。我写了个临时的诊断接口,让 Cloudflare 的边缘节点逐个尝试 不同目标,并把真实的 err.nameerr.message 带回来:

const TARGETS = [
  ['真实目标 :8001', 'http://203.0.113.42:8001/v1/models'],
  ['同 IP 端口 80',  'http://203.0.113.42/v1/models'],
  ['同 IP 443',      'https://203.0.113.42/v1/models'],
  ['对照:公网 HTTPS', 'https://api.github.com/meta'],
  ['对照:公网 HTTP',  'http://example.com/'],
];

for (const [label, url] of TARGETS) {
  const t0 = Date.now();
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
    results.push({ label, status: res.status, ms: Date.now() - t0,
                   preview: (await res.text()).slice(0, 200) });
  } catch (err) {
    results.push({ label, errorName: err?.name, errorMessage: err?.message });
  }
}

结果一目了然:

目标状态耗时
203.0.113.42:8001403 error code: 10030ms
203.0.113.42:80403 error code: 10032ms
203.0.113.42:443403 error code: 10031ms
api.github.com20058ms
example.com2008ms

三个端口全部 1003,而且耗时 0-2 毫秒。

真相

error code: 1003Cloudflare 拒绝直接用 IP 字面量访问。跟端口一点关系都没有。

那个 0-2 毫秒是最有力的证据:请求根本没出网,是 Cloudflare 在自己内部就拦掉了。 真要是网络层面连不上,至少会有几十毫秒的超时等待。

而对照组 example.com 和 GitHub 都能通——出网完全正常。我之前那个「平台禁止 非标准端口」的结论,纯属对着一个不相关的变量下判断。

解法

既然拦的是 IP 字面量,那就给它一个域名。有几个服务专门做这件事:把 IP 编进 域名里,解析回那个 IP。

203-0-113-42.traefik.me   →  203.0.113.42
203.0.113.42.sslip.io     →  203.0.113.42
203.0.113.42.nip.io       →  203.0.113.42

不需要注册、不需要配置,改个地址就完事。实测三家都能通,返回了完整的 16 个模型。

我按实测延迟排序,逐个兜底:

const UPSTREAMS = [
  'http://203-0-113-42.traefik.me:8001/v1',   // 602ms
  'http://203.0.113.42.sslip.io:8001/v1',     // 737ms
  'http://203.0.113.42.nip.io:8001/v1',       // 1133ms
];

async function fetchUpstream(path, init) {
  let lastError = null;
  for (const base of UPSTREAMS) {
    try {
      const res = await fetch(`${base}${path}`, init);
      // 5xx 换下一个;4xx 是请求本身有问题,换地址也没用
      if (res.status >= 500) { lastError = new Error(`上游 ${res.status}`); continue; }
      return res;
    } catch (err) { lastError = err; }
  }
  throw lastError ?? new Error('所有上游地址都不可用');
}

只用一家的话,那家 DNS 挂了生图就全挂——三个兜底的成本几乎为零。

教训

一,纯文本的错误页是个信号。 如果你的代码有 try/catch 却看到了平台自己的 错误页面,说明异常发生在你的代码控制范围之外。这能立刻把排查范围缩小一大半。

二,耗时是证据。 0 毫秒的失败和 5 秒的失败是完全不同的两种故障。前者是 被内部规则拦下,后者才是网络问题。我如果早点看这个数字,能少走很多弯路。

三,猜不出来就让程序自己报告。 我花在「想为什么」上的时间,远多于写那个 诊断接口的时间。而诊断接口一次就给出了答案。

对照组也很重要——如果我只测了目标地址,看到 1003 可能还会以为是服务器的问题。 加上 example.com 和 GitHub 之后,「出网正常,只有这个地址不行」这个结论才立得住。

顺便,那个诊断接口用完就删了。它能让任何人拿我的服务去探测内网地址, 不该留在生产环境里。