🗂

(実践Cloudflare Workers)Service bindingsの使い方

2023/07/11に公開

はじめに

Cloudflare WorkersにはBindings(バインディング)とよばれる仕組みがあります。バインディングを設定すると、オブジェクトストレージのR2やデータベースのD1など、Cloudflareが提供する別のサービスと連携できるようになります。

Service bindingsはバインディングの一種であり、Worker同士で通信するための仕組みです。無料プランと有料プランどちらでも利用できます。

通信はインターネットを経由せず、Cloudflareの閉じられたネットワーク内で完結します。そのため通常のfetch()関数の呼び出しと比較してレイテンシーは無視できるほど小さくなります。また、インターネットを経由しないため非公開のWorker同士で通信可能です。

Service bindingsがリリースされたのは2022年5月10日です。安定しているとは思いますが、将来的に仕様が変更されるかもしれません。アプリケーションを実装する際は最新の公式ドキュメントを参照してください。

実行環境

記事の投稿にあたり、以下の環境にて動作確認を行いました。

  • wranglerコマンドのバージョン: v3.4.0
  • wrangler.tomlファイルのcompatibility_date: 2023-07-24

Service bindingsを試す

それでは、2つのWorkerを作成し、Service bindingsを設定してみましょう。説明の都合でTypeScriptを選びましたが、JavaScriptでもService bindingsは利用可能です。

Worker B(name = "banana")の作成

以下のコマンドを実行してください。

$ cd /tmp
$ npm create cloudflare@latest

/tmpは適当なディレクトリで構いません。コマンドの実行後に表示される質問は以下のように答えてください。

  • In which directory do you want to create your application?
    • 「banana」と入力してください。
  • What type of application do you want to create?
    • 「Hello World" Worker」を選んでください。
  • Do you want to use TypeScript?
    • 「y」を入力してください。
  • Do you want to deploy your application?
    • 「n」を入力してください。

コマンドの実行が完了したら、/tmp/bananaディレクトリに移動してください。その後、wrangler.tomlsrc/worker.tsを以下の内容で置き換えてください。

wrangler.toml

name = "banana"
main = "src/worker.ts"
compatibility_date = "2023-07-24"
usage_model = "bundled"
workers_dev = false

src/worker.ts

interface BananaResponse {
	timestamp: number;
	random: string;
}

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
		const response: BananaResponse = {
			timestamp: new Date().getTime(),
			random: crypto.randomUUID(),
		};

		return Response.json(response);
	},
};

Worker A(name = "apple")の作成

以下のコマンドを実行してください。

$ cd /tmp
$ npm create cloudflare@latest

/tmpは適当なディレクトリで構いません。コマンドの実行後に表示される質問は以下のように答えてください。

  • In which directory do you want to create your application?
    • 「apple」と入力してください。
  • What type of application do you want to create?
    • 「Hello World" Worker」を選んでください。
  • Do you want to use TypeScript?
    • 「y」を入力してください。
  • Do you want to deploy your application?
    • 「n」を入力してください。

コマンドの実行が完了したら、/tmp/appleディレクトリに移動してください。その後、wrangler.tomlsrc/worker.tsを以下の内容で置き換えてください。

wrangler.toml

name = "apple"
main = "src/worker.ts"
compatibility_date = "2023-07-24"
usage_model = "bundled"

services = [
  { binding = "banana", service = "banana" }
]

src/worker.ts

export interface Env {
	banana: Fetcher;
}

interface BananaResponse {
	timestamp: number;
	random: string;
}

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
		const response1 = await env.banana.fetch(request.clone());
		const response2 = await env.banana.fetch(request.clone());
		const response3 = await env.banana.fetch(request.clone());

		const body1: BananaResponse = await response1.json();
		const body2: BananaResponse = await response2.json();
		const body3: BananaResponse = await response3.json();

		return Response.json({
			appleTimestamp: new Date().getTime(),
			appleRandom: crypto.randomUUID(),
			bananaTimestamps: [body1.timestamp, body2.timestamp, body3.timestamp],
			bananaRandoms: [body1.random, body2.random, body3.random],
		});
	},
};

デプロイ

Workerをデプロイしましょう。以下のコマンドを実行してください。

$ cd /tmp/banana
$ npx wrangler deploy

$ cd /tmp/apple
$ npx wrangler deploy

動作確認

デプロイが完了したら動作確認をしましょう。curlコマンドを実行してみてください。

$ curl https://apple.アカウント名.workers.dev

以下のようなレスポンスが返されます。

{
  "appleTimestamp": 1690769131344,
  "appleRandom": "203e5036-c243-4dfd-9925-5248f3985d9e",
  "bananaTimestamps": [
    1690769131344,
    1690769131344,
    1690769131344
  ],
  "bananaRandoms": [
    "3f393baa-5f61-4099-b2ea-9fd1a4af2db4",
    "53a58b26-4bfe-499f-a217-47718b3afe75",
    "f92536e4-31af-4133-b29d-23c62ed00ad3"
  ]
}

余談になりますが、タイムスタンプの数値に注目してください。すべて同じ値です。これは閉じられたネットワークの中で高速に通信しているから同じ値になったのではなく、Spectre対策のセキュリティの都合で意図的に同じ値が返されています。従って、例えばnew Date().getTime()の差分でレイテンシーの測定はできません。

解説

ここからはwrangler.tomlの設定とsrc/worker.tsの実装について解説します。

wrangler.tomlの解説

まず、appleとbanana共通のパラメータについてです。冒頭の3行はWorkerをデプロイする際に毎回指定するパラメータです。特別なことはしていないため、説明はスキップします。

name = "Workerの名前"
main = "src/worker.ts"
compatibility_date = "2023-07-24"

usage_modelについてはbundledを指定しています。CPUの利用時間が50 msに制限されている環境でも問題なく動作することを示すために設定しただけです。Service bindingsの振る舞いには影響しません。

usage_model = "bundled"

banana/wrangler.toml

workers_devfalseを指定することでWorkerを非公開に設定しています。

workers_dev = false

apple/wrangler.toml

Service bindingsは以下の行で設定されています。

services = [
  { binding = "banana", service = "banana" }
]

serviceにはWorkerの名前を指定します。具体的には、接続先Workerを構成しているwrangler.tomlnameフィールドの値を指定します。今回であればbananaが該当します。

bindingに指定した名前はsrc/worker.tsのfetchハンドラのenv引数にプロパティとして紐付けされます。例えばbindingの値がWorker1であれば、JavaScript / TypeScriptコードからはenv.Worker1として参照できます。

bindingに指定する値には命名規則がありません。Workerの名前と同じ名前を設定しても構いませんし、別の名前でも構いません。また、大文字と小文字の指定もありません。worker1Worker1WORKER1など自由に設定できます。

よくある間違いとしてはservicebindingの値を逆に指定することがあります。例えばbindingとしてbananaserviceとしてWorker1を指定すると、env.Worker1.fetch()はundefinedに対する関数呼び出しになるため失敗します。

そのような間違いを避けるためにbindingserviceには同じ名前を指定する、というルールを決めるのも一つの手段です。ただし、そうするとWorkerの名前はJavaScriptのプロパティ名として有効な名前を指定する制約が生じます。

Service bindingsは複数指定できます。ただし、一度に呼び出せるWorkerには制限があります。詳しくは後述します。

src/worker.tsの解説

banana/src/worker.tsについてはService bindingsに関連する処理はありません。以降はapple/src/worker.tsについて説明します。

まず、冒頭のexport interface EnvはService bindingsが利用できるように設定している箇所です。プロパティ名はwrangler.tomlで指定したbindingの名前、型はFetcherを指定します。

export interface Env {
	banana: Fetcher;
}

次はfetch()の呼び出しをしている箇所についてです。通常のfetch()ではなくenv.プロパティ名.fetch()を利用している点に注目してください。

const response1 = await env.banana.fetch(request.clone());
const response2 = await env.banana.fetch(request.clone());
const response3 = await env.banana.fetch(request.clone());

env.バインディング名.fetch()とJavaScript標準APIのfetch()のシグネチャは同じです。従って、Fetcherの引数にはRequest型の値を渡すか、URL文字列とオプションを渡します。

もちろん、同じWorkerに対して複数回リクエストを送信しても構いません。ただし、Service bindingsにはいくつか制約があります。詳細については後述します。

もうひとつ注目していただきたいのがrequest.clone()です。同じリクエストを使い回すと意図しないタイミングでオブジェクトの値が書き変わる恐れがあるため、clone()でディープコピーを作成しています。単発のリクエストを送信する場合など、値が書き変わる心配がない状況ではrequest.clone()ではなくrequestを直接渡す形でも問題ありません。

よくある質問

Service bindingsを利用する上で遭遇する疑問に答えます。

(Q. 1)リクエストは新しく作成するとどうなる?

正常に処理されます。リクエストはその場で新しく作成しても構いません。以下に実装例を示します。

const response = await env.Worker1.fetch(request.url, {
	cf: request.cf,
	method: 'POST',
	headers: new Headers({
		...request.headers,
		'Content-Type': 'application/json',
		'Foo': 'bar',
	}),
	body: JSON.stringify({
		value: 123,
	}),
});

その場で新しくリクエストを作成する際はURLとしてrequest.urlを利用してください。なお、request.urlのクエリパラメータについては除去したり追加しても構いません。

Fetcherに渡すURLとしてhttps://example.comのような別オリジンを指定するとリクエストは失敗します。FetcherはCloudflare内の閉じたネットワークにリクエストを送るための専用メソッドです。外部リソースにアクセスしたい場合は通常のfetch()を利用してください。

オプションについてはcfプロパティとheadersプロパティを引き継ぐように実装することをお勧めします。cfプロパティはCloudflareが独自に追加したプロパティです。オプショナル扱いになっていますが、挙動が変わる可能性に備えるため省略しないほうが無難かと思います。

headersプロパティについても同様です。ある日突然Cloudflareの独自ヘッダーが追加されないとも限らないので、元のヘッダーをスプレッド演算子で展開した上で上書きするような形で実装するのがベターかと思います。

なお、オリジナルのリクエストのcfプロパティとheadersプロパティについては、新しく作成するリクエストに含めなくても有効なリクエストとして送信されます。

(Q. 2)公開されているWorker同士で通常のfetch()を実行するとどうなる?

エラーが発生します。具体的にはError Code 1042でリクエストが拒否されます。

例えば、https://aaa.account1.workers.devhttps://bbb.account1.workers.dev同士で通常のfetch()関数を利用して通信することはできません。

異なるサブドメイン間でもError Code 1042エラーが発生します。例えばhttps://aaa.account1.workers.devhttps://aaa.account2.workers.dev同士で通常のfetch()関数を利用して通信することはできません。

これは*.workers.devドメインに適用されるセキュリティの制限です。回避するには独自のドメインを購入して、Workerに設定してください。

また、同じ*.アカウント名.workers.devサブドメインに属するWorker同士であれば、あえてインターネットを経由する必要はないため、Service bindingsを利用して通信することでエラーは回避できます。

制限

便利なService bindingsですが、いくつか制限が設けられています。以下、Cloudflare公式ドキュメントから引用します。

  1. Each request to a Worker via Service bindings count toward your subrequest limit.

  2. Nested calls to child Workers increase the depth of your Worker Pipeline. Maximum Pipeline depth is 32, including the first Worker. Subsequent calls will throw an exception.

  3. Simultaneous open connection limits are Pipeline-wide, meaning subrequests from multiple different Workers incur a global concurrent subrequest limit. However, a fetch call on a Service binding does not count as an open connection.

  4. サービスバインディングを介したワーカーへの各リクエストは、サブリクエストの制限にカウントされます。

  5. 子Workerへのネストした呼び出しはWorkerパイプラインの深さを増加させます。最大のパイプライン深さは32であり、最初のワーカーも含まれます。それ以降の呼び出しは例外をスローします。

  6. 同時にオープンされる接続の制限はパイプライン全体に適用されます。つまり、複数の異なるワーカーからのサブリクエストは、グローバルな同時サブリクエスト制限が発生します。ただし、サービスバインディング上のfetch呼び出しはオープンな接続としてカウントされません。

(引用元)Limits - Cloudflare Workers docs

それぞれ詳しく説明します。

サブリクエストの制限

Cloudflare公式ドキュメントの文脈で「サブリクエスト」とはインターネット上のリソースに対するfetch()関数の呼び出しのことです。Service bindingsを利用したWorkerに対するリクエストはインターネットを経由しませんがサブリクエストとしてカウントされます。

サブリクエストの回数には以下の制限が設けられています。回数は最初にリクエストを受け取ったWorkerを起点にカウントします。

  • 無料プラン: 1リクエストあたり50回
  • 有料プラン(Bundled): 1リクエストあたり50回
  • 有料プラン(Unbound): 1リクエストあたり1000回

なお、リクエストがCloudflareのネットワークに閉じられている場合、つまりService bindingsを利用したサブリクエストの回数制限はプランによらず1リクエストあたり1000回になるそうです。

ネストされた呼び出しの制限

ネストされた呼び出しというのは、Worker AがWorker Bを叩き、worker BがWorker Cを叩き、Worker CがWorker Dを叩き、...という状況のことです。

仮にそれぞれのWorkerが100回サブリクエストを送信するとしてネストの深さが3段であれば、100^3 = 1000000ですからリクエストの数はエクスポネンシャルに増加します。これを防ぐため、最初にリクエストを受け取ったWorkerを起点として、サブリクエストの回数は50あるいは1000に制限されているわけです。

記事の執筆時点ではネストの深さが32段に制限されています。将来的に制限が緩和されるかもしれませんが、ネストが深すぎるとアプリケーションの設計として見通しが良いとは言えません。

そもそもネストが深すぎる状況は意図しないWorkerの呼び出しが行われている可能性があります。32段の制限はWorkerの暴走を止めるための安全装置であると解釈するべきです。

同時接続数の制限

公式ドキュメントによると、記事の執筆時点では同時に開けるコネクションの数は6つに制限されているそうです。これにはサブリクエストだけでなく、例えばオブジェクトストレージのR2を読み書きする際のコネクションも含まれます。

加えて、この制限はService bindingsで呼び出したWorker全体に共有の制限として適用されます。

例えば、Worker AがR2の読み取りとWorker Bの呼び出しを行い、Worker BがR2の読み取りと外部リソースのfetch()呼び出しをすると、同時に開かれているコネクションは3本としてカウントされます。Service bindingsを利用したWorkerの呼び出しはカウントから除外されるため、4本ではなく3本になります。

なお、同時に開けるコネクションの上限に達すると、コネクションはキューに積まれて待ち状態になります。もしコネクションが開きっぱなしで読み書きが行われないと、デッドロックと見なされてコネクションはleast-recently-used方式でキャンセルされます。

料金

Service bindingsは無料プランと有料プランどちらでも利用できます。ただし、有料プランの場合、BundledとUnboundでコストに差が生じます。以下、公式ドキュメントから引用します。

Service bindings cost the same as any normal Worker. Each invocation is charged as if it is a request from the Internet with one important difference. You will be charged a single billable duration across all Workers triggered by a single incoming request.

Service bindingsの費用は通常のWorkerと同じです。各呼び出しはインターネットからのリクエストであるかのように課金されますが、1つ重要な違いがあります。単一のリクエストによってトリガーされるすべてのWorkerに対して、単一の請求期間として課金されます。

Bundledはリクエストの回数をもとに課金されるプランです。例えばWorker AがWorker Bを呼び出し、Worker BがWorker Cを呼び出す実装であれば、合計3リクエストとして課金されます。

Bundledプランは1リクエストあたりのCPU利用時間が50 ms以下に制限されています。別の見方をすれば、CPU利用時間が1 msでも50 msでも請求される費用は同じです。

UnboundはWorkerの利用時間をもとに課金されるプランです。Worker間のリクエスト数に関係なく、利用時間を元に課金されます。

例えばWorker Aが1 ms、Worker Bが2 msのCPU利用時間を消費するとして、AがBを呼び出すと利用時間は3 ms消費したものとカウントされます。Aが3 ms、Bが2 ms、合計5 msとして重複課金されることはありません。

従って、多くの場合はUnboundプランでWorkerを運用するほうがコストを抑えられます。コストの見積りについてはアプリケーションの特性を考慮して各自で計算してください。

Cloudflare Pages FunctionsでのService bindingsの利用について

Pages Functionsの実態はWorkersです。従って、Pages FunctionsにもService bindingsを設定できます。ただし、Workersとは異なる点があります。

  1. バインディングの設定はwrangler.tomlではなくCloudflareのダッシュボード画面から行います。
  2. Pages Functionsから別のPages Functionsを呼び出すことはできません。WorkersからPages Functionsを呼び出すこともできません。Pages FunctionsからWorkersを呼び出すパターンのみ可能です。

上記の相違点を除き、Pages FunctionsとWorkersのService bindingsは同じです。例えば非公開のWorkerをインタネットに公開されているPages Functionsから呼び出すことが可能です。

既知の問題

Service bindingsには以下の問題があります。Cloudflareのインフラは日々改善されているため近い将来に解消されるかもしれませんが、現時点で遭遇する問題をまとめます。

Pages Functionsローカル開発環境でのエミュレーションに未対応

wrangler pages devコマンドを実行するとローカル開発環境でFunctionsを試すことができます。ただし、Service bindingsをローカル環境でエミュレートする機能が提供されていません。

公式どキュエントにはwrangler pages devコマンドに--serviceフラグを渡せばOKと書かれていた時期があったそうですが、今は削除されてしまったようです。削除に言いたった経緯は不明ですが、そのうち再実装されるかもしれません。気長に待ちましょう。

ダッシュボードの表示

Service bindingsを設定するとCloudflareのダッシュボードには「Connected Worker: 1」のように接続情報が表示されます。

ただし、呼び出す側のWorkerにしか接続情報が表示されません。呼び出される側のWorkerについては、、どのWorkerから呼び出されているのか表示されません。Pages FunctionsにService bindingsを設定した場合も同様です。

大した問題ではないかもしれませんが、個人的には呼び出し側と呼び出される側の両方に接続情報が表示されていると嬉しいです。皆さんは「Connected Worker: 0」の表示を見ても焦らないようご注意ください。

wrangler deploy実行時のメッセージ

現状ではwrangler.tomlにService bindingsを設定すると、「"services" fields are experimental and may change or break at any time.」と表示されます。すでにベータ版の期間は終了して誰でも利用できるステータスのはずですが、なぜか警告が表示されます。

おわりに

いくつか制限はありますが、Service bindingsを駆使すればCloudflare Workersをさらに活用できることが理解いただけたかと思います。

参考資料

  1. Bindings - Cloudflare Workers docs
  2. About Service bindings - Cloudflare Workers docs
  3. Limits - Cloudflare Workers docs
  4. Service bindings - Runtime APIs - Cloudflare Workers docs
  5. FetchEvent - Runtime APIs - Cloudflare Workers docs
  6. Request - Runtime APIs - Cloudflare Workers docs
  7. Response - Runtime APIs - Cloudflare Workers docs
  8. Config - Wrangler - Cloudflare Workers docs
  9. Service bindings - Cloudflare Pages docs

Discussion