Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Go で WebAssembly を利用した 実用的なプラグインシステムの構築方法

Avatar for Masaaki Goshima Masaaki Goshima
September 27, 2025
620

Go で WebAssembly を利用した 実用的なプラグインシステムの構築方法

Go Conference 2025 登壇資料

Avatar for Masaaki Goshima

Masaaki Goshima

September 27, 2025
Tweet

Transcript

  1. 自己紹介 • goccy ( ごっしー ) • Go リポジトリの総獲得 Star

    数:10K+ ◦ go-json ◦ go-yaml ◦ bigquery-emulator • #67401 • 最近開発中のもの ◦ mercari/grpc-federation ◦ scenarigo
  2. 表記や前提について • プラグインを動かすアプリケーション側を Host、 プラグイン側を Guest と表記 • Host と

    Guest の実装はどちらも Go を前提とする ◦ 多言語対応は、まず Go で書いたプラグインが動くようになって から • WebAssembly は長いので、 WASM と表記 • スライド中のリンクは link のように表記
  3. • WASM を使ったプラグインシステムを構築し、 1年半以上 本番環境で運用 ◦ プラグインシステムを構築するにあたり多くの課題に遭遇し、 試行錯誤しながら解決してきた ◦ インターネットや

    AI からは得られない、活きた知見を多く得た • WASM の良さを知ってもらうと同時に、 WASM を使った プラグインシステムを作ることの難しさを理解してもらい、 発表で示した解決方法を役立てて独自の仕組みを作って欲しい 本発表の目的
  4. WASM • 当初はブラウザでの利用が目的だったが、近年ではブラウザ以外 での用途に注目が集まっている • Sandbox 機構により、安全に実行できる • いろいろな言語から作ることができる ◦

    e.g.) Go / Rust / C / C++ / Swift / Zig etc. • どの言語で作ったかに関係なく、 WASM ランタイムがあれば どこでも動く ◦ e.g.) Rust で作った WASM を Go 製のランタイムで動かす
  5. プラグインシステムにおける WASM のメリット • Guest で実行する処理を Host で制限することができる ◦ Sandbox

    機構により、悪意のあるコマンドの実行 (e.g. rm -rf / ) などを防ぐことができる • Guest がクラッシュしたときに、 Host がまきこまれない ◦ WASM を動かすランタイムが吸収してくれるため、 WASM ランタイムを動かしているアプリケーションには影響がない • 一度ビルドしたら、どこでも動く ◦ Local / CI / CD それぞれで動かすための準備が簡単
  6. WASM 以外の選択肢 ( Shared Library ) • go build -buildmode=plugin

    • plugin.Open で利用 • Host と Guest でメモリを共有する • Guest がクラッシュすると Host もクラッシュする • Guest で動くコードに制限がない ( rm -rf / もそのまま実行 ) • Host / Guest で Go と Go Modules の version を 全て揃えないとエラー 課題
  7. WASM 以外の選択肢 ( RPC ) • hashicorp/go-plugin • Host と

    Guest は別プロセス • HTTP / gRPC / STDIO などを利用して Guest と通信する • Guest で動くコードに制限がない ( rm -rf / もそのまま実行 ) • Guest のコードを動作するアーキテクチャにあわせて ビルド・配布する必要がある 課題
  8. • 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
  9. Go の WASI 対応状況 ( 1 / 2 ) •

    WASI の仕様は現在 0.2 ( P2 ) までリリース済み • Go は 0.1 ( P1 ) まで実装済み ◦ Preview2 の実装時期は未定 • P1 では Network Socket や Thread などが未対応 WASI Timeline Go の 対応状況は 0.1 ( P1 ) 現在
  10. 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 ) が利用可能
  11. TinyGo • 小型デバイスや組込み環境向けの軽量 Go コンパイラ • Go1.21 より前は TinyGo でしか

    WASI 対応の WASM を作るこ とはできなかった WASM にしたときのサイズが Go の場合に比べてかなり小さい Pros Cons reflect を使っている場合など、ビルドできない Go のコードが多数ある TinyGo logo © TinyGo Contributors, licensed under CC BY 4.0
  12. 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形式 )
  13. WASM を使ったプラグインシステムの壁を紹介 • HTTP Server には数百RPS以上の負荷がくる想定 • WASM プラグインで行うことに実装上の制限は設けない HTTP

    Server で WASM プラグインを運用する例を考える 並行処理 / メモリ管理 / Network Socket / TLS / コマンド実行 さまざまな壁が存在
  14. 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 に処理が移る )
  15. 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
  16. 並行処理の壁 • 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
  17. • 理想: WASMインスタンスがシングルスレッドで動くため、 リクエストごとにインスタンスを作って並行処理したい • 現実: 最小のGoコードをインスタンス化した際に使用する メモリ量でも 8MBある ◦

    実運用上は数十MB使用することを考慮する必要あり ◦ 何も考えずにインスタンスを多数作ると、あっという間に数十GBのメモリ を使用してしまう package main func main() {} メモリ管理の壁 Compiler File Size Memory Usage Go 1.6MiB 8MiB TinyGo 83KiB 128KiB
  18. Network Socket の壁 • Go の WASI P1では Network Socket

    は未対応 ◦ sock_recv / sock_send の実装が存在しない • wasmimport で Host 関数を経由してリクエストすることはでき るが... ◦ Guest 側の HTTP / gRPC を扱うすべての箇所をホスト関数を 経由するように書き換える必要がある ◦ 3rd party ライブラリの処理まで考えると現実的ではない
  19. TLS の壁 • Network Socket 問題を解決しても、 HTTPS を扱うには もうひとつ壁がある •

    Go の WASI P1 ビルドでは、サーバー証明書の正当性を検証す る際に利用する、システム証明書ストアを用意していない ため、明示的に証明書を指定しない限り必ず失敗してしまう ◦ x509: certificate signed by unknown authority
  20. コマンド実行の壁 • Go の WASI P1 ビルドでは exec.Command をサポートしてい ない

    • kubernetes/client-go では InClusterConfig を使えない場合 ( 例えば local での実行時 )、 gke-gcloud-auth-plugin コマンドを使って認証情報を得る ◦ Kubernetes API を Guest で使うことができない
  21. 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 の修正や機能追加を行っている
  22. • 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 )
  23. 標準ライブラリの実装を差し替える方法 go build -overlay と linkname directive を組み合わせる • go

    build -overlay ◦ ビルド時にファイルを任意のものに差し替えることができる • linkname directive ◦ 他の package にある symbol を直接参照して使える ▪ private でも大丈夫 ◦ symbol 解決を link 時に行う ( compile 時は無視 ) ◦ (注意) 標準ライブラリの symbol を third party で使うのは基本的に できなくなった: #67401
  24. 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 ファイルの置換 ファイルの追加
  25. 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 )
  26. 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
  27. 標準ライブラリの置き換え処理の隠蔽 • プラグインビルド用の専用コマンドを用意することで、 置き換え処理を意識せずに利用できる ◦ e.g.) myapp plugin build plugin.go

    ◦ コマンドの中で overlay.json を動的に生成して置き換える • 紹介した Overlay ファイルの作成処理は goccy/wasi-go に 実装済み
  28. TLS / exec.Command の壁を超える • 標準ライブラリの置換で対応する ◦ TLS: crypto/x509.(*Certificate).Verify を置換

    ◦ exec.Command: os/exec.(*Cmd).Start/Stop を置換 • wasmimport を使い、置換した関数から Host に処理を委譲する • Host に処理が戻ってくるため、実行したくないコマンドを弾くといっ たセキュアな実行方法も実現可能
  29. プラグインの非同期処理をサポートする ( 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 を自由に使える 実際は、この処理をライブラリで実装し、 プラグイン実装者からは隠す
  30. プラグインの非同期処理をサポートする ( 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 経由でレスポンスを受け取る
  31. メモリ使用量を考慮した運用 • インスタンスをあらかじめ任意の数 ( 固定値 ) 起動しておき、 ロードバランスする運用が有効 • Host

    と Guestのメモリ管理は別なので、 Guest 側で GC が 適切に動くように環境変数を設定したり、 runtime.GC() を明示的に呼び出すなどの工夫も有効 ◦ 何も考慮しないで運用した際、 OOM になるケースがあった ◦ WASM は 32bit なので、1インスタンスあたり最大 4GB の メモリを使う
  32. その他 ( 環境変数の Tips ) • GOMAXPROCS に 2 以上を指定すると

    panic する ◦ Env を Guest に渡す場合は、必ず GOMAXPROCS をフィルタする 必要がある • Google Cloud の API を利用する際は、 mTLS まわりの処理を無 効化するために以下の環境変数を設定しておく必要がある ◦ GOOGLE_API_USE_CLIENT_CERTIFICATE=false ◦ GOOGLE_API_USE_MTLS_ENDPOINT=never
  33. • WASM を使ってプラグインシステムを作ろう • プラグインビルド用のコマンドを用意して、 そのままだと動かない処理を置き換えよう • 処理を置き換える際は Host に処理を委譲できないか検討しよう

    • インスタンス化する場合はメモリの使用量に気をつけよう • wasmexport ではなく、 main loop を使って インスタンスを作ろう • Go 1.21 以上であれば、プラグインステムは作れる!
  34. For “Ask The Speaker” • 時間の都合で話せないトピックがたくさんあったので、 発表の内容以外にもいろいろ聞いてください ◦ 将来 WASI

    P2 がリリースされたときに、P1とP2の判断方法 ◦ プラグインシステムのバージョン管理 ◦ WASM の CI/CD との連携 ◦ 実例の紹介 ◦ 多言語対応のアイディア ◦ etc.