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 か #
コンソールだけを使う運用で出会う痛みは四つです。
- 再現不可 — staging を prod とまったく同じに立ち上げろ? 人の記憶では微細な差が常に残ります。
- 変更の追跡不可 — 「先週、誰が SG を変えたんだ?」となれば CloudTrail を漁らなければなりません。コードなら git log です。
- レビュー不可 — 運用クラスターの SG インバウンド一行の修正に同僚の目が入りません。
- 削除 / 再生成の負担 — 一つでも誤って作ると直すのが怖くなります。
IaC (Infrastructure as Code)はインフラを宣言的コードとして表現し、上の四つをまとめて押さえます。
| ツール | 役割 |
|---|---|
| Terraform | マルチクラウド、最も標準。本章の主役 |
| Pulumi | TypeScript / Python / Go で記述。動的ロジックに強い |
| AWS CDK | TypeScript / Python → CloudFormation へトランスパイル |
| CloudFormation | AWS ネイティブの YAML/JSON。動的表現が弱い |
| OpenTofu | Terraform の 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 だけが
terraform→tofuに変わります(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 の五つの構成要素 #
# 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 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 の三者の整合性を見たうえで変更計画を組みます。
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 をセットアップする一度のブートストラップ #
S3 と DynamoDB 自体は誰かが先に作らなければなりません。鶏と卵の問題です。二つの流れがあります。
- コンソール / CLI で一度手動生成(本章の前提)
- 別途「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この二つの資源は絶対に 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 を分離します。
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 }第22章 のコンソール作業がここで一つのファイルに集まりました。
モジュールの使用 #
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 統合 #
第24章 CI/CD の GitHub Actions とどうまとめるかです。
二つの流れ #
| 役割 | |
|---|---|
| A. インフラ / アプリの分離 | インフラ変更は別途 PR + apply、アプリのデプロイは image だけ更新 |
| B. 一つのワークフローにまとめる | image ビルド → terraform apply が新 image を service に |
最初は A を推奨します。インフラ変更は重く、アプリのデプロイは頻繁にあります。二つの流れの危険度が異なります。
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 レビューの段階で何が変わるかを一箇所で確認することが、運用事故をコードのマージ前に防ぐ最も効果的な方法です。
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 で開いて編集すると、ほぼ常に後悔します。代わりに 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_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 か」)を見ずに書き、各痛みを Terraform のどの機能(plan / git 履歴 / PR レビュー / state)が解決するかを一行ずつつなげてみてください。
- ローカルの
.tfstateが運用で危険な理由を三つ、§「state が壊れるとどうなるか」の表から整理し、S3 + DynamoDB backend がそれぞれどの危険を防ぐかを対にしてみてください。 terraform planの出力で-/+が見えるとき、なぜ止まって詳しく見なければならないかを、第23章 RDS 連携 のデータと結びつけて一段落で説明してみてください。prevent_destroyとdeletion_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秒かかったのか?」を一行で掴む流れまで扱います。