
IaC(Infrastructure as Code)とは、サーバーやネットワークなどのインフラ構成をコードで記述し、ツールに作成・変更・削除を実行させる手法です。Terraformはその代表的なツールで、HCLという宣言的な言語で「あるべき状態」を書くと、現在の状態との差分を計算して必要なAPI呼び出しだけを行います。
題材はAWSの最小構成(VPC、パブリックサブネット、セキュリティグループ、EC2 1台)です。init / plan / apply / destroyの流れ、S3バックエンドでのstate管理、prevent_destroyやmovedブロックによる事故防止、GitHub ActionsからのOIDC実行までを扱います。
この記事でわかること
- Terraformの差分実行モデルとstateの役割、CloudFormation・Ansibleとの分担
- IAM Identity Centerの名前付きプロファイルで認証し、アクセスキーをコードに置かない運用
- VPC・サブネット・IGW・ルートテーブル・SG・EC2をHCLで定義する最小構成
backend "s3"とuse_lockfileによるロック、prevent_destroy・ignore_changes・movedブロック- OIDCでAWSに接続するGitHub Actionsのplan / applyと、AWS Budgetsによるコスト管理
IaCとは:Terraformの特徴と他ツールとの分担
Terraformの動作は3段階です。HCLのファイル群から「望ましい状態」を読み、stateファイルにある「前回適用した状態」と実際のAWSの状態をprovider経由で照合し、差分だけをAPI呼び出しに変換します。この差分計算があるため、同じコードを何度applyしても2回目以降は「No changes」になり、コードの変更点がそのままインフラの変更点になります。
執筆時点のTerraformはHashiCorp(IBM傘下)がBUSLライセンスで提供しており、ライセンス変更を受けてコミュニティが分岐させたOpenTofuというMPL 2.0の互換実装もあります。この記事のHCLは両方で動きますが、コマンドの細部やバックエンドの挙動はバージョンごとの公式ドキュメントで確認してください。
| 観点 | Terraform | CloudFormation | Ansible |
|---|---|---|---|
| 対象 | マルチクラウド・SaaS(providerがあるもの) | AWSのみ | 主にOS内の設定。クラウド用モジュールもある |
| 状態管理 | stateファイル(自分で保管) | AWS側がスタックとして保持 | 持たない(毎回実機を確認) |
| 差分の事前確認 | terraform plan | 変更セット | --check --diff |
| 向いている仕事 | VPC・IAM・EC2などリソースの作成と変更 | AWS純正で完結させたい、追加のツールを増やしたくない | EC2の中にパッケージや設定を入れる |
使い分けの基準は「AWSリソースの作成はTerraform、EC2の中身はAnsible」で、Ansibleでサーバー構築を自動化すると組み合わせると一連の流れになります。CloudFormationはstateの保管場所を用意しなくてよい利点があり、AWSしか使わずTerraformの学習コストを払いたくないチームには十分な選択肢です。
インストールとAWS認証の準備
# Terraform:HashiCorpのaptリポジトリ(執筆時点の公式手順)
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y terraform
terraform version
# AWS CLI v2 は公式のzipインストーラーで導入したうえで、SSOプロファイルを作る
aws configure sso --profile tf-lab
export AWS_PROFILE=tf-lab
aws sts get-caller-identity認証情報は.tfファイルにも環境変数にも直書きせず、AWS CLIのプロファイルに置きます。IAMユーザーのアクセスキーをaws configure --profile tf-labで登録する方法もありますが、長期有効なキーは漏えい時の被害が大きいので、IAM Identity Centerで短命の認証情報を取るaws configure ssoを勧めます。AWS_PROFILEを設定しておけば、aws providerは自動でそのプロファイルを使います。付与する権限は学習段階ならPowerUserAccess相当に留め、IAMを扱う段階で最小権限のポリシーに絞ります。
課金事故の防止策を先に入れてください。AWS Budgetsで月額のしきい値アラートを作り、検証が終わったらterraform destroyで全削除します。無料利用枠の対象と期間はアカウントの種類や時期で変わるため、AWS公式の無料利用枠ページで確認し、この記事の構成でも課金が発生し得る前提で進めてください。
プロバイダーと最小構成
terraform {
required_version = "~> 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "tf-lab"
ManagedBy = "terraform"
}
}
}~>は「右端の桁だけ上げてよい」という制約で、~> 6.0なら6.x系に固定されます。執筆時点のaws providerのメジャーバージョンはTerraform Registryで確認してください。default_tagsは全リソースに共通タグを付ける設定で、コスト配分とTerraform管理外リソースの見分けに効きます。terraform initを実行すると.terraform.lock.hclにproviderの正確なバージョンとハッシュが記録されるので、このファイルはGitにコミットして全員で同じproviderを使います。
VPC・サブネット・セキュリティグループ・EC2を定義する
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "tf-lab-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "tf-lab-web"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH from admin"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.admin_cidr]
}
ingress {
description = "HTTP"
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"]
}
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
resource "aws_key_pair" "admin" {
key_name = "tf-lab-admin"
public_key = file("~/.ssh/id_ed25519.pub")
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.admin.key_name
user_data = <<-EOT
#!/bin/bash
apt-get update && apt-get install -y nginx
EOT
tags = { Name = "tf-lab-web" }
}variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "admin_cidr" {
type = string
description = "SSHを許可する自分のグローバルIP(/32)"
validation {
condition = can(cidrhost(var.admin_cidr, 0)) && endswith(var.admin_cidr, "/32")
error_message = "admin_cidr は x.x.x.x/32 の形式で指定してください。"
}
}
output "public_ip" {
value = aws_instance.web.public_ip
}リソース同士はaws_vpc.main.idのような参照で結び、Terraformはこの参照から依存関係のグラフを作って作成順を決めます(VPC → サブネット → EC2の順に作られ、destroyは逆順)。data "aws_ami"はCanonicalのアカウントID099720109477が公開する最新のUbuntu 24.04 AMIをリージョンごとに引く読み取り専用のデータソースで、AMI IDをコードに直書きしなくて済みます。admin_cidrの値はterraform.tfvarsにadmin_cidr = "203.0.113.10/32"のように置き、このファイルは環境ごとに分けます。
SSH(22番)を0.0.0.0/0に開けると、起動から数分でブルートフォースのログが積み上がります。上のvalidationブロックは/32以外を拒否する保険で、社内ネットワークからならそのグローバルIP、外出先からならSSM Session Managerを使って22番自体を開けない構成が本来の落としどころです。
init・plan・apply・destroyの流れ
terraform init # provider取得、backend初期化、.terraform.lock.hcl生成
terraform fmt -recursive && terraform validate
terraform plan -out=tfplan # 差分を計算してファイルに保存
terraform apply tfplan # 保存したplanどおりに適用(再計算しない)
terraform output -raw public_ip
terraform destroy # 検証が終わったら全削除planの出力記号は+が作成、-が削除、~が更新、-/+が「削除して再作成」です。特に-/+と# forces replacementの表示は、EC2ならディスク上のデータが消えることを意味するので、applyの前に必ず読みます。-out=tfplanで保存したplanをapplyに渡すと、planと適用の間に誰かがリソースを変えていても意図しない差分が混ざらず、レビューした内容だけが適用されます。
stateの管理:S3バックエンドとロック
stateには全リソースのIDと属性が入り、Terraformはこれを「自分が管理している実体」の台帳として使います。stateを失うと既存リソースを認識できず、applyで二重に作ろうとし、destroyもできなくなります。ローカルのterraform.tfstateはGitにコミットせず、S3などのリモートバックエンドに置いてバージョニングを有効にします。
terraform {
backend "s3" {
bucket = "example-tfstate-123456789012"
key = "lab/network/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true # Terraform 1.10以降。S3だけでロックできる
}
}use_lockfile = trueはTerraform 1.10で入ったS3ネイティブのロック機能で、apply中に.tflockファイルを同じバケットに置いて同時実行を防ぎます。それ以前はDynamoDBテーブル(dynamodb_table引数)が必要でしたが、執筆時点ではS3だけで完結します。バケットは事前に作成し、バージョニング有効・パブリックアクセスブロック・暗号化をオンにしてください。ローカルからの移行はterraform init -migrate-stateで、既存のstateがS3へコピーされます。
stateにはDBのパスワードや生成した秘密鍵などが平文で入ります。バケットのアクセス権はTerraformを実行する人とCIのロールに限定し、terraform state pullの出力をチャットや課題管理に貼らないでください。
変数・出力・モジュール
変数の値はterraform.tfvars、環境変数TF_VAR_admin_cidr、-varオプションの順で後のものが優先されます。sensitive = trueを付けた変数はplan出力でマスクされ、outputはterraform output -raw public_ipのようにスクリプトやCIから値を取り出す窓口になります。
モジュールは複数リソースをひとまとめにする単位です。上のVPC一式をmodules/network/に移し、呼び出し側でsource = "./modules/network"と書き、module.network.public_subnet_idのようにモジュールのoutputを参照します。検証環境と本番で同じ構成を変数だけ変えて複製できます。Terraform Registryのterraform-aws-modules/vpc/awsのような公開モジュールを使う場合はversionを必ず固定し、.terraform.lock.hclと同様に更新を意図的に行います。
破壊的変更を防ぐ:lifecycleとレビュー
resource "aws_instance" "web" {
# ...
lifecycle {
prevent_destroy = true # destroy や再作成を伴う plan をエラーにする
ignore_changes = [ami] # AMIが更新されても再作成しない
}
}
# リソース名を変えるときは state 上の名前も一緒に移す
moved {
from = aws_instance.web
to = aws_instance.web_a
}prevent_destroy = trueは、そのリソースを消す・作り直すplanをエラーで止めます。ignore_changes = [ami]は、data "aws_ami"が新しいAMIを返してもEC2を再作成しないための設定で、これがないとCanonicalがAMIを更新した翌日のplanにforces replacementが現れます。movedブロックはリソース名の変更をstate上の移動として扱わせるもので、これを書かずに名前だけ変えると「旧名を削除して新名を作成」と解釈されます。
運用ルールは3つです。planの結果をPRに貼ってレビューを受けてからapplyする、-targetで部分適用しない(stateと実体がずれる原因になる)、緊急時以外はコンソールを手で触らない。手で変えてしまった差分はterraform planで検出され、Terraform管理下に取り込むにはimportブロックを使います。
動作確認
$ terraform apply tfplan
(中略)
Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
Outputs:
public_ip = "54.xxx.xxx.xxx"
$ terraform plan
No changes. Your infrastructure matches the configuration.
$ curl -sI "http://$(terraform output -raw public_ip)" | head -n 2
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
$ terraform state list | head -n 3
aws_instance.web
aws_internet_gateway.igw
aws_key_pair.admin8リソースが作られ、直後のplanが「No changes」になり、public_ipにHTTPで200が返れば、コードとstateと実体の3つが一致しています。nginxの応答が返るまではuser_dataのapt処理で1〜2分かかります。
つまずきポイント
| 症状・エラーメッセージ | 原因 | 対処 |
|---|---|---|
Error: No valid credential sources found | AWS_PROFILEが未設定、またはSSOセッションが期限切れ | aws sso login --profile tf-labで再ログインし、aws sts get-caller-identityで確認 |
Error: Error acquiring the state lock | 前回の実行が中断してロックが残った、または別の人がapply中 | 他に実行中の人がいないことを確認してから、メッセージ中のLock IDでterraform force-unlock <ID> |
Error: Instance cannot be destroyed(lifecycle.prevent_destroy) | 意図どおりの保護。再作成を伴う変更をしようとしている | 本当に作り直すならprevent_destroy = falseに変えるPRを別に出し、2段階で適用する |
Error: Backend initialization required, please run "terraform init" | backendブロックを追加・変更した後にinitしていない | terraform init -migrate-state(既存stateを移す)か-reconfigure |
CIでの実行とコスト管理
CIからAWSに触るときは、アクセスキーをSecretsに置かず、GitHub ActionsのOIDCトークンでIAMロールを引き受けます。AWS側にIDプロバイダーtoken.actions.githubusercontent.comを登録し、ロールの信頼ポリシーでsubクレームをrepo:組織名/リポジトリ名:ref:refs/heads/mainのように絞ると、そのリポジトリのmainブランチからしか引き受けられなくなります。ワークフローの基本はGitHub Actionsで始めるCI/CDを参照してください。
name: terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
id-token: write
contents: read
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-terraform
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.10.0" # required_version と合わせて固定
- run: terraform init -input=false
- run: terraform plan -input=false -no-color -out=tfplan
# PRにはplanの結果をコメントとして残し、mainでは environment の承認後に
# 別ジョブで terraform apply tfplan を実行するコスト面では、この構成にNAT Gatewayを入れていないのは意図的です。NAT Gatewayは通信がなくても時間課金が続くため、検証用途ではパブリックサブネット + IGWで済ませます。default_tagsのProjectタグをコスト配分タグとして有効化すると、Cost Explorerで「このコードが生んだ費用」だけを追え、AWS Budgetsのアラートと組み合わせれば消し忘れに気づけます。作業が終わった環境はterraform destroyで消し、残すのはS3のstateだけにするのが検証環境の基本です。
よくある質問
TerraformとCloudFormationの違いは?
CloudFormationはAWS純正でスタックの状態をAWS側が保持するため、state置き場が不要です。TerraformはAWS以外のクラウドやSaaSも同じHCLで扱え、planの読みやすさとモジュールのエコシステムが強みです。AWS単独で他ツールを増やしたくないならCloudFormation、複数サービスをまたぐならTerraformが合います。
stateファイルはなぜ重要?
Terraformが「どのリソースを自分が管理しているか」を知る唯一の台帳だからです。stateを失うと既存リソースを二重に作ろうとしたり、削除できなくなったりします。パスワードなどの機密も平文で含まれるため、S3にバージョニングと暗号化付きで置き、アクセス権を絞り、ロック(use_lockfile)で同時実行を防ぐのが基本です。
無料で試せますか?
Terraform自体は無料で利用できます。AWS側は無料利用枠の対象リソースと期間がアカウントの種類や時期で変わるため、公式ページで確認してください。この記事の構成は小さなインスタンス1台とVPCだけなので費用は限定的ですが、ゼロとは限りません。AWS Budgetsのアラートを設定し、検証後にterraform destroyする運用にすれば、想定外の請求は避けられます。
まとめ
Terraformの学習で最初に固めるべきは、HCLの文法よりもstateの置き場所とロック、認証情報の扱い、planを読む習慣の3つです。S3バックエンドにuse_lockfile = true、認証はIAM Identity CenterかOIDC、変更はplan -outの結果をレビューしてからapply。守りたいリソースにはprevent_destroy、AMI更新にはignore_changes、名前変更にはmovedを使えば、日常の変更で本番を壊す経路のほとんどを塞げます。
次に読む
- オンプレミスとクラウドの使い分け:IaC化する対象をどこに置くかの判断軸
- ITコスト削減の進め方:タグとBudgetsを起点にしたクラウド費用の見直し