🚀

Cloudflare WorkersのService BindingsこそRemixアプリケーションでは積極的に採用したい

2022/05/27に公開

先日CloudflareからService BindingsというものがGAになりました。この期間はD1であったりR2であったり色々な発表がありエンジニア界隈では盛り上がったのではないでしょうか。もちろんD1もR2も期待を膨らませるには十分なサービスなのですが、私はこのService Bindingsのリリースも待ちに待ったサービスの1つです。

【前提条件】
この記事は以下の前提で上で記載しています。

  • アプリケーションはRemixを使用します
  • RemixはCloudflare Workers上で動作させます
  • ここで紹介した一部のコードはこちらに存在します

Cloudflare Workers + Remix

Remixより今はまだまだNext.jsの方がポピュラーであり、多く採用されているフレームワークです。しかしなぜRemixを使用するかというとCloudflare Workersというエッジコンピューティング上だけで動作するフレームワークだからです。もちろんNext.jsも一部はエッジコンピューティングで動作はしますが、全てではありません。わざわざエッジコンピューティングで動作させる要件は必ずしもないかもしれませんが、Cloudflare Workersで動作させる方が(アプリケーションだけを見ると)セルフホスティングする場合はもちろんGCPのCloud Runを凌ぐほどの容易な運用ができるからです。

Service Bindingsとは

実際の投入する話の前にそもそもService Bindingsとは何かという説明をここでします。

Service BindingsとはCloudflare Workers間の通信処理を可能にするものです。これが登場の前はCloudflare Workers間の通信は一度publicなネットワークつまりDNSを介したインターネットを通るしか方法しかありませんでした。しかしこのService BindingsはCloudflare Workers間の通信をprivateなネットワークで通信し、距離と速度的なものを解決するものです。
これにより複数のCloudflare Workersを束ねてアプリケーション化するといういわゆるCloudflare Workersを用いたマイクロサービス化するのに適したサービスです。

後にも出てきますが、Service Bindingsはwranger2でしか対応していないです。設定例としては以下です。wrangler2側の対応が若干遅れているのか、まだ設定項目がGAとして扱っていないのか unsafe という文言が必要になります。

wrangler.toml
[[unsafe.bindings]]
name = "BindingsRemix"
type = "service"
service = "bindings-remix-staging"
environment = "production"

name にTypeScriptやJavaScript内で使用するService Bindingsの編集名を記載します。 service には実際のデプロイされたCloudflare Workersの名前を設定すればOKです。

実際に呼び出すには以下のように記載すれば大丈夫です。今回の例ではModule Workersという形式で記載しています。

export default {
  async fetch(request: Request, environment: Environment, context: Context): Promise<Response> {
    // 通常のpublicネットワークのfetchはこう
    // return await fetch(request)
    return await environment.BindingsRemix.fetch(request)
  }
}

構成による検討事項

話を戻してCloudflare Workers + Remix構成も100%穴のない構成かというとそうでもありません。色々な方面の課題や検討事項があります。その中からいくつかの課題をService Bindingsを使用することで解決できると考えています。

  1. SSRによる処理コスト
  2. 肥大化するバンドルサイズ

SSRによる処理コストを抑える

RemixのレンダリングはSSRとCSRの2つのみです。SSRを使用するのはいくつか理由はあると思いますが、SEOなどの目的ではよく使用されます。目的のためにSSRを使用するのはいいのですが、SSRはレンダリング処理をサーバが行うので、その分サーバの処理リソースおよび処理のための時間を必要とします。サービスを運営する上で初期段階のユーザが少ないうちや機能が少ないうちはいいのですが、長く運用してくるとユーザも機能も増え続けSSRの処理コストは無視できなくなってきます。
ログインしてるユーザごとに表示内容が異なるようなページであればSSRするのではなく、CSR側に寄せるという手法が取れます。しかしWebメディアのようなサービス(このZennもその一種)を運用する場合にはその中にある記事ページなどは短い間隔で更新されるというのは稀であり、ページの更新頻度も1日1回程度あれば基本的には問題ないと思います。そうなるとその場合のSSRは非常にコストがかかります。しかしメディアであった場合はSEOなどを考慮するためにSSRを行うという判断も理解できます。
そこでNext.jsにあるISRに近い仕組みをCloudflare WorkersとService Bindingsを使用して実現します。

まずは実施しようとしていることを上の図に起こしています。前段に1つCloudflare Workersを用意して、後ろにRemixが乗ったCloudflare Workersがあります。前はNginx,後ろにRemixみたいな構成に近いです。こうすることでまず、前のCloudflare Workersに以下のコードを入れます。

src/index.ts
import { matchCache, createCache } from './cache'

export default {
  async fetch(request: Request, environment: Environment, context: Context): Promise<Response> {
    const response = await matchCache(request)

    if (response) {
      return response
    }

    const remixResponse = await environment.BindingsRemix.fetch(request)
    context.waitUntil(createCache(request, remixResponse))
    return remixResponse
  },
}
src/cache.ts
export const matchCache = async (request: Request): Promise<Response | null> => {
  const cache = caches.default
  const cacheResponse = await cache.match(request)

  if (cacheResponse) {
    return cacheResponse
  }

  return null
}

export const createCache = async (request: Request, response?: Response): Promise<Response> => {
  const cache = caches.default
  const cacheTarget = response ?? await fetch(request)

  const cacheResponse = genereateCacheDate(cacheTarget)
  await cache.put(request, cacheResponse)

  return cacheResponse
}

const genereateCacheDate = (response: Response): Response => {
  const expireTime = 60
  const copyResponse = response.clone()
  const cacheDate = new Response(copyResponse.body, copyResponse)
  cacheDate.headers.set("Cache-Control", `public, max-age=${expireTime}`)

  return cacheDate
}

これは何をしているかというとまずは src/index.ts でオリジンとなるRemixのCloudflare Workersにリクエストを投げます。投げますが、その前にキャッシュがあるかどうかを確認して、キャッシュがあればそれを返すという処理になっています。そうすることでRemixを毎回呼び出すわけでなく、レスポンスを返せるためSSRの処理コストをぐっ抑えることができます。それでいてキャッシュがない場合はオリジンにfetchしたレスポンスを返しますが、それをキャッシュとして保存する処理を非同期で呼び出すという処理を書いています。今回の例ではキャッシュを1分間保持して、1分後はキャッシュが無くなっているのでオリジンにアクセスされるというコードになっています。

サンプルのRemixアプリケーションは以下の記述を入れています。

app/root.tsx
const wait = (ms: number) => new Promise(res => setTimeout(res, ms));

export async function loader() {
  await wait(5000);
  return {};
}

root.tsx なので全部のページに入ります。要は単純にリクエストを5秒待つという処理が入っています。普段はそんなことしませんが、仮に遅いサーバサイドの処理がある場合と過程したコードという意味です。このコードが入っていれば基本的には5秒以上は確実に待つのですが、Service Workersを2つ繋いで、前段のCloudflare Workersでキャッシュすることでこの5秒を待つことなくレスポンスが返ってくることが確認できます。

Cloudflare Workersのサイズリミットを回避する

初期のRemixプロジェクトではバンドルサイズはまったく問題になりませんが、開発を進めていくにつれてコードはもちろんのことライブラリも多く追加されていきます。そうすることでバンドルサイズが大きくなっていきますが、Cloudflare Workersのバンドルサイズはデフォルトでは1MBです。これはRemixのアプリケーションを1つのCloudflare Workersにデプロイするので仕方ないといえば仕方ないです。ですが何かしらの対策をする必要があります。そこで1つの提案としては以下のライブラリを使用してRemixを複数のCloudflare Workersに分割するということを検討してもいいと思います。

https://github.com/aiji42/remix-service-bindings

このライブラリはRemixのアプリケーションを2つのCloudflare Workersに分離します。分離したWorkersはどうやって繋がっているかというとそこにService Bindingsを用いて繋げています。

READMEの画像を拝借しましたが、これがすべてを説明しています。RemixにはLoaderやActionというサーバサイドの処理を記述箇所がありますが、このサーバサイド処理の実態のみを別のCloudflare Workersに分離してService Bindingsを介して呼び出すということを行っています。

wrangler.edge.toml
[[unsafe.bindings]]
name = "BINDEE"
type = "service"
service = "bindings-remix-bindee"
environment = "production"

これがService Bindingsをもって接続するための設定例です。大事なのは最初の設定例でも見せた nameservice です。 name はREAME通りに記載していますが、これは変更が可能です。変更するには

remix.config.js
const { withEsbuildOverride } = require("remix-esbuild-override");
const remixServiceBindings = require("remix-service-bindings").default;

withEsbuildOverride((option, { isServer }) => {
  if (isServer) {
    option.plugins = [
      /**
       * remixServiceBindings
       * @param isEdgeSide {boolean} - When this is true, the build is for edge (binder) and when false, the build is for bindee.
       *                               (Deployment (build) must be done in two parts.)
       * @param bindingsName {string} - The bind name set in toml. This name will be converted to a bind object.
       * @param enabled {boolean} - If this is false, this plugin is disabled.
       */
      remixServiceBindings(!process.env.BINDEE, "BINDEE", !!process.env.DEPLOY),
      ...option.plugins,
    ];
  }

  return option;
});

remixServiceBindings の第2引数を変更すると変えることは可能です。これでService Bindingsの後ろにあるサーバ処理本体のCloudflare Workers(bindings-remix-bindee)を繋ぐという設定になっています。

導入してみたのですが、何点か注意事項があります。

  • @cloudflare/wrangler(ver1)ではなくて、 wrangler(ver2)を使用する必要があります。なのでRemix本体がまだWranger2ではないので自前で変更する必要があります。
  • remix-esbuild-override というRemixのesbuildのカスタマイズができるライブラリの前提の上で動作します。 (なのでremix-esbuild-overrideのインストールに書いてある postinstall の追加とコマンド実行が必要です。

最後に

RemixをCloudflare Workersで使用する場合の注意点を書いてみました。このService Bindingsは公式ではWebサーバぽい感じやロードバランサー的な使用方法など様々な例が示されています。
このService Bindingsの登場によりCloudflare Workersというエッジコンピューティング上で動作するアプリケーションの手段が増えたと言っても過言ではないと思っています。それに加えこういう問題や課題が発生する場合にも使用してみてはいかがでしょうか。

Discussion