🙆‍♀️

実用的なTerraformのテストコードのサンプルを実装(ValidationとかTestとかMockの話) ~ 後編 ~

2024/03/29に公開

これまで前編、中編と二回にわたって、Terraformのテストコードの実装方法を紹介してきた。

最後の後編では、テストコードの検証の過程の中で得られた知識を小分けにして紹介したい。

precondition/postcondition

Terraformにはprecondition/postconditionというplanやapplyコマンド実行前後にアサーションを行う機能がある。

まずpreconditionだが、前回の記事で作成したdbモジュールのgoogle_compute_global_addressにアサーションを記述した場合、例えば以下のようになる。

...
resource "google_compute_global_address" "cloudsql_ip_range" {
  name          = "sample-cloudsql-ip-range"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  address       = var.cloudsql_network_address
  prefix_length = 24
  network       = var.vpc_id
  lifecycle {
    precondition {
      condition     = can(regex("^10\\.[1-4]\\.(2|102)\\.0$", var.cloudsql_network_address)) && endswith(var.vpc_id, "sample-vpc")
      error_message = "Either the value of var.cloudsql_network_address or var.vpc_id isn't expected. Please see the above values."
    }
  }
}
...

ここでは、preconditionでvar.cloudsql_network_addressとvar.vpc_idで指定された値が意図するものかをチェックしていて、これらの条件に違反した値を変数に指定した場合、variableのvalidationに違反した場合と同様にplanコマンドやapplyコマンドが実行される前にエラー終了させることが出来る。

variableのvalidationは自身の変数の値しかチェックできないのに対して、preconditionは複数の変数を同時にチェックすることが出来る。

他にもresourceブロックだけでなく、dataやoutputブロックでも使用できるといった特徴がある。

一方のpostconditionは、planやapplyコマンド実行後の結果に対してアサーションを行うことが出来る。

postconditionではselfオブジェクトを使用することで、apply後のリソース属性の値をチェックすることができ、後続の依存するリソースたちが信頼して使っていいものかチェックする際などに使用される。

以下のサンプルコードではid属性の値をチェックしているが、この属性は(known after apply)の値で、plan実行時に値が確定しないためエラーになりそうだが、実際にはアサーションはパスする挙動となる。

...
  lifecycle {
    postcondition {
      condition     = self.id == "projects/${var.service}-${var.env}/global/addresses/sample-cloudsql-ip-range"
      error_message = "The self.id value must be 'projects/${var.service}-${var.env}/global/addresses/sample-cloudsql-ip-range'."
    }
  }
...

一方applyコマンドを実行した場合、アサーションは適切に実行され、条件に一致しない場合リソースを作成直後にエラーが発生してapplyを中断させることが出来る。

なおエラー終了しても、それまでに生成されたリソースはロールバックされずに残ったままになるため、その点については別途対処する必要がある。

precondition/postconditionはvariableのvalidationと併せて使用することで、入出力値チェックをより堅牢にすることが出来るため、積極的に利用していきたい。

なお、ブループリントのようなモジュールではこのprecondition/postconditionをほぼ高確率で使用出来ない点についても、第三者が開発したモジュールを利用する際のデメリットとして理解しておく必要がある。

(モジュール内には複数のリソースが定義されていて、すべてのリソースにprecondition/postconditionを変数経由などで定義できるようにするのは、現実的ではないことが理由と思われる)

ちなみにterraform testコマンド実行時に内部的に行われるplanやapplyでも、precondition/postconditionのチェックが行われる点はしっかり覚えておきたい。

Terraform Test関連Issues

Terraformのテスト関連のissueを確認することで今後追加されそうな機能が把握できる。

前回のdbモジュールのテストコード実装ではその過程でさまざまな問題に遭遇したが、どうやら他の開発者も別の方面からではあるものの、本質として同じ問題に直面していることが分かる。

たとえば、testコマンドにおけるapply -> assert -> destroyの一連のプロセスを中断できない件については、以下のissueを始めとして最も報告されているような状況となっている。

内容的にはTerraformのテストフレームワークであるTerratestのtest_structureパッケージのRunTestStageのような機能が欲しいという話ではあるが、環境変数に特定の値を指定することでdestroyの実行だけをキャンセルしたいという意図については、自分のケースと同じ課題感である。

(これまで散々説明してきたが、ライセンス問題の結果として、現在Terratestは数ヶ月以上にわたって機能の追加改修を実質休止している状態)

次に、データベースやクラスタといったリソース作成に時間がかかりすぎる問題に対しては、以下のようにエラー発生地点からテストを再開できる機能や、テストの並列実行機能が要望されている。

他にも、tftest.hcl内でlocalsブロックを使用できるようする機能要望については、個人的に遭遇した問題なので気になっている。

3つ以上のリソースの依存関係の解決

中編ではVPCとCloud SQLという2つのリソース間の依存関係について、tier1層に定義した常時稼働のVPCを使用して依存を解決するという方法を説明した。

では3つ以上の依存関係、たとえばCloud SQL(MySQL)のプライベートIPアドレスにCloud DNSでドメイン名を付与する場合、DNSレコードリソースを定義する必要があるが、このDNSレコードに対してテストコードを書く場合、依存関係はVPC -> Cloud SQL -> DNSレコードの3つのリソースで説明される。

このDNSレコードの定義をdbモジュール内に書けばTerraformが自動的に依存関係を認識してくれるため、特にテストコードの実装で問題になることはないが、もし仮にdnsモジュールなどに分けて定義した場合、dnsモジュールのvariableなどでCloud SQLのプライベートIPアドレスを渡す必要が出てくる。

(ちなみにDNSレコードはnetworkモジュールに定義するのが無難な気がするが、network -> db -> networkのような循環参照的依存関係は正しく動くか直感的に怪しく思うため、ここでは例としてdnsモジュールとしている)

このように3つ以上のリソースで依存関係があらわされる場合でも、依存元リソース -> テスト対象リソースだけを考えればよく、具体的にはテストコード専用のCloud SQLインスタンスをtier1で常時稼働させて、そのプライベートIPアドレスを渡せばいいと個人的には考えている。

中編ではtier1.tfという全環境間で共通のHCLファイルの例を挙げたが、このファイルに以下のようなfakeモジュールを定義したのちapplyして、後はこのCloud SQLインスタンスを依存解決専用に使うだけでいい。

...

# 前編で説明したnetworkモジュール
module "network" {
  source       = "../../../modules/network"
  service      = var.service
  env          = var.env
  subnet_ip    = var.subnet_ip
}

# 以下の定義を追加
# 依存解決専用のCloud SQLインスタンスを最小コストの'db-f1-micro'で作成
module "fake_db" {
  source       = "../../../modules/db"
  # test、sandbox環境でのみ作成
  count        = var.env == "test" || startswith(var.env, "sbx-") ? 1 : 0
  service      = var.service
  env          = var.env
  vpc_id       = module.network.vpc_id
  vpc_name = module.network.vpc_name
  db_instance_name = "fake-sample-instance"
  cloudsql_network_address = var.test_db_network_address
  tier = "db-f1-micro"
}

基本的に依存関係の解決にだけ使えれば良いため、テストを実行するtest環境やsandbox環境限定で一番低いスペックのインスタンスを起動しておくだけでよい。

なお、前回の記事ではglobals.tfでgoogleやgoogle-betaといったproviderを宣言していて、dbモジュールでもこの定義をシンボリックリンク経由で使用していたが、モジュールにproviderを含めると、上記のような呼び出し時にcountやfor_eachを併せて使用するとエラーになる点については対処する必要がある。

そもそもdbモジュールをtier2ではなくtier1で定義すればfakeモジュールは不要となるが、test環境やsandbox環境上でも常時稼働のリソースとして扱われることになるため、そこはコストとの相談となる。

あとはdnsモジュールにこのtest_dbのプライベートIPアドレスを変数経由で渡して、networkモジュールやdbモジュール同様の手順でテストを実装するだけとなる。

なぜGoogleのベストプラクティスではテスト実行毎にGCPプロジェクト削除を推奨するのか

インフラプロジェクト設計のスタート記事で紹介した話だが、GoogleのTerraformベストプラクティスでは、テストコードを実行するたびにGCPプロジェクトを再作成するのが最善としている。

テストでは、多くのリソースが作成され、削除されます。リソースのクリーンアップ中に誤って削除されないように、開発環境または本番環境プロジェクトから環境を隔離します。最善のアプローチは、テストごとに新しいプロジェクトまたはフォルダを作成することです。構成ミスを回避するため、テストの実行ごとに個別のサービス アカウントを作成することをおすすめします。

しかしこれに対して、組織のGCPプロジェクト作成申請フローやクォータなどが理由で大抵の企業ではこのような運用は不可能と自分は考えている。

ただ、実際にテストコードを実装してみると、(あくまで推測ではあるものの)その主張の理由がなんとなく分かったような気がしてきた。

GCPではApp Engineなどの一部のサービスで一度リージョンを設定したら二度と変更できないといったとんでもないトラップがあったり、VPCピアリングのコネクションとIP範囲を一度生成したら別の値に変更したくても、変更や復旧が不可能になるケースがあるといった、何回もリソースの生成/削除を行うTerraformのテストとは相入れない仕様が存在する。

そしてこの問題を解決する一番簡単な方法がプロジェクトをテストの度に使い捨てるということなのだと思われる。

ちなみに自分は上記VPCピアリングのコネクションの問題が起きたせいで、使いたいIP範囲が二度と使えなくなるという状況に陥ったが、解決のために結局はプロジェクトの再作成をすることになってしまった。

具体的には、infra-testing-google-sampleでは、GCPプロジェクトを${var.service}-${var.env}という命名規則で名前付けしているが、GCPプロジェクトが実在さえすれば、serviceにはinfra-testing-google-sampleでなくinfra-testing-google-sample-v2のように別の値を渡しても、一切のTerraformのコードの変更なく環境の再構築が行えるため、この方法で解決を行った。

(そして現在の自分のsandbox環境のGCPプロジェクト名はinfra-testing-google-sample-v3-sbx-e(仮名)のようになっている)

なお、この問題は常時GCPの全サービスの仕様を正確に把握していないと回避出来ない気がしていて、そのようなことはまず不可能であるため、誰にでも起きうる問題としてTerraformテストの導入の際には必ず事前に解決策を用意しておかなければいけないということになる。

networkモジュールとdbモジュールに対してtestコマンドを同時実行した場合

ターミナルで2つのタブを開いて、networkモジュールとdbモジュールに対して同時にterraform testコマンドを実行した場合どうなるかを検証してみたところ、どちらも正常にテストを実行できるという結果となった。

なお先ほどのissueのトピックでテストの並列実行機能を話題に挙げたが、この結果を受けてもしかすると不要なのではと思い始めている。

テストの同時実行を可能にするためのキーポイントとしては、terraform test実行時のstateがそれぞれの実行プロセスのメモリ上に作成されるために排他が発生しない点が挙げられる。

よって、各モジュール間で密結合な実装とならずに独立性を維持し続ける限りは、CIなどでテストコードの同時実行は可能という方向で現在インフラプロジェクトの設計を行なっている。

terraform testコマンドで異常発生時の挙動確認

command = applyのterraform testコマンド実行時にエラーが発生した場合、GCPコンソールなどから手動削除しなければいけないリソースが一覧表示されると、Terraform Testのドキュメントのどこかに記載されていたので、その挙動を確認してみることにした。

networkモジュールに対してterraform testコマンドでapplyを実行している最中にCtrl+Cで終了させた場合の結果は以下のようになる。

$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  terraform test
tests/main.tftest.hcl... in progress
^C
Interrupt received.
Please wait for Terraform to exit or data loss may occur.
Gracefully shutting down...

run "vpc"... pass
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... pass

Success! 1 passed, 0 failed.

実行ログにはGracefully shutting down...と書かれているが、実際にはシャットダウンされずテストが続行されるような挙動となる。

そのためCtrl+Cを2回以上実行してみると今度は強制終了が行われ、たしかにクリーンアップが必要なリソースが一覧表示されるのが分かる。

^C
Two interrupts received. Exiting immediately. Note that data loss may have
occurred.

Terraform was interrupted while executing tests/main.tftest.hcl, and may not have performed the expected cleanup operations.

Terraform was in the process of creating the following resources for "vpc" from the module under test, and they may not have been destroyed:
  - module.vpc.module.vpc.google_compute_network.network
  - module.vpc.module.subnets.google_compute_subnetwork.subnetwork["asia-northeast1/test-sample-subnet"]
  run "vpc"... fail
╷
│ Error: Test interrupted
│
│ The test operation could not be completed due to an interrupt signal. Please read
│ the remaining diagnostics carefully for any sign of failed state cleanup or
│ dangling resources.
╵
╷
│ Error: execution halted
│
│
╵
╷
│ Error: execution halted
│
│
╵
╷
│ Error: Error waiting to create Network: Error waiting for Creating Network: error while retrieving operation: unable to finish polling, context has been cancelled
│
│   with module.vpc.module.vpc.google_compute_network.network,
│   on .terraform/modules/vpc/modules/vpc/main.tf line 20, in resource "google_compute_network" "network":20: resource "google_compute_network" "network" {
│
╵
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... fail

Failure! 0 passed, 1 failed.  

おわり

以上でTerraformのテスコードの実装について一通り話を終えた形となる。

これでテストの体系化が完了したため、次は自分のインフラプロジェクトの本体であるinfra-testing-google-sampleにテストの機能を組み込んで、さらにインフラ開発や実運用で必要となるテスト実行用のヘルパースクリプトの設計について、その詳細を説明する予定となっている。

つまりinfra-testing-google-sampleのバージョン0.2.0がリリースされるということになるが、このリリースでは今回の全三編で紹介した内容に対して既に大量のリファクタリングが行われている状態なので、もしこれまでの一連の記事の内容を自分の環境で試す際は、出来れば最新の設計を確認した上で利用してもらいたいと考えている。

追記

続きといえば続き。

https://zenn.dev/erueru_tech/articles/befa2cfc2f7917

Discussion