はじめに
これまで IaC を触る機会がなかったので、代表的なツールである Terraform を使用してAWSリソースを作成してみました。
作成するリソース構成
今回は以下の構成で「自分のPCからのみパブリックアクセスできる、nginx が動く EC2サーバー」を作成します。
事前準備
Terraform / AWS CLI のインストール
Terraform 本体をインストールします。
% brew tap hashicorp/tap
% brew install hashicorp/tap/terraform
AWS を操作するための AWS CLI もインストールします。
% brew install awscli
インストールできたらバージョンを確認します。
% terraform version
% aws --version
AWSにログイン
ブラウザベースの認証を行いAWSにログインします。aws loginを実行すると自動でブラウザが開くので、AWSアカウントでサインインします。
% aws login
Attempting to open your default browser.
If the browser does not open, open the following URL:
https://ap-northeast-1.signin.aws.amazon.com/v1/authorize?...
ブラウザで認証が完了したら、ログインできているか確認します。
% aws sts get-caller-identity
Account(アカウントID)と Arn が表示されればログイン成功です。
作業フォルダの作成
任意の場所に作業用フォルダを作成します。
今回フォルダ名は「terraform-aws-handson」としています。
SSHキーペアの作成
EC2にSSH接続するためのキーペアを作成しておきます。
% ssh-keygen -t ed25519 -f ~/.ssh/iac-handson -N ""
-N "" は秘密鍵にパスフレーズを設定しない(空にする)オプションです。これにより、SSH接続時にパスフレーズの入力を求められなくなります。~/.ssh/iac-handson(秘密鍵)と ~/.ssh/iac-handson.pub(公開鍵)の2ファイルが作られます。
実装
基本のブロックと書式
Terraform のコードは、以下のようなブロックの集まりとして記述します。
ブロックの種類 "ラベル1" "ラベル2" {
引数 = 値
}
例えば resource "aws_vpc" "vpc" { ... } なら、種類が resource、ラベル1がリソースの種類(aws_vpc)、ラベル2が自分で付ける名前(vpc)です。ラベルの数はブロックの種類によって異なります。
主なブロックは以下のとおりです。
| ブロック | 定義する情報 |
|---|---|
terraform |
Terraform 本体のバージョンや、使用するプロバイダとそのバージョン |
provider |
プロバイダの設定 |
resource |
作成するリソース |
data |
他で作成済みのリソースや外部の情報を参照して取得する値 |
variable |
外部から受け取る入力値 |
output |
実行後に出力する値(他のモジュールからの参照にも使う) |
module |
再利用できるようにひとまとめにしたリソース群の呼び出し |
locals |
コード内で使い回すために名前を付けた値 |
フォルダ構成
Terraformでは、ディレクトリ内の全ての最上位ファイル(.tfおよび.tf.json)が1つのモジュールとして扱われます。
1つのファイルにまとめて記述することも可能ですが、可読性のため役割ごとにファイルを分けるのが慣例です。ただし特定のルールはなく、動作への影響もありません。
今回は用途別に、以下のファイルに分けて定義しました。
terraform-aws-handson/
├── providers.tf # Terraform / AWS プロバイダのバージョンと設定
├── variables.tf # 変数の定義
├── terraform.tfvars # 変数の値
├── .gitignore
├── network.tf # リソース定義(ネットワーク系)
├── security.tf # リソース定義(セキュリティグループ)
├── ec2.tf # リソース定義(EC2)
└── outputs.tf # 出力したい値
providers.tf
使用する Terraform 及び プロバイダのバージョンの指定、AWSプロバイダの設定を行います。
プロバイダ
Terraform が外部サービスのAPIを操作するためのプラグイン。
Terraform 本体はリソースの状態管理と実行計画の作成を担当するのみ。「EC2 インスタンスを作る」等の実際の操作はプロバイダが行う。
terraform {
required_version = ">= 1.9" # Terraformのバージョン指定
required_providers {
aws = {
source = "hashicorp/aws" # Terraform Registry 上の AWS用プロバイダ
version = "~> 6.0" # プロバイダのバージョン指定
}
}
}
provider "aws" {
region = var.region # awsのリージョン指定 ※変数から受け取る
# tags に対応するリソースへ共通タグを自動付与する。
default_tags {
tags = {
Project = var.project
ManagedBy = "Terraform"
}
}
}
variables.tf
コード内で扱う変数を宣言します。
変数の値は後述の terraform.tfvars で指定しています。
なお、値が指定されなかった場合は、default 値があればそれが使用されます。なければ実行時に入力を求められます。
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "project" {
type = string
default = "iac-handson"
}
variable "my_ip" {
description = "接続元のグローバルIP (CIDR表記)" # 値が未指定のとき入力プロンプトに表示される説明
type = string
}
variable "instance_type" {
type = string
default = "t2.micro"
}
terraform.tfvars
variables.tf で宣言した変数の値を設定します。
今回は、クライアントPCのグローバルIPを変数my_ipに設定します。
以下のコマンドで自分のグローバルIPを取得し、それを.tfvarsへ書き出します。
# checkip で自分のグローバルIPを取得して書き出す。
# このIPをセキュリティグループの許可対象にすることで、自分だけがアクセスできる状態にする。
echo "my_ip = \"$(curl -s https://checkip.amazonaws.com)/32\"" > terraform.tfvars
# 確認
cat terraform.tfvars # 例: my_ip = "203.0.113.10/32"
.gitignore
以下のファイルをGitから除外します。
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
crash.log
| ファイル名 | 用途 | 除外理由 |
|---|---|---|
.terraform/ |
terraform init でダウンロードしたプロバイダ本体(プラグイン)などが置かれるディレクトリ |
サイズが大きく、init すればいつでも再取得できるため |
*.tfstate / *.tfstate.*
|
terraform apply 時に自動生成される state 本体とそのバックアップ |
機密値(生成されたパスワードや鍵など)が平文で入り、公開すると情報漏洩に繋がるため |
crash.log |
Terraform がクラッシュしたときだけ自動生成されるログ | 一時的なデバッグ用でコードと無関係なため |
*.tfvars |
変数に入れる具体的な値を書いたファイル(これのみ手動作成) | 環境固有の値(今回は自分のIP)が入るため |
state(tfstate)
Terraform が「どのコードが、実際のどのリソースに対応するか」を記録するファイル。Terraform はこれとコードを突き合わせて差分を検出し、実際のリソースへ反映する。
network.tf
ネットワーク一式(VPC・サブネット・ゲートウェイなど)を定義します。
# VPC
resource "aws_vpc" "vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true # VPC内のDNS解決(Amazon提供DNS)を有効化
enable_dns_hostnames = true # パブリックIPを持つEC2にパブリックDNS名を割り当て(dns_supportとの併用が前提)
tags = { Name = "${var.project}-vpc" }
}
# IGW
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.vpc.id # VPCに紐付け
tags = { Name = "${var.project}-igw" }
}
# data = 既存情報の取得用途
# 利用可能なAZ一覧を取得する
data "aws_availability_zones" "available" {
state = "available"
}
# サブネット
resource "aws_subnet" "public" {
vpc_id = aws_vpc.vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0] # 取得したAZの先頭を使用
map_public_ip_on_launch = true # 起動したEC2に自動でパブリックIPを付与
tags = { Name = "${var.project}-public-subnet" }
}
# ルーティングテーブル
resource "aws_route_table" "public" {
vpc_id = aws_vpc.vpc.id
route {
# インターネット宛の通信を全てIGWに送る
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = { Name = "${var.project}-public-rt" }
}
# ルーティングテーブルをサブネットに関連付け
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
security.tf
セキュリティグループを定義します。
# セキュリティグループ
resource "aws_security_group" "web" {
name = "${var.project}-web-sg"
description = "Allow SSH and HTTP from my IP"
vpc_id = aws_vpc.vpc.id
tags = { Name = "${var.project}-web-sg" }
}
# inboundルール:自分のIPからのSSH通信を許可
resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = var.my_ip # 自分のIPのみ許可
from_port = 22 # SSH
to_port = 22
ip_protocol = "tcp"
description = "SSH from my IP"
}
# inboundルール:自分のIPからのHTTP通信を許可
resource "aws_vpc_security_group_ingress_rule" "http" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = var.my_ip # 自分のIPのみ許可
from_port = 80 # HTTP
to_port = 80
ip_protocol = "tcp"
description = "HTTP from my IP"
}
# outboundルール:全て許可
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1" # -1 = 全プロトコル
}
ec2.tf
EC2 インスタンス本と、起動に必要なリソース(キーペア・AMI取得)を定義します。
# SSH接続用の公開鍵を AWS に登録
resource "aws_key_pair" "ssh" {
key_name = "${var.project}-key"
public_key = file("~/.ssh/iac-handson.pub")
}
# 使うAMIを取得
data "aws_ami" "al2023" {
most_recent = true # 最新を選ぶ
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"] # Amazon Linux 2023
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id # 取得したAMI
instance_type = var.instance_type # インスタンスタイプ(デフォルト t2.micro)
subnet_id = aws_subnet.public.id # 作成したサブネットに配置
vpc_security_group_ids = [aws_security_group.web.id] # 作成したSGを適用
key_name = aws_key_pair.ssh.key_name # 登録したキーペアを使用
# 起動時に一度だけ実行されるスクリプト。nginxを入れて起動し、サンプルHTMLを配置
user_data = <<-EOF
#!/bin/bash
dnf install -y nginx
echo "<h1>Provisioned by Terraform</h1>" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
EOF
tags = { Name = "${var.project}-web" }
}
aws_key_pair では、事前準備で作成した鍵ペアのうち公開鍵(~/.ssh/iac-handson.pub)だけを AWS に登録します。対になる秘密鍵(~/.ssh/iac-handson)はローカルに置いておき、後述の SSH で接続 で使います。
outputs.tf
リソースの作成後に、ターミナルに出力したい値を定義します。
output "instance_public_ip" {
value = aws_instance.web.public_ip # 作成したEC2のIP
}
output "ssh_command" {
# SSH接続できるコマンド文字列(コピペ用)を組み立てて表示
value = "ssh -i ~/.ssh/iac-handson ec2-user@${aws_instance.web.public_ip}"
}
output "ami_id" {
description = "data sourceで解決されたAMI ID"
value = data.aws_ami.al2023.id # 実際に使用AMIを確認用に出力
}
実行
記述したコードを元に、以下のコマンドを叩いて実際にリソースを作成していきます。
terraform init
最初に実行するコマンドです。
providers.tf で指定したプロバイダ(今回は AWS)がダウンロードされ、作業ディレクトリが初期化されます。
% terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.62.0...
- Installed hashicorp/aws v6.62.0 (signed by HashiCorp)
中略
Terraform has been successfully initialized!
完了すると、.terraformディレクトリが作成されてプロバイダ本体が格納され、選択されたバージョンを記録する .terraform.lock.hclも生成されます。
terraform plan
コードを適用すると作成・変更・削除されるリソースを、事前に確認します。
% terraform plan
data.aws_availability_zones.available: Reading...
data.aws_ami.al2023: Reading...
data.aws_availability_zones.available: Read complete after 0s [id=ap-northeast-1]
data.aws_ami.al2023: Read complete after 0s [id=ami-02aa60981f501ed4b]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-02aa60981f501ed4b"
+ instance_type = "t2.micro"
+ key_name = "iac-handson-key"
+ subnet_id = (known after apply)
+ vpc_security_group_ids = (known after apply)
+ user_data = <<-EOT
#!/bin/bash
dnf install -y nginx
echo "<h1>Provisioned by Terraform</h1>" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
EOT
+ tags = {
+ "Name" = "iac-handson-web"
}
# ...(以下略)
}
中略 ※VPC・サブネット・IGW・ルートテーブル・キーペアなども同様に表示される
# 自分のIPからのみ許可されていることが確認できる(tfvars で指定したIP)
# aws_vpc_security_group_ingress_rule.ssh will be created
+ resource "aws_vpc_security_group_ingress_rule" "ssh" {
+ cidr_ipv4 = "126.123.223.56/32" # 自分のIP
+ description = "SSH from my IP"
+ from_port = 22
+ to_port = 22
+ ip_protocol = "tcp"
+ security_group_id = (known after apply)
# ...(以下略)
}
Plan: 11 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ ami_id = "ami-02aa60981f501ed4b"
+ instance_public_ip = (known after apply)
+ ssh_command = (known after apply)
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply"
now.
新規作成されるリソースには + create が付き、末尾の Plan: 11 to add, 0 to change, 0 to destroy. で今回は11個のリソースが作成されることが分かります。
terraform apply
plan と同じ実行計画が表示され、最後に実行の確認を求められます。yes を入力すると、実際に AWS 上へリソースが作成されます。
% terraform apply
中略 ※planと同じ出力
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_key_pair.ssh: Creating...
aws_vpc.vpc: Creating...
aws_key_pair.ssh: Creation complete after 0s [id=iac-handson-key]
aws_vpc.vpc: Creation complete after 2s [id=vpc-0f3ea994072d70803]
aws_internet_gateway.igw: Creating...
aws_security_group.web: Creating...
aws_subnet.public: Creating...
aws_internet_gateway.igw: Creation complete after 1s [id=igw-0993bd04c62568fba]
aws_route_table.public: Creating...
aws_route_table.public: Creation complete after 1s [id=rtb-02bdf8893955fedc6]
aws_security_group.web: Creation complete after 2s [id=sg-016c43f17e6145a8d]
aws_vpc_security_group_ingress_rule.http: Creating...
aws_vpc_security_group_ingress_rule.ssh: Creating...
aws_vpc_security_group_egress_rule.all: Creating...
aws_vpc_security_group_ingress_rule.http: Creation complete after 0s [id=sgr-0d4a4e76cfb36abde]
aws_vpc_security_group_egress_rule.all: Creation complete after 0s [id=sgr-07ee7d08fbc4acdcf]
aws_vpc_security_group_ingress_rule.ssh: Creation complete after 0s [id=sgr-0a514fef8aa241d5a]
aws_subnet.public: Still creating... [00m10s elapsed]
aws_subnet.public: Creation complete after 11s [id=subnet-00b9945414fb57d04]
aws_route_table_association.public: Creating...
aws_instance.web: Creating...
aws_route_table_association.public: Creation complete after 1s [id=rtbassoc-08a3987cfd632dea6]
aws_instance.web: Still creating... [00m10s elapsed]
aws_instance.web: Creation complete after 14s [id=i-09ac90ffc9ba1bc77]
Apply complete! Resources: 11 added, 0 changed, 0 destroyed.
Outputs:
ami_id = "ami-02aa60981f501ed4b"
instance_public_ip = "x.x.x.x"
ssh_command = "ssh -i ~/.ssh/iac-handson ec2-user@52.196.113.92"
完了すると outputs.tf で定義した値が表示されます。
動作確認
まず AWS コンソールでリソースが構成図どおりに作られているかを確認し、
続いてブラウザと SSH で、実際にサーバーへアクセスできることを確認します。
コンソール
コードどおりにリソースが作られているかを確認していきます。
VPC / サブネット / ルートテーブル / IGW
VPC コンソールで、iac-handson-vpc(10.0.0.0/16)が作成されていることを確認します。

合わせて、リソースマップで サブネット、Internet Gateway、ルートテーブルも確認します。

セキュリティグループ
iac-handson-web-sg が作成され、インバウンドルールとアウトバウンドルールが設定されていることを確認します。


EC2
EC2 コンソールで iac-handson-web が running になっていることを確認します。パブリック IP が outputs の instance_public_ip と一致していれば OK です。

ブラウザで確認
instance_public_ip をブラウザで開きます。user_data で入れた nginx が起動し、Provisioned by Terraform が表示されれば成功です。

SSH で接続
最後に、outputs の ssh_command をそのまま実行して SSH 接続してみます。初回は接続先の確認を求められるので yes を入力します。Amazon Linux 2023 のロゴが表示されれば接続成功です。
% ssh -i ~/.ssh/iac-handson ec2-user@52.196.113.92
The authenticity of host '52.196.113.92 (52.196.113.92)' can't be established.
ED25519 key fingerprint is: SHA256:V+5iU1t3YcmMo+7v/yENr0sqY5tMlT0EyRaLCH3HCB8
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '52.196.113.92' (ED25519) to the list of known hosts.
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
, #_
~\_ ####_ Amazon Linux 2023
~~ \_#####\
~~ \###|
~~ \#/ ___ https://aws.amazon.com/linux/amazon-linux-2023
~~ V~' '->
~~~ /
~~._. _/
_/ _/
_/m/'
これで、コードから作ったサーバーに実際にアクセスできることを確認できました。
terraform destroy
動作確認が終わったら、作成したリソースを削除します。terraform destroy は、state に記録されているリソースをまとめて削除するコマンドです。
apply と同様に確認を求められるので、yes を入力すると削除が実行されます。
% terraform destroy
...
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
...
Destroy complete! Resources: 11 destroyed.
今回使用しなかった機能
今回は使いませんでしたが、Terraform には以下のような機能もあります。
リモートstate
今回は state をローカル(作業フォルダの terraform.tfstate)で管理していましたが、チームで同じ構成を扱う場合は、state を共有しないと各自の手元で変更が競合したり食い違いが起きます。
そこで state を S3 などのリモートに置くのが backend ブロック(慣例的に backend.tf に書く)の役割です。リモートに置くと state を共有できるうえ、ロックによって同時変更の競合も防げます。
モジュール
今回は .tf ファイルに直接リソースを書きましたが、同じ構成を何度も作成するケース(例: dev / prod で同じネットワークを複製)では、リソースのまとまりを modules/ に切り出して再利用します。
おわりに
Terraform を使って、コードから AWS 上に nginx が動く EC2 サーバーを立てるところまでを一通り体験しました。
今回のシンプルな構成でもコードで管理できるIaCの恩恵を感じられましたが、実務でより複雑な構成を扱うほど、その恩恵は大きくなりそうだと感じました。