开发者生态
morning
在 Cloudflare Worker 上实现多机场 SaaS 规模的定价边缘计算
摘要
边缘侧的单体困境 我参与构建的边缘平台承载着数十万个租户账号的 Web 流量。这个平台最初和大多数边缘部署一样:一个 Cloudflare Worker "、一个 fetch 处理函数和一个路由。一个用于拦截请求、重写请求标头、转发到源站的脚本很容易理解,部署起来也轻而易举。但位于大型 SaaS 产品前方的 Worker 不会一直保持小巧体积,它会不断累积职责。 随着时间推移,同一个入口开始承担图...
Worker
Cloudflare
fetch
SaaS
CDN
每个功能
边缘侧的单体困境
我参与构建的边缘平台承载着数十万个租户账号的
Web
这个平台最初和大多数边缘部署一样
2026-10-01
1 阅读
约10分钟阅读
作者:Chintan Tank
字号:
边缘侧的单体困境 我参与构建的边缘平台承载着数十万个租户账号的 Web 流量。这个平台最初和大多数边缘部署一样:一个 Cloudflare Worker "、一个 fetch 处理函数和一个路由。一个用于拦截请求、重写请求标头、转发到源站的脚本很容易理解,部署起来也轻而易举。但位于大型 SaaS 产品前方的 Worker 不会一直保持小巧体积,它会不断累积职责。 随着时间推移,同一个入口开始承担图片优化、故障转移页面、路由、请求标头和 Cookie 重写以及按租户配置查询等功能。每一项都是合理的边缘侧能力,且大多由不同的团队维护。最初的单个文件慢慢变成所有人都在修改、却没有团队全权负责的共享单体。这和十年前应用单体的形成是同一股力量。在边缘场景下,它的危害会更严重。 站点规模较小时,这种问题尚可忍受。但到了 SaaS 规模,情况就完全不同。数十万个租户账号,每个都是独立客户,拥有独立配置、相互隔离,还有多个功能团队。Worker 处在所有请求的同步路径上,在这里每增加 1 毫秒耗时,所有用户都会多等 1 毫秒。 这是一个全球分布式的多租户平台,运行在多个内容分发网络(CDN)上。同一个功能必须在所有 CDN 上都能正常运行。正因如此,本文会持续对比其中两家厂商,目的不是给厂商排名,而是当平台团队需要重复实现同一套能力时,才能看清架构哪些部分具备可移植性,哪些不具备。 部署耦合 只有一个 Worker 时,就只有一个部署,任何改动都会重新部署整个脚本,因此所有团队的发布节奏由最慢的变更决定。一行请求标头修复也要等一个尚未完成的实验功能,然后两者一起发布。唯一的部署单元就是主干分支上的全部代码,回滚一个功能就是回滚所有功能。 故障爆炸半径 Worker 不同于负载均衡后端那些可替换的后端实例。它本身就在请求路径中。一个未处理的异常、一个糟糕的正则表达式,或某个功能的热点循环,拖垮的不只是那个功能,而是所有功能和所有租户。多租户架构下,一次糟糕的部署会演变成波及整个客户群的并发事故。 平台限制 Worker 存在硬性资源上限:每个请求有固定 CPU 限额和脚本大小上限。每个功能的依赖项都会导致共享代码包膨胀,并在热路径中增加解析和启动工作——即使请求根本不会执行这部分代码。多个团队共享一份资源配额,彼此看不到对方的资源消耗。 权责错配 故障转移逻辑很少改动;图片处理流水线却需要快速迭代。如果强制两者写在同一个文件里,就会出现大量合并冲突、所有权不清晰和“恐惧式开发”:没人敢修改这个文件,生怕一不小心破坏其他逻辑。 边缘会放大单体架构的所有弊端。在应用服务器里只是麻烦的耦合问题,到这里就变成可用性风险。解决思路是保留边缘最看重的轻量入口,同时给每个团队一个属于自己、可以独立测试并按自己节奏部署的单元。 模块化 Worker 架构 解决单体问题最直观的办法就是拆分,而最直接的反对理由是成本。把后端单体拆成多个服务,换来了隔离性,代价是增加了网络跳转。每次内部调用都要 DNS 解析、TLS 握手和之前没有的往返延迟。边缘的核心意义是从请求路径上削减请求耗时,因此这笔开销看起来无法承受。但在 Cloudflare 上,这种看法是错的。 原因在于服务绑定。服务绑定是 Worker 之间的调用,Cloudflare 会在同一台机器、同一个隔离沙箱内完成调度,没有上面那些网络开销。调用写法和 fetch() 一样,但开销近似普通函数调用。正是这一点,让边缘侧的模块拆分具备可行性。 这个模式是一个前置的精简网关 Worker,加上一组单一用途的功能 Worker。 网关只拥有两样东西:组合逻辑(判断当前请求要启用哪些功能、执行顺序)和全局横切关注点(请求预处理、请求标头规范化、可观测性)。它不包含功能逻辑。每个功能 Worker 只拥有一个关注点、自己的依赖包、测试套件和部署流程。这就是整个设计:每个功能 Worker 内部高内聚,彼此之间低耦合,网关知道何时启用某个功能,但不关心功能内部如何实现。 网关与每个功能之间的契约分为两部分。如果某个功能不适用于当前请求,就不会触发绑定调用。每个功能向外暴露一个轻量的判断函数 shouldApply(),网关直接内联执行;Worker 仅在这个函数判断需要为真时才通过服务绑定调度。内联决策,远程执行。 网关按固定顺序编排功能,下面的代码会更直观地展示这个逻辑。故障转移这类熔断逻辑会在访问源站之前提前拦截。然后执行请求预处理。图片优化可能接管源站拉取逻辑。最后,响应阶段的拦截器负责处理只能在响应里才能判断的条件。 export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { // 判断逻辑就地执行;仅把繁重任务通过服务绑定分发出去。 if ((await shouldServeFailover(request, env)).serve) return env.FAILOVER.fetch(request); const originRequest = prepareOriginRequest(request, env); const response = (await shouldOptimize(originRequest, env)).optimize ? await env.IMAGE_OPTIMIZER.fetch(originRequest) : await fetchFromOriginOrCache(originRequest, ctx); // 响应阶段关卡捕获只有到这一步才能发现的状况(源站5xx、被拒绝的请求)。 return shouldServeFailoverForResponse(request, response).serve ? env.FAILOVER.fetch(request) : sanitize(response); }, }; 网关只持有每个功能的 Fetcher 句柄,而不是引入功能代码。这有两个好处:各个 Worker 的依赖和 CPU 配额完全隔离,网关包里面只有少量判断逻辑,刚好解决前面提到的平台资源限制问题。并且,某个功能 Worker 出错或者超时,不能阻断整个请求。一旦功能 Worker 发生异常,网关直接返回未经转换的源站原始响应,而不是返回错误页面。两层都具备容灾能力,任何一层故障都不能拖垮整个请求。 隔离性不是免费的,它的代价是增加了协调成本。如果每个功能 Worker 单独放在代码仓库里,共享契约就会漂移,集成测试要拼接多个代码仓库的构建产物,对网关接口的一次变更会变成一次多仓库迁移。我们把网关和所有功能 Worker 放在同一个单体代码库中。包边界让每个 Worker 保持独立的构建和部署目标,网关只能导入每个功能对外暴露的接口。放在同一代码库带来了共享约定和契约的唯一事实来源,但不会引入共享部署。你获得了独立服务的所有权边界和单一代码库的一致性。正是这种组合,而不是网关模式本身,才是让架构在功能团队增多时仍然保持稳定性的关键。 协调成本不是唯一的代价。拆分还导致可观测性碎片化。一个
这篇文章对您有帮助吗?
订阅66必读
每日精选科技资讯,直达你的邮箱