MessageWeave/cloudflare-worker/README.zh-CN.md

16 KiB
Raw Blame History

MessageWeave Cloudflare Worker 负载均衡器

English version / 英文版 → README.md

面向一个或多个 MessageWeave 后端的统一 HTTPS 入口,带有界多 origin 故障转移(HA/LB 子项目)。透传模型:Worker 不解析业务载荷,把请求原样转发给后端 https origin;只有超时 / 5xx 才触发有界的故障转移到另一个实例。

本组件是针对 MessageWeave 需要放在同一个 URL 后面的一组固定路由的负载均衡器:路由集合固定(19 个路径)、后端 origin 在部署时固定且仅允许 https、任何未知路径一律 404。它只把这些固定后端之间的请求做转发与故障转移,不接受任意目标主机,也不提供通用的流量中转能力。

它的用途是负载均衡,不是防护。 Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以这是免费套餐账户在它的那些 MessageWeave 后端前面做多 origin 负载分摊与故障转移的办法。这就是它存在的唯一理由,负载均衡器是可选的:后端有它和没有它跑起来完全一样。

它不是安全特性。 这里没有隐藏后端的意图,也没有加固后端的意图。负载均衡器在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制,所以路由白名单不是防火墙,本文档任何一处都不该被读成防护。

设计依据:docs/design.md §11.3(NFR-HA-MULTI-INSTANCE)与 docs/deployment.md §10。 安全基线与禁令:docs/charter.md §3、docs/charter.md §5;稳定 ID 注册表 docs/charter.md §8。

1. 它是什么,不是什么

  • 只转发。 method、headers(含认证头)、body 一律原样转发。Worker 自身不做任何认证,也不改写任何内容。
  • 无 secret。 没有 Telegram bot token,没有 JMAP 密码,没有 session secret。它收到什么认证头就转发什么,自己一个都不校验。真正共享的是后端之间必须一致的同一组 SAF-AUTH-* secret——RECONCILE_TOKEN、TG_WEBHOOK_SECRET 与加密后的业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(C-LB-SHARED-SECRETS)。凭据不一致的表现是随机 401,而不是路由错误。
  • 不是安全层。 它是负载均衡器,不是访问控制边界。后端 origin 有没有它都同样直连可达,这里既没有隐藏也没有加固它们。白名单对未知路径的 fail-closed 应答,是让负载均衡器在一组固定路径上定义明确的手段,不是可以依赖的防护。
  • 不做 origin 发现。 origin 只来自部署时的 origin 列表。
  • 零运行时依赖。 纯 ES2022 加平台提供的 fetch、Headers、Request、Response、URL。wrangler 是 devDependency,只用于 check、deploy、dev。
  • 零状态。 不连 Redis、不连 JMAP、不连数据库(C-NO-DB、C-REDIS-ONLY-STATE)。唯一的可变状态是 isolate 内的健康探测缓存。
  • 无长连接(C-NO-LONG-CONN)。纯请求-响应。请求体只读一次为 arrayBuffer,每次尝试重新挂上去,因此不需要流式。
  • 不是队列。 除了下面那几轮有界尝试,不做任何重试。所有 origin 都失败时负载均衡器返回 503,靠 Telegram / Stalwart 的自身重投兜底。

2. 目录结构

文件 职责
wrangler.toml name、main、compatibility flags、[vars]
package.json 只有 check / test / deploy / dev 脚本,无运行时依赖
src/index.js 入口:HTTPS 重定向、路由分发、白名单、健康聚合
src/backends.js origin 解析与校验、路由白名单
src/lb.js 透传转发与有界故障转移
src/health.js 带 TTL 的健康聚合探测
test/*.test.js 四个文件共 38 条测试,全部离线

3. 透传语义

  • method、headers、body 原样发出;后端响应的 status、body、headers 原样返回。
  • 负载均衡器唯一新增的 header 是尽力而为的观测头:x-lb-backend,标记应答的 origin。写入放在 try/catch 里。跨域且不带 access-control-allow-* 的响应,其 header guard 是 immutable,headers.set 会抛异常。一次会抛异常的观测写入绝不能把健康的 2xx 掩盖成 503,所以写不进去时静默放弃该 header。这个缺陷就是 2026-09-30 的生产事故根因。
  • GET 和 HEAD 不带 body 转发:上游没有 body 的响应直接原样返回,不重算 Content-Length。
  • 后端的 3xx 由负载均衡器自己跟随(redirect: "follow"),跳转型的 origin 不会把第二跳甩给浏览器。
  • 日志与错误串只包含 method、path、origin、失败类别,绝不含 headers、body、认证 secret 或 App Password(SAF-LOG-PURITY)。

4. 路由白名单

所有入站 HTTP 请求先被永久 308 重定向到同一个 HTTPS URL,之后才做路由校验与转发(C-HTTPS-INBOUND)。此后请求必须命中 19 个路径之一且 method 匹配,其余组合一律在边缘拒绝。

路径 允许的 method
/ GET
/assets/config.js GET
/assets/styles.css GET
/api/status GET
/api/config GET PUT
/api/business-config GET PUT
/api/business-config/preflight POST
/api/admin/session POST
/api/admin/session/revoke POST
/api/enabled GET PUT
/webhook/tg POST
/push/jmap POST
/api/push/register POST
/api/telegram/register-webhook POST
/api/push/disable POST
/reconcile POST
/worker POST
/healthz GET
/ready GET
  • 未知路径:404,body 为 route not forwarded: <path>。因此负载均衡器永远不可能被当作跳板去访问后端的任意路径。
  • 路径已知但 method 不匹配:405,body 为 method not allowed for <path>,并附带 Allow header。
  • 引导接口(POST /api/bootstrap)与远程诊断面(/debug/*)刻意不在白名单内,两者都只保留在 origin 直连。
  • /healthz-worker 完全不在白名单里:它由负载均衡器自己应答,且只接受 GET。

5. 超时与重试预算

变量 必填 默认值 适用范围
BACKEND_ORIGINS_JSON 是 — 所有路由
LB_REQUEST_TIMEOUT_MS 否 10000 除下面两条外的所有路由,以及 §7.2 的探测
LB_MAX_ATTEMPTS 否 2 每请求的尝试次数
LB_RECONCILE_TIMEOUT_MS 否 320000 仅 POST /reconcile
LB_WORKER_TIMEOUT_MS 否 300000 仅 POST /worker
LB_HEALTH_TTL_MS 否 30000 仅 /healthz-worker 探测缓存
LB_VERSION 否 unknown /healthz-worker 的 version 字段

整数型 env 值用 Math.floor 解析。非有限数或非正值会被忽略并回落到默认值,所以手滑不会把预算关掉。

/reconcile 与 /worker 是单次尝试路由,各自有独立的超时覆盖。两者都是后端耗时的同步长任务:按全局 10 s 超时,负载均衡器会判定它们失败并故障转移到第二个实例,但第二个实例对 /reconcile 会立刻返回 409(集群级 lock:reconcile 初始租约 300 s,靠心跳续租),而第二个 /worker 只会把同一批消息再排一遍。所以这两条路由拿到 LB_RECONCILE_TIMEOUT_MS(默认 320 s,比 300 s 租约加心跳多留余量)与 LB_WORKER_TIMEOUT_MS(默认 300 s,等于后端单条事件地板 SINGLE_EVENT_CEILING_FLOOR_MS),并且都是 max_attempts = 1。

如果 /worker 的批大小上调,LB_WORKER_TIMEOUT_MS 必须跟着加:后端批量的默认值与硬上限都是 10。

6. 有界故障转移

  • 每个请求尝试 max(1, min(LB_MAX_ATTEMPTS, origins.length)) 个 origin。默认 2 即首次加一次故障转移。
  • 只有超时或 5xx 才会换下一个 origin。2xx、3xx、4xx 都是应答,立即原样返回。
  • 超时与网络层失败被判为可重试的条件:错误 name 是 AbortError,或消息、或 cause 链上更深的错误消息命中 fetch failed、network error、socket hang up、ECONNREFUSED、ECONNRESET、EAI_AGAIN、ENOTFOUND、ETIMEDOUT、EHOSTUNREACH、ENETUNREACH。其余一律 fail-fast。
  • 当 origin 大于 1 且尝试次数大于 1 时,起点 origin 由 rng 决定(默认 Math.random),把负载摊到健康实例上;单个 origin 退化为固定顺序。
  • 所有 origin 都失败:503,status text 为 All Backends Unavailable,body 为 all backends failed: <last reason>。随后由 Telegram 与 Stalwart 重投,消息因此不丢。

健康缓存只服务于 /healthz-worker 的聚合视图。转发路径不按健康过滤;现阶段故障转移就是安全网。

7. 健康接口

两个接口刻意不重叠,谁都不能掩盖谁。

7.1 GET /healthz — 透传

与其他白名单路由一样被转发。响应是后端自己的 liveness envelope,这也正是它在另一个 origin 已挂时依然显示健康的原因。

7.2 GET /healthz-worker — 负载均衡器聚合

由负载均衡器自己应答,完全不接触被转发的路由集合。

  • 用与转发同源的请求超时(LB_REQUEST_TIMEOUT_MS,默认 10 s)探测每个 origin 的 /healthz。重定向会被跟随,所以返回 3xx 的 origin 同样计为 up。
  • origin 应答状态码低于 500 即为 up。探测失败记 up: false 且 status: null。
  • 结果按 origin 缓存 LB_HEALTH_TTL_MS(默认 30 s)在 isolate 内的 map 中,用来控制探测流量。未命中就探测并写入;未过期的缓存直接返回,不再探测。
  • 响应体:{status, version, available, total, backends: [{origin, up, status}]}。
    • 至少一个 origin 在线:HTTP 200,status: "ok"
    • 全部 origin 离线:HTTP 503,status: "down"
    • 未配置 origin:HTTP 200,status: "no-backends"
    • version 即 LB_VERSION,未配置时为字面量 unknown
  • 任何非 GET 的 method:405,并带 Allow: GET。
  • 负载均衡器不代理 Redis 或 JMAP 的 readiness。端到端后端就绪判定(ARCH-READY-BASELINE:配置完整、Redis 可达、JMAP session、Telegram getMe)留在后端的 /ready 上,/ready 本身被透传。

8. 回调注册

SPA 的回调 URL 只注册一个 origin(C-LB-SINGLE-REG-URL)。只填 origin,不带路径:

  • 启用负载均衡器:填 Worker URL,例如 https://lb.example。
  • 不启用负载均衡器:填后端 origin,例如 https://a.example。

你注册的是哪个 origin,/webhook/tg 与 /push/jmap 就落在哪。三个注册接口都在白名单内、都会经过负载均衡器透传,所以两种前门都能注册。注册是幂等的,且注册新地址会注销旧订阅——换前门就靠这一步。

9. 配置

  • 默认值写在 wrangler.toml 的 [vars] 里,这是一个刻意的明文例外:当前取值只是公开可达的 https origin,不是凭据。一旦出现内网地址、带内嵌凭据的 URL、或不想公开的主机名,立刻改用 npx wrangler secret put BACKEND_ORIGINS_JSON 并删掉 [vars] 里那一行。git 历史不可逆,删掉并不等于没进过库。
  • BACKEND_ORIGINS_JSON 是纯字符串数组的 JSON。{"url": "..."} 这类对象形式会被拒绝:parseBackendOrigins 抛异常,而配置是每个请求重新解析的,所以表现是每个请求都 503,而不是启动即失败。body 为 misconfigured backends;空列表同样走这个异常路径,在转发任何请求之前就被拦下。
  • 每一项必须是 https origin:不允许 http、不允许 path、不允许 query、不允许 fragment、不允许内嵌凭据(C-HTTPS-INBOUND)。
  • wrangler.toml 同时固定 name = "messageweave-lb"、main = "src/index.js"、compatibility_date = "2025-01-01" 与 compatibility_flags = ["nodejs_compat_v2"]。nodejs_compat_v2 的作用是让测试文件能 import node:test 与 node:assert/strict;运行时代码不引入任何 node 模块。
  • wrangler.toml 的注释必须保持单行 # 注释。TOML 没有块注释,用 JSDoc 的 /** */ 块会让 wrangler 直接解析不了整个配置文件。

10. 本地与 CI 验证

cd cloudflare-worker

npm test       # 四个文件共 38 条测试,全部离线
npm run check  # 对源码与测试跑 node --check,再 wrangler deploy --dry-run

npm test 即 node --test,会自动收集 test/*.test.js:test/index.test.js 15、test/lb.test.js 12、test/backends.test.js 6、test/health.test.js 5。跑测试不需要安装依赖——Worker 没有运行时依赖,测试文件只引入 node:test 与 node:assert/strict。

两个坑:

  • node --test test/(传目录)在某些 Node 版本上会跑完零条测试然后退出 0。必须传文件 glob。
  • 如果 CI 默认安装步骤是 bun install,它会忽略 npm lockfile:自行重新解析,可能把 wrangler 跨大版本移动。需要锁住 wrangler 时,在测试步骤前显式加一步 npm ci。

11. 部署

cd cloudflare-worker
npm install --no-audit --no-fund
npm run deploy
curl https://<your-worker>.workers.dev/healthz-worker

npm run check 内含 wrangler deploy --dry-run --outdir=.build-check,会在发布前把四个源文件打包并报告产物体积。负载均衡器由 push 触发部署,所以对 /healthz-worker 发一次 curl 并对比其 version 就是验收手段。改 LB 逻辑的提交要同时 bump LB_VERSION,这样才能判断线上是哪一次构建在应答。

两起值得记住的生产事故:

  • 2026-09-30:某次构建把 x-lb-backend 写进了代理响应。跨域且不带 access-control-allow-* 的响应 header guard 是 immutable,headers.set 抛异常,外层 catch 于是把一个健康的后端记成不可用并耗尽重试——负载均衡器返回 503,而 GET / 实际返回的是 200。/healthz-worker 不走那段代码,这正是事故被掩盖的原因。已改为把 header 写入做成尽力而为。
  • 对象形式的 origin 列表在解析期静默失败。 parseBackendOrigins 抛异常后每个请求都是 503 misconfigured backends。weight 之类的 per-origin 调参不要写进列表,没有任何地方读它们。

12. 设计不变量

  • SAF-LB-PASSTHRU — 透传路径不改写认证、header 或 body
  • C-HTTPS-INBOUND — origin 仅允许 https,拒绝内嵌凭据与路径,HTTP 走 308
  • C-LB-SINGLE-REG-URL — 只注册一个回调 origin,未知路径 404
  • C-LB-SHARED-SECRETS — 所有后端携带同一组业务凭据
  • MOD-HEALTH-AGG — 由负载均衡器持有的带 TTL 健康聚合探测
  • C-NO-DB、C-REDIS-ONLY-STATE — 负载均衡器不访问数据库与 Redis
  • C-NO-LONG-CONN — 仅请求-响应
  • ARCH-READY-BASELINE — 端到端就绪判定留在后端的 /ready
  • SAF-LOG-PURITY — 仅 method、path、origin、失败类别
  • ARCH-LB-WORKER — 负载均衡器是唯一边缘组件
  • NFR-HA-MULTI-INSTANCE — 多 origin 故障转移即 HA 的实现方式

References

  • docs/deployment.md §10 — 负载均衡器部署、配置与上线
  • docs/reference.md §4 — 负载均衡器与后端的路由矩阵
  • docs/reference.md §9.2 — 后端配置与 secret
  • docs/reference.md §9.4 — 健康与发布检查
  • docs/design.md §11.3 — HA 与多实例设计
  • docs/charter.md §3、docs/charter.md §5、docs/charter.md §8 — 安全基线、禁令、稳定 ID 注册表