Next.js × Rails|Terraform importで“手動デプロイAWS”をIaC化する
以前、Terraformでインフラをコード化することを学びました。
現場では、最初からTerraformでデプロイすることより、
手動デプロイしたものをTerraform化するシチュエーションのほうが多いと聞きました。
それに近い形でトレーニングしたいので、
手動デプロイできた自作アプリを、
Terraform化をしていきたいと思います。
この記事では、手動デプロイでどのように設定しているかを説明した後、
コードを掲載して、さらに解説する形式で進めていきたいと思います。
バージョン情報
- Terraform 1.14.0
- AWS Provider 6.25
アーキテクチャ図

NATゲートウェイは高いので、
Fargateをパブリックサブネットに置いて、
セキュリティグループで、ALBのみアクセスできるようにしています。
準備編
インフラをコード化する前に、
事前準備が必要です。
AWS CLIをインストール

https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/getting-started-install.html
公式に飛びます。
OSを選択します。
タブからGUIインストーラーを選びます。
Macならばpkgを、WinならばmsiをDLします。
インストールします。
aws --version
インストール完了したら、
ターミナルでこれを入力してバージョンが返ってくれば成功です。
TerraformのIAMユーザーを作成
現在は、ルートユーザーと、
AdministratorAccessの権限を持った、
手動デプロイで使っている実行ユーザーの、
2つのユーザーが存在しています。
Terraform用に新たなユーザーを作成します。
ルートユーザーでログインして、
IAMの左のサイドバーからユーザーを選んで、
terraformというユーザーを作成します。
こちらも、AdministratorAccessの権限を付与します。
AWS CLI の認証情報を設定する
IAMの左サイドバーからユーザーを選びます。
アクセスキーを作成します。
ユースケースはCLIを選びます。
アクセスキーIDとシークレットアクセスキーを取得し保管します。
aws configure
既に登録してある場合はこれを飛ばします。
まだ登録してない場合、デフォルトユーザーを登録します。
ターミナルでこのようにコマンドを入れると、
アクセスキーIDとシークレットアクセスキーを入力できます。
デフォルトリージョンは東京にするので、ap-northeast-1を入れます。
デフォルトアウトプットフォーマットはjsonを入れます。
aws configure --profile terraform
これで、Terraformユーザーの登録をします。
要領はデフォルトユーザーと同じです。
defaultユーザーが既に登録してある場合、
terraformユーザーだけを新規登録します。
ホームに.awsという隠しディレクトリがあるので、
その中にcredentialsというファイルがあるので、
これにちゃんと記述されているか確認します。
シークレットは人に知られてはいけません!
Terraformのインストール
Terraformを直接インストールせず、
tfenvを使ってインストールします。
brew install tfenv
MacだとHomebrewを使えば簡単にtfenvをインストールできます。
自力でパスを通す必要ありません。
tfenv -v
これでバージョンが返ってくれば正常です。
tfenv list-remote
これでインストールできるTerraformのバージョンを閲覧できます。
1.14.0が一番バージョンが高く、サフィックスがついてないので、これを使います。
tfenv install 1.14.0
1.14.0をインストールします。
tfenv use 1.14.0
これで、インストールした物を使うようにします。
terraform -v
バージョンを確認します。
git-secretsのインストール
アクセスキーIDとシークレットアクセスキーは、
人に知られると、悪用される可能性があります。
git-secretsを導入すると、
誤ってシークレットをGithubにプッシュしてないか、
自動で監視してくれます。
brew install git-secrets
これでインストールできます。
git secrets --register-aws --global
このコマンドでは、AWS のアクセスキー(AKIA〜)や、
シークレットキーのパターンを検出するルールを、
グローバル設定に追加します。
これで、どのリポジトリでも AWS キーがコミットされそうになると
自動でブロックされるようになります。
git secrets --install ~/.git-templates/git-secrets -f
Git には「テンプレートディレクトリ」という仕組みがあります。
ここにフックを仕込んでおくと、git init で作成した新規プロジェクトに、
自動的にその内容がコピーされます。
このコマンドでは、git-secrets のフックを、
~/.git-templates/git-secrets に配置しています。
git config --global init.templatedir ~/.git-templates/git-secrets
最後に、Git に対して、
「新しくプロジェクトを作るときは、
このテンプレートディレクトリを使ってね」
と設定します。
これで、今後 git init をしたすべてのリポジトリに、
git-secrets のフックが自動適用されるようになります。
この3行はコピペで大丈夫です。
ダミーのシークレットをAIで生成して、
ダミーのプロジェクトで、commitしてみると、
確かめることができます。
Terraformプロジェクトの初期化
digital_ichiba_infra/
├─ .gitignore
├─ .terraform.lock.hcl
├─ README.md
├─ main.tf
├─ provider.tf
├─ terraform.tfvars
├─ variables.tf
└─ versions.tf
初期のディレクトリ構成はこのようになっています。
まずは、空のmain.tfを作ります。
provider "aws" {
profile = "terraform"
region = "ap-northeast-1"
}
provider.tfはこのように書きます。
profileをterraformとすることで、
defaultユーザーを使わず、
先ほど作ったterraformユーザーを使うようになります。
ap-northeast-1を指定することで、
東京リージョンを使います。
terraform {
required_version = ">= 1.6, < 2.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.20"
}
}
}
Terraformのバージョンを1.6より高く、2より小さくします。
2以上になったとき、破壊的な変更がある可能性があるので、
このように設定します。
AWS Providerのソースを、
hashicorpのawsに指定して、
こちらも~>とすることで、6.20以上、7以下を指定します。
variables.tfは、
Terraform で使用する変数を型や説明つきで宣言するファイル。
とりあえず空でファイルだけ作ります。
terraform.tfvarsは、
具体的な変数の値を入れるためのファイルです。
とりあえず空でつくります。
.gitignoreをAIに生成させて、*.tfstateは無視させる必要があります。
README.mdを書きます。
この2つは割愛します。
terraform init
initコマンドを使って、
プロジェクトをスタートさせます。
.terraform.lock.hclが生成されます。
この状態でgithubのorigin mainにプッシュします。
Terraform importのしくみ
AWS などに すでに存在するリソースを Terraform 管理下へ移行する際は、
terraform import を使って Terraform の state(状態ファイル)だけに反映させます。
ここで重要なのは、import は「リソースをコード化する作業」ではないという点です。
Terraform は import 時に「AWS にこのリソースが存在する」という事実だけを state に登録しますが、
*.tf のコードは自動生成されません。
terraform import aws_vpc.main vpc-0123456789abcdef0
左側:Terraform 上のリソース名(aws_vpc.main)
右側:実際の AWS のリソース ID(vpc-xxx)
これで、既存の VPC を Terraform の state に登録できます。
import が完了したら、次に必ず以下の作業が必要です:
- terraform plan を実行して差分を見る
- 差分がゼロになるように *.tf にコードを書く
import は state だけ更新するため、
terraform plan を実行すると、次のような差分が大量に出ます:
- Terraform では書いていないタグ
- デフォルト値の有無
- 実際の AWS 側とコード側の設定の差
Terraform の理想形は、plan が “No changes” になることです。
そのため、import 後は実際の AWS 設定を確認しながら、
.tf を Terraform 管理向けに整えていく作業が必要です。
Drift(ドリフト)と apply 時の事故について
import で既存リソースを state に取り込んだだけでは、
手元の .tf ファイルの内容とはまだ一致していません。
もしこの状態で tf ファイルを修正せずに terraform apply を実行すると、
Terraform は「手元の .tf が正しい姿だ」と誤認し、
実際に稼働しているリソースを .tf の状態へ強制的に書き換えようとします。
これが Drift(ドリフト)による典型的な事故 です。
- 実リソースの設定(例:VPC の CIDR、タグ、ルート設定など)が上書きされる
- 本番環境のネットワーク構成が壊れる
- 予期せぬリソース再作成(destroy → create)が起きる
特に本番リソースでは致命的な変更になりかねません。
そのため import 後は必ず、
- terraform plan で差分を確認する
- 実リソースに合わせて .tf を手作業で調整する(逆ではない)
- 差分がゼロになったことを確認してから apply する
という手順を踏む必要があります。
import 直後は必ず terraform plan を実行してください
ネットワークの構造と取り込み
ネットは他のリソースに依存せず、
逆に依存されるので先に説明します。
network/
├─ vpc.tf # VPC本体の定義
├─ subnet.tf # パブリック/プライベートサブネット
├─ igw.tf # インターネットゲートウェイ
├─ route_table_public.tf # パブリック用ルートテーブルと関連付け
├─ route_table_private.tf # プライベート用ルートテーブルと関連付け
├─ sg.tf # セキュリティグループ
└─ outputs.tf # 他のモジュールへ渡す出力値(VPC ID など)
ディレクトリはこのようになります。
VPC
最初にネットワークの起点となるVPCからやります。
VPCの手動設定
新規でVPCを作成しました。
「VPCのみ」を選択しました。
名前を「digital-ichiba-vpc」としています。
IPv4 CIDRを、「10.0.0.0/16」としています。
VPCの取り込み
resource "aws_vpc" "main" {
}
/network/vpc.tfのファイルを作成します。
resourceをaws_vpcのmainとして定義します。
resource “aws_vpc” “main” の “main” は
「Terraform 内でそのリソースを参照するための ローカル名」 です。
AWS 側の名前(タグ)とは無関係で、Terraform のコード内で使う識別子になります。
中身はとりあえず空のままにします。
module "network" {
source = "./network"
}
main.tfにこのように追記し、呼び出せるようにしておきます。
terraform init
moduleをmainに追加したら、terraform initをします。
terraform import module.network.aws_vpc.main vpc-xxxxxxxxxxxxxxxxx
vpc-xxxxxxxxxxxxxxxxxの具体的な値は、
AWSコンソールのVPC画面から取ってきます。
このコマンドを実行するとstateに、
実際動いているVPCの情報がimportされます。
この段階では、手元のtfファイルと、stateにはズレがあるので、
手動で解消していきます。
terraform plan
返ってくるメッセージを確認すると、
tagsとtags_allに差分があることがわかります。
18個、変更がないものが略されています。
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "digital-ichiba-vpc"
}
}
先ほどのリソースの中身を埋めていきます。
cidr_blockはデフォルト値ですが、重要なので明示します。
enable_dns_support はデフォルト true ですが、
ネットワークの基礎となる VPC 設定なので、あえて明示します。
enable_dns_hostnames は EC2 や ECS Fargate の ENI に
DNS ホスト名を付与するために必要なので true にします。
tagsは、AWS側の名前なので、明記します。
ags_allはプロバイダ側の合成結果なので、基本は直接管理しません。
terraform plan
リソースの中身を記入した状態で、もう一度planします。
変更に問題がないのを確認して
terraform apply
apply を実行すると、
vpc.tf に記述した設定内容が AWS 上の既存 VPC リソースへ反映されます。
これで最初のリソースのimportができました。
残りのリソースもimportしていきましょう。
サブネット
VPCが準備できたので、
次はサブネットについて説明します。
サブネットの手動設定
パブリックサブネットを2つ、
プライベートサブネットを2つ作成します。
これらはすべて先ほどの、digital-ichiba-vpcに紐づけます。
パブリックサブネット1
名前: digital-ichiba-subnet-public-1a
アベイラビリティーゾーン:ap-northeast-1a
IPv4 CIDR : 10.0.10.0/24
パブリックサブネット2
名前: digital-ichiba-subnet-public-1c
アベイラビリティーゾーン:ap-northeast-1c
IPv4 CIDR : 10.0.20.0/24
プライベートサブネット1
名前: digital-ichiba-subnet-private-1a
アベイラビリティーゾーン:ap-northeast-1a
IPv4 CIDR : 10.0.30.0/24
プライベートサブネット2
名前: digital-ichiba-subnet-private-1c
アベイラビリティーゾーン:ap-northeast-1c
IPv4 CIDR : 10.0.40.0/24
サブネットの取り込み
resource "aws_subnet" "public_1a" {
vpc_id = aws_vpc.main.id
}
# resource "aws_subnet" "public_1c" {
# vpc_id = aws_vpc.main.id
# }
# resource "aws_subnet" "private_1a" {
# vpc_id = aws_vpc.main.id
# }
# resource "aws_subnet" "private_1c" {
# vpc_id = aws_vpc.main.id
# }
network/subnet.tfの中でこのように書きます。
vpc_idを書かないとエラーが出るので、先に記述しておきます。
public_1aから一つずつ、処理していきます。
terraform import module.network.aws_subnet.public_1a subnet-xxxxxxxxxxxxxxxxx
サブネットIDはコンソールから取ってきます。
public_1aを取り込みます。
terraform plan
これで差分を確認すると、
destroyとaddを両方やろうとしていますが、
これはcidrブロックを定義してないときの挙動のようなので、
以下のように追記します。
resource "aws_subnet" "public_1a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
availability_zone = "ap-northeast-1a"
tags = {
Name = "digital-ichiba-subnet-public-1a"
}
}
これで改めてdestroyとaddをしようとしなくなったので、
これでapplyします。
public-1c、private-1a、private-1cも同様に繰り返します。
resource "aws_subnet" "public_1a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
availability_zone = "ap-northeast-1a"
tags = {
Name = "digital-ichiba-subnet-public-1a"
}
}
resource "aws_subnet" "public_1c" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.20.0/24"
availability_zone = "ap-northeast-1c"
tags = {
Name = "digital-ichiba-subnet-public-1c"
}
}
resource "aws_subnet" "private_1a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.30.0/24"
availability_zone = "ap-northeast-1a"
tags = {
Name = "digital-ichiba-subnet-private-1a"
}
}
resource "aws_subnet" "private_1c" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.40.0/24"
availability_zone = "ap-northeast-1c"
tags = {
Name = "digital-ichiba-subnet-private-1c"
}
}
コードはこのようになります。
planをすると、変更がないことがわかるので、
これでsubnetを取り込むことができました。
インターネットゲートウェイ
インターネットゲートウェイ(IGW)は、
VPC とインターネットを接続するための出入口です。
パブリックサブネットに配置した ALB や Fargate タスクが外部と通信する際、
ルートテーブルで0.0.0.0/0 → IGW を設定することで、
インターネットとの双方向通信を可能にします。
インターネットゲートウェイの手動設定
名前:digital-ichiba-ig
作ったあとに、アクションからVPCにアタッチしました。
インターネットゲートウェイの取り込み
resource "aws_internet_gateway" "igw" {
}
network/igw.tfにリソースを定義します。
terraform import module.network.aws_internet_gateway.igw igw-xxxxxxxxxxxxxxxxx
取り込みます。
resource "aws_internet_gateway" "igw" {
tags = {
Name = "digital-ichiba-igw"
}
}
このように書いて、planして差分がないことを確認します。
IGWとVPCのアタッチの取り込み
resource "aws_internet_gateway_attachment" "igw_attach" {
internet_gateway_id = aws_internet_gateway.igw.id
vpc_id = aws_vpc.main.id
}
IGWとVPCを紐づけるattachmentも定義します。
terraform import module.network.aws_internet_gateway_attachment.igw_attach igw-xxxxxxxxxxxxxxxxx:vpc-xxxxxxxxxxxxxxxxx
igwとvpcを:でつなげてインポートします。
planして、差分がないことを確認します。
ルートテーブル
ルートテーブルは「このサブネットの通信をどこへ流すか」を決める経路表です。
同じ VPC 内の通信は自動で届きますが、
VPC の外へ出る場合はルートを明示する必要があります。
パブリックサブネットは IGW へのルートを追加し、
プライベートサブネットは追加せず内部専用とします。
ルートテーブルの手動設定
ルートテーブルは、
VPCを作成したときに、1つ自動生成されます。
手動デプロイでは、このルートテーブルに
インターネットゲートウェイをアタッチして使っていました。
また、プライベートサブネットにはルートテーブルを作っていませんでした。
この状態はあまり良くないので、
改めてパブリックサブネット用ルートテーブルと、
プライベートサブネット用ルートテーブルを作ります。
Terraformでは、自動作成されたものを使わないのが望ましいです。
パブリックサブネット用RTを作成
最初から、自動作成されたものを使わないのが理想的ですが、
今回は仕方ないので、Terraformから新規作成します。
自動作成されているルートテーブルと、
パブリックサブネットの関連付けをあらかじめ手動で解除しておきます。
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = {
Name = "digital-ichiba-route-table-public"
}
}
resource "aws_route" "public_igw" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
resource "aws_route_table_association" "public_1a" {
subnet_id = aws_subnet.public_1a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_1c" {
subnet_id = aws_subnet.public_1c.id
route_table_id = aws_route_table.public.id
}
network/route_table_public.tfをこのように書きます。
今回はimportせずに、
新規でTerraform側で作って、planしてapplyします。
aws_routeでは、
ルートテーブルと先ほど作成したIGWを関連付けます。
これでパブリックサブネットからインターネットへでれるようになります。
aws_route_table_associationでは、
ルートテーブルを前に作成した2つのパブリックサブネットと関連付けます。
プライベートサブネット用RTを作成
手動のときは、
プライベートサブネットのためのルートテーブルを定義していなかったのですが、
これを明示的に定義したほうが望ましいので、新規作成します。
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "digital-ichiba-route-table-private"
}
}
resource "aws_route_table_association" "private_1a" {
subnet_id = aws_subnet.private_1a.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_1c" {
subnet_id = aws_subnet.private_1c.id
route_table_id = aws_route_table.private.id
}
network/route_table_privateにこのように記述します。
aws_route_table_associationでプライベートサブネットと関連付けます。
planで確認したら、applyで新規制作します。
セキュリティグループ
セキュリティグループは、
ALB・Fargate・RDS など各コンポーネントへ「どこからの通信を許可するか」を
個別に制御するためのファイアウォールです。
ALB 用・アプリ用・DB 用の SG を分けることで、
ALB → アプリ → DB という最小権限の通信経路を作れます。
外部から直接アプリや DB へアクセスさせず、安全な三層構造を実現します。
ALB用セキュリティグループ
3つあるので、
一つずつ、手動設定と取り込みを見ていきます。
まずはALB用からです。
ALB用SGの手動設定
名前をdigital-ichiba-sg-albとして、
VPCをdigital-ichiba-vpcを選択しました。
インバウンドルールとして、HTTPの80番ポートと、
HTTPSの443ポートを許可しました。
ユーザーがURLでhttpかhttpsの部分を省略した場合、
HTTPとしてアクセスするので、
あとでALBでHTTPを80で受け付けた場合、
HTTPSの443にリダイレクトをするように、設定します。
ここではHTTPも受け付けるようにしておきます。
アウトバンドは、デフォルトですべてを許可します。
ALB用SGの取り込み
resource "aws_security_group" "alb_sg" {
}
リソースを定義します。
terraform import module.network.aws_security_group.alb_sg sg-xxxxxxxxxxxxxxxxx
取り込みます。
resource "aws_security_group" "alb_sg" {
name = "digital-ichiba-sg-alb"
description = "digital-ichiba-sg-alb"
vpc_id = aws_vpc.main.id
tags = {
Name = "digital-ichiba-sg-alb"
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
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"]
}
}
このように記述して、
差分を埋めます。
ingressがインバウンドで、
egressがアウトバンドのことです。
planすると、
revoke_rules_on_delete = false、という差分がでてきます。
これはセキュリティグループを削除するときに、
「個別ルールを先に削除せずに、そのまままとめて削除する」挙動にする設定です。
今回はインラインでインバウンドとアウトバンドのルールを書いたので、falseで大丈夫です。
applyします。
APP用セキュリティグループ
ECS/Fargateのアプリのためのセキュリティグループです。
APP用SGの手動設定
インバウンドは、ALB用のSGからの通信だけを許可します。
Railsはポートが3000番なので、ここは3000番だけを許可します。
アウトバンドは、デフォルトのままで、すべて許可します。
APP用SGの取り込み
resource "aws_security_group" "app_sg" {
}
さっきのnetwork/sg.tfの続きに書きます。
terraform import module.network.aws_security_group.app_sg sg-xxxxxxxxxxxxxxxxx
取り込みます。
resource "aws_security_group" "app_sg" {
name = "digital-ichiba-sg-app"
description = "digital-ichiba-sg-app"
vpc_id = aws_vpc.main.id
tags = {
Name = "digital-ichiba-sg-app"
}
ingress {
from_port = 3000
to_port = 3000
protocol = "tcp"
security_groups = [aws_security_group.alb_sg.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
このように書き換えて、planとapplyをします。
DB用セキュリティグループ
DBのためのセキュリティグループです。
DB用SGの手動設定
インバウンドは、APPのSGからのみ接続できます。
Postgresなので5432を許可します。
アウトバンドはすべてを許可します。
DB用SGの取り込み
resource "aws_security_group" "db_sg" {
}
同じファイルのapp_sgの下に、リソースを定義します。
terraform import module.network.aws_security_group.db_sg sg-xxxxxxxxxxxxxxxxx
取り込みます。
resource "aws_security_group" "db_sg" {
name = "digital-ichiba-sg-db"
description = "digital-ichiba-sg-db"
vpc_id = aws_vpc.main.id
tags = {
Name = "digital-ichiba-sg-db"
}
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app_sg.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
planとapplyをします。
必要な情報を取り出せるようにする
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_1a_id" {
value = aws_subnet.public_1a.id
}
output "public_subnet_1c_id" {
value = aws_subnet.public_1c.id
}
output "private_subnet_1a_id" {
value = aws_subnet.private_1a.id
}
output "private_subnet_1c_id" {
value = aws_subnet.private_1c.id
}
output "alb_security_group_id" {
value = aws_security_group.alb_sg.id
}
output "app_security_group_id" {
value = aws_security_group.app_sg.id
}
output "db_security_group_id" {
value = aws_security_group.db_sg.id
}
network/outputs.tfにこのように記述します。
こうすることで、main.tfの階層で読み取れるようになります。
main.tfで、moduleを定義するとき、
追記すれば、そのmoduleに渡すことができるようになります。
これでネットワークの設定が終わりました。
Route53の構造と取り込み
route53/
├─ hosted_zone.tf # Route 53のパブリックホストゾーン定義
├─ record.tf # DNSレコード
└─ variables.tf # 変数定義
Route53にはホストゾーンと、レコードがあります。
ホストゾーン
AWS Route53 の Hosted Zone(ホストゾーン) は、特定のドメイン名に対して
DNS レコード(A, CNAME, TXT など)を管理するための“DNS の管理領域”です。
ドメイン名のルートを定義し、DNS の設定を集中管理するための基盤となります。
ホストゾーンの手動設定
Route53でドメインを購入しました。
そのドメインを使ってホストゾーンを作成しました
ホストゾーンを作成したときに、自動生成されたネームサーバーを、
ドメインのネームサーバーを上書きしました。
購入済みのドメイン自体はTerraform化しないようです。
ホストゾーンの取り込み
Terraformでリソースを定義するにはnameが必要です。
ホストゾーンのnameにはドメイン名を入れます。
ドメイン名を入れる場面は複数あるので、変数を使います。
variable "domain_name" {
type = string
description = "The domain name for the application"
}
プロジェクト直下のvariables.tfにこのように記述します。
これでdomain_nameという変数をstringで定義しました。
domain_name = "aws-training-shu.com"
terraform.tfvarsで、変数に値を代入します。
これでディレクトリ直下のファイルから変数を呼び出せるようになります。
しかしRoute53は下層なので、このままでは使えません。
variable "domain_name" {
type = string
description = "The domain name for the Route53"
}
/route53/variables.tfを作成し、もう一度変数を定義します。
module "route53" {
source = "./route53"
domain_name = var.domain_name
}
/main.tfにこれを追記します。
これでroute53ディレクトリに置いているファイルが有効化され、
さらにディレクトリ直下から渡した変数を使えるようになります。
terraform init
moduleを追加したのでterraform initをします。
resource "aws_route53_zone" "main" {
name = var.domain_name
}
/route53/hosted_zone.tfでリソースを定義して、
先ほどの変数で定義されたdomain_nameを使います。
terraform import module.route53.aws_route53_zone.main Z0xxxxxxxxxxxxxxxxxxx
コンソールからホストゾーンのIDを取得して、
このように取り込みます。
planをするとforce_destroy = falseというのが出てきます。
これはfalseであれ、レコードが残っていると削除できないようにするものなので、
falseのままでOKです。
resource "aws_route53_zone" "main" {
name = var.domain_name
tags = {
Name = "digital-ichiba-route53-zone"
}
}
タグを付けて、planして問題がないのを確認して、applyします。
ホストゾーンはタグを付けても、コンソールで見れないので、
省略してもOKです。
レコード
DNS レコードとは 特定のドメイン名をどこに接続させるかを指定する設定情報です。
例として、ドメインをサーバーやロードバランサー、
外部サービス(Vercel など)に紐づけたり、メールや証明書認証の情報を保持します。
つまり、ドメイン名とインターネット上の実体(IP・サービス)を結びつけるルールです。
レコードの手動設定
レコードは5つあります。
ホストゾーンが作られたときに、自動でタイプがNSとSOAのものが作られました。
この2つはTerraformで管理しません。
ACMで証明書を発行するためのCNAMEが一つあります。
aws-training-shu.comをVercelに結びつけるAが一つあります。
api.aws-training-shu.comをALBに結びつけるAが一つあります。
この3つはTerraformで管理します。
ALBは、先にALBをコード化する必要があるため、 一時的に保留にします。
レコードの取り込み
ACMとVercelのレコードを取り込んでいきます。
ACMのCNAMEレコード
httpsにするためにACMで証明書を発行するとき、
このドメインを所有していることを証明するためのものです。
証明書の更新にも使われるため、常に残しておく必要があります。
resource "aws_route53_record" "acm_validation" {
zone_id = aws_route53_zone.main.zone_id
name = "_51a4bf1957e9b6680931b385a5988bef.${var.domain_name}"
type = "CNAME"
}
リソースを定義します。
zone_id、name、typeがないとエラーが出るので、
先に埋めます。
zone_idは先ほど定義したmainというzoneのzone_idを渡します。
nameはコンソールにあるものと揃えます。
typeも揃えます。
<hosted-zone-id>_<record-name>_<record-type>
これまでimportするときはコンソールからIDを取得してきましたが、
レコードはこのIDがないです。
importをするときの、リソースの形式はこのように書きます。
terraform import \
module.route53.aws_route53_record.acm_validation \
Z0xxxxxxxxxxxxxxxxxxx__xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.aws-training-shu.com_CNAME
具体的なIDなどの番号はxでマスクをしています。
自分のものを使ってください。
ACMはこのようにして取り込みます。
resource "aws_route53_record" "acm_validation" {
zone_id = aws_route53_zone.main.zone_id
name = "_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.${var.domain_name}"
type = "CNAME"
ttl = 300
records = [
"_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxx.acm-validations.aws."
]
}
importしたあとttlを揃えて、
recordsに配列を渡し、その中にコンソールにあるレコードの値を渡します。
planをするとNo changesと出るので、うまく取り込めました。
VercelのAレコード
自作アプリのフロントエンドのNext.jsをVercelにおいて、
ドメインでアクセスできるようにしているので紐づけています。
resource "aws_route53_record" "root_vercel" {
zone_id = aws_route53_zone.main.zone_id
name = var.domain_name
type = "A"
}
リソースをこのように書きます。
terraform import \
module.route53.aws_route53_record.root_vercel \
Z0xxxxxxxxxxxxxxxxxxx_aws-training-shu.com_A
importします。
resource "aws_route53_record" "root_vercel" {
zone_id = aws_route53_zone.main.zone_id
name = var.domain_name
type = "A"
ttl = 300
records = ["216.198.79.1"]
}
このように書き足して、planをするとNo changeがでてくるので、OKです。
ACMの構造と参照
AWS Certificate Manager(ACM)は、
Web サイトを HTTPS 化するための SSL/TLS 証明書を自動で発行・管理してくれるサービスです。ドメインの所有を証明するため、Route53 に CNAME レコードを追加して認証を行います。
ACMの手動設定
メインのドメインである、aws-training-shu.comと、
すべてのサブドメインを含む、*.aws-training-shu.comを、
一つの証明書として作成しました。
CNAMEをRoute53に記入することで、ステータスが保留から成功になりました。
ACMを取り込まず参照する
ACM の証明書は Terraform から変更する項目がほとんどなく、
実際に操作する必要がないため import して管理するメリットがありません。
さらに Terraform が証明書を再作成し、HTTPS が落ちるリスクがあるため、
証明書本体は参照だけにします。
data "aws_acm_certificate" "main" {
domain = var.domain_name
statuses = ["ISSUED"]
types = ["AMAZON_ISSUED"]
most_recent = true
}
プロジェクト直下に、acm.tfを作成し、このように書きます。
domain:対象ドメイン(例:aws-training-shu.com)に一致する証明書を検索
statuses = [“ISSUED”]:発行済みで利用可能な証明書だけを選択
types = [“AMAZON_ISSUED”]:AWS が発行した証明書に限定
most_recent = true:複数存在する場合は最新の1枚を取得
これでdata.aws_acm_certificate.main.arnとすることでarnを取得できるようになります。
ALB周辺の構造と取り込み
alb/
├─ alb.tf # ALB本体(aws_lb)定義
├─ listener.tf # HTTP/HTTPS リスナー定義
├─ target_group.tf # ターゲットグループ定義
├─ variables.tf # モジュール入力変数
└─ outputs.tf # モジュール出力
ALB は本体だけで動作せず、
リスナーやターゲットグループなどの周辺リソースとセットでリクエストを処理します。
ここでは、その ALB 周辺リソースの構造と、既存環境を Terraform に取り込む手順を解説します。
ターゲットグループ
ターゲットグループは、
ALB が最終的にリクエストを送る宛先(ECS などの実行中タスク)をまとめて管理する仕組みです。
ALB本体にターゲットグループを指定する場所があるので、
先にターゲットグループを作ります。
ターゲットグループの手動設定
ターゲットの種類をIPアドレスにします。
名前を「digital-ichiba-tg」とします。
プロトコルはHTTP、Railsなのでポートは3000番にしました。
IPはv4のままにします。
VPCは自作のdigital-ichiba-vpcにします。
プロトコルバージョンはHTTP1のままにします。
ヘルスチェックは、
Railsで/upでヘルスチェックができるので
HTTPで、パスを/upにしました。
ターゲットを登録というページは初期設定のままで進みます。
ターゲットグループを作成します。
ターゲットグループの取り込み
variable "vpc_id" {
type = string
}
variable "public_subnet_1a_id" {
type = string
}
variable "public_subnet_1c_id" {
type = string
}
variable "alb_security_group_id" {
type = string
}
variable "certificate_arn" {
type = string
}
alb/variables.tfにこのように定義します。
変数を扱うための準備です。
module "alb" {
source = "./alb"
vpc_id = module.network.vpc_id
public_subnet_1a_id = module.network.public_subnet_1a_id
public_subnet_1c_id = module.network.public_subnet_1c_id
alb_security_group_id = module.network.alb_security_group_id
certificate_arn = data.aws_acm_certificate.main.arn
}
main.tfにこのように追記します。
これで、albモジュール内でも、
これらを変数として扱えるようになります。
terraform init
initすると読み込めるようになります。
resource "aws_lb_target_group" "app_tg" {
}
alb/target_group.tfにリソースを書きます。
terraform import \
module.alb.aws_lb_target_group.app_tg \
arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:targetgroup/digital-ichiba-target/xxxxxxxxxxxxxxxx
ターゲットグループのARNを使って、
このようにしてimportします。
resource "aws_lb_target_group" "app_tg" {
name = "digital-ichiba-alb-tg"
target_type = "ip"
port = 3000
protocol = "HTTP"
vpc_id = var.vpc_id
tags = {
Name = "digital-ichiba-alb-tg"
}
health_check {
path = "/up"
protocol = "HTTP"
healthy_threshold = 5
unhealthy_threshold = 2
}
}
このように追記します。
healthy_threshold = 5は、ヘルスチェックで200が返ってきて、
成功が5回ならば、健康とみなします。
unhealthy_threshold = 2は2回失敗したら、不健康とみなします。
planすると、以下が返ってきます。
lambda_multi_value_headers_enabled = false
今回はFargateなのでfalseです。
proxy_protocol_v2 = false
今回はV1なので、falseで大丈夫です。
とくに問題なさそうなのでapplyします。
ALB本体とリスナー
ALB(Application Load Balancer)は、
外部からのリクエストを最初に受け取る “入口” の役割を持つロードバランサーです。
どのサブネットに配置するか、
どのセキュリティグループで保護するかといった、本体の構成が基盤になります。
リスナーは、ALB が特定のポート(80/443)で受け取ったリクエストを
どう処理するか決める設定です。
HTTP → HTTPS へのリダイレクトや、
ターゲットグループへの振り分けを行う役割を持ちます。
手動設定では、リスナーはALB本体に含まれるますが、
Terraformでは別々に設定します。
ALB本体とリスナーの手動設定
EC2のロードバランシングから設定します。
Application Load Balancerを選択します。
名前を「digital-ichiba-alb」としました。
インターネット向け、IPv4のままにします。
VPCを自分で作ったdigital-ichiba-vpcにします。
サブネットは、ap-northeast-1aとap-northeast-1cで自分が作った、
パブリックサブネットをそれぞれ選択します。
セキュリティグループは、
自作したdigital-ichiba-sg-albを選択します。
リスナーはALB本体の中で設定します。
HTTPの80番を、
URLにリダイレクトの、
URL部分の、
HTTPSの443を選択します。
これで、HTTPSにリダイレクトしてくれます。
リスナーを追加して、
HTTPSの443番を、
事前ルーティングなし、
ターゲットグループの転送を選んで、
先程作ったターゲットグループを選びます。
「セキュアリスナーの設定」で、
デフォルト SSL/TLS サーバー証明書は、
「ACMから」を選び、
自分で作った証明書を選択します。
他の部分はそのままで、ロードバランサーを作成しました。
ALB本体の取り込み
resource "aws_lb" "app_alb" {
}
alb/alb.tfにリソースを記述します。
terraform import \
module.alb.aws_lb.app_alb \
arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:loadbalancer/app/xxxxxxxxxxxxxx/xxxxxxxxxxxxxxxx
ARNを指定してimportをします。
resource "aws_lb" "app_alb" {
name = "digital-ichiba-alb"
internal = false
load_balancer_type = "application"
security_groups = [var.alb_security_group_id]
subnets = [var.public_subnet_1a_id, var.public_subnet_1c_id]
tags = {
Name = "digital-ichiba-alb"
}
}
リソースに追記してして、applyします。
リスナーの取り込み
まずはHTTPの80番ポートのリダイレクトから取り込みます。
resource "aws_lb_listener" "alb_listener_http" {
load_balancer_arn = aws_lb.app_alb.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
このようにリソースを定義します。
terraform import \
module.alb.aws_lb_listener.alb_listener_http \
arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:listener/app/digital-ichiba-alb/xxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxx
ARNを使って、取り込みます。
resource "aws_lb_listener" "alb_listener_https" {
certificate_arn = var.certificate_arn
load_balancer_arn = aws_lb.app_alb.arn
port = 443
protocol = "HTTPS"
default_action {
type = "forward"
forward {
target_group {
arn = aws_lb_target_group.app_tg.arn
weight = 1
}
stickiness {
enabled = false
duration = 3600
}
}
}
}
HTTPSもこのように書きます。
terraform import \
module.alb.aws_lb_listener.alb_listener_https \
arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:listener/app/xxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxx
取り込みます。
planをするとdefault_action直下のARNがnullになりますが、
そのままだと2重になるので、このままapplyします。
ロードバランサーとドメインの紐づけ
ロードバランサーを取り込めたので、
Route53でドメインと紐づけるレコードを作ります。
レコードの手動設定
Route53からホストゾーンに入り、
レコードを作成します。
api.aws-training-shu.comのようにサブドメインを設定します。
エイリアスを有効化して、
ALBへのエイリアスと東京リージョンを選択して、
該当するALB本体を選択してレコードを作成します。
レコードの取り込み
resource "aws_route53_record" "api_alb" {
zone_id = aws_route53_zone.main.zone_id
name = "api.${var.domain_name}"
type = "A"
}
route53/record.tfに記述します。
terraform import \
module.route53.aws_route53_record.api_alb \
Z0xxxxxxxxxxxxxxxxxxx_api.aws-training-shu.com_A
これで取り込みます。
output "alb_dns_name" {
value = aws_lb.app_alb.dns_name
}
output "alb_zone_id" {
value = aws_lb.app_alb.zone_id
}
output "target_group_arn" {
value = aws_lb_target_group.app_tg.arn
}
alb/outputs.tfにこのように書きます
これは後で、Route53やECSで使います。
variable "alb_dns_name" {
type = string
description = "The DNS name of the ALB"
}
variable "alb_zone_id" {
type = string
description = "The Zone ID of the ALB"
}
route53/variables.tfで2つ変数を追加で定義します。
module "route53" {
source = "./route53"
domain_name = var.domain_name
alb_dns_name = module.alb.alb_dns_name
alb_zone_id = module.alb.alb_zone_id
}
main.tfのroute53のところにこの2つを追記します。
resource "aws_route53_record" "api_alb" {
zone_id = aws_route53_zone.main.zone_id
name = "api.${var.domain_name}"
type = "A"
alias {
name = var.alb_dns_name
zone_id = var.alb_zone_id
evaluate_target_health = true
}
}
route53/record.tfのリソースを埋めて、planします。
dualstack.がnameから取れるけど、とくに問題がないようなので、
applyします。
これでロードバランサー周辺の取り込みができました。
TerraformとGitHub Actionsの役割分担
Terraform化する前に、GitHub Actionsを使って、
デプロイを自動化しました。
自動化というのはこれらを指します。
- GitHub で main ブランチにマージ
- GitHub Actions が起動
- Docker イメージをビルドし、タグ付け
- ECR に push
- ECS Task Definition を更新
- ECS Service を更新(新しいタスクが起動)
これらはGithub Actions側でやるので、
Terraform側では設定をしません。
特ににTask Definitionは、どちらにも置くことができるのですが、
更新頻度が高いので、GitHub Actions側においたほうが適切だと判断しました。
自動デプロイの記事はこちら
Next.js × Rails|ECS/FargateをGitHub Actionsで自動デプロイ
ECRの構造と取り込み
ECR(Elastic Container Registry) は、
Docker イメージを保存・管理するための AWS のマネージドなコンテナレジストリです。
ECS や EKS などの実行基盤から、
安全に Docker イメージを取得してアプリケーションを起動する役割を担います。
ECRの手動設定
digital-ichiba-ecrという名前で、リポジトリを新規作成しました。
ミュータビリティはミュータブルのままにします。
:latestで自動更新したいので、イメージタグを上書きできるようにします。
ECRの取り込み
resource "aws_ecr_repository" "app" {
name = "digital-ichiba-ecr"
}
プロジェクト直下に、ecr.tfを作成して、
リソースを定義します。
terraform import aws_ecr_repository.app digital-ichiba-ecr
ECRはリポジトリ名でインポートできます。
これでECRをインポートできました。
IAMの構造と取り込み
IAM では、ロールやポリシーを定義し、
ユーザーや AWS リソースに関連付けることで、
AWS 上で実行できる操作の権限を管理します。
iam/
├─ oidc_provider.tf # GitHub Actions 用 OIDC プロバイダー
│
├─ gha_role.tf # GitHub Actions が Assume するデプロイ用ロール
├─ gha_policy.tf # GitHub Actions 用の権限ポリシー
├─ gha_attachments.tf # 上記ポリシーを GHA ロールへアタッチ
│
├─ exec_role.tf # ECS Task Execution Role
├─ exec_policy_secrets.tf # ExecRole: SecretsManager 読み取り権限
├─ exec_policy_logs.tf # ExecRole: CloudWatch Logs 出力権限
├─ exec_managed_policy.tf # AWS管理ポリシー AmazonECSTaskExecutionRolePolicy の参照
├─ exec_attachments.tf # ExecRole へ managed/カスタムポリシーをアタッチ
│
├─ task_role.tf # ECS Task Role(アプリがAWSへアクセスするためのロール)
├─ task_policy_s3.tf # TaskRole: S3 CRUD 権限ポリシー
└─ task_attachment.tf # TaskRole へ S3 ポリシーをアタッチ
GitHub Actionsのための権限
GitHub Actionsが、クレデンシャルを使わず、
OIDCで自動デプロイするための設定をします。
詳細は以下の記事を見てください。
Next.js × Rails|ECS/FargateをGitHub Actionsで自動デプロイ
権限の手動設定
IDプロバイダーと、ロールを作成します。
IDプロバイダーの手動設定
IDプロバイダーを作成しました。
OpenID Connectedを選択し、
プロバイダーの対象者を、token.actions.githubusercontent.com
対象者を、sts.amazonaws.comと固定値で入れて作成しました。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EcrPush",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage"
],
"Resource": [
"arn:aws:ecr:ap-northeast-1:575357958164:repository/digital-ichiba-ecr"
]
},
{
"Sid": "EcsDeploy",
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:UpdateService"
],
"Resource": [
"arn:aws:ecs:ap-northeast-1:575357958164:service/digital-ichiba-cluster/*",
"arn:aws:ecs:ap-northeast-1:575357958164:task-definition/digital-ichiba-*:*"
]
},
{
"Sid": "PassTaskRolesToEcs",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::575357958164:role/DigitalIchibaEcsTaskRole",
"arn:aws:iam::575357958164:role/DigitalIchibaEcsTaskExecRole"
]
},
{
"Sid": "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": [
"arn:aws:logs:ap-northeast-1:575357958164:log-group:/ecs/digital-ichiba*:log-stream:*"
]
}
]
}
これで、DigitalIchibaGithubActionsDeployPolicyを作成しました。
詳細な解説は自動デプロイの方でしているので、省きます。
ロールの手動設定
ロールを作成しました。
ウェブアイデンティティを選びました。
プロバイダーとオーディエンスを、IDプロバイダー作るときに入れた固定値を選びました。
自分のリポジトリをGitHub organizationに入れました。
先程作ったDigitalIchibaGithubActionsDeployPolicyをアタッチしました。
権限の取り込み
OIDCプロバイダーが1つ、ポリシーが1つ、ロールが一つ、
ポリシーとロールのアタッチメントが一つあります。
OIDCプロバイダーの取り込み
module "iam" {
source = "./iam"
}
iamのディレクトリを作って、main.tfに追記します。
terraform init
反映させます。
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
iamディレクトリの中に、oidc_provider.tfを作って、
リソースを定義します。
terraform import \
module.iam.aws_iam_openid_connect_provider.github_actions \
arn:aws:iam::xxxxxxxxxxxx:oidc-provider/token.actions.githubusercontent.com
インポートします。
planをすると差分がないので、IDプロバイダーを取り込むことができました。
ポリシーの取り込み
resource "aws_iam_policy" "gha_policy" {
name = "DigitalIchibaGithubActionsDeployPolicy"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EcrPush",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage"
],
"Resource": [
"arn:aws:ecr:ap-northeast-1:575357958164:repository/digital-ichiba-ecr"
]
},
{
"Sid": "EcsDeploy",
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:UpdateService"
],
"Resource": [
"arn:aws:ecs:ap-northeast-1:575357958164:service/digital-ichiba-cluster/*",
"arn:aws:ecs:ap-northeast-1:575357958164:task-definition/digital-ichiba-*:*"
]
},
{
"Sid": "PassTaskRolesToEcs",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::575357958164:role/DigitalIchibaEcsTaskRole",
"arn:aws:iam::575357958164:role/DigitalIchibaEcsTaskExecRole"
]
},
{
"Sid": "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": [
"arn:aws:logs:ap-northeast-1:575357958164:log-group:/ecs/digital-ichiba*:log-stream:*"
]
}
]
})
}
iamディレクトリに、gha_policy.tfを作成し、リソースを定義します。
AWSコンソールからポリシーをJSONで取得して、埋めます。
terraform import \
module.iam.aws_iam_policy.gha_policy \
arn:aws:iam::xxxxxxxxxxxx:policy/DigitalIchibaGithubActionsDeployPolicy
インポートします。
planして差分がないことを確認します。
ロールの取り込み
resource "aws_iam_role" "gha_role" {
name = "DigitalIchibaGitHubActionsDeployRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = "arn:aws:iam::575357958164:oidc-provider/token.actions.githubusercontent.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = [
"repo:shu915/*"
]
}
}
}
]
})
}
AWSコンソールのロールの信頼関係にJSONがあるので、
それを取ってきて、assume_role_policyに入れます。
HCLでは微妙に書き方が違うので、AIに修正させます。
assume_role_policyというのは、
誰がそれをやってよいかを定義する信頼ポリシーです、
先程取り込んだ、DigitalIchibaGithubActionsDeployPolicyは許可ポリシーで、
何をしてよいかのポリシーで、
この2つを混同しないように気をつけます。
terraform import \
module.iam.aws_iam_role.gha_role \
DigitalIchibaGitHubActionsDeployRole
取り込みます。
planすると少しだけ修正がありましたが、applyします。
アタッチメントの取り込み
resource "aws_iam_role_policy_attachment" "gha_policy_attachment" {
role = aws_iam_role.gha_role.name
policy_arn = aws_iam_policy.gha_policy.arn
}
iamにgha_attachments.tfを作り、
リソースを定義します。
terraform import \
module.iam.aws_iam_role_policy_attachment.gha_policy_attachment \
DigitalIchibaGitHubActionsDeployRole/arn:aws:iam::xxxxxxxxxxxx:policy/DigitalIchibaGithubActionsDeployPolicy
取り込みます。
planして、差分がないことを確認します。
これでGitHub ActionsのためのIAMを取り込めました。
タスクロールのための権限
タスクロールは、Railsのアプリが、
AWSリソースに対して持つ権限です。
権限の手動設定
ポリシーが1つ、ロールが一つあります。
ポリシーの手動設定
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::aws-training-shu",
"arn:aws:s3:::aws-training-shu/*"
]
}
]
}
RailsからS3へ操作できる権限を与えます。
DigitalIchibaTaskRoleS3CRUDPolicyという名前にして、
タスクロール用であることを明記します。
ロールの手動設定
ロールを作成します。
AWSのサービスを選択します。
ユースケースからECSを選んで、
Elastic Container Service Taskを選びます。
これで信頼ポリシーがjsonを書かずに、アタッチされます。
ポリシーから先ほど作成したDigitalIchibaTaskRoleS3CRUDPolicyをアタッチします。
これでRailsがS3にアクセスするためのポリシーとロールができました。
権限の取り込み
ポリシーの取り込み
resource "aws_iam_policy" "task_policy_s3" {
name = "DigitalIchibaTaskRoleS3CRUDPolicy"
description = "DigitalIchibaTaskRoleS3CRUDPolicy"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::aws-training-shu",
"arn:aws:s3:::aws-training-shu/*"
]
}
]
})
}
S3への操作のポリシーを取り込みます。
iam/task_policy_s3.tfを作成し、
AWSコンソールからJSONを取得して貼り付けます。
terraform import \
module.iam.aws_iam_policy.task_policy_s3 \
arn:aws:iam::xxxxxxxxxxxx:policy/DigitalIchibaTaskRoleS3CRUDPolicy
インポートします。
planして差分がないことを確認します。
ロールの取り込み
resource "aws_iam_role" "task_role" {
name = "DigitalIchibaEcsTaskRole"
description = "DigitalIchibaEcsTaskRole"
assume_role_policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
})
}
iam/task_role.tfを作成します。
リソースを定義します。
AWSコンソールから信頼関係のJSONを取得して、貼り付けます。
terraform import \
module.iam.aws_iam_role.task_role \
DigitalIchibaEcsTaskRole
インポートします。
planすると説明文がデフォルトのものから書き換えられますが、
applyします。
アタッチメントの取り込み
resource "aws_iam_role_policy_attachment" "task_policy_s3_attachment" {
role = aws_iam_role.task_role.name
policy_arn = aws_iam_policy.task_policy_s3.arn
}
iam/task_attachments.tfを作成します。
リソースを定義します。
terraform import \
module.iam.aws_iam_role_policy_attachment.task_policy_s3_attachment \
DigitalIchibaEcsTaskRole/arn:aws:iam::575357958164:policy/DigitalIchibaTaskRoleS3CRUDPolicy
インポートします。
差分がないことを確認します。
これでタスクロール関連の取り込みが完了しました。
タスク実行ロールのための権限
タスク実行ロールはECS/Fargateが使う権限を付与します。
権限の手動設定
自作ポリシーが2つ、AWSが用意したポリシーが1つ、
ロールが1つ、アタッチメントが3つあります。
Secrets Managerの操作権限
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "arn:aws:secretsmanager:ap-northeast-1:575357958164:secret:digital-ichiba-9eAozm"
}
]
}
Secrets Managerにあるシークレットを、
Railsに渡すために許可を与えます。
紛らわしいのですが、これはタスク実行ロールの方にアタッチします。
名前をDigitalIchibaExecRoleSecretsPolicyとします。
CloudWatch Logs の操作権限
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "*"
}
]
}
ログに対して、書き込んだり、参照するための権限です。
名前は、DigitalIchibaExecRoleLogsPolicyとします。
タスク実行ロールの手動設定
ロールを作成します。
AWSのサービスを選びます。
ユースケースでECSを選びます。
Task Execution Role for Elastic Container Serviceを選びます。
AmazonECSTaskExecutionRolePolicyが最初から許可されています。
ロールを作成します。
作成したロールの、許可を追加から、
先ほどのDigitalIchibaExecRoleSecretsPolicyと、
DigitalIchibaExecRoleLogsPolicyをアタッチします。
権限の取り込み
AWS管理ポリシーを参照
data "aws_iam_policy" "ecs_task_execution_role_policy" {
arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
参照なので、インポートとプランはしなくていいです。
Secrets Managerのポリシー
resource "aws_iam_policy" "exec_policy_secrets" {
name = "DigitalIchibaExecRoleSecretsPolicy"
description = "DigitalIchibaExecRoleSecretsPolicy"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "arn:aws:secretsmanager:ap-northeast-1:575357958164:secret:digital-ichiba-9eAozm"
}
]
})
}
iam/exec_policy_secrets.tfを作成します。
リソースを定義します。
AWSコンソールからJSONを取得して、貼り付けます。
terraform import \
module.iam.aws_iam_policy.exec_policy_secrets \
arn:aws:iam::xxxxxxxxxxxx:policy/DigitalIchibaExecRoleSecretsPolicy
インポートします。
CloudWatch Logsのポリシー
resource "aws_iam_policy" "exec_policy_logs" {
name = "DigitalIchibaExecRoleLogsPolicy"
description = "DigitalIchibaExecRoleLogsPolicy"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "arn:aws:logs:*:*:log-group:/ecs/digital-ichiba*"
}
]
})
}
iam/exec_policy_logs.tfを作成します。
リソースを定義して、JSONを貼り付けます。
terraform import \
module.iam.aws_iam_policy.exec_policy_logs \
arn:aws:iam::xxxxxxxxxxxx:policy/DigitalIchibaExecRoleLogsPolicy
インポートして、planで差分がないことを確認します。
タスク実行ロールの取り込み
resource "aws_iam_role" "exec_role" {
name = "DigitalIchibaEcsTaskExecRole"
description = "DigitalIchibaEcsTaskExecRole"
assume_role_policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAccessToECSForTaskExecutionRole",
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
})
}
iamにexec_role.tfを作成して、
リソースを定義します。
AWSコンソールから信頼ポリシーを取得して、貼り付けます。
terraform import \
module.iam.aws_iam_role.exec_role \
DigitalIchibaEcsTaskExecRole
インポートします。
planすると説明書きだけ変わるので、applyします。
アタッチメントの取り込み
タスク実行ロールポリシー
resource "aws_iam_role_policy_attachment" "exec_managed_policy_attachment" {
role = aws_iam_role.exec_role.name
policy_arn = data.aws_iam_policy.ecs_task_execution_role_policy.arn
}
3つあるので続けてやります。
iam/exec_attachments.tfを作成して、
リソースを定義します。
terraform import \
module.iam.aws_iam_role_policy_attachment.exec_managed_policy_attachment \
DigitalIchibaEcsTaskExecRole/arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
インポートします。
planで差分がないことを確認します。
Secrets Manager
resource "aws_iam_role_policy_attachment" "exec_policy_secrets_attachment" {
role = aws_iam_role.exec_role.name
policy_arn = aws_iam_policy.exec_policy_secrets.arn
}
同様に、Secrets Managerもリソースを定義します。
terraform import \
module.iam.aws_iam_role_policy_attachment.exec_policy_secrets_attachment \
DigitalIchibaEcsTaskExecRole/arn:aws:iam::xxxxxxxxxxxx:policy/DigitalIchibaExecRoleSecretsPolicy
インポートして、planで差分がないことを確認します。
CloudWatch Logs
resource "aws_iam_role_policy_attachment" "exec_policy_logs_attachment" {
role = aws_iam_role.exec_role.name
policy_arn = aws_iam_policy.exec_policy_logs.arn
}
インポートして、planで差分がないことを確認します。
これでIAMをすべて取り込むことができました。
ECSの構造と取り込み
ECS(Elastic Container Service)は、
Docker コンテナを AWS 上で実行・管理するための
マネージドなコンテナオーケストレーションサービスです。
Fargate を使うことで、EC2 を意識せずにアプリケーションの実行に集中できます。
ecs/
├─ cluster.tf # ECSクラスタ定義
├─ service.tf # ECSサービス定義
└─ variables.tf # ecsモジュール入力変数
ECSの手動設定
手動設定では、まずタスク定義を作成しました。
クラスターとサービスを作成し、
ロードバランサーなどを選択して、
アプリケーションが起動できるようになりました。
その後、デプロイを自動化し、
タスク定義は、Github Actionsの管理下になりました。
タスク定義の作成
タスク定義ファミリーをdigital-ichiba-taskとします。
全体の設定は以下のようになります。
| 項目 | 設定 |
|---|---|
| 起動タイプ | Fargate |
| OS | Linux/X86_64 |
| CPU | 0.5vCPU |
| メモリ | 1GB |
| タスクロール | DigitalIchibaEcsTaskRole |
| タスク実行ロール | DigitalIchibaEcsTaskExecRole |
タスク定義には、1つの必須コンテナがあり、
設定は以下のようになります。
| 項目 | 設定 |
|---|---|
| 名前 | digital-ichiba |
| 必須コンテナ | はい |
| コンテナポート | 3000 |
| プロトコル | TCP |
| CPU | 0 |
| メモリーのハード制限 | 0.375 |
| メモリのソフト制限 | 0.125 |
| ログ収集の使用 | 有効 |
イメージURLはボタンから、
xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/digital-ichiba-ecr:latestを選びます。
環境変数とシークレットは省略しています。
シークレットは値のタイプを、ValueFormにして、
Secrets Managerから呼び出します。
クラスターの作成
digital-ichiba-clusterとしてクラスターを作成します。
設定はデフォルトのままです。
サービスの作成
タスク定義ファミリーを、先ほど作ったdigital-ichiba-taskを選びます。
サービス名をdigital-ichiba-serviceとします。
とりあえず1つタスクを起動させるので、必要なタスクを1にします。
ネットワーキングで、
自作VPCのdigital-ichiba-vpcを選択します。
サブネットを、自作の2つのpublic-1aとpublic-1cを選択します。
セキュリティグループも、自作のdigital-ichiba-sg-appを選択します。
パブリックIPは不要なので、falseにします。
ロードバランサーを有効にして、
アプリケーションロードバランサーを選択し、
既存のロードバランサーで、自分で作ったdigital-ichiba-albを選択します。
既存のリスナーからHTTPS:443を選択します。
既存のターゲットグループからdigital-ichiba-tgを選択します。
サービスを作成します。
ECSの取り込み
タスク定義は、Github Actionsの方に任せているので、
Terraformではクラスターとサービスです。
クラスターの取り込み
resource "aws_ecs_cluster" "digital_ichiba_cluster" {
name = "digital-ichiba-cluster"
}
ecs/cluster.tfを作成して、
リソースを定義します。
module "ecs" {
source = "./ecs"
public_subnet_ids = [module.network.public_subnet_1a_id, module.network.public_subnet_1c_id]
app_security_group_id = module.network.app_security_group_id
target_group_arn = module.alb.target_group_arn
}
main.tfに追記します。
モジュールを有効化して、変数を受け取れるようにします。
variable "public_subnet_ids" {
type = list(string)
}
variable "app_security_group_id" {
type = string
}
variable "target_group_arn" {
type = string
}
variable "container_name" {
type = string
default = "digital-ichiba"
}
variable "container_port" {
type = number
default = 3000
}
ecs/variables.tfを作成して、変数を扱えるようにします。
terraform init
モジュールを有効化します。
terraform import \
module.ecs.aws_ecs_cluster.digital_ichiba_cluster \
digital-ichiba-cluster
クラスターをインポートします。
resource "aws_ecs_cluster" "digital_ichiba_cluster" {
name = "digital-ichiba-cluster"
configuration {
execute_command_configuration {
logging = "DEFAULT"
}
}
}
planをすると差分があるので、なくすように追記します。
サービスの取り込み
resource "aws_ecs_service" "digital_ichiba_service" {
name = "digital-ichiba-service"
}
ecs/service.tfを作成して、
リソースを定義します。
terraform import \
module.ecs.aws_ecs_service.digital_ichiba_service \
digital-ichiba-cluster/digital-ichiba-service
インポートするした後、planをするとエラーが出ます。
force_new_deployment = trueを付与してから、
もう1度planをすると、差分が見れるので、差分をなくしていきます。
resource "aws_ecs_service" "digital_ichiba_service" {
name = "digital-ichiba-service"
cluster = aws_ecs_cluster.digital_ichiba_cluster.id
enable_ecs_managed_tags = true
capacity_provider_strategy {
base = 0
capacity_provider = "FARGATE"
weight = 1
}
deployment_circuit_breaker {
enable = true
rollback = true
}
load_balancer {
target_group_arn = var.target_group_arn
container_name = var.container_name
container_port = var.container_port
}
network_configuration {
assign_public_ip = false
security_groups = [var.app_security_group_id]
subnets = var.public_subnet_ids
}
lifecycle {
ignore_changes = [task_definition, desired_count]
}
}
lifecycleのignore_changesに入れたものは、
Terraformの監視から外れます。
コードをこのように書いて、planをすると、
差分がなくなりました。
RDSの構造と取り込み
RDSは、PostgreSQLなどのリレーショナルDBを、
AWSが運用(バックアップ/パッチ/監視など)してくれるマネージドDBです。
ECS/FargateのアプリはRDSに接続して、
ユーザー情報や取引データみたいな永続データを保存します。
rds/
├─ instance.tf # RDSインスタンス定義
├─ subnet_group.tf # DBサブネットグループ定義
└─ variables.tf # rdsモジュール入力変数
RDSの手動設定
Postgresを使っています。
まずはサブネットグループを作って、
そのあとDBの設定をしました。
サブネットグループの設定
名前をdigital-ichiba-rds-subnet-groupにします。
VPCを自作のdigital-ichiba-vpcにします。
アベイラビリティゾーンをap-northeast-1aとap-northeast-1cにします。
サブネットを自作のdigital-ichiba-subnet-private-1aと、
digital-ichiba-subnet-private-1cを選択します。
作成します。
DBインスタンスの設定
フル設定を選びます。
Postgresを選びます。
エンジンのバージョンを18.1-R1にしておきます。
本番稼働用を選びます。
マルチAZ DBインスタンスデプロイの2インスタンスを選びます。
DBインスタンス識別子を、digital-ichiba-dbとします。
マスターユーザー名を設定して覚えておきます。
パスワードはセルフマネージドで、設定して覚えておきます。
インスタンスは一番安いt3.microを選びます。
ストレージはpg2の20GBにします。
自作VPCのdigital-ichiba-vpcを選択します。
先程作ったサブネットグループのdigital-ichiba-rds-subnet-groupを選択します。
セキュリティグループは自作のdigital-ichiba-sg-dbを選択します。
下の方に追加設定があり、
そこで最初のDBの名前を設定して覚えておきます。
残りの設定はデフォルトのままで作成します。
RDSの取り込み
サブネットグループから取り込んでいきます。
サブネットグループの取り込み
variable "private_subnet_1a_id" {
type = string
}
variable "private_subnet_1c_id" {
type = string
}
variable "db_security_group_id" {
type = string
}
rds/variables.tfを作成して変数を扱えるようにしておきます。
module "rds" {
source = "./rds"
private_subnet_1a_id = module.network.private_subnet_1a_id
private_subnet_1c_id = module.network.private_subnet_1c_id
db_security_group_id = module.network.db_security_group_id
}
main.tfに追記します。
terraform init
モジュールを有効化します。
resource "aws_db_subnet_group" "rds_subnet_group" {
name = "digital-ichiba-rds-subnet-group"
subnet_ids = [var.private_subnet_1a_id, var.private_subnet_1c_id]
}
rds/subnet_group.tfを作成します。
リソースを定義します。
terraform import \
module.rds.aws_db_subnet_group.rds_subnet_group \
digital-ichiba-rds-subnet-group
インポートをして、差分がないことを確認します。
DBインスタンスの取り込み
resource "aws_db_instance" "main" {
instance_class = "db.t3.micro"
storage_encrypted = true
}
rds/instance.tfを作成して、
リソースを定義します。
terraform import \
module.rds.aws_db_instance.main \
digital-ichiba-db
インポートします。
resource "aws_db_instance" "main" {
instance_class = "db.t3.micro"
storage_encrypted = true
identifier = "digital-ichiba-db"
allocated_storage = 20
engine = "postgres"
engine_version = "18.1"
db_subnet_group_name = aws_db_subnet_group.rds_subnet_group.name
vpc_security_group_ids = [var.db_security_group_id]
apply_immediately = false
copy_tags_to_snapshot = true
deletion_protection = true
max_allocated_storage = 1000
skip_final_snapshot = false
final_snapshot_identifier = "digital-ichiba-db-final-snapshot"
tags = {
devops-guru-default = "digital-ichiba-db"
}
lifecycle {
# 既存環境の設定値(IAM Roleなど)をここで決め打ちできないため、差分のみ抑止する
ignore_changes = [
monitoring_interval,
monitoring_role_arn,
performance_insights_enabled,
performance_insights_kms_key_id,
performance_insights_retention_period,
tags_all,
]
}
}
このように埋めると差分がなくなります。
lifecycleのignore_changesとしているのは、
AWS側で勝手に書き換える値なので、
Terraform側で扱わないようにしています。
skip_final_snapshot = false
final_snapshot_identifier = "digital-ichiba-db-final-snapshot"
この2行で、削除するときスナップショットを取らせます。
削除時に必ず同名で作られるので、必要なら日時サフィックス等を検討してください。
engine_version は 必ずコンソール / describe-db-instances の値と完全一致させてください。
一致しない場合、意図せず upgrade / downgrade が発生します。
これでRDSを取り込むことができました。
S3の構造と取り込み
プロフィール画像や商品の画像、
お店のヘッダーなどを投稿すると、
S3で保存できるようにします。
S3の手動設定
デフォルトの設定で作ります。
ACLは無効のままで、
パブリックアクセスはすべてブロックにします。
Railsにはタスクロールで、S3を操作する権限があります。
S3の取り込み
resource "aws_s3_bucket" "app" {
bucket = "aws-training-shu"
tags = {
Name = "aws-training-shu"
Service = "digital-ichiba"
}
}
bucket名を明記して、ついでにタグをつけておきます。
terraform import aws_s3_bucket.app aws-training-shu
インポートします。
planをして、タグの追加を確認してapplyします。
あえて取り込まないもの
Secrets Managerは、手動でAWSコンソールを使って管理するため、
Terraformには取り込みません。
Github Actions側で、ARNを使って使いますが、
Terraformで直接使う場面が今回にはないです。
LogsもIAMの権限だけTerraformに取り込んだのですが、
Logs自体をTerraform化する必要がないと思うので、
取り込みませんでした。
まとめ
本記事では、手動デプロイ済みの AWS 環境を
Terraform import でコード管理へ移行しました。
現場でよくある「あとから Terraform 化する」流れを想定しています。
import は state を登録するだけなので、
import 後に terraform plan で差分を確認し、
.tf を実環境に合わせて調整することが重要です。
これを怠ると apply 時に事故が起きます。
構成は Network → ALB / Route53 → IAM → ECS → RDS → S3 の順で整理しました。
ECS の Task Definition やデプロイは GitHub Actions に任せ、
Terraform では Cluster / Service などの基盤のみ管理しています。
また、Secrets Manager や CloudWatch Logs など、
Terraform で管理しない方が安全なものは参照や権限管理に留めることで、
運用負荷とリスクを下げています。
この構成により、既存環境を壊さず Terraform 管理へ移行し、
Terraform と GitHub Actions を役割分担した実運用に近い形を作ることができました。