tech.guitarrapc.cóm

Technical updates

tflintを使ってCIでTerraformのコードをチェックする

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 inittflintを実行します。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は次のようなエラーを出力します。期待通りですね。

image

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ワークフローを再考するのもいいですね。


  1. Terraformの解決が同一ディレクトリ階層なので、ファイル = ディレクトリを考えるのが自然です
  2. tflint自身に--recursivemax-workers=Nがあるのですが、パスをいい感じに指定しながら実行するにはちょっと厳しい