golangに組み込み可能なWASMランタイムの調査
前書き
ユーザーにウェブサービスを自分でカスタマイズして使ってほしいと考えたとき、サービス提供者が取り得るアプローチは大きく分けて 2 つあります。
- サービス提供者が提供している API を利用して、ユーザー自身が用意した環境で実行する
- サービス提供者が提供している API を利用して、サービス側が提供する環境の元で実行する
一般的には 1 のアプローチが取られることが多いですが、2 のアプローチを取ることで、ユーザーは自分でアプリケーションの実行環境を用意する必要がなくなったり、サービス間の接続を考える必要がなくなるなどのメリットがあります。
ユーザーが記述したコードを実行できる機能を提供しているサービスとしてはたとえばGoogle Apps Scriptが挙げられるでしょう。
2 のアプローチを実現するには、ユーザーのコードを安全に実行できる環境を提供する必要があります。
サービス提供者側の視点だと、ユーザーが特定の範囲の機能・情報しか参照・変更できないよう制限する必要があります。また計算機資源の利用についても、大量のCPU資源・メモリを消費することで、システム全体や他のユーザーのコードの実行に支障をきたさないようにする必要があります。
このような機能を提供する方法としては、サンドボックスと呼ばれる仕組みがあります。サンドボックスとは、ユーザーのコードが実行される環境を隔離する仕組みです。
サンドボックスの実現方法としては、まず OS レベルの仕組みを利用する(コンテナ含む)方法が考えられます。
OS レベルの仕組みを利用する場合、サービス提供者は、ユーザーのコードを実行するための環境(chrootやコンテナ)を用意し、その環境上でユーザーのコードを実行します。実行されるプロセスからは、特定のファイルシステムやネットワーク、特定の機能にしかアクセスできないように制限します。また、CPU やメモリの利用についても、制限をかけることができます。
多くの場合はこれに近い手法でユーザーコードの実行を実現しているものと思います。
それ以外の方法として、ユーザーコードを、プロセスに組み込んだ実行環境で実行する方法があります。イメージとしてはマクロのようなものです。マクロからはプロセスが提供する機能を利用できますが、提供された機能の外側や、プロセスの外部にはアクセスできません。
ただし、マクロの実行によりCPUやメモリ負荷を増加させる可能性があるので、実行時間やプロセス内の優先度、メモリの利用については制限をかける必要があります。
ところで、WASMを実行するWASMランタイムにはサンドボックスという機能があり、サンドボックス外の指定したもの以外のリソースにアクセスすることができません。
加えて CPU やメモリの利用について制限を行うことができれば、プロセス内でのユーザーコードの実行を実現できるのではないかと考えて調査してみました。
WASM自体の知識がほぼゼロのため間違っている部分があるかもしれません。
なにかありましたらご指摘いただけますと幸いです。
調査対象
弊社では主に Golang を使用してサービスを開発しているので、 Golang に組み込み可能な以下のWASMランタイムについて調査してみました。
-
wazero
- star: 4.1k
- fork: 205
- 直近 1 ヶ月の PR 数: 73
-
wasm-micro-runtime
- star: 4.1k
- fork: 521
- 直近 1 ヶ月の PR 数: 47
-
wasmtime
- star: 13.3k
- fork: 1.1k
- 直近 1 ヶ月の PR 数: 不明
-
WasmEdge
- star: 6.8k
- fork: 613
- 直近 1 ヶ月の PR 数: 18
-
wasmer
- star: 2.6k
- fork: 189
- 直近 1 ヶ月の PR 数: 0
wazero
依存性ゼロの Golang製WASM ランタイムで、オフィシャルなサイトは https://wazero.io/ 。
ホスト言語としてはGoのみ。
組み込みのWASMランタイム全般にいえることですが、ホスト言語から WASM モジュールがエクスポートする関数の呼び出しを行うことができます。
この際に引数を渡すことでホストとゲストの間でデータのやり取りができます。結果は関数の返値として受け取ります。
また、ホスト言語からWASMモジュール側に関数を公開でき、WASM側からホスト側の関数を呼び出すこともできます。
実際にホスト・ゲストで相互に関数呼び出しを行う例が examples/import-go として公開されています。
ホスト側での関数の登録方法は以下のようになっています(例からの抜粋)
_, err := r.NewHostModuleBuilder("env").
NewFunctionBuilder().
WithFunc(func(v uint32) {
fmt.Println("log_i32 >>", v)
}).
Export("log_i32").
NewFunctionBuilder().
WithFunc(func() uint32 {
if envYear, err := strconv.ParseUint(os.Getenv("CURRENT_YEAR"), 10, 64); err == nil {
return uint32(envYear) // Allow env-override to prevent annual test maintenance!
}
return uint32(time.Now().Year())
}).
Export("current_year").
Instantiate(ctx)
if err != nil {
log.Panicln(err)
}
他のランタイムとの比較でいうと、cgoを利用する必要がない部分がメリットとして挙げられます。
wasm-micro-runtime
WASMやWASIの標準に従ったソフトウェア基盤の実装を目指す非営利団体であるbytecodeallianceが開発しています。
ランタイムはC言語で実装されており、Golangに限定したものではないので、Goから利用する場合はcgoを利用する必要があります。
組み込み例はlanguage-bindingに記載があります。
以下のようなコードで、WASMモジュールを読み込み、関数を呼び出すことができるようです。スタックとヒープのサイズが指定できるのでメモリの制限は可能そうです。
var module *wamr.Module
var instance *wamr.Instance
var results []interface{}
var err error
/* Runtime initialization */
err = wamr.Runtime().FullInit(false, nil, 1)
/* Read WASM/AOT file into a memory buffer */
wasmBytes := read_wasm_binary_to_buffer(...)
/* Load WASM/AOT module from the memory buffer */
module, err = wamr.NewModule(wasmBytes)
/* Create WASM/AOT instance from the module */
instance, err = wamr.NewInstance(module, 16384, 16384)
/* Call the `fib` function */
results = make([]interface{}, 1, 1)
err = instance.CallFuncV("fib", 1, results, (int32)32)
fmt.Printf("fib(32) return: %d\n", results[0].(int32));
/* Destroy runtime */
wamr.Runtime().Destroy()
wasmtime-go
wasm-micro-runtimeと同じく、bytecodeallianceが開発しているランタイムです。
wasmtimeはrust製のランタイムで、多数の言語に組み込めるライブラリが公開されています。
Golangに組み込むためのラッパーとしてwasmtime-goが公開されているので、wasm-micro-runtimeより利用のハードルは低いと思います。
ランタイムが利用するメモリ量を変更するAPIはなさそうでした。
組み込み例は以下の通りです。
package main
import (
"fmt"
"github.com/bytecodealliance/wasmtime-go/v14"
)
func main() {
// Almost all operations in wasmtime require a contextual `store`
// argument to share, so create that first
store := wasmtime.NewStore(wasmtime.NewEngine())
// Compiling modules requires WebAssembly binary input, but the wasmtime
// package also supports converting the WebAssembly text format to the
// binary format.
wasm, err := wasmtime.Wat2Wasm(`
(module
(import "" "hello" (func $hello))
(func (export "run")
(call $hello))
)
`)
check(err)
// Once we have our binary `wasm` we can compile that into a `*Module`
// which represents compiled JIT code.
module, err := wasmtime.NewModule(store.Engine, wasm)
check(err)
// Our `hello.wat` file imports one item, so we create that function
// here.
item := wasmtime.WrapFunc(store, func() {
fmt.Println("Hello from Go!")
})
// Next up we instantiate a module which is where we link in all our
// imports. We've got one import so we pass that in here.
instance, err := wasmtime.NewInstance(store, module, []wasmtime.AsExtern{item})
check(err)
// After we've instantiated we can lookup our `run` function and call
// it.
run := instance.GetFunc(store, "run")
if run == nil {
panic("not a function")
}
_, err = run.Call(store)
check(err)
}
func check(e error) {
if e != nil {
panic(e)
}
}
WasmEdge
Linux Foundationの中の、コンテナ関連技術を推進する財団Cloud Native Computing Foundation(FluentdとかKubernetes、gRPCのプロジェクトを管理してる)のSandboxプロジェクトとのこと。
C++製のランタイムで、現在のところGo, Rust, Cに組み込むためのライブラリが公開されています(Golang用SDKのドキュメント)
WASM側で公開した関数を呼び出す以外に、下記のように単純な実行も可能なようです。
package main
import (
"os"
"github.com/second-state/WasmEdge-go/wasmedge"
)
func main() {
wasmedge.SetLogErrorLevel()
var conf = wasmedge.NewConfigure(wasmedge.REFERENCE_TYPES)
conf.AddConfig(wasmedge.WASI)
var vm = wasmedge.NewVMWithConfig(conf)
var wasi = vm.GetImportModule(wasmedge.WASI)
wasi.InitWasi(
os.Args[1:], // The args
os.Environ(), // The envs
[]string{".:."}, // The mapping directories
)
// Instantiate wasm. _start refers to the main() function
vm.RunWasmFile(os.Args[1], "_start")
vm.Release()
conf.Release()
}
wasmer
今回調査した中では唯一存在を知っていた&今回調べた中では唯一企業によって開発されているランタイムです。現在はWASMの実行環境を提供するビジネスを行っているようです。
ランタイムはRust製。多数の言語のSDKが公開されています。
Golang用のSDKはwasmer-goです。
組み込み例は以下の通りです(ドキュメントより抜粋)
メモリの制限を行う機能はなさそうでした。
package main
import (
"fmt"
"io/ioutil"
wasmer "github.com/wasmerio/wasmer-go/wasmer"
)
func main() {
wasmBytes, _ := ioutil.ReadFile("simple.wasm")
engine := wasmer.NewEngine()
store := wasmer.NewStore(engine)
// Compiles the module
module, _ := wasmer.NewModule(store, wasmBytes)
// Instantiates the module
importObject := wasmer.NewImportObject()
instance, _ := wasmer.NewInstance(module, importObject)
// Gets the `sum` exported function from the WebAssembly instance.
sum, _ := instance.Exports.GetFunction("sum")
// Calls that exported function with Go standard values. The WebAssembly
// types are inferred and values are casted automatically.
result, _ := sum(5, 37)
fmt.Println(result) // 42!
}
まとめとあとがき
ユーザーコードを埋め込みのランタイムで実行するのは基本的にハードルが高いだろうと想定していましたが、やはり難しいかなという印象です。
リソースの制限については、メモリの利用を制限できるものはありましたが、CPUの利用を制限できるものは現在のところなさそうでした。
CPUの利用を制限するには、単純にはWASMの実行を一定時間で停止させる方法が考えられますが、ランタイム本体でそのような機能を提供しているものは見つかりませんでした。
goroutineやcontextを利用して、一定時間でタイムアウトさせることで実現できる可能性はあります。
ただし、例えばどの程度でタイムアウトさせるかはユースケースやリソースの状況によって変わってくるので、仮にサービスに組み込むとしたらハードルはそれなりに高いと思います。
また、フル機能のプログラミング言語を使ってユーザーが利用中のサービスを拡張したいようなユースケースがどのくらい出てくるかなというのもあります。
以前の仕事でデータの検索・分類条件をユーザーが自由に記述できるようにしたいという要望があり、条件式のマッチングライブラリ(matcher)を作ってみことがあるので、そのようなユースケースはあるのかなと思いつつ、用途と文法が限定的であれば同様にパーサージェネレーターで実現できるし、そちらの方がハードルは低いかなと思います。
ユーザーコードを実行する手段としては他に組み込み言語を利用する方法(luaとか)もありますが、WASMだと言語の選択肢が広がるので、その点はユーザーにとってはメリットがあると思います。
利用中のサービスをユーザーが自由に拡張できるというのは夢があって面白いと思いますが、ハンマーとして巨大すぎる感があり、ユーザー自身が考えて利用していただくとか、こちらからユーザーに提案ができるまでには結構時間かかりそうな気はします。
主に興味ドリブンで調査したので読みづらい部分も多々あったかと思います。
最後まで読んでいただきありがとうございました。
Discussion