Cloudflare Workers in Productionというタイトルで発表させていただきます。 本発表は、自分の体調不良によりJSConf本番で発表できなかった内容をリライトしたものになります。 この度供養する機会を頂いた運営の皆様に感謝申し上げます。
まず簡単に自己紹介させてください。 釋迦郡元気と申します。今は株式会社ベースマキナでソフトウェアエンジニアをしております。 この6年ぐらいはTypeScriptを書くことが多く、もともと関数型言語から入ったので型が好きです。あと飼い犬を溺愛しています。 最近はOSS貢献に目覚めてNeverthrowといういわゆるResult型ライブラリのメンテナをやったり、Honoの型関連の修正をしたりしてます。 このスライドはWebにアップしますので、linktreeの方はそちらからご利用ください。
本題に入る前の注意事項なのですが、今回のお話は現在所属しているベースマキナの事例ではなく、 先月まで所属していたSpirという会社のものになります。 ベースマキナでもプロダクトではなく開発支援ツールとしては使っているんですが、そこは勘違いしないようお願いいたします。Spirです
それでは本題に入りたいと思います。 今回は、Cloudflare Workersでも普通のSaaSをサクッと作れるぞい、ということをお伝えしたいと思っています。 普通っていうのは、ユーザーが使うためのブラウザアプリケーションがあって、DBへのアクセスがあるバックエンドがあるような、ありふれた構成のSaaSです。 結論からいうと苦労話は全然なくて、全然困らなかったしむしろめちゃくちゃ体験よかったです。 まあこれは発表なんで大きなことを言っておこうと目論見もあって言ってますが、本心でもあります
本発表の流れはこんな感じです。 まずイントロで開発していたサービスの概要と、アーキテクチャ、Workersの採用理由をお話します つぎにCloudflare Workersという製品の概要と、そのランタイムについて概説します。 そのあとフロントエンドからバックエンドまでは具体的な実装の話で、そのあと少しだけSentryの話をしてObservabilityについて触れます。 そのあとまとめに入ります。
前職への義理も兼ねてサービスの概要を簡単にお話しすると、転職エージェントの方が転職希望者と採用企業の面接の日程調整するときの手間を減らすためのサービスです。名前はSpir for Agentです。 Spirはもともと日程調整サービスを作っている会社で、その横展開的な新プロダクトになります。 エージェント経由で転職活動をしたことがある方ならわかると思いますが、応募した会社と面接するとなったとき、 エージェントさんが代わりに日程を調整するのが一般的です。 エージェントが転職希望者から日程候補を聞き出して企業に打診するかたちが多いです。 この作業は転職エージェントにとってはあまり本質的ではないにもかかわらず必ず発生します。 これを楽にしよう、というのがサービスの趣旨です。
しゃべってばかりだと面白くないのでどういった画面があるかをお見せします。 とはいえいきなり画面上どんな作業が行われているのか理解するのは難しいと思いますが、なんか普通のSaaSっぽいなーということを感じて普通の仕組みで動いてそうなことに思いを馳せてもらえればと思います。
大まかなシステム構成はこんな感じになります。 フロントエンドにはRemix、バックエンドにはHonoを採用し、それぞれがWorkersで動いています。 認証にはSupabase Authを使っています。 HonoバックエンドからはCloudflareのHyperdriveという製品を経由してSupabaseのPostgreSQLにアクセスしています。 ブラウザ、Remixのサーバーサイド、Honoバックエンド全てでSentryによるエラー収集とトレーシングを行っています。
さて、そもそもなぜWorkersかという話なんですが、まずコンテナが嫌でした。 既存サービスはCloud RunでNode.jsを動かしていたのですが、ご存知の通りイメージのビルドはそこそこ時間かかります。 後でも見ますが、その点Workersはデプロイが爆速です。 また、RemixをCloud Runで動かすのもためらわれました。 静的ファイルの配信にCDNかませたりしないといけないし、最初は問題にならないとしてもSSRというCPUバウンドな処理のスケーラビリティも心配でした。 その点WorkersはWorkersでも動作が保証されているし、スケーラビリティも申し分ありません。 あと、後ほど詳しく説明しますが、検証していくなかで開発エコシステムはかなり整備されており、めちゃくちゃ開発体験がいいことがわかりました。 それでいてデプロイも爆速なので、使ってみたいという気持ちに支配されました。 それだけだと単なる好奇心で会社にリスクを取らせることになってしまいますが、 後々何か困ったことがあって別のインフラに移行したくなっても比較的簡単だという確信が得られたので、最終的に採用に踏み切りました。 この点についても最後に説明します。
まずCloudflare Workersとは何なのかというところからお話しします。 一言で言ってしまえば、AWS LambdaやGoogle Cloud FunctionsのようなFunction-as-a-Serviceです。 ただ、コンテナではなく、より軽量なV8のIsolateを利用しています。 一つのプロセス内で複数のIsolateを高速に低コストで実行できるため、コンテナよりもスケーリングに優れており、cold start time0秒をうたっています。 また、Cloudflareの世界中のデータセンターにデプロイされ、リクエストユーザーから地理的に一番近いデータセンターで実行されます。 これが、リージョンが固定される普通のFaaSとの大きな違いです。
公式のサポート言語にはJS/Python/Rust/Wasmがあります。 これは余談ですが、PythonはWASMターゲットにコンパイルされたPythonインタプリターによってV8上で実行されますし、 RustもWasmにコンパイルする必要があるので、どちらも実質Wasmです。 なので、Wasmコンパイルさえできれば自力で動かせるはずです。 実際に、同僚がGoで書いたアプリケーションをCloudflare Workersで動かすためのパッケージを公開しています
次に、WorkersのJSランタイムについても軽くみておきます。 WorkersのJSランタイムは、他のランタイムとの移植性を高めるためCommon Web Platform APIというweb standardのサブセットを提供しています。 また、最近はNode.jsの標準ライブラリ互換のAPIも充実してきており、fsなどの低レベルなもの以外はかなり揃っています。 Chrome stableの最新に追従するように週に1回はリリースが行われます。 後方互換性の完全なサポートもうたっています。
気になる人も多そうな部分としては、Node.js互換APIがあると言っても本当にちゃんと動くの?という点があると思います。 これは経験ベースの話にはなってしまうのですが、ライブラリで困ったことは一度もないです。 Node.js互換APIのおかげばっちり動いているよ!だけだとそこで話が終わっちゃうんですが、それ以外の理由としては、expressみたいなwebフレームワークのレイヤーを除けば、そもそもNode.js専用のライブラリって基本的なWeb開発ではあまり使わないんだと思います。ブラウザでも動くような子が多いです。 Expressは動かないかもだけど、Expressで満足できるならHonoでも満足できる、というか個人的には完全に上位互換です。
ミニマムなコードはこんな感じで、fetch関数を持つオブジェクトをエクスポートしたファイルがエントリーポイントになります。 Cloudflareのplatform側がこのfetch関数に引数を渡してコードを実行します。
Wranglerという公式CLIがあって、この子がほぼ全部を担ってくれます。 自動リロードのある普通のdevサーバーを建てられるのですが、これはNode.jsではなく本番と同じランタイムで動きます。 最近はNode.jsにも導入されましたが、tsも事前のコンパイルなしに直接実行可能です。 また、CloudflareはKey Value storeのサービスやらストレージサービスやらも持っているのですが、それらもdevサーバーを立てたらエミューレーターもくっついてくるのでかなり楽ちんです。 Remixの開発に関してはVite pluginを仲介することになりますが、同様の仕組みでWorkersのランタイムで実行できます。
wranglerは本当によくできていて、デバッガも提供しています。 Node.jsでもよくやるChrome dev toolでデバッガーを開いて、メモリダンプやトレース情報など取れます。 また、VSCodeのデバッガとも連携できるので、breakpointを仕込んで変数の中身を見たりもできます。
言葉だけだと伝わりにくいので、実際にどんな感じか動画を用意しました。 まずdev serverですね。tsファイルをそのまま実行しています。 次にデバッガを使ってみます。ターミナルでdボタンを押すとシュッと開きます。 breakpointを仕込んで再読み込みをすると、ちゃんと止まってくれますね。 最後にデプロイです。はい、爆速ですね。もう終わりました。
最後はテストです。 テストツールも揃っています。Cloudflare公式のVitestプラグインがあり、これによってテストもWorkersのランタイムで実行できます。 だからといってテストの書き方が変わるわけではなく、普通のNode.jsアプリケーションと全く同じかたちで書けます。 設定ファイルでVitestプラグインをかますだけです。 こんな感じで、開発エコシステムが一定の水準を満たしていることは確認できたと思いますので、次はより具体的にフロントエンド、バックエンドそれぞれの開発についてご紹介します。
ここの細かい技術選定理由は割愛しますが、フロントエンドにはRemixを採用しました。 ご存知の通りRemixはReactのメタフレームワークで、SSRとSPAに対応していますが、今回はSSRを採用しました。 Remixはもともと様々なランタイムで動くように設計されていて、Workersも公式サポートされています。もちろんNode.jsやDenoでも動きます。 正直Remix on Workersは本当に困ることがなくてあまり話すことがないです
RemixはSSRできるフレームワークなので、サーバー側でコードを実行できます。なので、DB呼び出しを含むビジネスロジックも全てRemixの中に書くことも可能です。 ただ今回はバックエンドAPIを別途建てて、RemixのサーバーサイドはBFFとしてAPIからデータを取得して整形する役割だけを与えています。 これは、非同期ジョブのようなWebアプリ以外のクライアントが生えたときのことを考えるとバックエンドを分離しておいた方が綺麗という理由と、Workers間の通信が非常に高速で分離するコストが低いという理由があります。この通信方法が次のトピックになります。
WorkersをデプロイするとインターネットからアクセスできるURLが払い出されるので、ネットワークごしにWorker同士で通信することもできますが、Service Bindingという仕組みが提供されています。 これは実態としてはあるWorkerのコードから別のWorkerのコードを呼び出すだけなので、ネットワークのオーバーヘッドはありません。
設定はめちゃくちゃ簡単で、開発やデプロイ用のWranglerの設定ファイルにちょろっと書くだけです。 比較対象としてVPCを設定することを考えてもらうといいと思いますが、本当にこれだけです。 VPCのためのリソース作ったりIPレンジを考えたりする必要はありません。 この設定では、service-bというWorkerを、コード上では大文字のSERVICE_Bという名前で呼び出すよ、と宣言しています。
これが実際のコードです。第二引数のenvというオブジェクトに、先ほど設定した大文字のSERVICE_Bという名前でservice-bを呼び出すためのコードが入っています。 個人的にはService Bindingのもっとも優れた点は、Web標準のfetch関数と同じインターフェースを持っていることだと考えています。 つまり、SERVICE_B.fetchはグローバルのfetch関数と同じように使えます。
正直これはかなり天才的な設計だと思っていて、fetchと同じインターフェースであることによって移植性がかなり高くなっています。 つまり、仮に別のクラウドに移ってnetwork越しの通信になったとしてもコードの見た目を変えずに移行可能です。 呼び出される側のWorkerはパブリックアクセスを無効にすることも可能なので、プライベート通信の完全な代替になりえます。 また、Wranglerでservice-aより前にservice-bを立ち上げておけばローカルでもエミュレート可能です。 以上がService Bindingの説明になります。次はフロントからService Bindingで接続されるバックエンドの話に移ります。
バックエンドにはHonoを採用しました。 HonoはWorkersで動かすために作られたフレームワークですが、今はほとんどの主要なランタイムで使えるようになっていて、その点はRemixと近い立ち位置にあります。 例の如く、Hono on Workersでの苦労話は全くないので、好きな点だけお伝えしていきます。
HonoにはRPCという機能があって、これによってクライアントにRequest/ResponseのTypeScript型を共有することができます。 これは本当に便利で、OpenAPIをシコシコ書いてコード生成していたのがバカらしくなりました。 今回の開発ではこれをゴリゴリに活用していて、RemixからのAPI呼び出しには全て型がついています。
少し長いですがコード例をみてみます。 サーバーにはhello, dogという2つのエンドポイントがあって、それぞれmessageとfaceというキーを持ったJSONを返すようになっています。 クライアント側ではhonoのhcという関数にサーバー側のHonoインスタンスの型を渡すことで、型付きのクライアントが取得できます。 client.hello, client.dogというように、エンドポイントのパスに応じたプロパティアクセスができますし、そのresponseのjsonボディにも型がついています。 コードが長くなってしまうので今回は割愛しましたが、request bodyに型をつけることも可能で、不正な型のオブジェクトを送れないようにできます。
そしてなんとこれ、Service Bindingと一緒に使えるんです。 honoのhc関数にはカスタムfetch関数を渡すことができるので、ここにservice bindingのfetch関数を渡すだけで、あとの使い方は何も変えずにservice bindingを使った通信になります。 HonoのAPIもよく練られていると思いますが、やはりService Bindingがfetchなのがここでも威力を発揮しています。 ここが個人的に最も激アツなポイントで、いわゆる魂が震えるというやつかもしれません
まとめにちょっと蛇足的ではありますが、最後にObservabilityについて軽く触れておきます。 Sentry公式のcloudflare用SDKがあって、Workersでもエラー収集とトレースはやってくれます。 ただ、現状プロファイルは取れません。 このプロダクトではブラウザと2つのWorkersすべてにSentryを入れていて、分散トレースもばっちり動いています
まとめに入ります。 冒頭にも述べたように、Workersでの開発は非常に気持ちがいいです。 Wranglerはよくできているし、テストもちゃんと本番と同じランタイムで動かせます。 コンテナに比べるとデプロイは爆速、 RemixやHonoは非常に使いやすいですし、Service BindingとHonoの相性は抜群です
とはいえ、今日お話ししたことはしばらく開発した上で自信を持って言えることで、 まだまだ枯れていないプロダクトなので採用するには勇気が必要でした。 自分の中での基準としては、失敗したらどうリカバリするのか、採用前から考えておこうと思っていました。
それに対する答えは、Workersやめて別のインフラに載せ替えるのはそんなに難しくない、というものです。 RemixとHonoはそもそもランタイム非依存なので、アダプタを切り替えるだけで動くし、 Service Bindingはfetchを使っているので、ほとんどコードを変えずにprivate networkの通信に置き換えられます。 ただし、KVなどの他のCloudflareのサービスを使っている場合のみ、移行先を探す必要があります。