knqyf263's blog

自分のためのメモとして残しておくためのブログ。

至高のGoプラグイン用ツールを作った

最近YouTuberのリュウジの料理を毎日作っているので至高とか無限とか言いがちですが個人の感想です。万人にとって美味しい料理はないように、万人にとって至高のツールは存在しません(何の話?)。ちなみに公開してすぐバグを見つけてしまったので全然至高じゃありませんでした。

要約

Goでプラグイン機構を実現するためのツールを作りました。Protocol Buffersのスキーマからコードを自動生成するので簡単にプラグイン機構を実現可能です。内部的にはWebAssembly(Wasm)を使っています。最近はWasmはブラウザ外での利活用が進んでおり、今回のツールもブラウザは一切関係ないです。Wasmはサンドボックス内で実行されるためファイルアクセスやネットワークアクセスはホストが許可しなければプラグインは行えずセキュリティ的に安全ですし、異なるアーキテクチャ用に複数バイナリを用意しなくてもプラグイン用にWasmバイナリを1つだけ作れば良いので配布も簡単です。これらの特徴から、サードパーティ製のコードを動かす必要のあるプラグイン用途に向いている気がしたので作りました。

github.com

概要

Goでプラグインを実現する方法はいくつかありますが、どれも一長一短でこれだ!というものがないのが現状かと思います。分かりやすく比較を載せてくださっている記事があったので置いておきます。

zenn.dev

Goには plugin パッケージがありますが自由度が高すぎてシグネチャのズレが容易に生じてしまいますし、あまり実例も少ないです。

上のブログ内でも言及されていますが、サードパーティ製で一番普及しているのはHashicorpにより開発されている hashicorp/go-plugin だと思います。Terraform等のOSSで採用されているため実例も多いです。内部はプラグインのバイナリがサーバとして動きツール側がクライアントとしてRPCで通信するアーキテクチャとなっています。プラグイン作りたいと思った人は100%知っていると思います。gRPCに対応しておりProtocol Buffersのprotoファイルを書けばクライアントとサーバ用のコードが自動生成できるため開発体験が良いです。ただし、プラグインと言ってもただの実行可能なバイナリなのでGoでプラグインを書く場合は環境ごとに異なるバイナリを配布する必要がありますし、単にバイナリを実行するという関係上セキュリティ的にはかなりよろしくないです。あと個人的にはprotobuf-goprotobuf-gen-go-grpc で生成されたコードをプラグインで使えるようにするためのおまじないが必要で、書きっぷりが直感的じゃないなと感じています(これは自分がアホなだけの可能性もあります)。

もっともらしい理由を書いてきましたが、一言でいうと自分の美学に合わないというのが一番の理由です。趣味のOSS開発の動機なんかそんな感じでいいと思います。競合の調査とか真面目にやってるとどれも凄そうに見えて永遠に作り始められませんし、自分のツールこそが最高!(お前がそう思うんならそうなんだろう お前ん中ではな)と思っておけば良いと思います。

ということで別の方法を探していて、WebAssembly (Wasm) が使えないかと考えました。1年以上構想を温めてきたのですがようやく納得行くアーキテクチャを思いついたので作りました。それが以下のツールです。

github.com

マスコットは妻にさっと描いてもらいました。名前は go-penguin 略してゴーペンくんです(適当)。

gRPCのようにProtocol Buffersの定義ファイルを渡すとWasm用のSDKを生成します。プラグイン側はそのSDKに従ってGoのinterfaceを実装するだけでプラグインが作れるようになっています。ホスト側もSDKが生成されるのでそれをimportしていくつか関数を呼び出すだけです。hashicorp/go-pluginと異なりプラグイン用のコード生成も行うためプラグイン開発者の負担はかなり低くなります。またSDKが自動生成されるため、Wasmに関する知識がなくても扱えるのも利点です。

プラグインはGoで書けるのですが、GoがWASI(ブラウザ外でWasmを使うためのSystem Interface)に対応していない関係でビルドはTinyGoで行う必要があります。TinyGoはGoに比べると機能が不足しており例えば encoding/json などが使えないのですが、その辺はHost Functionsを使ってある程度解消可能です。Host Functionsの使い方は後述します。

ただWasmは最近2.0のドラフトが出たばかりですし、WASIも2年前に snapshot_preview1が出てからまだ1.0に到達していませんし、現時点で安定的に使えるかと言うとまだ微妙な感じではありますが今後に期待というところです。

特徴

knqyf263/go-plugin の特徴として以下のものがあります。

  • Goのインタフェースを実装するだけで良い
    • SDKが自動生成されるため、Wasmを意識せずにGoのインタフェースを実装するだけでプラグインが作れる
  • セキュア
    • Wasmはメモリセーフかつサンドボックス上で実行されるため安全に実行できる
    • プラグインがpanicを起こしてもホスト側でハンドル可能
  • ポータブル

他にも色々あるのですが、そちらはREADMEを見ていただければと思います。

使い方

以下で使い方の説明をしていきますが、基本的に全てREADMEに書いてある内容なので直接GitHub見てもらったほうが早いかもしれません。サンプルコードもGitHubにいくつか置いています。

まず最初に開発フローについて説明し、その後に具体的な使い方を見ていきます。

流れ

  1. まずプラグイン用のインタフェースをProtocol Buffersの定義ファイルに書きます
  2. go-plugin を使ってGoのSDKを生成します
  3. 2でGoの interface が生成されるのでプラグインでそれを実装します
  4. TinyGoを使ってプラグインをWasmにコンパイルします
  5. ホスト側でWasmを読み込んで定義したインタフェースに従って関数を呼び出します

事前準備

まず必要なツールをインストールします。go-pluginprotocプラグインとして動作するため、事前に protoc をインストールしておく必要があります。そして go-plugin のバイナリは以下でダウンロード可能です。適当にパスの通った場所に置いてください。

github.com

あとはTinyGoも必要なのでインストールします。

インタフェースの定義

Protocol Buffersと同じフォーマットを使います。.proto ファイルを書いたことがない人は以下のドキュメントを読むと良いです。extensionsとか一部の機能を除けば基本的な機能はシンプルなのですぐ理解できると思います。

developers.google.com

大まかに言うと messageservice で成り立っており、 message が型を、 service がインタフェースを定義します。例を見てみます。

syntax = "proto3";
package greeting;

option go_package = "github.com/knqyf263/go-plugin/examples/helloworld/greeting";

// The greeting service definition.
// go:plugin type=plugin version=1
service Greeter {
  // Sends a greeting
  rpc SayHello(GreetRequest) returns (GreetReply) {}
}

// The request message containing the user's name.
message GreetRequest {
  string name = 1;
}

// The reply message containing the greetings
message GreetReply {
  string message = 1;
}

Greeter というサービスが SayHello という関数を持っています。 rpc と言っていますが、 go-plugin ではRPC通信は行わず、単にプラグインとのインタフェースを定義するためだけに使っています。

SayHello の引数は GreetRequest で戻り値は GreetReply になっています。それぞれはその下で message として定義されています。どちらもstringの値を一つ持つだけです。

ちなみに // go:plugin で始まる行が go-plugin 用のserviceであることを示すプラグマとなっています。これを書かないとスルーされます。特に type=pluginプラグイン用のインタフェースであることを示すために必ず必要です。

go_package は自分のプロジェクトのパッケージを指定します。

SDKの生成

上でprotoファイルが出来たら次にGoのコードを生成します。protoc が$PATH内の protoc-gen-go-plugin を自動で見つける仕組みになっているので、必ずパスの通った場所に protoc-gen-go-plugin を置いてください。

$ protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative greeting.proto

成功すれば greet.pb.go, greet_host.pb.go, greet_plugin.pb.go, greet_vtproto.pb.go の4つのファイルが生成されているはずです。

プラグインの実装

以下のようなGoの interface が生成されています。

type Greeter interface {
    SayHello(context.Context, GreetRequest) (GreetReply, error)
}

プラグインはこれを実装してstructをRegister関数経由で登録するだけで終わりです。例えば以下のように実装します。

//go:build tinygo.wasm

package main

import (
    "context"

    "github.com/path/to/your/greeting"
)

// main is required for TinyGo to compile to Wasm.
func main() {
    greeting.RegisterGreeter(MyPlugin{})

}

type MyPlugin struct{}

func (m MyPlugin) SayHello(ctx context.Context, request greeting.GreetRequest) (greeting.GreetReply, error) {
    return greeting.GreetReply{
        Message: "Hello, " + request.GetName(),
    }, nil
}

もうプラグイン用のWasmとしてビルドできる状態です。簡単ですね。画期的です。SDKを自動生成するアプローチだからこういうことが可能になっています。

$ tinygo build -o plugin.wasm -scheduler=none -target=wasi --no-debug plugin.go

ターゲットをWASIにしている関係でバイナリがそこそこ大きいです。WASIの機能が不要な場合に -target=wasm で何とかやれないかなーという野望もありますが、現状はWASI必須です。

ホストの実装

ホスト側のサンプルコードを載せます。

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/path/to/your/greeting"
)

func main() {
    ctx := context.Background()
 
    // Initialize a plugin loader
    p, err := greeting.NewGreeterPlugin(ctx, greeting.GreeterPluginOption{})
    if err != nil {...}

    // Load a plugin
    plugin, err := p.Load(ctx, "path/to/plugin.wasm")
    if err != nil {...}

    // Call SayHello
    reply, err := plugin.SayHello(ctx, greeting.GreetRequest{Name: "go-plugin"})
    if err != nil {...}
 
    // Display the reply
    fmt.Println(reply.GetMessage())
}

コード生成したパッケージをimportすると greeting.NewGreeterPlugin() などは自動生成されていて使える状態になっています。これはただのローダなので、その後に Load(ctx, "/path/to/plugin.wasm") で実際のプラグインを読み込んでいます。ローディングが終わればもう SayHello が呼べる状態になっています。今回は SayHello の戻り値を単に標準出力に表示しています。

実行

通常通り上のホスト用ファイルを実行します。

$ go run main.go
Hello, go-plugin

ということで無事にプラグインから返されたメッセージを表示できました。ほぼ同様のことを行うサンプルコードは以下に置いてあります。

go-plugin/examples/helloworld at 1ebeeca373affc319802989c0fe6304f014861c4 · knqyf263/go-plugin · GitHub

発展

基本的な機能は上で説明したのですが、いくつか発展的な内容についても書いておきます。

Host Functions

プラグイン側のコードはTinyGoでビルドすることになるのですが、一部機能が不足しており使えない標準パッケージがあります。

tinygo.org

例えばJSONの操作に使う encoding/json に対応していません。そのような場合は gjsoneasyjson などTinyGoでも動作可能なサードパーティ製のライブラリを使う必要があります。

また、Wasmはセキュリティを考慮して作られているためファイルアクセスなどが制限されています。WASIによってファイルシステムへのアクセスは限定的に可能になっていますが(後述します)、ネットワークアクセスなどは現在の go-plugin ではできません。これはTinyGoの制約ではなくWasmの制約です。また、Wasmランタイムとして利用しているwazeroで対応していないWASIの関数というのもあります。以下のwazeroのドキュメントにも書かれていますが、WASIの仕様がまだpreviewでいまいち固まりきっていないのが理由のようです。

wazero.io

そういった場合の方法としてhost functionを使う方法があります。これは名前の通り関数をホスト側で定義し、それをプラグインが呼び出せるようにする仕組みです。ホスト側はGoでコンパイルされるので encoding/json なども利用可能です。他にもプラグイン側にロギング用の関数やHTTP通信するための関数を提供したり、といったことが可能です。

host functionの定義は簡単で、上でプラグイン用のインタフェースを定義したprotoファイル内に以下のようにserviceを書きます。 //go:plugin type=host がhost functionを意味するので必須です。引数と戻り値のmessageも定義する必要がありますが今回は省略しています。GitHub/examples で残りも見られます。

// go:plugin type=host
service HostFunctions {
  // Sends a HTTP GET request
  rpc HttpGet(HttpGetRequest) returns (HttpGetResponse) {}
}

今回はプラグイン側で実行できないHTTPリクエストを定義しています。そして再度 protoc でコード生成すると今度は以下の interface も生成されます。

type HostFunctions interface {
    HttpGet(context.Context, HttpGetRequest) (HttpGetResponse, error)
}

このインタフェースを実装します。

// myHostFunctions implements HostFunctions
type myHostFunctions struct{}

// HttpGet is embedded into the plugin and can be called by the plugin.
func (myHostFunctions) HttpGet(ctx context.Context, request greeting.HttpGetRequest) (greeting.HttpGetResponse, error) {
    ...
}

protoファイル内にhost functionが定義されていると、 Load() でそのインタフェースを受け取れるようになっています。上で定義した myHostFunctions を渡せばOKです。

greetingPlugin, err := p.Load(ctx, "plugin/plugin.wasm", myHostFunctions{})

ちなみに今回は雑にGETリクエストを送れる関数をexportしましたが、実際にプライベートIPアドレスには送れないようにするなどの制約を行うべきです。何でもかんでも便利関数をexportすると、せっかくWasmでサンドボックスにしているのにガバガバになってしまいます。

ファイルアクセス

前述したようにプラグインはローカルのファイルにはデフォルトではアクセスできないのですが、ホスト側が明示的に許可することでプラグイン側でもファイルへのアクセスが可能になります。

プラグインローダの初期化時にホスト側から fs.FS を渡すことができます。特定のファイルでも良いですしディレクトリでも良いです。

publicDirFS := os.DirFS("./public")
p, err := cat.NewFileCatPlugin(ctx, cat.FileCatPluginOption{
    FS:     publicDirFS,         // Loaded plugins can access only files that the host allows.
})

サンプルコードは以下です。

go-plugin/examples/wasi at main · knqyf263/go-plugin · GitHub

その他

何でprotobufのextensions使わないの?とかTinyGo以外の多言語対応は?とか、その他細かい諸々はREADMEを参照してください。

苦労した点

WebAssemblyはintやfloatぐらいしか引数や戻り値として使えません。より複雑な型を扱うにはどうするかというと、シリアライズしてメモリに書き込みWasm側でデシリアライズするのが一般的です。この辺については前回ブログを書いたので参照してください。

knqyf263.hatenablog.com

Wasmのコードを書いていて気付いたのは、上のシリアライズ・デシリアライズ処理を頻繁に書くということです。じゃあこれを一般化して自動生成できるようにすれば幸せなんじゃないか?と思ったのが go-plugin の最初の動機です。

そこでシリアライズのフォーマットとして選んだのがProtocol Buffersになります。gRPCの普及により慣れ親しんでいる人が多いですし、スキーマドリブンの開発はやはり体験が良いためです。ですが、Wasm用途で広く使われるTinyGoはProtocol Buffersに対応していません(2022/08/29時点)。

github.com

そのため、protobuf-go で生成したコードはTinyGoではコンパイルできません。ここでprotobufの利用は一旦諦めたのですが、どうしてもスキーマドリブンの開発体験を諦めきれずもう少し中を見ることにしました。実際にコンパイルして試したところreflectパッケージの利用が問題のようでした。つまりreflectionを使わずにシリアライズ・デシリアライズできれば動くんじゃないかと考え実装を始めました。そうしたら vtprotobuf を発見しました。このツールはreflectionを使わずにmarshal/unmarshalするコードを生成してくれるのですが、protobuf-goとの併用が必須でprotobuf-goがreflectを使ってしまうため結局コンパイルできません。ならばprotobuf-goをベースにreflectを使わずに再実装すれば動くんじゃないかと思い、protobuf-goとvtprotobufを魔合体して改修して作ったのが go-plugin です。思いつきで始めたものの何とか動くようになって安心しました。

その副産物として、1バイナリで全てのコード生成を行えるようになりました。通常はprotobuf-goなど複数のプラグインを組み合わせてコード生成を行うため色々と事前準備が面倒ですが、 protoc-gen-go-plugin 一つだけprotocプラグインをインストールすればOKなので比較的楽です。

ですが今度はwell-known typesが動かないという問題に遭遇しました。protobufはデフォルトだとintやstringなどのプリミティブ型しか提供していませんが、Googleがtimestamp型などのよく使われる便利なmessageを提供してくれています。

developers.google.com

これは事前にビルドされたものがprotobuf-goのリポジトリに置かれています。コード生成されたときにこれらのパッケージをimportしています。

github.com

これらがreflectを使っているせいで動かないという状態でした。そこで一旦諦めたのですが、well-known typesはよく使われるものだし対応しないわけにもいかないよな...と考え直した結果、自分で各well-known typesのコードを再生成することにしました。TinyGoでコンパイルできる型が以下に定義されています。 import "google/protobuf/timestamp.proto"; とかするとGoogleではなく独自定義のstructが使われるようになっています。何も知らないとprotobuf-goのパッケージじゃないので驚くかもしれませんが、こういう事情があって泣く泣くreplaceしています。

github.com

まだ完全に移行が終わってないのでいくつか関数が足りなかったりしますが、最低限必要なものは動くんじゃないかなという気がします。以下にwell-known typesを使ったサンプルも置いてあります。

go-plugin/examples/known-types at main · knqyf263/go-plugin · GitHub

1年前の長期休暇で何か勉強したいなと思ってRust(初学)でWebAssembly(初学)ランタイムを書くという無謀なことをしたところ案の定作り終わらなかったのですが、手応えを得たのでプラグインのアイディアを思いつきました。ただWasm周りの処理をプラグイン開発者にそのまま書かせるのはしんどすぎるしSDKはどうする?シリアライズはどうする?とか色々悩んでいたら気付いたら1年経っていたという感じです。

他にも乗り越えるべき壁がたくさんあったのですが、寝不足でブログ書くの疲れてきたので一旦ここまでにします。

まとめ

僕の考えた最強のGoプラグイン用ツールの紹介でした。WasmやTinyGoによる制約があるのでどんな要件でも満たせるかというと怪しいのですが、多くのケースで使えるプラグイン用ツールに仕上がったのではないかと思います。WebAssembly 2.0のドラフトも出ましたし、今後より便利になっていくと思います。

久々に0から趣味でOSSを作ったので疲れました。最近は自分用にライブラリやツールを作ることが多かったのでドキュメントとか適当でしたが、やはり人に使ってもらおうとするとドキュメントも必要だし労力が大きいです。疲労で目の痙攣が起こりましたしOSSは身体に悪いです。

なかなか良いアーキテクチャが思いつかず苦労したのですが、その分良いものになったと自負しています。最近時間ない中で睡眠を削ったりして割と頑張ったので広く使ってもらえると良いなと思っていますが、世界中誰にも認められなくても自分だけは自分を褒められる完成度になりました(バグは多分まだたくさんあると思いますが)。誰かに認めてもらおうというモチベーションだと個人OSSは続かないので、少なくとも自分だけは幸せだからヨシ!という気持ちです。一方で知らない誰かと共同でものを作っていくのがOSSの楽しさの一つだとも思っているので、使ってもらうための努力としてドキュメントはちゃんと書いておこう、というダブルスタンダードでやっています。