跳到內容

多租戶執行使用者程式碼:Workers for Platforms vs Dynamic Workers

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24

如果你在做一個平台型產品,遲早會遇到這個需求:讓客戶跑他們自己的程式碼。Shopify 的 app、Zapier 的自訂步驟、CI 平台的 build script、或者 LinkForge 讓租戶自訂重導向前置邏輯。

Cloudflare 有兩個產品解決這件事,名字都很像、都在 2026 年很活躍、而且官方文件從來沒有把它們放在一起比較過。本章最主要的價值就是把它們分乾淨。


Workers for PlatformsDynamic Workers
誰寫程式碼你的客戶你的 Worker(或 AI,或使用者貼上的片段)
什麼時候上傳客戶部署時(上傳到 namespace)執行期,就是一個字串
怎麼叫用env.DISPATCHER.get(name)env.LOADER.get(id, () => ({ modules }))
設定鍵dispatch_namespacesworker_loaders
狀態成熟的付費產品(無 beta 標示)Open beta(2026-03-24)
計費$25/月方案 + 超出的 per-script 費用每月含 1,000 個 unique Worker,之後 $0.002 / 個 / 天

判準:

  • 客戶自己 deploy 一個 Worker,你用名字去 dispatch → Workers for Platforms
  • 你的 Worker 在執行期產生或取得一段程式碼並載入它 → Dynamic Workers

⚠️ 兩者互不取代。 官方沒有任何一頁比較它們 —— 兩棵文件樹完全不相交:Workers for Platforms 的文件從未提到 “Dynamic Workers” 或 “Worker Loader”,Dynamic Workers 的文件與發表 blog 也從未提到 Workers for Platforms。

所以「Dynamic Workers 取代了 Workers for Platforms」這種說法是沒有根據的。上面那張表的判準是本章自己的整理,不是官方立場。


{ "worker_loaders": [{ "binding": "LOADER" }] }

schema 只有 binding 一個欄位,additionalProperties: false。

實測 binding 的形狀:

{
"ctorName": "WorkerLoader",
"protoKeys": ["get", "load", "constructor"]
}

這是一個真正的類別,不是第 23、30、32 章那種 Fetcher + JsRpcProperty 的 RPC proxy。方法真的在 prototype 上。

interface WorkerLoader {
get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise<...>): WorkerStub;
load(code: WorkerLoaderWorkerCode): WorkerStub;
}

實測:

{ "isPromise": false, "keys": ["getEntrypoint", "getDurableObjectClass", "constructor"] }

get() 立刻回傳一個 WorkerStub,不是 Promise。 callback 也還沒被呼叫 —— 它是懶惰的。這個設計讓「取得 stub」變成零成本,實際的載入延後到第一次真的發請求時。

WorkerStub 有兩個方法:

  • getEntrypoint<T>(name?, options?) → Fetcher<T>
  • getDurableObjectClass<T>(name?, options?) → DurableObjectClass<T>

第二個代表 dynamic Worker 可以匯出 Durable Object 類別,這是 33.6 那個 DO Facets 的基礎。

官方文件講得很直白:

「永遠不保證兩個請求會進到同一個 isolate。即使你用同一個 WorkerStub 發多個請求,它們也可能在不同的 isolate 執行。傳給 loader.get() 的 callback 可能被呼叫任意次數(雖然被呼叫超過一次並不常見)。」

實測連續三次 get() + fetch():

{ "callbackInvocations": 1, "statuses": [200, 200, 200] }

本機只呼叫了一次(warm isolate 被重用),但這不是保證。

實務規則:callback 必須是冪等的、純函式的。 絕對不要在裡面做計費、寫 log、扣配額、或任何有副作用的事。它的工作只有一件:回傳程式碼。

需要「載入一次就記一次」的話,記在 get() 外面。

這是 Dynamic Workers 最重要的性質。實測 —— 主 Worker 有 CODE(KV)、DISPATCHER、LOADER 三個 binding,但載入的 Worker 看到的是:

{
"transformedBy": "acme",
"upper": "HELLO",
"envKeys": ["TENANT_ID"],
"canFetch": "function"
}

Object.keys(env) 只有 ["TENANT_ID"] —— 就是我明確傳進去的那個。KV、dispatcher、loader 一個都看不到。

const stub = env.LOADER.get(`tenant:${tenantId}`, async () => ({
compatibilityDate: "2026-07-24",
mainModule: "main.js",
modules: { "main.js": tenantCode },
env: { TENANT_ID: tenantId }, // ← 客戶程式碼能看到的全部
globalOutbound: null, // ← 完全沒有對外網路
}));

這是能力導向的安全模型(capability-based security):客戶程式碼不是「被禁止存取 KV」,而是根本不知道 KV 存在。沒有可以被繞過的黑名單。

需要給它一個受控的能力時,傳一個你自己實作的 WorkerEntrypoint 進去:

env: {
TENANT_ID: tenantId,
// 一個只能讀這個租戶前綴的 KV 代理,方法簽章由你決定
STORE: ctx.exports.TenantStore({ props: { tenantId } }),
}

官方:「設 globalOutbound 為 null 可以讓 dynamic Worker 與網路完全隔離」、「這會讓 dynamic Worker 發出的任何 fetch() 或 connect() 請求拋出例外」。

實測讓客戶程式碼嘗試三種逃逸:

{
"fetch": "This worker is not permitted to access the internet via global functions like fetch(). It ",
"eval": "Code generation from strings disallowed for this context",
"wasm": "WebAssembly.compile(): Wasm code generation disallowed by embedder"
}

三條路全部封死。注意後兩條不是 Dynamic Workers 特有的 —— 那是第 27 章實測過的、整個 Workers 平台的性質。合起來看:

在 globalOutbound: null 的 dynamic Worker 裡,客戶程式碼既不能連外,也不能在執行期產生新的程式碼。 它能做的只有純運算,加上你放進 env 的那些能力。這是一個很紮實的沙箱邊界。

比 null 更彈性的做法是把 globalOutbound 指向你自己的 WorkerEntrypoint,這樣所有出站請求都會經過你的 handler,你可以檢查、改寫、或阻擋。

實測型別:

interface WorkerLoaderModule {
js?: string; cjs?: string; text?: string;
data?: ArrayBuffer; json?: any; py?: string; wasm?: ArrayBuffer;
}

py —— Python 模組。 還有 cjs(CommonJS)、json、text、wasm。

實測 json 與 text 模組:

modules: {
"main.js": `import cfg from "./cfg.json"; import note from "./note.txt"; ...`,
"cfg.json": { json: { feature: true } },
"note.txt": { text: "hello from a text module" },
}
{ "cfg": { "feature": true }, "note": "hello from a text module" }

modules 的值可以直接給字串(等同 { js: "..." })或給物件指定型別。

資源限制:production 保證,本機不保證

Section titled “資源限制:production 保證,本機不保證”
const stub = env.LOADER.get("spinner", () => ({
...,
limits: { cpuMs: 50 },
}));

官方:「如果 Dynamic Worker 撞到這兩個限制中的任何一個,它會立刻拋出例外」。也可以在 getEntrypoint() 上設,「兩者取較低者」。

實測本機:完全沒有生效。 一段刻意燒 5 秒 CPU 的程式碼在 cpuMs: 50 的設定下跑完了:

{ "ok": { "n": 94513572 }, "ms": 5011 }

⚠️ Dynamic Workers 的文件裡完全沒有提到本機開發。 「local」、「wrangler dev」、「miniflare」這些字在整個文件集裡一次都沒有出現 —— 所以沒有任何一頁說本機會不會套用限制。

實測結果是不會。這代表:沙箱的隔離性(globalOutbound、env)本機測得出來,但資源上限(cpuMs、subRequests)測不出來。 一段會吃爆 CPU 的租戶程式碼在本機看起來完全正常。

每月含 1,000 個 unique Dynamic Worker,之後 $0.002 / 個 / 天。計數規則(官方):

用法計為
同 ID 同程式碼、重複呼叫1 個
同程式碼、不同 ID每個 ID 算 1 個
同 ID、不同程式碼每個版本算 1 個
不給 ID,或用 .load(code)每次呼叫算 1 個

最後一列是災難。load() 每次都是全新的一次計費。

所以 get(id, cb) 的 id 不只是快取鍵,它是計費鍵。 正確的 id 應該是「租戶 + 程式碼版本」:

env.LOADER.get(`t:${tenantId}:v:${codeVersion}`, () => ({ ... }))

用 crypto.randomUUID() 當 id,或每次都算一次程式碼的 hash 當 id 而程式碼其實沒變,都會讓帳單線性成長。

另外注意:beta 期間的免費期已經結束 ——「Starting May 26, 2026, Dynamic Workers created daily are billed as part of Dynamic Workers pricing」。

狀態:2026-03-24 進入 open beta,僅 Workers Paid。我找不到任何 GA 公告;不過現行的文件頁上也沒有 beta 橫幅了,所以狀態在文件裡是模糊的。最後一次正式的狀態聲明是 open beta。


{
"dispatch_namespaces": [
{ "binding": "DISPATCHER", "namespace": "production", "remote": true }
]
}
const worker = env.DISPATCHER.get(
customerWorkerName,
{}, // args(官方所有範例都給空物件)
{ limits: { cpuMs: 50, subRequests: 10 } },
);
const res = await worker.fetch(request);

注意 subRequests 的大寫 R。 實測型別定義:

interface DynamicDispatchLimits { cpuMs?: number; subRequests?: number; }

Cloudflare 的設定檔大多用 snake_case(compatibility_date、new_sqlite_classes),這裡卻是 camelCase 而且第二個字大寫。寫成 subrequests 或 sub_requests 都不會報錯,只會靜默不生效。

官方 dynamic-dispatch 頁展示的就是這個:

try {
const worker = env.DISPATCHER.get(name);
return await worker.fetch(request);
} catch (e) {
if ((e as Error).message.startsWith("Worker not found")) {
return new Response("", { status: 404 });
}
if ((e as Error).message.includes("CPU time limit")) {
return new Response("", { status: 429 });
}
throw e;
}

沒有型別化的錯誤類別,字串比對是官方示範的做法。這很脆弱,但目前沒有替代方案。

這是很多人第一次做會做錯的。官方原文:

「你所有客戶的 Worker 都應該放在單一 namespace(例如 production)。不要為每個客戶建立一個 namespace。」

唯一建議的第二個 namespace 是 staging,用來安全測試變更。

隔離靠的是 Worker 的名字與 dispatch 時的參數,不是 namespace。

實測不加 remote: true 直接呼叫:

{ "threwName": "Error", "threw": "Binding DISPATCHER needs to be run remotely" }

而且 —— 這一點很重要 —— 這個錯誤讓 33.3 那段 404 處理邏輯在本機根本測不到:

{ "threw": "Binding DISPATCHER needs to be run remotely", "matchesWorkerNotFound": false }

e.message.startsWith("Worker not found") 在本機永遠是 false。你的 404 分支在本機是死程式碼。

官方 local-development 頁只描述了 remote: true 這一條路:

「這會告訴你在本機執行的 dispatch Worker 去連接遠端的 production namespace。」

沒有任何一頁說明純本機模式會怎樣(沒有限制清單、沒有說會失敗)—— 上面那個錯誤訊息是我實測出來的。

「Workers for Platforms Paid plan 是每月 $25」,含 2,000 萬次請求、6,000 萬 CPU ms、1,000 個 script。

超出:每百萬請求 +$0.30、每百萬 CPU ms +$0.02、每多一個 script +$0.02。

一個很值得記住的計費特性:

「Workers for Platforms 在整條鏈上(dispatch → user → outbound)只收 1 次請求費用」,但 CPU time 三段都會計。Subrequest 不計費。

所以 dispatch Worker 本身寫得薄一點是有價值的 —— 它的 CPU 也算錢。

第三個參數的 outbound 讓你攔截客戶 Worker 的所有出站請求:

"dispatch_namespaces": [{
"binding": "DISPATCHER", "namespace": "production",
"outbound": { "service": "my-outbound-worker", "parameters": ["params_object"] }
}]
env.DISPATCHER.get(name, {}, { outbound: { params_object: contextFromDispatcher } });

這是 Workers for Platforms 對應 Dynamic Workers globalOutbound 的機制。 兩個產品在這一點上的設計思路是一致的:出站流量必須可以被平台方攔截。


本章刻意糾正三件事:

(一)「Dynamic Workers 已經 GA」 —— 沒有。最後一次正式狀態聲明是 2026-03-24 的 open beta,我找不到 GA 公告。

(二)「Dynamic Workflows 是 Workflows v2」 —— 不是。見 33.5。

(三)「Workers for Platforms 被 Dynamic Workers 取代了」 —— 沒有任何根據。兩份文件互不引用,兩者解決的是不同時間點的問題(deploy time vs runtime)。


第 21 章的 Workflows 有一個對平台型產品致命的限制:workflow class 必須存在於你部署的程式碼裡(class_name 在部署時就綁死了)。

這對「每個客戶帶自己的程式碼」的平台是不可行的 —— engine 在幾小時後醒來要繼續執行時,得能重新進入正確那個租戶的程式碼。

@cloudflare/dynamic-workflows 就是解這件事的。官方描述:

「一個 Workflow 通常在部署時綁定到單一的靜態 class_name」,而目標是「從單一 dispatcher worker,為每個租戶執行不同的 Cloudflare Workflow 實作」。

兩個主要 API:wrapWorkflowBinding() 與 createDynamicWorkflowEntrypoint()。

⚠️ 一個很容易漏的步驟:DynamicWorkflowBinding 必須從你的 Worker Loader Worker 再匯出一次。

export { DynamicWorkflowBinding } from "@cloudflare/dynamic-workflows";

漏掉的話 engine 找不到入口。

狀態:MIT 授權的函式庫(repo cloudflare/dynamic-workflows),2026-05-01 的 changelog 發表。它是一個函式庫,不是一個計費產品,而且 README 裡沒有任何 experimental / beta / production-ready 的穩定性標示。

所以它不是「Workflows v2」。 底層還是第 21 章那個 Workflows,這只是一層讓 workflow 實作可以動態解析的包裝。


實測 WorkerStub.getDurableObjectClass() 的存在,指向另一個能力:

this.ctx.facets.get(name: string, callback: () => FacetStartupOptions): Fetcher

這屬於 Dynamic Workers,不是 Durable Objects 的核心功能。 文件在 /dynamic-workers/usage/durable-object-facets/,不在 /durable-objects/。這個歸屬很容易搞錯。

官方:「Durable Object Facets 讓你從 Dynamic Worker 載入一個 Durable Object 類別,並把它當作你自己的 Durable Object 的子物件來執行。」

三層模型:

你的 supervisor DO
└─ 從 Dynamic Worker 動態載入的 DO 類別
└─ facet 實例(每個有自己隔離的 SQLite)

注意 ctx.facets.get(name, cb) 和 LOADER.get(id, cb) 是同一個懶惰 callback 模式 —— callback 只在 facet 尚未啟動或已 hibernate 時執行。所以 33.2 那條「callback 必須冪等」的規則在這裡同樣適用。

每個 facet 有自己隔離的 SQLite —— 這正是第 15 章那個「每個 DO 一顆資料庫」再往下一層。對「每個租戶一個 AI 生成的小應用,各自有狀態」這種需求非常合適。

文件上沒有任何 beta / experimental 標示,也沒有提到需要 compatibility flag。


33.7 LinkForge:租戶自訂重導向邏輯

Section titled “33.7 LinkForge:租戶自訂重導向邏輯”

需求:讓租戶提供一小段 JS,在重導向前決定要不要改目標 URL(A/B 測試、地區分流、時段限制)。

這是 Dynamic Workers 的場景(程式碼是使用者在你的 UI 上貼的,不是他們 deploy 的 Worker)。

app.get("/:slug", async (c) => {
const link = await resolve(c.env, c.req.param("slug"));
if (!link) return c.notFound();
// 沒有自訂邏輯就走快路徑,不要付 Dynamic Worker 的錢
if (!link.hookVersion) return c.redirect(link.url, 302);
const code = await c.env.CODE.get(`hook:${link.tenantId}:${link.hookVersion}`);
if (!code) return c.redirect(link.url, 302);
// id 同時是快取鍵與計費鍵:租戶 + 版本
const stub = c.env.LOADER.get(`hook:${link.tenantId}:${link.hookVersion}`, () => ({
compatibilityDate: "2026-07-24",
mainModule: "hook.js",
modules: { "hook.js": code },
env: {
// 只給它需要的資料,不給任何 binding
TENANT_ID: link.tenantId,
DEFAULT_URL: link.url,
},
globalOutbound: null, // 租戶程式碼不能連外
limits: { cpuMs: 20, subRequests: 0 },
}));
let target = link.url;
try {
const res = await stub.getEntrypoint().fetch("http://hook/", {
method: "POST",
body: JSON.stringify({
country: c.req.raw.cf?.country ?? null,
ua: c.req.header("user-agent") ?? "",
hour: new Date().getUTCHours(),
}),
headers: { "content-type": "application/json" },
});
const out = (await res.json()) as { url?: string };
// 一定要驗證回傳值 —— 租戶程式碼可以回傳任何東西
if (out.url && isSafeRedirect(out.url, link.tenantId)) target = out.url;
} catch (e) {
console.warn("hook failed", link.tenantId, String(e)); // 失敗就用預設值
}
return c.redirect(target, 302);
});

五個設計點,每一個都對應本章的一項實測:

  1. 沒有 hook 就完全不碰 loader —— 計費是按 unique Worker 算的,沒必要為沒有自訂邏輯的租戶付錢。
  2. id = 租戶 + 版本 —— 33.2 的計費規則。版本沒變就不會產生新的計費單位。
  3. env 只放資料,不放 binding —— 33.2 的能力邊界。租戶程式碼看不到 KV、D1、任何東西。
  4. globalOutbound: null + subRequests: 0 —— 雙重保險。但記住 33.2 的實測:limits 本機不生效,所以本機看起來正常不代表 production 安全。
  5. 回傳值必須驗證。 沙箱保證租戶程式碼不能做壞事,但不保證它回傳的東西是好的。isSafeRedirect() 要擋掉 javascript:、擋掉指向其他租戶網域的 URL、擋掉 open redirect。

最後這一點值得強調:沙箱管的是「它能做什麼」,不是「它說了什麼」。 從沙箱回來的資料和使用者輸入是同一個信任等級。

如果需求變成「租戶自己用 wrangler deploy 一個完整的 Worker 上來」,那才切換到 Workers for Platforms。


#結論影響
1兩個產品的官方文件互不引用,沒有任何比較頁「取代」的說法沒有根據
2WorkerLoader 是真正的類別(get/load 在 prototype 上)不是 RPC proxy(對照第 23、30、32 章)
3LOADER.get() 同步回傳 WorkerStub(isPromise: false),callback 尚未執行取得 stub 是零成本的
4WorkerStub 有 getEntrypoint() 與 getDurableObjectClass()後者是 DO Facets 的基礎
5官方明載 callback 可能被呼叫任意次數必須冪等,不可放副作用
6實測載入的 Worker Object.keys(env) 只有你傳進去的那些能力導向安全模型,不是黑名單
7globalOutbound: null 時 fetch 丟「not permitted to access the internet」網路完全封死
8同一沙箱內 eval 與 WebAssembly.compile 也都被禁但那是平台性質(第 27 章),非 Dynamic Workers 特有
9WorkerLoaderModule 支援 js/cjs/text/data/json/py/wasmPython 模組可載入
10limits: { cpuMs: 50 } 本機完全不生效(5 秒 CPU 迴圈跑完)沙箱隔離本機測得出,資源上限測不出
11Dynamic Workers 文件裡「local」「wrangler dev」「miniflare」一次都沒出現本機行為完全無文件
12計費規則:不給 ID 或用 .load() → 每次呼叫算一個 unique Workerid 是計費鍵,不只是快取鍵
13beta 免費期已於 2026-05-26 結束
14Dynamic Workers 最後一次正式狀態聲明是 open beta(2026-03-24);找不到 GA 公告現行文件也沒有 beta 橫幅,狀態在文件裡模糊
15DynamicDispatchLimits 是 cpuMs 與 subRequests(大寫 R)拼錯會靜默不生效
16官方示範用 e.message.startsWith("Worker not found") 與 .includes("CPU time limit")沒有型別化錯誤類別
17本機沒有 remote: true 時丟 Binding DISPATCHER needs to be run remotely
18由 17 推得:startsWith("Worker not found") 在本機永遠 false404 分支在本機是死程式碼
19官方明文:所有客戶共用單一 namespace,不要一客戶一 namespace
20WfP $25/月含 20M 請求、60M CPU ms、1,000 script;超出每個 script +$0.02
21WfP 整條鏈只收 1 次請求費,但 CPU 三段都計dispatch Worker 要寫薄
22Dynamic Workflows 是 MIT 授權的函式庫,不是計費產品,README 無穩定性標示不是「Workflows v2」
23DynamicWorkflowBinding 必須從 Worker Loader Worker 再匯出很容易漏
24DO Facets 的文件在 /dynamic-workers/,不在 /durable-objects/歸屬容易搞錯
25每個 facet 有自己隔離的 SQLite;ctx.facets.get(name, cb) 是同一個懶惰 callback 模式冪等規則同樣適用

  1. 在 get() 的 callback 裡放一個 KV 寫入當作「計次」,然後用不同的 id 與相同的 id 各打 20 次,觀察計數與你的預期差多少 —— 這是 33.2 那條「callback 不可有副作用」的實證。
  2. 部署一個 limits: { cpuMs: 20 } 的 dynamic Worker 跑一段無窮迴圈,比較本機(跑完)與 production(拋例外)的差異。
  3. 把 globalOutbound 從 null 換成一個你自己寫的 WorkerEntrypoint,記錄租戶程式碼嘗試的每一個出站請求。
  4. 用 subrequests(小寫 s)取代 subRequests,確認它被靜默忽略。
  5. 為 LinkForge 的 hook 寫一個惡意的租戶程式碼:回傳 javascript:alert(1)、回傳指向別的租戶的 URL、回傳一個 10 MB 的字串。確認 isSafeRedirect() 全部擋下 —— 沙箱不會幫你做這件事。

Workers for Platforms

Dynamic Workers

第 33 章結束 Part 6。下一章起進入 Part 7 — AI:第 34 章 Workers AI,平台上唯一真正有 GPU 的地方(對照第 29 章的 Containers 完全沒有)。