多租戶執行使用者程式碼:Workers for Platforms vs Dynamic Workers
如果你在做一個平台型產品,遲早會遇到這個需求:讓客戶跑他們自己的程式碼。Shopify 的 app、Zapier 的自訂步驟、CI 平台的 build script、或者 LinkForge 讓租戶自訂重導向前置邏輯。
Cloudflare 有兩個產品解決這件事,名字都很像、都在 2026 年很活躍、而且官方文件從來沒有把它們放在一起比較過。本章最主要的價值就是把它們分乾淨。
33.1 一句話的區別
Section titled “33.1 一句話的區別”| Workers for Platforms | Dynamic Workers | |
|---|---|---|
| 誰寫程式碼 | 你的客戶 | 你的 Worker(或 AI,或使用者貼上的片段) |
| 什麼時候上傳 | 客戶部署時(上傳到 namespace) | 執行期,就是一個字串 |
| 怎麼叫用 | env.DISPATCHER.get(name) | env.LOADER.get(id, () => ({ modules })) |
| 設定鍵 | dispatch_namespaces | worker_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」這種說法是沒有根據的。上面那張表的判準是本章自己的整理,不是官方立場。
33.2 Dynamic Workers
Section titled “33.2 Dynamic Workers”設定與 API
Section titled “設定與 API”{ "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;}get() 同步回傳
Section titled “get() 同步回傳”實測:
{ "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 的基礎。
callback 可能被呼叫多次
Section titled “callback 可能被呼叫多次”官方文件講得很直白:
「永遠不保證兩個請求會進到同一個 isolate。即使你用同一個
WorkerStub發多個請求,它們也可能在不同的 isolate 執行。傳給loader.get()的 callback 可能被呼叫任意次數(雖然被呼叫超過一次並不常見)。」
實測連續三次 get() + fetch():
{ "callbackInvocations": 1, "statuses": [200, 200, 200] }本機只呼叫了一次(warm isolate 被重用),但這不是保證。
實務規則:callback 必須是冪等的、純函式的。 絕對不要在裡面做計費、寫 log、扣配額、或任何有副作用的事。它的工作只有一件:回傳程式碼。
需要「載入一次就記一次」的話,記在
get()外面。
能力邊界:env 就是全部
Section titled “能力邊界:env 就是全部”這是 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 真的封死網路
Section titled “globalOutbound: null 真的封死網路”官方:「設 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,你可以檢查、改寫、或阻擋。
modules 支援的型別比你想的多
Section titled “modules 支援的型別比你想的多”實測型別:
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 的租戶程式碼在本機看起來完全正常。
計費:ID 的用法直接決定成本
Section titled “計費:ID 的用法直接決定成本”每月含 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。
33.3 Workers for Platforms
Section titled “33.3 Workers for Platforms”設定與 API
Section titled “設定與 API”{ "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都不會報錯,只會靜默不生效。
錯誤處理靠比對字串
Section titled “錯誤處理靠比對字串”官方 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;}沒有型別化的錯誤類別,字串比對是官方示範的做法。這很脆弱,但目前沒有替代方案。
所有客戶共用一個 namespace
Section titled “所有客戶共用一個 namespace”這是很多人第一次做會做錯的。官方原文:
「你所有客戶的 Worker 都應該放在單一 namespace(例如
production)。不要為每個客戶建立一個 namespace。」
唯一建議的第二個 namespace 是 staging,用來安全測試變更。
隔離靠的是 Worker 的名字與 dispatch 時的參數,不是 namespace。
本機:一定要 remote
Section titled “本機:一定要 remote”實測不加 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 去連接遠端的
productionnamespace。」
沒有任何一頁說明純本機模式會怎樣(沒有限制清單、沒有說會失敗)—— 上面那個錯誤訊息是我實測出來的。
「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 Workers
Section titled “Outbound Workers”第三個參數的 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 的機制。 兩個產品在這一點上的設計思路是一致的:出站流量必須可以被平台方攔截。
33.4 兩個容易被搞錯的說法
Section titled “33.4 兩個容易被搞錯的說法”本章刻意糾正三件事:
(一)「Dynamic Workers 已經 GA」 —— 沒有。最後一次正式狀態聲明是 2026-03-24 的 open beta,我找不到 GA 公告。
(二)「Dynamic Workflows 是 Workflows v2」 —— 不是。見 33.5。
(三)「Workers for Platforms 被 Dynamic Workers 取代了」 —— 沒有任何根據。兩份文件互不引用,兩者解決的是不同時間點的問題(deploy time vs runtime)。
33.5 Dynamic Workflows
Section titled “33.5 Dynamic Workflows”第 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 實作可以動態解析的包裝。
33.6 Durable Object Facets
Section titled “33.6 Durable Object Facets”實測 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);});五個設計點,每一個都對應本章的一項實測:
- 沒有 hook 就完全不碰 loader —— 計費是按 unique Worker 算的,沒必要為沒有自訂邏輯的租戶付錢。
- id = 租戶 + 版本 —— 33.2 的計費規則。版本沒變就不會產生新的計費單位。
env只放資料,不放 binding —— 33.2 的能力邊界。租戶程式碼看不到 KV、D1、任何東西。globalOutbound: null+subRequests: 0—— 雙重保險。但記住 33.2 的實測:limits本機不生效,所以本機看起來正常不代表 production 安全。- 回傳值必須驗證。 沙箱保證租戶程式碼不能做壞事,但不保證它回傳的東西是好的。
isSafeRedirect()要擋掉javascript:、擋掉指向其他租戶網域的 URL、擋掉 open redirect。
最後這一點值得強調:沙箱管的是「它能做什麼」,不是「它說了什麼」。 從沙箱回來的資料和使用者輸入是同一個信任等級。
如果需求變成「租戶自己用 wrangler deploy 一個完整的 Worker 上來」,那才切換到 Workers for Platforms。
33.8 本章實測結論彙整
Section titled “33.8 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | 兩個產品的官方文件互不引用,沒有任何比較頁 | 「取代」的說法沒有根據 |
| 2 | WorkerLoader 是真正的類別(get/load 在 prototype 上) | 不是 RPC proxy(對照第 23、30、32 章) |
| 3 | LOADER.get() 同步回傳 WorkerStub(isPromise: false),callback 尚未執行 | 取得 stub 是零成本的 |
| 4 | WorkerStub 有 getEntrypoint() 與 getDurableObjectClass() | 後者是 DO Facets 的基礎 |
| 5 | 官方明載 callback 可能被呼叫任意次數 | 必須冪等,不可放副作用 |
| 6 | 實測載入的 Worker Object.keys(env) 只有你傳進去的那些 | 能力導向安全模型,不是黑名單 |
| 7 | globalOutbound: null 時 fetch 丟「not permitted to access the internet」 | 網路完全封死 |
| 8 | 同一沙箱內 eval 與 WebAssembly.compile 也都被禁 | 但那是平台性質(第 27 章),非 Dynamic Workers 特有 |
| 9 | WorkerLoaderModule 支援 js/cjs/text/data/json/py/wasm | Python 模組可載入 |
| 10 | limits: { cpuMs: 50 } 本機完全不生效(5 秒 CPU 迴圈跑完) | 沙箱隔離本機測得出,資源上限測不出 |
| 11 | Dynamic Workers 文件裡「local」「wrangler dev」「miniflare」一次都沒出現 | 本機行為完全無文件 |
| 12 | 計費規則:不給 ID 或用 .load() → 每次呼叫算一個 unique Worker | id 是計費鍵,不只是快取鍵 |
| 13 | beta 免費期已於 2026-05-26 結束 | |
| 14 | Dynamic Workers 最後一次正式狀態聲明是 open beta(2026-03-24);找不到 GA 公告 | 現行文件也沒有 beta 橫幅,狀態在文件裡模糊 |
| 15 | DynamicDispatchLimits 是 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") 在本機永遠 false | 404 分支在本機是死程式碼 |
| 19 | 官方明文:所有客戶共用單一 namespace,不要一客戶一 namespace | |
| 20 | WfP $25/月含 20M 請求、60M CPU ms、1,000 script;超出每個 script +$0.02 | |
| 21 | WfP 整條鏈只收 1 次請求費,但 CPU 三段都計 | dispatch Worker 要寫薄 |
| 22 | Dynamic Workflows 是 MIT 授權的函式庫,不是計費產品,README 無穩定性標示 | 不是「Workflows v2」 |
| 23 | DynamicWorkflowBinding 必須從 Worker Loader Worker 再匯出 | 很容易漏 |
| 24 | DO Facets 的文件在 /dynamic-workers/,不在 /durable-objects/ | 歸屬容易搞錯 |
| 25 | 每個 facet 有自己隔離的 SQLite;ctx.facets.get(name, cb) 是同一個懶惰 callback 模式 | 冪等規則同樣適用 |
33.9 動手練習
Section titled “33.9 動手練習”- 在
get()的 callback 裡放一個 KV 寫入當作「計次」,然後用不同的 id 與相同的 id 各打 20 次,觀察計數與你的預期差多少 —— 這是 33.2 那條「callback 不可有副作用」的實證。 - 部署一個
limits: { cpuMs: 20 }的 dynamic Worker 跑一段無窮迴圈,比較本機(跑完)與 production(拋例外)的差異。 - 把
globalOutbound從null換成一個你自己寫的WorkerEntrypoint,記錄租戶程式碼嘗試的每一個出站請求。 - 用
subrequests(小寫 s)取代subRequests,確認它被靜默忽略。 - 為 LinkForge 的 hook 寫一個惡意的租戶程式碼:回傳
javascript:alert(1)、回傳指向別的租戶的 URL、回傳一個 10 MB 的字串。確認isSafeRedirect()全部擋下 —— 沙箱不會幫你做這件事。
Workers for Platforms
Dynamic Workers
-
概觀 · Getting started · Limits · Egress control · DO Facets · Dynamic Workflows · Pricing · Open beta changelog(2026-03-24)
-
型別來源:
wrangler types產生的worker-configuration.d.ts(WorkerLoader、WorkerStub、WorkerLoaderWorkerCode、DispatchNamespace、DynamicDispatchLimits)
第 33 章結束 Part 6。下一章起進入 Part 7 — AI:第 34 章 Workers AI,平台上唯一真正有 GPU 的地方(對照第 29 章的 Containers 完全沒有)。