Read OSS

Miniflareの内部構造:プラグインアーキテクチャとworkerdの統合

上級

前提知識

  • このシリーズの第1回・第3回の記事
  • Cloudflare Workersランタイムモデルの基本的な理解
  • Zodスキーマバリデーションの知識
  • Node.jsにおける子プロセス管理の概念

Miniflareの内部構造:プラグインアーキテクチャとworkerdの統合

Miniflareは、Workersのローカル実行環境シミュレーターです。ローカルモードで wrangler dev を実行したり、テスト内で getPlatformProxy() を呼び出したりすると、KVネームスペース・D1データベース・R2バケット、その他あらゆるCloudflareバインディングを提供しているのがMiniflareです。Cloudflareのネットワークには一切アクセスせず、すべてあなたのマシン上で動作します。

その仕組みはシンプルで、Cloudflareのオープンソース実装である workerd を子プロセスとして起動し、精巧に組み立てた設定を渡します。その設定は28個の独立したプラグインによって生成され、各プラグインがそれぞれ1つのCloudflareサービスを担当しています。本記事では、プラグインの動作原理・workerdの管理方法・設定が頻繁に更新される状況での整合性維持の仕組みを詳しく見ていきます。

Miniflareクラス:プライベートステートの全体像

packages/miniflare/src/index.ts#L910-L965Miniflare クラスは、多くのプライベートステートを保持しています。それぞれのフィールドが何を担うかを理解することで、Miniflareが管理するものの全体像が見えてきます。

classDiagram
    class Miniflare {
        -#runtime: Runtime
        -#runtimeMutex: Mutex
        -#proxyClient: ProxyClient
        -#liveReloadServer: WebSocketServer
        -#webSocketServer: WebSocketServer
        -#devRegistry: DevRegistry
        -#maybeInspectorProxyController: InspectorProxyController
        -#hyperdriveProxyController: HyperdriveProxyController
        -#sharedOpts: PluginSharedOptions
        -#workerOpts: PluginWorkerOptions[]
        -#tmpPath: string
        -#disposeController: AbortController
        -#externalPlugins: Map
        -#browserProcesses: Map
    }

主要なフィールドをまとめると以下の通りです。

フィールド 役割
#runtime workerd子プロセスを管理する Runtime インスタンス
#runtimeMutex 設定更新を直列化し、競合状態を防ぐMutex
#proxyClient Node.jsプロキシバインディングを提供(getPlatformProxy() 用)
#liveReloadServer ブラウザのライブリロード信号を扱うWebSocketサーバー
#webSocketServer ユーザーWorkerのWebSocket接続を扱うWebSocketサーバー
#devRegistry マルチWorkerのサービスディスカバリーレジストリ
#maybeInspectorProxyController Chrome DevTools Protocolプロキシ
#hyperdriveProxyController HyperdriveのTCP接続向けローカルプロキシ
#tmpPath 一時ディレクトリ(dispose() 時に削除)
#disposeController dispose() 時にシグナルを送り、実行中の処理をキャンセルするAbortController

967行目のコンストラクターでは、Zodを使ったオプションの検証(validateOptions())・ループバックサーバーの起動・WebSocketサーバーの初期化・devレジストリの作成・workerdランタイムの起動が行われます。これらはすべてコンストラクター内で同期的に処理され、非同期の初期化処理は #initPromise に格納されます。

28個のプラグインシステム

Cloudflareの各サービスは独立したプラグインとして実装されています。packages/miniflare/src/plugins/index.ts#L48-L77PLUGINS マップに、28個すべてが登録されています。

プラグイン名 サービス
core Workerの基本設定(スクリプト・モジュール・互換性)
cache Cache API
d1 D1データベース
do (durable-objects) Durable Objects
kv Workers KV
queues Queues
r2 R2オブジェクトストレージ
hyperdrive Hyperdriveデータベースコネクター
ratelimit レート制限
assets Workers Static Assets
workflows Workflows
pipelines Pipelines
secret-store Secret Store
email メール送信
analytics-engine Analytics Engine
ai Workers AI
ai-search AI Search
browser-rendering ブラウザレンダリング
dispatch-namespace Workers for Platforms
images 画像変換
stream Stream(メディア)
vectorize Vectorizeベクターデータベース
vpc-services VPC Services
mtls 相互TLS証明書
hello-world 組み込みテスト用Worker
worker-loader サービスバインディング / マルチWorker
media メディア処理
version-metadata Workerバージョンメタデータ

Cloudflareのドキュメントではよくある KV・D1・R2・Durable Objectsを中心に説明していますが、MiniflareがBrowser RenderingやPipelinesといったニッチな機能も含む28個のプラグインを実装している点から、ローカルシミュレーションのカバレッジの広さが伝わります。

ヒント: ローカルで動作しないように見えるCloudflareバインディングがあれば、まず packages/miniflare/src/plugins/ ディレクトリを確認してみましょう。あまり知られていなくても、プラグインが存在している可能性が高いです。

プラグインのインターフェース:options・sharedOptions・getServices()・getBindings()

すべてのプラグインは、packages/miniflare/src/plugins/shared/index.ts#L103-L133 で定義された Plugin インターフェースを実装します。

export interface PluginBase<Options extends z.ZodType, SharedOptions extends z.ZodType | undefined> {
    options: Options;
    getBindings(options: z.infer<Options>, workerIndex: number): Awaitable<Worker_Binding[] | void>;
    getNodeBindings(options: z.infer<Options>): Awaitable<Record<string, unknown>>;
    getServices(options: PluginServicesOptions<Options, SharedOptions>): Awaitable<Service[] | ServicesExtensions | void>;
    getPersistPath?(sharedOptions, tmpPath): string;
    getExtensions?(options): Awaitable<Extension[]>;
}

packages/miniflare/src/plugins/kv/index.ts#L75-L202 のKVプラグインを見ると、このパターンがより具体的に理解できます。

  1. optionskvNamespacessitePathsiteIncludesiteExclude を定義するZodスキーマ(KVOptionsSchema
  2. sharedOptions — Worker間で共有するオプション(kvPersist)のZodスキーマ
  3. getBindings() — バインディング名をKVネームスペースサービスにマッピングする Worker_Binding オブジェクトを生成
  4. getNodeBindings() — Node.jsプロキシアクセス用の ProxyNodeBinding インスタンスを返す
  5. getServices() — Durable Objectベースのネームスペースワーカー・ディスクストレージサービス・Workers Sitesサービスを含む、workerdの Service 定義を返す
flowchart TD
    OPTIONS["KVOptionsSchema (Zod)"] -->|"validated input"| PLUGIN["KV Plugin"]
    PLUGIN -->|"getBindings()"| BINDINGS["Worker_Binding[]<br/>KV namespace bindings"]
    PLUGIN -->|"getServices()"| SERVICES["Service[]<br/>namespace DO worker +<br/>disk storage service"]
    PLUGIN -->|"getNodeBindings()"| NODE["ProxyNodeBinding<br/>for getPlatformProxy()"]
    SERVICES --> CONFIG["workerd config"]
    BINDINGS --> CONFIG

このプラグインシステムの優れた点は、コンポーザビリティにあります。Miniflareクラスは PLUGIN_ENTRIES をイテレートして各プラグインのメソッドを呼び出し、workerd設定全体を組み立てます。プラグイン同士は互いを知らず、Durable Objectのクラス名やキューのコンシューマーといった共有状態は PluginServicesOptions パラメーターを通じて渡されます。

共有型:DurableObjects・Queues・Persistence

プラグイン間の連携には、packages/miniflare/src/plugins/shared/index.ts#L29-L71 で定義された共有型が使われます。

  • DurableObjectClassNames — サービス名とエクスポートされたDurable Objectクラス名のマッピング。マルチWorker構成でどのWorkerがDOクラスを宣言しているかをcoreプラグインが把握するために使われます。
  • QueueProducers / QueueConsumers — キュー名とプロデューサー/コンシューマー設定のマッピング。Worker間のキュールーティングを実現します。
  • PersistenceSchemaboolean | URL | path のZod unionで、デフォルトの永続化ルートは .mf です。true の場合はデータが .mf/<plugin-name>/ に保存され、false の場合は dispose() 時に削除される一時ディレクトリが使われます。
  • WrappedBindingNames — ラップドバインディングとして使用されているWorker名を追跡し、ルーティング可能なサービスとして公開されないようにします。
classDiagram
    class DurableObjectClassNames {
        Map~string, Map~string, DOConfig~~
    }
    class QueueProducers {
        Map~string, QueueProducerConfig~
    }
    class QueueConsumers {
        Map~string, QueueConsumerConfig~
    }
    class PersistenceSchema {
        boolean | URL | path
        DEFAULT_PERSIST_ROOT = ".mf"
    }

PluginServicesOptions インターフェースはいわば「何でも入れる袋」で、これらの横断的な型がすべてのプラグインの getServices() 呼び出しに渡されます。ソースコードのコメントも率直です。// ~~Leaky abstractions~~ "Plugin specific options" :) と書かれています。

workerd子プロセスの管理

packages/miniflare/src/runtime/index.ts#L208-L349Runtime クラスが、workerd子プロセスのライフサイクルを管理します。updateConfig() メソッドは次の手順で処理を進めます。

  1. 停止 — 既存のプロセスがあれば SIGKILL で強制終了します。SIGTERM ではなく SIGKILL を使うのは、Chromeが約10秒間接続を保持することでプロセスの終了がブロックされる場合があるためです。
  2. 起動 — バイナリCapnP設定モード・実験的フラグ・fd 3上のコントロールパイプを指定して新しいworkerdプロセスを起動します。
  3. 書き込み — シリアライズされた設定をstdinに書き込んで閉じます。
  4. 待機 — fd 3から届くコントロールメッセージを解析して起動完了を検知します。

起動完了の検知には、packages/miniflare/src/runtime/index.ts#L21-L31 のZodスキーマが使われます。

const ControlMessageSchema = z.discriminatedUnion("event", [
    z.object({ event: z.literal("listen"), socket: z.string(), port: z.number() }),
    z.object({ event: z.literal("listen-inspector"), port: z.number() }),
]);

workerdがリッスンを開始すると、コントロールパイプにJSONメッセージを書き出します。Miniflareはこれらのメッセージからポートのアサイン結果を取得します。ポート0を指定した自動割り当ての場合に特に重要です。waitForPorts() 関数はすべての必要なソケットが確認されるまでメッセージを収集し続けます。

sequenceDiagram
    participant MF as Miniflare
    participant RT as Runtime
    participant WD as workerd process

    MF->>RT: updateConfig(configBuffer)
    RT->>RT: dispose() existing process
    RT->>WD: spawn("workerd", ["serve", "--binary", ...])
    RT->>WD: stdin.write(configBuffer)
    RT->>WD: stdin.end()
    WD-->>RT: fd3: {"event":"listen","socket":"entry","port":8787}
    WD-->>RT: fd3: {"event":"listen-inspector","port":9229}
    RT-->>MF: SocketPorts map

104行目の getRuntimeCommand() 関数は、まず MINIFLARE_WORKERD_PATH 環境変数を確認してから、インストール済みのworkerdバイナリへのフォールバックを試みます。カスタムビルドのworkerdに対してテストを行う際に便利です。

Cap'n Proto設定のシリアライズとランタイムMutex

workerdはJSONやTOMLを読み取りません。設定はCap'n Protoバイナリ形式で渡す必要があります。serializeConfig() 関数(./runtime/config からインポート)がJavaScriptの設定オブジェクトをworkerdが解析できるバイナリバッファに変換します。

Miniflareクラスの945行目にある #runtimeMutex は、正確な動作のために欠かせない存在です。ホットリロードの場面を考えてみましょう。ユーザーがファイルを保存するとウォッチャーが反応し、設定が再計算されて setOptions() が呼ばれます。しかし setOptions() は非同期処理を伴います。Zodバリデーション・プラグインサービスの生成・設定のシリアライズ・プロセスの再起動、これらすべてが非同期です。最初のリロードが完了する前にユーザーが再度ファイルを保存した場合、2回目の setOptions() 呼び出しが1回目の処理と競合してしまいます。

Mutexによってこれらの操作が直列化され、2回目の呼び出しは1回目の完了を待ってから始まります。これにより状態の破損を防ぎ、最終的に動作している設定が常に最後にリクエストされた更新と一致することが保証されます。

ヒント: 急速な設定変更後にMiniflareが古い設定を持っているように見える問題をデバッグしている場合、Mutexが保持中の可能性があります。Miniflareのデバッグログで "Acquiring runtime mutex" というメッセージを確認してみましょう。

Inspectorプロキシとdev Registry

Miniflareの機能をさらに充実させる高度な仕組みが2つあります。

InspectorProxyController は、WorkerをデバッグするためのChrome DevTools Protocol エンドポイントを作成します。設定可能なポート(通常は9229)で動作し、Chrome DevToolsとworkerdの組み込みV8 inspectorの間でWebSocket接続をプロキシします。このプロキシ層が必要な理由は、リロード中にworkerdが再起動する可能性があるためです。プロキシがDevTools向けの安定した接続を維持しつつ、裏側で新しいworkerd inspectorへの再接続を行います。

DevRegistry はマルチWorkerのサービスディスカバリーを実現します。複数のWorkerをローカルで実行する場合(例:フロントエンドWorkerとAPI Worker)、各Workerは自身のローカルアドレスとバインディングをdev registryへ登録します。これにより他のWorkerはサービスバインディングをローカルアドレスとして解決できるようになります。レジストリは packages/miniflare/src/index.ts#L1046 で初期化され、ファイルベースのプロセス間通信によって動作します。

次のステップ

ここまでで、Miniflareがworkerd向けのコンフィグを組み立て、ライフサイクルを管理する仕組みを見てきました。次はMiniflareが動き出すのステップ、つまりTypeScript製Workerのソースコードをデプロイ可能なJavaScriptモジュールへ変換するバンドル処理に目を向けます。第5回ではWranglerのバンドルパイプライン・カスタムesbuildプラグイン・Cloudflareエッジへのデプロイの流れを解説します。