TerragruntによるDRYなTerraformアーキテクチャ:Dev、Staging、Production環境の効率的な管理手法

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

背景と導入理由:マルチ環境におけるコード重複の罠

開発(dev)、ステージング(staging)、本番(prod)にまたがるクラウドインフラの管理は、最初はシンプルに始まります。VPC用のTerraformファイルを数行書き、RDS Postgresインスタンスを立ち上げ、ECSクラスターを起動する程度なら問題ありません。しかし、チームがステージングと本番の完全な環境パリティ(同等性)を求め始めた瞬間、問題が表面化します。

標準的なTerraformでは、「ワークスペース」を使うか「ディレクトリを分ける」かという、どちらも厄介な2つの妥協案のいずれかを迫られます。Terraformのワークスペースは一見シンプルに見えます。しかし、バックエンド設定や変数スキーマが共通化されてしまうため、AWSアカウントの分離や環境固有のパラメータ上書きを行うにはリスクが伴います。

もう一方の選択肢であるdev、staging、prodごとに個別のディレクトリを維持する方法は、運用管理の悪夢へと直結します。何十ものフォルダに、ほぼ同一のbackend.tfやprovider.tfブロックをコピーして配置することになります。3つの環境にまたがる15のマイクロサービスで構成された環境なら、40個以上の重複したバックエンド定義を管理しなければならなくなることも珍しくありません。

なぜこのようなことが起きるのでしょうか?それはTerraformの根本的な設計上、バックエンドブロック内での動的変数の使用が禁止されているためです。ネイティブのterraform { backend "s3" {...} }定義の内部では、環境名の文字列やAWSアカウントIDをS3バケットのキーに展開(インターポレーション)することができません。この制約のせいで、エンジニアはステートのパスをハードコードし、モジュール定義を複製し、プロバイダーの認証ブロックを手動で同期せざるを得なくなっています。

Terragruntはこの根本的な制約を解決します。Terraformのオーケストレーターおよび薄いラッパーとして機能することで、TerragruntはDRY(Don’t Repeat Yourself:繰り返しを避ける)原則を徹底します。子ディレクトリの設定は、単一のルートファイルからバックエンド宣言やプロバイダー設定を直接継承できます。この構成により、分離された複数のAWSアカウントにインフラを展開する際でも、設定ドリフト(構成の不整合)を防ぐことができます。

インストール

Terragruntを実行するには、$PATH内にTerraformバイナリがインストールされている必要があります。本番環境では特定のバージョンを固定するか、CI/CDランナーに直接静的バイナリをインストールすることを推奨します。

ステップ1: Terraformのインストール

DebianおよびUbuntu環境では、HashiCorpの公式リポジトリからパッケージを取得します:

sudo apt-get update && sudo apt-get install -y gnupg software-properties-common curl
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.8.*

ステップ2: Terragruntのインストール

GruntworkのGitHubリポジトリからコンパイル済みバイナリを取得し、システムのバイナリディレクトリに配置します:

TERRAGRUNT_VERSION="v0.58.5"
curl -L -o terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/${TERRAGRUNT_VERSION}/terragrunt_linux_amd64
chmod +x terragrunt
sudo mv terragrunt /usr/local/bin/terragrunt

両方のツールのバージョンを確認します:

terraform version
terragrunt --version

設定:クリーンなマルチ環境構成の設計

コードをDRYに保つため、リポジトリを明確に2つの領域へ分割します。汎用的な設計図は再利用可能なTerraformモジュールとしてまとめ、環境固有の入力値やバックエンドのロジックはTerragruntのライブディレクトリに分離します。

リポジトリのディレクトリ構成

複数のAWSアカウント向けに設計されたクリーンなディレクトリパターンの例です:

infrastructure/
├── modules/
│   └── vpc/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── live/
    ├── terragrunt.hcl          # ルート設定(リモートステートとプロバイダー定義)
    ├── dev/
    │   ├── env.hcl             # 環境レベルの変数(CIDRブロック、タグなど)
    │   └── vpc/
    │       └── terragrunt.hcl  # Dev環境のVPCデプロイ定義
    ├── staging/
    │   ├── env.hcl
    │   └── vpc/
    │       └── terragrunt.hcl
    └── prod/
        ├── env.hcl
        └── vpc/
            └── terragrunt.hcl

1. ルート設定(`live/terragrunt.hcl`)

ルートファイルでは、Terragruntのpath_relative_to_include()関数を使用して、リモートバックエンドとAWSプロバイダーブロックの両方を動的に生成します。

# live/terragrunt.hcl
locals {
  # 子ディレクトリから環境レベルの変数を自動読み込み
  env_vars   = read_terragrunt_config(find_in_parent_folders("env.hcl"))
  env_name   = local.env_vars.locals.environment
  aws_region = "us-east-1"
}

# バックエンドの集中管理設定
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "mycompany-tf-state-${local.env_name}-${local.aws_region}"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = local.aws_region
    encrypt        = true
    dynamodb_table = "mycompany-tf-locks-${local.env_name}"
  }
}

# 動的なAWSプロバイダー生成
generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "${local.aws_region}"
  default_tags {
    tags = {
      Environment = "${local.env_name}"
      ManagedBy   = "Terragrunt"
    }
  }
}
EOF
}

2. 環境変数ファイル(`live/dev/env.hcl`)

各モジュールでアカウントIDやタグを何度も記述するのではなく、環境ごとの固有値を環境単位の単一ファイルに集約して管理します:

# live/dev/env.hcl
locals {
  environment    = "dev"
  aws_account_id = "111122223333"
}

3. 子モジュール設定(`live/dev/vpc/terragrunt.hcl`)

コンポーネント設定がいかに簡潔になるかに注目してください。バックエンドの定型コードも、プロバイダーブロックも不要です。親設定の継承宣言と必要な変数を渡すだけで完結します。

# live/dev/vpc/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../../modules/vpc"
}

inputs = {
  vpc_cidr             = "10.10.0.0/16"
  public_subnet_cidrs  = ["10.10.1.0/24", "10.10.2.0/24"]
  private_subnet_cidrs = ["10.10.10.0/24", "10.10.20.0/24"]
  enable_nat_gateway   = true
  single_nat_gateway   = true # dev環境でNATゲートウェイを1台にして月額約65ドルを節約
}

ステージングや本番環境を作成する際は、devディレクトリの構造を複製し、env.hclや入力パラメータを調整するだけで対応できます。例えば本番環境では、マルチAZの冗長性を確保するためにsingle_nat_gateway = falseに切り替えます。コアとなるVPCモジュールには1行も手を加えることなく、高可用性を実現できます。

検証とモニタリング

ディレクトリ構成が整えば、インフラのテストは非常にスムーズです。個別のコンポーネントを対象にすることも、スタック全体に対して一括でコマンドを実行することも可能です。

ステップ1: 単一コンポーネントの検証

該当するコンポーネントのディレクトリに移動します。Terragruntが裏側でリモートバックエンドのセットアップ、ローカルプロバイダーファイルのレンダリング、ソースモジュールの取得を行い、Terraformを実行します:

cd live/dev/vpc
terragrunt init
terragrunt plan

実行計画(plan)の内容に問題がなければ、適用(apply)を実行します:

terragrunt apply -auto-approve

ステップ2: 複数モジュールの安全なオーケストレーション

現代のスタックには、VPC、RDSインスタンス、EKSクラスターのように相互に連携するリソースが含まれます。Terragruntは明示的なdependencyブロックを使用して、実行順序を制御し、モジュール間で出力をシームレスに受け渡します:

# live/dev/app/terragrunt.hcl
dependency "vpc" {
  config_path = "../vpc"
}

inputs = {
  vpc_id     = dependency.vpc.outputs.vpc_id
  subnet_ids = dependency.vpc.outputs.private_subnet_ids
}

特定環境内のすべてのモジュールに対する変更を一度に確認したい場合は、その環境ディレクトリ直下からrun-allを実行します:

cd live/dev
terragrunt run-all plan

Terragruntが依存関係グラフを構築し、独立したモジュールは並列でplanを実行し、依存関係のあるリソースは厳密な順序に従って適用します。

ステップ3: 継続的なモニタリングとドリフト検出

手動変更などの予期せぬ更新が障害を引き起こす前に検知するため、GitHub ActionsやGitLab CIなどでスケジュール実行によるドリフト検出を設定します:

# 終了コード2は未管理のインフラドリフトを検知したことを示す
terragrunt run-all plan -detailed-exitcode

ステート定義の集中管理と、環境ごとに分離された入力パラメータを組み合わせることで、コードベースを常にクリーンに保つことができます。脆弱なコピペ作業を排除し、監査可能な設定境界を確立しながら、単一のプロトタイプからマルチリージョンの本番運用までスケール可能なインフラ基盤を構築できます。

Share: