ほわいとぼーど

ぷろぐらまのメモ帳

hashicorp/go-pluginを試しかけたメモ

GolangでPlugin機構を使ってみたくなった。
Golangは1binaryが利点の1つだと思っているけど、
本体とPluginを分離することによりPlugin開発速度と本体の堅牢さの両立を
維持したいケースもあると思います。

例えばmackerelio/mackerel-agent-pluginsなんかは良い例。
他にPlugin機構を持っているケースとしてHashicorpプロダクトが思い浮かんだので
ちょっと調べたところ hashicorp/go-pluginというライブラリを見つけたので触ってみた。
go-pluginはTerraformで使われている。

ちなみにHashicorpのPluginに関する話としてmitchellさんの動画がある。
2016 Kickoff - Hashicorp & Go Plugin Architecture
今年の2月のものだけど、これまでのHashicorpのPluginに対する開発歴史や今後も語られていて面白い。
今のTerraformがv5で、今後はよりSecureかつユーザフレンドりな要素を入れたv6を作って
VaultでもユーザがPluginを作れるようにしたいそう。

ということで、hashicorp/go-plugin/exampleを試してみる。
このexampleは単体で動くようになっているので、ビルドして実行すれば動く

$ go build main.go
$ ./main
Hello!

内部的にはmain()の中から再度mainバイナリをpluginとして起動すると
mainPlugin()がPlugin側として動作するものらしい。

しかしこれだとPluginの恩恵が感じられないので本体とPluginが分離した状態で動かしたい。
もっとも簡単にやるならmain.goを複製してplugin.goを作成し、
mainPlugin()をmain()にして不要なimport文を削れば動く・・・のだが
それでは意味がないので試行錯誤した。
なお、完全に理解したとは言い難い(特に言語的に)のでご容赦ください。

最終的にした構成はmain.go plugin1.go plugin/plugin.go plugin/greeter_plugin.go

plugin/plugin.go

package plugin

import (
    "os/exec"

    "github.com/hashicorp/go-plugin"
)

var handshakeConfig = plugin.HandshakeConfig{
    ProtocolVersion:  1,
    MagicCookieKey:   "BASIC_PLUGIN",
    MagicCookieValue: "hello",
}

func NewClient(cmd string) (c *plugin.Client) {
    return plugin.NewClient(&plugin.ClientConfig{
        HandshakeConfig: handshakeConfig,
        Plugins:         pluginMap,
        Cmd:             exec.Command(cmd, "plugin"),
    })
}

// pluginMap is the map of plugins we can dispense.
var pluginMap = map[string]plugin.Plugin{
    "greeter": &GreeterPlugin{},
}

func Serve(g Greeter) {
    plugin.Serve(&plugin.ServeConfig{
        HandshakeConfig: handshakeConfig,
        Plugins:         PluginMap(g),
    })
}

func PluginMap(g Greeter) map[string]plugin.Plugin {
    return map[string]plugin.Plugin{
        "greeter": &GreeterPlugin{PluginFunc: g},
    }
}

本体とPlugin双方で使うHandshake、pluginMapの部分をまとめている。
NewClientのexec.Commandの部分はPluginパスを起動元から受けて実行する形にした。
本体側の入力に応じて実行するPluginを変えることもできるし、
hashicorp/go-pluginにDiscoveryの仕組みも入っているので色々できそう。
Serve()はPlugin側で定義したものを受け取ってセットする。
元はここがPlugin用に定義してるものを直接読んでいたために、最後まで一番ハマった。
雑に直した感あってベターなのかよくわからない。

plugin/greeter_plugin.go

package plugin

import (
    "net/rpc"

    "github.com/hashicorp/go-plugin"
)

// Greeter is the interface that we're exposing as a plugin.
type Greeter interface {
    Greet() string
}

type GreeterPlugin struct{
    PluginFunc Greeter
}

func (p *GreeterPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
    return &GreeterRPCServer{Impl: p.PluginFunc}, nil
}

func (GreeterPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
    return &GreeterRPC{client: c}, nil
}

// Here is an implementation that talks over RPC
type GreeterRPC struct{ client *rpc.Client }

func (g *GreeterRPC) Greet() string {
    var resp string
    err := g.client.Call("Plugin.Greet", new(interface{}), &resp)
    if err != nil {
        // You usually want your interfaces to return errors. If they don't,
        // there isn't much other choice here.
        panic(err)
    }

    return resp
}

// Here is the RPC server that GreeterRPC talks to, conforming to
// the requirements of net/rpc
type GreeterRPCServer struct {
    // This is the real implementation
    Impl Greeter
}

func (s *GreeterRPCServer) Greet(args interface{}, resp *string) error {
    *resp = s.Impl.Greet()
    return nil
}

Greeter interfaceに関わる部分を集約。
Plugin側で定義したコマンドを受け取るためにGreeterPluginに要素生やして
GreeterRPCServerに渡している。
本来はPluginとのI/F定義が集約されるので一番大事なのだが、
まだ理解しきってないので今後の課題です・・・

plugin1.go

package main

import (
    "github.com/a3no/go-plugin-example/plugin"
)

func main() {
    plugin.Serve(new(GreeterHello))
}


// Here is a real implementation of Greeter
type GreeterHello struct{}

func (GreeterHello) Greet() string { return "Hello!Hello!!" }

plugin起動時にGreeter interfaceの実体を渡している。

main.go

package main

import (
    "fmt"
    "io/ioutil"
    "log"

    "github.com/a3no/go-plugin-example/plugin"
)

func main() {
    // We don't want to see the plugin logs.
    log.SetOutput(ioutil.Discard)

    // We're a host! Start by launching the plugin process.
    client := plugin.NewClient("./plugin1")
    defer client.Kill()

    // Connect via RPC
    rpcClient, err := client.Client()
    if err != nil {
        log.Fatal(err)
    }

    // Request the plugin
    raw, err := rpcClient.Dispense("greeter")
    if err != nil {
        log.Fatal(err)
    }

    // We should have a Greeter now! This feels like a normal interface
    // implementation but is in fact over an RPC connection.
    greeter := raw.(plugin.Greeter)
    fmt.Println(greeter.Greet())
}

ラップしたNewClientを呼ぶように変えたくらいで元のmain()と大差なし

これでそれぞれビルドして動かせば動く

$ go get github.com/a3no/go-plugin-example/plugin
$ go build plugin1.go
$ go run main.go
Hello!Hello!!

なんとか分割したもののPluginとのI/Fを自在に定義できるようにならないと
柔軟には使えないと思うのでもうちょいどうにかしたい。