hashicorp/go-plugin を理解して Terraform provider の情報を得る

人間が Terraform の設定を書くときにはどんなリソースがあるか、どんなアトリビュートやブロックがあるかはドキュメントを読めば分かるけど、機械が Terraform の設定をチェックするときには構造化されたスキーマが欲しくなる。 Terraform の provider はどれも実行可能なバイナリのプラグインとして本体から分離されており、Terraform 本体がそのバイナリからスキーマ情報を得て型チェック等をしているはずなので、プラグインからどうやって情報を得ているのか調べた。 プラグインを書く側のドキュメントはまぁまぁあるんだけど、プラグインを起動する側のドキュメントはまとまったものがなくて調べるのに意外と苦労した……

hashicorp/go-plugin

Terraform や Packer 等の Hashicorp プロダクトのプラグインシステムには https://github.com/hashicorp/go-plugin が採用されている。 ざっくり言うと、プラグインを実行可能なバイナリとして表現し、バイナリを子プロセスとして実行すると gRPC サーバ *1 が起動するので、親プロセスはその gRPC サーバを介してプラグインとやりとりするようなアーキテクチャになっている。

プラグインの起動

起動方法は https://github.com/hashicorp/go-plugin/blob/master/docs/internals.md に書かれている…… と思いきや「You do not need to understand the internals of the handshake, unless you're building a go-plugin compatible plugin in another language」とのことで省略されている。 実装を読んでみると、handshake 用の特殊な環境変数と、プロトコルのバージョンを指定する環境変数を与えるとプラグインを起動できるようだった。 Terraform の場合は handshake 用の環境変数https://pkg.go.dev/github.com/hashicorp/terraform-plugin-sdk/v2@v2.3.0/plugin#pkg-variables で定義されている。この場合、TF_PLUGIN_MAGIC_COOKIE=d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2 という環境変数を指定すれば OK。 これだけでもプラグインの起動には成功するが、プロトコルのバージョンも指定しないとちゃんと通信できない。 Terraform の場合は https://github.com/hashicorp/terraform/tree/master/docs/plugin-protocol にあるように、現在のプロトコルバージョンは 5 である。 よって Terraform プラグインを起動するときに必要な環境変数PLUGIN_PROTOCOL_VERSIONS=5 TF_PLUGIN_MAGIC_COOKIE=d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2 になる。

プラグインとの通信

このように起動すると、プラグインは stdout に 1|5|unix|/tmp/plugin243689465|grpc| のような1行を出力する。 これの意味はちゃんと https://github.com/hashicorp/go-plugin/blob/master/docs/internals.md に書いてある。CORE-PROTOCOL-VERSION は1固定、APP-PROTOCOL-VERSION は PLUGIN_PROTOCOL_VERSIONS に指定した通りの5、NETWORK-TYPE はおそらく UNIX 環境では unix 固定、NETWORK-ADDR は UNIX 環境では UNIX domain socket のパス、PROTOCOL は Terraform プラグインでは grpc 固定である。 この UNIX domain socket が gRPC サーバになっているので、gRPC でプラグインと通信できるようになる。 Terraform の場合はどういう gRPC サーバになってるかは後述。

プラグインの終了

これもとくにドキュメントを見つけられなくて実装を読んだ。シェルからプラグインを起動すると C-c が潰されていて特殊な終了方法があると気付きやすくなっていて気が利いてる (?)。 実はプラグインが起動する gRPC サーバはプラグインの種類毎に実装されたサービスだけでなく、GRPCBroker と GRPCController と呼ばれるサービスも共通で実装している。 このうち GRPCController が Shutdown() という RPC を持ってるので、これを呼ぶと終了できる https://github.com/hashicorp/go-plugin/blob/v1.4.0/internal/plugin/grpc_controller.proto 。 retruns Empty になってるけど Shutdown() を呼ぶと gRPC サーバも終了するので Empty のレスポンスを正常に受け取れることは無い。 正常に処理が進めば Shutdown() でプラグインプロセスも終了するはずだけど、hashicorp/go-plugin の実装では一定のタイムアウト後にプロセスを kill しているみたい。

Terraform プラグインの gRPC サーバ

ドキュメントとしては https://github.com/hashicorp/terraform/tree/master/docs/plugin-protocol にあるんだけど、実際に実装で使われてるのはこっち https://github.com/hashicorp/terraform-plugin-go/blob/v0.2.0/tfprotov5/internal/tfplugin5/tfplugin5.proto 。 Provider の GetSchema() を呼ぶと resource、data source の一覧を取得することができるので、これでようやく目的を達成できる。

type のシリアライゼーション

上記の proto を見ていると、Attribute の type が bytes 型になっていることに気付く。 ここには Terraform 上の型 *2JSON 形式でシリアライズされた値が入っており、シリアライズの方法は https://github.com/hashicorp/terraform/blob/master/docs/plugin-protocol/object-wire-format.md#schemaattribute-mapping-rules-for-json に書かれている。 なので GetSchema() の結果からアトリビュートの型を知りたい場合はこれに従ってデシリアライズすると分かる。

Terraform の service discovery

ここまでで Terraform プラグインのバイナリがあればそこから情報を引き出すことができるようになったので、あとは Terraform プラグイン自体を手に入れる必要がある。 Terraform プラグインhttps://www.terraform.io/docs/internals/remote-service-discovery.html にある方法で見つけることができる。これはちゃんとドキュメントが書かれているのでその通りに実装すればいいだけ。 公式レジストリから手に入れるにはまず https://registry.terraform.io/.well-known/terraform.json にアクセスして providers.v1 のパスを知り、そこから https://www.terraform.io/docs/internals/provider-registry-protocol.htmlAPI で見つければいい。

dump-tf-schema

ここまでを実装したのがこれ。

github.com

Terraform プラグインの理解の参考にどうぞ。

*1:厳密には Go の net/rpc のサーバが起動するパターンもあるが、Terraform の場合は gRPC

*2:正確に言うと type constraint