go1.21 の WASI Preview を試してみる

2023-09-15

はじめに

こんにちは。Belong Inc. で Backend Engineer を担当している niwa です。

2023/8 に、golang 1.21.0 がリリースされました。リリースノートを眺めていると、下記のように、WASI への実験的なサポートが追加された旨の記載がありました。

WebAssembly System Interface
Go 1.21 adds an experimental port to the WebAssembly System Interface (WASI), Preview 1 (GOOS=wasip1, GOARCH=wasm).

As a result of the addition of the new GOOS value "wasip1", Go files named *_wasip1.go will now be ignored by Go tools except when that GOOS value is being used. If you have existing filenames matching that pattern, you will need to rename them.

この記載に興味が湧いたので、現状の WASI および go1.21 での WASI Support について調査を行いました。今回はそれについて共有をしたいと思います。

WASI とは

そもそも WASI という言葉に耳馴染みのない方もいらっしゃるかと思います。
WASI は WebAssembly System Interface の略称で、 WebAssembly を拡張した規格となります。
WebAssembly は あくまでブラウザ上で実行されることを想定した規格であり、例えば外部との通信などはブラウザの機能 (= JavaScript Interface) を利用して実現する形になります。 そこに対して、WASI は WebAssembly に対して、ファイル I/O や Socket 通信などの機能を提供し、ブラウザ以外の環境でも WebAssembly を利用できるようにすることを目的としています。

現在は WASI の規格として、 preview 2 と呼ばれるものが策定中です。
golang 1.21 でサポートされるのは、すでに規格が Freeze されている preview 1 です。

preview 1 においては、

  • system clock のサポート
  • ファイルシステムの取り扱いのサポート
  • Socket に関する (部分的な) サポート

が、仕様として定められています。当該仕様は Github 上にて公開されており、誰でも参照することが可能です。 https://github.com/WebAssembly/WASI/blob/main/legacy/preview1/docs.md

WASI のユースケース 

我々の身近なところでいうと Shopify Functions において WASI を利用した拡張が実装されています。

Shopify Functions については、以前に 弊社エンジニアの marc もこちらの記事で紹介をしていますが、あらためて触れさせていただきます。

Shopify Functions は、Shopify の機能をカスタマイズするための機能です。
https://shopify.dev/docs/apps/functions/language-support/webassembly

Shopify Functions においては、入力パラメータを STDIN から受け取り、出力結果を STDOUT (および、STDERR ) に返却する、という規約が定められています。
この STDIN, STDOUT および STDERR とのやりとり、の部分が WASI によって実現されています。

WASI に対してのコンパイルがサポートされている言語であれば、言語を問わず上記仕様を満たすアプリケーションを記述することで、WebAssembly の主目的であるパフォーマンス、およびサンドボックスによるセキュリティの担保を行った上での Shopify Backend のカスタマイズが可能になる、ということですね。

試してみる

ここまで WASI について、簡単な紹介と現状の立ち位置について触れてきました。それでは、さっそく go1.21 でどのようなことができるようになったのかを試してみたいと思います。

WASI preview 1 の仕様を読むと、socket に関する API は以下の 4 つがサポートされているようです。

  • sock_accept
  • sock_recv
  • sock_send
  • sock_shutdown

名前を聞く限り、限定的サポートとはいいつつも、HTTP リクエストを受けて、レスポンスを返すような簡易 API サーバのようなものはサクッと作れる気がします。ので、実際に試していきます。

今回は下記のようなコードを書いてみました。 アクセスすると、Hello World! という文字列を返すだけのシンプルな HTTP Server です。

package main

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		io.WriteString(w, "Hello world!")
	})

	err := http.ListenAndServe(":1111", nil)
	if err != nil {
		log.Fatalf("failed to listen and serve: %v", err)
	}
}

こんな感じのコマンドで WASI module を作成します。

$ GOOS=wasip1 GOARCH=wasm go1.21 build -o main.wasi main.go

WASI を実行できるランタイムはいくつか種類が存在するようですが、今回は wasmtime というランタイムを使用します。

$ wasmtime --tcplisten 127.0.0.1:1111 main.wasi

上記コマンドを実行した結果、下記のような出力が得られました。

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
net.(*fakeNetFD).accept(...)
        net/net_fake.go:229
net.(*netFD).accept(0x144e2c0)
        net/fd_wasip1.go:88 +0x44
net.(*TCPListener).accept(0x142a0e0)
        net/tcpsock_posix.go:152 +0x4
net.(*TCPListener).Accept(0x142a0e0)
        net/tcpsock.go:315 +0x8
net/http.(*Server).Serve(0x1470000, {0xca110, 0x142a0e0})
        net/http/server.go:3056 +0x30
net/http.(*Server).ListenAndServe(0x1470000)
        net/http/server.go:2985 +0x10
net/http.ListenAndServe(...)
        net/http/server.go:3239
main.main()
        ./main.go:16 +0xa

見事に deadlock していますね。。 ただ、コード自体には特に怪しいところはなさそうなので、エラーメッセージを追っていきます。

fd_wasip1.go という見慣れないファイルからエラーが発生しているようです。 https://github.com/golang/go/blob/release-branch.go1.21/src/net/fd_wasip1.go

//go:build wasip1

package net
...
// Network file descriptor.
type netFD struct {
	pfd poll.FD

	// immutable until Close
	family      int
	sotype      int
	isConnected bool // handshake completed or use of association with peer
	net         string
	laddr       Addr
	raddr       Addr

	// The only networking available in WASI preview 1 is the ability to
	// sock_accept on an pre-opened socket, and then fd_read, fd_write,
	// fd_close, and sock_shutdown on the resulting connection. We
	// intercept applicable netFD calls on this instance, and then pass
	// the remainder of the netFD calls to fakeNetFD.
	*fakeNetFD
}

このファイルには go:build wasip1 という記述があり、wasi に対しての netFD の実装であることがわかります。
意味深なコメントと共に、fakeNetFD といういかにもモック的な構造体が埋め込まれていますね。

fakeNetFD についても、念のため定義を確認してみます。
https://github.com/golang/go/blob/release-branch.go1.21/src/net/net_fake.go

// Fake networking for js/wasm and wasip1/wasm. It is intended to allow tests of other package to pass.

//go:build (js && wasm) || wasip1

package net

...
type fakeNetFD struct {
	listener fakeNetAddr
	r        *bufferedPipe
	w        *bufferedPipe
	incoming chan *netFD
	closedMu sync.Mutex
	closed   bool
}

it is intended to allow tests of other package to pass というコメントの通り、テスト用のモックとして実装されているようです。 では、go1.21 では HTTP Server の起動は諦めるのしかないでしょうか?

もう少し粘っていると、net_wasip1.go の中に下記定義がされているのを発見しました。
https://github.com/golang/go/blob/release-branch.go1.21/src/syscall/net_wasip1.go

//go:wasmimport wasi_snapshot_preview1 sock_accept
//go:noescape
func sock_accept(fd int32, flags fdflags, newfd unsafe.Pointer) Errno

//go:wasmimport wasi_snapshot_preview1 sock_shutdown
//go:noescape
func sock_shutdown(fd int32, flags sdflags) Errno

...

func Accept(fd int) (int, Sockaddr, error) {
	var newfd int32
	errno := sock_accept(int32(fd), 0, unsafe.Pointer(&newfd))
	return int(newfd), nil, errnoErr(errno)
}

...

func Shutdown(fd int, how int) error {
	errno := sock_shutdown(int32(fd), sdflags(how))
	return errnoErr(errno)
}

ちゃんと sock_accept, sock_shutdown に関しては実装されていそうな気配がありますね! go:wasmimport は go1.21 から追加された、WebAssembly Runtime から関数定義を読み込むためのディレクティブのようです。 https://tip.golang.org/doc/go1.21

つまり、sock_accept, sock_shutdown に関しては、wasmtime の実装が問題なく使われていそうです。 ただ、先程 preview1 においてサポートされていると書いた sock_recv, sock_send に関しては、実装が見当たりません。 上記と同様に、これらについては自前で import してあげればうまく動くのでは…?

その勘をもとに、以下関数を下記のように自前で実装することにしました。

//go:wasmimport wasi_snapshot_preview1 sock_recv
//go:noescape
func SockRecv(fd int32, riData unsafe.Pointer, riSize uint32, riFlags uint32, oResult unsafe.Pointer, roFlags unsafe.Pointer) syscall.Errno

//go:wasmimport wasi_snapshot_preview1 sock_send
//go:noescape
func SockSend(fd int32, siData unsafe.Pointer, siSize uint32, siFlags uint32, oResult unsafe.Pointer) syscall.Errno

// ioVector is used for sock_recv and sock_send to read / write data
type ioVector struct {
	buf    uint32
	bufLen uint32
}

func bytesPointer(b []byte) unsafe.Pointer {
	return unsafe.Pointer(unsafe.SliceData(b))
}
func NewIovec(b []byte) unsafe.Pointer {
	return unsafe.Pointer(&ioVector{
		buf:    uint32(uintptr(bytesPointer(b))),
		bufLen: uint32(len(b)),
	})
}

また、元のコードだと http.ListenAndServe を使用してサーバの起動を行っていました。
http.Serve を使用すると、 net.Listener を引数に取ることができるので、これを利用して自前で Accept 処理を行う net.Listener を実装することにしました。
コードとしては以下のような形です。

// file discriptor for socket used by wasmtime
const SocketFd int32 = 3

type wasiSocketListener struct {
	acceptFD int
}

var _ net.Listener = (*wasiSocketListener)(nil)

func (l *wasiSocketListener) Accept() (net.Conn, error) {
	for {
		acceptFD, _, err := syscall.Accept(int(SocketFd))

		if err != nil {
			if errors.Is(syscall.EAGAIN, err) {
				time.Sleep(time.Millisecond * 100)
				continue
			}
			return nil, err
		}

		l.acceptFD = acceptFD

		return &wasiSockConn{fd: int32(acceptFD)}, nil
	}
}

func (l *wasiSocketListener) Close() error {
	return syscall.Close(l.acceptFD)
}

func (l *wasiSocketListener) Addr() net.Addr {
	return &wasiSockAddr{fd: SocketFd}
}

net.Addr を実装した struct は下記のように定義しました。
Network は WASI に関しては取得するすべがないため、 Unknown としています。

type wasiSockAddr struct {
	fd int32
}
var _ net.Addr = (*wasiSockAddr)(nil)

func (w wasiSockAddr) Network() string {
	return "Unknown"
}

func (w wasiSockAddr) String() string {
	return fmt.Sprintf("file descriptor: %d", w.fd)
}

net.Conn を実装した struct は下記のように定義しました。
Deadline に関しては、今回は実装を省略しています。

type wasiSockConn struct {
	fd int32
}

var _ net.Conn = (*wasiSockConn)(nil)

func (s wasiSockConn) Read(b []byte) (n int, err error) {
	var result int64
	var resultFlags socket.Roflags

	err = socket.SockRecv(s.fd, socket.NewIovec(b), 1, 0, unsafe.Pointer(&result), unsafe.Pointer(&resultFlags))

	return int(result), err
}

func (s wasiSockConn) Write(b []byte) (n int, err error) {
	var result int64
	err = socket.SockSend(s.fd, socket.NewIovec(b), 1, 0, unsafe.Pointer(&result))

	return int(result), err
}

func (s wasiSockConn) Close() error {
	err := socket.SockShutdown(s.fd, 0)
	return err
}

func (s wasiSockConn) LocalAddr() net.Addr {
	return wasiSockAddr{fd: s.fd}
}

func (s wasiSockConn) RemoteAddr() net.Addr {
	return wasiSockAddr{fd: s.fd}
}

func (s wasiSockConn) SetDeadline(t time.Time) error {
	//TODO implement me
	return nil
}

func (s wasiSockConn) SetReadDeadline(t time.Time) error {
	//TODO implement me
	return nil
}

func (s wasiSockConn) SetWriteDeadline(t time.Time) error {
	//TODO implement me
	return nil
}

最後に、wasiSocketListener を使用して、 http.Serve を実行するように変更します。

package main

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		io.WriteString(w, "Hello world!")
	})

	err := http.Serve(&wasiSocketListener{}, nil)
	if err != nil {
		log.Fatalf("failed to listen and serve: %v", err)
	}
}

これを先程と同じ wasmtime を使用して起動すると、下記のような結果になりました。

$ wasmtime --tcplisten 127.0.0.1:1111 main.wasi
---
❯ curl localhost:1111
> Hello World

多少手直しが必要ではありましたが、無事 go 1.21 においても http.Server を用いて、WASI の socket support を利用できることを確認できました。
冒頭で紹介した Shopify Functions では 外部との入出力 I/F は STDIN/OUT でしたが、HTTP に限らず Socket 通信ができることで、より柔軟なアプリケーションの実現ができるのではないかと思います。

まとめ

今回は go1.21 での WASI の実装を追いかけつつ、実際に WASI 上で HTTP Server が Listen できることを証明しました。 とはいえ、まだまだ WASI 規格自体が preview 1 ということもあり、規格自体の発展および今後の go における実装の追従も期待したいところです。

最後に、Belong Inc. では我々と一緒にサービスの成長にコミットしてくれるメンバーを募集中です。

ぜひ https://entrancebook.belonginc.dev/ をご覧いただけたら幸いです。

Appendix

ちょうど本稿を記述している際に、  Go  公式からの WASI Support に関する記事が公開されました。(ネタが被ってしまった)
https://go.dev/blog/wasi

上記で言及している、WASI preview 1 においての socket support についても触れられています。ので、もし本記事を読んで関心を持っていただけた方は、上記記事も合わせて参照いただけたらと思います。