Terraform × Terragrunt × pre-commit-terraform で構築するリポジトリ運用スタイル
はじめに
AWSの環境構築をターゲットにこの本を手に取り、Terraformを使い始めて早3年が経過。現時点でのTerraformリポジトリ運用スタイルをこれを機にまとめようと思います。 Terraform公式からスタイルガイドも出ていますが、その内容と被らないようなことを雑多にまとめていこうと思います。
使用技術まとめ
以下に現在Terraformリポジトリで使用している技術をまとめます。
ベース
-
Terraform
- 言わずもがな
-
Terragrunt
- Terraformのラッパーツール
- 環境毎のTerraformコードをDRYにするために使用
-
tfenv
- Terraformのバージョン管理をするために使用
-
tgenv
- Terragruntのバージョン管理をするために使用
-
Terraform AWS Provider
- AWS用Terraformプロバイダ
-
Terraform Archive Provider
- LambdaのコードをZIP化してデプロイするために使用
-
Terraform AWS Cloud Control Provider
- AWS Chatbotデプロイ用
コードチェック
-
pre-commit-terraform
- pre-commitフレームワークで使用可能なTerraform用のgit hookスクリプト集
- コミット前にいろいろチェックするために使用
- 以下技術はpre-commit-terraformで使用
-
pre-commit
- 様々な言語のコミット前フックを管理および保守するためのフレームワーク
-
TFLint
- Terraformのリンター
- 以下2種類のルールセットを使用
-
TFLint Ruleset for Terraform Language
- Terraform用
-
TFLint Ruleset for terraform-provider-aws
- Terraform AWS Provider用
-
TFLint Ruleset for Terraform Language
-
Trivy
- Terraformのセキュリティ問題を静的解析するために使用
-
terraform-docs
- Terraformで管理するリソース一覧、入力変数および出力変数をドキュメント化するために使用
-
pre-commit
その他
-
AWS CLI
- 言わずもがな
-
Session Manager Plugin
- ECS ExecとかEC2へセキュアに接続するために使用
-
aws-vault
- IAM認証情報を安全に保存するために使用
-
Graphviz
- AWSリソースの依存関係図を作成するために使用
リポジトリ構造
Terraformのリポジトリ構造は今後のリポジトリ運用を考える上で非常に重要なポイントです。様々な考え方がありますが、私は以下のスライド資料を参考にしています。
いろいろ試すうちに以下のような構成に落ち着きました。.
├── 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インフラのドキュメントとしての価値を可能な限り高めつつ、デプロイオペレーションは可能な限りシンプルにすることを重要視しています。
デプロイオペレーション
実際にどのようなデプロイオペレーションを行っているのかを以下に記載します。
-
. set_aws_profile.sh
を実行し、これからデプロイする環境を指定 -
./init.sh
を実行し、これからデプロイする準備を行う -
cd resources/*
で、デプロイを実行したいAWSサービスのディレクトリへ移動 -
terragrunt plan
でdry run -
terragrunt apply
を実行し、AWSリソースをデプロイ
set_aws_profile.sh
では、デプロイオペレーションに必要な環境変数を設定し、aws-vault exec
を実行。デプロイ対象となるAWS環境への操作権限を取得します。
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
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",
]
}
}
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によって上書き生成しています。
#!/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
これによってリポジトリを常に健全な状態を保つことができます。
設定ファイルは以下の通りです。
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
は以下のようにリスクメッセージと該当リンクを合わせて記載する
# 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