GoのCDK for Terraformでcdktf-cliを使用せず、関数からリソースをデプロイする方法

7 min read

はじめに

CDK for Terraformは、クラウドリソースのプロビジョニングをよりプログラム的に、そして柔軟に行うための強力なツールです。多くのユーザーはcdktf-cliを使用してこれを行いますが、CLIを使用せずに関数から直接リソースをデプロイする方法も存在します。この記事では、その手法について詳しく解説します。CLIを介さずにCDK for Terraformを最大限に活用したい方は、ぜひ参考にしてください。 また、本記事の方法はGo言語でのみ動作します。(“github.com/hashicorp/terraform-exec/tfexec”が他の言語で実装されていないため。)

通常のCDKTFでのリソースデプロイ方法

EC2インスタンスをデプロイする例を記述します。

サンプルコード

以下はEC2インスタンスをデプロイするためのコードです。

package main

import (
  "github.com/aws/constructs-go/constructs/v10"
  "github.com/aws/jsii-runtime-go"
  "github.com/hashicorp/terraform-cdk-go/cdktf"
  "github.com/hashicorp/terraform-cdk-go/cdktf/providers/aws"
)

func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
  stack := cdktf.NewTerraformStack(scope, &id)

  // AWSプロバイダの定義
  aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
    Region: jsii.String("ap-northeast-1"),
  })

  // EC2インスタンスの作成
  aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
    Ami:          jsii.String("ami-0c55b159cbfafe1f0"),
    InstanceType: jsii.String("t2.micro"),
  })

  return stack
}

func main() {
  app := cdktf.NewApp(nil)

  NewMyStack(app, "MyStack")

  app.Synth(nil)
}

デプロイ方法

以下のコマンドを使用して、CDKTFリソースをデプロイします。

cdktf deploy

このようにcdktf-cliを使用してリソースの管理をすることができます。

しかし、コマンドを使用せず関数からCDKTFリソースをデプロイする場合は“os/exec”ライブラリをインポートし、cmd := exec.Command("cdktf", "deploy", "--auto-approve")のようなコードを記述する必要があります。 これはセキュリティリスクや変数を扱う際に適切な方法ではありません。

cdktf-cliを使用せずにCDKTFリソースをデプロイする方法

次にcdktf-cliを使用せずにCDTKFリソースをデプロイする方法を紹介します。

サンプルコード

以下は先ほどのEC2インスタンスをcdktf-cliを使用せずにデプロイできるようにコードをリファクタリングしたものです。

cdktf-cli は Terraform の実行をラップするツールですが、“github.com/hashicorp/terraform-exec/tfexec” というモジュールを使用することで、cdktf-cli を使わずに直接 Terraform を操作し、リソースをデプロイすることも可能です。このアプローチは、Terraform のプロセスをより細かく制御したい場合や、特定の環境で cdktf-cli の使用が制限されている場合に有用です。

import (
  "context"
  "fmt"
  "os"
  "path/filepath"

  "github.com/aws/constructs-go/constructs/v10"
  "github.com/aws/jsii-runtime-go"
  "github.com/hashicorp/terraform-cdk-go/cdktf"
  "github.com/hashicorp/go-version"
  "github.com/hashicorp/hc-install/product"
  "github.com/hashicorp/hc-install/releases"
  "github.com/hashicorp/terraform-exec/tfexec"
  
  "github.com/hashicorp/terraform-cdk-go/cdktf/providers/aws"
)

func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
  stack := cdktf.NewTerraformStack(scope, &id)

  // AWSプロバイダの定義
  aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
    Region: jsii.String("ap-northeast-1"),
  })

  // EC2インスタンスの作成
  aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
    Ami:          jsii.String("ami-0c55b159cbfafe1f0"),
    InstanceType: jsii.String("t2.micro"),
  })

  return stack
}

func main() {
  // 一時ディレクトリを作成
  tempDir, err := os.MkdirTemp("", "temp-")
  if err != nil {
    return nil, err
  }
  // 処理が終了したら一時ディレクトリを削除
  defer os.RemoveAll(tempDir)

  // CDKTFのアプリケーションを初期化
  app := cdktf.NewApp(&cdktf.AppOptions{Outdir: jsii.String(tempDir)})
  NewMyStack(app, "MyStack")
  app.Synth()
    // 関数終了時にJSII関連ファイルを削除します。
    defer jsii.Close()

  // Terraformのバージョンを指定してインストーラを初期化
  installer := &releases.ExactVersion{
    Product: product.Terraform,
    Version: version.Must(version.NewVersion("1.6.1")),
  }

  // Terraformをインストール
  execPath, err := installer.Install(context.Background())
  if err != nil {
    return nil, fmt.Errorf("Failed to install Terraform: %w", err)
  }

  // Terraformの作業ディレクトリを設定
  workingDir := filepath.Join(tempDir, "stacks", name)
  tf, err := tfexec.NewTerraform(workingDir, execPath)
  if err != nil {
      return nil, fmt.Errorf("Failed to setup Terraform: %w", err)
  }

  // Terraformの初期化
  err = tf.Init(context.Background(), tfexec.Upgrade(true))
  if err != nil {
    return nil, fmt.Errorf("Failed to initialize Terraform: %w", err)
  }

  // TerraformのApplyを実行してリソースをデプロイ
  err = tf.Apply(context.Background())
  if err != nil {
    return fmt.Errorf("Failed to apply infrastructure: %w", err)
  }
    return nil
}

デプロイ方法

以下のコマンドを使用してcdktf-cliを使用せずにCDKTFリソースをデプロイすることができます。

go run main.go

このようにgo標準のrunコマンドでリソースをデプロイすることが可能になりました。

しかし、これではまだ関数からのデプロイができないのでコードを改変していきます。

関数からCDKTFリソースをデプロイする方法

次に関数からCDTKFリソースをデプロイする方法を紹介します。

サンプルコード

Go言語において、“main.go”というファイル名や“main”という関数が含まれているプログラムは、実行可能なプログラムとして特別視されます。このため、“main”関数は他の関数のように呼び出すことができず、プログラムのエントリーポイントとして機能します。プログラムが実行されると、“main”関数が自動的に呼び出され、プログラムの実行が開始されます。したがって、“main”関数を通常の関数として利用することはできません。

そこで、ファイル名をmain.goから変更し、main関数をそれぞれ機能ごとの関数に分割します。

package main

import (
    "context"
    "fmt"
    "os"
    "path/filepath"

    "github.com/aws/constructs-go/constructs/v10"
    "github.com/aws/jsii-runtime-go"
    "github.com/hashicorp/terraform-cdk-go/cdktf"
    "github.com/hashicorp/terraform-exec/tfexec"
    "github.com/hashicorp/hc-install/releases"
    "github.com/hashicorp/hc-install/product"
    "github.com/hashicorp/go-version"
	  
    "github.com/hashicorp/terraform-cdk-go/cdktf/providers/aws"
)

func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
    stack := cdktf.NewTerraformStack(scope, &id)

    // AWSプロバイダの定義
    aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
        Region: jsii.String("ap-northeast-1"),
    })

    // EC2インスタンスの作成
    aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
        Ami:          jsii.String("ami-0c55b159cbfafe1f0"),
        InstanceType: jsii.String("t2.micro"),
    })

    return stack
}

// CreateTempDirは一時ディレクトリを作成し、そのパスを返します。
func CreateTempDir() (string, error) {
    // 一時ディレクトリを作成します。プレフィックスは "temp-" です。
    tempDir, err := os.MkdirTemp("", "temp-")
    if err != nil {
        // ディレクトリの作成中にエラーが発生した場合、エラーを返します。
        return "", fmt.Errorf("Failed to create temporary directory: %w", err)
    }
    // 一時ディレクトリのパスを返します。
    return tempDir, nil
}

// SetupTerraformはTerraformのセットアップを行い、tfexec.Terraformオブジェクトを返します。
func SetupTerraform(tempDir, name string) (*tfexec.Terraform, error) {
    const terraformVersion = "1.6.1" // 使用するTerraformのバージョンを指定します。

    // Terraformの作業ディレクトリを設定します。
    workingDir := filepath.Join(tempDir, "stacks", stackName)

    // 指定されたバージョンのTerraformをインストールするためのインストーラーを作成します。
    installer := &releases.ExactVersion{
		InstallDir: workingDir,
		Product: product.Terraform,
		Version: version.Must(version.NewVersion(terraformVersion)),
	}

    // Terraformのバイナリをインストールし、そのパスを取得します。
    execPath, err := installer.Install(context.Background())
    if err != nil {
        // インストール中にエラーが発生した場合、エラーを返します。
        return nil, fmt.Errorf("Failed to install Terraform: %w", err)
    }
    
    // tfexecを使用して、Terraformの新しいインスタンスを作成します。
    tf, err := tfexec.NewTerraform(workingDir, execPath)
    if err != nil {
        // インスタンス作成中にエラーが発生した場合、エラーを返します。
        return nil, fmt.Errorf("Failed to setup Terraform: %w", err)
    }

    // Terraformを初期化します。このステップでは、Terraformファイルが準備され、必要なプラグインがインストールされます。
    err = tf.Init(context.Background(), tfexec.Upgrade(true))
    if err != nil {
        // 初期化中にエラーが発生した場合、エラーを返します。
        return nil, fmt.Errorf("Failed to initialize Terraform: %w", err)
    }

    // セットアップされたTerraformのインスタンスを返します。
    return tf, nil
}

// CreateInfrastructureは指定されたパラメータでインフラストラクチャを作成します。
func CreateInfrastructure(stack_name string) error {
    // 一時ディレクトリを作成します。
    tempDir, err := CreateTempDir()
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // 関数の終了時に一時ディレクトリを削除します。
    defer os.RemoveAll(tempDir)

    // 新しいCDKTFアプリケーションを作成します。
    app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
    // スタックを作成し、アプリケーションに追加します。
    NewMyStack(app, stack_name)
    app.Synth()
    // 関数終了時にJSII関連ファイルを削除します。
    defer jsii.Close()

    // Terraformをセットアップします。
    tf, err := SetupTerraform(tempDir, stack_name)
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // Terraformを使用してインフラストラクチャを適用(作成)します。
    err = tf.Apply(context.Background())
    if err != nil {
        // 適用中にエラーが発生した場合、そのエラーを返します。
        return fmt.Errorf("Failed to apply infrastructure: %w", err)
    }
    // 成功した場合、nilを返します。
    return nil
}

// DestroyInfrastructureは指定されたパラメータでインフラストラクチャを破棄します。
func DestroyInfrastructure(stack_name string) error {
    // 一時ディレクトリを作成します。
    tempDir, err := CreateTempDir()
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // 関数の終了時に一時ディレクトリを削除します。
    defer os.RemoveAll(tempDir)

    // 新しいCDKTFアプリケーションを作成します。
    app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
    // スタックを作成し、アプリケーションに追加します。
    NewMyStack(app, stack_name)
    app.Synth()
    // 関数終了時にJSII関連ファイルを削除します。
    defer jsii.Close()

    // Terraformをセットアップします。
    tf, err := SetupTerraform(tempDir, stack_name)
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // Terraformを使用してインフラストラクチャを破棄します。
    err = tf.Destroy(context.Background())
    if err != nil {
        // 破棄中にエラーが発生した場合、そのエラーを返します。
        return fmt.Errorf("Failed to destroy infrastructure: %w", err)
    }
    // 成功した場合、nilを返します。
    return nil
}

デプロイ方法

別のファイルから関数を呼び出す形式でデプロイできるようになります。 ただし、今回はmain.goと同じ階層にinframgt.goが配置されているものとします。

package main

import (
	"fmt"
)

func main() {
	// ここでは、Stack名として "MyStack" を使用しています。
	stackName := "MyStack"

	// インフラストラクチャを作成します。スタック名を動的に指定できるようにしています。
	err := CreateInfrastructure(stackName)
	if err != nil {
		// インフラストラクチャの作成中にエラーが発生した場合、エラーメッセージを出力します。
		fmt.Printf("Failed to create infrastructure: %s\n", err.Error())
		return
	}

	// インフラストラクチャが正常に作成されたことを示すメッセージを出力します。
	fmt.Println("Infrastructure created successfully!")
}

このように別ファイルなどから関数を呼び出すことでCDKTFリソースをデプロイすることが可能となります。 これを利用することにより、gRPCのコールからリソースの制御を行ったりと柔軟なコーディングが可能になります。

おまけ

リソースパラメータの動的化

上記のコードではNewMyStack関数内でEC2インスタンス作成のパラメータがハードコーディングされています。しかし、以下のような変更を加えることでパラメータを動的に指定することができるようになります。

~~~~

// EC2InstanceOptions はEC2インスタンスの設定オプションを定義するための構造体です。
type EC2InstanceOptions struct {
    Ami          string // Amazon Machine Image ID
    InstanceType string // EC2インスタンスタイプ(例:t2.micro)
}

// NewMyStack はTerraformスタックを作成し、AWSプロバイダとEC2インスタンスを設定します。
// 引数には、スタックのスコープ、スタックID、およびEC2インスタンスのオプションが含まれます。
func NewMyStack(scope constructs.Construct, id string, ec2Options EC2InstanceOptions) cdktf.TerraformStack {
    stack := cdktf.NewTerraformStack(scope, &id)

    // AWSプロバイダの定義
    aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
        Region: jsii.String("ap-northeast-1"), // AWSリージョンの設定
    })

    // EC2インスタンスの作成
    // EC2InstanceOptionsから受け取ったパラメータを使用して、EC2インスタンスを設定します。
    aws.NewInstance(stack, jsii.String("Ec2Instance"), &aws.InstanceConfig{
        Ami:          jsii.String(ec2Options.Ami),          // AMI ID
        InstanceType: jsii.String(ec2Options.InstanceType), // インスタンスタイプ
    })

    return stack
}

~~~~

// CreateInfrastructureは指定されたパラメータでインフラストラクチャを作成します。
func CreateInfrastructure(stack_name string, ec2Options EC2InstanceOptions) error {
    // 一時ディレクトリを作成します。
    tempDir, err := CreateTempDir()
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // 関数の終了時に一時ディレクトリを削除します。
    defer os.RemoveAll(tempDir)

    // 新しいCDKTFアプリケーションを作成します。
    app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
    // スタックを作成し、アプリケーションに追加します。EC2インスタンスのオプションも渡します。
    NewMyStack(app, stack_name, ec2Options)
    app.Synth()
    // 関数終了時にJSII関連ファイルを削除します。
    defer jsii.Close()

    // Terraformをセットアップします。
    tf, err := SetupTerraform(tempDir, stack_name)
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // Terraformを使用してインフラストラクチャを適用(作成)します。
    err = tf.Apply(context.Background())
    if err != nil {
        // 適用中にエラーが発生した場合、そのエラーを返します。
        return fmt.Errorf("Failed to apply infrastructure: %w", err)
    }
    // 成功した場合、nilを返します。
    return nil
}

// DestroyInfrastructureは指定されたパラメータでインフラストラクチャを破棄します。
func DestroyInfrastructure(stack_name string, ec2Options EC2InstanceOptions) error {
    // 一時ディレクトリを作成します。
    tempDir, err := CreateTempDir()
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // 関数の終了時に一時ディレクトリを削除します。
    defer os.RemoveAll(tempDir)

    // 新しいCDKTFアプリケーションを作成します。
    app := cdktf.NewApp(&cdktf.AppConfig{Outdir: jsii.String(tempDir)})
    // スタックを作成し、アプリケーションに追加します。EC2インスタンスのオプションも渡します。
    NewMyStack(app, stack_name, ec2Options)
    app.Synth()
    // 関数終了時にJSII関連ファイルを削除します。
    defer jsii.Close()

    // Terraformをセットアップします。
    tf, err := SetupTerraform(tempDir, stack_name)
    if err != nil {
        // エラーが発生した場合、そのエラーを返します。
        return err
    }
    // Terraformを使用してインフラストラクチャを破棄します。
    err = tf.Destroy(context.Background())
    if err != nil {
        // 破棄中にエラーが発生した場合、そのエラーを返します。
        return fmt.Errorf("Failed to destroy infrastructure: %w", err)
    }
    // 成功した場合、nilを返します。
    return nil
}

それに合わせて呼び出す関数も以下のように変更してください。

package main

import (
	"fmt"
)

func main() {
	// ここでは、Stack名として "MyStack" を使用しています。
	stackName := "MyStack"

	// EC2インスタンスのオプションを設定します。
	ec2Options := EC2InstanceOptions{
		Ami:          "ami-0c55b159cbfafe1f0",
		InstanceType: "t2.micro",
	}

	// インフラストラクチャを作成します。スタック名とEC2インスタンスのオプションを動的に指定できるようにしています。
	err := CreateInfrastructure(stackName, ec2Options)
	if err != nil {
		// インフラストラクチャの作成中にエラーが発生した場合、エラーメッセージを出力します。
		fmt.Printf("Failed to create infrastructure: %s\n", err.Error())
		return
	}

	// インフラストラクチャが正常に作成されたことを示すメッセージを出力します。
	fmt.Println("Infrastructure created successfully!")
}

このようにリソースのパラメータを動的に指定することができます。

おわりに

私がこのコードを作成した背景には、gRPCのコールを通じてCDKTFリソースをデプロイしたいという目的があります。当初はcmd := exec.Command("cdktf", "deploy", "--auto-approve")のようなコマンドを使用してデプロイ管理を考えていましたが、これにはいくつかの問題が伴います。第一に、外部コマンドを実行することは潜在的なセキュリティリスクを孕んでおり、特にインフラストラクチャのコードが関わる場合、そのリスクはさらに高まります。第二に、このアプローチはコードの管理を複雑にする可能性があります。コマンドの実行は、エラーハンドリング、出力の解析、環境の違い、そしてバージョン管理など、多くの追加的な考慮事項を必要とします。これらの理由から、私はプログラム内で直接リソースをデプロイする方法を探求し、その結果、このコードを作成しました。これにより、デプロイプロセスがより安全かつ管理しやすくなると考えています。

追記(2023/11/03)

関数を実行するたびに/tmp以下にファイルが保存されてしまう問題

root@82ceb385ab4e:/app# ls /tmp
jsii-kernel-5f3e7F  jsii-runtime.1995419442  jsii-runtime.4233241251  jsii-runtime.868038890  terraform_1.6.1_linux_amd64.zip1711922255
jsii-kernel-nCEcR5  jsii-runtime.2858332123  jsii-runtime.85103194    temp-902113077          terraform_1.6.1_linux_amd64.zip3988491512

JSII関連ファイルの圧迫

関数を実行するたびに/tmp以下にJSII関連ファイルが保持されることを確認しました。 これは容量圧迫を招くため、解決する必要があります。

これは以下のIssueにて起票されている問題でした。

これらのIssueを見ると、defer jsii.Close()を関数内に含めることで、CDKTFプロセスの実行後、JSII関連ファイルを削除できるようです。

これをもとに本記事は修正を加えております。

terraform-exec関連ファイルの圧迫

JSIIと同様に関数を実行するたびに/tmp以下にTerraform関連ファイルが保持されることを確認しました。 Terraform関連ファイルはZIPファイルと解凍して出現するバイナリファイルでした。 TerraformバイナリファイルはCDKTFのtempDir内に含め、関数実行後自動削除されるように変更しました。 しかし、TerraformのZIPファイルは以下Issueで挙げられているように削除する機構が用意されていないようです。

ZIPファイルの削除機構は自身で作成する必要があるかもしれません。

参考にさせていただいたサイト

Deploy Infrastructure using CDK for Terraform with Go