🛠

Terragrunt 導入で Terraform コードを DRY にしてみた

2022/11/15に公開約11,600字

こんにちは。エンジニアチームの山岸 (@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          # デプロイ

参考

実装

サンプル実装を交えながら、Terragrunt の挙動について説明します。

想定する構成

  • envsmodules を同一リポジトリで管理します。
  • サンプルのモジュールとして s3_bucket, s3_bucket_acl を用意しています。(実際はこのような分け方はしないと思いますが、簡単のため)
    • s3_bucket_acls3_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
variables.tf
variable "bucket_name" {
  type = string
}
main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}
outputs.tf
output "bucket_name" {
  value = aws_s3_bucket.this.id
}
modules/s3_bucket_acl
variables.tf
variable "bucket_name" {
  type = string
}

variable "canned_acl" {
  type = string
}
main.tf
resource "aws_s3_bucket_acl" "this" {
  bucket = var.bucket_name
  acl    = var.canned_acl
}
outputs.tf
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

子ファイルで重要な記述は以下です。これによって後述の親ファイルの内容を継承でき、子ファイル側でバックエンド設定の記述を省略することができます。

terragrunt.hcl - 子
include "root" {
  path = find_in_parent_folders()
}

find_in_parent_folders 関数は、カレントディレクトリから親ディレクトリを再起的に探索し、最初に見つけた terragrunt.hcl の絶対パスを返します。また、関数の引数に任意のファイル名を指定することができ、その場合は terragrunt.hcl の代わりに指定したファイル名で同様の挙動を示します。該当するファイルが親ディレクトリを辿っても存在しない場合はエラーになります。

親 terragrunt.hcl

親ファイルでまず重要なのはバックエンドの記述です。Terragrunt では terragrunt.hclremote_state ブロックに記述します。

特徴的なのは tfstate のキー指定です。バックエンド設定の中で唯一共通化できない部分ですが、path_relative_to_include 関数によって親ファイルから include ブロックを持つ子ファイルまでの相対パスを取得し、これをキーに含めることで共通化を実現しています。

terragrunt.hcl - 親
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 ブロック、およびそれを利用した変数については後述します)

terragrunt.hcl - 親
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 に記述します。

https://github.com/roki18d/terragrunt-example/blob/main/common_vars.yaml

https://github.com/roki18d/terragrunt-example/blob/main/envs/dev/env_vars.yaml

これらの設定を terragrunt.hcl から以下のように参照できます。

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 として渡します。

envs/dev/s3_bucket/terragrunt.hcl
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> のようにアクセスできます。

envs/dev/s3_bucket_acl/terragrunt.hcl
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

https://github.com/roki18d/terragrunt-example/blob/main/envs/dev/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

以上です。今後の運用していく中で適宜、加筆・修正していきたいと思います。

最後に

Terraform での開発・運用は自由度が大きい分さまざまなパターンがあり、チーム内でどのような方法・構成を採用するか意思決定していくのが難しいなと感じる今日この頃です。本記事で紹介している内容もパターンの 1 つとして参考になれば幸いです。

最後までご覧いただき、ありがとうございました。

脚注
  1. DRY 原則 ... "Don't Repeat Youself" の略で、「同じ意味や機能を持つ情報を複数箇所に記述することを避けるべき」というソフトウェア開発原則の1つ。 ↩︎

  2. https://blog.studysapuri.jp/entry/2022/03/30/080000 ↩︎

Discussion

ログインするとコメントできます