在 Cloudflare Worker 上实现多租户 SaaS 规模的模块化边缘计算
点击查看原文>
边缘侧的单体困境
我参与构建的边缘平台承载着数十万个租户账号的 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<Response> {// 判断逻辑就地执行;仅把繁重任务通过服务绑定分发出去。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 保持独立的构建和部署目标,网关只能导入每个功能对外暴露的接口。放在同一代码库带来了共享约定和契约的唯一事实来源,但不会引入共享部署。你获得了独立服务的所有权边界和单一代码库的一致性。正是这种组合,而不是网关模式本身,才是让架构在功能团队增多时仍然保持稳定性的关键。
协调成本不是唯一的代价。拆分还导致可观测性碎片化。一个 Worker 能看到整个请求,而一个 Worker 链上的每个 Worker 只能看到自己那一小段。如果你想观察一个在途请求,又不想依赖集中式日志,一种办法是使用特殊的请求标头,把诊断信息放到响应头里返回。这在网关层是有效的,但在服务绑定下游的功能 Worker 内部捕获的诊断信息不会被传播回来,比如请求进入图片优化 Worker 的信息就会丢失。端到端追踪一个请求是单体免费自带的。
多 CDN 的现实困境
到这里介绍的架构 是基于 Cloudflare 的方案。人们很容易想当然:只要 CDN 支持无服务器计算,这个架构就能直接迁移过去。但事实并非如此。我在 Akamai 和 Cloudflare 上都实现了同一个图片优化功能,两者差异远大于“把代码放到边缘”这种简单设想。根源来自平台提供的是不同的底层原语。
在 Cloudflare 上,计算单元是 Worker。入口点是单个 fetch 处理函数,端到端地处理请求:检查请求、做决策、通过绑定调用其他 Worker、获取源站、重写响应。网关模式可以自然地在这里实现,因为一段代码持有整个请求并能组合其余部分。
Akamai 就不是这么回事。它的配置单元是 property——一个根据请求属性匹配并应用行为的规则引擎。图片优化是其中的一种行为:一个托管产品(Image Manager),靠规则开启,而不是手写代码实现。计算(即 Akamai EdgeWorker)是这条流水线里的一个插件,在指定生命周期事件触发执行,并不拥有整个请求。它不会调用 Akamai 的 Image Manager,也不会转换图片。
我们实现的“可选择关闭” EdgeWorker,简化之后逻辑是这样:
// Akamai EdgeWorker:生命周期事件处理器,并非 fetch 处理器。// 它不能调用图像转换逻辑。它在请求状态中留下一个决策结果,// 下游的 PROPERTY 会读取该决策。export async function onClientRequest(request) {const key = deriveSiteKey(request); // 规范化后的主机名 + 站点前缀const optedOut = await lookupOptOut(request, key);// 该工作器的全部输出就是一个属性变量。request.setVariable("PMUSER_SKIP_TRANSFORM", optedOut ? "true" : "false");}
在 Cloudflare 上,决策和执行在同一段代码里:if (shouldOptimize) return env.IMAGE_OPTIMIZER.fetch(...)。在 Akamai 上,EdgeWorker 把决策写入请求状态,property 内部的一条规则读取该变量并控制是否启用 Image Manager。这两部分不在同一个调用栈,也没有类似服务绑定的机制把自定义计算和托管转换连接起来。
这个差异会传导到数据平面。选择退出状态在两个平台上都放在键值存储里,但这两个存储的可达范围不同。Cloudflare Workers KV 是全局的。你写入一个键,它在任何地方都是可读的,网关不用关心地域。我在构建 Akamai 版本时,它的 EdgeKV 命名空间——Akamai 的等价键值存储——是按区域配置的,在创建时选择,不存在跨所有区域的全局命名空间。所以 Akamai 的 Worker 里有一段 Cloudflare 完全不需要的代码,它在读取之前会先把请求的源站所在大洲映射到区域存储。
// Akamai(原有实现):存储是区域化部署的,因此在读取之前,Worker// 必须把请求所属大洲映射到对应的区域,并打开该命名空间——不存在跨所有区域的统一存储。const region = CONTINENT_TO_REGION[continentOf(request)] || "amer";const store = openNamespace(namespaceFor(region));
移植这个功能不是复制粘贴,而是需要重写这一层。Cloudflare 版本直接删掉大洲映射逻辑。同一个功能,在两个平台上,目标意图一致,但代码形态完全不同。
不过这个地域限制后来有所改善。Akamai 新增了全局命名空间。平台一直在迭代。今年你为某个差异做兼容,明年这个差异可能就消失了。平台能力对等不是一个一劳永逸的状态,两边能力会来回变化。
哪种会变成负担,取决于你的目标。平台想要全球覆盖,那么自动全局的存储更简单,区域路由就是额外的开销。换个需求,数据驻留合规要求区域数据留在本区域,二者角就反过来了。Akamai 按区域隔离的命名空间正好满足合规,变成了优势;Cloudflare KV 的设计是全局的,没有区域开关,要实现数据驻留,就得换别的原语,而不只是 KV 的一个设置。
既然代码无法直接复用,什么东西可以跨平台保留?是设计意图,以及经过实践验证的不变量。两个平台都限制边缘计算可使用的内存,所以在两个平台上,我们都在键值存储前放置了一个小的、有容量上限的本地缓存,定时清理保证数据新鲜。缓存结构、容量上限、过期窗口都保持一致,只是代码写法不同。这个约束来自边缘本身,不是来自厂商:内存上限加上热路径延迟敏感,不能每次请求都访问远端存储,因此两个平台都要实现同样的行为。
所以多 CDN 并不是“写一次,到处部署”,而是持续的适配成本:一个功能必须在两个 CDN 都正常运行才算完成。除了边缘本身带来的硬性约束,两种实现的其余部分都会走向分化。
边缘的图片优化
图片优化只是网关编排的众多功能之一,并不是平台本身的目标。两个 CDN 上,“自研还是直接用托管服务”的选择截然相反。
两个平台抽象层级不一样。在 Akamai 上,图片优化是一项托管服务。你配置一个策略,它自行协商每个请求,不需要逐请求运行代码。在 Cloudflare 上,它是一个更低层的原语:你在 Worker 内部按请求指定转换。Cloudflare 当然也提供了自己的托管层级(例如 Polish,一个区域级开关),但它进行了缓存优化,按文件扩展名匹配 URL,并跳过任何不可公开缓存的内容。这个平台的图片流量既没有统一的扩展名,也不是统一公开的,所以这个开关并不适用。没有谁更先进,只是抽象模型不同。托管服务接受配置,而原语接受代码。我们直接使用前者,在后者之上自行构建。Cloudflare 文档也写得很清楚:使用底层原语时,自动格式协商要由调用方自己实现。
工作量更大,但换来的是之前提到的价值:策略写在代码里,不管 CDN 托管产品能力、缺陷、后续版本怎么变,策略行为保持一致。它也要求你必须清晰说明“协商图像”是什么意思,这包含三个独立的决策,需要依次作出。
不是所有图片都允许被处理
第一个是安全。多租户平台上的一些图片是私有的,鉴权逻辑放在源站,边缘默认不会复现鉴权。缓存会让这件事变得危险。如果你优化了一张私有图片,图片会以 URL 作为键存入共享边缘缓存。下一次相同 URL 请求直接返回缓存,不再访问源站鉴权。因此 Worker 拒绝优化任何它无法证明是公开的东西。一个带有认证信号的请求会被直接透传,不做处理。这不是图片质量规则,而是用来防止性能优化变成数据泄露的策略。
不是所有客户端都支持所有格式
AVIF、WebP 的体积比网站存储的 JPEG/PNG 小很多。独立基准测试显示,同等画质下 AVIF 大约只有 JPEG 一半大小,WebP 比 JPEG 小约三分之一。但向不支持解码的客户端返回 AVIF 比不返回图片更加糟糕。客户端会通过 Accept 请求头声明支持格式。Worker 从中挑选最优格式,不支持就回退到原始格式。
不是所有设备都需要全部像素
使用蜂窝网络的手机和使用光纤的桌面电脑不应该接收相同的几百万像素大图,所以 Worker 从请求信号中读取设备类别并限制图片宽度。Akamai 为我们预设了断点;在 Cloudflare 上,我们用少量代码自行实现。
这些都不是画质参数问题,而是策略(安全、兼容性、成本),而策略就是代码。决策逻辑大致如下,检查按优先级排序:
function planOptimization(request: Request, config: TenantConfig): OptPlan | null {// 优先执行安全校验关卡;任意一项校验失败,则“不处理这张图片”。if (request.method !== "GET") return null;if (hasSessionCookie(request)) return null;if (config.optOut) return null;if (isExcludedType(request.url)) return null;const accept = request.headers.get("Accept") ?? "";if (!accept.startsWith("image/")) return null;const format =accept.includes("image/avif") ? "avif" :accept.includes("image/webp") ? "webp" : "origin";return { format, maxWidth: widthForDevice(deviceClass(request)), fit: "scale-down", quality: DEFAULT_QUALITY };}
这个函数里有两处设计将平台功能与演示样例区分开来。
第一个是按租户选择退出(config.optOut),用来解决粒度问题。优化默认开启,但租户可以单独关闭,无需部署,也不影响其他租户——区域级开关做不到这种账号级的独立控制。这也是为什么要在请求时读取租户配置,而下一个要解决的问题是配置放在哪里,以及如何保证它在热路径上的访问性能。
第二个是当优化失败时如何处理。因为规模上来之后,故障一定会发生。转换服务可能会拒绝图片、超时或碰到资源上限。降级到源站这条规则没有例外:图片优化失败,绝对不能让图片加载失败。Worker 把转换的错误当作一个分支,而不是异常,要么返回原始字节,要么重试普通的源站路径。
无论哪种情况,用户都能拿到图片。热路径上所有可选功能都遵循同一原则:优化是增强能力,原始响应是服务契约。
大规模部署与运维
对于单个站点,部署就是一次推送,如果出问题了就再推送一次。面对数十万个租户账号,“一次性全量替换”等于主动选择巨大故障半径,因为一个糟糕的版本会在传播完成后影响所有租户。所以在这里,部署是一次受控的滚动发布,具备三个特性,保障故障可承受。
版本发布是不可变版本集合,不是分支状态
每个 Worker 独立版本化。一次发布把每个 Worker 的一个版本绑定为一个命名标签。永远不直接部署“主干分支当前最新代码”,而是部署一个特定的、可复现的集合。
// 一次发布是一组不可变更的各 Worker 版本集合,由标签固定锁定。const release = {tag: "release/2024-11-14",workers: { gateway: "4.2.0", imageOptimizer: "2.7.1", failover: "3.0.0" },};
回滚不是特殊操作
由于上一个版本的发布包仍被固定在它自己的标签下,回滚操作就是重新部署。既不需要匆忙还原代码,也不用在高压之下紧急打热补丁。
这个设计很早就被验证是有价值的。有个 Worker 版本改成流式读取请求体,而流只能被读取一次。当源站 POST 请求返回重定向时,请求体已经被读取,所以 Worker 会返回 500,无法跟随跳转。旧版本是直接透传完整请求对象,重定向时请求体仍然可用,恢复方案就是重新部署上一个标签。因为发布按队列移动,这个回归问题在扩散到全量集群之前就在金丝雀队列(即内部和预生产账号)中暴露出来了。
滚动发布按队列交错进行
先金丝雀发布,再逐步扩大范围,每个阶段之间进行自动校验和人工审批。瞬时错误自动重试,版本存在问题就停止滚动发布,而不是继续推给全部账号。提前选定故障爆炸半径,而不是上线之后才发现。

还要区分一件事:不是所有租户差异化配置都走这条发布流水线。Worker 代码走上面那条版本化、交错、有验证门的路径,因为代码变更很少(发布按季度,不是每天多次),代价是出现服务中断。按租户的配置,比如图片优化开关,是数据,不是代码。它位于一个单独的平面上,键值存储在请求时读取,变更无需部署。在热路径上读取配置的代价是每次请求都要访问存储,这就是为什么每个 Worker 在存储前面都要放置一个小的、有界的、短期过期的缓存。这与多 CDN 的缓存是同一个思路:边缘内存有上限,热路径承担不起额外往返延迟。
边缘代码的测试策略
人们会很容易误以为边缘代码比业务代码更好测:只是个小函数,修改请求头、选图片格式、转发源站。这能出什么问题?单体架构问题给出的答案是:一旦出事,所有租户一起出问题。边缘代码运行在同步请求路径上,而多租户运行在不完全受你控制的平台上。这不会降低测试标准,反而提高了,并改变了测试的层级。
架构本身已经为此做好了准备。判断逻辑与 Worker 分离原本是为了运行时性能,但这也是测试挂载点。判断函数是纯函数:输入请求和配置,输出是否启用功能。这使它成为软件中最容易测试的东西——只是输入和期望输出,不需要网络,也不需要搭建平台。Akamai EdgeWorker 刻意导出内部辅助函数,方便单独进行单元测试。轻量判断层也是低成本测试层。
// 判断函数是只依赖请求入参的纯函数,因此测试完全不需要边缘运行时:// 无需网络、无需模拟桩,只需要输入和预期输出。test("skips authenticated requests, even when the format is optimizable", () => {const req = new Request("https://site/img.jpg", {headers: { Accept: "image/avif", Cookie: "session=abc" },});expect(planOptimization(req, PUBLIC_CONFIG)).toBeNull();});
所以第一层是密集的、快速的单元测试覆盖,覆盖每个功能的决策逻辑,以毫秒级运行,不需要边缘运行时。只要判断逻辑单元测试全部通过,就能堵住大部分功能异常场景。
第二层是网关集成。重点测试平时很难触发的场景:故障降级。架构强制要求一个失败的功能必须降级返回源站原始响应,不能让请求失败。这是一个在正常行中很少发生的行为,这正是为什么必须有意识地测试它。集成测试套件让一个功能 Worker 抛出异常、超时并返回垃圾响应,然后断言用户每次仍然收到有效响应。失败路径在这里不是边缘情况,它本身就是产品的一部分。
第三层是把边缘测试与普通测试分开:你不能对平台进行单元测试。本地测试无法告诉你 CDN 的转换是否在某个地区遵守了你的格式请求,服务绑定是否如承诺的那样在进行了隔离调度,或者热路径上的配置读取在真实负载下是否返回得足够快。这些行为只存在于生产环境中,所以你只能在生产环境测试它,也就是合成监控:持续对线上端点发送脚本化请求,按地域、CDN 分别运行。
不是笼统验证“功能正常”,而是分别验证在每个平台上正常工作。平台各不相同,永远不会完全一致。发布时的健康检查是同一设计思路在更窄时间窗口下的体现:这是一种合成检测,有权阻断发布流程。
关键经验和可复用模式
抛开 CDN 厂商相关细节,剩下的是一套通用思路:把约束作为设计输入,而不是当成麻烦。下面的四条模式不局限于边缘场景。
沿着成本边界做拆分。任何有单请求资源配额限制的系统都可以借鉴“内联判断,远程执行”,在获得模块化能力的同时,无需为不需要该能力的请求承担对应的开销。
增强能力可选,原始响应作为契约。热路径上所有可选的增强逻辑,其评判标准不只看最优表现,更要看异常场景对用户是否完全无感知。
当你无法标准化平台时,标准化不变量。对于多厂商系统,把必须保持一致的逻辑写在代码中,接受厂商特定实现存在差异。
按故障爆炸半径分层管理变更。根据变更发生频率、破坏范围做拆分,这是通用工程原则,不是边缘计算特有的。
测试经验是前面所有原则的叠加。每个功能判断都是纯函数,测试可以用模板或者 AI 工具批量生成,提交前卡点校验。这种机械工作交给流水线或者助手。但决定编排哪些功能、以什么顺序组合,仍然需要熟悉流量特征的工程师。
查看英文原文:https://www.infoq.com/articles/modular-edge-computing/
本文来源:InfoQ