Dynamic Workflowsの紹介:テナントに追従する耐久的な実行
2026-05-01 | Dan Lapid、Luís Duarte | 9分で読める
8年前にWorkersを最初にローンチした時、それは開発者向けの直接プラットフォームでした。長年にわたり、私たちはエコシステムを拡大・スケールしてきたため、プラットフォームはWorkers上で直接構築できるだけでなく、多くのマルチテナントアプリケーションを通じて顧客がコードを私たちにシップできるようになりました。現在、Workersで以下のようなものが見られます:
- ユーザーが何を望むかを説明し、AIが実装を書くアプリケーション
- すべての顧客のビジネスロジックが実行時に、プラットフォームが以前見たことのないTypeScriptであるマルチテナントSaaS
- 独自のツールを書いて実行するエージェント
- すべてのリポジトリが独自のパイプラインを定義するCI/CDプロダクト
先月、Dynamic Workersのオープンベータをリリースした時、私たちはこれらのプラットフォームにコンピュート側の明確なプリミティブを提供しました:Workersランタイムに実行時にコードを渡し、同じマシン上で、1桁ミリ秒で、分離されたサンドボックス化されたWorkerを取得します。
Durable Object Facetsは同じ考え方をストレージに拡張しました — 動的にロードされた各アプリは独自のSQLiteデータベースを持つことができ、オンデマンドでスピンアップされ、プラットフォームが監督者として前に座ります。
Artifactsはソース管理に対して同じことを行いました:Git-native、バージョン管理されたファイルシステムで、数百万単位で作成でき、エージェントごと、セッションごと、テナントごとに1つです。
そのため、ストレージとソース管理のための動的デプロイメントがあります。次は何でしょうか?
本日、私たちは耐久的な実行と動的デプロイメントをDynamic Workflowsで橋渡しします。
耐久的な実行と動的な実行のギャップ
Cloudflare Workflowsは私たちの耐久的な実行エンジンです。これはrun(event, step)関数をプログラムに変え、すべてのステップが障害を生き残り、数時間または数日間スリープでき、外部イベントを待つことができ、アイソレートがリサイクルされた時に正確に中断したところから再開します。
これは、単一のリクエストを超えて「進み続ける」必要があるすべてのもの、つまりオンボーディングフロー、ビデオトランスコーディングパイプライン、マルチステージ課金、長時間実行されるエージェントループ、および — Workflows V2の時点で — アカウントごとに最大50,000の同時インスタンスと秒あたり300の新しいインスタンスに対する正しいプリミティブです。エージェント時代向けに再設計されました。
しかし、Workflowsには常に1つの仮定が組み込まれていました:ワークフローコードはデプロイメントの一部です。あなたのwrangler.jsoncには「エンジンがWORKFLOWSを呼び出す時、MyWorkflowというクラスを実行する」というブロックがあります。1つのバインディング、1つのクラス。デプロイごと。
これはすべてのコードを所有している場合は問題ありません。従来のアプリケーションを実行している場合は問題ありません。顧客にワークフローをシップさせたい瞬間に機能しなくなります。
AIがすべてのテナント向けにTypeScriptを書くアプリプラットフォームを構築しているとします。各リポジトリが独自のパイプラインを持つCI/CDプロダクトを実行しているとします。各エージェントが独自の耐久的なプランを書くエージェントSDKを使用しているとします。
これらのすべてのケースで、ワークフローはすべてのテナント、すべてのエージェント、すべてのリクエストで異なります。バインドする単一のクラスはありません。
これはDynamic Workersがコンピュートに対して解決した問題と同じ形状であり、Durable Object Facetsがストレージに対して解決した問題と同じです。私たちはそれを耐久的な実行に対してまだ解決していませんでした。
Dynamic Workflows
@cloudflare/dynamic-workflowsは小さなライブラリです。大体300行のTypeScript。これにより、単一のWorker — Worker Loader — がすべてのcreate()呼び出しを異なるテナントのコードにルーティングでき、重要なことに、Workflowsエンジンがrun(event, step)をワークフローが実際に実行される時(秒後、数時間後、または数日後)に同じコードにディスパッチできます。
ここが全体的なパターンです。
Worker Loader:
import {
createDynamicWorkflowEntrypoint,
DynamicWorkflowBinding,
wrapWorkflowBinding,
} from '@cloudflare/dynamic-workflows';
export { DynamicWorkflowBinding };
function loadTenant(env, tenantId) {
return env.LOADER.get(tenantId, async () => ({
compatibilityDate: '2026-01-01',
mainModule: 'index.js',
modules: {
'index.js': await fetchTenantCode(tenantId)
},
env: {
WORKFLOWS: wrapWorkflowBinding({ tenantId })
},
}));
}
export const DynamicWorkflow = createDynamicWorkflowEntrypoint<Env>(
async ({ env, metadata }) => {
const stub = loadTenant(env, metadata.tenantId);
return stub.getEntrypoint('TenantWorkflow');
}
);
export default {
fetch(request, env) {
const tenantId = request.headers.get('x-tenant-id');
return loadTenant(env, tenantId).getEntrypoint().fetch(request);
},
};
wrangler.jsoncに追加:
"workflows": [
{
"name": "dynamic-workflow",
"binding": "WORKFLOW",
"class_name": "DynamicWorkflow"
}
]
テナントは平易で慣用的なWorkflowsコードを書きます。
テナントはディスパッチされていることに気づきません:
import { WorkflowEntrypoint } from 'cloudflare:workers';
export class TenantWorkflow extends WorkflowEntrypoint {
async run(event, step) {
return step.do('greet', async () => `Hello, ${event.payload.name}!`);
}
}
export default {
async fetch(request, env) {
const instance = await env.WORKFLOWS.create({
params: await request.json()
});
return Response.json({ id: await instance.id });
},
};
それだけです。テナントはenv.WORKFLOWS.create(...)を完全に通常のWorkflowバインディングのように見えるものに対して呼び出します。ワークフローID、.status()、.pause()、リトライ、ハイバーネーション、耐久的なステップ、step.sleep('24 hours')、step.waitForEvent() — すべてが常にと同じように機能します。
ライブラリは1つのことを処理します:Workflowsエンジンが最終的に目覚めてrun(event, step)を呼び出す時、それが正しいテナントのコード内で終わることを確認することです。
どのように機能するか
3つのレイヤー:上部のWorkflowsエンジン(プラットフォーム)、中央のWorker Loader、下部のテナントのコード(Dynamic Worker)。
リクエストがWorker Loaderに到達すると、実行を正しい動的コードにその場でルーティングします。実行の残りは、これら3つのレイヤー間のハンドオフであり、時間的に左から右です:リクエストが入り、エンジンまで跳ね上がり、永続化され、後で再び跳ね下がります。
フローを歩く:
① → ② テナントのコードに入る
Worker LoaderはHTTPリクエストを受け取り、どのテナント用かを判断し、Worker Loaderを通じてそのテナントのコードをロードし、リクエストをそのdefault.fetchに転送します。テナントに渡すenvには、WORKFLOWS: wrapWorkflowBinding({ tenantId })が含まれます。テナントの観点からは、それは実際のWorkflowバインディングのように見え、機能します。
③ Worker Loaderまで
テナントがenv.WORKFLOWS.create({ params })を呼び出す時、それは実際にはWorker Loader — ラップされたバインディングはWorkerEntrypoint サブクラス(DynamicWorkflowBinding)であり、ランタイムはロード時にテナントのメタデータで特化させたものへのリモートプロシージャコール(RPC)を行っています。
そのため、あなたのWorker Loaderから{ DynamicWorkflowBinding }をエクスポートする必要があります:ランタイムはcloudflare:workersエクスポートでクラスを検索することによってテナントごとのスタブを構築します。
Dynamic Worker境界を越えるバインディングはRPCスタブである必要があります — 平易な{ create, get }オブジェクトは構造化クローンできず、生のWorkflowバインディングもシリアライズ可能ではありません。
Worker Loader内で、ラップされたバインディングはペイロードを透過的に書き直します:
テナント呼び出し:
create({ params: { name: 'Alice' } })
│
▼
エンジンが見るもの:
create({ params: { __workerLoaderMetadata: { tenantId: 't-42' }, params: { name: 'Alice' } }})
④ エンジンまで
Worker Loaderは実際のWORKFLOWSバインディングで.create()を呼び出し、エンベロープをパラメータとして渡します。ここからWorkflowsエンジンが引き継ぎます。event.payload — これでエンベロープを含む — を永続化し、実行をスケジュールします。
エンジンが後でワークフローを目覚めさせるたびに(24時間のスリープ後、クラッシュ後、またはデプロイ後であるかどうかに関わらず)、メタデータはペイロードと一緒に乗り、実行をルーティングするのを待ちます。
1つの含意:メタデータをルーティングヒントとして扱い、認可としてではありません。テナントはinstance.status()を通じてそれを読み取ることができます。そこにシークレットを入れないでください。
⑤ → ⑥ エンジンが下がってくる
エンジンがステップを実行する準備ができた時、wrangler.jsoncに登録したクラス — createDynamicWorkflowEntrypointが与えたもの — で.run(event, step)を呼び出します。そのクラスはエンベロープをアンラップし、メタデータをあなたが書いたloadRunnerコールバックに渡し、アンラップされたイベントをコールバックが返すランナーに転送します。
コールバックはすべての興味深いことが起こる場所であり、それは完全にあなたのものです:
- R2からテナントの最新ソースをフェッチします
- 彼らのプラン層をチェックし、リージョンを選択します
- テナントごとのログ用にテール Workerをアタッチします
- @cloudflare/worker-bundlerでTypeScriptをその場でバンドルします
一般的なケースでは、Worker Loaderに渡すだけです:
const stub = env.LOADER.get(tenantId, () => loadTenantCode(tenantId));
return stub.getEntrypoint('TenantWorkflow');
Worker LoaderはIDでキャッシュするため、多くのステップを多くの時間にわたって実行するワークフローは、それらすべてで同じDynamic Workerを再利用します。アイソレートが最終的に削除される時、次のstep.do()はコードを再度プルし、進み続けます — テナントのワークフローは何も起こったことに気づきません。
Dynamic Workerは1桁ミリ秒で起動し、数メガバイトのメモリを使用するため、ディスパッチオーバーヘッドは本質的に無料です。
100万のテナントを持つことができ、それぞれが独自の異なるワークフローコードを持ち、それぞれが必要なステップ境界で遅延スピンアップされ、アイドル中はコストがかかりません。
エスケープハッチ
WorkflowEntrypointを自分でサブクラス化したい場合 — run()の周りにログを追加するため、テナントごとの可観測性を配線するため、またはカスタム状態をスレッド化するため — ライブラリはcreateDynamicWorkflowEntrypointが構築されている低レベルのdispatchWorkflowプリミティブを公開します:
import { dispatchWorkflow } from '@cloudflare/dynamic-workflows';
export class MyDynamicWorkflow extends WorkflowEntrypoint {
async run(event, step) {
return dispatchWorkflow(
{ env: this.env, ctx: this.ctx },
event,
step,
({ metadata, env }) => loadRunnerForTenant(env, metadata),
);
}
}
その他すべて — ID、一時停止/再開、sendEvent、リトライ — は変更されずに実際のWorkflowsエンジンに落ちます。
Dynamic Workersはプリミティブです
仕様から一歩下がってください。このライブラリの興味深いすべての行は、アウトバウンド側の.create()の周りのラッパーか、インバウンド側のWorkflowEntrypointの周りのラッパーのいずれかです。
実際の作業 — テナントのコードをスピンアップする、それをサンドボックス化する、RPCを境界を越えてルーティングする、アイソレートをキャッシュする、ステップ間でハイバーネーションする — はすべてDynamic Workersの下で行われます。
それが本当の話であり、それはWorkflowsよりもはるかに大きいです。
Dynamic Workersはすべてを飲み込むプリミティブです。Durable Object Facetsは同じパターンをDurable Objectsに適用したものです。Dynamic WorkflowsはそのパターンをWorkflowEntrypointに適用したものです。
それぞれは、あなたが常に持っていた静的バインディングと、顧客に今渡すことができる動的バージョンの間の同じ小さな量のエンベロープとアンラップグルーです。
そして、私たちはWorkflowsで止まっていません。Workersが現在公開しているすべてのバインディングは動的な対応物に向かっています — 各プロデューサーが独自のハンドラーをシップするキュー、キャッシュ、データベース、オブジェクトストア、AIバインディング、およびすべてのテナントが独自のツールを持ってくるMCPサーバー。
今日Workerにバインドするものは何でも、すぐに動的にバインドできるようになります:テナントごと、エージェントごと、リクエストごとにディスパッチされ、ゼロアイドルコストで。
このようなプラットフォームを実行する単位経済学は、率直に言って、ばかげています。
マルチテナント製品をシップすることは、かつて、すべての顧客に独自のコンテナ、独自のデータベース、独自のディスク、独自のスケジューラを与え、オーケストレーショングルー、サービスメッシュ、および頭を悩ませる課金数学でそれを一緒に縫い合わせることを意味していました。