Go言語で実装するプラグイン機構

ソフトウェアに拡張性を持たせる時にプラグイン機構を持たせる事は一般的ですが、それを実現する方法は結構バラバラなのかなと思います。例えば、

  1. C 言語等の.so/.dll を読み込む方法
  2. Nodejs のような言語での単なる import
  3. TCP や Unix ソケットを利用して RPC 通信を行う方法

などが有るのかなと思います。1 番目・2 番目は、関数の呼び出し速度等のオーバーヘッドが少なく高速ですが、言語等の制約が大きくなる・メモリを共有することによるセキュリティリスクが発生します。そこで、提供するインターフェースを制約出来る場合は、3 番目の手法が多く使われるようです。

Go 言語で開発されている、hashicorp/terraform cloudfoundry/cli は共に 3 番目の RPC 通信でプラグイン機構を実装しています。その中でも terraform で使用されているプラグイン機構は、言語への依存が低いという特徴があり、興味深いので調べてみました。

hashicorp/go-plugin を試す

https://github.com/hashicorp/go-plugin

HashiCorp が開発しているライブラリで、Packer, Terraform, Nomad, Vault 等の内部で利用されています。RPC に gRPC を使用していて、ホスト側のライブラリは Go 言語ですが、クライアント(プラグイン側)の言語依存がほぼ無いのが特徴です。Go+Go/Go+Nodejs/Go+Python 等の構成が非常に簡単に実現できます。また、実装も非常に軽いです。

試しに Go のホストと、Scala のプラグインという構成で雑に試してみました。

サンプルリポジトリ: https://github.com/kamijin-fanta/go-plugin-example

このような簡単なサービスを実装してみました。サービスの定義・実装は素の gRPC として実装するので、言語に合ったツールを使用します。Scala でのサーバ実装には、protoc の代わりに ScalaPB のコード生成を使用しました。trait が自動的に生成されるので、それぞれのメソッド(ここでは SayHello)を実装するだけです。

// サービス定義
syntax = "proto3";
package com.example.protos;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}
// サーバ実装
class GreeterImpl extends GreeterGrpc.Greeter {
  override def sayHello(req: HelloRequest) = {
    val reply = HelloReply(message = "Hello " + req.name)
    Future.successful(reply)
  }
}
var Handshake = plugin.HandshakeConfig{
  ProtocolVersion:  1,
}
var PluginMap = map[string]plugin.Plugin{
  "greeter": &GreeterGRPCPlugin{},
}
client := plugin.NewClient(&plugin.ClientConfig{
  HandshakeConfig:  Handshake,
  Plugins:          PluginMap,
  Cmd:              exec.Command(os.Getenv("PLUGIN")),
  AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
})

rpcClient, _ := client.Client()
raw, _ := rpcClient.Dispense("greeter")
service := raw.(Greeter)
result, _ := service.Hello("fukuyama") // Hello fukuyama

多くのコードを省略しているので、詳細はリポジトリよりコードを探してください。

通信フロー

hashicorp/go-plugin では、TCP の gRPC を用いて通信を行いますが、ホストはポート番号等をどうやって知っているのでしょうか?簡単に図にしてみました。

  1. ホスト側はプラグインを ForkExec する
  2. プラグインはネゴシエーションのための文字列を標準出力でホストに伝える (API バージョン/接続プロトコル/Listen アドレスが含まれる)
  3. ホスト側の API バージョンと一致していれば、指定されたアドレスへ gRPC 接続を行う
  4. サービスのヘルスチェックを行う
  5. プラグインの通信を行う
  6. 通信が終了する際にホストはプラグインのプロセスを終了する

Negotiation

ネゴシエーションの為にプラグインからホストに送られる文字列は、1|1|tcp|127.0.0.1:1234|grpcの様な形式です。

CORE-PROTOCOL-VERSION | APP-PROTOCOL-VERSION | NETWORK-TYPE | NETWORK-ADDR | PROTOCOL
  • CORE-PROTOCOL-VERSION go-plugin 自体の API バージョンで、現在は 1
  • APP-PROTOCOL-VERSION ユーザが用意する API のバージョン
  • NETWORK-TYPE unix か tcp が指定可能
  • NETWORK-ADDR 接続先のアドレス
  • PROTOCOL grpc か netrpc が指定可能

Health Check

ヘルスチェックは、gRPC を通じて行われます。プラグイン側は Health サービスを実装している必要があります。とりあえず面倒なら常に status: ServingStatus.SERVING を返しておけば良いです。

message HealthCheckRequest {
  string service = 1;
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
  }
  ServingStatus status = 1;
}

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}

health.proto: https://github.com/grpc/grpc/blob/master/src/proto/grpc/health/v1/health.proto

雑記

これを調べるきっかけは、Grafana を調べている時に見つけた BackendPlugins でした。Grafana のプラグインの種類は他に、Palnel Plugin, Datasource Plugin の 2 種類有るのですが、いずれも JS で記述するフロントエンドのプラグインです。認証等のコードを差し込めるように用意されたのが Backend Plugins です。

Developing Backend Plugins: https://grafana.com/docs/plugins/developing/backend-plugins-guide/

このプラグインに採用されている仕組みが hashicorp/go-plugin でした。現在は DataSource と Renderer の 2 つの API しか用意されていないようですが、今後増えてくると拡張性が非常に増すのではないかと思います。

公式が用意している grafana/grafana-image-renderer プラグインは、TypeScript で全て書かれています。言語に依存されない利点を活用しています。Nodejs 上で gRPC サーバを用意し、今回のガイドと同じ様な実装を行ってサービスをプラグインとして提供しています。まだサードパーティのプラグインを受け入れるという感じでは無いのが悲しいですが、時間の問題だと思います。

↑ 水と油のリポジトリ

参考