この前、Terraform の Azure Provider に機能追加のプルリクエストを送りました。受けれられるといいな。

さて、アプリはできたのですが、一つだけどうやってやっているのかが想像つかない箇所がありました。それは、plugin
です。terraform のプラグインは、バイナリを作って
terraform init
とやると読み込まれます。しかも、terraform のプラグインである Azure Provider の方は、明らかに実行用のライブラリです。んーなんで?と思いました。その秘密は、というハシコープで書かれている、go-plugin というプラグインを使っていることでした。基本的な仕組みは、内部で、rpc を使って、クライアントサーバー型になっているという構造になっています。
それだけでは、よくわからないので、サンプルを作って理解してみましょう。
Terraform っぽいサンプル
サンプルは、Terraform を理解するためなので、Terraformっぽく作っています。
.
├── common
│ └── provider.go
├── main.go
├── plugin
│ ├── azure_provider
│ └── azure_provider.go
こんな感じです。plugin の方が、Azure Provider 相当です。こちらの方も個別でバイナリを作ります。ポイントは、common クライアントとサーバーの両方で使うインターフェイスや struct を置いてあることです。そこからみてみましょう。
common
package provider
import (
"net/rpc"
plugin "github.com/hashicorp/go-plugin"
)
type ProviderRPC struct{ client *rpc.Client }
func (p *ProviderRPC) Create(scheme *Scheme) string {
var resp string
err := p.client.Call("Plugin.Create", scheme, &resp)
if err != nil {
panic(err)
}
return resp
}
func (p *ProviderRPC) Update(scheme *Scheme) string {
var resp string
err := p.client.Call("Plugin.Update", scheme, &resp)
if err != nil {
panic(err)
}
return resp
}
func (p *ProviderRPC) Delete(scheme *Scheme) string {
var resp string
err := p.client.Call("Plugin.Delete", scheme, &resp)
if err != nil {
panic(err)
}
return resp
}
func (p *ProviderRPC) Get(id string) *Scheme {
var resp *Scheme
err := p.client.Call("Plugin.Get", id, resp)
if err != nil {
panic(err)
}
return resp
}
type ProviderRPCServer struct {
Impl Provider
}
func (s *ProviderRPCServer) Create(args *Scheme, resp *string) error {
*resp = s.Impl.Create(args)
return nil
}
type Scheme struct {
Default interface{}
}
type Provider interface {
Create(scheme *Scheme) string
Update(scheme *Scheme) string
Delete(scheme *Scheme) string
Get(id string) *Scheme
}
type ProviderPlugin struct {
Impl Provider
}
// Server
func (p *ProviderPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
return &ProviderRPCServer{Impl: p.Impl}, nil
}
// Client
func (ProviderPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
return &ProviderRPC{client: c}, nil
}
長々としていますが、ポイントは、シンプルで、Schema
という構造体があって、それを rpc で転送します。 Schema は例えば、リソース(Log Analytics とか、 SQL サーバーとか)の定義情報が入っているイメージです。それぞれによって違うので、 interface{} 型にしています。
type Scheme struct {
Default interface{}
}
type Provider interface {
Create(scheme *Scheme) string
Update(scheme *Scheme) string
Delete(scheme *Scheme) string
Get(id string) *Scheme
}
type ProviderPlugin struct {
Impl Provider
}
あとは、Azure だけではなくて、いろんなプロバイダが使えるように、インタフーェイスをきっていますが、ポイントは使えるメソッドは同じにしています。他には、RPC用のクライアントも書いています。もちろん先ほどのスキーマを使ってやりとりをしています。ProviderPlugin 構造体の中で使うようになっています。
type ProviderRPC struct{ client *rpc.Client }
func (p *ProviderRPC) Create(scheme *Scheme) string {
var resp string
err := p.client.Call("Plugin.Create", scheme, &resp)
if err != nil {
panic(err)
}
return resp
}
Provider
最終的にこの場所が呼ばれます。これは、Create の例です。本来ここに、Azure SDK を使って、実際にインフラを作成します。ここでは、Scheme からデータを受け取って、メッセージを表示しています。go-plugin の方では、サンプルが単純なデータのやりとりだけだったので、構造体にしてみました。
type AzureProvider struct {
logger hclog.Logger
}
func (p *AzureProvider) Create(scheme *provider.Scheme) string {
p.logger.Debug("Creating Azure Resources ...")
p.logger.Debug("message: " + scheme.Default.(string))
return "123"
}
ここは、サーバーが呼ばれると、"azure" というキーがきたら、実行されるようになっています。
func main() {
logger := hclog.New(&hclog.LoggerOptions{
Level: hclog.Trace,
Output: os.Stderr,
JSONFormat: true,
})
azureProvider := &AzureProvider{
logger: logger,
}
var pluginMap = map[string]plugin.Plugin{
"azure": &provider.ProviderPlugin{Impl: azureProvider},
}
logger.Debug("Now Azure Provider Serving")
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
})
}
client
プロバイダのコンフィグを書いて、サーバー側と同じ設定にします。
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "PROVIDER_PLUGIN",
MagicCookieValue: "azure",
}
var pluginMap = map[string]plugin.Plugin{
"azure": &provider.ProviderPlugin{},
}
func main() {
logger := hclog.New(&hclog.LoggerOptions{
Name: "client",
Output: os.Stdout,
Level: hclog.Debug,
})
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
Cmd: exec.Command("./plugin/azure_provider"),
Logger: logger,
})
defer client.Kill()
rpcClient, err := client.Client()
if err != nil {
log.Fatal(err)
}
raw, err := rpcClient.Dispense("azure")
if err != nil {
log.Fatal(err)
}
azureProvider := raw.(provider.Provider)
message := "Hello"
scheme := provider.Scheme{
message,
}
fmt.Println(azureProvider.Create(&scheme))
}
一旦、設定をしてしまうと、まるでローカル呼び出しのようにサーバーにアクセスできていますね!
サーバーと言っても、terraform では同じマシンにある前提ですが、それでもとても便利な仕組みです。
fmt.Println(azureProvider.Create(&scheme))
プログラムをコンパイル、実行する
- go-plugin-sample に全ソースコードを環境付きで置いておきました。
Plugin のコンパイル
$ go build -o ./plugin/azure_provider ./plugin/azure_provider.go
これで、plugin のバイナリができます。
main のコンパイル
$ go build -o terrappoi .
プラグインとは全く個別に、別の環境としてコンパイルして、バイナリができます。
実行してみましょう。
実行結果
バッチリ、RPC 通信して、うまくデータもやりとりできているのがわかりますね!すごくいい感じ!
invincible:go-plugin-sample ushio$ ./terrappoi
2017-09-25T02:25:43.516-0700 [DEBUG] client: starting plugin: path=./plugin/azure_provider args=[./plugin/azure_provider]
2017-09-25T02:25:43.521-0700 [DEBUG] client: waiting for RPC address: path=./plugin/azure_provider
2017-09-25T02:25:43.531-0700 [DEBUG] client.azure_provider: Now Azure Provider Serving: timestamp=2017-09-25T02:25:43.530-0700
2017-09-25T02:25:43.532-0700 [DEBUG] client.azure_provider: plugin address: timestamp=2017-09-25T02:25:43.532-0700 address=/var/folders/5b/rt6_lb397_3d92vp67g98pgr0000gn/T/plugin663688024 network=unix
2017-09-25T02:25:43.534-0700 [DEBUG] client.azure_provider: Creating Azure Resources ...: timestamp=2017-09-25T02:25:43.534-0700
2017-09-25T02:25:43.534-0700 [DEBUG] client.azure_provider: message: Hello: timestamp=2017-09-25T02:25:43.534-0700
123
2017-09-25T02:25:43.537-0700 [DEBUG] client: plugin process exited: path=./plugin/azure_provider
ハマったところ
最初下記のようなエラーが出ていました。
./terrappoi
2017-09-24T02:56:32.393-0700 [DEBUG] plugin: starting plugin: path=./plugin/azure_provider args=[./plugin/azure_provider]
2017-09-24T02:56:32.397-0700 [DEBUG] plugin: waiting for RPC address: path=./plugin/azure_provider
2017-09-24T02:56:32.406-0700 [DEBUG] plugin.azure_provider: Now Azure Provider Serving: timestamp=2017-09-24T02:56:32.406-0700
2017-09-24T02:56:32.406-0700 [DEBUG] plugin.azure_provider: plugin address: timestamp=2017-09-24T02:56:32.406-0700 address=/var/folders/5b/rt6_lb397_3d92vp67g98pgr0000gn/T/plugin930697090 network=unix
2017-09-24T02:56:32.410-0700 [DEBUG] plugin: plugin process exited: path=./plugin/azure_provider
panic: gob: local interface type *interface {} can only be decoded from remote interface type; received concrete type Scheme
goroutine 1 [running]:
github.com/TsuyoshiUshio/go-plugin-sample/common.(*ProviderRPC).Create(0xc420214140, 0x14548c0, 0xc42021c460, 0xc42021c460, 0xc420214140)
/Users/ushio/Codes/rpc/src/github.com/TsuyoshiUshio/go-plugin-sample/common/provider.go:15 +0xcc
main.main()
/Users/ushio/Codes/rpc/src/github.com/TsuyoshiUshio/go-plugin-sample/main.go:50 +0x4fd
特にこの部分。
panic: gob: local interface type *interface {} can only be decoded from remote interface type; received concrete type Scheme
なんだこれ?という感じですが、内部で、gob というパッケージを使っていますが、その gob は、シリアライズ、デシリアライズをします。その際に、型があっていないというエラーです。結構、型は完全一致しなくてもいいのですが、特定の時に、エラーが出るようです。下記のサイトがおすすめ。
Resource
上記以外