a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
|
Raw History Blame Contribute Delete
17.3 kB

WorkBuddy2API — Cloudflare Worker:反代 + 保活

把公网入口 cli.3216gemini.dpdns.org 反代到 Hugging Face Space a3216/gcli2api(直连域名 https://a3216-gcli2api.hf.space), 并用 Cron 定时打 /healthz,防止 Space 被 HF 暂停。

这个 Worker 做什么

能力 说明
🔁 全量反向代理 方法 / 路径 / query / 请求体 / 端到端请求头原样转发,源站的状态码、响应头、响应体原样回传
⚡ 零缓冲流式 直接返回 originResponse.body,不调用 .text() / .json()。POST /v1/chat/completions 是 SSE 长连接,缓冲会直接毁掉流式体验
↩️ 不吞后端 302 fetch(..., { redirect: 'manual' }),所以 GET / → /panel/ 的跳转是浏览器看到的,不会被 Worker 内部跟掉
🌐 CORS 全透传 后端已经返回宽松的 CORS 头,Worker 一律不自己造 Access-Control-*,避免出现重复头
🧹 逐跳头剥离 剥掉 host / connection / keep-alive / transfer-encoding / upgrade / proxy-* / cf-* / x-forwarded-*;Host 永远不会用公网域名转发过去
🍪 set-cookie 原样 不做 domain / path 改写,多条也完整保留
⏰ Cron 保活 scheduled() 每 5 分钟 ping 一次源站 /healthz,25s 硬超时 + 失败重试 1 次 + 每次一行日志
🛡️ 不抛异常 Cron 处理器内部吞掉所有异常,不会污染 Cron 运行记录

涉及的后端接口(都不需要 Worker 特殊处理,纯透传):

  • GET /healthz → {"healthy":2,"realm_servable":{...},"service":"workbuddy2api","total":3}(无可服务账号时 503)
  • GET / → 302 → /panel/
  • GET /panel/ → 单页管理面板
  • /panel/api/* → 面板 JSON API,需 Authorization: Bearer <api_key>
  • POST /v1/chat/completions → OpenAI 兼容,SSE 流式,需同一个 Bearer 密钥
  • GET /v1/models、GET /status

为什么需要这个 Cron

Hugging Face 免费 cpu-basic Space 有一条硬规则:连续 48 小时没有任何 HTTP 流量就被暂停, 下一次访问要等 30–60 秒冷启动。

所以「反代」和「保活」其实是同一件事的两半:反代提供入口,Cron 保证入口后面那台机器不会睡着。 只要有任意 HTTP 请求打到 Space,HF 的闲置计时器就会归零——响应内容是什么完全不影响判断, 这就是为什么一次裸的 GET /healthz 就够了。

⚠️ 接管前必读:现有入口的 /healthz 是 Cloudflare 自己应答的

同一时刻实测对比:

目标 /healthz 响应 判据
https://a3216-gcli2api.hf.space/healthz {"healthy":2,…,"service":"workbuddy2api"} 带 x-service、x-proxied-replica,以及 HF 的 link: …/spaces/a3216/gcli2api 头
https://cli.3216gemini.dpdns.org/healthz ok(2 字节) 只有 cf-ray;没有 x-service、没有 link

结论:现有入口把 /healthz 短路了,请求根本没到 Space。 很可能是故意的 —— 避免探活 / 监控 / 负载均衡把 30~60s 冷启动打起来。它只短路了这一个路径: /status、/panel/* 等仍是透传的(两边都带 HF 的 link 头,可自证)。

两个直接后果:

  1. 千万别拿 cli.3216gemini.dpdns.org/healthz 做保活。 它会稳定返回 200、 看起来一切健康,但 Space 完全没被唤醒 —— 48 小时一到照样被暂停。 保活必须打 Space 的直连域名(见仓库根的 .github/workflows/space-keepalive.yml)。
  2. 本 Worker 接管路由后,/healthz 的行为会变:它会把 /healthz 原样透传给 Space, 于是返回的是网关 JSON 而不是 ok。对"真探活"来说这是更正确的 (能反映账号可用性),但如果你有监控依赖那个 ok,请先调整它。 想让本 Worker 也短路 /healthz,在 worker.js 的 fetch() 里加一条早返回即可。

⚠️ 免费版只有 5 个 Cron(最容易踩的坑)

Cloudflare 免费版的 cron 触发器限制是「每个账号 5 个」,不是每个 Worker 5 个。 如果你账号里别的 Worker 已经占了 5 个,再部署本 Worker 就会看到这种半成功:

Uploaded workbuddy2api-hf-proxy (1.63 sec)
Deployed workbuddy2api-hf-proxy triggers (1.62 sec)
✘ [ERROR] Trigger configuration for "workbuddy2api-hf-proxy" was only partially updated:
    Cron schedules: ... failed.
    - This account has reached the Workers Free limit of 5 cron triggers per account.
      [code: 10072]
  Successful trigger changes were not rolled back.

怎么读这段:Worker 的代码确实上传成功了,但 cron 没加上,而且 "Successful trigger changes were not rolled back" 只是说"已成功的部分不回滚"。 结果是这个 Worker 处于空转状态——没有 cron,它不会去唤醒任何人。 (* * * * * 这种多行 crons 数组里每一行都算一个触发器,别写成 5 行。)

两条出路:

出路 A:腾一个 CF cron 名额(保持 CF 原生方案)

Dashboard → Workers & Pages → 逐个 Worker 看 Settings → Triggers → Cron Triggers, 找出已经不用的那个删掉,然后重新 npx wrangler deploy。

想用 API 一次性列全账号的 cron(需要 CLOUDFLARE_API_TOKEN,权限含 Workers Scripts:Read):

ACC=a75c5f86fdd1b75eb3e860f5436a7970        # 你的 account id
for w in $(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts" \
            -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[].id'); do
  echo "== $w"
  curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts/$w/schedules" \
    -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '.result.schedules'
done

出路 B:改用 GitHub Actions(推荐,无数量限制)

本仓库自带 .github/workflows/space-keepalive.yml,每 6 小时 ping 一次 Space, 零成本、不占 CF 名额。而且 6 小时一次完全够用——HF 的判定阈值是 48 小时, 所以 GitHub 定时任务偶发延迟几十分钟毫无影响(反过来说,本 Worker 里 */5 的 5 分钟粒度其实是严重过量的,纯粹因为免费额度够用才这么写)。

两条路不冲突:Worker 留着做反代,保活交给 Actions 也完全可以。

部署

先看上一节:如果你账号的 5 个 cron 名额已满,wrangler deploy 会报 code: 10072, 这时按「出路 A/B」处理。

1. 前置条件

node -v            # 需要 Node 18+
npx wrangler -v    # 首次会提示安装 wrangler
npx wrangler login # 登录你的 Cloudflare 账号

2. 部署

cd D:\workbuddy2api-panel\deploy\cloudflare-worker
npx wrangler deploy

部署成功后 Worker 名称为 workbuddy2api-hf-proxy(见 wrangler.toml 的 name)。

3. 接线路由

wrangler.toml 里的 routes 默认是注释掉的,两种接法任选其一:

  • A. 交给 wrangler:取消 routes 注释后重新 wrangler deploy,路由自动创建;
  • B. 在 Dashboard 手动加:Workers → 你的 Worker → Settings → Domains & Routes → 添加 cli.3216gemini.dpdns.org/*(推荐,避免误删线上路由)。

⚠️ 启用路由前确认该主机名当前没有指向别处的「已代理」DNS 记录, 否则 Worker 路由和 DNS 记录会互相打架。正确做法是该主机名由 Worker 路由接管 (DNS 里留一条指向 100:: 的 AAAA 占位记录)。

4. 设置密钥(可选)

只有开启「可选唤醒/签到」时才需要。密钥永远不要写进 wrangler.toml,也不要提交。

npx wrangler secret put WB2A_API_KEY
# 粘贴网关的 api_key(与面板登录用的是同一个),回车即可

未设置时 scheduled() 只做 /healthz 裸 ping,不发送任何鉴权请求。

验证

先接路由再验:下面这些 URL 打的是 cli.3216gemini.dpdns.org,只有在该主机名已经 路由到本 Worker 时才是"在验 Worker"。没接路由的话,你验到的仍是原来的链路。

*.workers.dev 在部分网络下不可用(国内常见:DNS 被污染,解析到不相干的 IP、 443 不通)。所以 wrangler deploy 打印的 https://<worker>.<sub>.workers.dev 在你这里可能根本连不上——这不代表部署失败,用自定义域名的路由来验即可。

# 探活:能看到网关自己的 JSON,且带 total / service 字段
curl -i https://cli.3216gemini.dpdns.org/healthz

# 后端自带的 302 应该原样到达客户端(不要出现 -L 跟随)
curl -i https://cli.3216gemini.dpdns.org/

# 面板
curl -I https://cli.3216gemini.dpdns.org/panel/

# 流式:应逐块吐出,而不是憋到最后一次性返回
curl -N -H "Authorization: Bearer <api_key>" \
     -H "Content-Type: application/json" \
     -d '{"model":"deepseek-v4-flash","stream":true,"messages":[{"role":"user","content":"hi"}]}' \
     https://cli.3216gemini.dpdns.org/v1/chat/completions

看到 server: cloudflare 与 cf-ray 属正常——那是 Cloudflare 边缘加的,不代表后端没响应; 后端的响应头(如 service、x-*)应当同时原样出现。

看日志

cd D:\workbuddy2api-panel\deploy\cloudflare-worker
npx wrangler tail                     # 实时请求日志
npx wrangler tail --format pretty     # 人类可读格式

wrangler tail 报 Error: Unexpected server response: 400? 它走的是 WebSocket,经过本地抓包代理(DevSidecar / Reqable 之类)时经常被挡掉。 换成这几条路即可:

  • Dashboard → Workers & Pages → 你的 Worker → Logs → Live / Cron Events(最省事);
  • 关掉代理再 npx wrangler tail;
  • 或者干脆不依赖 tail:Cron 有没有在跑,看 Space 的 last_checkin_day 有没有变成当天 最直接(桶里的 data/state.json),或看 npx wrangler deployments list 的更新时间。

Cron 每次运行会打一行:

[keepalive] 2025-01-01T00:05:03.412Z /healthz -> HTTP 200 (183ms)

失败时是 warn 级别,同时带上首次失败原因,便于区分「偶发抖动」和「真的没醒」:

[keepalive] 2025-01-01T00:05:03.412Z /healthz -> FAILED status=0 (25013ms) first=ping timeout detail=ping timeout

Cron 粒度说明

  • [triggers] crons = ["*/5 * * * *"] = 每 5 分钟一次,这是 Cloudflare Cron Triggers 的最小粒度(不支持每分钟);
  • 频率换算:每 5 分钟 ≈ 288 次/天,对比 48 小时暂停阈值,余量极大;
  • Cron 触发时间有秒级抖动(官方说明可能延迟数秒到数十秒),对保活完全无影响;
  • 如果只是防休眠,不需要再加密;调成 */30 之类也够用,但 5 分钟最稳。

本地调试

推荐:先用 dev-server.mjs 把代理逻辑验完,再考虑 deploy。 它用 Node 起一个本地服务, 原样 import 这个 worker.js(不做任何改写),转发到真实的 HF Space——不需要 wrangler、 不需要登录:

cd deploy/cloudflare-worker
node dev-server.mjs                 # 监听 127.0.0.1:8787
# PORT=9000 node dev-server.mjs     # 换端口
# ORIGIN=https://other.hf.space node dev-server.mjs

另开一个终端逐条验:

curl -i http://127.0.0.1:8787/           # 期望 302 → /panel/(不要加 -L)
curl -i http://127.0.0.1:8787/healthz    # 期望网关自己的 JSON(带 total / service)
curl -i http://127.0.0.1:8787/status     # 期望 401 invalid_api_key(说明真的打到了网关)
curl -s  http://127.0.0.1:8787/__cron    # 手动跑一次保活定时器,看控制台日志

/__ping 与 /__cron 是本地专用路径,永远不会被代理到源站,部署后的 Worker 里没有它们。

也可以走 wrangler(需要它自己的本地环境):

npx wrangler dev
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"   # 临时触发 Cron 处理器

.dev.vars

本地调试需要的变量(尤其是 WB2A_API_KEY)放在同目录 .dev.vars 里,不要提交:

cp .dev.vars.example .dev.vars   # 然后按需填写

格式为 KEY="value",一行一个。wrangler dev 会自动读取该文件中的变量与密钥。

✅ .gitignore 已经覆盖到位(根目录规则):.dev.vars / .dev.vars.* / .wrangler/ 全部被忽略,同时用 !.dev.vars.example 把模板放回版本库(模板里只有占位符)。 四个可提交文件:worker.js、wrangler.toml、.dev.vars.example、本 README.md。

任何 secret 都不得提交,线上一律走 wrangler secret put。

环境变量一览

名称 类型 默认值 说明
ORIGIN vars https://a3216-gcli2api.hf.space 源站地址。换 Space 只改这里
ROOT_REDIRECT vars(注释中) /panel/ GET / 的跳转目标;设成空串则关闭跳转、直接透传后端的 404
HEALTH_PATH vars(注释中) /healthz 保活探测路径
CHECKIN_CRON vars(注释中) 空 = 关闭 只在此 Cron 表达式触发时做签到
CHECKIN_PATH vars(注释中) 空 = 关闭 带 Bearer 调用的路径,如 /panel/api/keepalive
WB2A_API_KEY secret 未设置 面板/网关 Bearer 密钥,仅签到功能需要

ORIGIN 若填成非法 URL,Worker 会回落到默认源站而不是整体 500——配置拼错时仍能提供服务。

如果你已有自己的反代 Worker

不要替换掉你现有的反代逻辑,只把「保活」那一小块搬过去即可,改动量极小:

  1. 加 scheduled() 处理器:从 worker.js 里复制 scheduled(event, env, ctx) 整个函数, 连同它依赖的 DEFAULT_ORIGIN / HEALTH_PATH / PING_TIMEOUT_MS / PING_RETRY_DELAY_MS 常量、resolveOrigin() / normalizePath() 两个工具函数一起拿走。 (可选签到那段如果不需要,连同 CHECKIN_* 一起删掉即可。)

  2. **确认你的默认导出里有 scheduled**:必须是 export default { fetch(request, env, ctx) {...}, scheduled(event, env, ctx) {...} } 这种形状。 如果你原来是 export default { async fetch(...) },直接把 scheduled 加进同一个对象即可, 不要改成 export { scheduled } 之外的写法。

  3. 在 wrangler.toml 加上 trigger 块:

    [triggers]
    crons = ["*/5 * * * *"]
    
  4. 重新部署:npx wrangler deploy。改 [triggers] 必须重新部署才会生效, 只改 Dashboard 里的 Cron 是临时的、下次部署会被覆盖。

反代部分如果你已经有实现,保持原样——本 Worker 的 fetch() 只是一个满足 「不缓冲 / 不跟随 302 / 剥离逐跳头 / 透传 CORS」四条硬约束的参考实现, 你的版本只要满足同样四条,效果完全等价。

常见问题

代理后 /v1/chat/completions 变成一次性返回,没有流式效果?

几乎一定是某处调用了 await res.text() / res.json() / 中间做了缓冲(包括自己包一层 TransformStream 却忘了 flush)。确认直接 return new Response(originResponse.body, ...)。

GET / 返回的是 /panel/ 的内容,看不到 302?

说明 fetch 的 redirect: 'manual' 丢了。默认的 redirect: 'follow' 会在 Worker 内部 把 302 跟掉,客户端就永远看不到那次跳转。

GET / 到底该返回 302 还是后端原始的 404?

302 → /panel/。 这是实测出来的既有行为,值得记一笔:网关只注册了 /panel/, 所以直连 Space 的 GET / 是 404;但公网入口 https://cli.3216gemini.dpdns.org/ 返回的却是 302 → /panel/。同一路径两种结果,说明这层跳转是 CF 侧加的(Worker 或 Redirect Rule)。worker.js 因此也自带了这个跳转(ROOT_REDIRECT,默认 /panel/), 换用本 Worker 后直接打开域名首页的体验不变。

想对比两种行为:

curl -i https://a3216-gcli2api.hf.space/     # 404(后端真实行为)
curl -i https://cli.3216gemini.dpdns.org/    # 302 → /panel/(入口层加的)

想把跳转关掉(严格透传后端):把 ROOT_REDIRECT 设成空串。

浏览器报 CORS 重复头错误?

Worker 自己造了 Access-Control-Allow-Origin,而后端也返回了同一个头。 删掉 Worker 里的 CORS 处理,让它纯透传。

Cron 日志里一直是 503,是不是保活失败了?

不是。 503 是后端「当前没有可用账号可服务」的业务语义,但只要它回了 HTTP, Space 就是醒着的,保活目的已经达到。只有 status=0(超时/网络失败)才需要关注—— 那通常意味着正在冷启动,或者 Space 真的挂了。

想确认 Cron 到底有没有在跑?

npx wrangler tail --format pretty     # 等 5 分钟就能看到 [keepalive] 行

或者 Dashboard → Workers → 你的 Worker → Logs → 筛选 Cron Events。