【コードリーディング】Terraform が Core と Provider との間で RPC 通信するところを覗いてみた👀

記事タイトルとURLをコピーする

こんにちは。OSS探訪部(部活の部)の紅林です。完全に yak shaving です。

様々なインフラリソースをコードで管理できるTerraformは、CoreとProviderとでRPC通信によりやりとりを行っています。Terraformは誰でもカスタムプロバイダを作ることができますが、作成したカスタムプロバイダはRPCサーバとして動作するため、Terraform Coreから見ると同じスキーマでどのプロバイダともやりとりできるというわけです。

今回、TerraformのCoreとProviderとの間でRPC通信する部分のソースコードを読んでみましたので、要点をまとめてみたいと思います。漠然と読んでいくのは途方もないので、 Hashicorp が提供しているローカルのファイルを管理するだけのシンプルな terraform-provider-local プロバイダで plan コマンドを実行する過程に絞って確認してみたいと思います。

環境やバージョンは以下の通りです:

Terraform Core(RPCクライアント側)

まず最初に、比較的シンプルなため、RPCのクライアント側となる Terraform Core がサーバにリクエストを送信する箇所を見ていきたいと思います。

plan コマンドの処理を読み進めていくと、以下の処理にたどり着きます(一部抜粋)。

func (n *NodeAbstractResourceInstance) refresh(ctx EvalContext, deposedKey states.DeposedKey, state *states.ResourceInstanceObject) (*states.ResourceInstanceObject, tfdiags.Diagnostics) {
    // 略
    provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider)
    // 略
    resp := provider.ReadResource(providerReq)

https://github.com/hashicorp/terraform/blob/v1.6.1/internal/terraform/node_resource_abstract_instance.go#L573

ここの ReadResource メソッドがRPCサーバに対して処理を命令している箇所と伺えます。レシーバとなっている providerproviders.Interface のインターフェースです。

さらに読み進めていくと、以下にたどり着きます。

func (p *GRPCProvider) ReadResource(r providers.ReadResourceRequest) (resp providers.ReadResourceResponse) {
    // 略
    protoResp, err := p.client.ReadResource(p.ctx, protoReq)

https://github.com/hashicorp/terraform/blob/v1.6.1/internal/plugin/grpc_provider.go#L400

ここの ReadResource メソッドは以下の処理を指しており、

type ProviderClient interface {
    // 略
    ReadResource(ctx context.Context, in *ReadResource_Request, opts ...grpc.CallOption) (*ReadResource_Response, error)
    // 略
}

https://github.com/hashicorp/terraform/blob/v1.6.1/internal/tfplugin5/tfplugin5.pb.go#L5100

このファイル tfplugin5.pb.go はRPCのプロトコル定義ファイルから、protocにより自動生成されたファイルです。すなわち、このメソッドを呼び出すことで、プロトコル定義に則ったリクエストを送信していると分かります。

それでは次に、RPC サーバとして Terraform Provider が動作する箇所を読んでいきたいと思います。

Terraform Provider(RPCサーバ側)

今回読み進める対象となる Terraform Providerは前述の通り、terraform-provider-local: v2.4.0とします。

ちなみに、Terraform Providerの実装方法として、現在、Terraform Plugin SDK を使う方法と Terraform Plugin Framework を使う方法とが存在します(後者が推奨されている)が、terraform-provider-localは後者が使われています。*1

サーバ側のシーケンスは複数のリポジトリのパッケージにまたがって実行され、少々複雑です。最初に全体の流れを図示しておきます。

RPCサービスの登録

まず、RPCサーバの起動の過程で、サービスが登録される部分を確認していきたいと思います。ProviderのバイナリがTerraform Coreから実行されると以下 main 関数が実行されます。

func main() {
    // 略
    err := providerserver.Serve(context.Background(), provider.New, providerserver.ServeOpts{
        Address:         "registry.terraform.io/hashicorp/local",
        Debug:           debug,
        ProtocolVersion: 5,
    })

https://github.com/hashicorp/terraform-provider-local/blob/v2.4.0/main.go#L22-L26

引用している Serve メソッドでサーバを起動していると伺えるため、ここを読み進めていきます。

レシーバのproviderserver は terraform-plugin-framework で定義されているパッケージです。 ここを追っていくと以下にたどりつきます。

func Serve(ctx context.Context, providerFunc func() provider.Provider, opts ServeOpts) error {
    err := opts.validate(ctx)
        // 略
        return tf5server.Serve(
            opts.Address,
            func() tfprotov5.ProviderServer {
                provider := providerFunc()

                return &proto5server.Server{
                    FrameworkServer: fwserver.Server{
                        Provider: provider,
                    },
                }
            },
            tf5serverOpts...,
        )

https://github.com/hashicorp/terraform-plugin-framework/blob/v1.4.1/providerserver/providerserver.go#L79-L105

tf5server.Serve 部分を読み進めていくと、 terraform-plugin-goリポジトリ *2の以下にたどり着きます。

func Serve(name string, serverFactory func() tfprotov5.ProviderServer, opts ...ServeOpt) error {
       // 略
    go plugin.Serve(serveConfig)

https://github.com/hashicorp/terraform-plugin-go/blob/v0.19.0/tfprotov5/tf5server/server.go#L243

さらに読み進めていくと、 go-plugin リポジトリ*3の以下にたどり着きます。

var ServerProtocol
switch protoType {
// 略
case ProtocolGRPC:
    // Create the gRPC server
    server = &GRPCServer{
        Plugins: pluginSet,
        Server:  opts.GRPCServer,
        TLS:     tlsConfig,
        Stdout:  stdout_r,
        Stderr:  stderr_r,
        DoneCh:  doneCh,
        logger:  logger,
    }
    // 略
func Serve(opts *ServeConfig) {
    // 略
    if err := server.Init(); err != nil {
          logger.Error("protocol init", "error", err)
          return
      }
    // 略
    go server.Serve(listener)

https://github.com/hashicorp/go-plugin/blob/v1.5.2/server.go#L231

引用の Init() メソッド と Serve() メソッドがポイントのようなので、ここを読み進んでいきます。 レシーバーとなっている server は同リポジトリで定義している plugin.GRPCServer です。

まず Init() を読み進めていくと、以下の定義にたどり着きます。

func (s *GRPCServer) Init() error {
    p, ok := raw.(GRPCPlugin)
    // 略
    if err := p.GRPCServer(s.broker, s.server); err != nil {
    // 略

https://github.com/hashicorp/go-plugin/blob/v1.5.2/grpc_server.go#L67-L111

p.GRPCServerpGRPCPlugin インターフェースを実装した構造体が指定されており、今回の場合は、 terraform-plugin-go リポジトリの tf5server.GRPCServer() が呼ばれます。

func (p *GRPCProviderPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
    tfplugin5.RegisterProviderServer(s, New(p.Name, p.GRPCProvider(), p.Opts...))
}

https://github.com/hashicorp/terraform-plugin-go/blob/v0.19.0/tfprotov5/tf5server/plugin.go#L47-L50

この tfplugin5.RegisterProviderServertfplugin5_grpc.pb.go で定義されており、このファイルは プロトコル定義ファイルから自動生成されたファイルです。すなわち、ここでRPCの各種サービス登録をしていると分かります。

RPCサーバの起動

戻って(再掲)、以下の go server.Serve(listener) 部分を読み進めていきます。

var ServerProtocol
switch protoType {
// 略
case ProtocolGRPC:
    // Create the gRPC server
    server = &GRPCServer{
        Plugins: pluginSet,
        Server:  opts.GRPCServer,
        TLS:     tlsConfig,
        Stdout:  stdout_r,
        Stderr:  stderr_r,
        DoneCh:  doneCh,
        logger:  logger,
    }
    // 略
func Serve(opts *ServeConfig) {
    // 略
    if err := server.Init(); err != nil {
          logger.Error("protocol init", "error", err)
          return
      }
    // 略
    go server.Serve(listener)

https://github.com/hashicorp/go-plugin/blob/v1.5.2/server.go#L231

進んでいくと、go-plugin リポジトリの以下にたどり着きます。

func (s *GRPCServer) Serve(lis net.Listener) {
    // 略
    err := s.server.Serve(lis)
    // 略
}

https://github.com/hashicorp/go-plugin/blob/v1.5.2/grpc_server.go#L151-L157

s.server は Go のGRPCパッケージ *grpc.Server です。すなわち、ここでRPCサーバを起動していると分かります。

メソッドの実行

RPCのサービス登録&サーバの起動までの流れをざっと確認しました。今度はRPCのリクエストに応答する部分を見ていきたいと思います。

Core部で確認したとおり、 plan コマンドでは ReadResource メソッドが呼ばれると分かったので、この部分を読んでいきます。

ReadResource はTerraformにおいてRPCで定義されたメソッドですが、各プロバイダのリポジトリのコードの中でこのメソッドが定義されているわけではありません。 ReadResource メソッドは直接的には terraform-plugin-go リポジトリ内のコードで定義されています。

以下の部分で、RPCで登録するメソッド名とハンドラー名を管理し、

var Provider_ServiceDesc = grpc.ServiceDesc{
    ServiceName: "tfplugin5.Provider",
    HandlerType: (*ProviderServer)(nil),
    Methods: []grpc.MethodDesc{
        // 略
        {
            MethodName: "ReadResource",
            Handler:    _Provider_ReadResource_Handler,
        },

https://github.com/hashicorp/terraform-plugin-go/blob/v0.19.0/tfprotov5/internal/tfplugin5/tfplugin5_grpc.pb.go#L536

以下でサービスとして登録します。

func RegisterProviderServer(s grpc.ServiceRegistrar, srv ProviderServer) {
    s.RegisterService(&Provider_ServiceDesc, srv)
}

https://github.com/hashicorp/terraform-plugin-go/blob/v0.19.0/tfprotov5/internal/tfplugin5/tfplugin5_grpc.pb.go#L295C1-L297C2

具体的な処理は同じファイルの中の _Provider_ReadResource_Handler メソッドで定義されます。

func _Provider_ReadResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {

https://github.com/hashicorp/terraform-plugin-go/blob/v0.19.0/tfprotov5/internal/tfplugin5/tfplugin5_grpc.pb.go#L425

このハンドラをさらに読み進めていくと、 terraform-plugin-goリポジトリの以下メソッドを経由し、

func (s *server) ReadResource(ctx context.Context, req *tfplugin5.ReadResource_Request) (*tfplugin5.ReadResource_Response, error) {

https://github.com/hashicorp/terraform-plugin-go/blob/v0.19.0/tfprotov5/tf5server/server.go#L772

次に terraform-plugin-framework に入り、

func (s *Server) ReadResource(ctx context.Context, proto5Req *tfprotov5.ReadResourceRequest) (*tfprotov5.ReadResourceResponse, error) {
    // 略
    s.FrameworkServer.ReadResource(ctx, fwReq, fwResp)

https://github.com/hashicorp/terraform-plugin-framework/blob/v1.4.1/internal/proto5server/server_readresource.go#L18

func (s *Server) ReadResource(ctx context.Context, req *ReadResourceRequest, resp *ReadResourceResponse) {
    // 略
    req.Resource.Read(ctx, readReq, &readResp)

https://github.com/hashicorp/terraform-plugin-framework/blob/v1.4.1/internal/fwserver/server_readresource.go#L101

最終的にカスタムプロバイダの Read メソッドにたどり着きます。ここで具体的な処理が進められるということとなります。

func (n *localFileResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {

https://github.com/hashicorp/terraform-provider-local/blob/v2.4.0/internal/provider/resource_local_file.go#L236

以上により、RPCサービスからカスタムプロバイダのメソッドが実行される流れを確認できました。

おわりに

今回、Terraform Core と Provider との間で具体的にどのような実装でRPC通信がなされるかが気になったので、調べてみました。

元々やろうとしていたことの脇道にそれすぎて、思っていたより入り込んでしまいまいした。途中でやめようかなぁと思ったりもしたのですが、疑問に思っていたことが解消した時の「ちょっとすっきりした」感覚が気持ちよくてやめられないのですよねぇ・・・。

*1:> We recommend using the framework to develop new provider functionality because it offers significant advantages as compared to the SDKv2. Home - Plugin Development: SDKv2 | Terraform | HashiCorp Developer

*2:terraform-plugin-go リポジトリは Terraform のプラグインプロトコルのラッパ相当を提供しているリポジトリです。 terraform-plugin-go provides low-level Go bindings for the Terraform plugin protocol, for integrations to be built upon. It strives to be a minimal possible abstraction on top of the protocol, only hiding the implementation details of the protocol while leaving its semantics unchanged. GitHub - hashicorp/terraform-plugin-go: A low-level Go binding for the Terraform protocol for integrations to be built on top of.

*3:go-plugin リポジトリはTerraformに限らず、GoのプロジェクトのRPCのラッパ相当の機能を提供するもののようです。 go-plugin is a Go (golang) plugin system over RPC. It is the plugin system that has been in use by HashiCorp tooling for over 4 years. While initially created for Packer, it is additionally in use by Terraform, Nomad, Vault, Boundary, and Waypoint. https://github.com/hashicorp/go-plugin

くればやし (記事一覧)