AWS実践 #4 IaC — Terraform 入門

読了 8分

#1 ~ #3 で作ったインフラは コンソール / CLI で手元にある 状態です。もう一度同じ形を立てろと言われたら — 記憶で? メモで? — 心もとない状態です。それを Terraform に移すのが今回のテーマです。

扱うこと:

  • なぜ IaC — 再現性 / コードレビュー / drift 追跡
  • Terraform の形 — provider、resource、data、variable、output、state
  • state こそ核心 — S3 + DynamoDB lock backend
  • モジュール — 再利用単位、環境別の分岐
  • #1 の ECS インフラ を 1 行ずつコード化

なぜ IaC #

コンソールだけ使っていた頃に出会う痛み 4 つ:

  1. 再現不能 — staging を prod と同じに立ててと言われても、人の記憶では微妙な差が必ず残る
  2. 変更追跡不能 — 「先週誰が SG を変えたっけ?」 → CloudTrail を漁る。コードなら git log
  3. レビュー不能 — 本番クラスタの SG inbound 1 行修正に同僚の目が入らない
  4. 削除 / 再生成への恐怖 — 1 つでも間違って作ると修正が怖い

IaC (Infrastructure as Code) はインフラを 宣言的なコード で表現して、上の 4 つを一気に解決します。

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

このシリーズは Terraform で統一。会社方針で OpenTofu を使うとしても文法は同じ。

1) Terraform の 5 つのブロック #

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
}

5 つが集まって 1 つのインフラ単位になります。

ワークフロー 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 の 3 者の整合性を見て変更計画を組みます。

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 をセットアップする 1 度のブートストラップ #

S3 と DynamoDB 自体は誰かが先に作る必要があります。鶏と卵 の問題。2 通り:

  1. コンソール / CLI で 1 度だけ手動作成 (この記事の前提)
  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

この 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 (1 回だけ)

環境別の 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 }

#1 のコンソール作業がこの 1 ファイルに集まりました。

モジュールの利用 #

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 統合 #

#3 の GitHub Actions とどう束ねるか。

2 通りの流れ #

説明
A. インフラ / アプリの分離インフラ変更は別 PR + apply、アプリデプロイは image だけ更新
B. 1 つのワークフローに束ねるimage build → terraform apply が新 image を service に

最初は A 推奨。インフラ変更は重く、アプリデプロイは頻繁。2 つの流れの危険度が違います。

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 レビューの段階で 何が変わるかを 1 か所で確認 — 本番事故をコードマージ前に止める最も効果的なポイント。

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 コマンド
terraform state list                       # リソース一覧
terraform state show aws_ecr_repository.x  # 1 リソースの詳細
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 段階で止まります。

まとめ #

今回押さえたこと:

  • なぜ IaC — 再現 / 追跡 / レビュー / 安全な destroy
  • 5 つのブロック — provider、resource、data、variable、output
  • ワークフロー — init → plan → apply → destroy。plan こそ最大の価値
  • state — Terraform の核心。ローカル state は学習用
  • S3 + DynamoDB Backend — 本番標準、encrypt、versioning
  • Bootstrap — backend 自体はコンソール / 別の形
  • ディレクトリ構造 — modules/ + envs/{dev,prod}/、環境別 backend key 分離
  • モジュール — 同じパターンを変数で差別化。dev は軽く、prod はフル
  • CI/CD 統合 — Plan を PR コメントに、plan/apply 権限を分離
  • Drift 追跡plan -detailed-exitcode で定期に
  • 落とし穴 — ロック解除、state 編集、平文パスワード、-/+、provider バージョン、destroy 保護

次回 — モニタリング #

インフラがコードになり、デプロイが自動になりました。今度は 回っているか / うまく回っているか を本格的に見る番。

#5 モニタリング — CloudWatch アラームと X-Ray では ECS / RDS / ALB の核心メトリクス、Logs Insights の本番クエリ、アラームを Slack に飛ばす仕組み、そして X-Ray 分散トレースで「なぜこのリクエストだけ 5 秒かかったのか」を 1 行で押さえる流れまで。

X