前回までで、管理画面を本番用のコンテナイメージにまとめました。今回から、そのイメージを動かす AWS 側の準備に入ります。最初に作るのは、Terraform(=インフラの構成をコードで書き、その通りに作成・変更するツール)の土台と、すべての部品が乗るネットワーク(VPC)です。
「AWS の画面で手作業で作った環境が、誰がいつ何を変えたのか分からなくなってしまった。コードで管理したいけれど、どこから始めればいいのか…」
結論から言うと、Terraform で最初に決めるのは「state(=何を作ったかの記録)をどこに置き、同時に書き換えられないようにするか」と「ネットワークを public と private に分け、外への出口をどうするか」の2つです。ここを後から変えるのは大変なので、最初の回でまとめて固めます。
前回(第6回:本番用コンテナイメージを作る)では、nginx と php-fpm の本番用イメージを作りました。今回はそれを動かすための VPC・サブネット・NAT Gateway・セキュリティグループを Terraform で定義します。
なお、執筆環境には AWS アカウントを用意していないため、terraform apply(実際の作成)は行っていません。terraform validate による構文と整合性の確認と、AWS に接続しない形での plan(作成予定の一覧)までを確認しています。
Terraform の土台とネットワークで作るもの
今回追加するファイルは次のとおりです(すべて infra/terraform/ 配下)。
| ファイル | 内容 |
|---|---|
versions.tf / .terraform.lock.hcl | Terraform と AWS プロバイダー(=AWS を操作するためのプラグイン)のバージョン固定 |
backend.tf / backend.hcl.example | state の保存先(S3)とロック |
providers.tf | リージョンと、全リソース共通のタグ |
variables.tf / locals.tf / terraform.tfvars.example | 環境ごとに変える値 |
network.tf | VPC、サブネット、インターネットゲートウェイ、NAT Gateway、ルートテーブル |
vpc_endpoints.tf | S3 のゲートウェイ型エンドポイント、インターフェイス型エンドポイント(任意) |
security_groups.tf | ALB・Web タスク・その他のタスク・RDS の通信の許可 |
outputs.tf | 次回以降に使う ID の出力 |
1つのディレクトリに役割ごとのファイルを並べる構成にしました(この規模ならモジュール分割は不要と判断)。検証環境(stg)と本番(prod)は、同じコードに別の変数と別の state を渡して作り分けます。
state をS3に置き、use_lockfile でロックする
state とは何か
Terraform は、作ったリソースの ID や設定を state ファイルに記録し、次の実行時に「コードと実際の差分」を計算します。state を担当者のパソコンに置くと、別の人の実行で差分を正しく計算できず、二重作成や削除事故につながるため、S3 などの共有の場所に置きます。
backend の設定:バケット名はコードに書かない
infra/terraform/backend.tf
# state(Terraform が管理しているリソースの記録)の保存先
#
# バケット名などの環境ごとの値はコードに書かず、init 時に渡す(部分設定)
# terraform init -backend-config=backend.hcl
# backend.hcl の例は backend.hcl.example を参照(backend.hcl 自体は Git に入れない)
terraform {
backend "s3" {
# S3 にロックファイル(<key>.tflock)を作って同時実行を防ぐ。DynamoDB のロック用テーブルは不要
use_lockfile = true
encrypt = true
}
}
infra/terraform/backend.hcl.example
bucket = "example-admin-tfstate" # 実際のバケット名に置き換える
key = "admin/stg/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true は、Terraform 1.11 で正式な機能になった S3 のネイティブロックです。実行中は state と同じ場所にロック用のファイルを置き、他の人が同時に apply できないようにします。以前は DynamoDB のテーブルをロックに使うのが定番でしたが、1.11 のドキュメントでは DynamoDB によるロックは非推奨とされています。
📰 出典:Terraform v1.11.x Backend Type: s3
バケット名や state のパス(key)は、backend.hcl に書いて terraform init -backend-config=backend.hcl で渡します(部分設定)。環境ごとに key を変えれば、同じコードで stg と prod の state を分けられます。
state 用の S3 バケットそのものは、Terraform の外で先に作ります(state を置く場所を、その state で管理することはできないためです)。AWS CLI なら次のような手順です。バージョニングを有効にしておくと、state を誤って壊したときに前の版に戻せます。
aws s3api create-bucket --bucket <バケット名> --region ap-northeast-1 \
--create-bucket-configuration LocationConstraint=ap-northeast-1
aws s3api put-bucket-versioning --bucket <バケット名> --versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket <バケット名> \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
state には、リソースの設定値がそのまま記録されます。パスワードなどの秘密情報を Terraform の変数で渡すと state にも残るため、この連載では DB のパスワードを AWS 側で生成・管理させます(第8回)。
バージョン固定と共通タグ
infra/terraform/versions.tf(terraform ブロック)
terraform {
required_version = "~> 1.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.97"
}
}
}
~> 5.97 は「5.97 以上、6.0 未満」という指定です。実際に使う版は、terraform init で作られる .terraform.lock.hcl に記録され、チーム全員が同じ版を使います。筆者の環境(2025年8月時点)では AWS プロバイダー 5.100.0 が選ばれました。このファイルは Git に含めます。
infra/terraform/providers.tf
provider "aws" {
region = var.region
# すべてのリソースに共通のタグを付ける(費用の集計・持ち主の確認に使う)
default_tags {
tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
}
default_tags を付けておくと、請求画面で環境ごとの費用をタグで集計できます。ManagedBy = "terraform" は、画面から手で変更してはいけないリソースの目印にもなります。
VPC を public と private のサブネットに分ける
ネットワークの全体像
| 場所 | 置くもの | インターネットとの関係 |
|---|---|---|
| public サブネット(2つの AZ) | ALB(ロードバランサー)、NAT Gateway | 直接つながる |
| private サブネット(2つの AZ) | ECS のタスク、RDS | 外から直接は届かない。外向きは NAT Gateway 経由 |
AZ(アベイラビリティゾーン)は、同じリージョン内で物理的に離れたデータセンターの単位です。ALB と RDS はどちらも2つ以上の AZ のサブネットを必要とするため、最初から2つの AZ に作ります。
サブネットの IP アドレス範囲は、VPC の 10.0.0.0/16 を cidrsubnet() 関数で /24 に分けて決めています(public は 10.0.0.0/24・10.0.1.0/24、private は 10.0.10.0/24・10.0.11.0/24)。
NAT Gateway:1台にするか AZ ごとにするか
infra/terraform/network.tf(NAT Gateway と private のルート)
resource "aws_eip" "nat" {
count = local.nat_gateway_count
domain = "vpc"
tags = { Name = "${local.name_prefix}-nat-${var.azs[count.index]}" }
}
resource "aws_nat_gateway" "main" {
count = local.nat_gateway_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "${local.name_prefix}-nat-${var.azs[count.index]}" }
depends_on = [aws_internet_gateway.main]
}
resource "aws_route" "private_nat" {
count = length(var.azs)
route_table_id = aws_route_table.private[count.index].id
destination_cidr_block = "0.0.0.0/0"
# NAT が1台なら全 AZ がそれを使う。AZ ごとに置いた場合は同じ AZ の NAT を使う
nat_gateway_id = aws_nat_gateway.main[min(count.index, local.nat_gateway_count - 1)].id
}
locals.tf で nat_gateway_count = var.single_nat_gateway ? 1 : length(var.azs) としており、変数1つで台数を切り替えられます。
- 1台(
single_nat_gateway = true、既定):費用を抑えられます。その代わり、NAT Gateway のある AZ に障害が起きると、もう一方の AZ のタスクも外に出られなくなります。 - AZ ごと(
false):1つの AZ の障害でも外に出られます。NAT Gateway は台数分の時間課金がかかります。
検証環境は1台、本番は業務の止まってよい時間に応じて判断する、というのが一般的な考え方です。
📰 出典:AWS NAT ゲートウェイ(Amazon VPC ユーザーガイド)
NAT Gateway と VPC エンドポイントを比較する
private サブネットの ECS タスクは、起動時にイメージの取得(ECR)、ログの送信(CloudWatch Logs)、秘密情報の取得(Secrets Manager・SSM Parameter Store)のために AWS のサービスへ接続します。その経路は2通りあります。
| 経路 | 仕組み | 費用の構造 | 向いている場面 |
|---|---|---|---|
| NAT Gateway | インターネット経由で AWS のサービスや外部 API に出る | 台数×時間+通信量 | 外部の API(決済・メールなど)も使う。構成を単純にしたい |
| VPC エンドポイント(インターフェイス型) | VPC 内から AWS のサービスへ直接つなぐ | エンドポイント数×AZ 数×時間+通信量 | 外に出る通信をなくしたい。イメージの取得などの通信量が多い |
| VPC エンドポイント(ゲートウェイ型、S3) | ルートテーブルで S3 への通信を直接つなぐ | 追加料金なし | 常に作っておいてよい |
infra/terraform/vpc_endpoints.tf(抜粋)
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
tags = { Name = "${local.name_prefix}-s3" }
}
locals {
# ECS on Fargate がイメージ取得・ログ送信・シークレット取得に使うサービスと、キュー(SQS)
interface_endpoint_services = var.enable_interface_endpoints ? toset([
"ecr.api",
"ecr.dkr",
"logs",
"secretsmanager",
"ssm",
"sqs",
]) : toset([])
}
ECR のイメージ本体は S3 から取得されるため、S3 のゲートウェイ型エンドポイントは常に作り、NAT Gateway を通る通信量を減らしています。インターフェイス型は enable_interface_endpoints で切り替えられるようにしました。6つのサービス×2つの AZ 分の時間課金がかかるため、この連載の既定は「NAT Gateway 1台+S3 ゲートウェイ型」です。
📰 出典:Amazon ECR インターフェイス VPC エンドポイント(AWS PrivateLink)
セキュリティグループは「相手のグループ」で許可する
セキュリティグループ(=リソースごとの通信の許可リスト)は、IP アドレスではなく「どのセキュリティグループからの通信か」で許可します。ECS のタスクは起動のたびに IP アドレスが変わるためです。
| グループ | 受け付ける通信 | 出ていく通信 |
|---|---|---|
| alb | インターネットから 80・443 | web へ 8080 |
| web(nginx+php-fpm) | alb から 8080 | 443(AWS の API など)、rds へ 3306 |
| app(worker・scheduler・マイグレーション) | なし | 443、rds へ 3306 |
| rds | web と app から 3306 | なし |
infra/terraform/security_groups.tf(web タスクと RDS の部分)
# web タスク(nginx+php-fpm):ALB からの 8080 番だけを受け付ける
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web"
description = "ECS web tasks: from ALB only"
vpc_id = aws_vpc.main.id
tags = { Name = "${local.name_prefix}-web" }
}
resource "aws_vpc_security_group_ingress_rule" "web_from_alb" {
security_group_id = aws_security_group.web.id
description = "From ALB"
referenced_security_group_id = aws_security_group.alb.id
ip_protocol = "tcp"
from_port = 8080
to_port = 8080
}
resource "aws_vpc_security_group_ingress_rule" "rds_from_tasks" {
for_each = {
web = aws_security_group.web.id
app = aws_security_group.app.id
}
security_group_id = aws_security_group.rds.id
description = "MySQL from ECS ${each.key} tasks"
referenced_security_group_id = each.value
ip_protocol = "tcp"
from_port = 3306
to_port = 3306
}
待ち受けを 8080 番にしているのは、第6回で nginx を非 root で動かすようにしたためです。ルールは aws_security_group の中に書かず、aws_vpc_security_group_ingress_rule / egress_rule という1ルール1リソースの書き方にしました。AWS プロバイダーのドキュメントでも、aws_security_group の中にルールを書く方法は避け、こちらを使うことが勧められています。ルールごとに説明やタグを付けられ、追加・削除が他のルールに影響しにくくなります。
📰 出典:Terraform AWS Provider 5.100.0 aws_security_group
動作確認の方法
Terraform も Docker のイメージで実行します(infra/terraform で実行)。
docker run --rm -v $PWD:/w -w /w hashicorp/terraform:1.11.4 fmt -check -recursive
docker run --rm -v $PWD:/w -w /w hashicorp/terraform:1.11.4 init -backend=false
docker run --rm -v $PWD:/w -w /w hashicorp/terraform:1.11.4 validate
init -backend=false は、S3 の state に接続せずにプロバイダーだけをダウンロードする指定で、AWS アカウントがなくても構文とリソースの整合性を確認できます。筆者の環境では次の結果でした。
fmt -checkは差分なし、validateはSuccess! The configuration is valid..terraform.lock.hclに AWS プロバイダー 5.100.0 が記録された- 確認用に、backend をローカルに差し替え、AWS に接続しない設定(ダミーの認証情報と認証チェックの省略)を一時的に加えて
planを実行したところ、既定の変数でPlan: 35 to add。single_nat_gateway = falseとenable_interface_endpoints = trueにすると NAT Gateway が2台、インターフェイス型エンドポイントが6つ増えてPlan: 43 to addになった。environment = "dev"のように許可していない値は、変数の検証でエラーになった
この plan は AWS に問い合わせていないため、AZ 名が実在するか、アカウントの上限に収まるかなどは確認できていません。読者の AWS アカウントでは、backend.hcl と terraform.tfvars を用意して、terraform init -backend-config=backend.hcl、terraform plan で作成予定を確認してから apply してください。
つまずきやすい点・セキュリティ上の注意
- state・tfvars を Git に入れない:
.gitignoreに*.tfstate、*.tfvars(例のファイルを除く)、backend.hclを入れています。state にはリソースの詳細な設定が入ります。 - 画面から手で変更しない:Terraform で作ったリソースを AWS の画面で変更すると、次の
planで元に戻す差分が出ます。緊急対応で変更した場合は、必ずコードにも反映します。 - NAT Gateway は作った瞬間から課金される:検証が終わった環境は
terraform destroyで削除するか、使わない時間帯の扱いを決めておきます。 - サブネットの IP 範囲は後から変えにくい:社内ネットワークや他の VPC と接続する予定があれば、重ならない範囲を最初に決めます。
- VPC フローログなどの監査の仕組みは省略:本番では、通信の記録(VPC フローログ)の要否もセキュリティ要件として検討します。
発注者向けメモ:AWS の費用は「常にかかるもの」と「使った分」に分かれる
AWS の月額費用は、大きく2種類に分けて考えると見積もりが読みやすくなります。
- 時間課金で常に発生するもの:NAT Gateway、ALB、RDS、Fargate のタスク、インターフェイス型 VPC エンドポイント。利用者がいない夜間も費用がかかります
- 使った分だけのもの:通信量、ログの量と保存期間、S3 の保存量
今回の NAT Gateway のように、「1台にするか AZ ごとにするか」で費用と障害への強さが変わる部品があります。検証環境と本番で構成を分けるのは一般的ですが、その判断の根拠(どれくらい止まってよいか)は発注者側が決める必要があります。
- インフラをコードで管理しているか:Terraform などのコードが納品物に含まれるか
- state の置き場所と権限:誰の AWS アカウントに、誰が変更できる形で置くか
- 検証環境と本番の違い:どこを省略し、それによってどんなリスクを受け入れるか
開発会社には、次のように確認してみてください。
- 「AWS の構成はコードで管理していますか?そのコードと state は、契約終了後にこちらで引き継げますか?」
- 「検証環境と本番で構成が違う部分はどこですか?それぞれの月額の目安を分けて出せますか?」
- 「1つのアベイラビリティゾーンで障害が起きたとき、システムはどうなりますか?」
- 「画面から手作業で変更した場合、コードとのずれをどうやって防いでいますか?」
まとめと次回予告
第7回では、Terraform の土台とネットワークを定義しました。
- state は S3 に置き、Terraform 1.11 の
use_lockfileでロックする。バケット名は部分設定で渡し、コードに書かない .terraform.lock.hclでプロバイダーの版を固定し、default_tagsで全リソースにタグを付ける- VPC を public と private のサブネット(2つの AZ)に分け、NAT Gateway の台数を変数で切り替える
- S3 はゲートウェイ型エンドポイントを常に作り、インターフェイス型は必要に応じて有効にする
- セキュリティグループは相手のグループで許可し、RDS には ECS のタスクからだけ接続できるようにする
次回は「RDS for MySQL と秘密情報:パスワードをコードに書かない」です。今回作った private サブネットに RDS for MySQL 8.4 を置き、DB のパスワードを AWS に管理させる方法と、APP_KEY などの秘密情報の置き場所を扱います。
この連載の記事一覧
この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第1回】全体像と開発環境:Laravel 12+Vue 3 スターターキットを Docker で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第2回】顧客マスタの一覧と登録:FormRequest でバリデーションする
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第3回】ロールと Policy:誰が何をできるかをコードで決める
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第4回】案件管理と一覧画面の作り込み:検索・並べ替え・ページング
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第5回】請求と非同期処理:キューとスケジューラをローカルで動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第6回】本番用コンテナイメージを作る:マルチステージ Dockerfile
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第7回】Terraform の土台とネットワーク:state 管理と VPC(この記事)
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第8回】RDS for MySQL と秘密情報:パスワードをコードに書かない
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第9回】ECR・ECS on Fargate・ALB:管理画面をインターネットに公開する
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第10回】キューワーカーとスケジューラを ECS で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第11回】GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第12回】運用編:ログ・監視・スケーリング・切り戻し










コメント
コメント一覧 (1件)
[…] 前回(第7回:Terraform の土台とネットワーク)では、VPC と private サブネット、RDS 用のセキュリティグループを作りました。今回はそこに RDS を置き、秘密情報の置き場所と読み取り権限を定義します。 […]