Terraform 運用 #3 コード品質とテスト — tflint、pre-commit、terraform test

読了 5分

#1 の PR ワークフローで人間のレビュアーが見ていたのは、コードと plan でした。しかしレビュアーの時間はチームでいちばん高価なリソースであり、機械が捕まえられる問題にその時間を使うのはもったいないことです。今回は、人間のレビューの前に自動チェックを層として積み上げていく話です。すでに知っている fmt と validate から始めて、tflint を経て、モジュールの動作を検証する terraform test まで登っていきます。

チェックの階層 — 各ツールが捕まえるもの #

4 つの層のチェックは、それぞれ捕まえるものが違います。

ツール捕まえるもの例
terraform fmt形式インデント、整列
terraform validate文法・参照タイプミスした引数、存在しない変数
tflintプロバイダー知識ベースのエラー・規約存在しないインスタンスタイプ
terraform testモジュールの動作「この入力ならこの結果になるべき」

validate と tflint の違いが核心です。instance_type = "t3.mcro"(タイプミス)は validate を通過します。HCL としてはまったく問題のない文字列だからです。このタイプミスは apply が AWS のエラーを受け取って初めて表面化しますが、tflint は AWS プロバイダーのルールセットで、こうした値レベルのエラーを plan の前に捕まえます。宣言されたのに使われていない変数や、命名規約の違反といった整理項目も拾ってくれます。

.tflint.hcl
# .tflint.hcl
plugin "aws" {
  enabled = true
  version = "0.44.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

pre-commit — コミット時点の自動化 #

これらのチェックを CI だけで回すと、フィードバックが遅くなります(プッシュして数分待って失敗に気づく)。pre-commit フックでコミット時点にローカルで回せば、数秒で捕まります。

.pre-commit-config.yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.99.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint

pre-commit-terraform は、この組み合わせをまとめた事実上の標準リポジトリです。注意すべき点は 1 つだけで、CI のチェックはそのまま残すことです。フックは個人環境の設定なので、インストールしていない人もいるかもしれません。ローカルフックは速いフィードバックのためのもので、最終関門は CI です。

terraform test — モジュールの動作を検証します #

fmt から tflint までは、コードの表面を見ています。「このモジュールにこの入力を与えたら、意図した結果になるのか」という動作の検証は、Terraform 1.6 から内蔵された terraform test の担当です。実践 #9 でモジュールがチームの共有資産になったことで、モジュールを直すときに既存の利用箇所が壊れないという保証が必要になりました。それがまさに、テストが必要になるタイミングです。

テストは tests/ ディレクトリの .tftest.hcl ファイルとして書きます。基礎 #8 の static-bucket モジュールを検証してみましょう。

modules/static-bucket/tests/naming.tftest.hcl
# modules/static-bucket/tests/naming.tftest.hcl
run "bucket_name_is_applied" {
  command = plan

  variables {
    bucket_name = "test-bucket-name"
  }

  assert {
    condition     = aws_s3_bucket.this.bucket == "test-bucket-name"
    error_message = "bucket 引数に渡した名前がそのまま反映される必要があります。"
  }
}

run "versioning_is_enabled" {
  command = plan

  variables {
    bucket_name = "test-bucket-name"
  }

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
    error_message = "このモジュールのバケットは常にバージョニングが有効である必要があります。"
  }
}
テスト実行
$ terraform -chdir=modules/static-bucket test
tests/naming.tftest.hcl... pass
  run "bucket_name_is_applied"... pass
  run "versioning_is_enabled"... pass

構造は単純です。run ブロック 1 つがテストケース 1 つで、variables で入力を与え、assert の condition が真であれば通過します。condition に書く文法は、基礎 #4 で学んだ式そのままです。

plan テストと apply テストのコスト #

command = plan がデフォルトではなく意図的な選択だという点が重要です。run ブロックのデフォルトの command は apply で、実際にリソースを作ってテスト後に消します。実際の AWS の動作まで検証できる代わりに、遅く、料金がかかり、後片付けに失敗するリスクがあります。実用的な基準はこうです。

  • 基本は plan テスト: 入力と計画結果の関係の検証は plan で十分です。速くて無料なので、pre-commit や CI に負担なく組み込めます。
  • apply テストは重要なモジュールに選択的に: 「本当に作られるのか」まで確認すべき共有モジュール程度に絞り、CI では別ワークフロー(夜間実行など)に分離して、PR のフィードバックを遅くしないようにします。

これらのチェック全部(#1 の plan を含む)が PR で自動的に回るようになると、人間のレビュアーに届く PR は、形式と文法と値と動作がすでに検証済みの状態になります。レビュアーはようやく、人間にしかできない問い(この設計は正しいのか、この置き換えは意図どおりなのか)に時間を使えるようになります。

まとめ #

今回扱った内容です。

  • チェックは fmt(形式)、validate(文法)、tflint(プロバイダー知識)、terraform test(動作)の 4 層です
  • validate は存在しないインスタンスタイプを捕まえられません。値レベルのエラーは tflint の AWS ルールセットが plan の前に捕まえます
  • pre-commit フックは速いローカルフィードバックのためのもので、最終関門は CI です
  • terraform test は run・variables・assert 構造の tftest ファイルでモジュールの動作を検証します。モジュールが共有資産になった瞬間が、テスト導入のタイミングです
  • run のデフォルトの command は apply(実リソース作成・コスト発生)なので、基本は plan テストにして、apply テストは重要なモジュールにだけ選択的に適用します

次回(#4 セキュリティ・コストスキャン)では、もう 2 つの自動チェックの軸を追加します。セキュリティ設定のミスを捕まえる trivy・checkov と、PR にコストの変化を表示する Infracost です。

X