メインコンテンツへスキップ

Terraformステート管理 Part2 - ファイルレイアウトとterraform_remote_state

·
インフラ Terraform AWS ハンズオン・チュートリアル
目次

今日学んだこと
#

前回の記事でワークスペースによるステート分離を学んだが、本番環境の分離には不十分だった。今回はファイルレイアウトによる分離を実践し、RDSとWebサーバーを別々に管理しながらterraform_remote_stateで連携させる方法を学んだ。

前回の振り返り: ワークスペースの限界
#

Part1でワークスペースの欠点を確認した。

欠点説明
同一バックエンド全環境が同じS3/DynamoDBを使用(権限分離が困難)
可視性が低い今どのワークスペースにいるか分かりにくい
誤操作リスクterraform workspace select prod を忘れて本番を破壊する可能性

これらの問題を解決するのがファイルレイアウトによる分離


ファイルレイアウトによる分離とは
#

環境ごと・コンポーネントごとに別のディレクトリで管理する方法。

terraform-project/
├── stage/                            # ステージング環境
│   ├── data-stores/mysql/            # DB層(変更頻度: 低)
│   └── services/webserver-cluster/   # App層(変更頻度: 高)
└── prod/                             # 本番環境(完全に別管理)
    ├── data-stores/mysql/
    └── services/webserver-cluster/

なぜコンポーネントも分離するのか
#

レイヤー変更頻度リスク
VPC/ネットワーク月1回程度高(全体に影響)
データベース週1回程度高(データ損失)
Webサーバー1日数回低(再デプロイ可能)

頻繁に変更するWebサーバーと、めったに変更しないDBを同じステートで管理すると、Webサーバーの変更時に誤ってDBを破壊するリスクがある。

分離されたコンポーネント間の連携
#

問題: RDSとWebサーバーが別プロジェクトになると、WebサーバーはRDSの接続情報をどうやって知るのか?

解決: terraform_remote_stateで別プロジェクトのステートからoutputを参照する。

[mysql/]                              [webserver-cluster/]
   │                                         │
   └── outputs.tf で address/port を出力    │
              │                              │
              └──────────────────────────────┼──→ terraform_remote_state で参照

Step 1: RDSの構築
#

ディレクトリ構成
#

mkdir -p stage/data-stores/mysql
cd stage/data-stores/mysql

variables.tf
#

variable "db_name" {
  description = "データベース名"
  type        = string
  default     = "example_database"
}

variable "db_username" {
  description = "データベースのユーザー名"
  type        = string
  sensitive   = true
}

variable "db_password" {
  description = "データベースのパスワード"
  type        = string
  sensitive   = true
}

sensitive = trueを指定すると、terraform planterraform applyの出力でマスクされる。

main.tf
#

provider "aws" {
  region = "ap-northeast-1"
}

terraform {
  backend "s3" {
    # 重要: keyはRDS専用のパスにする
    key            = "stage/data-stores/mysql/terraform.tfstate"
    bucket         = "tf-state-backend-20251128"
    region         = "ap-northeast-1"
    dynamodb_table = "tf-state-locks"
    encrypt        = true
  }
}

resource "aws_db_instance" "example" {
  identifier_prefix   = "terraform-up-and-running"
  engine              = "mysql"
  allocated_storage   = 10
  instance_class      = "db.t3.micro"
  skip_final_snapshot = true
  db_name             = var.db_name

  username = var.db_username
  password = var.db_password
}

outputs.tf(重要)
#

Webサーバーから参照するために、接続情報をoutputとして公開する。

output "address" {
  value       = aws_db_instance.example.address
  description = "データベースの接続エンドポイント"
}

output "port" {
  value       = aws_db_instance.example.port
  description = "データベースのポート番号"
}

デプロイ
#

terraform init
terraform apply
# var.db_username: admin
# var.db_password: yourpassword123

RDSの作成には5-10分かかる。完了したらterraform outputで接続情報を確認できる。


Step 2: Webサーバークラスタの構築
#

ディレクトリ構成
#

mkdir -p stage/services/webserver-cluster
cd stage/services/webserver-cluster

terraform_remote_stateとは
#

なぜ必要なのか
#

ファイルレイアウトで分離すると、RDSとWebサーバーは別々のTerraformプロジェクトになる。それぞれが独自のステートファイルを持つため、通常の方法では互いのリソース情報にアクセスできない。

[mysql/]                         [webserver-cluster/]
terraform.tfstate                terraform.tfstate
    │                                │
    └── RDSのaddress/portを保持      └── RDSの情報が必要だが...?

WebサーバーがRDSに接続するには、RDSのエンドポイント(address/port)が必要。これを解決するのがterraform_remote_state

仕組み
#

terraform_remote_state読み取り専用のデータソースで、別プロジェクトのステートファイルからoutputで公開された値を取得する。

[mysql/]                                    [webserver-cluster/]
    │                                              │
    ├── outputs.tf で address/port を公開          │
    │       │                                      │
    │       ▼                                      │
    │   S3に保存されたステート ◀─────────────────────┤
    │   (stage/data-stores/mysql/                  │
    │    terraform.tfstate)                        │
    │       │                                      │
    │       │  terraform_remote_state で読み取り   │
    │       └──────────────────────────────────────▶ db_address, db_port として使用

重要: terraform_remote_stateで読み取れるのはoutputで明示的に公開された値のみ。ステートファイル内の全リソース情報にアクセスできるわけではない。

基本構文
#

data "terraform_remote_state" "db" {
  backend = "s3"  # バックエンドの種類

  config = {
    bucket = "tf-state-backend-20251128"
    key    = "stage/data-stores/mysql/terraform.tfstate"  # RDSのステートのkey
    region = "ap-northeast-1"
  }
}
属性説明
backend参照先のバックエンド種類(s3, gcs, azurerm等)
config.bucketS3バケット名
config.key参照先プロジェクトのステートファイルのkey
config.regionS3バケットのリージョン

参照方法
#

# outputs.address を参照
data.terraform_remote_state.db.outputs.address

# outputs.port を参照
data.terraform_remote_state.db.outputs.port

ハードコードとの比較
#

方式コード例問題点
ハードコードdb_address = "terraform-xxx.rds.amazonaws.com"RDS再作成時に手動更新が必要
terraform_remote_statedb_address = data.terraform_remote_state.db.outputs.address自動的に最新値を取得
# NG: ハードコード
db_address = "terraform-xxx.rds.amazonaws.com"
# → RDSを再作成するとアドレスが変わり、手動更新が必要
# → 複数環境で異なる値を管理する必要がある

# OK: terraform_remote_state
db_address = data.terraform_remote_state.db.outputs.address
# → RDSが変わっても terraform plan/apply 時に最新のアドレスを取得
# → 環境ごとにkeyを変えるだけで対応可能

注意点
#

注意点説明
keyの一致terraform_remote_statekeyは、参照先プロジェクトのbackend設定と完全に一致させること
outputの公開参照したい値は参照先でoutputとして定義する必要がある
依存関係参照先(RDS)を先にデプロイしてからWebサーバーをデプロイすること
読み取り専用ステートの読み取りのみ可能。変更はできない

templatefile関数
#

外部ファイルを読み込み、変数を埋め込む関数。

user_data = base64encode(templatefile("user-data.sh", {
  server_port = var.server_port
  db_address  = data.terraform_remote_state.db.outputs.address
  db_port     = data.terraform_remote_state.db.outputs.port
}))

HCLとbashを分離することで可読性が向上し、スクリプト単体でのテストも可能になる。

user-data.sh
#

#!/bin/bash
cd /home/ec2-user
cat > index.html <<EOF
<h1>Hello, World</h1>
<p>DB address: ${db_address}</p>
<p>DB port: ${db_port}</p>
EOF
nohup python3 -m http.server ${server_port} &

注意: このコードは terraform_remote_state の動作確認用サンプルです。ブラウザでアクセスするとDBの接続情報が画面に表示されます。実際の運用環境では、このような機密情報をHTMLページに表示せず、アプリケーション内部でのみ使用してください。

variables.tf
#

variable "server_port" {
  description = "HTTPリクエストを受け付けるポート番号"
  type        = number
  default     = 8080
}

variable "alb_name" {
  description = "ALBの名前"
  type        = string
  default     = "terraform-asg-example"
}

variable "alb_security_group_name" {
  description = "ALB用セキュリティグループの名前"
  type        = string
  default     = "terraform-example-alb"
}

variable "instance_security_group_name" {
  description = "EC2インスタンス用セキュリティグループの名前"
  type        = string
  default     = "terraform-example-instance"
}

main.tf
#

provider "aws" {
  region = "ap-northeast-1"
}

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-20251128"
    key            = "stage/services/webserver-cluster/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "tf-state-locks"
    encrypt        = true
  }
}

# -----------------------------------------------------------------------------
# Data Sources
# -----------------------------------------------------------------------------

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

# RDSの状態をリモートステートから取得
data "terraform_remote_state" "db" {
  backend = "s3"

  config = {
    bucket = "tf-state-backend-20251128"
    key    = "stage/data-stores/mysql/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

# -----------------------------------------------------------------------------
# Launch Template & Auto Scaling Group
# -----------------------------------------------------------------------------

resource "aws_launch_template" "example" {
  image_id      = "ami-03852a41f1e05c8e4"
  instance_type = "t2.micro"

  vpc_security_group_ids = [aws_security_group.instance.id]

  user_data = base64encode(templatefile("user-data.sh", {
    server_port = var.server_port
    db_address  = data.terraform_remote_state.db.outputs.address
    db_port     = data.terraform_remote_state.db.outputs.port
  }))
}

resource "aws_autoscaling_group" "example" {
  vpc_zone_identifier = data.aws_subnets.default.ids

  launch_template {
    id      = aws_launch_template.example.id
    version = "$Latest"
  }

  target_group_arns = [aws_lb_target_group.asg.arn]
  health_check_type = "ELB"

  min_size = 2
  max_size = 10

  tag {
    key                 = "Name"
    value               = "terraform-asg-example"
    propagate_at_launch = true
  }
}

# -----------------------------------------------------------------------------
# Security Groups
# -----------------------------------------------------------------------------

resource "aws_security_group" "instance" {
  name = var.instance_security_group_name

  ingress {
    from_port   = var.server_port
    to_port     = var.server_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "alb" {
  name = var.alb_security_group_name

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# -----------------------------------------------------------------------------
# Application Load Balancer
# -----------------------------------------------------------------------------

resource "aws_lb" "example" {
  name               = var.alb_name
  load_balancer_type = "application"
  subnets            = data.aws_subnets.default.ids
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.example.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = 404
    }
  }
}

resource "aws_lb_target_group" "asg" {
  name     = var.alb_name
  port     = var.server_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id

  health_check {
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
    interval            = 15
    timeout             = 3
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

resource "aws_lb_listener_rule" "asg" {
  listener_arn = aws_lb_listener.http.arn
  priority     = 100

  condition {
    path_pattern {
      values = ["*"]
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.asg.arn
  }
}

outputs.tf
#

output "alb_dns_name" {
  value       = aws_lb.example.dns_name
  description = "ロードバランサーのDNS名"
}

output "asg_name" {
  value       = aws_autoscaling_group.example.name
  description = "Auto Scaling Groupの名前"
}

デプロイ
#

terraform init
terraform apply

Step 3: 動作確認
#

terraform consoleで確認
#

terraform console
> data.terraform_remote_state.db.outputs
{
  "address" = "terraform-up-and-running-xxx.rds.amazonaws.com"
  "port" = 3306
}
> exit

ブラウザで確認
#

# ALBのDNS名を取得
terraform output alb_dns_name

# curlで確認
curl http://<ALB_DNS_NAME>

「Hello, World」と「DB address」「DB port」が表示されれば成功。


クリーンアップ: 依存関係と削除順序
#

なぜ削除順序が重要なのか
#

Terraformリソースには依存関係がある。依存されているリソースを先に削除しようとするとエラーになる。

[Webサーバー] ──依存──→ [RDS]
      │                    │
      │                    └── 接続情報(address/port)を参照
      │
      └── terraform_remote_state でRDSのステートを参照

[S3/DynamoDB]
      │
      └── 全プロジェクトのステートを保管・ロック

正しい削除順序: 依存する側 → 依存される側

# 1. Webサーバー(RDSに依存)
cd stage/services/webserver-cluster
terraform destroy

# 2. RDS
cd ../../../stage/data-stores/mysql
terraform destroy

# 3. S3/DynamoDB(全体のバックエンド)- 必要な場合のみ

逆順で削除しようとした場合:

  • S3を先に削除 → Webサーバーのステートにアクセスできずエラー
  • RDSを先に削除 → Webサーバーの terraform_remote_state がエラー

構築したアーキテクチャ
#

┌─────────────────────────────────────────────────────────────────┐
│                         S3 Backend                               │
│  ├── stage/data-stores/mysql/terraform.tfstate    ← RDSの状態    │
│  └── stage/services/webserver-cluster/terraform.tfstate          │
└────────────────────────────────────────────────────────────────┘
                              │
                              │ terraform_remote_state で参照
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        Webサーバー                               │
│  ┌──────────────────┐    ┌──────────────────┐                   │
│  │   ALB (port 80)   │───▶│   ASG (EC2 x2)   │                   │
│  └──────────────────┘    └──────────────────┘                   │
│                                   │                              │
│                                   │ DB接続情報を取得             │
│                                   ▼                              │
│                          ┌──────────────────┐                   │
│                          │   RDS (MySQL)     │                   │
│                          └──────────────────┘                   │
└─────────────────────────────────────────────────────────────────┘

完成形のコード
#

ディレクトリ構成
#

stage/
├── data-stores/
│   └── mysql/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── services/
    └── webserver-cluster/
        ├── main.tf
        ├── variables.tf
        ├── outputs.tf
        └── user-data.sh
stage/data-stores/mysql/

main.tf

provider "aws" {
  region = "ap-northeast-1"
}

terraform {
  backend "s3" {
    key            = "stage/data-stores/mysql/terraform.tfstate"
    bucket         = "tf-state-backend-20251128"
    region         = "ap-northeast-1"
    dynamodb_table = "tf-state-locks"
    encrypt        = true
  }
}

resource "aws_db_instance" "example" {
  identifier_prefix   = "terraform-up-and-running"
  engine              = "mysql"
  allocated_storage   = 10
  instance_class      = "db.t3.micro"
  skip_final_snapshot = true
  db_name             = var.db_name

  username = var.db_username
  password = var.db_password
}

variables.tf

variable "db_name" {
  description = "データベース名"
  type        = string
  default     = "example_database"
}

variable "db_username" {
  description = "データベースのユーザー名"
  type        = string
  sensitive   = true
}

variable "db_password" {
  description = "データベースのパスワード"
  type        = string
  sensitive   = true
}

outputs.tf

output "address" {
  value       = aws_db_instance.example.address
  description = "データベースの接続エンドポイント"
}

output "port" {
  value       = aws_db_instance.example.port
  description = "データベースのポート番号"
}
stage/services/webserver-cluster/

main.tf

provider "aws" {
  region = "ap-northeast-1"
}

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-20251128"
    key            = "stage/services/webserver-cluster/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "tf-state-locks"
    encrypt        = true
  }
}

# -----------------------------------------------------------------------------
# Data Sources
# -----------------------------------------------------------------------------

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

data "terraform_remote_state" "db" {
  backend = "s3"

  config = {
    bucket = "tf-state-backend-20251128"
    key    = "stage/data-stores/mysql/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

# -----------------------------------------------------------------------------
# Launch Template & Auto Scaling Group
# -----------------------------------------------------------------------------

resource "aws_launch_template" "example" {
  image_id      = "ami-03852a41f1e05c8e4"
  instance_type = "t2.micro"

  vpc_security_group_ids = [aws_security_group.instance.id]

  user_data = base64encode(templatefile("user-data.sh", {
    server_port = var.server_port
    db_address  = data.terraform_remote_state.db.outputs.address
    db_port     = data.terraform_remote_state.db.outputs.port
  }))
}

resource "aws_autoscaling_group" "example" {
  vpc_zone_identifier = data.aws_subnets.default.ids

  launch_template {
    id      = aws_launch_template.example.id
    version = "$Latest"
  }

  target_group_arns = [aws_lb_target_group.asg.arn]
  health_check_type = "ELB"

  min_size = 2
  max_size = 10

  tag {
    key                 = "Name"
    value               = "terraform-asg-example"
    propagate_at_launch = true
  }
}

# -----------------------------------------------------------------------------
# Security Groups
# -----------------------------------------------------------------------------

resource "aws_security_group" "instance" {
  name = var.instance_security_group_name

  ingress {
    from_port   = var.server_port
    to_port     = var.server_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "alb" {
  name = var.alb_security_group_name

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# -----------------------------------------------------------------------------
# Application Load Balancer
# -----------------------------------------------------------------------------

resource "aws_lb" "example" {
  name               = var.alb_name
  load_balancer_type = "application"
  subnets            = data.aws_subnets.default.ids
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.example.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = 404
    }
  }
}

resource "aws_lb_target_group" "asg" {
  name     = var.alb_name
  port     = var.server_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id

  health_check {
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
    interval            = 15
    timeout             = 3
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

resource "aws_lb_listener_rule" "asg" {
  listener_arn = aws_lb_listener.http.arn
  priority     = 100

  condition {
    path_pattern {
      values = ["*"]
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.asg.arn
  }
}

variables.tf

variable "server_port" {
  description = "HTTPリクエストを受け付けるポート番号"
  type        = number
  default     = 8080
}

variable "alb_name" {
  description = "ALBの名前"
  type        = string
  default     = "terraform-asg-example"
}

variable "alb_security_group_name" {
  description = "ALB用セキュリティグループの名前"
  type        = string
  default     = "terraform-example-alb"
}

variable "instance_security_group_name" {
  description = "EC2インスタンス用セキュリティグループの名前"
  type        = string
  default     = "terraform-example-instance"
}

outputs.tf

output "alb_dns_name" {
  value       = aws_lb.example.dns_name
  description = "ロードバランサーのDNS名"
}

output "asg_name" {
  value       = aws_autoscaling_group.example.name
  description = "Auto Scaling Groupの名前"
}

user-data.sh

#!/bin/bash
cd /home/ec2-user
cat > index.html <<EOF
<h1>Hello, World</h1>
<p>DB address: ${db_address}</p>
<p>DB port: ${db_port}</p>
EOF
nohup python3 -m http.server ${server_port} &

まとめ
#

学んだこと
#

項目内容
ファイルレイアウト分離環境・コンポーネントごとにディレクトリを分けて完全分離
outputs.tf他プロジェクトに公開したい値を定義
terraform_remote_state別プロジェクトのoutputを参照
templatefile外部ファイルに変数を埋め込む
削除順序依存する側 → 依存される側の順で削除

ワークスペース vs ファイルレイアウト
#

観点ワークスペースファイルレイアウト
設定の手軽さ簡単ディレクトリ構成が必要
権限分離困難(同一バックエンド)可能(別バックエンド)
可視性低い高い(ディレクトリ名で明確)
コード重複なしあり(ch4のモジュールで解決)
推奨用途個人開発・実験本番環境・チーム開発

チェックリスト
#

  • 各コンポーネントのkeyは一意か
  • RDSのoutputs.tfでaddress/portを出力しているか
  • terraform_remote_stateのkeyはRDSと一致しているか
  • 削除は依存関係の逆順で行っているか

参考
#