Share
Embed
Go で WebAssembly を利用した 実用的なプラグインシステムの構築方法
Go Conference 2025 登壇資料
Masaaki Goshima
September 27, 2025
Transcript
41 slides
-
自己紹介 • goccy ( ごっしー ) • Go リポジトリの総獲得 Star
数:10K+ ◦ go-json ◦ go-yaml ◦ bigquery-emulator • #67401 • 最近開発中のもの ◦ mercari/grpc-federation ◦ scenarigo -
表記や前提について • プラグインを動かすアプリケーション側を Host、 プラグイン側を Guest と表記 • Host と
Guest の実装はどちらも Go を前提とする ◦ 多言語対応は、まず Go で書いたプラグインが動くようになって から • WebAssembly は長いので、 WASM と表記 • スライド中のリンクは link のように表記 -
• WASM を使ったプラグインシステムを構築し、 1年半以上 本番環境で運用 ◦ プラグインシステムを構築するにあたり多くの課題に遭遇し、 試行錯誤しながら解決してきた ◦ インターネットや
AI からは得られない、活きた知見を多く得た • WASM の良さを知ってもらうと同時に、 WASM を使った プラグインシステムを作ることの難しさを理解してもらい、 発表で示した解決方法を役立てて独自の仕組みを作って欲しい 本発表の目的 -
Agenda 1. プラグインシステムにおける WASM のメリット 2. Go と WASM の基礎知識
3. WASM の壁 4. 壁超え 5. まとめ -
WASM • 当初はブラウザでの利用が目的だったが、近年ではブラウザ以外 での用途に注目が集まっている • Sandbox 機構により、安全に実行できる • いろいろな言語から作ることができる ◦
e.g.) Go / Rust / C / C++ / Swift / Zig etc. • どの言語で作ったかに関係なく、 WASM ランタイムがあれば どこでも動く ◦ e.g.) Rust で作った WASM を Go 製のランタイムで動かす -
プラグインシステムにおける WASM のメリット • Guest で実行する処理を Host で制限することができる ◦ Sandbox
機構により、悪意のあるコマンドの実行 (e.g. rm -rf / ) などを防ぐことができる • Guest がクラッシュしたときに、 Host がまきこまれない ◦ WASM を動かすランタイムが吸収してくれるため、 WASM ランタイムを動かしているアプリケーションには影響がない • 一度ビルドしたら、どこでも動く ◦ Local / CI / CD それぞれで動かすための準備が簡単 -
WASM 以外の選択肢 ( Shared Library ) • go build -buildmode=plugin
• plugin.Open で利用 • Host と Guest でメモリを共有する • Guest がクラッシュすると Host もクラッシュする • Guest で動くコードに制限がない ( rm -rf / もそのまま実行 ) • Host / Guest で Go と Go Modules の version を 全て揃えないとエラー 課題 -
WASM 以外の選択肢 ( RPC ) • hashicorp/go-plugin • Host と
Guest は別プロセス • HTTP / gRPC / STDIO などを利用して Guest と通信する • Guest で動くコードに制限がない ( rm -rf / もそのまま実行 ) • Guest のコードを動作するアーキテクチャにあわせて ビルド・配布する必要がある 課題 -
• WASI ( WebAssembly System Interface ) ◦ Host のリソースへのアクセス方法を
Guest に提供するための 共通 API ( POSIX System Call のようなもの ) WASM と WASI WASM Module ( Guest ) WASM Module ( Guest ) Go Application ( Host ) Host OS OS Resources File System System Clock Network Socket WASI によって OS Resources への アクセスを可能にする どのリソースにアクセス させるかは Host Application 側で 細かく制御できる WASM Runtime WASI WASM WASM Instance Compile hello.wasm -
Go の WASI 対応状況 ( 1 / 2 ) •
WASI の仕様は現在 0.2 ( P2 ) までリリース済み • Go は 0.1 ( P1 ) まで実装済み ◦ Preview2 の実装時期は未定 • P1 では Network Socket や Thread などが未対応 WASI Timeline Go の 対応状況は 0.1 ( P1 ) 現在 -
Go の WASI 対応状況 ( 2 / 2 ) Go
version 1.21 1.22 1.23 1.24 1.25 WASI P1 へのコンパイルに対応 GOOS=wasip1 GOARCH=wasm wasmimport をサポート Host Function ( Guest => Host ) が利用可能 wasmexport をサポート Guest Function ( Host => Guest ) が利用可能 -
TinyGo • 小型デバイスや組込み環境向けの軽量 Go コンパイラ • Go1.21 より前は TinyGo でしか
WASI 対応の WASM を作るこ とはできなかった WASM にしたときのサイズが Go の場合に比べてかなり小さい Pros Cons reflect を使っている場合など、ビルドできない Go のコードが多数ある TinyGo logo © TinyGo Contributors, licensed under CC BY 4.0 -
Go の WASM ランタイムの選択肢 wazero 一択 • Pure Go で実装されている唯一の
WASM ランタイム ◦ Go の Portable 性を活かしてプラグインシステムを作るには Pure Go が必須 • 採用実績も多数あり ◦ Trivy / gRPC Federation etc. • Interpreter と Compiler の 2つのランタイムをもつ ◦ Interpreter: WASM Virtual Machine で逐次 WASM 命令を実行する ◦ Compiler: WASM Binary から Machine Code を生成して実行 ( AOT形式 ) -
WASM を使ったプラグインシステムの壁を紹介 • HTTP Server には数百RPS以上の負荷がくる想定 • WASM プラグインで行うことに実装上の制限は設けない HTTP
Server で WASM プラグインを運用する例を考える 並行処理 / メモリ管理 / Network Socket / TLS / コマンド実行 さまざまな壁が存在 -
Go x WASMの並行処理クイズ Q. 出力順序は? 1. A -> B ->
C 2. B -> A -> C 3. B -> C -> A //go:wasmexport foo func foo() { go func() { time.Sleep(10 * time.Second) fmt.Println("A") }() fmt.Println("B") } //go:wasmexport bar func bar() { fmt.Println("C") } // foo関数を呼び出す foo.Call(ctx) // 100秒待つ time.Sleep(100 * time.Second) // bar関数を呼び出す bar.Call(ctx) Host Guest foo や bar は wazero を使って取得した Guest 関数を呼び出すための変数 ( Call によって Guest に処理が移る ) -
Q. 出力順序は? 1. A -> B -> C 2. B
-> A -> C 3. B -> C -> A //go:wasmexport foo func foo() { go func() { time.Sleep(10 * time.Second) fmt.Println("A") }() fmt.Println("B") } //go:wasmexport bar func bar() { fmt.Println("C") } Guest Go x WASMの並行処理クイズ // foo関数を呼び出す foo.Call(ctx) // 100秒待つ time.Sleep(100 * time.Second) // bar関数を呼び出す bar.Call(ctx) Guest から処理が返ったタイミングで ランタイムが停止するため、 Goroutine も停止。 bar を呼び出したタイミングで foo の Goroutine が再 開するため、 A が最後になる Host -
並行処理の壁 • Guest 関数を呼び出した場合、関数を抜けると非同期処理も停止 ◦ Guest 関数の中で HTTP Listen するようなコードは書けない
• WASM インスタンスは シングルスレッド / 非同期割り込みなし ◦ Thread Safe でないため、Host から Goroutine を使って Guest関数を 並行呼び出しすることはできない WASM Module ( Guest ) WASM Instance Go Application ( Host ) WASM Runtime Single Thread Goroutine A Goroutine B -
• 理想: WASMインスタンスがシングルスレッドで動くため、 リクエストごとにインスタンスを作って並行処理したい • 現実: 最小のGoコードをインスタンス化した際に使用する メモリ量でも 8MBある ◦
実運用上は数十MB使用することを考慮する必要あり ◦ 何も考えずにインスタンスを多数作ると、あっという間に数十GBのメモリ を使用してしまう package main func main() {} メモリ管理の壁 Compiler File Size Memory Usage Go 1.6MiB 8MiB TinyGo 83KiB 128KiB -
Network Socket の壁 • Go の WASI P1では Network Socket
は未対応 ◦ sock_recv / sock_send の実装が存在しない • wasmimport で Host 関数を経由してリクエストすることはでき るが... ◦ Guest 側の HTTP / gRPC を扱うすべての箇所をホスト関数を 経由するように書き換える必要がある ◦ 3rd party ライブラリの処理まで考えると現実的ではない -
TLS の壁 • Network Socket 問題を解決しても、 HTTPS を扱うには もうひとつ壁がある •
Go の WASI P1 ビルドでは、サーバー証明書の正当性を検証す る際に利用する、システム証明書ストアを用意していない ため、明示的に証明書を指定しない限り必ず失敗してしまう ◦ x509: certificate signed by unknown authority -
コマンド実行の壁 • Go の WASI P1 ビルドでは exec.Command をサポートしてい ない
• kubernetes/client-go では InClusterConfig を使えない場合 ( 例えば local での実行時 )、 gke-gcloud-auth-plugin コマンドを使って認証情報を得る ◦ Kubernetes API を Guest で使うことができない -
Network Socket の壁を超える ( 1 / 2 ) • dispatchrun/wasi-go
と dispatchrun/net を組み合わせることで、 Network Socket 対応の実装を wazero で利用することができる ◦ dispatchrun/wasi-go ▪ ゼロから WASI P1 用の sock_send と sock_recv を実装したもの ◦ dispatchrun/net ▪ WASI P1 用の net.DialContext と net.Listen 関数を用意 ◦ WasmEdge 向けと書かれているが、wazero で問題なく使える • 実際のプロジェクトでは、それぞれを fork した goccy/wasi-go と goccy/wasi-go-net を利用して解決 ◦ build error の修正や機能追加を行っている -
• Guest の net.DialContext や net.Listen を使っているすべての箇 所を置き換えるのは現実的ではない ◦ net.DefaultResolver
変数を init() で置き換えるアプローチもあるが、 import の順序に左右されてしまったり、DefaultResolver を 使っていないコードに対応できない • プラグインビルド時に、標準ライブラリの実装を WASI P1用の動く実装に置き換えるアプローチで解決する ◦ net.DialContext => github.com/goccy/wasi-go/ext/wasip1/net.DialContext ◦ net.Listen => github.com/goccy/wasi-go/ext/wasip1/net.Listen Network Socket の壁を超える ( 2 / 2 ) -
標準ライブラリの実装を差し替える方法 go build -overlay と linkname directive を組み合わせる • go
build -overlay ◦ ビルド時にファイルを任意のものに差し替えることができる • linkname directive ◦ 他の package にある symbol を直接参照して使える ▪ private でも大丈夫 ◦ symbol 解決を link 時に行う ( compile 時は無視 ) ◦ (注意) 標準ライブラリの symbol を third party で使うのは基本的に できなくなった: #67401 -
go build -overlay • 置き換えるファイルの対応関係を記述した JSON ファイルを用 意して go build
-overlay の引数に指定する • 標準ライブラリのファイルの場所 : $(go env GOROOT)/src • 置き換え先に存在しないファイルを指定すると、 ファイルを追加する挙動になる { "Replace": { "/usr/local/go/src/net/dial.go" : "/tmp/dial.go", "/usr/local/go/src/net/new.go" : "/tmp/new.go" } } overlay.json ファイルの置換 ファイルの追加 -
Overlay による標準ライブラリの動的置換 // 元の DialContext の名前を衝突しない名前に書き換える func (d *Dialer) __DialContext__(ctx
context.Context, network, address string) (Conn, error) { // import 文を書き換えたりするのが面倒なので、元の処理をそのまま残す } func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) { // wasip1 用の処理を書く } 1.go/parser で置き換えたい 関数のあるパッケージ配下の ファイルを全てパース 2. 置き換え対象を見つけた ら、関数名を衝突しない 名前に書き換える 3. もう一つのファイルに 置き換え先の実装を書く $GOROOT/src/net/dial.go ( replace ) $GOROOT/src/net/dial_wasip1.go ( add ) -
linkname directive • //go:linkname symbolName targetSymbol ◦ targetSymbol は net.DialContext
など package 名を含めた FQDN を記述 • linknameによってsymbol解決のタイミングをリンク時に 遅らせることで、パッケージの循環参照問題を解決できる ( import する必要がない ) package net import ( _ "unsafe" ) func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) { return wasip1_dial_context(ctx, network, address) } //go:linkname wasip1_dial_context github.com/goccy/wasi-go/ext/wasip1/net.DialContext func wasip1_dial_context(context.Context, string, string) (Conn, error) $GOROOT/src/net/dial_wasip1.go -
標準ライブラリの置き換え処理の隠蔽 • プラグインビルド用の専用コマンドを用意することで、 置き換え処理を意識せずに利用できる ◦ e.g.) myapp plugin build plugin.go
◦ コマンドの中で overlay.json を動的に生成して置き換える • 紹介した Overlay ファイルの作成処理は goccy/wasi-go に 実装済み -
TLS / exec.Command の壁を超える • 標準ライブラリの置換で対応する ◦ TLS: crypto/x509.(*Certificate).Verify を置換
◦ exec.Command: os/exec.(*Cmd).Start/Stop を置換 • wasmimport を使い、置換した関数から Host に処理を委譲する • Host に処理が戻ってくるため、実行したくないコマンドを弾くといっ たセキュアな実行方法も実現可能 -
プラグインの非同期処理をサポートする ( Guest ) //wasmimport myapp write func write(ptr, size
uint32) // Host から関数を import する func main() { // STDIN を使って Host からのリクエストを待ち受ける reader := bufio.NewReader(os.Stdin) for { // 改行区切りで Host からのリクエストを受け取る req, _ := reader.ReadString('\n') // リクエストを処理する ( goroutine を使っても大丈夫 ) // レスポンスは文字列に変換する res := handle(req) // レスポンスを Host 関数経由で書き込む write( uint32(uintptr(unsafe.Pointer(&res[0]))), uint32(len(res)), ) } } 1. Goroutine が正しく処理できなく なるため、wasmexport は使わない 2. main loop でリクエストを処理し、 ランタイムが停止しないようにする 3. STDIN を使って Host からの リクエストを待ち受ける 4. レスポンスは Host 関数を使って Host に伝える - STDOUT を自由に使える 実際は、この処理をライブラリで実装し、 プラグイン実装者からは隠す -
プラグインの非同期処理をサポートする ( Host ) // Host / Guest 間で情報伝達するために os.Pipe()
を利用する reqR, reqW, _ := os.Pipe() // リクエスト用の Pipe resR, resW, _ := os.Pipe() // レスポンス用の Pipe // wasmimport で使う Host 関数の定義 hostBuilder.WithGoModuleFunction( api.GoModuleFunc(func(_ context.Context, mod api.Module, stack []uint64) { // Guest 側のメモリからレスポンスを取得する b, _ := mod.Memory().Read(uint32(stack[0]), uint32(stack[1])) resW.Write(b) // resW に書き込むと、resR で読み込める }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{} ) go func() { wasmRuntime.InstantiateModule( wazero.NewModuleConfig(). // Guest の Stdin にリクエスト読み込み用の Pipe を渡す WithStdin(reqR).WithStdout(os.Stdout).WithStderr(os.Stderr) ) }() reqW.Write("hello\n") // リクエストを書き込む。Guest の Stdin 経由で読み込む resp, _ := bufio.NewReader(resR).ReadString('\n') // 結果を resR 経由で読み込む 1. Host => Guest / Guest => Host の Pipe を作る 2. Host Function を定義し、Guest からの戻り値を Pipe に書き込む 3. WASMインスタンス起動時に STDIN に Pipe を渡す 4. Pipe 経由でリクエストを送る 5. Pipe 経由でレスポンスを受け取る -
メモリ使用量を考慮した運用 • インスタンスをあらかじめ任意の数 ( 固定値 ) 起動しておき、 ロードバランスする運用が有効 • Host
と Guestのメモリ管理は別なので、 Guest 側で GC が 適切に動くように環境変数を設定したり、 runtime.GC() を明示的に呼び出すなどの工夫も有効 ◦ 何も考慮しないで運用した際、 OOM になるケースがあった ◦ WASM は 32bit なので、1インスタンスあたり最大 4GB の メモリを使う -
その他 ( 環境変数の Tips ) • GOMAXPROCS に 2 以上を指定すると
panic する ◦ Env を Guest に渡す場合は、必ず GOMAXPROCS をフィルタする 必要がある • Google Cloud の API を利用する際は、 mTLS まわりの処理を無 効化するために以下の環境変数を設定しておく必要がある ◦ GOOGLE_API_USE_CLIENT_CERTIFICATE=false ◦ GOOGLE_API_USE_MTLS_ENDPOINT=never -
• WASM を使ってプラグインシステムを作ろう • プラグインビルド用のコマンドを用意して、 そのままだと動かない処理を置き換えよう • 処理を置き換える際は Host に処理を委譲できないか検討しよう
• インスタンス化する場合はメモリの使用量に気をつけよう • wasmexport ではなく、 main loop を使って インスタンスを作ろう • Go 1.21 以上であれば、プラグインステムは作れる! -
For “Ask The Speaker” • 時間の都合で話せないトピックがたくさんあったので、 発表の内容以外にもいろいろ聞いてください ◦ 将来 WASI
P2 がリリースされたときに、P1とP2の判断方法 ◦ プラグインシステムのバージョン管理 ◦ WASM の CI/CD との連携 ◦ 実例の紹介 ◦ 多言語対応のアイディア ◦ etc.