GoのWASI 0.1をWASI 0.2に変換して実行する
GoはWASI 0.1をサポートしていますが,現時点のWASIの最新バージョンは0.2.x[1]で,Goはまだ0.2以降のWASIをサポートしていません.
GoでWASI 0.2を利用したい場合,2025年時点ではTinyGoを使いましょうと言う話になるのですが,この記事ではTinyGoを使わずにWASI 0.2を利用する方法を探してみたいと思います.
アダプタを利用する
RustもTinyGoも今ではWASI 0.2に直接ビルドすることができますが,かつてWASI 0.1のモジュールをラップしてWASI 0.2のコンポーネントを作成するためアダプタモジュールを利用してWASI 0.2を作成する方法を取っていた時期がありました.
例えば,次のようなコードをtinygo
を利用してWASI 0.1のWasmモジュールに変換します.
// hello.go
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello, 世界!")
}
# WASI 0.1を作成
GOOS=wasip1 GOARCH=wasm tinygo build -o hello.wasm hello.go
次にアダプタとなるWasmモジュールをダウンロードし,wasm-tools
を利用してWasmコンポーネントを作成します.
# アダプタをダウンロード
wget https://github.com/bytecodealliance/wasmtime/releases/download/v36.0.2/wasi_snapshot_preview1.command.wasm
# WASI 0.2に変換
wasm-tools component new -o component.wasm --adapt wasi_snapshot_preview1.command.wasm hello.wasm
WASI 0.1のままでも実行することできますが,このようにWASI 0.2に変換したWasmも実行することができます.
# 実行
wasmtime run ./component.wasm
Hello, 世界!
WASI 0.2に準拠しているWasmなのかを確認したい場合,wasm-tools
コマンドを使って確かめることができます.
wasm-tools component wit component.wasm | head -n 24
package root:component;
world root {
import wasi:cli/environment@0.2.6;
import wasi:cli/exit@0.2.6;
import wasi:io/error@0.2.6;
import wasi:io/poll@0.2.6;
import wasi:io/streams@0.2.6;
import wasi:cli/stdin@0.2.6;
import wasi:cli/stdout@0.2.6;
import wasi:cli/stderr@0.2.6;
import wasi:cli/terminal-input@0.2.6;
import wasi:cli/terminal-output@0.2.6;
import wasi:cli/terminal-stdin@0.2.6;
import wasi:cli/terminal-stdout@0.2.6;
import wasi:cli/terminal-stderr@0.2.6;
import wasi:clocks/monotonic-clock@0.2.6;
import wasi:clocks/wall-clock@0.2.6;
import wasi:filesystem/types@0.2.6;
import wasi:filesystem/preopens@0.2.6;
import wasi:random/random@0.2.6;
export wasi:cli/run@0.2.6;
}
このようにexport wasi:cli/run@0.2.x
となっていればWASI 0.2.xのモジュールです.[2]
TinyGoからビルドするとこのように実行することができますが,一方でGoからビルドしたWasmをラップして実行すると次のようなエラーが発生してしまいます.
# WASI 0.1を作成
GOOS=wasip1 GOARCH=wasm go build -o hello.wasm hello.go
(途中のコマンドはTinyGoの例と同じです)
# GoビルドしたWasmをラップして実行
wasmtime run ./component.wasm
assertion failed at adapter line 2795Error: failed to run main module `component.wasm`
Caused by:
0: failed to invoke `run` function
1: error while executing at wasm backtrace:
0: 0x25f3df - wit-component:adapter:wasi_snapshot_preview1!wasi_snapshot_preview1::macros::assert_fail::h191faa7b1dd0627d
1: 0x25faf8 - wit-component:adapter:wasi_snapshot_preview1!args_sizes_get
2: 0x263bd8 - wit-component:shim!adapt-wasi_snapshot_preview1-args_sizes_get
3: 0x8d4e4 - <unknown>!runtime.args_sizes_get
4: 0x8db24 - <unknown>!runtime.goenvs
5: 0x9c763 - <unknown>!runtime.schedinit
6: 0x1180f4 - <unknown>!runtime.rt0_go
7: 0x11ad09 - <unknown>!_rt0_wasm_wasip1
8: 0x25f2df - wit-component:adapter:wasi_snapshot_preview1!wasi:cli/run@0.2.6#run
2: wasm trap: wasm `unreachable` instruction executed
エラー調査
先ほどのエラーを見てみると,0x25f3df
のassert_fail
で発生しているようです.
ただ,assert_fail
はエラーのハンドリング処理のようなので,実際には一つ前にある0x25faf8
のargs_sizes_get
にエラーの原因がありそうです.
dumpしてWasmの処理の内容を見てみると,0x25faf8
は次の処理(func 49
)の中にあることがわかります.
# エラーの発生箇所を確認
wasm-tools dump component.wasm
============== func 49 ====================
0x25fa4c | a7 02 | size of function
0x25fa4e | 01 | 1 local blocks
0x25fa4f | 04 7f | 4 locals of type I32
0x25fa51 | 23 00 | global_get global_index:0
0x25fa53 | 41 20 | i32_const value:32
...
0x25faef | 24 00 | global_set global_index:0
0x25faf1 | 41 00 | i32_const value:0
0x25faf3 | 0f | return
0x25faf4 | 0b | end
0x25faf5 | 41 eb 15 | i32_const value:2795
0x25faf8 | 10 2a | call function_index:42 <- ここ
...
dumpの内容だけだとまだ読みづらいため,WATを出力してfunc 49
を探してみます.
すると,func 49
はエラーログにある通りargs_sizes_get
関数であることがわかります.
# テキストフォーマットに変換して読みやすくする
wasm-tools print component.wasm
(func $args_sizes_get (;49;) (type 13) (param i32 i32) (result i32)
(local i32 i32 i32 i32)
global.get $__stack_pointer
i32.const 32
i32.sub
local.tee 2
global.set $__stack_pointer
block ;; label = @1
block ;; label = @2
block ;; label = @3
block ;; label = @4
call $_ZN22wasi_snapshot_preview15State3ptr17h37198fea2433a6f6E
local.tee 3
i32.load
i32.const 560490357
i32.ne
br_if 0 (;@4;)
local.get 3
i32.load offset=65532
i32.const 560490357
i32.ne
br_if 1 (;@3;)
...
end
i32.const 2795
call $_ZN22wasi_snapshot_preview16macros11assert_fail17h191faa7b1dd0627dE
unreachable
end
i32.const 2796
call $_ZN22wasi_snapshot_preview16macros11assert_fail17h191faa7b1dd0627dE
unreachable
end
...
end
i32.const 551
call $_ZN22wasi_snapshot_preview16macros11unreachable17hb5b1b1c3b6e09812E
unreachable
)
テキストフォーマット (WAT)の内容を読み解いてみると,メモリ上の値がある定数(ここでは560490357
)と異なる場合にassert_fail
を呼び出していることがわかります.
LLM曰く,この処理はメモリ上のデータ破損チェック処理とのことでした.
エラーの原因
調査内容は省略しますが,もう少しWATを読み解いてみるとGoのランタイムとアダプタが同じメモリ上で別々にメモリ管理を行っていることがわかりました.そのため,Goが利用しているメモリ領域とアダプタが利用しているメモリ領域の競合が発生したことによってこのエラーに到達しているようでした.
つまり,このエラーはアダプタとGoのランタイムが使用するメモリの領域を分離できれば解決できそうです.
解決策
cabi_reallocの実装
Goからビルドする際にcabi_realloc
関数をエクスポートすることで,メモリ管理をGo側で行えるようにできそうでしたが,この関数がGo側の初期化処理が終わる前に呼び出されてしまうため,エラーになってしまいます.
package main
//go:wasmexport cabi_realloc
func cabiRealloc(oldPtr, oldSize, align, newSize uint32) uint32 {
// reallocの実装
}
詳しくはcabi_realloc
を実装してみてWATを読んでみてください.
multi-memoryを利用する
メモリ領域の分離というと,Wasmのmulti-memoryを利用するのが良さそうでしたが,Bytcode Allianceが提供しているアダプタ処理はmulti-memoryに対応していません.
# なんとかしてmulti-memoryに対応したアダプタを作成してコンポーネントを作成する
wasm-tools component new -o component.wasm --adapt wasi_snapshot_preview1.command.wasm hello.wasm
error: failed to encode a component from module
Caused by:
0: failed to decode world from module
1: failed to reduce input adapter module to its minimal size
2: adapter modules don't support multi-memory
ビルド後のコンポーネントを編集してmulti-memoryにしてしまえばうまくいくかもしれないと思ったのですが,書き換え箇所の特定が大変そうなので断念しました.[3]
メモリ上の領域を被らないようにする
一般的にメモリ確保は近い領域で行われるはずなので,Goのランタイムとアダプタが使用するメモリ領域を被らないようメモリ上の扱う領域を離してみます.
WATファイルの中身を調べてみると,次の2つの項目が見つかりました.
(memory (;0;) 36)
(global $__stack_pointer (;0;) (mut i32) i32.const 0)
(memory 36)
は,メモリの定義と初期サイズを意味しているため,このコンポーネントは起動したタイミングで36ページ (36 x 64KiB
)のメモリを確保します.[4]
次の(global $__stack_pointer (mut i32) i32.const 0)
は,アダプタが使うメモリ領域のスタックポインタの指定に利用されています.
この2つの項目をそれぞれ(memory 128)
と(global $__stack_pointer (mut i32) i32.const 8388608)
に書き換えてみます.[5]
# メモリの定義とスタックポインタの初期値を書き換え,新しくcomponent_fix.wasmを作成
wasm-tools print component.wasm \
| sed 's/(memory (;0;) 36)/(memory 128)/' \
| sed 's/(global $__stack_pointer (;0;) (mut i32) i32.const 0)/(global $__stack_pointer (mut i32) i32.const 8388608)/' \
| wasm-tools parse -o component_fix.wasm
実行してみると...
# 実行
wasmtime run ./component_fix.wasm
Hello, 世界!
このようにうまく動作させることができました.
この書き換えを行うためのスクリプトを用意してみたので,参考にしてみてください.
これだけでは実現できていないこと
ひとまず簡単な処理であればここで紹介した方法で動作させることができるようになりましたが,ファイルの中身を読み出すといったことがここで紹介した方法だけではできませんでした.
WASI 0.1の範囲ではファイルの中身を読み出せていたので,アダプタ側に何かありそうです.
また,TinyGoの場合はメモリを必要としない環境であればメモリ領域の書き換えをしなくとも動くものの,大きめのメモリ領域が必要になるケースで同様の問題が発生してしてしまいます.
なので,multi-memoryに対応したアダプタ用意して完全にメモリ領域を分けることが一番確実な方法ではないでしょうか.
まとめ
まだ課題はあるものの,GoのWASI 0.1をWASI 0.2に変換して実行できることを確認することができました.
もし,GoでWASI 0.2を試したいのであれば...
# WASI 0.2を作成
GOOS=wasip2 GOARCH=wasm tinygo build -o hello.wasm hello.go
# 実行
wasmtime run ./hello.wasm
Hello, 世界!
Discussion