KernelRanch
返回文章列表

项目:API 代理与限流,别让自己的 Worker 变成公用出口

2026-11-10 #Cloudflare

后端接口不暴露公网、走 Worker 转发,同时把密钥藏在服务端——这是 Workers 最常见的用法之一。但它有两个失败模式:一是忘了开限流,你的 Worker 成了任何人都能免费调用的出口,上游额度替别人买单;二是没做路径与方法的白名单,攻击者拿你的域名探测内网或做开放代理。这篇用一个能跑通的限流器加一个受控代理,把两个问题一起解决。

基础请求处理见 54-workers-fetch-handler-tutorial.md,密钥怎么放见 64-workers-env-secrets.md。限流的算法对比在 78-rate-limiting.md 里,这篇给的是能直接部署的版本。

1. 固定窗口还是滑动窗口

方案存储精度成本
固定窗口计数KV窗口边界有双倍突发每次请求 1 读 1 写
滑动窗口Durable Objects精确需要 DO,免费档有限制
令牌桶DO / KV 近似平滑实现复杂

固定窗口的问题在边界:限制 5 次/分钟时,用户在 12:00:59 打 5 次、12:01:00 再打 5 次,两秒内过了 10 次。个人项目的防护目的不是精确计费,是挡住脚本刷量,固定窗口够用。

KV 做计数还有两个硬约束。同一 key 每秒只能写一次,突发的并发写会互相覆盖;写入跨机房最长 60 秒才可见,多地用户同时打,各自看到的计数不一样。所以这套限流器是"尽力而为",不是精确配额。

2. 限流器代码

const WINDOW = 60;
const LIMIT = 5;

async function bump(env, key) {
  const bucket = `rl:${key}:${Math.floor(Date.now() / 1000 / WINDOW)}`;
  const cur = Number(await env.LINKS.get(bucket)) || 0;
  const next = cur + 1;
  await env.LINKS.put(bucket, String(next), { expirationTtl: WINDOW * 2 });
  return next;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const ip = request.headers.get("cf-connecting-ip") || "unknown";
    const n = await bump(env, `${ip}:${url.pathname}`);
    const headers = {
      "x-ratelimit-limit": String(LIMIT),
      "x-ratelimit-remaining": String(Math.max(0, LIMIT - n))
    };
    if (n > LIMIT) {
      const retry = WINDOW - (Math.floor(Date.now() / 1000) % WINDOW);
      return Response.json({ error: "too many requests", retry }, {
        status: 429,
        headers: { ...headers, "retry-after": String(retry) }
      });
    }
    return Response.json({ ok: true, count: n, path: url.pathname }, { headers });
  }
};

窗口用时间戳整除得到,key 里带窗口号,过期时间设成两倍窗口,旧 key 自动消失,不需要清理任务。cf-connecting-ip 是 Cloudflare 注入的真实客户端 IP,不能信 x-forwarded-for,那个头客户端可以自己塞。

3. 实测

部署输出(2026-11-10,wrangler 4.146.0):

Total Upload: 1.27 KiB / gzip: 0.66 KiB
Worker Startup Time: 1 ms
Uploaded cf100-ratelimit (4.61 sec)
Deployed cf100-ratelimit triggers (1.05 sec)
  https://cf100-ratelimit.hyysn.workers.dev

连续请求 7 次 /data:

次数状态响应体
1–5200{"ok":true,"count":1..5,"path":"/data"}
6429{"error":"too many requests","count":6,"retry":39}
7429{"error":"too many requests","count":7,"retry":38}

第 5 次之后放行结束,第 6 次立刻拦截,retry 从 39 开始倒数,说明窗口按 60 秒对齐而不是从第一次请求起算。计数连续递增没有丢,是因为这七次请求间隔足够、都落在同一机房。换成并发突发就会看到覆盖——这不是 bug,是 KV 的能力边界。

4. 代理上游的写法

限流只管频率,代理本身还要做三件事:限定能访问哪些上游、把密钥留在服务端、别让上游的慢拖住 Worker。

const ALLOW = [
  { prefix: "/v1/weather", upstream: "https://api.example.com/weather" },
  { prefix: "/v1/geo", upstream: "https://api.example.com/geo" }
];

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const rule = ALLOW.find(r => url.pathname.startsWith(r.prefix));
    if (!rule) return new Response("Not Found", { status: 404 });
    if (request.method !== "GET" && request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const target = new URL(rule.upstream + url.pathname.slice(rule.prefix.length));
    target.search = url.search;

    const upstream = await fetch(target, {
      method: request.method,
      headers: {
        authorization: `Bearer ${env.UPSTREAM_KEY}`,
        "content-type": request.headers.get("content-type") || "application/json"
      },
      body: request.method === "POST" ? request.body : undefined,
      signal: AbortSignal.timeout(10000)
    });

    return new Response(upstream.body, {
      status: upstream.status,
      headers: {
        "content-type": upstream.headers.get("content-type") || "application/json",
        "cache-control": upstream.ok ? "public, max-age=60" : "no-store"
      }
    });
  }
};

白名单是重点。不要用 fetch(new URL(url.pathname, "https://api.example.com")) 这种拼法,//evil.com/x 这类路径能把它带到别的主机上去。逐条列前缀、只允许明确的方法,代理才是代理而不是隧道。

signal: AbortSignal.timeout(10000) 是防止上游挂起把 Worker 拖到超时。Workers 对单次请求有 CPU 时间限制,免费档 10 ms,等上游响应不占 CPU,但连接一直挂着会占用并发,超时兜底是必要的。

5. 上游出错时怎么透传

代理最容易写错的地方是错误处理。上游返回 500 时,如果 Worker 直接抛异常,客户端看到的是 1101 而不是上游的 500,排查时会误以为是 Worker 的问题。正确做法是原样透传状态码,只在网络层失败时自己造一个 502:

try {
  return new Response(upstream.body, { status: upstream.status, headers });
} catch (err) {
  return Response.json({ error: "upstream unreachable" }, { status: 502 });
}

三种情况要区分开:上游正常返回(透传状态码与 body)、上游连不上(502,附带自己的错误码便于日志检索)、上游超时(504,AbortSignal 触发的是 TimeoutError)。日志里把 cf-ray、上游耗时、状态码一起打,事后能直接定位是哪一段慢。排查顺序见 104-troubleshooting-playbook.md。

不要给错误响应加缓存。cache-control: no-store 要显式写上,否则一次偶发的 500 可能被边缘缓存住,用户反复看到同一个错误。

6. 常见问题

限流键为什么带上路径?只按 IP 限的话,用户访问两个接口共用一个额度,容易被误伤。带上路径等于每个接口独立计数,代价是 KV 的 key 数量变多。只保护一个写接口时,按 IP 就够了。键的粒度决定了限流是"防刷"还是"配额",这两件事的目标不同,别用同一套参数。

429 之后前端怎么处理?读 retry-after 秒数再重试,别立刻重打。前端做指数退避比在 Worker 里加更复杂逻辑省事。

能不能直接把 Cloudflare 的 Rate Limiting 规则用上?免费档的速率限制规则能力有限,而且需要 Zone 权限。应用层自己限流的好处是与业务绑定,能按用户、按接口、按套餐分别设限。

小结

这个限流器的全部成本是一个 KV 命名空间加 20 行代码,实测在每秒几次的顺序请求下表现正确:前 5 次放行,第 6 次返回 429 并给出 Retry-After。它的边界也很清楚——KV 不是原子计数器,并发突发会丢计数,跨机房有延迟,只能当粗粒度的防刷。

代理部分真正重要的不是转发代码,是白名单、密钥隔离与错误透传。把上游地址写死在配置里、密钥放 secret、超时兜底、错误码原样返回,这四件事决定了你的 Worker 是"网关"还是"公用出口"。额度被打满之后会发生什么,见 102-cost-control-quotas.md。

数据来源与检索时间

检索时间 2026-11-10。部署与限流行为为本机真实执行。

来源URL对应数据
Workers 限额https://developers.cloudflare.com/workers/platform/limits/100,000 请求/天、免费档 10 ms CPU、子请求 50 个
Workers KVhttps://developers.cloudflare.com/kv/同 key 每秒 1 次写、最终一致性
Workers 运行时 APIhttps://developers.cloudflare.com/workers/runtime-apis/AbortSignal.timeout、Response.json

核验说明

  • 真实执行:部署 cf100-ratelimit,连续请求 7 次记录状态码与响应体,确认第 6 次起 429、retry 从 39 递减。上传 1.27 KiB、启动 1 ms、部署 4.61 秒为实测。
  • 未做:并发压测(未验证丢计数的具体比例)、跨机房一致性、代理上游部分(没有可用的上游密钥,代码未部署)。第 4、5 节的代理代码为依据 Workers fetch API 写的示例,未在账号内运行。
  • 验证完成后已 wrangler delete cf100-ratelimit。