用 Service Bindings 构建可分阶段发布的多租户 Cloudflare Workers

2026-09-23 29 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

在多租户 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 是否转发 AcceptSec-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-TypeVaryX-Image-FormatX-Image-Width。如果本地开发工具对 Service Binding 的行为与生产环境存在差异,还应在预发布环境进行一次真实边缘请求验证。

落地时的检查清单

模块化 Cloudflare Workers 的价值不只是把一个大文件拆成几个小文件,而是明确每个能力的责任、配置和发布边界。落地时可以按下面的顺序检查:

  1. 为每个模块定义稳定的请求和响应协议。
  2. 只把需要独立发布或独立隔离的能力抽成服务。
  3. 对租户配置设置默认值、状态和回滚字段。
  4. 对图片源站实行白名单和必要的签名校验。
  5. 明确 CDN 的缓存键、Vary 和请求头转发规则。
  6. 先按租户或内部流量灰度,再扩大发布范围。
  7. 同时准备单元测试、绑定协议测试和预发布边缘测试。

Service Bindings 让边缘模块可以保持低延迟的内部调用,但它不会自动解决配置漂移、缓存污染或错误隔离问题。真正可维护的架构,仍然需要清晰的协议、可观测指标和可执行的回滚路径。


相关推荐