📝

Terraform × Terragrunt × pre-commit-terraform で構築するリポジトリ運用スタイル

2024/04/08に公開

はじめに

https://nextpublishing.jp/book/10983.html
AWSの環境構築をターゲットにこの本を手に取り、Terraformを使い始めて早3年が経過。現時点でのTerraformリポジトリ運用スタイルをこれを機にまとめようと思います。
https://developer.hashicorp.com/terraform/language/style
Terraform公式からスタイルガイドも出ていますが、その内容と被らないようなことを雑多にまとめていこうと思います。

使用技術まとめ

以下に現在Terraformリポジトリで使用している技術をまとめます。

ベース

コードチェック

その他

  • AWS CLI
    • 言わずもがな
  • Session Manager Plugin
    • ECS ExecとかEC2へセキュアに接続するために使用
  • aws-vault
    • IAM認証情報を安全に保存するために使用
  • Graphviz
    • AWSリソースの依存関係図を作成するために使用

リポジトリ構造

Terraformのリポジトリ構造は今後のリポジトリ運用を考える上で非常に重要なポイントです。様々な考え方がありますが、私は以下のスライド資料を参考にしています。
https://speakerdeck.com/harukasakihara/besutona-terraform-deirekutorigou-cheng-wokao-cha-sitemita
いろいろ試すうちに以下のような構成に落ち着きました。

.
├── modules                         # 繰り返し使うリソース構築コードを置くディレクトリ
├── opt_resources                   # AWSサービス毎にリソース構築コードを置くディレクトリ (一時的)
├── resources                       # AWSサービス毎にリソース構築コードを置くディレクトリ
│   ├── .tpl                        # テンプレートディレクトリ。このディレクトリをコピーして使う
│   │   ├── .terraform              # `terragrunt run-all init` で自動生成されるディレクトリ
│   │   ├── .terraform.lock.hcl     # `terragrunt run-all init` で自動生成されるファイル
│   │   ├── .tflint.hcl             # TFLint で使用するルールを設定するファイル
│   │   ├── .trivyignore            # Trivy でセキュリティスキャンをする際、スキャン対象外にしたい設定ミスIDを記載するファイル
│   │   ├── README.md               # ディレクトリ内で定義されているリソース、入出力変数などを記載するファイル
│   │   ├── _backend.tf             # `terragrunt run-all init` で自動生成されるファイル
│   │   ├── _provider.tf            # `terragrunt run-all init` で自動生成されるファイル
│   │   ├── _versions.tf            # `terragrunt run-all init` で自動生成されるファイル
│   │   ├── main.tf                 # リソース構築コードが記載されたファイル
│   │   ├── outputs.tf              # 別ディレクトリの main.tf で使用する出力変数を記載するファイル
│   │   ├── prod.tfvars             # 本番環境の環境変数が記載されたファイル
│   │   ├── stg.tfvars              # ステージング環境用の環境変数が記載されたファイル
│   │   ├── terragrunt.hcl          # Terragrunt を使う際に参照される設定ファイル (子)
│   │   └── variables.tf            # main.tf 内で扱われる変数が記載されたファイル
│   ├── acm
│   │   ├── .terraform
:   :   :
│   ├── graph.svg                   # ↑のディレクトリ群の依存関係図を示したファイル
│   └── terragrunt.hcl              # Terragrunt を使う際に参照される設定ファイル (親)
├── .editorconfig                   # コードフォーマットツールの設定ファイル
├── .gitignore                      # Git管理下から除外するファイル、ディレクトリを記載するファイル
├── .pre-commit-config.yaml         # pre-commit-terraform を使用する際の設定ファイル
├── .terraform-version              # 使用する Terraform のバージョンを設定するファイル
├── .terragrunt-version             # 使用する Terragrunt のバージョンを設定するファイル
├── README.md                       # 今読んでるファイル
├── infrastructure.png              # インフラ構成図を示したファイル
├── init.sh                         # デプロイ操作の事前準備を行うスクリプト
└── set_aws_profile.sh              # デプロイ操作の対象となる環境を指定するスクリプト

このリポジトリ構造のポリシーは以下の通りです。

  • tfstateファイルを小分けにしてAWSリソースの変更容易性を向上
  • resources/*/main.tfを参照すれば、AWSサービス単位でのTerraformコードすべてが把握できる
  • 一時テスト用のEC2インスタンスや一時期だけ有効にするコスト削減用のサーバスケジューラLambdaなど、場合によって作ったり消したりするリソースはopt_resourcesで管理

AWSインフラのドキュメントとしての価値を可能な限り高めつつ、デプロイオペレーションは可能な限りシンプルにすることを重要視しています。

デプロイオペレーション

実際にどのようなデプロイオペレーションを行っているのかを以下に記載します。

  1. . set_aws_profile.sh を実行し、これからデプロイする環境を指定
  2. ./init.sh を実行し、これからデプロイする準備を行う
  3. cd resources/* で、デプロイを実行したいAWSサービスのディレクトリへ移動
  4. terragrunt plan でdry run
  5. terragrunt apply を実行し、AWSリソースをデプロイ

set_aws_profile.shでは、デプロイオペレーションに必要な環境変数を設定し、aws-vault execを実行。デプロイ対象となるAWS環境への操作権限を取得します。

set_aws_profile.sh
set -eu
export AWS_REGION='ap-northeast-1'
export PRODUCT_CODE='sample-product'
export REPOSITORY_URL='https://github.com/sample-user/sample-product-terraform'
export SLACK_WORKSPACE_ID='**********'
export TERRAFORM_VERSION=`cat .terraform-version`
export PROVIDER_AWS_VERSION='5.44.0'
export PROVIDER_ARCHIVE_VERSION='2.4.2'
export PROVIDER_AWSCC_VERSION='0.73.0'
export LAMBDA_PYTHON_VERSION='python3.12'
export LAMBDA_NODEJS_VERSION='nodejs20.x'

# 事前に環境変数を初期化
unset AWS_VAULT

echo 'デプロイ先の環境を指定してください。'
read -p 'ex) stg/prod:' ENV

# AWSプロファイルを設定
if [ ${ENV} = 'stg' ] || [ ${ENV} = 'prod' ]; then
    echo "***** [wl-${PRODUCT_CODE}-${ENV}]:接続開始 *****"
else
    echo '[stg] or [prod] のいずれかを入力してください。'
fi
export DEPLOY_ENV="${ENV}"
aws-vault exec --duration=8h "wl-${PRODUCT_CODE}-${ENV}"

set +eu

set_aws_profile.shで設定する環境変数は、プロダクト毎に変更もしくは頻繫に書き換えるVer情報などがあり、terragrunt.hclによって各main.tfへ継承できるようにしています。

terragrunt.hcl
resources/terragrunt.hcl(親)
locals {
  terraform_version        = get_env("TERRAFORM_VERSION")
  provider_aws_version     = get_env("PROVIDER_AWS_VERSION")
  provider_archive_version = get_env("PROVIDER_ARCHIVE_VERSION")
  provider_awscc_version   = get_env("PROVIDER_AWSCC_VERSION")
  lambda_python_version    = get_env("LAMBDA_PYTHON_VERSION")
  lambda_nodejs_version    = get_env("LAMBDA_NODEJS_VERSION")
  aws_region               = get_env("AWS_REGION")
  product_code             = get_env("PRODUCT_CODE")
  deploy_env               = get_env("DEPLOY_ENV")
  aws_vault                = get_env("AWS_VAULT")
  repository_url           = get_env("REPOSITORY_URL")
  slack_workspace_id       = get_env("SLACK_WORKSPACE_ID")
}

generate "versions" {
  path      = "_versions.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
terraform {
  required_version = "${local.terraform_version}"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "${local.provider_aws_version}"
    }
  }
}
EOF
}

generate "provider" {
  path      = "_provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "${local.aws_region}"
  default_tags {
    tags = {
      Name          = "${local.product_code}"
      Environment   = "${local.deploy_env}"
      ManagedBy     = "Terraform"
      RepositoryURL = "${local.repository_url}"
    }
  }
}
EOF
}

remote_state {
  backend = "s3"
  generate = {
    path      = "_backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket  = "terraform-state-${local.aws_vault}"
    key     = "${path_relative_to_include()}.tfstate"
    region  = "${local.aws_region}"
    encrypt = true
  }
}

terraform {
  extra_arguments "common_vars" {
    commands = get_terraform_commands_that_need_vars()
    arguments = [
      "-var-file=${local.deploy_env}.tfvars",
    ]
  }
}
resources/lambda/terragrunt.hcl(子)
include "root" {
  path   = find_in_parent_folders()
  expose = true
}

dependency "iam" {
  config_path = "../iam"
  mock_outputs = {
    service_role_lambda_slack_notification_arn = "temporary-dummy-string"

  }
}

inputs = {
  product_code                               = include.root.locals.product_code
  deploy_env                                 = include.root.locals.deploy_env
  lambda_python_version                      = include.root.locals.lambda_python_version
  lambda_nodejs_version                      = include.root.locals.lambda_nodejs_version
  service_role_lambda_slack_notification_arn = dependency.iam.outputs.service_role_lambda_slack_notification_arn
}

init.shでは、resources/*すべてのディレクトリに対してterragrunt init -reconfigure -upgradeを実行し、Terraformの基本ブロックをTerragruntによって上書き生成しています。

init.sh
#!/bin/bash
set -e

# [. set_aws_profile.sh] を実行し、デプロイ先を指定しているか確認
if [ -z ${AWS_VAULT} ]; then
    echo '[. set_aws_profile.sh] を実行し、デプロイ先を指定して下さい。' >&2
    exit -1
fi

# Terragrunt の初期化
cd resources/.tpl/ && terragrunt init -reconfigure -upgrade && cd -
cd resources/ && terragrunt run-all init -reconfigure -upgrade && cd -

# TFLint の初期化
RESOURCE_LIST=`echo $(ls -d resources/*/)`
for RESOURCE in ${RESOURCE_LIST}; do
    cd ${RESOURCE}
    tflint --init
    cd -
done

このデプロイオペレーションの問題点としてinit.shの実行時間が結構かかってしまうことが挙げられますが、その代わり環境毎にTerraformのコードを修正する必要がありません。
環境毎のTerraformコード修正の手間&修正漏れリスクを無くすことを重要視した結果、このようなオペレーションになっています。

あと便利なコマンドとして以下が存在し、1つのコマンドでAWSサービス全てを横断的にデプロイし忘れていないかチェックすることが可能です。

terragrunt run-all plan

環境別に変更対応する方法

このリポジトリ構造は全ての環境で同じTerraformコードを使用することが前提です。これは「環境別でシステム構成が違ったら何のためのステージング環境か!?」みたいなことを意識しています。
ですが実際問題、サーバスペックは本番環境以外下げたい、特定の監視項目は本番環境以外無効にしたいといった欲求が発生します。そういった欲求は以下のような手段で解消しています。

  • 特定の変数を環境毎に使い分けたい場合はresources/*/<deploy_env>.tfvars
    • resources/terragrunt.hcl(親)の最後terraformブロックで、set_aws_profile.shにて設定したDEPLOY_ENVに紐付くverファイルを自動で使うように定義している
  • 環境毎で作ったり消したりするAWSリソースの違いはメタ引数のcountを活用
    • count = var.deploy_env == "prod" ? 1 : 0とすると、本番環境以外では作成されなくなる
    • 認知負荷を上げないためにも使用は最小限に

modulesについて

modulesでは必要最小限の以下のmoduleしか作らないようにしています。

  • iam_service_role
    • IAMサービスロール用
  • s3_bucket
    • S3バケットのベース用
  • vpc_sg_pair
    • AWSサービス間を繋ぐセキュリティグループ用
  • vpc_sg_single
    • AWSサービスと何かを繋ぐセキュリティグループ用

理由として、リポジトリ構造のポリシーである以下を守るためです。

`resources/*/main.tf`を確認すれば、AWSサービス単位でのTerraformコードすべてが把握できる

同様の理由でモノレポも使わないようにしています。

pre-commit-terraform

pre-commit-terraformによってコミットする都度、以下コマンドが実行されるようにしています。

  • terraform fmt -diff
  • terraform validate
  • tflint --module
    • 使用プラグイン設定は各ディレクトリの .tflint.hcl
  • trivy config ./
    • スキャン対象外設定は各ディレクトリの .trivyignore
  • terraform-docs markdown table --output-file README.md --output-mode inject
  • terragrunt hclfmt
  • terragrunt run-all validate

これによってリポジトリを常に健全な状態を保つことができます。
設定ファイルは以下の通りです。

.pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.88.4
    hooks:
      - id: terraform_fmt
        args:
          - --args=-diff
      - id: terraform_validate
      - id: terraform_tflint
        args:
          - --args=--module
      - id: terraform_trivy
      - id: terraform_docs
        args:
          - --hook-config=--path-to-file=README.md
          - --hook-config=--add-to-existing-file=true
          - --hook-config=--create-file-if-not-exist=true
        exclude: '(\.terraform\/.*|\.tpl\/.*)$'
      - id: terragrunt_fmt
      - id: terragrunt_validate
        files: '(terragrunt\.hcl)$'
        exclude: '(\.terraform\/.*|resources\/terragrunt\.hcl)$'

依存関係図の作成

以下コマンドをresourcesで実行することで依存関係図を作成できます。これはterragrunt.hclで書くdependencyブロックをもとに作成されます。
AWSサービス間がどのように依存しているのかや、デプロイフローの確認などに活用できます。

terragrunt graph-dependencies | grep -v "[INFO]" | dot -Tsvg > graph.svg

その他意識していること

  • main.tf内ではローカル変数を使わない
  • EC2のユーザーデータ、ECSのタスク定義など templatefile関数で外だしできるものは一貫して外だしする
  • 以下のようにAWSアカウント番号やEC2のAMI、RDSのエンジンVerなど、TerraformのData Sourcesを活用できるものはべた書きしないようにする
# AWSアカウント番号
data "aws_caller_identity" "current" {}

# ELBサービスアカウントのアカウント番号
data "aws_elb_service_account" "current" {}

# AWSリージョンコードの取得
data "aws_region" "current" {}

# CloudFrontのキャッシュポリシー
data "aws_cloudfront_cache_policy" "caching_optimized" {
  name = "Managed-CachingOptimized"
}

# EC2のAMI
data "aws_ami" "windows_server_2022" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["Windows_Server-2022-Japanese-Full-Base-*"]
  }
}

# RDSのエンジンVer
data "aws_rds_engine_version" "mysql" {
  engine = "mysql"
}
  • .trivyignoreは以下のようにリスクメッセージと該当リンクを合わせて記載する
resources/iam/.trivyignore
# https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/#trivyignore

## [HIGH: IAM policy document uses sensitive action '---:-----' on wildcarded resource '*'](https://avd.aquasec.com/misconfig/avd-aws-0057)
AVD-AWS-0057

## [LOW: One or more policies are attached directly to a user](https://avd.aquasec.com/misconfig/avd-aws-0143)
AVD-AWS-0143

## [MEDIUM: IAM policy allows 'iam:PassRole' action](https://avd.aquasec.com/misconfig/avd-aws-0342)
AVD-AWS-0342

さいごに

Terraformを導入して何が1番嬉しいのかを改めて考えてみると、

  • クラウドインフラリソースのドキュメント化
  • クラウドインフラリソース操作の標準化

これら2つの役割を1つのリポジトリで賄えることだと思います。
だからこそコードの認知負荷や運用負荷を下げるためにTerragruntやpre-commit-terraformを使い、認知負荷を上げるmodulesやworkspaceなどは乱用しない。
モノリシックでもいいじゃない。と私は考えています。

Discussion