Terraformのコードが一定のルールを守っているかチェックするためのツールとしてtflintがあります。tflintを使うといわゆるプログラミング言語のアナライザーのようにTerraformのコードを解析し、構文エラーやベストプラクティスに反している箇所を指摘します。
ここ数年、ローカルとCIでtflintを使ってTerraformのコードをチェックしているのでメモです。
tflintはリアルタイムじゃない
tflintはTerraformのコードを解析して問題を指摘するツールですが、リアルタイムで問題を指摘してくれるわけではありません。あくまでも外部コマンドとして実行することで問題を指摘してくれるので、ローカルで実行するよりもCIで実行してコードを担保するのが良いでしょう。
あと実行も別に早くないです、むしろ遅い。
このあたりを踏まえると、tflintはTerraformのコード全体をチェックする以外に、PRで変更があったコードを含むディレクトリ単位で実行してほしくなります。1
tflintを並列実行するラッパー
tflintを実行するには、terraformの初期化init
、tflintの実行が必要です。また、modulesを利用している側でtflintを実行してもmodulesの詳細をチェックしてくれないので、modulesの中に潜ってtflintを実行する必要があります。ということで、以下のようなララッパークリプトtflint.sh
を作成すると便利です。2
このラッパーは、指定した並列度(デフォルトは3)で指定したディレクトリに潜ってterraform init
とtflint
を実行します。GitHub Actions Hosted Runnerのような環境が初期化されるCIで使う場合は--action clean
で.terraform
を削除する必要はないですが、環境が保持される場合は、状況に応じて.terraform
を削除する--action clean
を指定すると良いでしょう。xargsを使って並列に実行するため、エラーメッセージがわかりにくくなりやすいです。このため、tflintで失敗した時、エラーメッセージとともにどのパスで失敗したか + エラーコード255を返すようにしています。
#!/bin/bash set -eo pipefail # Summary -> 各フォルダに潜って terraform init && tflint を実行します。 # description -> tflint はルートモジュールでしか厳密なチェックを行わないため、modules フォルダをチェックするためにはフォルダに潜ってtflintを実行します。 # # 実行例: # * 全実行 -> bash ./terraform/tflint.sh # * 指定パスだけ実行 -> bash ./terraform/tflint.sh --target-paths "path1 path2 path3" # * .terraform維持 -> bash ./terraform/tflint.sh" --keep-plugins true # * .terraform削除 -> bash ./terraform/tflint.sh --action clean while [ $# -gt 0 ]; do case $1 in # option --action) _ACTION=$2; shift 2; ;; --target-paths) _TARGET_PATHS=$2; shift 2; ;; --parallelism) _PARALLELIISM=$2; shift 2; ;; --keep-plugins) _KEEP_PLUGINS=$2; shift 2; ;; --help) usage; exit; ;; *) shift ;; esac done # 一番時間かかるので、terraform init は並列で実行して回る (xargs の実行コマンドを見たい場合 -t をつけてください) function parallel_echo() { xargs -I{} -P${parallelism} -n1 bash -ec " cd {}; echo \"PROCESSING echo {} ...\" cd - > /dev/null" } function parallel_init() { xargs -I{} -P${parallelism} -n1 bash -ec " cd {}; echo \"PROCESSING init {} ...\" terraform init -backend=false || ( echo 'terraform init failed on {}' exit 255 ) cd - > /dev/null" } function parallel_lint() { xargs -I{} -P${parallelism} -n1 bash -ec " cd {}; echo \"PROCESSING lint {} ...\" tflint --config \"${config}\" --fix || ( echo 'tflint failed on {}' exit 255 ) cd - > /dev/null" } function parallel_rm() { xargs -I{} -P${parallelism} -n1 bash -ec " cd {}; echo \"PROCESSING rm {} ...\" rm -rf \".terraform\" rm -f .terraform.lock.hcl cd - > /dev/null" } # CI用にまとめて実行する。これなら容量を食わない && エラーが特定のモジュールで起こっても判別可能になる。 # exit 255でxargsはエラーが出たときに処理を中断するので利用する。see: https://unix.stackexchange.com/questions/566834/xargs-does-not-quit-on-error function parallel_all() { xargs -I{} -P "${_PARALLELIISM}" -n1 bash -ec " cd {}; echo \"PROCESSING {} ...\" && \ terraform init -backend=false > /dev/null && \ tflint --config \"${config}\" && \ rm -rf \".terraform\" || exit 255 cd - > /dev/null" } # どこからスクリプトを呼びだしても動作するために、スクリプトのあるディレクトリを取得する CWD=$(dirname "$0") echo "Arguments:" echo "--action=${_ACTION:="run"}" # "run" or "clean" echo "--target-paths=${_TARGET_PATHS:="${CWD}"}" # "" to run all, or "path1 path2 path3" echo "--parallelism=${_PARALLELIISM:=3}" # 3並列 echo "--keep-plugins=${_KEEP_PLUGINS:=false}" # .terraform維持したいならtrueで実行する echo "Initialize tflint..." config="$(realpath "${CWD}")/.tflint.hcl" tflint --init --config "${config}" for path in ${_TARGET_PATHS}; do echo "path: $path" tf_paths=$(find "${path}" -type f -name "*.tf" -exec dirname {} \; | sort -u) if [[ "${_ACTION}" == "clean" ]]; then echo "Cleaning (${_ACTION})..." echo "${tf_paths}" | parallel_rm exit 0 fi echo "Begin tflint (${_ACTION})..." # terraform init & tflint if [[ "${CI}" == "true" ]]; then # CI専用のパス echo "${tf_paths}" | parallel_all else # ローカルでは自動修復する echo "${tf_paths}" | parallel_init echo "${tf_paths}" | parallel_lint if [[ "${_KEEP_PLUGINS}" != "false" ]]; then echo "${tf_paths}" | parallel_rm fi fi done echo "tflint successfully completed."
私は次のようなGitHub ActionsワークフローでPR時にtflintを実行しています。Terraformコードはaws/
ディレクトリ以下にある想定です。
name: Terraform Tests on: pull_request: branches: - "main" paths: - ".github/workflows/terraform-tests.yaml" - "aws/**/*.tf" jobs: lint: runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.ref }} - uses: ./.github/actions/setup-terraform with: working-directory: ./aws/ - id: changed-files uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: list-files: csv # default 'none'. Disables listing of matching files. filters: | aws: - aws/**/*.tf - name: tflint if: ${{ steps.changed-files.outputs.aws == 'true' }} run: bash ./aws/tflint.sh --target-paths "${{ steps.changed-files.outputs.aws_files }}"
setup-terraformは次のようにterraformとtflintをインストールしています。
name: Setup terraform description: | terraform のインストールを行います。 inputs: tfcmt-version: description: "利用するtfcmtバージョンを指定します。" default: "4.14.0" required: false tflint-version: description: "TFLintのバージョン" default: "v0.55.0" required: false tf-api-token: description: "Terraform Cloud API Token" default: "" required: false # Workflowで `deafult.run.working-directory` を指定していても、Actionsでは別途必要だった。 working-directory: description: "versions.tfのあるワーキングディレクトリ" required: true runs: using: "composite" steps: - name: Get terraform version from .terraform-version shell: bash id: terraform-version run: | required_version=$(grep -E 'required_version\s*=\s*"([^"]+)"' "versions.tf" | sed -n 's/.*required_version\s*=\s*"\([^"]*\)".*/\1/p') echo "value=$required_version" | tee -a "${GITHUB_OUTPUT}" working-directory: ${{ inputs.working-directory }} - uses: hashicorp/setup-terraform@2f1b53ffa558af27b90742f3f28397d986061ece # v3.1.2 with: terraform_version: ${{ steps.terraform-version.outputs.value }} cli_config_credentials_token: ${{ inputs.tf-api-token }} - name: Show terraform versions shell: bash run: terraform --version - uses: terraform-linters/setup-tflint@90f302c255ef959cbfb4bd10581afecdb7ece3e6 # v4.1.1 with: tflint_version: ${{ inputs.tflint-version }} - name: Install tfcmt if: ${{ inputs.tf-api-token != '' }} # Terraform Cloud のAPITokenが空文字 = terraform実行時にtfcmtは必要でないはず shell: bash run: | curl -L -O "https://github.com/suzuki-shunsuke/tfcmt/releases/download/v${{ inputs.tfcmt-version }}/tfcmt_linux_amd64.tar.gz" tar -xzf ./tfcmt_linux_amd64.tar.gz sudo mv tfcmt /usr/local/bin/. tfcmt --version
実行例
例えば、tflintにはTerraformの標準フォルダ構成(main.tf、outputs.tf、variables.tf)がある想定のルールterraform_standard_module_structureがあります。
aws/guarddutyにoutputs.tfがない時、tflintは次のようなエラーを出力します。期待通りですね。
tflintのルール構成
tflintはプラグインとして様々なルールを設定できます。例えばawsリソースであればterraform-linters/tflint-ruleset-aws | GitHub を使います。
私は次のようなルールでtflintを実行しています。
# see: https://github.com/terraform-linters/tflint/blob/master/docs/user-guide/config.md plugin "aws" { enabled = true version = "0.37.0" source = "github.com/terraform-linters/tflint-ruleset-aws" } config { format = "compact" call_module_type = "local" force = false # false で結果に応じて終了コードが変わる disabled_by_default = false # trueでconfigに記載のルールだけチェックされる } # tflint 一般ルール一覧: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/main/docs/rules/README.md # tflint-ruleset-aws で有効になるルール一覧: https://github.com/terraform-linters/tflint-ruleset-aws/blob/master/docs/rules/README.md # Disallow // comments in favor of # rule "terraform_comment_syntax" { enabled = true } # Disallow legacy dot index syntax rule "terraform_deprecated_index" { enabled = true } # Disallow deprecated (0.11-style) interpolation rule "terraform_deprecated_interpolation" { enabled = true } rule "terraform_deprecated_lookup" { enabled = true } # Disallow output declarations without description rule "terraform_documented_outputs" { enabled = true } # Disallow variable declarations without description rule "terraform_documented_variables" { enabled = true } # Disallow comparisons with [] when checking if a collection is empty rule "terraform_empty_list_equality" { enabled = true } # Disallow specifying a git or mercurial repository as a module source without pinning to a version rule "terraform_module_pinned_source" { enabled = true } # Checks that Terraform modules sourced from a registry specify a version rule "terraform_module_version" { enabled = true } # Enforces naming conventions for resources, data sources, etc rule "terraform_naming_convention" { enabled = true format = "snake_case" } # Require that all providers have version constraints through required_providers rule "terraform_required_providers" { enabled = false # module も対象に terraform init するので required providers なんてできないよ } # Disallow terraform declarations without require_version rule "terraform_required_version" { enabled = false # module も対象に terraform init するので required version なんてできないよ } # Ensure that a module complies with the Terraform Standard Module Structure rule "terraform_standard_module_structure" { enabled = true } # Disallow variable declarations without type rule "terraform_typed_variables" { enabled = true } # Disallow variables, data sources, and locals that are declared but never used rule "terraform_unused_declarations" { enabled = true } # Check that all required_providers are used in the module rule "terraform_unused_required_providers" { enabled = false # cloudfront の module で provider が必要で設定しているが、tflint的には要らないといわれてしまうので除外 } # terraform.workspace should not be used with a "remote" backend with remote execution rule "terraform_workspace_remote" { enabled = true }
利用可能なルール
tflint-ruleset-awsで利用可能なルールは、docs/rulesで確認できます。
デフォルトでほとんどのルールが有効なので、自分のプロジェクトに合わないルールは無効にするか、ルールに合わせるといいでしょう。私はtflintでばらついていたルールが検出された時、tflintに合わせました。
deep_checks
私はTerraform CloudのRemote Executionがメインなのでdeep_checkは利用していません。ただ、AWS ProfileでTerraformを実行しているなら使ってもいいかもですね。
まとめ
tflintはローカルで実行するとワークフロー的に時間がかかって厳しいところがあります。CIで変更のあるファイルをベースに実行して、コードルールの担保と速度を両立させることができます。
terraformのワークフローとしてPRを介してコードレビューを受ける前提になりますが、tflintを使えるかどうかからTerraformワークフローを再考するのもいいですね。