在多租户 SaaS 进入规模化阶段后,一个包揽鉴权、业务路由、图片处理和响应缓存的边缘 Worker 会逐渐变成发布瓶颈。任何模块的改动都可能触发整站部署,也会把故障影响范围扩大到所有租户。
一种更容易控制的做法,是把边缘能力拆成多个独立 Worker,再通过 Cloudflare Workers 的 Service Bindings 组合它们。本文以图片优化为例,说明如何实现按租户协商图片格式、根据设备选择尺寸,并把配置、发布和测试边界管理清楚。
为什么多租户边缘架构需要拆分
单体 Worker 的问题通常不在于代码量本身,而在于变更之间的耦合:
- 图片处理模块升级,可能需要重新发布主路由 Worker。
- 某个租户的特殊配置容易混入全局逻辑。
- 一个异常请求可能占用共享资源,影响其他租户。
- 回滚只能回滚整个 Worker,难以只撤销单个能力。
模块化架构可以把入口路由、租户配置、图片处理等能力拆开。例如,入口 Worker 只负责解析租户和转发请求,Image Worker 负责格式协商与尺寸选择。两者通过 Service Binding 通信,调用方不需要公开一个额外的公网域名。
这并不意味着所有模块都必须拆成独立服务。拆分的边界应当落在具有独立发布节奏、独立故障边界或独立资源约束的能力上。过度拆分会增加配置、观测和本地测试成本。
一个可运行的图片处理示例
下面的示例假设图片源站支持 Cloudflare Images Transformations 或类似的图片变换参数。入口 Worker 根据请求头和租户配置决定目标格式、宽度与质量,然后调用绑定的 IMAGE_SERVICE。
图片处理 Worker
这个 Worker 只处理图片参数,不关心请求来自哪个租户。生产环境中可以把租户授权、源站白名单和签名校验放在这里,避免任何调用方随意代理内部资源。
// image-service.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
const source = url.searchParams.get("src");
if (!source) {
return new Response("missing src", { status: 400 });
}
let sourceUrl;
try {
sourceUrl = new URL(source);
} catch {
return new Response("invalid src", { status: 400 });
}
if (!env.ALLOWED_HOSTS.split(",").includes(sourceUrl.hostname)) {
return new Response("source host is not allowed", { status: 403 });
}
const accept = request.headers.get("Accept") || "";
const format = accept.includes("image/avif")
? "avif"
: accept.includes("image/webp")
? "webp"
: "jpeg";
const device = request.headers.get("Sec-CH-Width") || "";
const requestedWidth = Number.parseInt(device, 10);
const width = Number.isFinite(requestedWidth)
? Math.min(Math.max(requestedWidth, 320), 1600)
: 960;
const transformed = new URL(sourceUrl);
transformed.searchParams.set("width", String(width));
transformed.searchParams.set("format", format);
transformed.searchParams.set("quality", "80");
const response = await fetch(transformed, {
cf: {
cacheEverything: true,
cacheTtl: 86400
}
});
const headers = new Headers(response.headers);
headers.set("Vary", "Accept, Sec-CH-Width");
headers.set("X-Image-Format", format);
headers.set("X-Image-Width", String(width));
return new Response(response.body, {
status: response.status,
headers
});
}
};
这里有几个边界值得保留:src 不能直接接受任意 URL,否则会形成 SSRF 风险;图片格式协商必须配合 Vary: Accept;设备宽度不能原样作为无限制参数,否则会制造大量缓存键。示例使用 320 到 1600 像素的范围,并为未知设备提供默认宽度。
主路由 Worker
主路由 Worker 负责从域名、路径或请求头中识别租户,再将图片请求交给绑定的服务。下面的代码使用一个简单的 tenant.example.com 约定,真实系统可以替换为签名令牌、网关注入的租户上下文或数据库配置。
// router.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
const tenant = url.hostname.split(".")[0];
const config = await env.TENANT_CONFIG.get(tenant, "json");
if (!config || config.status !== "active") {
return new Response("tenant is unavailable", { status: 404 });
}
if (url.pathname === "/image") {
const imageUrl = new URL("https://image-service.internal/transform");
imageUrl.searchParams.set("src", config.assetOrigin + url.searchParams.get("path"));
const imageRequest = new Request(imageUrl, request);
imageRequest.headers.set("X-Tenant-Id", tenant);
return env.IMAGE_SERVICE.fetch(imageRequest);
}
return new Response(`tenant=${tenant}\n`, {
headers: { "content-type": "text/plain; charset=utf-8" }
});
}
};
env.IMAGE_SERVICE.fetch() 是 Service Binding 的调用点。调用方不需要维护图片服务的公网路由,也不会因为图片 Worker 的内部实现变化而修改主路由协议。租户配置则可以放在 KV、D1 或其他配置系统中;具体选择取决于一致性要求和更新频率。
Wrangler 配置
下面是一个可以改造的 wrangler.toml 片段。两个 Worker 应分别部署,主路由 Worker 通过绑定引用图片服务。
# router/wrangler.toml
name = "saas-router"
main = "src/router.js"
compatibility_date = "2024-11-01"
[[services]]
binding = "IMAGE_SERVICE"
service = "saas-image-service"
[[kv_namespaces]]
binding = "TENANT_CONFIG"
id = "replace-with-production-kv-id"
[vars]
ENVIRONMENT = "production"
图片 Worker 可以使用独立配置:
# image-service/wrangler.toml
name = "saas-image-service"
main = "src/image-service.js"
compatibility_date = "2024-11-01"
[vars]
ALLOWED_HOSTS = "assets.example.com,cdn.example.com"
部署前替换 KV ID、源站域名和服务名称。配置文件中的租户密钥、签名密钥等敏感值应使用 Wrangler secrets,而不是提交到仓库。
多 CDN 与发布策略
当 SaaS 同时使用多个 CDN 时,不能假设所有 CDN 对请求头、缓存键和图片变换参数的行为完全一致。至少要验证以下项目:
- CDN 是否转发
Accept和Sec-CH-Width。 - 缓存键是否包含影响输出的请求头或查询参数。
Vary是否被保留到最终响应。- 图片变换服务对 AVIF、WebP 和 JPEG 的回退顺序是否一致。
- 错误响应是否会被边缘缓存过久。
建议把发布拆成两个层次。服务绑定和配置结构先在测试环境验证,再逐步将新版本指向少量租户或内部租户。图片服务可以先只切换一个租户,检查命中率、响应格式、源站请求量和错误率,确认结果后再扩大范围。
对于配置变更,也要区分“代码发布”和“租户启用”。例如,图片 Worker 已部署新逻辑,但只有配置中的 imageVersion = "v2" 的租户使用新路径。这样可以降低回滚成本,不过需要为旧版本保留清晰的兼容策略,避免代码发布后立即删除仍在使用的配置字段。
测试应覆盖协议,而不只是函数
模块化之后,单元测试仍然有价值,但更关键的是绑定之间的协议测试。至少应覆盖:
- 不同
Accept头得到正确的输出格式。 - 不同设备宽度被限制在允许范围内。
- 缺少
src、非法 URL 和未授权源站会返回预期状态码。 - 租户不存在或已停用时,主路由不会调用图片服务。
- 响应包含正确的
Vary,缓存不会把一个租户或一种格式错误地复用给另一个请求。
可以用 Wrangler 的本地开发环境启动两个 Worker,并用 curl 进行最小验证:
# 在 router 目录启动本地服务
npx wrangler dev --local --port 8787
# 请求 WebP,模拟窄屏设备
curl -i \
-H 'Accept: image/webp' \
-H 'Sec-CH-Width: 480' \
'http://acme.localhost:8787/image?path=/products/phone.jpg'
测试结果应至少检查状态码、Content-Type、Vary、X-Image-Format 和 X-Image-Width。如果本地开发工具对 Service Binding 的行为与生产环境存在差异,还应在预发布环境进行一次真实边缘请求验证。
落地时的检查清单
模块化 Cloudflare Workers 的价值不只是把一个大文件拆成几个小文件,而是明确每个能力的责任、配置和发布边界。落地时可以按下面的顺序检查:
- 为每个模块定义稳定的请求和响应协议。
- 只把需要独立发布或独立隔离的能力抽成服务。
- 对租户配置设置默认值、状态和回滚字段。
- 对图片源站实行白名单和必要的签名校验。
- 明确 CDN 的缓存键、
Vary和请求头转发规则。 - 先按租户或内部流量灰度,再扩大发布范围。
- 同时准备单元测试、绑定协议测试和预发布边缘测试。
Service Bindings 让边缘模块可以保持低延迟的内部调用,但它不会自动解决配置漂移、缓存污染或错误隔离问题。真正可维护的架构,仍然需要清晰的协议、可观测指标和可执行的回滚路径。