情報畑でつかまえてロゴ
本サイトは NTTテクノクロスが旬の IT をキーワードに
IT 部門が今知っておきたい最新テクノロジーに関する情報をお届けするサイトです

Terraform連載 第3回:Terraform v1.6のtestコマンドについてご紹介

「デジタルツイン」×「ネットワーク」事業部コラボ企画で話題にあげたTerraform。今回はv1.6のtestコマンドについてご紹介します。

terraform_banner.pngのサムネイル画像

はじめに

こんにちは、NTTテクノクロスの岩瀨です。

今回はTerraform v1.6で追加されると思われるtestコマンドについて取り上げてみたいと思います。

なお執筆時点(2023年8月末)ではv1.6はアルファビルドであり、正式版リリースまでに仕様が変わり、本記事の内容と差異が出る可能性もありますので、ご了承ください。
また本記事で引用しているURLも、Terraformの公式Webサイトのものではなく、TerraformのGithubリポジトリのURLとなります。

関連記事:
Terraform連載 第1回:いまさら聞けない、IaCってなに?~Terraform、IaSQLの紹介~
Terraform連載 第2回:Terraform v1.5の紹介&活用方法について考えてみた
「Terraform連載 第3回:Terraform v1.6のtestコマンドについてご紹介」
Terraform連載 第4回:for_eachの使い方
Terraform連載 第5回:module(モジュール)の紹介
Terraform連載 第6回:Terraform v1.7 removedブロックの紹介
Terraform連載 第7回:importブロックはmoduleのリソースも取り込めるか?

Terraform v1.6 のtestコマンドについて

Terraformのtestコマンドは、テストコード(実行結果が適切かどうかをチェックするためのコード)に基づいて、プロビジョニングしたインフラストラクチャが正しく構成されているかをテストするコマンドです。v1.5まではexperimental features(実験的な機能)として提供されてきましたが、v1.6で正式な機能になる見込みです。

v1.6ではテストコードの構成などもv1.5から大きく変わっており、後方互換性は無くなったように思われます。
今回紹介するのはv1.6の新しいtestコマンドのみとし、v1.5以前との比較は行わないこととします。

v1.6のtestコマンドは、大まかに以下の動作をするようです。

  • 1. 構成ディレクトリ内でテストコードが書かれたファイル(以下テストファイルとします)を探す
  • 2. 各テストファイルで指定された構成内でインフラストラクチャをプロビジョニングする(プロビジョニングを省略する=planのみでテストすることも可能)
  • 3. プロビジョニングされたインフラストラクチャに対してアサーションを実行する
  • 4. テストの終了時に、プロビジョニングされたインフラストラクチャを破棄する

実際にAWS上に環境を構築&テストを行い、修了後に削除する、という動作のようです。TerraformのテストツールとしてTerratestがありますが、これと似た機能が公式にサポートされた、という理解で良さそうです。

ではさっそく検証をしてみます。検証では執筆時点でのアルファビルド最新版 v1.6.0-alpha20230816 を使用しています。

準備1:テスト対象のインフラストラクチャコードを作成する

今回はEC2インスタンスを作成するコードを書いてみました。VPCおよびサブネットは、前回のTerraformのimportブロックの紹介記事で作成したものをそのまま流用します。tfstateファイルは、今回の検証分は前回分とは別で管理します。実行環境は引き続きCloudShellとなります。

今回作成したコードはVPCやサブネットを構成管理していないので、Dataソースを活用してAWS側から情報を取得してEC2作成時のパラメータとして参照させる構成としています。AMIもDataソースでAmazonLinux2023(arm64)の最新版を取得しています。またVariableブロックで変数も利用しています。

[terraform:provider.tf]

# provider.tf
 
provider "aws" {
 region = "ap-northeast-1"
}
terraform {
 backend "s3" {
  bucket = "terraform-state-management-tx"
  key    = "tf-test-03"
  region = "ap-northeast-1"
 }
 required_providers {
  aws = {
   source  = "hashicorp/aws"
   version = "~> 5.0"
  }
 }
}

[terraform:data.tf]

# data.tf
 
locals {
 az_name         = "ap-northeast-1a"
 subnet_tag_name = "nara-private-1a"
}
 
data "aws_vpc" "nara" {
 filter {
  name   = "tag:Name"
  values = [var.system_name]
 }
}
 
data "aws_subnet" "nara_private_1a" {
 availability_zone = local.az_name
 vpc_id            = data.aws_vpc.nara.id
 filter {
  name   = "tag:Name"
  values = ["*private*"]
 }
}
 
data "aws_ami" "al2023_arm64" {
 most_recent = true
 owners      = ["amazon"]
 filter {
  name   = "architecture"
  values = ["arm64"]
 }
 filter {
  name   = "name"
  values = ["al2023-ami-2023*"]
 }
}

[terraform:variables.tf]

# variables.tf
 
variable "environment" {
 type        = string
 description = "環境種別:dev, stg, prd のいずれかを指定(それぞれ開発環境、ステージング環境、本番環境)"
}
 
variable "system_name" {
 type        = string
 description = "システム名称:本システムの名称を指定。一意な名称にすること"
}

[terraform:ec2.tf]

# ec2.tf
 
locals {
 ec2_name     = "tftest"
 ec2_tag_name = join("-", [var.system_name, var.environment, local.ec2_name]) # これをEC2のNameタグに付加する
}
 
resource "aws_instance" "nara" {
 ami           = data.aws_ami.al2023_arm64.id
 instance_type = "t4g.micro"
 subnet_id     = data.aws_subnet.nara_private_1a.id
 root_block_device {
  volume_type = "gp3"
  volume_size = 10
  iops        = 3000
 }
 tags = {
  Name = local.ec2_tag_name
 }
}

[terraform:terraform.tfvars]

# terraform.tfvars
 
environment = "dev"
system_name = "nara"

このコードを用いてterraform planコマンドを実行し、正常に実行できることを確認しました(コードの構文チェックを行う目的のため、applyは行いません)。
nara-dev-tftestというNameタグのEC2インスタンスを、サブネットnara-private-1aに作成する実行計画が表示されています。

[bash]

$ terraform --version 
Terraform v1.6.0-alpha20230816
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.12.0
$ terraform plan 
data.aws_ami.al2023_arm64: Reading...
data.aws_vpc.nara: Reading...
data.aws_ami.al2023_arm64: Read complete after 1s [id=ami-0e0166ef4456f252a]
data.aws_vpc.nara: Read complete after 1s [id=vpc-0d22e95db506de346]
data.aws_subnet.nara_private_1a: Reading...
data.aws_subnet.nara_private_1a: Read complete after 0s [id=subnet-03c0e41a059737435]
 
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
 + create
 
Terraform will perform the following actions:
 
 # aws_instance.nara will be created
 + resource "aws_instance" "nara" {
   + ami                                  = "ami-0e0166ef4456f252a"
   + arn                                  = (known after apply)
   + associate_public_ip_address          = (known after apply)
   + availability_zone                    = (known after apply)
   + cpu_core_count                       = (known after apply)
   + cpu_threads_per_core                 = (known after apply)
   + disable_api_stop                     = (known after apply)
   + disable_api_termination              = (known after apply)
   + ebs_optimized                        = (known after apply)
   + get_password_data                    = false
   + host_id                              = (known after apply)
   + host_resource_group_arn              = (known after apply)
   + iam_instance_profile                 = (known after apply)
   + id                                   = (known after apply)
   + instance_initiated_shutdown_behavior = (known after apply)
   + instance_lifecycle                   = (known after apply)
   + instance_state                       = (known after apply)
   + instance_type                        = "t4g.micro"
   + ipv6_address_count                   = (known after apply)
   + ipv6_addresses                       = (known after apply)
   + key_name                             = (known after apply)
   + monitoring                           = (known after apply)
   + outpost_arn                          = (known after apply)
   + password_data                        = (known after apply)
   + placement_group                      = (known after apply)
   + placement_partition_number           = (known after apply)
   + primary_network_interface_id         = (known after apply)
   + private_dns                          = (known after apply)
   + private_ip                           = (known after apply)
   + public_dns                           = (known after apply)
   + public_ip                            = (known after apply)
   + secondary_private_ips                = (known after apply)
   + security_groups                      = (known after apply)
   + source_dest_check                    = true
   + spot_instance_request_id             = (known after apply)
   + subnet_id                            = "subnet-03c0e41a059737435"
   + tags                                 = {
     + "Name" = "nara-dev-tftest"
    }
   + tags_all                             = {
     + "Name" = "nara-dev-tftest"
    }
   + tenancy                              = (known after apply)
   + user_data                            = (known after apply)
   + user_data_base64                     = (known after apply)
   + user_data_replace_on_change          = false
   + vpc_security_group_ids               = (known after apply)
 
   + root_block_device {
     + delete_on_termination = true
     + device_name           = (known after apply)
     + encrypted             = (known after apply)
     + iops                  = 3000
     + kms_key_id            = (known after apply)
     + throughput            = (known after apply)
     + volume_id             = (known after apply)
     + volume_size           = 10
     + volume_type           = "gp3"
    }
  }
 
Plan: 1 to add, 0 to change, 0 to destroy.
 
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.

準備2:テストファイルを作成する

ここからテストファイルの作成に移ります。

テストファイルの概要については[こちら](Github)に記載があります。

テストファイルは、デフォルトではカレントディレクトリにあるtestsという名前のディレクトリから読み込むようになっています。テストコードの記述言語はHCLまたはJSONで、拡張子は.tftest.hclまたは.tftest.jsonになります(今回の検証はHCLを使います)。

テストファイルに記載するテストコードには、以下のブロックがあります:

  • - variables:変数を記載する。ここの設定で構成ファイル側のvariableをオーバーライド可能。このブロックは1つのテストファイル内に1つだけ記載できる(不要なら省略可能)
  • - provider:provider定義を記載する。こちらもオーバーライド可能で、たとえばテスト用に別のAWSアカウントがある場合などに使うことを想定していると思われる。このブロックは1つのテストファイル内に1つだけ記載できる(不要なら省略可能)
  • - run:アサーションを記載する。詳細は後述します。このブロックは1つのテストファイル内に複数記載できる

ちなみにvariableブロックの定義は、terraform.tfvarsファイルで代用することも可能です。

続いてrunブロックの説明です。こちらは以下の設定値があります:

  • - command:plan または apply を指定する。planを指定するとインフラストラクチャーのプロビジョニングを行わない(=plan結果のみでアサーションを行う)。デフォルトはapply
  • - variables:変数を記載する。前述のvariablesブロックよりもこちらが優先される
  • - assert:アサーションを記載する。下記の設定を指定する
     - condition:OKとする条件を記載する
     - error_message:conditionの判定がfalseのときに出力するメッセージを記載する

今回は以下の2つのテストファイルを作成しました。

テスト1:EC2インスタンスのNameタグの値をチェックする

planを実行し、インスタンスのNameタグが想定したものになっているかをテストするものです。
2つ目のrunブロックは、アサーションをわざと失敗させるため、Nameタグのnaraをkyotoに変更しています。

variablesブロックはterraform.tfvarsで定義するためテストファイルには含まれません。またproviderブロックも今回はオーバーライド不要なので定義していません。

[terraform:01-ec2-nametag-check.tftest.hcl]

# 01-ec2-nametag-check.tftest.hcl
 
run "01_01_valid_ec2_name_tag" {
 command = plan
 assert {
  condition     = aws_instance.nara.tags.Name == "nara-dev-tftest"
  error_message = "Nameタグの値が予期した名称と一致しませんでした"
 }
}
 
run "01_02_valid_ec2_name_tag_error" {
 command = plan
 assert {
  condition     = aws_instance.nara.tags.Name == "kyoto-dev-tftest"
  error_message = "testコマンド動作確認用にわざと失敗させる"
 }
}

テスト2:EC2インスタンスのIPアドレスをチェックする

こちらはapplyを実行してEC2インスタンスを作成し、IPアドレスの第3オクテットまでが想定通りかチェックしています。

このテスト2について補足すると、意図としては「terraform planコマンドの段階ではチェックできない」ケースを想定したものです。
例えばテスト1のNameタグはterraform planコマンドでも表示されているので目視チェックでも確認できてしまうのですが、IPアドレス(private_ipの項目です)はplan結果が(known after apply)となっており、applyするまで値がわかりません。これをterraform testコマンドでチェックしよう、というわけです。

今回EC2を作成するサブネットのCIDRブロックは 172.16.0.64/26 であるため、IPアドレスの範囲は172.16.0.64~127となります(一部サブネットで予約されたIPアドレスがあるため、すべてのIPアドレスがEC2インスタンスに割り当てられるわけではありません)。

こちらも正常終了するrunとわざと失敗させるrunの2つを定義しています。

[terraform:02-ec2-ipaddress-check.tftest.hcl]

# 02-ec2-ipaddress-check.tftest.hcl
 
run "02_01_valid_ec2_private_ipaddress" {
 command = apply
 assert {
  condition     = substr(aws_instance.nara.private_ip, 0, 9) == "172.16.0."
  error_message = "EC2インスタンスのPrivateIPアドレスが予期した値と一致しませんでした"
 }
}
 
run "02_02_valid_ec2_private_ipaddress_error" {
 command = apply
 assert {
  condition     = substr(aws_instance.nara.private_ip, 0, 9) == "172.16.1."
  error_message = "testコマンド動作確認用にわざと失敗させる"
 }
}

テストを実行する

作成したファイル類のディレクトリ構成は以下のようになりました。
tests配下のterraform.tfvarsは、ルートモジュール側と同じものが入っています。

[bash]

.
├── data.tf
├── ec2.tf
├── providers.tf
├── terraform.tfvars
├── tests
│   ├── 01-ec2-nametag-check.tftest.hcl
│   ├── 02-ec2-ipaddress-check.tftest.hcl
│   └── terraform.tfvars
└── variables.tf

テストはterraform testコマンドで実行できます。コマンドの書式は[こちら](Github)を参照願います。

想定では4つのrunブロックのうち2つが成功し2つが失敗となるはずですが、どうでしょうか。

[bash]

$ terraform test 
tests/01-ec2-nametag-check.tftest.hcl... fail
 run "01_01_valid_ec2_name_tag"... pass
 run "01_02_valid_ec2_name_tag_error"... fail
╷
│ Error: Test assertion failed
│ 
│   on tests/01-ec2-nametag-check.tftest.hcl line 14, in run "01_02_valid_ec2_name_tag_error":
│   14:     condition     = aws_instance.nara.tags.Name == "kyoto-dev-tftest"
│     ├────────────────
│     │ aws_instance.nara.tags.Name is "nara-dev-tftest"
│ 
│ testコマンド動作確認用にわざと失敗させる
╵
tests/02-ec2-ipaddress-check.tftest.hcl... fail
 run "02_01_valid_ec2_private_ipaddress"... pass
 run "02_02_valid_ec2_private_ipaddress_error"... fail
╷
│ Error: Test assertion failed
│ 
│   on tests/02-ec2-ipaddress-check.tftest.hcl line 14, in run "02_02_valid_ec2_private_ipaddress_error":
│   14:     condition     = substr(aws_instance.nara.private_ip, 0, 9) == "172.16.1."
│     ├────────────────
│     │ aws_instance.nara.private_ip is "172.16.0.122"
│ 
│ testコマンド動作確認用にわざと失敗させる
╵
 
Failure! 2 passed, 2 failed.

想定通り、わざとエラーになるアサーションを書いたテスト2つは失敗し、それ以外のテストは成功になる結果となりました。

なお今回は使っていませんが、testのコマンドオプションで実行するテストファイル名の絞り込みやテストファイルを格納したディレクトリの指定、テスト結果をJSON形式で出力する等もできますので必要に応じて使ってみてください。

ちなみにtestコマンド実行直後にAWSのEC2の画面を見ると、テスト用のEC2インスタンスが起動および終了(terminate)されたことがわかります。

[テストしたEC2の残骸]

テストツールを使わずに手動でapply+動作確認を行った場合、リソースを削除し忘れてしまって想定外のコストが発生していた・・・というケースもありますので、自動的にお掃除してくれるのもテストツールの良いところですね。

当然ですがこのEC2の料金は利用者側の負担となりますので、テスト実行でコストが発生することは押さえておくと良さそうです。

まとめ

今回はTerraform v1.6のアルファビルドを使ってterraform testコマンドを紹介しました。Terraform単体でテストまで実行できるようになり、すごく良い機能だと思います。
個人的にテストコードをHCLで記述できるのは良いと思いました。サードパーティのテストツールではテストコードを別の言語(例えばTerratestは通常go言語です)で書く必要があり、テストツール本体以外の理解や習熟も必要になり負担が大きかった印象です。Terraform v1.6ではテストコードもHCLで書けるようになるため、かなり敷居は下がったのではないか、と思います。

今回は簡単なアサーションしか検証しませんでしたが、実際にはもっと複雑な条件判定もできそうな印象です。この辺りは正式リリース後に機会があれば検証してみたいと思います。

Terraform v1.6のリリースが楽しみです。

IOWNデジタルツイン事業部では、Terraform EnterpriseおよびTerraform Cloud Plusに関する、日本語による保守サポートをご提供しております。Terraformの導入にあたってお悩みの方は、ぜひお気軽にご相談ください。

HashiCorp製品サポートサービス(弊社ホームページ)

連載シリーズ
著者プロフィール
岩瀬 正太郎
岩瀬 正太郎

NTTテクノクロス株式会社
ⅠOWNデジタルツイン事業部
第三ビジネスユニット