Cloudflare Workers
in
Production

自己紹介

Motoki Shakagori
(釋迦郡 元気)

  • Org: 株式会社ベースマキナ ソフトウェアエンジニア
  • ❤️: 型、犬(→)
  • Contribution: Neverthrow🙅‍♀️, Hono🔥
  • Linktree: https://linktr.ee/mshaka

注意!

今回のお話はベースマキナの事例ではなく*、前職のSpirのものです!!!

ただ、ベースマキナでもプロダクト以外でWorkersを使っています!

テーマ

  • Cloudflare Workersで普通のtoB SaaSを半年のあいだ開発した経験の共有
  • 普通って?
    • ユーザーはブラウザで使う
    • バックエンド(REST API)がDBとやりとり
  • 結論としては正直全然困らなかったしめちゃくちゃ体験よかった🙌

目次

  • イントロ
    • サービス概要、アーキテクチャ、Workersの採用理由
  • Cloudflare Workersとは
  • フロントエンド
  • Service Binding
  • バックエンド
  • Sentry
  • まとめ

イントロ

サービス概要(1/2)

  • Spir for Agent
  • 人材紹介会社の方が採用面接の日程調整をするときの手間を減らすサービス

サービス概要(2/2)

アーキテクチャ図

なぜWorkersを採用したか?

  • コンテナはデプロイが重くて嫌だった
    • 既存サービスのバックエンドはNode.js on Cloud Run
  • Remixをデプロイしやすかった
  • 開発エコシステムが整備されていて体験がいいしデプロイも速いので(後述)、使ってみたいという気持ちが膨れ上がった
  • 検証段階を過ぎて何か困ることがあったときも脱出は容易だと判断した(後述)

Cloudflare Workersとは

Cloudflare Workersの概要(1/2)

  • Function-as-a-Service
  • V8のIsolateを利用した高速に水平スケールする実行環境
  • Cold start time0秒を宣伝文句にしている
  • グローバルに分散したノードで実行される

Cloudflare Workers の概要(2/2)

  • 公式のサポート言語はJS/Python/Rust/Wasm
  • (余談)JS以外は実質Wasmなので、Wasmコンパイルして頑張れば自力で動かせるはず

WorkersのJSランタイム(1/2)

  • Common Web Platform API(Web standardサブセット)とNode.jsの互換API(一部サポート外)を持つ
  • 最低でも週に1度はリリースされ、Chrome stable最新と同じ機能が使える
  • 後方互換性を完全にサポート

WorkersのJSランタイム(2/2)

  • Node.js向けのライブラリが動く保証はない
  • が、今のところそれで困ったことはない
    • Webフレームワークを除けば、意外にNode.js向けのライブラリは使わない(ブラウザでも動くやつを使う)
    • Expressは動かないかもだけど、Expressで満足できるならHonoでいい

サンプルコード

export default {
  async fetch(request, env, ctx) {
    return new Response("Hello, world!");
  },
};

ローカル開発環境

Wrangler(1/3)

  • Wranglerという大変よくできた公式CLIがdev serverの立ち上げからdeployまでを担う
  • dev serverは本番と同等のランタイムで動く
  • tsも事前のコンパイルなしで実行可能(型検査は別途実行する必要あり)
  • Cloudflare KV, R2などのCloudflareサービスのエミュレータ付き
  • Remixの開発ではVite pluginを通してWorkersのランタイムを使う

Wrangler(2/3)

  • Chromeのdev toolでdebuggerも使える
  • VSCodeでbreakpointを仕込むことも可能

Wrangler(3/3)

テスト

  • テストにはCloudflare公式の@cloudflare/vitest-pool-workersというVitestプラグインがある
  • テストも本番と同じランタイムで実行可能
  • テストの書き方は普通のNode.jsアプリケーションと全く同じ

Frontend

Remix

  • React のメタフレームワーク。SPAも書けるが今回はSSR
  • アダプターを通して様々なランタイムで動くように設計されており、Workersも公式サポート
    • 他にもNode, Deno, Vercelなどなど
  • Remix on Workersは本当に困ることがなくてあまり話すことがない

Remixの責務

  • サーバーサイドでコードを実行できるので、DB呼び出しを含むビジネスロジックも全てRemixの中に書くことも可能
  • 今回の開発では、APIからデータを取得して整形するだけのBFF的な役目に徹する
    • Webアプリ以外のクライアントが生えたときのことを考えるとバックエンドを分離しておいた方が綺麗
    • Workers間の通信が非常に高速で分離するコストが低い

Workers間の通信

Service Binding(1/4)

  • Workers同士の通信は、Service Bindingという特殊な仕組みが使える
  • 実態は単なるコード呼び出しなので、ネットワークのオーバーヘッド無し

Service Binding(2/4)

設定ファイルにちょろちょろっと書くだけで本番でもローカル開発時も使える

// service-aというWorkerからservice-bというWorkerを呼び出す場合
{
  "name": "service-a",
  "services": [
    {
      "binding": "SERVICE_B", // コードから呼びだすときの名前
      "service": "service-b", // デプロイするときにつけた名前
    },
  ],
}

Service Binding(3/4)

インターフェースはfetchなのでHTTP越しのやりとりにしか見えない*

// index.ts
export default {
  async fetch(request, env) {
    return await env.SERVICE_B.fetch(request);
  },
};

* fetch以外のインターフェースを使えるモードもあります

Service Binding(4/4)

  • fetchインターフェースが使われているため、仮に別のクラウドに移ってnetwork越しの通信になったとしてもコードの見た目を変えずに移行可能
  • 呼び出される側のWorkerはパブリックアクセスを無効にすることも可能
  • Wranglerでservice-aより前にservice-bを立ち上げておけばローカルでもエミュレート可能

Backend

Hono

Hono RPC(1/3)

  • クライアント側にRequest/ResponseのTypeScript型を共有する仕組み
  • これによって、RemixのサーバーサイドからのAPI呼び出しが書きやすくなる

Hono RPC(2/3)

// server.ts
import { Hono } from "hono";
const app = new Hono()
  .get("/hello", (c) => c.json({ message: "Hello, World!" }))
  .get("/dog", (c) => c.json({ face: "🐶" }));
export type AppType = typeof app;
export default app;

// client.ts
import { hc } from "hono/client";
import type { AppType } from "./server";
const client = hc<AppType>("http://localhost:8787");
await client.hello.$get().then((res) => res.json()); // { message: string }型
await client.dog.$get().then((res) => res.json()); // { face: string } 型

Hono RPC(3/3)

Service Bindingでも問題なく使える!!!🔥⚡

import { hc } from "hono/client";
import type { AppType } from "./server";

export default {
  async fetch(request, env) {
    const _fetch: typeof fetch = (...args) => env.SERVICE_B.fetch(...args);
    // 本番ではドメインは無視されるが有効なURL文字列を入れないとエラーになる
    const client = hc<AppType>("http://localhost:8787", { fetch: _fetch });
    const res = await client.hello.$get();
    const data = await res.json();
    return new Response(data.message);
  },
};

Sentry

  • @sentry/cloudflareがあるので動く
    • エラーとトレースのみ
    • 現時点ではプロファイルは取れない
  • 分散トレースもバッチリ

まとめ

開発体験のよさ

  • Wranglerによる統合された開発環境、公式Vitestプラグインの存在
  • 高速なデプロイ
  • Workersで使えるRemixとHonoの使いやすさ、Service Bindingとの相性

撤退戦略(1/2)

  • とはいえWorkersはまだまだ枯れてるとは言えないプロダクト
  • 採用にあたって、失敗したらどうするの?という問いに答えを持っておきたかった

撤退戦略(2/2)

  • インフラ載せ替えは難しくないと判断
  • RemixとHonoはそもそもランタイム非依存
    • アダプタを切り替えるだけ
  • Service Bindingはprivate networkに置き換え
    • fetchインターフェースなのでコードの変更は少ない
  • KVなどの他のCloudflareのサービスを使っている場合のみ、置き換え先を探す必要あり

Thank you!!!

よいCloudflare Workersライフを!

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のサービスを使っている場合のみ、移行先を探す必要があります。