Dynamic Workflows の紹介:テナントに追随する耐久実行
2026-05-01 — Dan Lapid, Luís Duarte — 9 min read
Workers を最初にローンチしたのは8年前で、当初は開発者向けの直接的なプラットフォームでした。以来、プラットフォームは拡張・スケールされ、プラットフォーム自身が Workers を直接利用するだけでなく、多くのマルチテナントアプリケーションを通じてユーザーがコードをデプロイできるようになりました。
現在 Workers 上では次のようなケースが見られます:
- ユーザーがやりたいことを記述し、AI がその実装コードを書き上げるアプリケーション
- 各顧客のビジネスロジックがランタイム時にプラットフォームがこれまで見たことのない TypeScript で定義されるマルチテナント SaaS
- 自分でツールを書いて実行するエージェント
- 各リポジトリが独自のパイプラインを定義する CI/CD プロダクト
先月、Dynamic Workers のオープンベータを公開した際、ランタイム側の計算処理に対してクリーンなプリミティブを提供しました。ランタイムにコードを渡すと、同一マシン上でミリ秒単位の遅延で分離されたサンドボックス Worker を返します。Durable Object Facets は同じ考えをストレージに拡張し、動的にロードされる各アプリにオンデマンドで独自の SQLite データベースを持たせられるようにしました。Artifacts はソースコントロールに同様のアプローチを提供し、Git ネイティブでバージョン化されたファイルシステムをエージェントごと、セッションごと、テナントごとに数千万単位で作成できるようにしました。
つまり、ストレージとソースコントロールには動的デプロイが存在します。次は何か?
本日発表すること
本日は、耐久実行(durable execution)と動的デプロイを橋渡しする Dynamic Workflows を発表します。
耐久実行と動的デプロイのギャップ
Cloudflare Workflows は耐久実行エンジンです。run(event, step) 関数を、各ステップが障害を越えて生き残り、数時間〜数日間スリープでき、外部イベントを待ち、アイソレートが回収されたときに正確に中断位置から再開するプログラムに変えます。オンボーディングフロー、ビデオトランスコードパイプライン、多段階請求、長時間実行するエージェントループなど、「単一リクエストを越えて続ける」必要があるあらゆる用途に適したプリミティブです。Workflows V2 ではアカウントごとに最大 50,000 同時インスタンス、毎秒 300 新規インスタンスといったスケーリングが可能になり、エージェンティックな時代向けに再設計されています。
しかし Workflows には一つの前提がありました:ワークフローコードはデプロイの一部である、ということです。あなたの wrangler.jsonc には "engine が 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 バインディングに対して呼び出すだけに見えます。Workflow ID、.status()、.pause()、リトライ、ハイバネーション、durable steps、step.sleep('24 hours')、step.waitForEvent() — すべて従来どおり動作します。ライブラリが担うのは、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 への RPC(Remote Procedure Call)を行っています。ラップされたバインディングは WorkerEntrypoint のサブクラス(DynamicWorkflowBinding)で、ランタイムがロード時にテナントのメタデータで特殊化します。これが export { DynamicWorkflowBinding } を Worker Loader で行う理由で、ランタイムは cloudflare:workers エクスポートを見てテナントごとのスタブを構築します。Dynamic Worker 境界を越えるバインディングは RPC スタブでなければなりません — 単純な { create, get } オブジェクトは structured-clone できず、生の Workflow バインディングもシリアライズ可能ではないためです。
-
Worker Loader 内では、ラップされたバインディングがペイロードを書き換えます:
-
テナントが呼ぶ:
create({ params: { name: 'Alice' } })
-
エンジンに見える形:
create({ params: { __workerLoaderMetadata: { tenantId: 't-42' }, params: { name: 'Alice' } }})
-
④ エンジンまで上がる。
-
Worker Loader はそのエンベロープを params として本物の WORKFLOWS バインディングの .create() を呼びます。ここから先は Workflows エンジンの責任です。エンジンは event.payload(今はエンベロープを含む)を永続化し、実行をスケジュールします。エンジンが後でワークフローを起動するたびに(24時間のスリープ後であれ、クラッシュからであれ、デプロイ後であれ)、メタデータはペイロードと一緒に運ばれ、run をルーティングするために使われます。
-
重要な点:メタデータはルーティングのヒントとして扱ってください。認可情報としては扱わないでください。テナントは instance.status() 経由でそれを読み返すことができます。秘密はそこに入れないでください。
-
⑤ → ⑥ エンジンが下りてくる。
-
エンジンがステップを実行する準備ができると、wrangler.jsonc に登録したクラス(createDynamicWorkflowEntrypoint が生成したクラス)で .run(event, step) を呼びます。そのクラスはエンベロープをアンラップし、あなたが書いた loadRunner コールバックにメタデータを渡して、アンラップされたイベントをコールバックが返すランナーにフォワードします。コールバックがすべて面白いことを行う場所で、完全にあなたの自由です。
-
たとえば、テナントの最新ソースを R2 からフェッチしたり、プランの階層を確認してリージョンを選んだり、テナントごとのログ用に tail 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 は数ミリ秒でブートし、数メガバイトのメモリしか使わないため、ディスパッチのオーバーヘッドは事実上ゼロです。テナントが百万あってそれぞれ異なるワークフローコードを持ち、ステップの境界で必要に応じて遅延起動され、アイドル時にコストが発生しない、という状態を実現できます。
エスケープハッチ
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、pause/resume、sendEvent、リトライなどの扱いはすべて本物の Workflows エンジンにそのまま落ちます。
Dynamic Workers がプリミティブである理由
少し具体から離れて考えると、このライブラリの興味深い行はほとんどが外向きの .create() のラッパーか、インバウンド側の WorkflowEntrypoint のラッパーです。本当の仕事 — テナントのコードを起動し、サンドボックス化し、境界を越えて RPC をルーティングし、アイソレートをキャッシュし、ステップの間でハイバネートさせること — は下層の Dynamic Workers が行っています。これが本当の物語であり、Workflows よりずっと大きなものです。
Dynamic Workers はすべてを呑み込むプリミティブです。Durable Object Facets は Durable Objects に同じパターンを適用したもので、Dynamic Workflows はそのパターンを WorkflowEntrypoint に適用したものです。いずれも、従来の静的なバインディングと、顧客に渡せる動的バージョンとの間の小さなエンベロープとアンラップの接着に過ぎません。
そして我々は Workflows にとどまりません。現在 Workers が公開しているすべてのバインディングは動的対応へ向かっています — 各プロデューサが独自のハンドラを出す queues、キャッシュ、データベース、オブジェクトストア、AI バインディング、各テナントが自分のツールを持ち込む MCP サーバーなど。今日 Worker にバインドできるものは、まもなくすべて動的にバインドできるようになります:テナントごと、エージェントごと、リクエストごとにディスパッチされ、アイドル時のコストはゼロ。
このようなプラットフォームを運用する際の単位当たり経済(unit economics)は、率直に言って驚くほど良好です。かつてマルチテナント製品を出すには、各顧客にコンテナ、データベース、ディスク、スケジューラを与え、それらをオーケストレーションの仕組みやサービスメッシュ、煩雑な請求計算でつなぐ必要がありました。Dynamic Workers とその派生は、その世界を根本から変えます。
Dynamic Workflows の詳細や導入方法は @cloudflare/dynamic-workflows パッケージのドキュメントとサンプルコードを参照してください。