Cloudflare WorkersからTinyGoでビルドしたWabAssemblyバイナリを呼び出す

こんにちは、マネーフォワード ケッサイのテックリードをやっておりますgarsueです。

最近、CDNのエッジで動くサーバレス環境が充実してきましたね。

代表的なものとしてはCloudflare Workersやfastlyの[email protected]などがあります。CloudflareではKey-Valueストアもあり、S3やGCSのようなオブジェクトストレージまで揃いつつあるようです。

そんな流れに乗って、Cloudflare Workersを使ってGoで実装した計算ロジックをエッジで動かせるか検証してみました。今回はその結果をレポートします。

TL;DR

  • アップロードサイズ制限があるのでTinyGo使って小さいwasmバイナリを作る
  • wrangler.tomlで type = "CompiledWasm"のモジュールとしてwasmバイナリをアップロードする
  • wasmのエントリポイント内でJavaScriptの名前空間にJavaScriptの関数としてセットする

検証した背景

今回このような検証をした理由は、Goで実装されたサーバーサイドの計算ロジックをフロントエンドでも使いたいというモチベーションからでした。

例えば、ユーザーがフォーム画面で入力した値に対して何らかの計算をした結果を保存する際、保存前に計算結果のプレビューを表示したいといったケースです。

今までそういった場面では、Goで実装されたものと同じ計算ロジックをフロントエンド側でも実装してメンテナンスしつづけるということをしていました。

しかしそれではどうしても抜け漏れが生まれがちでメンテナンスコストも高くついていました。また、計算ロジックの二重管理になるため、ブラウザ上でロードされている計算ロジックとサーバーアプリケーションで実装されている計算ロジックの互換性も壊さないようにケアしないといけません。

思い切って都度サーバーにリクエストを飛ばし、サーバーサイドで計算した結果を返すようなやり方もあります。が、その場合は通信をはさむため当然ながらレスポンスの悪いものになり、ユーザー体験を損ねてしまいます。

折衷案として、レイテンシを抑えることで通信をはさむのは許容しつつGoで実装された計算ロジックを実行できないか、というのを考えました。そこでCloudflare WorkersをつかってGoを動かせないか検証することになりました。1

問題点

Cloudflare Workersでは利用可能なプログラム言語としてGoをサポートしていませんが、WebAssemblyを利用できます。

GoもWebAssemblyとしてのコンパイルをサポートしているため、すんなり行けそうな気がしますが、落とし穴があります。バイナリサイズの問題です。

Cloudflare Workersにはスクリプトサイズは1MBまでという制限があります。2 Goで簡単なHello Worldを書いてwasmとしてビルドしたところ、1.2MBになりました。これではHello Worldもできませんね。

TinyGo

サイズ制限をクリアするために、TinyGoを使うことにしました。

TinyGoでビルドした際のバイナリサイズ

TinyGoは組み込み向けのGoコンパイラで、標準のGoの仕様を一部諦める代わりに組み込みでも動かせるようなコンパクトなバイナリを作ることができます。

さらに組み込み向けだけでなく、WebAssemblyのバイナリも作ることができます

TinyGoを使って前述のHello Worldプログラムをビルドしたところ、13KBで済みました。これならば現実的です。

JavaScriptから呼び出し可能な関数をWebAssemblyで作る

サイズ問題はクリアできたため、実際にJavaScriptから呼び出し可能なロジックをGoで記述し、WebAssemblyとしてビルドしてみます。

TinyGoの以下のサンプルが参考になります。

https://github.com/tinygo-org/tinygo/blob/cf0b7edc78e42038a0bb522b3f1a5b76928e730e/src/examples/wasm/slices/wasm.go

GoにはWebAssemblyとしてJavaScriptと連携するためのAPIをまとめたパッケージ"syscall/js"があり、これをTinyGoでも利用できます。

上記のサンプルでは、js.Value型を使ってJavaScript関数として呼び出せるシグネチャを持った関数をGoで記述しています。 その関数を js.Global().Set("splitter", js.FuncOf(splitter)) としてJavaScriptのグローバル名前空間にセットしているのがポイントです。

最後にどこからも書き込まれることのない空のチャネル wait を読み込むことでmainから抜けないようにし、Go側で定義した関数が開放されないようにしています。

Cloudflare WorkersでWebAssemblyバイナリを読み込む

アップロードの設定

Cloudflare WorkersでWebAssemblyを利用する場合、現在はモジュールとしてロードする方法が主流なのでそちらを利用します。

WebAssemblyバイナリをアップロードするように設定ファイルであるwrangler.tomlのbuild.upload.rules`に以下のような記述を追加します。3

[[build.upload.rules]]
type = "CompiledWasm"
globs = ["hello.wasm"]

[[build.upload.rules]]
type = "ESModule"
globs = ["/path/to/wasm_exec.js"]

これでWebAssemblyバイナリをモジュールとしてimportできるようになります。ブートストラップ用のJavaScriptコード wasm_exec.js も必要なので一緒にアップロードします。

スクリプトの実装

worker本体のスクリプトは以下のようになります。

import 'wasm_exec.js'
import module from 'hello.wasm'

global.performance = {
  now() {
    return Date.now()
  },
};

const go = new Go()
let instance;
WebAssembly.instantiate(module, go.importObject).then((obj) => {
  instance = obj
  go.run(instance)
})

export default {
  async fetch (request, environment, context) {
    return new Response(`call wasm function: ${splitter('foo,bar')}`, {
      headers: { 'content-type': 'text/plain' },
    })
  }
}

global.performancewasm_exec.js中で必要になりますが、Cloudflare Workers内では定義されていないので雑に実装しています。本来はまともなPolyfillを利用すべきところです。

wasmモジュールの読み込み自体はブラウザと変わらないので、このあたりもTinyGoのサンプルが参考になります。

これをデプロイしてアクセスすれば実際にGoで実装した関数を呼び出したことを確認できます。

課題

ひとまず大まかな実現方法だけは確認できましたが、以下の点がまだ課題です。

  • コードは一元化できたがデプロイ先は2重管理になるため、やはりある程度の互換性はケアする必要がある
    • 加えて、継続的デリバリーをどうするのが良いか
  • リアルなプロダクションコードでTinyGoの制限を受ける箇所がどれだけあるか
  • 呼び出し頻度などのコスト面(GoやWebAssemblyというよりはユースケースとして本当に適切か)

最後に

ニッチな内容だからか、Web上に同様の話があまりまとまっていなかったので書いてみました。GoでCloudflare Workersを使いたい奇特な方のお役に立てたら嬉しいです。

マネーフォワードケッサイではこのような技術検証や技術的なチャレンジを日々の開発の中でもやっていけるような取り組みもしており、このブログもその成果の一環です。

そういった開発文化に興味がある方やGoでおもしろいことをやりたい方はぜひお気軽にお話しましょう


  1. Fastlyの[email protected]を採用しなかった理由は単にCDNとしてCloudflareをすでに利用していたためです。 [return]
  2. 上限の引き上げをリクエストすることも可能なようです。 https://developers.cloudflare.com/workers/platform/limits/#script-size [return]
  3. ここではWrangler 1の設定を例にしています。 https://developers.cloudflare.com/workers/wrangler/cli-wrangler/configuration/#build-2 [return]