AWS実践 #4 IaC — Terraform 入門
#1 ~ #3 で作ったインフラは コンソール / CLI で手元にある 状態です。もう一度同じ形を立てろと言われたら — 記憶で? メモで? — 心もとない状態です。それを Terraform に移すのが今回のテーマです。
扱うこと:
- なぜ IaC — 再現性 / コードレビュー / drift 追跡
- Terraform の形 — provider、resource、data、variable、output、state
- state こそ核心 — S3 + DynamoDB lock backend
- モジュール — 再利用単位、環境別の分岐
- #1 の ECS インフラ を 1 行ずつコード化
なぜ IaC #
コンソールだけ使っていた頃に出会う痛み 4 つ:
- 再現不能 — staging を prod と同じに立ててと言われても、人の記憶では微妙な差が必ず残る
- 変更追跡不能 — 「先週誰が SG を変えたっけ?」 → CloudTrail を漁る。コードなら git log
- レビュー不能 — 本番クラスタの SG inbound 1 行修正に同僚の目が入らない
- 削除 / 再生成への恐怖 — 1 つでも間違って作ると修正が怖い
IaC (Infrastructure as Code) はインフラを 宣言的なコード で表現して、上の 4 つを一気に解決します。
| ツール | 役割 |
|---|---|
| Terraform | マルチクラウド、最も標準。今回の主役 |
| Pulumi | TypeScript / Python / Go で書く。動的ロジックに強い |
| AWS CDK | TypeScript / Python → CloudFormation トランスパイル |
| CloudFormation | AWS ネイティブ YAML/JSON。動的表現に弱い |
| OpenTofu | Terraform の OSS フォーク (ライセンス紛争後) |
このシリーズは Terraform で統一。会社方針で OpenTofu を使うとしても文法は同じ。
1) Terraform の 5 つのブロック #
# 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 init # provider をダウンロード、backend を初期化
terraform plan # 何が作られ / 変わり / 削除されるかを事前に見る
terraform apply # 適用
terraform destroy # 削除plan の出力こそ Terraform の最大の価値。事故をコードマージ前に止めます。
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 が差分を計算 できます。
実際の AWS のインフラ ←────── Terraform code
│
▼
state (最後の apply の結果)Terraform は code ↔ state ↔ AWS の 3 者の整合性を見て変更計画を組みます。
state が壊れるとどうなるか #
| 状況 | 結果 |
|---|---|
| state 紛失 | Terraform は「何も作っていない」と認識 → 既存リソースをまた作ろうとする |
| 二人が同時に apply | state が壊れるか、片方が他方の変更を上書き |
| state ファイルが git に平文 | パスワード / キーが漏れる (state には secret を含むリソースが多数) |
→ ローカルの .tfstate は学習用のみ。本番は リモート backend が必須。
S3 + DynamoDB Backend #
最もよくある本番パターン。
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 bucket | state ファイルの保管 (バージョン管理 + 暗号化を有効) |
| DynamoDB table | 同時 apply 阻止 — ロックテーブル |
| bucket key の prefix | <プロジェクト>/<環境>/terraform.tfstate のパターンで環境分離 |
| encrypt = true | KMS で自動暗号化 |
Backend をセットアップする 1 度のブートストラップ #
S3 と DynamoDB 自体は誰かが先に作る必要があります。鶏と卵 の問題。2 通り:
- コンソール / CLI で 1 度だけ手動作成 (この記事の前提)
- 別途 “bootstrap” フォルダで local backend で作り、その後 backend を S3 にマイグレーション
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 を分離:
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 で繰り返さないように。
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 }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 ファイルに集まりました。
モジュールの利用 #
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 = 1、multi_az = false、instance_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 コメントに #
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 が 差分を見せながら 「戻すか?」と問います。
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 で開いて編集 — ほぼ後悔します。代わりに:
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_instance の password、aws_secretsmanager_secret_version の secret_string — state に平文で入ります。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_providers に version 未指定 → 次の 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 行で押さえる流れまで。