READYFOR Tech Blog

READYFOR のエンジニアブログ

Terraform のテストを Golang で書く!

こんにちは。READYFOR 株式会社で SRE として働いている ジェダイ・パンくず🚀 と申します。

突然ですが、皆さんの現場ではインフラのテストの仕組みは導入済みですか?我々はまだ出来ていません(笑)。

READYFOR では IaaS として AWS を、IaC として Terraform を導入しているのですがコードのテスト・コードによってデプロイされた状態のテストを何かしらの仕組みを使って実践したいなぁと考え、今調査している最中です。

今回は Terraform のテストを書くツール Terratest について調べた内容を皆さんに共有したいと思っています。また AWS の状態をテストする仕組みとしてよく知られている Awspec との比較も行っていきたいと思っています。

必要になるモノ

事前に必要になるソフトウェアの一覧です。

  • Golang (requires version >= 1.13)
  • Ruby (required by Awspec)
  • Awspec

今回ターゲットにする AWS インフラと Terraform コード

任意の VPC, サブネット上に ECS クラスタ・サービス・タスクを作る Terraform コードを前提にテストを実施していきたいと思います。VPC やサブネットは事前に作成したモノを利用 (terraform の data や variable を用いる) します。

今回 AWS インフラをデプロイするために用意した main.tf と output.tf です。(ID などはマスクさせて頂きました) Terratest のテストを行う際に事前に AWS インフラがデプロイされている必要は無いためここでは terraform apply しません。

main.tf

terraform {
  required_version = ">= 0.12"
}

# ------------------------------------------------
# variables の設定
# ------------------------------------------------
variable "vpc_id" {
  default = "****"
}

variable "cluster_name" {
  description = "example"
  type        = string
  default     = "example"
}

variable "service_name" {
  description = "example"
  type        = string
  default     = "example"
}

# ------------------------------------------------
# 既存 Resource の指定
# ------------------------------------------------
data "aws_vpc" "example" {
  id = var.vpc_id
}

data "aws_subnet_ids" "all" {
  vpc_id = data.aws_vpc.example.id

  filter {
    name   = "tag:Name"
    values = ["rf-sandbox-***-private0-sb", " rf-sandbox-***--private0-sb"]
  }
}

# ------------------------------------------------
# 新規 Resource の作成
# ------------------------------------------------
resource "aws_ecs_cluster" "example" {
  name = var.cluster_name
}

resource "aws_ecs_service" "example" {
  name            = var.service_name
  cluster         = aws_ecs_cluster.example.arn
  task_definition = aws_ecs_task_definition.example.arn
  desired_count   = 0
  launch_type     = "FARGATE"

  network_configuration {
    subnets = data.aws_subnet_ids.all.ids
  }
}

resource "aws_ecs_task_definition" "example" {
  family                   = "terratest"
  network_mode             = "awsvpc"
  cpu                      = 256
  memory                   = 512
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.execution.arn
  container_definitions    = <<-JSON
    [
      {
        "image": "terraterst-example",
        "name": "terratest",
        "networkMode": "awsvpc"
      }
    ]
JSON
}

resource "aws_iam_role" "execution" {
  name               = "${var.cluster_name}-ecs-execution"
  assume_role_policy = data.aws_iam_policy_document.assume-execution.json
}

resource "aws_iam_role_policy_attachment" "execution" {
  role       = aws_iam_role.execution.id
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

data "aws_iam_policy_document" "assume-execution" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

output.tf

output "task_definition" {
  value = aws_ecs_task_definition.example.arn
}

Terratest のテストを書いてみる

テストを書く

次に Terratest のテストを書いていきます。

package test

import (
    "fmt"
    "testing"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/terraform"

    awsSDK "github.com/aws/aws-sdk-go/aws"
    "github.com/stretchr/testify/assert"
)

func TestTerraformAwsEcs(t *testing.T) {
    t.Parallel()

    expectedClusterName := fmt.Sprintf("example")
    expectedServiceName := fmt.Sprintf("example")

    // テストで起動するリージョンを複数指定し、どのリージョンでも起動することを確認する
    // awsRegion := aws.GetRandomStableRegion(t, []string{"us-east-1", "ap-northeast-1"}, nil)
    awsRegion := "ap-northeast-1"

    terraformOptions := &terraform.Options{
        TerraformDir: "../",

        Vars: map[string]interface{}{
            "cluster_name": expectedClusterName,
            "service_name": expectedServiceName,
        },

        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": awsRegion,
        },
    }

    defer terraform.Destroy(t, terraformOptions)

    if _, err := terraform.InitE(t, terraformOptions); err != nil {
        fmt.Printf("Terraform Init Error.")
    }

    if _, err := terraform.ApplyE(t, terraformOptions); err != nil {
        fmt.Printf("Terraform Apply Error.")
    }

    taskDefinition := terraform.Output(t, terraformOptions, "task_definition")

    // 各テスト (次項 "各テストの説明" を参照のこと)

    // (1) クラスタ周りのテスト
    // Reference: https://docs.aws.amazon.com/ja_jp/sdk-for-go/v1/api/service.ecs.Cluster.html
    cluster := aws.GetEcsCluster(t, awsRegion, expectedClusterName)
    assert.Equal(t, int64(1), awsSDK.Int64Value(cluster.ActiveServicesCount))
    assert.Equal(t, expectedClusterName, awsSDK.StringValue(cluster.ClusterName))
    assert.Equal(t, int64(0), awsSDK.Int64Value(cluster.PendingTasksCount))

    // (2) ECS サービス周りのテスト
    // Reference: https://docs.aws.amazon.com/ja_jp/sdk-for-go/v1/api/service.ecs.Service.html
    service := aws.GetEcsService(t, awsRegion, expectedClusterName, expectedServiceName)
    assert.Equal(t, int64(0), awsSDK.Int64Value(service.DesiredCount))
    assert.Equal(t, "FARGATE", awsSDK.StringValue(service.LaunchType))
    assert.Equal(t, expectedServiceName, awsSDK.StringValue(service.ServiceName))
    assert.Equal(t, "LATEST", awsSDK.StringValue(service.PlatformVersion))

    // (3) ECS Task 周りのテスト
    // Reference: https://docs.aws.amazon.com/ja_jp/sdk-for-go/v1/api/service.ecs.TaskDefinition.html
    task := aws.GetEcsTaskDefinition(t, awsRegion, taskDefinition)
    assert.Equal(t, "256", awsSDK.StringValue(task.Cpu))
    assert.Equal(t, "512", awsSDK.StringValue(task.Memory))
    assert.Equal(t, "awsvpc", awsSDK.StringValue(task.NetworkMode))
}

コードの説明をしていきます。

  • import 行で 'terratest' と 'testify/assert' をロードしています
  • expectedClusterName, expectedServiceName で期待するクラスタ名・サービス名を記しています
  • awsRegion でテストの際に AWS インフラをデプロイするリージョンを指定しています
  • defer でテストが終わった後の処理として Destroy することを明記しています
  • ECS クラスタ・サービス・タスク毎にテストが記されています (次項)

各テストの説明

assert 文で Terraform によってデプロイされた AWS インフラの状態でテストしています。ここではそれぞれのテストの意味を説明していきます。

(1) ECS クラスタ

テストされるモノ 期待値
アクティブな ECS サービス数 1
ECS クラスタ名 expectedClusterName
ペンディングタスク数 0

(2) ECS サービス

テストされるモノ 期待値
ECS DesiredCount 0
ECS ローンチタイプ 'FARGATE'
ECS サービス名 expectedServiceName

(3) ECS タスク

テストされるモノ 期待値
タスク定義で記されている CPU ユニット数 256
タスク定義で記されているメモリ量 512
タスク定義で記されているネットワークモード 'awsvpc'

テストの記述方法

テストの1行をピックアップしてテストの書き方について説明します。

assert.Equal(t, int64(1), awsSDK.Int64Value(cluster.ActiveServicesCount))

整数値: 1 と awsSDK.Int64Value(cluster.ActiveServicesCount) が同一であることをテストしている行ですが、cluster.ActiveServicesCount はどの様に知れば良いでしょう?答えとしては 'aws-sdk-go' のドキュメント (下記: クラスタ・サービス・タスク) を確認しつつテストを書いていく作業が必要になります。(この時にエディタの補完機能を使っていると容易に導き出せます)

テストの実行

上記で書いたテストを test ディレクトリに配置します。ディレクトリ構造は下記のようになります。

.
├── main.tf
├── output.tf
└── test
    └── terraform-aws-ecs_test.go

では go コマンドを使ってテストを実行します。

cd test
go mod init test
go test

結果、全てのテストが終了すると結果の末尾に下記のようなログが出力されます。この時に terraform apply, テスト実行, terraform destroy が処理されるので、テスト完了までしばらく待つ必要があります。

PASS
ok      test    80.068s

失敗するケースとして誤ったテストを書きテストを実行すると下記のようなログが得られます。

--- FAIL: TestTerraformAwsEcs (91.04s)
    terraform-aws-ecs_test.go:60:
                Error Trace:    terraform-aws-ecs_test.go:60
                Error:          Not equal:
                                expected: "511"
                                actual  : "512"

                                Diff:
                                --- Expected
                                +++ Actual
                                @@ -1 +1 @@
                                -511
                                +512
                Test:           TestTerraformAwsEcs
FAIL
exit status 1
FAIL    test    91.344s

上記の場合、ECS タスクに定義されたメモリ量が期待したものと異なっていたとエラー出力されています。

Awspec のテストを書いてみる

Awspec は国内のエンジニアの方が開発した AWS 状態をテストするツールです。海外の技術ブログやコードの中にも最近は登場するようになりました。今回 AWS インフラをデプロイするために用いた Terraform のコードに沿って Awspec テストを書いてみました。

require 'spec_helper'

describe ecs_cluster('example') do
  it { should exist }
  it { should be_active }
end

describe ecs_service('example'), cluster: 'example' do
  it { should exist }
  it { should be_active }
  its(:cluster) { should eq 'example' }
  its(:service_name) { should eq 'example' }
  its(:service_arn) { should eq 'arn:aws:ecs:ap-northeast-1:********:service/example' }
  its(:cluster_arn) { should eq 'arn:aws:ecs:ap-northeast-1:********:cluster/example' }
  its(:desired_count) { should eq 0 }
  its(:pending_count) { should eq 0 }
  its(:running_count) { should eq 0 }
end

describe ecs_task_definition('terratest') do
  it { should exist }
  it { should be_active }
  its(:family) { should eq 'terratest' }
end

※ awspec の Resource Type 'ecs_service' のドキュメントによると上記のような記述は書けませんが、, cluster: 'exmaple' を追記してテストを実施することが実際には出来ました。今回も main.tf でデフォルトではないクラスタを作成しているので上記のように記述しています。尚、awspec のドキュメントに対して下記の通り PR をしておきました。

github.com

Terratest, Awspec のテストを比較・考察

両者ともに Terraform のデプロイを行った結果としての AWS インフラの状態をテストしているツールだということがわかりました。Awspec に関しては Resource Types の開発が追いつかないとそもそもテストが書けませんが、Terratest は AWS 公式のライブラリ 'aws-sdk-go' に沿ってテストを書くのでその心配は必要なさそうです。また Terratest は Terraform によるデプロイとテスト、その後のインフラの削除まで自動で行ってくれるツールですが Awspec は Terraform でデプロイした結果をテストするツールということで、その辺りの違いもあるようです。また Terratest は Terraform コードの output 文で得られた値もテストに利用できます。

一方で Awspec は Test-Kitchen (https://github.com/test-kitchen/test-kitchen) と Test-Kitchen の Terraform Driver である Kitchen-Terraform (https://github.com/newcontext-oss/kitchen-terraform) と組合せて、下記の URL にあるようなことも出来るようです。

Terraform デプロイし tfstate の状態と同一になっているかテストする例

これは 'terraform-aws-modules/terraform-aws-eks' という EKS の Terraform Module なのですがその中のファイル 'test_eks.rb' で下記のように記述されていて

require 'awspec'

# rubocop:disable LineLength
state_file = 'terraform.tfstate.d/kitchen-terraform-default-aws/terraform.tfstate'
tf_state = JSON.parse(File.open(state_file).read)
region = tf_state['modules'][0]['outputs']['region']['value']
ENV['AWS_REGION'] = region

これは Terraform デプロイした結果として得られる tfstate ファイルの通りに AWS インフラがデプロイされているかどうかを Awspec でテストしているように見えます。(まだ手元で動かしたわけではないので間違っていたら指摘頂けると助かります)

また、このテストを実行している .kitchen.yml ファイルは下記のようになっています。

---
driver:
  name: "terraform"
  root_module_directory: "examples/basic"

provisioner:
  name: "terraform"

platforms:
  - name: "aws"

verifier:
  name: "awspec"

suites:
  - name: "default"
    verifier:
      name: "awspec"
      patterns:
      - "test/integration/default/test_eks.rb"

まとめ

Golang でテストを書くのか Rspec DSL でテストを書くのか、それによって選定してもいいですし、CI/CD のパイプラインの中でテストをどう自動実行するかを考慮して選定してもいいと思います。READYFOR では CI/CD に CodeBuild, CodePipeline を使っているので、その中でどう処理出来るのか、これから検討していきたいと思っています。