概要
Workflows(マルチステップアプリケーション向けの耐久実行エンジン)を最初に設計したとき、ワークフローはユーザーのサインアップや注文といった人間の操作でトリガーされる想定でした。オンボーディングフローのように、ワークフローは人ごとに1つのインスタンスをサポートすればよく、人間はクリックに限界があります。
しかし実際には負荷とアクセスパターンが定量的に変化しました:人間がトリガーするワークフローは減り、機械が高速で作るエージェント駆動ワークフローが増えています。エージェントが持続的かつ自律的なインフラになり、数時間〜数日ユーザーの代理で動作するようになると、彼らの作業を支えるために耐久性のある非同期実行エンジンが必要になります。
Workflows はまさにそれを提供します:各ステップは独立してリトライ可能で、ヒューマン・イン・ザ・ループの承認でワークフローを一時停止でき、各インスタンスは障害が起きても進行を失わず生き残ります。さらに、ワークフロー自体がエージェントループを実装したり、エージェントを管理・維持する耐久ハーネスとして使われるようになっています。Agents SDK 統合により、エージェントがワークフローインスタンスを起動し、リアルタイムの進行状況を得ることが容易になりました。単一のエージェントセッションが数十のワークフローを起動でき、複数のエージェントが同時に動作すれば数千のインスタンスが数秒で作られます。Project Think の登場を受け、さらに速度は増すと見込んでいます。
これらに対応するため、Workflows 上でエージェントとアプリケーションをスケールさせやすくするために、次をサポートするようになったことを発表します:
- 50,000 concurrent instances(同時並列実行インスタンス数)、従来は 4,500
- 300 instances/second created per account(アカウントあたりの作成速度)、以前は 100
- 2 million queued instances(作成または再起床され、同時実行スロットを待っているインスタンス)/workflow、以前は 1 million
これらの増加に対応するため、利用データと原理から Workflows の制御プレーンを再設計しました。
V1:Workflows の初期アーキテクチャ
ベータ公開時に説明したように、Workflows は当社のデベロッパープラットフォーム上に構築されています。基本的に、ワークフローは一連の耐久ステップで、各ステップは独立してリトライ可能で、タスク実行、外部イベント待ち、または所定の時刻までのスリープが可能です。
export class MyWorkflow extends WorkflowEntrypoint {
async run(event, step) {
const data = await step.do("fetch-data", async () => {
return fetchFromAPI();
});
const approval = await step.waitForEvent("approval", {
type: "approval",
timeout: "24 hours",
});
await step.do("process-and-save", async () => {
return store(transform(data));
});
}
}
各インスタンスをトリガーし、そのロジックを実行し、メタデータを保存するために、SQLite バックエンドの Durable Objects を活用しました。Durable Object は分散システム内の調整とストレージのための単純だが強力なプリミティブです。
制御プレーンでは、Engine(ステップ実行、リトライ、スリープロジックを含む実際のワークフローインスタンスを実行する DO)のように、インスタンスあたり 1:1 の比率でスピンアップする DO もあれば、Account のようにアカウント単位で全ワークフローとインスタンスを管理する単一の Durable Object もありました。
ベータローンチ後、お客様が急速に Workflows をスケールするのを見て喜んだ一方で、アカウントレベル情報をすべて単一の Durable Object に保存することがボトルネックになることも分かりました。多くのユーザーは毎分数百〜数千のインスタンスを作成・実行する必要があり、Account DO に負荷が集中しました。元のレート制限(4,500 同時スロット、10 秒あたり 100 インスタンス作成)はこの限界の結果であり、V1 の制御プレーンではこれらがハードキャップでした。
すべての Account に依存する操作(作成、更新、一覧取得など)は単一の DO を経由する必要があり、高並列負荷のユーザーは Account に対して秒間数千のリクエストを生成し得ました。
V2:高スループットに向けた水平スケール
新バージョンでは、高ボリュームワークフロー向けにあらゆる操作を根本から見直しました。最終目標は、何千/秒のインスタンス作成や、同時に何百万インスタンスが動くような要件にも対応できることです。また、V2 では柔軟に制限を調整できるようにし、V1 のようなハードキャップを撤廃しました。
設計の要点は以下です:
- あるインスタンスが "存在する" かどうかの真のソースはその
Engine であること。
- V1 ではキューイング前に Engine の存在確認がなく、Engine がまだスピンアップしていない状態でインスタンスがキューされる不整合が発生する可能性がありました。
- インスタンスのライフサイクルとライブネス機構はワークフロー単位で水平にスケールし、多くのリージョンに分散されること。
- 新しい
Account シングルトンは必要最小限のメタデータのみを保持し、同時要求数に上限不変(invariant maximum)を持つこと。
V2 制御プレーンにはスケーラビリティを改善するための重要な新コンポーネントが2つあります:SousChef と Gatekeeper。
-
SousChef
Account の "副指揮" 的役割を持つコンポーネントです。以前は Account がそのアカウント内のすべてのワークフローとインスタンスのメタデータとライフサイクルを管理していました。SousChef は特定のワークフロー内のインスタンスのサブセットを管理します。アカウント内に複数の SousChef を分散させ、それらが Account に効率的に報告することで管理性を高めます。
- 副次的な利点として、アカウント単位の隔離に加えて、同一アカウント内で
per-workflow の隔離も得られます(各 SousChef は特定のワークフローだけを担当するため)。
-
Gatekeeper
- アカウント内の全 SousChefs に対して同時実行 "スロット"(concurrency limits から派生)を配布するリースシステムです。インスタンス作成時にインスタンスはアカウント内のランダムな SousChef に割り当てられ、SousChef は
Account にそのインスタンスのトリガー(スロット要求)を行います。スロットが与えられれば SousChef は実行を開始してそのインスタンスが詰まらないよう責任を持ちます。与えられなければインスタンスはキューされます。
Gatekeeper を導入することで Engine が Account を過負荷にしないようにし、SousChef と Account の通信は 1 秒に 1 回の周期で行われます。各周期でスロット要求をバッチするため、1 秒あたりの JSRPC コールは 1 回に抑えられます。これによりインスタンス作成レートが Account をオーバーロードすることを防ぎます(なお、SousChef の数が多すぎる場合は呼び出しをレート制限したり異なる時間帯に分散します)。
- またこの周期性により、古いインスタンスの公平性(既に起きているインスタンスを新規インスタンスより優先する等)や max-min 公平性を保てます。例えば、インスタンスが再起床した場合は新規作成インスタンスよりスロットの優先度が高くなるべきですが、各 SousChef は自分のインスタンスが詰まらないことを保証します。
このアーキテクチャはより分散化され、したがってスケーラブルです。インスタンス作成時のリクエストパスは次の通りです:
- 制御プレーンバージョンをチェック
- その場所にワークフローとバージョン情報のキャッシュがあれば利用
- なければ
Account を問い合わせてワークフロー名、ユニーク ID、バージョンを取得してキャッシュ
- 必要最小限のメタデータ(インスタンスペイロード、作成日時)を自身の
Engine に保存
では、Engine が制御プレーンに「存在した」と伝えるのはどうするか?それはインスタンスメタデータを設定した後のバックグラウンドで行われます。Durable Object のバックグラウンド操作は縮退やサーバ障害で失敗する可能性があるため、作成のホットパスで Engine に "alarm" を設定します。これによりバックグラウンドタスクが完了しなかった場合でも、アラームがインスタンスを起動させます。
Durable Object のアラームは将来の細かい時刻で DO インスタンスを起こすことができ、少なくとも一回(at-least-once)実行モデルと自動リトライが組み込まれています。この背景タスクとアラームの組合せを多用することで、ホットパスから操作を取り除きつつ確実に処理が行われるようにしています。これにより、インスタンス作成を高速に保ちながら信頼性を損なわないようにできます。
V2 によって得られる主な利点:
- インスタンス一覧のパフォーマンスが速く、カーソルページネーションと整合的になる
- インスタンスに対する任意の操作は正確に1回のネットワークホップで済む(直接その
Engine に行けるため、応答遅延が最小化される)
- 同時により多くのインスタンスが正しく(オンタイムで)振る舞っていることを保証でき、遅延があれば修正できる
V1 → V2 の移行
新しい制御プレーンが高負荷に耐えられるようになったあとは、顧客とインスタンスを新システムへ移行する「地味だが重要」な作業が残ります。Cloudflare 程度の規模ではこの "地味" な作業自体が大きな問題になります。Workflows は1年未満で既に数百万インスタンスと数千の顧客を抱えていました。また V1 の技術的負債により、キューに入ったインスタンスに対して Engine DO がまだ作られていないケースがあり、移行はさらに複雑でした。
移行が難しい理由は、顧客はいつでもインスタンスを実行している可能性があり、SousChef と Gatekeeper を既存アカウントに追加しても中断やダウンタイムを起こさないようにする必要があるためです。
最終的に決めた方法は、既存の Account(以降 AccountOld と呼ぶ)を SousChef として振る舞わせることでした。Account DO を保持することでインスタンスメタデータを温存しつつ、その DO を SousChef 用の DO に変換します。つまり既存 DO を永続化したまま、SousChef の機能を注入します。
次のような変更を AccountOld のコンストラクタ末尾に追加しました:
import { SousChef } from "@repo/souschef";
class AccountOld extends DurableObject {
constructor(state: DurableObjectState, env: Env) {
if (this.currentVersion === ControlPlaneVersions.SOUS_CHEFS) {
this.sousChef = new SousChef(this.ctx, this.env);
await this.sousChef.setup()
}
}
async updateInstance(params: UpdateInstanceParams) {
if (this.currentVersion === ControlPlaneVersions.SOUS_CHEFS) {
assert(this.sousChef !== undefined, 'SousChef must exist on v2');
return this.sousChef.updateInstance(params);
}
}
}
@RequiresVersion<AccountOld>(ControlPlaneVersions.V
(注:上記はソースに示されているスニペットに基づく抜粋です。)
このやり方により、既存アカウントを段階的かつシームレスに V2 の流れに乗せることができました。
まとめ
- エージェント主導の高頻度・高並列のワークロードに対応するため、Workflows の制御プレーンを V2 へ再設計しました。
SousChef と Gatekeeper の導入により、アカウント単位のボトルネックを解消し、水平スケーリングと公平性を実現しました。
- Durable Object のアラームとバックグラウンドタスクの併用でホットパスを短く保ちつつ信頼性を確保しています。
- V1→V2 の移行は、既存の Account DO を SousChef として振る舞わせることでダウンタイムなしに行いました。
これにより、Workflows はエージェント主導の "agentic era" における高スケールなワークロードをサポートできる基盤を手に入れました。