目次
25 章

IaC — Terraform 入門

なぜ IaC か、Terraform の provider / resource / state の形、S3 + DynamoDB backend でのチーム協業、モジュールによる環境分離、そして前章のインフラを1つずつコード化する流れまで整理します。

第22章 ~ 第24章 で作ったインフラは、依然としてコンソールと CLI で直接触っています。もう一度同じ構成を立ち上げてみろと言われたら — 記憶で? メモで? — 揺らぎます。その作業を Terraform へ移すのが本章です。

4部の四番目の章として、扱う内容は次のとおりです。

  • なぜ IaC か — 反復性 / コードレビュー / drift 追跡
  • Terraform の構造 — provider, resource, data, variable, output, state
  • state こそが本当の核心 — S3 + DynamoDB lock backend
  • モジュール — 再利用の単位、環境別の分岐
  • 22章の ECS インフラを1つずつコード化

なぜ IaC か #

コンソールだけを使う運用で出会う痛みは四つです。

  1. 再現不可 — staging を prod とまったく同じに立ち上げろ? 人の記憶では微細な差が常に残ります。
  2. 変更の追跡不可 — 「先週、誰が SG を変えたんだ?」となれば CloudTrail を漁らなければなりません。コードなら git log です。
  3. レビュー不可 — 運用クラスターの SG インバウンド一行の修正に同僚の目が入りません。
  4. 削除 / 再生成の負担 — 一つでも誤って作ると直すのが怖くなります。

IaC (Infrastructure as Code)はインフラを宣言的コードとして表現し、上の四つをまとめて押さえます。

ツール役割
Terraformマルチクラウド、最も標準。本章の主役
PulumiTypeScript / Python / Go で記述。動的ロジックに強い
AWS CDKTypeScript / Python → CloudFormation へトランスパイル
CloudFormationAWS ネイティブの YAML/JSON。動的表現が弱い
OpenTofuTerraform の OSS フォーク(ライセンス紛争後)

この本は Terraform で統一します。ただし2026年時点では Terraform のライセンス変化と OpenTofu という選択肢を知っておく必要があるので、ツールを本格的に使う前に一度触れておきます。

Terraform vs OpenTofu — 2026 の分かれ道 #

2023年に HashiCorp が Terraform のライセンスをオープンソース(MPL 2.0)から BSL(Business Source License) 1.1 へ変更しました。個人とほとんどの会社はそのまま使えますが、Terraform で競合製品を作ることは制限されます。これを受けてコミュニティが最後の MPL バージョンをフォークして作ったのが OpenTofu で、Linux Foundation の下でオープンソースとして管理されます。2025年には IBM が HashiCorp を買収しました。

重要なのは 両者が事実上互換 だという点です。

  • 同じ HCL 文法、同じプロバイダエコシステム、互換性のある state。
  • CLI だけが terraformtofu に変わります(tofu init / tofu plan / tofu apply)。
  • 本書のすべての .tf コードは Terraform と OpenTofu の両方でそのまま動作します。
選ぶとき選択
完全なオープンソース / コミュニティガバナンス / BSL 回避が重要OpenTofu
HCP Terraform(クラウド state · ポリシー · チーム機能)や商用サポートが必要Terraform
学習 / サイドプロジェクトどちらでも可(文法は同じ)

2026年現在、OpenTofu は十分に成熟し、production 導入事例(Boeing · Capital One など)が増えています。本書は説明とコマンドを terraform 基準で書きますが、会社が OpenTofu を使うなら コマンドを tofu に読み替えれば そのまま通用します。

1) Terraform の五つの構成要素 #

main.tf — 最も小さな構造
# 1) Provider — AWS とどう通信するか
provider "aws" {
  region = "ap-northeast-2"
}

# 2) Resource — 実際に作るインフラ
resource "aws_ecr_repository" "blog_api" {
  name                 = "blog-api"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

# 3) Data — すでにあるリソースを照会
data "aws_caller_identity" "current" {}

# 4) Variable — 外部入力
variable "environment" {
  type    = string
  default = "dev"
}

# 5) Output — 作った結果を露出
output "ecr_url" {
  value = aws_ecr_repository.blog_api.repository_url
}

五つの構成要素が一つのファイルに集まると一つのインフラ単位になります。

ワークフロー4段階 #

Terraform の 4 段階
terraform init      # provider ダウンロード、backend 初期化
terraform plan      # 何が作られ/変わり/削除されるかをプレビュー
terraform apply     # 適用
terraform destroy   # 削除

plan の出力が Terraform の最も大きな価値です。事故をコードのマージ前に防ぎます。

plan 出力の例
Terraform will perform the following actions:

  # aws_security_group.fargate will be created
  + resource "aws_security_group" "fargate" {
      + arn                    = (known after apply)
      + name                   = "sg-fargate"
      + ingress = [
          + {
              + from_port = 8000
              + to_port   = 8000
              + protocol  = "tcp"
              + ...
            },
        ]
    }

Plan: 1 to add, 0 to change, 0 to destroy.

+ 追加 / ~ 変更 / - 削除 / -/+ 再生成(ID が変わるとひどいので常に意識)です。

2) State — 本当の核心 #

Terraform は state(.tfstate ファイル)に「これまで作ったインフラの状態」を保存します。このファイルがあってこそ、次の plan が差分を計算できます。

state の役割
実際の AWS のインフラ     ←──────  Terraform code
                           state (最後の apply の結果)

Terraform は code ↔ state ↔ AWS の三者の整合性を見たうえで変更計画を組みます。

state が壊れるとどうなるか #

状況結果
state の紛失Terraform は「何も作ったことがない」と認識 → すでにあるリソースをもう一度作ろうとする
二人が同時に applystate が壊れるか、一方が他方の変更を上書きする
state ファイルが git に平文パスワード / キーの露出(state の中に secret がある資源が多数)

したがって、ローカルの .tfstate は学習用のみです。運用はリモート backend が必須です。

S3 + DynamoDB Backend #

最もよくある運用パターンです。

backend.tf
terraform {
  required_version = ">= 1.7"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "myorg-terraform-state"
    key            = "blog-api/prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

構成を整理すると次のとおりです。

役割
S3 bucketstate ファイルの保存(バージョン管理 + 暗号化を有効)
DynamoDB table同時 apply の遮断 — ロックテーブル
bucket key の prefix<プロジェクト>/<環境>/terraform.tfstate パターンで環境を分離
encrypt = trueKMS で自動暗号化

Backend をセットアップする一度のブートストラップ #

S3 と DynamoDB 自体は誰かが先に作らなければなりません。鶏と卵の問題です。二つの流れがあります。

  1. コンソール / CLI で一度手動生成(本章の前提)
  2. 別途「bootstrap」フォルダで local backend として作り、その後 backend を S3 へマイグレーション
bootstrap
aws s3api create-bucket \
  --bucket myorg-terraform-state \
  --region ap-northeast-2 \
  --create-bucket-configuration LocationConstraint=ap-northeast-2

aws s3api put-bucket-versioning \
  --bucket myorg-terraform-state \
  --versioning-configuration Status=Enabled

aws dynamodb create-table \
  --table-name terraform-state-lock \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region ap-northeast-2

この二つの資源は絶対に Terraform で destroy しません。 state がその中に住んでいます。

3) ディレクトリ構造 — 環境別の分離 #

実戦の形
infra/
├─ modules/
│   ├─ network/        ← VPC, Subnets, SGs
│   ├─ ecs-service/    ← ALB + Service + Auto Scaling
│   └─ rds/            ← DB
├─ envs/
│   ├─ dev/
│   │   ├─ main.tf
│   │   ├─ backend.tf
│   │   ├─ variables.tf
│   │   └─ terraform.tfvars
│   └─ prod/
│       ├─ main.tf
│       ├─ backend.tf
│       ├─ variables.tf
│       └─ terraform.tfvars
└─ bootstrap/          ← S3 / DynamoDB (一度だけ)

環境別の backend key を違えて state を分離します。

envs/dev/backend.tf
terraform { backend "s3" {
  bucket         = "myorg-terraform-state"
  key            = "blog-api/dev/terraform.tfstate"
  region         = "ap-northeast-2"
  dynamodb_table = "terraform-state-lock"
}}

こうすると dev と prod が完全に分離されます。dev の apply が prod state を触ることがありません。

4) モジュール — 再利用の単位 #

同じインフラパターンを dev / prod で繰り返さないようにモジュールでまとめます。

modules/ecs-service/variables.tf
variable "name"          { type = string }
variable "cluster_arn"   { type = string }
variable "image"         { type = string }
variable "vpc_id"        { type = string }
variable "subnet_ids"    { type = list(string) }
variable "alb_sg_id"     { type = string }
variable "desired_count" { type = number, default = 2 }
variable "cpu"           { type = string, default = "512" }
variable "memory"        { type = string, default = "1024" }
variable "container_port" { type = number, default = 8000 }
modules/ecs-service/main.tf (抜粋)
resource "aws_security_group" "fargate" {
  name        = "sg-${var.name}-fargate"
  description = "Fargate task SG"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [var.alb_sg_id]
  }

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

resource "aws_lb_target_group" "this" {
  name        = "tg-${var.name}"
  port        = var.container_port
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = var.vpc_id

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    interval            = 15
  }
}

resource "aws_ecs_task_definition" "this" {
  family                   = var.name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name  = "api"
    image = var.image
    portMappings = [{ containerPort = var.container_port, protocol = "tcp" }]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.this.name
        "awslogs-region"        = data.aws_region.current.name
        "awslogs-stream-prefix" = "api"
      }
    }
  }])
}

resource "aws_ecs_service" "this" {
  name            = var.name
  cluster         = var.cluster_arn
  task_definition = aws_ecs_task_definition.this.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.subnet_ids
    security_groups  = [aws_security_group.fargate.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.this.arn
    container_name   = "api"
    container_port   = var.container_port
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }
}

output "target_group_arn" { value = aws_lb_target_group.this.arn }
output "service_name"     { value = aws_ecs_service.this.name }

第22章 のコンソール作業がここで一つのファイルに集まりました。

モジュールの使用 #

envs/prod/main.tf
module "network" {
  source       = "../../modules/network"
  name         = "blog-prod"
  cidr         = "10.0.0.0/16"
  azs          = ["ap-northeast-2a", "ap-northeast-2c"]
}

module "rds" {
  source            = "../../modules/rds"
  name              = "blog-prod"
  vpc_id            = module.network.vpc_id
  db_subnet_ids     = module.network.db_subnet_ids
  fargate_sg_id     = module.api.fargate_sg_id
  multi_az          = true
  instance_class    = "db.t4g.small"
  deletion_protection = true
}

module "api" {
  source         = "../../modules/ecs-service"
  name           = "blog-prod"
  cluster_arn    = aws_ecs_cluster.blog.arn
  image          = var.image  # CI が注入
  vpc_id         = module.network.vpc_id
  subnet_ids     = module.network.private_subnet_ids
  alb_sg_id      = module.network.alb_sg_id
  desired_count  = 4
  cpu            = "1024"
  memory         = "2048"
}

dev 環境は desired_count = 1multi_az = falseinstance_class = "db.t4g.micro" で軽い形です。同じモジュール + 違う変数が核心です。

5) Terraform ↔ CI/CD 統合 #

第24章 CI/CD の GitHub Actions とどうまとめるかです。

二つの流れ #

役割
A. インフラ / アプリの分離インフラ変更は別途 PR + apply、アプリのデプロイは image だけ更新
B. 一つのワークフローにまとめるimage ビルド → terraform apply が新 image を service に

最初は A を推奨します。インフラ変更は重く、アプリのデプロイは頻繁にあります。二つの流れの危険度が異なります。

Plan を PR コメントに #

.github/workflows/terraform-plan.yml
name: Terraform Plan
on:
  pull_request:
    paths: ['infra/**']

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run: { working-directory: infra/envs/prod }
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
          aws-region: ap-northeast-2
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: 1.9.0 }
      - run: terraform init
      - run: terraform plan -no-color -out=tfplan
      - name: Comment Plan
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const out = require('child_process')
              .execSync('terraform show -no-color tfplan', { cwd: 'infra/envs/prod' });
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '```\n' + out + '\n```'
            });

PR レビューの段階で何が変わるかを一箇所で確認することが、運用事故をコードのマージ前に防ぐ最も効果的な方法です。

terraform-plan 役割は read-only 権限で十分です。apply 権限は別の役割に置きます。

6) Drift 追跡 #

コンソールで手で変えた資源は state とずれます(drift)。terraform plan差分を見せながら「元に戻すか?」を尋ねます。

定期 drift 点検
terraform plan -detailed-exitcode
# exit 0 = 差分なし
# exit 2 = 差分あり (失敗ではない)

CI で毎日一度回し、exit 2 なら Slack に知らせます。

落とし穴 — Terraform 運用の落とし穴 #

1) state のロック解除がされない #

apply が ctrl-c で中断されると DynamoDB ロックがそのまま残ります。次の apply が「Resource locked」で失敗します。

強制解除 (慎重に)
terraform force-unlock <LOCK_ID>

LOCK_ID はエラーメッセージに表示されます。他の人が本当に作業中でないかを必ず確認してから行います。

2) state の手動編集 #

.tfstate を vim で開いて編集すると、ほぼ常に後悔します。代わりに state コマンドを使います。

state コマンド
terraform state list                       # 資源の一覧
terraform state show aws_ecr_repository.x  # 一つの資源の詳細
terraform state rm aws_ecr_repository.x    # state から除去 (実際の資源は消さない)
terraform state mv module.a.x module.b.x   # 資源の移動
terraform import aws_ecr_repository.x my-repo  # 既存の資源を state に登録

3) パスワードが state に平文 #

aws_db_instancepasswordaws_secretsmanager_secret_versionsecret_string は state に平文で入ります。state bucket の暗号化 + アクセス制限が必須です。

state bucket ポリシー (例)
data "aws_iam_policy_document" "state_bucket" {
  statement {
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = ["arn:aws:s3:::myorg-terraform-state/*"]
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

4) -/+ destroy/create #

plan で -/+ が見えると資源 ID が変わります。RDS ならデータ損失です。詳しく見るべき部分です。

  # aws_db_instance.blog must be replaced
-/+ resource "aws_db_instance" "blog" {
      ~ engine_version = "16.3" -> "17.0"  # forces replacement
    }

こうした変更は別途マイグレーション手順で行います。RDS は in-place アップグレードのオプションが別にあります。

5) Provider バージョンを固定しない #

required_providersversion を未指定にすると、次の init で壊れることがあります。常に ~> 5.0 のようなパターンを使います。

6) terraform destroy の事故 #

prod 環境で誤って destroy する場合です。保護装置を置きます。

重要な資源の保護
resource "aws_db_instance" "blog" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

prevent_destroy = true がある資源は destroy / replace が plan 段階で塞がれます。

練習問題 #

  1. コンソールだけを使う運用の四つの痛み(§「なぜ IaC か」)を見ずに書き、各痛みを Terraform のどの機能(plan / git 履歴 / PR レビュー / state)が解決するかを一行ずつつなげてみてください。
  2. ローカルの .tfstate が運用で危険な理由を三つ、§「state が壊れるとどうなるか」の表から整理し、S3 + DynamoDB backend がそれぞれどの危険を防ぐかを対にしてみてください。
  3. terraform plan の出力で -/+ が見えるとき、なぜ止まって詳しく見なければならないかを、第23章 RDS 連携 のデータと結びつけて一段落で説明してみてください。prevent_destroydeletion_protection がそれぞれ何を防ぐかも区別して書いてみてください。

一行まとめ: IaC はインフラを宣言的コードにして、再現・追跡・レビュー・安全な削除をまとめて押さえます。Terraform は provider / resource / data / variable / output の五要素で組まれ、init → plan → apply → destroy で回ります。核心は state であり、運用は S3 + DynamoDB backend が必須です。同じモジュールに違う変数を与えて dev/prod を分離し、plan を PR コメントに出してマージ前に事故を防ぎ、-/+ 再生成と terraform destroy は lifecycle で保護します。

次の章 #

インフラがコードになり、デプロイが自動になりました。これで動いているか、うまく動いているかを本格的に見る番です。次の 第26章 モニタリング — CloudWatch アラームと X-Ray では、ECS / RDS / ALB の核となるメトリクス、Logs Insights の運用クエリ、アラームを Slack へ送る流れ、そして X-Ray の分散トレースで「なぜこのリクエストだけ5秒かかったのか?」を一行で掴む流れまで扱います。

X