Terragrunt 導入で Terraform コードを DRY にしてみた
こんにちは。エンジニアチームの山岸 (@yamagishihrd) です。
シンプルフォームではクラウドリソースのデプロイツールとして Terraform を利用していますが、運用面でいくつか課題があったため、検討の末 Terragrunt を導入することにしました。今回は、Terragrunt の概要や基本的な使い方について紹介します。
はじめに
想定読者
- Terraform の利用経験はあるが、Terragrunt は初心者という方
利用するバージョン
本記事で利用する Terraform, Terragrunt のバージョンは以下の通りです。
-
Terraform ...
1.3.1
-
Terragrunt ...
0.39.2
サンプルコード
本記事で使用するサンプルコードを、以下に公開しています。
Terraform について
Terraform は HashiCorp 社によって Go 言語で開発された、Infrastructure as Code (IaC) のためのツールです。詳細は割愛しますが、以下のような特徴があります。
- マルチクラウド対応 ... AWS であれば AWS CloudFormation, Azure あれば Azure Resource Manager (ARM) などに該当するサービスですが、Terraform では HashiCorp Language (HCL) という共通の言語・文法で記述できます。
- 宣言的実行 ... Terraform は宣言的に動作します。すなわち、デプロイされるリソース群の最終的な状態のみを記述しておけば良く、デプロイ過程におけるリソース間の依存関係などは自動的に解決してくれます。
- Dry-Run 機能 ... Terraform は優れた Dry-Run (plan) 機能を備えています。これから行うデプロイ操作が、何のリソースに対して・どのような変更操作(作成・変更・削除)を加えるものなのかを事前に確認できます。
Terraform を利用して開発・運用する中で感じていた主な課題感は以下のようなものです。
(1)バックエンド設定などの記述を tfstate 毎に記述する必要がある
Terraform は同一ディレクトリ内に存在する全ての tf ファイルを解析し、その中で宣言されたリソースを 1 つの tfstate 内で管理します。逆に言うと、異なるディレクトリ配下で宣言されたリソース群の属する tfstate は異なるため、ほぼ同じ内容のバックエンド設定を tfstate 毎に記述する必要があります。
(2)tfstate 間に依存関係がある場合にリソース情報をハードコードする必要がある
例えば VPC とその Subnet 内で起動する EC2 インスタンスのように、依存関係のある複数のリソースを定義したいとします。同一ディレクトリ内で定義する場合、EC2 インスタンスに設定する Subnet ID は aws_subnet.main.id
のように論理的な値を設定できますが、別ディレクトリで定義する場合、subnet-XXXXXXXX
のような物理的な値をハードコードする必要があります。
Terragrunt について
Terragrunt は Gruntwork 社が提供する Terraform のラッパーツールです。
詳細は後述しますが、導入によって以下のようなメリットを享受できます。
- Terraform コードを DRY [1] にできる。
- tfstate 間の依存関係を解決してくれる。
運用時の使用感としては、基本的に terraform
コマンドが terragrunt
コマンドになるだけです。(その他、Terragrunt 独自の便利なコマンドも存在します)
% terragrunt init # 初期化
% terragrunt validate # 検証
% terragrunt fmt # コード整形
% terragrunt plan # Dry-Run
% terragrunt apply # デプロイ
参考
- CLI options - Terragrunt
- Built-in functions - Terragrunt
- Configuration Blocks and Attributes - Terragrunt
実装
サンプル実装を交えながら、Terragrunt の挙動について説明します。
想定する構成
-
envs
とmodules
を同一リポジトリで管理します。 - サンプルのモジュールとして
s3_bucket
,s3_bucket_acl
を用意しています。(実際はこのような分け方はしないと思いますが、簡単のため)-
s3_bucket_acl
はs3_bucket
に依存します。
-
- モジュールは複数の環境
dev
,prod
にデプロイされることを想定します。
terraform % tree
.
├── envs
│ ├── dev
│ │ ├── s3_bucket
│ │ └── s3_bucket_acl
│ └── prod
│ ├── s3_bucket
│ └── s3_bucket_acl
└── modules
├── s3_bucket
└── s3_bucket_acl
モジュールの実装は以下のようになっています。
modules/s3_bucket
variable "bucket_name" {
type = string
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
}
output "bucket_name" {
value = aws_s3_bucket.this.id
}
modules/s3_bucket_acl
variable "bucket_name" {
type = string
}
variable "canned_acl" {
type = string
}
resource "aws_s3_bucket_acl" "this" {
bucket = var.bucket_name
acl = var.canned_acl
}
output "bucket_acl_id" {
value = aws_s3_bucket_acl.this.id
}
terragrunt.hcl
Terragrunt でリソース管理を行うには、バックエンド設定などを記述する terragrunt.hcl
というファイルが必要になります。
terragrunt.hcl
には 1 つの親ファイルと複数の子ファイルがあり、共通的な設定(バックエンド情報など)を親ファイルに記述します。これを複数の子ファイルから参照することで DRY な記述を実現しています。
terraform % tree
.
├── envs
│ ├── dev
│ │ ├── s3_bucket
+ │ │ │ └── terragrunt.hcl # 子
│ │ └── s3_bucket_acl
+ │ │ └── terragrunt.hcl # 子
│ └── prod
├── modules
│ ├── s3_bucket
│ │ │ └── examples
+ │ │ │ └── terragrunt.hcl # 子
│ └── s3_bucket_acl
+ └── terragrunt.hcl # 親
子 terragrunt.hcl
子ファイルで重要な記述は以下です。これによって後述の親ファイルの内容を継承でき、子ファイル側でバックエンド設定の記述を省略することができます。
include "root" {
path = find_in_parent_folders()
}
find_in_parent_folders 関数は、カレントディレクトリから親ディレクトリを再起的に探索し、最初に見つけた terragrunt.hcl
の絶対パスを返します。また、関数の引数に任意のファイル名を指定することができ、その場合は terragrunt.hcl
の代わりに指定したファイル名で同様の挙動を示します。該当するファイルが親ディレクトリを辿っても存在しない場合はエラーになります。
親 terragrunt.hcl
親ファイルでまず重要なのはバックエンドの記述です。Terragrunt では terragrunt.hcl
の remote_state ブロックに記述します。
特徴的なのは tfstate のキー指定です。バックエンド設定の中で唯一共通化できない部分ですが、path_relative_to_include 関数によって親ファイルから include
ブロックを持つ子ファイルまでの相対パスを取得し、これをキーに含めることで共通化を実現しています。
remote_state {
backend = "s3"
config = {
region = "ap-northeast-1"
bucket = "terragrunt-example-tfstate"
key = "${path_relative_to_include()}/terraform.tfstate"
encrypt = true
}
generate = {
path = "_backend.tf"
if_exists = "overwrite_terragrunt"
}
}
以下は Terraform リソースが利用する provider 設定の記述です。contents
にヒアドキュメントとして記述した内容を path
に指定したファイル名でファイル生成します。( locals
ブロック、およびそれを利用した変数については後述します)
generate "provider" {
path = "_provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region_name}"
default_tags {
tags = {
Environment = "${local.env}"
}
}
}
EOF
}
common_vars.yaml
, env_vars.yaml
設定ファイル - 続いて設定ファイルです。リポジトリ直下に common_vars.yaml
を作成し、環境毎に env_vars.yaml
を作成します。modules/
の配下にも、Dry-Run ができるよう env_vars.yaml
を作成しています。
terraform % tree
.
+ ├── common_vars.yaml
├── envs
│ ├── dev
+ │ ├── env_vars.yaml # env: dev
│ │ ├── s3_bucket
│ │ │ └── terragrunt.hcl # 子
│ │ └── s3_bucket_acl
│ │ └── terragrunt.hcl # 子
+ │ ├── env_vars.yaml # env: prod
│ └── prod
├── modules
+ │ ├── env_vars.yaml # env: test
│ ├── s3_bucket
│ │ │ └── examples
│ │ │ └── terragrunt.hcl # 子
│ └── s3_bucket_acl
└── terragrunt.hcl # 親
ファイル名の通り、common_vars.yaml
には環境横断で共通的な設定を記述し、環境毎の設定を env_vars.yaml
に記述します。
これらの設定を terragrunt.hcl
から以下のように参照できます。
locals {
common_vars = yamldecode(file(find_in_parent_folders("common_vars.yaml")))
env_vars = yamldecode(file(find_in_parent_folders("env_vars.yaml")))
org = local.common_vars.org
env = local.env_vars.env
}
モジュール呼び出し
モジュール呼び出しの際は、terraform
ブロックの source
にモジュールへのパスを指定します。モジュールの variables.tf
で定義された入力は inputs
として渡します。
include "root" {
path = find_in_parent_folders()
}
+ terraform {
+ source = "${dirname(find_in_parent_folders())}/modules//s3_bucket/"
+ }
locals {
common_vars = yamldecode(file(find_in_parent_folders("common_vars.yaml")))
env_vars = yamldecode(file(find_in_parent_folders("env_vars.yaml")))
org = local.common_vars.org
env = local.env_vars.env
}
+ inputs = {
+ bucket_name = "${local.org}-example-bucket-${local.env}"
+ }
tfstate 間の依存関係定義
依存するリソースは dependency
ブロックで定義します。依存するモジュールの出力には dependency.<NAME>.outputs.<OUTPUT_NAME>
のようにアクセスできます。
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "${dirname(find_in_parent_folders())}/modules//s3_bucket_acl/"
}
+ dependency "s3_bucket" {
+ config_path = "${dirname(find_in_parent_folders())}/envs/dev/s3_bucket"
+
+ mock_outputs = {
+ bucket_name = "hoge"
+ }
+ }
+ inputs = {
+ bucket_name = dependency.s3_bucket.outputs.bucket_name
+ canned_acl = "private"
+ }
terragrunt graph-dependencies
コマンドで依存関係の有向グラフを確認できます。
terragrunt-example/envs/dev % terragrunt graph-dependencies
digraph {
"s3_bucket" ;
"s3_bucket_acl" ;
"s3_bucket_acl" -> "s3_bucket";
}
# 画像出力
% tg graph-dependencies | dot -Tpng > graph.png
その他
run-all
コマンド
複数モジュールへの plan や apply を単一のコマンドで実行するには、run-all
コマンドを利用します。
% pwd
/path/to/terragrunt-example/envs/dev
% terragrunt run-all plan
% terragrunt run-all apply
% terragrunt run-all output
% terragrunt run-all destroy
機密リソースの管理
Secrets Manager シークレットや SSM パラメータのような機密リソースを扱う場合、sops という暗号化ツールを用いることで、機密情報も含めて安全に Terraform 管理することができます。詳細については以下のエントリをご覧ください。
Snowflake Provider
Terragrunt を使用して組織におけるマルチアカウント Snowflake 環境を構築する方法について以下のエントリで紹介しているので、よろしければ併せてご覧ください。
以上です。今後の運用していく中で適宜、加筆・修正していきたいと思います。
最後に
Terraform での開発・運用は自由度が大きい分さまざまなパターンがあり、チーム内でどのような方法・構成を採用するか意思決定していくのが難しいなと感じる今日この頃です。本記事で紹介している内容もパターンの 1 つとして参考になれば幸いです。
最後までご覧いただき、ありがとうございました。
-
DRY 原則 ... "Don't Repeat Youself" の略で、「同じ意味や機能を持つ情報を複数箇所に記述することを避けるべき」というソフトウェア開発原則の1つ。 ↩︎
Discussion