🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

Cloudflare WorkersのService bindingsの現状整理

2023年7月版ってことで。

About Service bindings · Cloudflare Workers docs

これなに

とあるWorkerから、別のWorkerを呼び出せる仕組み。

前にも書いてるけど、デプロイしたWorkerAから別のWorkerBを呼びたいときがあったとしても、HTTP経由でさえそれができない場合があった。

Cloudflare Workersで、Workerから別のWorkerを呼びたい - console.lealog();

複数のWorkerでマイクロサービス的な構成をしたい場合にもれなく不便やったけど、それができるようになる。
加えて、HTTP(インターネット)経由じゃなく、Cloudflare内の特別な経路を通るため、パフォーマンスも安定するよって触れ込み。

KVや他のバインディングと同じように登録しておくと、さもそこにWorkerがあるかのように`fetch()`できる。

export default <ExportedHandler<{ MY_SVC: Fetcher }>> {
  async fetch(req, env) {
    return await env.MY_SVC.fetch(req);
  },
};

制限と特徴

Service bindingsをアーキテクチャに加えるか判断するために、知っておくべきことたちを、ドキュメントからサマリしておく。

  • CPUリソースは、親(呼び出し元)のWorkerと共通
    • サービス側でのサブリクエストも、親の上限50と共通
    • 同時リクエスト数の6並列も親と共通
  • `waitUntil()`か`await`しないと、親と共に消える
  • サービスのネストは32層まで

などなど、基本的な理解として、呼び出し元の親Workerのリソースを消費するってところがポイント。

つまり、こういうサービスを書いたとして、

// Sub
export default {
  fetch: async () => new Response("Service!"),
}

それをService bindingsで呼び出す場合も、

// Main
export default {
  fetch: async (req, env) => env.MY_SVC.fetch(req),
}

そのサービスのコードをモジュールとして使う場合も、

// Main
import MY_SVC from "...";

export default {
  fetch: async (req) => MY_SVC.fetch(req),
}

できることは一緒ってことかと。(ネットワーキングのルートは違う)

つまり、CPUヘビーな処理を逃がす目的では使えない!
HTTP経由じゃない分のパフォーマンス向上はあるかもしれないが、そういう意味での違いは、Workerごとのバンドルサイズ制限(free: 1MB / Paid: 10MB)を回避できるってことくらい?

`wrangler dev`の対応状況

良し悪しはさておき、これを採用する場合は、ローカルで`wrangler dev`することになるはず。

手順としては、

  • 呼び出されるサービスを`wrangler dev`
  • 呼び出すメインのWorkerを`wrangler dev`

というように、複数のプロセスを立ち上げると自動で連携されて、ローカルで確認できるようになる。

注意点としては、メインの呼び出す側をどう起動したかによって、その挙動が変わるってところ・・・。

呼び出す側の親のWorkerの状態としては、この3つのパターンがあって、

  • `wrangler dev`: dev@ローカル
  • `wrangler dev --remote`: dev@リモート
  • 実際にデプロイされてる状態

それぞれこんな感じの対応だった。

- O: main:dev@local  - sub:dev@local
- X: main:dev@local  - sub:dev@remote
- X: main:dev@local  - sub:deployed
- X: main:dev@remote - sub:dev@remote
- X: main:dev@remote - sub:dev@local
- O: main:dev@remote - sub:deployed
- O: main:deployed   - sub:deployed

つまり、親がローカルだとローカルにあるサービスにしかつなげられないし、親がリモートだと実際にデプロイされてるサービスにしかつなげられない。

  • `dev --remote`でサービスを立てても、それにアクセスする術がない
  • `dev --remote`と`dev --local`を混ぜることもできない

という、かなりわかりにくい上に厳しい感じで、Issueもお察し。

RFC: Multi-worker development · Issue #1182 · cloudflare/workers-sdk · GitHub

`wrangler`の内部実装について

実は、`wrangler dev`すると、こっそり`:6284`ポートにも`express`のWebサーバーが起動してる。
これが、ローカルで`wrangler dev`してるプロセスを中央管理する役目を負っていて、それによってローカルでもService bindingsが動くようになってるのである。

https://github.com/cloudflare/workers-sdk/blob/9ae3d93e6070ab37e4261b2c5d6e8d91a4b1bcd7/packages/wrangler/src/dev-registry.ts

あれ、`workerd`さん・・・?

その他

TypeScriptの型

`env.MY_SVC`をどういう型にすればいいかというと、`Fetcher`っていう型らしい。
(ただし`env.MY_SVC.constructor.name`は、ローカルでは`Object`のまま)

`Fetcher`は`fetch()`のほかに`connect()`っていうメソッドもあって、わかる人にはそういうことかって感じ。

`req.url`どうなる

サービスは`fetch()`で呼び出すわけではあるが、そのリクエストの行き先はそのサービスって決まってるわけで。

ならリクエストされた側として、URLのオリジンとかどうなってんの?って思ったので調べてみたところ、

  • リモートでは、呼び出し元と同じオリジンが入る(親と同じ)
  • ローカルでは、そのサービスの起動オリジンのまま・・・
  • オリジン以降のパスやらは、`env.MY_SVC.fetch()`時に渡した内容になる

サービスを呼び分けたい場合は、リクエストのパスか、ヘッダーか、ボディで。
ブラウザでは許されるが、CFWの`fetch()`(というか`Request`もか)は、フルのURLでしか指定できないので、そこは注意。

// NG
env.MY_SVC.fecth("/foo/bar?x=1&yy");

// OK
env.MY_SVC.fecth("http://fake-host.example.com/foo/bar?x=1&yy");
// OK
const req = new Request(request, { body: "..." })
env.MY_SVC.fecth(req);

なんでインターフェースを`fecth()`にしたんや?って気持ちはある。

おわりに

  • バンドルサイズの制限を回避したい
  • チームの都合などで、モジュールでの共有は難しい
  • HTTP経由になんらかの懸念がある

って場合にだけ(というかまぁ、マイクロサービスしたいときだけ?)、(なんかいろいろ不安定なのを)がんばって使えばいいけど、そうでないなら使わないやつかな・・・。

それでもがんばりたいあなたのために、拙作の`wrangler dev`ブリッジでは、local/remoteを混ぜて使えるようにしておきました。

https://github.com/leader22/cfw-bindings-wrangler-bridge