電通総研 テックブログ

電通総研が運営する技術ブログ

AWS Providerのリポジトリを参考にTerraform Custom Providerを作ってみた

はじめに

こんにちは、クロスイノベーション本部エンジニアリングテクノロジーセンター、新卒1年目の大岡叡です。

今回は、TerraformでAuto Scaling Groupの挙動に悩んだ経験をきっかけに、AWS Providerを参考にしながらCustom Providerを実装してみた話をご紹介します。実装を通じて、Terraform Providerの構造や拡張の仕組みを理解することができました。

Terraformとは

私はTerraformを10月に部署配属されて初めて知ったので、知らない方のためにまずは簡単にTerraformについて説明します。
Terraformとは、HashiCorp社が提供するマルチクラウド対応のIaC(Infrastructure as Code)ツールです。
AWS、Azure、Google Cloudなど様々なクラウドリソースをコードで構築できます。
例えば、AWSで東京リージョンにVPCを作る場合は以下のとおりです。

Terraform Providerとは

Terraform公式サイトでは、Providerについて以下のように説明しています。

Terraform relies on plugins called providers to interact with cloud providers, SaaS providers, and other APIs.

Terraform configurations must declare which providers they require so that Terraform can install and use them. Additionally, some providers require configuration (like endpoint URLs or cloud regions) before they can be used.
(Terraform公式サイトより引用)

要するに、Terraform ProviderとはTerraformと外部サービスをつなぐためのプラグインです。Terraformそのものはリソース定義や状態管理を行い、実際にクラウドと通信してリソースを作成・変更するのはProviderの役割です。
例えば、TerraformがAWS上にリソースを構築する場合の大まかな流れは次のようになります。

  1. ユーザーがTerraformコード(HCL)でリソースを定義
  2. Terraformが対応するProviderをダウンロード
  3. ProviderがAWS SDK(Go言語用のAWS公式ライブラリ)を使って、AWS APIを呼び出す
  4. APIのレスポンスを加工し、Terraformのステートファイル(terraform.tfstate)に反映する

主要なクラウドのTerraform Providerは、GitHubオープンソースとして公開されており、実装の詳細を確認することができます。

また、Providerは自作することも可能であり、自作したProviderのことをCustom Providerと呼びます。

Custom Providerを実装したモチベーション

Terraformを使い始めたばかりのある日、Auto Scaling Group(ASG)の構築に取り組んでいました。
まずは、以下のコードでASGを作成しました。

resource "aws_launch_template" "main" {
  name_prefix = "main"
  image_id    = "ami-01205c30badb279ec“
  instance_type = "t2.micro"
}
resource "aws_autoscaling_group" "main" {
  name                = "main"
  max_size            = 3
  desired_capacity    = 2
  min_size            = 1
  health_check_type   = "EC2"
  vpc_zone_identifier = [aws_subnet.main.id, aws_subnet.secondary.id]
  launch_template {
    id = aws_launch_template.main.id
  }
}

問題はここからです。
起動テンプレートにセキュリティグループ(SG)の設定を入れ忘れていたことに気づき、以下のように修正しました。

resource "aws_launch_template" "main" {
  name_prefix   = "main"
  image_id      = "ami-01205c30badb279ec"
  instance_type = "t2.micro"
  vpc_security_group_ids = [var.ec2_security_group_id] #SGを追加
}

起動テンプレートの設定を反映させるために再度terraform applyを実行した後、マネジメントコンソールでASG管理下のEC2インスタンスをすべて削除し、ASGに新しいインスタンスを起動させました。
しかし、起動したインスタンスを確認するとdefaultのSGが設定されており、追加したSGが反映されていませんでした。

Terraform初心者だった私は、学習のために生成AIを使用しない縛りを自分に課していたこともあり、原因の特定に1時間ほどかかってしまいました。最終的に、原因は以下のAWS Providerの仕様だということが分かりました。

  • aws_autoscaling_groupリソースのlaunch_template ブロックでversion を指定しない場合、ASGは常に Defaultバージョンを参照する
  • aws_launch_templateリソースにおいてdefault_versionupdate_default_versionといった引数でDefaultバージョンの設定をしない限り、最初に作成されたバージョン1が参照され続ける

つまり、ASGに起動テンプレートの更新内容を反映させるには、version = "$Latest"を明示的に指定する必要があったのです。

resource "aws_autoscaling_group" "main" {
  name = "main"
  ...
  launch_template {
    id      = aws_launch_template.main.id
    version = "$Latest" # ← これを明示する必要があった!
  }
}

この仕様を理解したとき、「デフォルトでLatestを使ってくれたらいいのに……」と直感的に感じました。
一方で、Providerの仕様としては以下のような理由から妥当だとも思いました。

  • 運用上、意図的にDefaultで固定したいケースがある
  • AWSマネジメントコンソールでもASG作成時の初期値はDefault(1)となっている

そして、ちょうどその頃にProviderを自作できることを知ったこともあり、「versionをデフォルトで$LatestにしてくれるProviderを自分で作って勉強してみよう」と決意しました。
これが、今回のCustom Provider実装のモチベーションです。

Custom Providerの実装

目標と方針

今回作ったCustom Providerは「awsmini」という名前で、次のような方針で作りました。

  • aws_autoscaling_groupaws_launch_template のみをサポート
  • aws_autoscaling_grouplaunch_templateブロックのversionのデフォルト値を$Latestに設定
  • ローカルでビルドして、Terraformに直接読み込ませる方式を採用

実装は公式AWS ProviderのGitHubリポジトリを参考にしました。

ディレクトリ・ファイル構成

ディレクトリ・ファイル構成は以下のとおりです。
それぞれのファイルは、表1に示すように公式リポジトリを参考にして実装しました。

awsmini/
├── bin/                        # ビルド成果物
├── internal/
│   ├── provider/provider.go    # Provider定義
│   ├── conns/
│   │   ├── awsclient.go        # AWSクライアント生成・キャッシュ
│   │   └── config.go           # AWS認証情報設定
│   └── service/
│       ├── autoscaling/group.go       # ASGリソース実装
│       └── ec2/launch_template.go     # Launch Templateリソース実装
└── main.go                     # エントリポイント

表1. awsmini内のファイルと公式AWS Providerの対応関係

awsmini ファイル 公式リポジトリ内の参考ファイル
main.go main.go
internal/provider/provider.go internal/provider/sdkv2/provider.go
internal/conns/awsclient.go internal/conns/awsclient.go
internal/conns/config.go internal/conns/config.go
internal/service/autoscaling/group.go internal/service/autoscaling/group.go
internal/service/ec2/launch_template.go internal/service/ec2/ec2_launch_template.go

※注意:上記リンク先のソースコードは、今後のProviderアップデートによりコード構造が変更される可能性があります。
そのため、執筆時点(2025年11月)での参考情報としてご覧ください。

また、group.go 内でaws_autoscaling_grouplaunch_templateブロックのversion引数のデフォルト値を$Latestに設定しました。

"launch_template": {
                Type:     schema.TypeList,
                Optional: true,
                MaxItems: 1,
                Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                        "id": {
                            Type:     schema.TypeString,
                            Optional: true,
                            Computed: true,
                        },
                        "name": {
                            Type:     schema.TypeString,
                            Optional: true,
                            Computed: true,
                        },
                        "version": {
                            Type:     schema.TypeString,
                            Optional: true,
                            Default:  "$Latest",  // ここでデフォルト値をLatestに設定
                        },
                    },
                },
            },

ソースコード全体

group.golaunch_template.goのコードは分量が多いため、ここではそれ以外のファイルのみを掲載します。
前提として、awsminiディレクトリで以下のコマンドを実行しました(コマンド内のリポジトリは実際に存在するものではありません)。

go mod init github.com/torut/terraform-provider-awsmini
  • awsmini/main.go
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package main

import (
    "context"
    "flag"
    "log"

    "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
    "github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
    "github.com/torut/terraform-provider-awsmini/internal/provider"
)

func main() {
    var debugMode bool

    flag.BoolVar(&debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve")
    flag.Parse()

    opts := &plugin.ServeOpts{
        ProviderFunc: func() *schema.Provider {
            ctx := context.Background()
            p, err := provider.New(ctx)
            if err != nil {
                log.Fatalf("failed to create provider: %v", err)
            }
            return p
        },
    }

    if debugMode {
        ctx := context.Background()
        err := plugin.Debug(ctx, "local/awsmini", opts)
        if err != nil {
            log.Fatalf("failed to serve provider in debug mode: %v", err)
        }
        return
    }

    plugin.Serve(opts)
}
  • awsmini/internal/provider/provider.go
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package provider

import (
    "context"
    "fmt"

    "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
    "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
    "github.com/torut/terraform-provider-awsmini/internal/conns"
    "github.com/torut/terraform-provider-awsmini/internal/service/autoscaling"
    "github.com/torut/terraform-provider-awsmini/internal/service/ec2"
)

// New returns a new, initialized Terraform Plugin SDK v2-style provider instance.
// The provider instance is fully configured once the ConfigureContextFunc has been called.
func New(ctx context.Context) (*schema.Provider, error) {
    provider := &schema.Provider{
        Schema: map[string]*schema.Schema{
            "access_key": {
                Type:        schema.TypeString,
                Optional:    true,
                Description: "The access key for API operations. You can retrieve this from the 'Security & Credentials' section of the AWS console.",
            },
            "profile": {
                Type:        schema.TypeString,
                Optional:    true,
                Description: "The profile for API operations. If not set, the default profile created with `aws configure` will be used.",
            },
            "region": {
                Type:        schema.TypeString,
                Optional:    true,
                Description: "The region where AWS operations will take place. Examples are us-east-1, us-west-2, etc.",
            },
            "secret_key": {
                Type:        schema.TypeString,
                Optional:    true,
                Description: "The secret key for API operations. You can retrieve this from the 'Security & Credentials' section of the AWS console.",
            },
            "shared_config_files": {
                Type:        schema.TypeList,
                Optional:    true,
                Description: "List of paths to shared config files. If not set, defaults to [~/.aws/config].",
                Elem:        &schema.Schema{Type: schema.TypeString},
            },
            "shared_credentials_files": {
                Type:        schema.TypeList,
                Optional:    true,
                Description: "List of paths to shared credentials files. If not set, defaults to [~/.aws/credentials].",
                Elem:        &schema.Schema{Type: schema.TypeString},
            },
            "skip_credentials_validation": {
                Type:        schema.TypeBool,
                Optional:    true,
                Description: "Skip the credentials validation via STS API. Used for AWS API implementations that do not have STS available/implemented.",
            },
            "skip_region_validation": {
                Type:        schema.TypeBool,
                Optional:    true,
                Description: "Skip static validation of region name. Used by users of alternative AWS-like APIs or users w/ access to regions that are not public (yet).",
            },
            "skip_requesting_account_id": {
                Type:        schema.TypeBool,
                Optional:    true,
                Description: "Skip requesting the account ID. Used for AWS API implementations that do not have IAM/STS API and/or metadata API.",
            },
            "token": {
                Type:        schema.TypeString,
                Optional:    true,
                Description: "Session token for temporary credentials.",
            },
        },

        // Resources
        ResourcesMap: map[string]*schema.Resource{
            "awsmini_autoscaling_group": autoscaling.ResourceGroup(),
            "awsmini_launch_template":   ec2.ResourceLaunchTemplate(),
        },

        DataSourcesMap: map[string]*schema.Resource{},
    }

    provider.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
        return configure(ctx, d, provider.TerraformVersion)
    }

    return provider, nil
}

// configure initializes the AWS client
func configure(ctx context.Context, d *schema.ResourceData, terraformVersion string) (interface{}, diag.Diagnostics) {
    var diags diag.Diagnostics

    if terraformVersion == "" {
        terraformVersion = "0.11+compatible"
    }

    config := conns.Config{
        AccessKey:               d.Get("access_key").(string),
        Profile:                 d.Get("profile").(string),
        Region:                  d.Get("region").(string),
        SecretKey:               d.Get("secret_key").(string),
        SkipCredsValidation:     d.Get("skip_credentials_validation").(bool),
        SkipRegionValidation:    d.Get("skip_region_validation").(bool),
        SkipRequestingAccountId: d.Get("skip_requesting_account_id").(bool),
        TerraformVersion:        terraformVersion,
        Token:                   d.Get("token").(string),
    }

    // Handle shared config files
    if v, ok := d.GetOk("shared_config_files"); ok && len(v.([]interface{})) > 0 {
        config.SharedConfigFiles = expandStringList(v.([]interface{}))
    }

    // Handle shared credentials files
    if v, ok := d.GetOk("shared_credentials_files"); ok && len(v.([]interface{})) > 0 {
        config.SharedCredentialsFiles = expandStringList(v.([]interface{}))
    }

    // Initialize the AWS client
    client, err := config.ConfigureProvider(ctx)
    if err != nil {
        return nil, diag.FromErr(fmt.Errorf("error configuring AWS provider: %w", err))
    }

    return client, diags
}

// expandStringList expands a list of interfaces to a list of strings
func expandStringList(configured []interface{}) []string {
    vs := make([]string, 0, len(configured))
    for _, v := range configured {
        val, ok := v.(string)
        if ok && val != "" {
            vs = append(vs, val)
        }
    }
    return vs
}
  • awsmini/internal/conns/awsclient.go
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package conns

import (
    "context"
    "sync"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/autoscaling"
    "github.com/aws/aws-sdk-go-v2/service/ec2"
)

// AWSClient holds the AWS SDK clients
type AWSClient struct {
    awsConfig *aws.Config
    region    string
    lock      sync.Mutex

    // Service clients (cached)
    autoscalingClient *autoscaling.Client
    ec2Client         *ec2.Client
}

// AwsConfig returns a copy of the AWS configuration
func (c *AWSClient) AwsConfig(context.Context) aws.Config {
    return c.awsConfig.Copy()
}

// Region returns the configured AWS Region
func (c *AWSClient) Region(context.Context) string {
    return c.region
}

// AutoScalingClient returns the Auto Scaling service client
func (c *AWSClient) AutoScalingClient(ctx context.Context) *autoscaling.Client {
    c.lock.Lock()
    defer c.lock.Unlock()

    if c.autoscalingClient == nil {
        c.autoscalingClient = autoscaling.NewFromConfig(*c.awsConfig)
    }

    return c.autoscalingClient
}

// EC2Client returns the EC2 service client
func (c *AWSClient) EC2Client(ctx context.Context) *ec2.Client {
    c.lock.Lock()
    defer c.lock.Unlock()

    if c.ec2Client == nil {
        c.ec2Client = ec2.NewFromConfig(*c.awsConfig)
    }

    return c.ec2Client
}
  • awsmini/internal/conns/config.go
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package conns

import (
    "context"
    "fmt"
    "time"

    awsbase "github.com/hashicorp/aws-sdk-go-base/v2"
    basediag "github.com/hashicorp/aws-sdk-go-base/v2/diag"
    "github.com/hashicorp/aws-sdk-go-base/v2/logging"
)

// Config holds the provider configuration
type Config struct {
    AccessKey               string
    Profile                 string
    Region                  string
    SecretKey               string
    SharedConfigFiles       []string
    SharedCredentialsFiles  []string
    SkipCredsValidation     bool
    SkipRegionValidation    bool
    SkipRequestingAccountId bool
    TerraformVersion        string
    Token                   string
}

// ConfigureProvider configures and returns an AWS client
func (c *Config) ConfigureProvider(ctx context.Context) (*AWSClient, error) {
    ctx, logger := logging.NewTfLogger(ctx)

    const maxBackoff = 300 * time.Second

    awsbaseConfig := awsbase.Config{
        AccessKey: c.AccessKey,
        APNInfo: &awsbase.APNInfo{
            PartnerName: "HashiCorp",
            Products: []awsbase.UserAgentProduct{
                {Name: "Terraform", Version: c.TerraformVersion, Comment: "+https://www.terraform.io"},
                {Name: "terraform-provider-awsmini", Version: "0.0.1", Comment: "+https://github.com/torut/terraform-provider-awsmini"},
            },
        },
        CallerDocumentationURL:  "https://github.com/torut/terraform-provider-awsmini",
        CallerName:              "Terraform AWS Mini Provider",
        Logger:                  logger,
        MaxBackoff:              maxBackoff,
        MaxRetries:              25,
        Profile:                 c.Profile,
        Region:                  c.Region,
        SecretKey:               c.SecretKey,
        SkipCredsValidation:     c.SkipCredsValidation,
        SkipRequestingAccountId: c.SkipRequestingAccountId,
        Token:                   c.Token,
    }

    if len(c.SharedConfigFiles) != 0 {
        awsbaseConfig.SharedConfigFiles = c.SharedConfigFiles
    }

    if len(c.SharedCredentialsFiles) != 0 {
        awsbaseConfig.SharedCredentialsFiles = c.SharedCredentialsFiles
    }

    // Get AWS configuration
    ctx, cfg, awsDiags := awsbase.GetAwsConfig(ctx, &awsbaseConfig)

    if awsDiags.HasError() {
        return nil, fmt.Errorf("error configuring AWS: %s", diagsToString(awsDiags))
    }

    // Create AWS client
    client := &AWSClient{
        awsConfig: &cfg,
        region:    c.Region,
    }

    return client, nil
}

// diagsToString converts diagnostics to a string
func diagsToString(diags basediag.Diagnostics) string {
    var result string
    for _, d := range diags {
        if result != "" {
            result += "; "
        }
        result += fmt.Sprintf("%s: %s", d.Summary(), d.Detail())
    }
    return result
}

ビルドと設定

awsminiディレクトリで以下のコマンドを実行します。

go build -o .\bin\terraform-provider-awsmini_v0.0.1.exe .

適切な場所(Windowsならユーザーの%APPDATA%ディレクトリ、Macならユーザーのホームディレクトリ)にterraform.rcというファイルを作成して以下の内容を記述します。

provider_installation {
  dev_overrides {
    "local/awsmini" = "C:\\Users\\torut\\Desktop\\awsmini\\bin"
  }
}

これにより、Terraformがlocal/awsminiプロバイダーを指定されたローカルパスから読み込むようになります。(参考:Create a Terraform CLI configuration file

dev_overridesだとterraform initが使用できないため注意してください。
filesystem_mirrorを指定すれば、terraform initを使用してローカルのプロバイダーを参照できます。

Custom Providerでリソースを作った結果

以下のコードを実行してASGを作成しました。

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    awsmini = {
      source  = "local/awsmini"
      version = "0.0.1"
    }
  }
}
provider "awsmini" {
  region = "ap-northeast-1"
}

resource "awsmini_launch_template" "example" {
  name_prefix   = "awsmini-lt-"
  image_id      = "ami-0d52744d6551d851e"
  instance_type = "t3.micro"
 }

resource "awsmini_autoscaling_group" "example" {
  name                = "awsmini-asg-example"
  max_size            = 3
  desired_capacity    = 2
  min_size            = 1
  health_check_type   = "EC2"
  vpc_zone_identifier = ["subnet-0fcd0acfe38968bb2", "subnet-017fa0e9eb9e46c84"]
  launch_template {
    id      = awsmini_launch_template.example.id
    # versionは未指定
  }
}

実行した結果、version未指定でASGの起動テンプレートのバージョンが$Latestとして設定されていることを確認しました。

  • terraform planの出力(一部)
Terraform will perform the following actions:

  # awsmini_autoscaling_group.example will be created
  + resource "awsmini_autoscaling_group" "example" {
      + arn                       = (known after apply)
      + default_cooldown          = (known after apply)
      + desired_capacity          = 2
      + health_check_grace_period = 300
      + health_check_type         = "EC2"
      + id                        = (known after apply)
      + max_size                  = 3
      + min_size                  = 1
      + name                      = "awsmini-asg-example"
      + vpc_zone_identifier       = [
          + "subnet-017fa0e9eb9e46c84",
          + "subnet-0fcd0acfe38968bb2",
        ]

      + launch_template {
          + id      = (known after apply)
          + name    = (known after apply)
          + version = "$Latest"  
        }
    }
  • マネジメントコンソール

まとめ

今回は、公式のAWS Providerを参考にCustom Providerを実装し、aws_autoscaling_grouplaunch_templateブロックのversionをデフォルトで$Latestに設定できるようにしました。
実装を通して、Terraform Providerの内部構造や仕組みを理解し、公式リポジトリを読み解きながら拡張する経験を得られました。
今後、AWS Provider に課題を感じた際は、今回の経験を活かして Custom Provider を実装することで課題を解決したり、さらに理解が深まれば AWS Provider へのコントリビュートにも挑戦してみたいと思います。
この記事が、Custom Provider 実装に取り組む方の一助になれば幸いです。

ここまでお読みいただき、ありがとうございました。

私たちは一緒に働いてくれる仲間を募集しています!

電通総研 キャリア採用サイト 電通総研 新卒採用サイト

執筆:@ooka.toru
レビュー:@miyazawa.hibiki
Shodoで執筆されました