TerraformでWebアプリをデプロイしよう

このハンズオンでは、ノートアプリケーション(HTML/CSS/JavaScript + FastAPI + MySQL)をTerraformのモジュールを活用してAWSにデプロイする方法について実際にハンズオン形式で手を動かしながら体験します。

  • モジュールを使った再利用可能なインフラ構成
  • リモートバックエンドによるtfstate管理
  • VPC、サブネット、セキュリティグループのコード化
  • RDSによるデータベース層の構築
  • ECS(Fargate)+ ALBによるコンテナベースのAPIサーバの構築
  • S3 + CloudFrontによるフロントエンドの配信
  • カスタムドメインとACM証明書によるHTTPS通信の設定

1. 事前準備

Gitの基礎知識を習得していることを前提とします。自信のない方は先に以下の講座を実施してください。

この講座のハンズオンでは、以下のツールやアカウントが必要です。まだ準備できていない場合は、リンク先の手順に沿って準備をお願いします。

💡 ポイント
このハンズオンでは、ALBとCloudFrontにカスタムドメインとHTTPS通信を設定します。TerraformでRoute53ホストゾーンを新規作成し、DNSレコードを自動管理する構成のため、baseモジュールの適用後にドメインのネームサーバを新しいホストゾーンのNSレコードに更新する必要があります。

2. ハンズオンの概要

TerraformでシンプルなAWS構成を作ろう」では、VPC・サブネット・セキュリティグループ・EC2といった基本的なリソースをTerraformで構築しました。このハンズオンでは、その構成をベースに以下の観点を追加し、より実践的な構成に挑戦します。

  • 前回は1つのmain.tfにすべてのリソースを記述しましたが、今回は変更頻度とチームの責務を基準にモジュールを分割し、再利用可能な構成にします
  • 前回はtfstateをローカルに保存しましたが、今回はS3 + DynamoDBによるリモートバックエンドを導入し、チーム開発に対応できる構成にします
  • 前回はパブリックサブネット1つにEC2を配置しましたが、今回はパブリック/プライベートサブネットを2つのアベイラビリティゾーンに作成し、可用性の高いマルチAZ構成にします
  • 前回はEC2にアプリケーションを配置しましたが、今回はECS(Fargate)を使ってコンテナベースで動かします
  • 前回はパブリックIPで直接アクセスしましたが、今回はRoute53 + ACM証明書でカスタムドメインとHTTPS通信を設定します

2.1 独自ドメインの利用

このハンズオンでは、ALBとCloudFrontにカスタムドメインを設定してHTTPS通信を行います。そのため、独自ドメインの取得が事前に必要です。Route53ホストゾーンの作成やDNSレコードの設定はTerraformで自動的に行うため、ドメインの取得だけ済ませておけば問題ありません。

まだドメインを取得していない場合は、「コンテンツ配信をしよう」の「ドメインの取得」セクションの手順を参考に、Route53またはお名前.comなどのレジストラでドメインを取得してください。

2.2 コンテナ操作について

このハンズオンでは、APIサーバをECS(Fargate)上のコンテナとして動かします。そのため、ハンズオンの中でDockerを使った以下の操作を行います。

  • アプリケーションのコンテナイメージをdocker buildでビルド
  • ビルドしたイメージをECR(AWSのコンテナレジストリ)にdocker pushでアップロード

コンテナの基本操作に不安がある場合は、「Dockerの基本操作」や「ECR講座」を先に確認しておくとスムーズに進められます。

2.3 扱うAWSサービス

前回のハンズオンではVPC・サブネット・セキュリティグループ・EC2の4種類を扱いましたが、今回は本番環境を想定した以下のサービスを組み合わせて構築します。

レイヤ AWSサービス 役割
ネットワーク VPC、サブネット(パブリック/プライベート)、インターネットゲートウェイ、ルートテーブル マルチAZ対応のネットワーク基盤
アプリケーション ECS(Fargate)、ECR、ALB FastAPIコンテナによるAPIサーバ
データベース RDS(MySQL) プライベートサブネットに配置したデータベース
フロントエンド S3、CloudFront 静的ファイルのCDN配信
権限管理 IAMロール ECSタスクからAWSサービスへのアクセス権限

これらのリソースをモジュールごとに分割し、environments/ディレクトリで環境ごとの設定を分離する構成を採用します。リモートバックエンドでtfstateをS3で管理し、すべてのリソース名には環境名(test)のプレフィックスが付き、test-noteapp-vpcのような命名規則になります。

2.4 ディレクトリ構成

このハンズオンで作成するディレクトリ構成は以下のとおりです。modules/ディレクトリにTerraformモジュールを配置し、environments/test/ディレクトリからモジュールを呼び出してインフラを構築します。

terraform-noteapp/
├── modules/
│   ├── remote/              ← コードは自動生成
│   ├── base/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── api/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── frontend/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── environments/
│   └── test/
│       ├── backend.tf
│       ├── main.tf
│       ├── variables.tf
│       ├── outputs.tf
│       └── terraform.tfvars
└── app/
    └── api/                 ← git cloneされたリポジトリ
        ├── api/
        │   ├── Dockerfile
        │   ├── main.py
        │   ├── models.py
        │   ├── database.py
        │   ├── requirements.txt
        │   └── routers/
        │       └── notes.py
        └── front/
            ├── index.html
            ├── style.css
            └── script.js

モジュールは変更頻度とチームの責務を基準に4つに分割しています。

モジュール 内容 分割の理由
remote S3バケット(tfstate保存)、DynamoDBテーブル(ロック) tfstate管理のためのリソースで、最初に1回作成した後はほぼ変更しない。アプリケーションのインフラとはライフサイクルが異なる
base VPC、サブネット、IGW、ルートテーブル、セキュリティグループ、RDS、ECR、CloudWatch Logs、Route53ホストゾーン ネットワークやデータベースなどの基盤リソースは変更頻度が低く、安定稼働後はほぼ触らない
api ECSサービス、タスク定義、ターゲットグループ、ALB、ACM(ALB用)、Route53 Aレコード APIサーバはアプリケーションの更新やスケーリング変更で比較的頻繁に変更される
frontend S3、CloudFront、ACM(CloudFront用、us-east-1)、Route53レコード フロントエンドはデプロイ設定の変更やCDN設定の調整で独立して変更される
📝 モジュール分割の考え方
モジュールを分割することで、APIの変更時にVPCやRDSまでplan/applyの対象になることを避けられます。また、インフラチーム・バックエンドチーム・フロントエンドチームがそれぞれの領域だけを管理でき、変更による影響範囲を限定できます。将来environments/prod/のように本番環境を追加する際にも、同じモジュールを再利用できます。

3. プロジェクトのセットアップ

ハンズオンの概要で確認したとおり、このハンズオンではモジュールごとにファイルを分割した構成でインフラを構築します。まず、その土台となるプロジェクトフォルダとディレクトリ構成を作成します。

任意の場所にterraform-noteappフォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から、作成したterraform-noteappフォルダを開きます。

terraform-noteapp  ← このフォルダを作成

続いて、以下のディレクトリ構成を作成します。今回は必要なフォルダとファイルが多いため、コマンドで一括作成します。

terraform-noteapp/
├── modules/
│   ├── remote/
│   ├── base/
│   ├── api/
│   └── frontend/
├── environments/
│   └── test/
└── app/

Visual Studio Codeのターミナルを開き、terraform-noteappフォルダ内で以下のコマンドを実行してください。フォルダと空のファイルがすべて一括で作成されます。

Windowsの場合:

フォルダを作成します。

mkdir modules\remote modules\base modules\api modules\frontend environments\test app

空のファイルを作成します。

type nul > modules\base\main.tf
type nul > modules\base\variables.tf
type nul > modules\base\outputs.tf
type nul > modules\api\main.tf
type nul > modules\api\variables.tf
type nul > modules\api\outputs.tf
type nul > modules\frontend\main.tf
type nul > modules\frontend\variables.tf
type nul > modules\frontend\outputs.tf
type nul > environments\test\backend.tf
type nul > environments\test\main.tf
type nul > environments\test\variables.tf
type nul > environments\test\outputs.tf
type nul > environments\test\terraform.tfvars

macOSの場合:

フォルダを作成します。

mkdir -p modules/remote modules/base modules/api modules/frontend environments/test app

空のファイルを作成します。

touch modules/base/main.tf \
      modules/base/variables.tf \
      modules/base/outputs.tf \
      modules/api/main.tf \
      modules/api/variables.tf \
      modules/api/outputs.tf \
      modules/frontend/main.tf \
      modules/frontend/variables.tf \
      modules/frontend/outputs.tf \
      environments/test/backend.tf \
      environments/test/main.tf \
      environments/test/variables.tf \
      environments/test/outputs.tf \
      environments/test/terraform.tfvars

Visual Studio Codeのエクスプローラーで、フォルダと空のファイルがすべて作成されていることを確認してください。

💡 ポイント
modules/remote/にはファイルを作成していません。このモジュールでは、importブロックの-generate-config-outオプションでコードを自動生成するため、事前にファイルを用意する必要がありません。importブロック自体はルートモジュール(environments/test/main.tf)に記述します。

4. アプリケーションの準備

プロジェクトのディレクトリ構成が整ったので、次にデプロイ対象となるアプリケーションのソースコードを準備します。Terraformでインフラを構築する前に、コンテナイメージのビルド元となるコードやフロントエンドのファイルが手元に必要です。

4.1 コンテナイメージの準備

ECS(Fargate)でAPIサーバを動かすには、コンテナイメージが必要です。ここでは、APIアプリケーションのリポジトリをcloneして、後ほどコンテナイメージをビルドできるように準備します。

app/フォルダに移動します。

cd app

リポジトリをcloneします。apiという名前のフォルダにcloneされます。

git clone https://github.com/gevanni-academy/sample-note-api.git api

cloneが完了すると、以下の構成になります。

terraform-noteapp/
├── app/
│   └── api/                 ← cloneされたリポジトリ
│       ├── api/
│       │   ├── Dockerfile
│       │   ├── main.py
│       │   ├── models.py
│       │   ├── database.py
│       │   ├── requirements.txt
│       │   └── routers/
│       │       └── notes.py
│       └── front/
│           ├── index.html
│           ├── style.css
│           └── script.js
├── ...

コンテナイメージのビルドとECRへのPushは、baseモジュールでECRリポジトリを作成した後に行います。

4.2 フロントエンドアプリケーション

フロントエンドのソースコードは、先ほどcloneしたリポジトリのfront/フォルダに含まれています。

terraform-noteapp/
├── app/
│   └── api/
│       ├── front/              ← このフォルダのファイルを使用
│       │   ├── index.html
│       │   ├── style.css
│       │   └── script.js
│       ├── api/
│       └── ...
├── ...

front/script.jsの先頭に、APIの接続先が定義されています。

const API_URL = 'http://localhost:8000';

この値はローカル開発用の設定です。Terraformでフロントエンドをデプロイする際に、Terraformの replace 関数を使ってこの http://localhost:8000https://api.<ドメイン名> に自動で書き換えるため、ソースコードを手動で修正する必要はありません。

5. リモートバックエンドの準備

Terraformの状態ファイル(tfstate)をリモートで管理するために、S3バケットとDynamoDBテーブルを用意します。リモートバックエンドを使うことで、チームメンバー間での状態の共有、DynamoDBによるロックでの同時実行防止、S3のバージョニングによる状態ファイルの履歴管理が可能になります。

💡 ポイント
リモートバックエンドには「鶏と卵」の問題があります。tfstateを保存するS3バケット自体をTerraformで作ろうとすると、「tfstateの保存先がまだ存在しないのに、Terraformを実行しなければならない」という矛盾が生じます。この問題を解決するため、S3バケットとDynamoDBテーブルは先にマネジメントコンソールで手動作成し、その後Terraformのimportブロックで管理下に取り込む方法を採用します。
📝 別の管理方法(bootstrap pattern)
本講座では、手動で作成したS3・DynamoDBをterraform importで同じstateに取り込む方式を採用していますが、実務では「bootstrap pattern」と呼ばれる別の方式も広く採用されています。これは、バックエンド用リソース(S3 + DynamoDB)専用のrootディレクトリを別途用意し、アプリ側のTerraform設定とは別のstateで管理するアプローチです。

import方式が向くケース: 単一環境の小規模構成、構成のシンプルさを優先したい場合
bootstrap patternが向くケース: 複数環境(dev / staging / prod等)を扱う、バックエンドとアプリのライフサイクルを分離したい場合

両方とも実務で採用されている正当な選択肢です。本講座では構成のシンプルさを優先してimport方式を採用していますが、ポートフォリオやプロジェクトの規模に応じてbootstrap patternを選んでも構いません。

5.1 S3バケットの作成

リモートバックエンドでは、tfstateファイルの保存先としてS3バケットが必要です。ここでは、マネジメントコンソールでS3バケットを手動作成します。

AWSマネジメントコンソールでS3のダッシュボードを開き、「バケットを作成」をクリックします。

下記表に従い、設定を行ってください。記載のない項目はデフォルトのままで構いません。

設定項目 設定の基準
バケット名 noteapp-tfstate-{自分のAWSアカウントID} アカウントIDを含めてグローバルで一意にする
AWSリージョン アジアパシフィック(東京)ap-northeast-1 他のリソースと同じリージョンに配置
バケットのバージョニング 有効にする tfstateの変更履歴を保持し、誤操作時に復元可能にする

設定後、「バケットを作成」をクリックします。

無事にS3バケットが作成されれば、ここまでの操作は完了です。

📝 バケット名にアカウントIDを含める理由
S3バケット名はグローバルで一意である必要があります。アカウントIDを含めることで、他のAWSアカウントとの名前の衝突を防ぎます。

5.2 DynamoDBテーブルの作成

S3バケットだけでは、複数人が同時にterraform applyを実行した場合にtfstateが競合する可能性があります。DynamoDBテーブルを用意することで、Terraformが排他ロックを取得し、同時実行による破損を防止できます。

AWSマネジメントコンソールでDynamoDBのダッシュボードを開き、「テーブルの作成」をクリックします。

下記表に従い、設定を行ってください。記載のない項目はデフォルトのままで構いません。

設定項目 設定の基準
テーブル名 noteapp-tfstate-lock Terraformのロックテーブルであることがわかる名前
パーティションキー LockID(文字列) Terraformのロック機構が使用するキー名
テーブル設定 設定をカスタマイズ キャパシティモードを変更するため
キャパシティモード オンデマンド ロック用途のためアクセス頻度が低く、オンデマンドが最適

設定後、「テーブルの作成」をクリックします。

DynamoDBテーブルが作成されたら、リモートバックエンドの準備は完了です。この後、Terraformの初期化時にこれらのリソースがバックエンドとして正しく機能することを確認します。

6. リモートバックエンドのコード化

リモートバックエンドの準備ができたので、environments/test/ディレクトリにバックエンド設定のコードを作成し、Terraformを初期化します。その後、マネジメントコンソールで手動作成したS3バケットとDynamoDBテーブルをimportブロックでTerraformの管理下に取り込みます。

6.1 コードの記載

environments/test/ディレクトリにリモートバックエンドの設定コードを記述していきます。ここでは、リモートバックエンドのbackend設定と、AWSプロバイダの基本設定を記述します。変数やモジュールの呼び出しは、リモートバックエンドのコード化が完了した後で追加していきます。

backend.tf

environments/test/backend.tfに以下の内容を記述します。bucketの値は実際のS3バケット名に置き換えてください。

terraform {
  backend "s3" {
    bucket         = "noteapp-tfstate-123456789012"
    key            = "test/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "noteapp-tfstate-lock"
    encrypt        = true
  }
}
💡 ポイント
keytest/terraform.tfstateと指定することで、環境ごとにtfstateファイルを分けて管理できます。将来prod環境を追加する場合は、prod/terraform.tfstateのように別のキーを使用します。

コードを解説します。

backend "s3"

terraform {
  backend "s3" {
    bucket         = "noteapp-tfstate-123456789012"
    key            = "test/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "noteapp-tfstate-lock"
    encrypt        = true
  }
}

tfstateファイルをS3に保存するためのバックエンド設定です。

bucketは、tfstateファイルの保存先となるS3バケット名を指定する設定です。リモートバックエンドで作成したバケット名を指定します。

keyは、S3バケット内でのtfstateファイルのパスを指定する設定です。test/terraform.tfstateとすることで、環境ごとにtfstateを分離できます。

dynamodb_tableは、同時実行防止のためのロックテーブルを指定する設定です。複数人が同時にterraform applyを実行した場合の競合を防ぎます。

encryptは、tfstateファイルをS3上で暗号化するかどうかを制御する設定です。trueにすることで、機密情報を含むtfstateが暗号化されて保存されます。

main.tf

environments/test/main.tfに以下の内容を記述します。この時点ではAWSプロバイダの基本設定のみを記述します。変数やモジュールの呼び出しは、リモートバックエンドのコード化が完了した後で順番に追加していきます。

terraform {
  required_version = ">= 1.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

コードを解説します。

terraform / required_providers

terraform {
  required_version = ">= 1.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

Terraform 1.0以上の安定版を使用し、AWSプロバイダはメジャーバージョン6系(~> 6.0)を指定しています。

プロバイダ設定

provider "aws" {
  region = "ap-northeast-1"
}

メインのプロバイダは東京リージョン(ap-northeast-1)を使用します。

6.2 Terraformの初期化とリモートバックエンドの確認

ターミナルでenvironments/test/ディレクトリに移動し、Terraformを初期化します。この初期化により、backend.tfで設定したS3バケットとDynamoDBテーブルにtfstateが保存されるようになります。

アプリケーションの準備でapp/フォルダに移動しているため、まずプロジェクトルートに戻ります。

cd ..

environments/test/ディレクトリに移動します。

cd environments/test

Terraformを初期化します。

terraform init

以下のような出力が表示されれば成功です。Successfully configured the backend "s3"!というメッセージが表示され、先ほどマネジメントコンソールで作成したS3バケットがリモートバックエンドとして正しく設定されたことを確認してください。

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v5.x.x...
- Installed hashicorp/aws v5.x.x (signed by HashiCorp)

Terraform has been successfully initialized!

リモートバックエンド(S3)が正しく設定されていることを確認してください。

⚠️ エラーが出る場合
「Error configuring S3 Backend」などのエラーが出る場合、backend.tfbucket名が実際に作成したS3バケット名と一致しているか確認してください。また、AWS CLIの認証情報が正しく設定されていることも確認してください。

6.3 remoteモジュールのインポート

リモートバックエンドが正しく設定されたことを確認できたので、次にマネジメントコンソールで手動作成したS3バケットとDynamoDBテーブルをTerraformの管理下に取り込みます。ここでは、importブロックのコード生成機能-generate-config-out)を使って、既存リソースからTerraformコードを自動生成します。

📝 これから行う作業の全体像
このセクションでは、以下の流れで既存リソースをTerraformの管理下に取り込みます。少し複雑なので、最初に全体像を把握しておくと迷いません。

1. importブロックをルートモジュールに記述(まだ module.remote. 付きにしない)
2. terraform plan -generate-config-out=generated.tfenvironments/test/generated.tf を自動生成
3. 生成された generated.tf の不要な属性を削除
4. generated.tfmodules/remote/ に移動
5. importブロックの tomodule.remote.aws_xxx に書き換え
6. terraform planterraform apply で取り込みを反映

この2段階方式を採っている理由は、-generate-config-out オプションが ルートモジュールのリソースにのみ対応 しているためです。モジュール内のリソースには直接コードを生成できないため、一度ルートで生成してからモジュールに移動する という流れが必要になります。

importブロックの追加

environments/test/main.tfのプロバイダ設定の下に、remoteモジュールの呼び出しとimportブロックを追記します。importブロックはルートモジュール(environments/test/)に記述する必要があるため、モジュール呼び出しと一緒にmain.tfに記載します。<アカウントID>の部分はご自身のAWSアカウントIDに置き換えてください。

まず、コード自動生成のために、importブロックのtoをルートモジュールのアドレス(module.remote.なし)で記述します。<アカウントID>の部分はご自身のAWSアカウントIDに置き換えてください。

# リモートバックエンドモジュール
module "remote" {
  source = "../../modules/remote"
}

# コード自動生成用(生成後にmodule.remote.付きに書き換える)
import {
  to = aws_s3_bucket.tfstate
  id = "noteapp-tfstate-<アカウントID>"
}

import {
  to = aws_s3_bucket_versioning.tfstate
  id = "noteapp-tfstate-<アカウントID>"
}

import {
  to = aws_dynamodb_table.tfstate_lock
  id = "noteapp-tfstate-lock"
}
📝 importブロックについて
importブロックは、Terraform 1.5で導入された機能で、既存のAWSリソースをTerraformの管理下に宣言的に取り込めます。従来のterraform importコマンドと異なり、コードとして管理できるため、チームメンバーが同じ手順を再現しやすくなります。toにTerraformリソースのアドレスを、idにAWSリソースの識別子を指定します。

Terraformの再初期化

新しいモジュールを追加したため、Terraformを再初期化してモジュールを読み込む必要があります。

terraform init

以下のように、remoteモジュールの初期化が行われれば成功です。

Initializing modules...
- remote in ../../modules/remote

Initializing the backend...

Initializing provider plugins...

Terraform has been successfully initialized!

コードの自動生成

Terraformの初期化が完了したら、importブロックの-generate-config-outオプションを使って、既存のAWSリソースからTerraformコードを自動生成します。-generate-config-outはルートモジュールのリソースにのみ対応しているため、先ほどimportブロックのtomodule.remote.なしで記述しました。

terraform plan -generate-config-out=generated.tf

以下のような実行結果が表示されます。エラーが表示される場合がありますが、generated.tfファイル自体は生成されているため問題ありません。

aws_s3_bucket.tfstate: Preparing import... [id=noteapp-tfstate-123456789012]
aws_s3_bucket_versioning.tfstate: Preparing import... [id=noteapp-tfstate-123456789012]
aws_dynamodb_table.tfstate_lock: Preparing import... [id=noteapp-tfstate-lock]

Plan: 3 to import, 0 to add, 0 to change, 0 to destroy.
💡 ポイント
-generate-config-outは、importブロックで指定したリソースの現在の状態をAWSから読み取り、対応するresourceブロックを自動生成する機能です。手動でコードを書く必要がなく、AWSの実態と完全に一致したコードが得られます。ただし、この機能は実験的(experimental)であり、生成されたコードに手動修正が必要な場合があります。

生成されたコードの確認と移動

生成されたenvironments/test/generated.tfを開いて内容を確認しましょう。S3バケット、バージョニング設定、DynamoDBテーブルの3つのリソースが定義されているはずです。

-generate-config-out は実験的(experimental)機能のため、そのままではapplyできない値が含まれることがあります。具体的には、DynamoDBテーブル(aws_dynamodb_table)のpoint_in_time_recoveryブロック内に recovery_period_in_days = 0 という行がある場合は、その行を削除してください。

  point_in_time_recovery {
    recovery_period_in_days = 0  # ← この行を削除
  }

削除する理由は、recovery_period_in_days は有効値(1以上)を求める属性であり、自動生成時に 0 が入ってしまうと後の terraform applyValue must be at least 1 のようなバリデーションエラーが発生するためです。属性自体を省略すると、AWSのデフォルト値が使われます。

また、値がnullになっている属性も同様にそのままだとエラーや不要な差分の原因になるため、削除して構いません。

修正が完了したら、generated.tfmodules/remote/ディレクトリに移動します。

Windowsの場合:

move generated.tf ..\..\modules\remote\generated.tf

macOSの場合:

mv generated.tf ../../modules/remote/generated.tf

移動後、modules/remote/ディレクトリにgenerated.tfが存在し、environments/test/ディレクトリからgenerated.tfがなくなっていれば成功です。

importブロックの書き換え

generated.tfをモジュール内に移動したので、environments/test/main.tfのimportブロックのtomodule.remote.付きのアドレスに書き換えます。

# マネジメントコンソールで手動作成したリソースをTerraformに取り込む
import {
  to = module.remote.aws_s3_bucket.tfstate
  id = "noteapp-tfstate-<アカウントID>"
}

import {
  to = module.remote.aws_s3_bucket_versioning.tfstate
  id = "noteapp-tfstate-<アカウントID>"
}

import {
  to = module.remote.aws_dynamodb_table.tfstate_lock
  id = "noteapp-tfstate-lock"
}
📝 なぜ書き換えが必要なのか
-generate-config-outはルートモジュールのリソースにのみ対応しているため、コード生成時はmodule.remote.なしで記述しました。生成されたコードをモジュールに移動した後は、importブロックのtoをモジュール内のアドレス(module.remote.付き)に書き換えることで、正しくインポートされます。

差分の確認

コードが自動生成されましたが、AWSへ反映する前にterraform planで差分を確認し、意図しない変更がないことを確かめます。

terraform plan

以下のような実行結果が表示されます。

module.remote.aws_s3_bucket.tfstate: Preparing import... [id=noteapp-tfstate-123456789012]
module.remote.aws_s3_bucket_versioning.tfstate: Preparing import... [id=noteapp-tfstate-123456789012]
module.remote.aws_dynamodb_table.tfstate_lock: Preparing import... [id=noteapp-tfstate-lock]

Plan: 3 to import, 0 to add, 0 to change, 0 to destroy.

3つのリソースがインポート対象として表示され、新規作成や変更が0件であることを確認してください。自動生成されたコードはAWSの実態と一致しているため、差分は発生しません。

AWSへの反映

terraform planで差分に問題がないことを確認できたので、terraform applyでAWSに反映します。

terraform apply

確認プロンプトが表示されたら、yesと入力して実行します。以下のように、3件のリソースがインポートされたことを確認します。

Apply complete! Resources: 3 imported, 0 added, 0 changed, 0 destroyed.

これで、マネジメントコンソールで作成したS3バケットとDynamoDBテーブルがTerraformの管理下に置かれました。以降、これらのリソースの変更や削除もTerraformで管理できます。

💡 ポイント
importが完了したら、environments/test/main.tfのimportブロック3つは削除して構いません。importブロックは取り込み時に一度だけ使用するもので、取り込み完了後は不要です。残しておいても害はありませんが、コードをすっきりさせるために削除するのが一般的です。

これで、リモートバックエンドのコード化が完了しました。マネジメントコンソールで手動作成したS3バケットとDynamoDBテーブルがTerraformの管理下に置かれ、モジュールを追加するたびにterraform planで差分を確認し、terraform applyで反映できる状態になりました。

7. メインインフラの準備

リモートバックエンドのコード化が完了したので、次にアプリケーションのインフラを構築するための変数や環境設定をenvironments/test/ディレクトリに定義していきます。

7.1 コードの記載

environments/test/ディレクトリの各ファイルにコードを記述していきます。ここでは、リポジトリにコミットしたくない値(機密情報・受講生ごとに異なる値)を受け取るための入力変数と、この環境で使う固定値をまとめるローカル変数を定義します。

💡 ポイント
このあと modules/base/variables.tf でも db_password などの変数を定義します。環境側とモジュール側の両方に同じ名前の変数が現れますが、これはTerraformのモジュールの仕組み上、必要な構造です。理由は modules/base/ のコードを記述した後に詳しく解説します。

variables.tf

environments/test/variables.tfに以下の内容を記述します。ここではリポジトリにコミットしたくない値だけを変数として宣言します。具体的には、機密情報であるdb_passwordと、受講生ごとに異なるdomain_nameの2つです。環境名やVPCのCIDRなど、この環境で使う固定値は後ほどmain.tflocalsブロックにまとめて定義します。

variable "db_password" {
  description = "RDSのマスタパスワード"
  type        = string
  sensitive   = true
}

variable "domain_name" {
  description = "カスタムドメイン名(例: example.com)"
  type        = string
}

コードを解説します。

db_password

variable "db_password" {
  description = "RDSのマスタパスワード"
  type        = string
  sensitive   = true
}

RDSのマスタパスワードです。sensitiveは、変数の値をterraform planterraform applyの出力に表示するかどうかを制御する設定です。trueにすることで、パスワードがターミナルに表示されることを防ぎます。機密情報であるため、実際の値はterraform.tfvarsに記述してリポジトリには含めません。

domain_name

variable "domain_name" {
  description = "カスタムドメイン名(例: example.com)"
  type        = string
}

カスタムドメイン名です。ALBのHTTPS設定(api.<ドメイン名>)とCloudFrontのHTTPS設定(<ドメイン名>)の両方で使用します。受講生ごとに異なるドメインを使用するため、この値もリポジトリには含めずterraform.tfvarsに記述します。

📝 変数 / ローカル変数の使い分け
Terraformで値を扱う場所には、大きく 変数(variables.tf + terraform.tfvarsローカル変数(localsブロック) の2種類があります。本ハンズオンでは、「リポジトリにコミットしたくない値(機密情報や受講生ごとに異なる値)」は変数として variables.tf で宣言し、terraform.tfvars で値を指定します。terraform.tfvars.gitignore でGit管理から除外します。一方、「この環境で使う固定値」(環境名、VPCのCIDR、AZ、DBユーザ名など)は main.tflocals ブロックで直接定義します。こうすることで、ディレクトリ(environments/test/)とそこで使う値の対応関係が明確になり、リポジトリを見るだけで「この環境がどう構成されているか」が一目でわかるようになります。

main.tfの更新

environments/test/main.tfに、この環境で使う固定値をまとめたlocalsブロックを追記します。プロバイダ設定の下に以下を追加してください。

locals {
  env                = "test"
  project_name       = "${local.env}-noteapp"
  vpc_cidr           = "10.0.0.0/16"
  availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
  db_username        = "admin"
  db_name            = "notedb"
}
📝 project_nameについて
localsブロックでproject_name${local.env}-noteappと定義しています。envtestなので、すべてのリソース名がtest-noteapp-vpctest-noteapp-api-albのように環境名付きの名前になります。これにより、複数の環境が同じAWSアカウント内に共存できます。

コードを解説します。

env / project_name

locals {
  env          = "test"
  project_name = "${local.env}-noteapp"
}

envはこの環境の識別子です。ディレクトリ(environments/test/)がすでに環境を示しているため、localsで固定値としてハードコードします。本番環境を追加する際は、environments/prod/main.tflocalsenv = "prod"と定義します。project_nameenvをプレフィックスとしたリソース命名規則で、envtestならtest-noteappprodならprod-noteappとなります。

vpc_cidr / availability_zones

locals {
  vpc_cidr           = "10.0.0.0/16"
  availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
}

VPCのCIDRブロックと、マルチAZ構成で使用するアベイラビリティゾーンのリストです。どちらもこの環境の固定値としてハードコードします。本番環境を追加する場合は、environments/prod/main.tflocalsで別の値(例:10.1.0.0/16)を設定することで、環境ごとに異なるCIDRブロックを使用できます。

db_username / db_name

locals {
  db_username = "admin"
  db_name     = "notedb"
}

RDSのマスタユーザ名とデータベース名です。機密情報ではなく、環境によって変える必要もないため、localsで固定値として定義します。

terraform.tfvars

environments/test/terraform.tfvarsに以下の内容を記述します。このファイルには、variables.tfで宣言した2つの変数(機密情報のdb_passwordと、受講生ごとに異なるdomain_name)の値だけを記述します。

db_password = "YourSecurePassword123!"
domain_name = "example.com"  # ご自身のドメイン名に置き換えてください

受講生が書き換えるのはこの2つの値だけです。他の設定値(envvpc_cidravailability_zonesなど)はmain.tflocalsで固定値として定義しているため、terraform.tfvarsに記述する必要はありません。

本番環境を追加する場合は、environments/prod/main.tflocalsenv = "prod"などを定義し、environments/prod/terraform.tfvarsに本番用のパスワードとドメイン名を記述することで、同じモジュールを別の環境にデプロイできます。

📝 terraform.tfvarsの取り扱い
terraform.tfvarsには機密情報(データベースパスワード)が含まれます。Gitで管理する場合は、リポジトリにコミットしないよう.gitignore*.tfvarsを追加してください。
⚠️ 「No value for required variable」エラーが出る場合
terraform planterraform apply 実行時に Error: No value for required variable が出る場合、以下のいずれかが原因です。
1. terraform.tfvars の記述漏れdb_passworddomain_name の両方が記述されているか確認してください
2. ファイル名の間違いterraform.tfvar(sなし)や terraform.tf.vars ではなく、正確に terraform.tfvars である必要があります
3. ファイルの配置場所の間違いenvironments/test/ ディレクトリ直下にある必要があります

これで、モジュールを追加するための環境設定が整いました。次のセクションから、base、api、frontendの各モジュールを順番に作成していきます。

8. baseモジュールの作成

メインインフラの準備が整ったので、次にアプリケーションを動かすためのネットワークやデータベースなどの基盤リソースを作成します。VPC、サブネット、セキュリティグループ、RDSなどをbaseモジュールとしてまとめて管理します。

このモジュールで作成されるリソースは以下のとおりです。

種類 リソース名 用途
Route53 ホストゾーン test-noteapp-zone カスタムドメインのDNSレコードを管理
VPC test-noteapp-vpc ネットワークの基盤
パブリックサブネット test-noteapp-public-1, 2 ALB、NATゲートウェイを配置
プライベートサブネット test-noteapp-private-1, 2 ECS Fargate、RDSを配置
インターネットゲートウェイ test-noteapp-igw VPCからインターネットへの出入口
NATゲートウェイ test-noteapp-nat-gw プライベートサブネットからのインターネットアクセス
セキュリティグループ test-noteapp-alb-sg ALBのインバウンドルール(HTTPS/HTTP)
セキュリティグループ test-noteapp-api-sg ECSタスクのインバウンドルール(ポート8000)
セキュリティグループ test-noteapp-rds-sg RDSのインバウンドルール(ポート3306)
RDS test-noteapp-db MySQLデータベース
ECR test-noteapp-api APIのコンテナイメージを格納
CloudWatch Logs /ecs/test-noteapp-api ECSタスクのログを収集

8.1 コードの記載

ここでは、VPC、サブネット、インターネットゲートウェイ、ルートテーブル、セキュリティグループ、RDSを一つのモジュールとして作成します。modules/base/ディレクトリの各ファイルにコードを記述していきます。

variables.tf

modules/base/variables.tfに以下の内容を記述します。

variable "project_name" {
  description = "プロジェクト名"
  type        = string
}

variable "vpc_cidr" {
  description = "VPCのCIDRブロック"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "使用するアベイラビリティゾーン"
  type        = list(string)
}

variable "db_name" {
  description = "データベース名"
  type        = string
}

variable "db_username" {
  description = "マスタユーザ名"
  type        = string
}

variable "db_password" {
  description = "マスタパスワード"
  type        = string
  sensitive   = true
}

variable "db_instance_class" {
  description = "RDSインスタンスクラス"
  type        = string
  default     = "db.t3.micro"
}

variable "domain_name" {
  description = "カスタムドメイン名"
  type        = string
}

コードを解説します。

project_name

variable "project_name" {
  description = "プロジェクト名"
  type        = string
}

リソース名のプレフィックスとして使用します。test-noteappが渡されると、VPCはtest-noteapp-vpc、ALBはtest-noteapp-api-albのような命名になります。

vpc_cidr / availability_zones

variable "vpc_cidr" {
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  type        = list(string)
}

vpc_cidrは、VPCに割り当てるIPアドレス範囲を定義する設定です。デフォルトの10.0.0.0/16で約65,000個のIPアドレスを確保できます。

availability_zonesは、マルチAZ構成で使用するアベイラビリティゾーンのリストです。["ap-northeast-1a", "ap-northeast-1c"]のように指定します。

RDS関連の変数

variable "db_password" {
  type        = string
  sensitive   = true
}

variable "db_instance_class" {
  default     = "db.t3.micro"
}

db_namedb_usernamedb_passworddb_instance_classの4つです。

sensitiveは、変数の値をterraform planterraform applyの出力に表示するかどうかを制御する設定です。trueにすることで、パスワードなどの機密情報がターミナルに表示されることを防ぎます。

db_instance_classはデフォルトでdb.t3.micro(検証用の最小構成)を使用します。

domain_name

variable "domain_name" {
  description = "カスタムドメイン名"
  type        = string
}

カスタムドメイン名(例: example.com)です。この値を基に、Route53ホストゾーンの作成を行います。

📝 環境側とモジュール側で variables.tf が分かれている理由
environments/test/variables.tfmodules/base/variables.tf の両方に db_password など同じ名前の変数が登場します。一見すると二重定義に見えますが、これはTerraformのモジュールの仕組み上、必要な構造です。environments/test/variables.tf は環境ごとに「何の値を受け取るか」を宣言する場所(terraform.tfvars から値を受け取る入口)で、modules/base/variables.tf はモジュールが「どんな値を受け取れるか」を定義する場所(関数の引数宣言のようなもの)です。プログラミングに例えると、モジュール側の variables.tf関数の引数定義、環境側の variables.tfその関数を呼び出すときに渡す値の宣言にあたります。値は terraform.tfvars → 環境側 variables.tf(または locals)→ main.tfmodule "base" { ... } 呼び出し → モジュール側 variables.tf という順番でバケツリレーで渡っていきます。この流れがTerraformでは必須のため、環境側とモジュール側の両方に変数定義が必要になります。

main.tf

modules/base/main.tfに以下の内容を記述します。

# =============================================================================
# ネットワーク
# =============================================================================

# VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

# インターネットゲートウェイ
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-igw"
  }
}

# パブリックサブネット
resource "aws_subnet" "public" {
  count = length(var.availability_zones)

  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${count.index + 1}"
  }
}

# プライベートサブネット
resource "aws_subnet" "private" {
  count = length(var.availability_zones)

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name = "${var.project_name}-private-${count.index + 1}"
  }
}

# パブリックルートテーブル
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "${var.project_name}-public-rt"
  }
}

# パブリックサブネットとルートテーブルの関連付け
resource "aws_route_table_association" "public" {
  count = length(var.availability_zones)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# NATゲートウェイ用のElastic IP
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "${var.project_name}-nat-eip"
  }
}

# NATゲートウェイ(パブリックサブネットに配置)
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.project_name}-nat-gw"
  }
}

# プライベートルートテーブル
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = {
    Name = "${var.project_name}-private-rt"
  }
}

# プライベートサブネットとルートテーブルの関連付け
resource "aws_route_table_association" "private" {
  count = length(var.availability_zones)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

# =============================================================================
# ドメイン・証明書
# =============================================================================

# Route53ホストゾーンの作成
resource "aws_route53_zone" "main" {
  name = var.domain_name

  tags = {
    Name = "${var.project_name}-zone"
  }
}

# =============================================================================
# セキュリティグループ
# =============================================================================

# ALB用セキュリティグループ
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group for ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTPS from Internet"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTP from Internet (redirect to HTTPS)"
    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"]
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

# API用セキュリティグループ
resource "aws_security_group" "api" {
  name        = "${var.project_name}-api-sg"
  description = "Security group for API containers"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "HTTP from ALB"
    from_port       = 8000
    to_port         = 8000
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-api-sg"
  }
}

# RDS用セキュリティグループ
resource "aws_security_group" "rds" {
  name        = "${var.project_name}-rds-sg"
  description = "Security group for RDS"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "MySQL from API containers"
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.api.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-rds-sg"
  }
}

# =============================================================================
# RDS
# =============================================================================

# DBサブネットグループ
resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet-group"
  subnet_ids = aws_subnet.private[*].id

  tags = {
    Name = "${var.project_name}-db-subnet-group"
  }
}

# RDSインスタンス
resource "aws_db_instance" "main" {
  identifier = "${var.project_name}-db"

  engine         = "mysql"
  engine_version = "8.0"
  instance_class = var.db_instance_class

  allocated_storage     = 20
  max_allocated_storage = 100
  storage_type          = "gp2"

  db_name  = var.db_name
  username = var.db_username
  password = var.db_password

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  publicly_accessible = false
  skip_final_snapshot = true

  tags = {
    Name = "${var.project_name}-db"
  }
}

# =============================================================================
# ECR / CloudWatch Logs
# =============================================================================

# ECRリポジトリ
resource "aws_ecr_repository" "api" {
  name         = "${var.project_name}-api"
  force_delete = true

  tags = {
    Name = "${var.project_name}-api"
  }
}

# CloudWatch Logsグループ
resource "aws_cloudwatch_log_group" "api" {
  name              = "/ecs/${var.project_name}-api"
  retention_in_days = 7

  tags = {
    Name = "${var.project_name}-api-logs"
  }
}

コードを解説します。

Route53ホストゾーン

resource "aws_route53_zone" "main" {
  name = var.domain_name

カスタムドメインのDNSレコードを管理するためのホストゾーンを作成します。

nameは、ホストゾーンで管理するドメイン名を指定する設定です。ここではvar.domain_name(例: example.com)を指定し、このホストゾーンの中にALBやCloudFront向けのDNSレコードを追加していきます。

VPC

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

VPCを作成します。

cidr_blockは、VPCのIPアドレス範囲を定義する設定です。10.0.0.0/16を指定することで、約65,000個のIPアドレスを確保しています。

enable_dns_hostnamesは、VPC内のリソースにDNSホスト名を自動的に付与するかどうかを制御する設定です。trueにすることで、EC2やRDSなどにDNSホスト名が割り当てられます。

enable_dns_supportは、VPC内でのDNS名前解決を有効にするかどうかを制御する設定です。trueにすることで、VPC内のリソースがドメイン名でお互いに通信できるようになります。

サブネット

resource "aws_subnet" "public" {
  count                   = length(var.availability_zones)
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  map_public_ip_on_launch = true

パブリックサブネットを2つのアベイラビリティゾーンに作成します。

cidrsubnetは、VPCのCIDRブロックをもとにサブネット用のCIDRを自動計算する関数です。cidrsubnet(var.vpc_cidr, 8, count.index)とすることで、10.0.0.0/2410.0.1.0/24が割り当てられます。

map_public_ip_on_launchは、サブネット内に起動したリソースにパブリックIPアドレスを自動付与するかどうかを制御する設定です。trueにすることで、ALBやNATゲートウェイがインターネットと通信できるようになります。

resource "aws_subnet" "private" {
  count      = length(var.availability_zones)
  cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)

プライベートサブネットを2つのアベイラビリティゾーンに作成します。cidrsubnetの第3引数にcount.index + 10を指定することで、10.0.10.0/2410.0.11.0/24が割り当てられ、パブリックサブネットとCIDRが重複しません。

NATゲートウェイ

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id
}

プライベートサブネットからインターネットにアクセスするためのNATゲートウェイを作成します。

allocation_idは、NATゲートウェイに割り当てるElastic IPを指定する設定です。Elastic IPを割り当てることで、固定のパブリックIPアドレスでインターネットにアクセスします。

subnet_idは、NATゲートウェイを配置するサブネットを指定する設定です。NATゲートウェイはパブリックサブネットに配置する必要があるため、aws_subnet.public[0]を指定しています。

💡 ポイント
このハンズオンではゾーナルNATゲートウェイを1つのAZにのみ配置しています。業務では、リージョン全体で1つのNATゲートウェイを共有できるリージョナルNATゲートウェイを使うと構成がシンプルになりますが、リージョン内のAZ数に応じた時間料金がかかります。ハンズオンではコストを抑えるため、ゾーナルNATゲートウェイを1つだけ配置するシングル構成にしています。
resource "aws_route_table" "private" {
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

プライベートサブネット用のルートテーブルを作成します。

cidr_blockは、ルーティングの宛先となるIPアドレス範囲を指定する設定です。0.0.0.0/0を指定することで、すべてのインターネット宛トラフィックが対象になります。

nat_gateway_idは、トラフィックの転送先となるNATゲートウェイを指定する設定です。これにより、ECS FargateタスクがECRからイメージを取得したり、CloudWatch Logsにログを送信したりできるようになります。

セキュリティグループ(ALB用)

resource "aws_security_group" "alb" {
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

ALB用のセキュリティグループを作成します。

ingressは、インバウンド(外部からの受信)トラフィックを許可するルールです。from_portto_portは許可するポート範囲を指定する設定で、同じ値を指定すると単一ポートの許可になります。ここではHTTPS(443)とHTTP(80)の両方を許可しています。HTTPリクエストはALBのリスナーでHTTPSにリダイレクトされるため、ポート80も開放しておく必要があります。

RDS

resource "aws_db_instance" "main" {
  engine              = "mysql"
  engine_version      = "8.0"
  instance_class      = var.db_instance_class
  publicly_accessible = false
  skip_final_snapshot = true

MySQL 8.0のRDSインスタンスをプライベートサブネットに作成します。

publicly_accessibleは、RDSインスタンスにインターネットから直接アクセスできるかどうかを制御する設定です。falseにすることで、VPC内部からのみアクセスを許可し、セキュリティを確保しています。

skip_final_snapshotは、RDSインスタンスを削除する際にスナップショットを自動取得するかどうかを制御する設定です。trueにすることで、削除時のスナップショット取得をスキップし、ハンズオン終了時にスムーズに削除できるようにしています。本番環境ではfalseにして、削除前にスナップショットを取得してください。

ECRリポジトリ

resource "aws_ecr_repository" "api" {
  name         = "${var.project_name}-api"
  force_delete = true

APIのコンテナイメージを格納するECRリポジトリを作成します。

force_deleteは、リポジトリ内にイメージが残っている状態でも削除を許可するかどうかを制御する設定です。trueにすることで、terraform destroy時にイメージが残っていてもリポジトリを削除できます。

CloudWatch Logsグループ

resource "aws_cloudwatch_log_group" "api" {
  name              = "/ecs/${var.project_name}-api"
  retention_in_days = 7

ECSタスクのログを収集するCloudWatch Logsグループを作成します。

retention_in_daysは、ログの保持期間(日数)を制御する設定です。7にすることで、7日経過したログが自動的に削除されます。ハンズオン用のため短い保持期間にしていますが、本番環境では要件に応じて適切な期間を設定してください。

modules/base/outputs.tfに以下の内容を記述します。

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "パブリックサブネットIDのリスト"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "プライベートサブネットIDのリスト"
  value       = aws_subnet.private[*].id
}

output "alb_security_group_id" {
  description = "ALB用セキュリティグループID"
  value       = aws_security_group.alb.id
}

output "api_security_group_id" {
  description = "API用セキュリティグループID"
  value       = aws_security_group.api.id
}

output "rds_endpoint" {
  description = "RDSエンドポイント"
  value       = aws_db_instance.main.address
}

output "ecr_repository_url" {
  description = "ECRリポジトリURL"
  value       = aws_ecr_repository.api.repository_url
}

output "cloudwatch_log_group_name" {
  description = "CloudWatch Logsグループ名"
  value       = aws_cloudwatch_log_group.api.name
}

output "route53_zone_id" {
  description = "Route53ホストゾーンID"
  value       = aws_route53_zone.main.zone_id
}
📝 モジュールの出力について
モジュールのoutputs.tfで定義した値は、モジュールを呼び出す側でmodule.<モジュール名>.<出力名>として参照できます。これにより、モジュール間でリソースの情報を受け渡すことができます。

次に、environments/test/main.tfにbaseモジュールの呼び出しを追加します。localsブロックの下に以下を追記してください。

# 基盤モジュール(ネットワーク + RDS + ドメイン)
module "base" {
  source = "../../modules/base"

  project_name       = local.project_name
  vpc_cidr           = local.vpc_cidr
  availability_zones = local.availability_zones
  db_name            = local.db_name
  db_username        = local.db_username
  db_password        = var.db_password
  domain_name        = var.domain_name
}
📝 モジュール間の依存関係
baseモジュールの中でVPC、サブネット、セキュリティグループ、RDSをまとめて管理しているため、リソース間の依存関係はモジュール内部で自動的に解決されます。apiモジュールやfrontendモジュールは、baseモジュールの出力値を参照することで、必要なリソース情報を受け取ります。

8.2 Terraformの再初期化

baseモジュールを追加したため、Terraformを再初期化してモジュールを読み込みます。

terraform init

以下のように、baseモジュールの初期化が行われれば成功です。

Initializing modules...
- base in ../../modules/base

Initializing the backend...

Initializing provider plugins...

Terraform has been successfully initialized!
📝 terraform init が必要になるタイミング
本ハンズオンに限らず、以下のタイミングで terraform init を実行する必要があります。既存リソースのコードを書き換えただけでは不要ですが、以下のいずれかに該当する場合は忘れずに実行してください。

- 新しいモジュールを追加したとき(本セクションのように module "base" { ... } を追加した場合)
- 既存モジュールの source を書き換えたとき(モジュールの配置先を変更した場合)
- 新しいプロバイダを追加したとき(例: provider "aws" { alias = "us_east_1" } を追加)
- バックエンド設定(backend.tf)を変更したとき
- Terraform本体のバージョンを変更したとき

逆に、モジュール内の main.tf / variables.tf / outputs.tf を編集しただけなら init は不要 です。その場合はそのまま terraform plan / terraform apply を実行できます。迷ったら init を実行しても害はないので、エラーが出たときは一度 init を試してみるのもおすすめです。

8.3 差分の確認

terraform planで作成されるリソースを確認します。

terraform plan

以下のような実行結果が表示されます。

Terraform will perform the following actions:

  # module.base.aws_route53_zone.main will be created
  # module.base.aws_vpc.main will be created
  # module.base.aws_internet_gateway.main will be created
  # module.base.aws_subnet.public[0] will be created
  # module.base.aws_subnet.public[1] will be created
  # module.base.aws_subnet.private[0] will be created
  # module.base.aws_subnet.private[1] will be created
  # module.base.aws_route_table.public will be created
  # module.base.aws_route_table_association.public[0] will be created
  # module.base.aws_route_table_association.public[1] will be created
  # module.base.aws_eip.nat will be created
  # module.base.aws_nat_gateway.main will be created
  # module.base.aws_route_table.private will be created
  # module.base.aws_route_table_association.private[0] will be created
  # module.base.aws_route_table_association.private[1] will be created
  # module.base.aws_security_group.alb will be created
  # module.base.aws_security_group.api will be created
  # module.base.aws_security_group.rds will be created
  # module.base.aws_db_subnet_group.main will be created
  # module.base.aws_db_instance.main will be created
  # module.base.aws_ecr_repository.api will be created
  # module.base.aws_cloudwatch_log_group.api will be created

Plan: 22 to add, 0 to change, 0 to destroy.

Route53ホストゾーン、VPC、サブネット(パブリック2つ、プライベート2つ)、インターネットゲートウェイ、パブリックルートテーブル、ルートテーブルの関連付け(パブリック2つ)、Elastic IP、NATゲートウェイ、プライベートルートテーブル、ルートテーブルの関連付け(プライベート2つ)、セキュリティグループ(3つ)、DBサブネットグループ、RDSインスタンス、ECRリポジトリ、CloudWatch Logsグループの合計22リソースが作成されることを確認してください。

8.4 AWSへの反映

問題なければterraform applyで反映します。

terraform apply

確認プロンプトが表示されたら、yesと入力して実行します。以下のように、22件のリソースが作成されたことを確認します。

Apply complete! Resources: 22 added, 0 changed, 0 destroyed.
💡 ポイント
RDSの作成には 5〜15分程度 かかります。コマンドが止まっているように見えても、裏ではAWS側でデータベースインスタンスの起動処理が進んでいるので、焦らず待ちましょう。待ち時間を有効に使うために、以下のようなことをしておくのがおすすめです。

- ここまでに記述した environments/test/main.tfmodules/base/main.tf を読み返し、どのリソースがどんな順番で作られているかを追ってみる
- 前のセクションの📝ポイント(variables.tfterraform.tfvars の役割分担、モジュール構成の理由など)を復習する
- AWSマネジメントコンソールで VPC や RDS のダッシュボードを開いておき、リソースが作成されていく様子を観察する
⚠️ ホストゾーンの作成でエラーが出る場合
「コンテンツ配信をしよう」のハンズオンで作成したRoute53ホストゾーンが残っている場合、同じドメイン名のホストゾーンが重複して作成されます。Terraformは正常に完了しますが、DNSの名前解決が正しく行われない可能性があります。Route53のダッシュボードで同じドメイン名のホストゾーンが2つ存在する場合は、古い方(Terraformで作成していない方)を手動で削除してください。

8.5 ネームサーバの設定

Terraformで新しいホストゾーンを作成すると、既存のホストゾーンとは異なるネームサーバ(NS)が割り当てられます。ドメインのDNS解決を新しいホストゾーンで行うために、ネームサーバの設定を更新する必要があります。

💡 ポイント
このネームサーバ設定は、本ハンズオンで最もつまずきやすいポイントの一つです。設定が正しく行われていないと、この後のセクション(apiモジュール作成)で実行する terraform apply が、ACM証明書のDNS検証で 20〜30分ほど待機し続けた後にタイムアウト失敗 します。時間を無駄にしないためにも、次のセクションに進む前にネームサーバが正しく更新されていることを必ず確認してください

Route53のダッシュボードでホストゾーンを開き、NSレコードに表示されている4つのネームサーバを確認します。

次に、Route53の「登録済みドメイン」を開き、対象のドメインを選択します。

「ネームサーバ」セクションで「編集」をクリックします。

ホストゾーンのNSレコードに表示されている4つのネームサーバに更新してください。NSレコードの値の末尾にはピリオド(.)が付いていますが、ネームサーバの設定時にはピリオドを除いた値を入力してください。

ネームサーバの設定が完了したら、次のステップに進みます。

💡 ポイント
お名前.comなどの外部レジストラでドメインを管理している場合は、Route53の「登録済みドメイン」ではなく、レジストラの管理画面でネームサーバを設定してください。手順はレジストラごとに異なるため、「<レジストラ名> ネームサーバ 変更」で検索するとスムーズです。

ネームサーバ設定のチェックリスト

次のセクションに進む前に、以下の4点が満たされていることを必ず確認してください。

  • ホストゾーンに表示されている4つのNSレコードをコピーした
  • ピリオド(.)を除いた値を設定した(例: ns-123.awsdns-45.com.ns-123.awsdns-45.com
  • レジストラ(Route53の「登録済みドメイン」または外部レジストラ)に4つすべて登録した
  • 自分のドメインを購入した方のレジストラ側で設定した(ホストゾーン側だけ見ても設定は完了しない)

ネームサーバ伝播の確認(推奨)

ネームサーバの設定変更はDNSへの反映に数分〜数十分かかることがあります。次のセクションに進んで terraform apply を実行する前に、以下のコマンドで実際の伝播状況を確認しておくと、ACM検証のタイムアウトを未然に防げます。

macOS / Linux:

dig NS <ご自身のドメイン名> +short

Windows(PowerShell):

Resolve-DnsName -Name <ご自身のドメイン名> -Type NS

以下のような実行結果が表示されます。

ns-123.awsdns-45.com.
ns-456.awsdns-78.net.
ns-789.awsdns-01.org.
ns-012.awsdns-23.co.uk.

Route53のホストゾーンに表示されているNSレコードと同じ4つが返ってくれば伝播完了です。異なるネームサーバが返ってくる場合は、設定が反映されるまで数分待ってから再度確認してください。

8.6 AWSコンソールでの確認

terraform applyの成功メッセージだけでは、リソースが意図した設定で作成されたかまではわかりません。AWSマネジメントコンソールで実際のリソースを確認し、設定値が正しいことを確かめます。

Route53: Route53のダッシュボードを開き、ご自身のドメイン名のホストゾーンが作成されていることを確認します。

  • NSレコードSOAレコードが自動的に作成されていること

VPC: VPCのダッシュボードを開き、「test-noteapp-vpc」という名前のVPCが作成されていることを確認します。

  • IPv4 CIDR が「10.0.0.0/16」であること
  • DNSホスト名 が「有効」であること
  • DNS解決 が「有効」であること

サブネット: VPC > サブネット を開き、以下の4つのサブネットが作成されていることを確認します。

  • test-noteapp-public-1(ap-northeast-1a、パブリックIPの自動割り当て: はい)
  • test-noteapp-public-2(ap-northeast-1c、パブリックIPの自動割り当て: はい)
  • test-noteapp-private-1(ap-northeast-1a)
  • test-noteapp-private-2(ap-northeast-1c)

NATゲートウェイ: VPC > NATゲートウェイ を開き、「test-noteapp-nat-gw」が作成されていることを確認します。

  • 状態 が「Available」であること
  • サブネット がパブリックサブネット(test-noteapp-public-1)であること

セキュリティグループ: VPC > セキュリティグループ を開き、以下の3つのセキュリティグループが作成されていることを確認します。

  • test-noteapp-alb-sg(インバウンド: ポート443、ポート80)
  • test-noteapp-api-sg(インバウンド: ポート8000、ソースがALBのSG)
  • test-noteapp-rds-sg(インバウンド: ポート3306、ソースがAPIのSG)

RDS: RDSのダッシュボードを開き、「test-noteapp-db」という名前のRDSインスタンスが作成されていることを確認します。

  • エンジン が「MySQL 8.0」であること
  • インスタンスクラス が「db.t3.micro」であること

ECR: ECRのダッシュボードを開き、test-noteapp-apiリポジトリが作成されていることを確認します。

CloudWatch Logs: CloudWatch > ロググループ を開き、/ecs/test-noteapp-apiが作成されていることを確認します。

これらすべてのリソースがAWSコンソールで確認できれば、baseモジュールの作成は完了です。

9. コンテナイメージのPush

baseモジュールでECRリポジトリが作成されたので、APIアプリケーションのコンテナイメージをビルドしてPushします。

9.1 ECRへのログイン

まず、ECRにログインします。<アカウントID>の部分はご自身のAWSアカウントIDに置き換えてください。

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com

以下のような実行結果が表示されます。

Login Succeeded

9.2 イメージのビルドとPush

app/api/api/ディレクトリ(Dockerfileがある場所)に移動し、イメージをビルドしてPushします。前の手順でenvironments/test/ディレクトリにいるため、まずプロジェクトルートに戻ります。

cd ../..

Dockerfileがあるディレクトリに移動します。

cd app/api/api

イメージをビルドします。<アカウントID>の部分はご自身のAWSアカウントIDに置き換えてください。--platform linux/amd64を指定することで、Fargateで動作するx86_64アーキテクチャのイメージをビルドします。

docker build --platform linux/amd64 -t <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/test-noteapp-api:latest .

以下のような実行結果が表示されます。

Successfully built xxxxxxxxxx
Successfully tagged <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/test-noteapp-api:latest

イメージをPushします。

docker push <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/test-noteapp-api:latest

以下のような実行結果が表示されます。

The push refers to repository [<アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/test-noteapp-api]
latest: digest: sha256:xxxxxxxx size: xxxx

ECRのコンソールで、リポジトリにイメージがPushされていることを確認してください。test-noteapp-apiリポジトリを開き、latestタグのイメージが表示されていれば完了です。

💡 ポイント
本ハンズオンの初回は、このあと作成するapiモジュールの terraform apply 時にECSタスクが初めて起動してこのイメージを取得するため、特別な操作は不要です。

一方、APIのコードを修正してECRに再Pushした場合、タスク定義のイメージURL(latestタグ)自体は変わらないため、ECSは新しいイメージに自動で切り替わりません。この場合は以下のコマンドで ECSサービスを強制的に新しいデプロイに切り替える 必要があります。

aws ecs update-service --cluster test-noteapp-cluster --service test-noteapp-api --force-new-deployment --region ap-northeast-1

ポートフォリオでCI/CDを組むときは、この --force-new-deployment をパイプラインの最後に入れるのが定番パターンです。

10. apiモジュールの作成

baseモジュールでネットワークやデータベースなどの基盤が整い、コンテナイメージもECRにPushできたので、次にECS(Fargate)でAPIサーバを構築するモジュールを作成します。

このモジュールで作成されるリソースは以下のとおりです。

種類 リソース名 用途
ACM 証明書 api.example.com ALBのHTTPS通信用SSL証明書
ALB test-noteapp-api-alb APIへのトラフィックを分散
Route53 Aレコード api.example.com → ALB APIのカスタムドメインをALBに紐付け
ALB ターゲットグループ test-noteapp-api-tg ECSタスクへのトラフィック振り分け
ALB リスナー(HTTPS) ポート443 HTTPSリクエストをターゲットグループに転送
ALB リスナー(HTTP) ポート80 HTTPリクエストをHTTPSにリダイレクト
ECS クラスタ test-noteapp-cluster ECSサービスを配置する論理グループ
ECS タスク定義 test-noteapp-api コンテナの設定(イメージ、CPU、メモリ、環境変数)
ECS サービス test-noteapp-api Fargateタスクの実行・管理
IAM ロール test-noteapp-ecs-task-execution-role ECRからのイメージ取得とログ書き込み用

10.1 コードの記載

ここでは、FastAPIコンテナを**ECS(Fargate)**で実行し、ALBACM証明書Route53 AレコードALBターゲットグループALBリスナーでトラフィックを分散する構成を作成します。ALB本体やACM証明書もこのモジュールで作成し、baseモジュールからはネットワークやセキュリティグループの情報を受け取ります。modules/api/ディレクトリの各ファイルにコードを記述していきます。

variables.tf

modules/api/variables.tfに以下の内容を記述します。

variable "project_name" {
  description = "プロジェクト名"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID"
  type        = string
}

variable "public_subnet_ids" {
  description = "パブリックサブネットIDのリスト"
  type        = list(string)
}

variable "private_subnet_ids" {
  description = "プライベートサブネットIDのリスト"
  type        = list(string)
}

variable "api_security_group_id" {
  description = "API用セキュリティグループID"
  type        = string
}

variable "alb_security_group_id" {
  description = "ALB用セキュリティグループID"
  type        = string
}

variable "ecr_repository_url" {
  description = "ECRリポジトリURL"
  type        = string
}

variable "container_image_tag" {
  description = "コンテナイメージのタグ"
  type        = string
  default     = "latest"
}

variable "db_host" {
  description = "データベースホスト"
  type        = string
}

variable "db_name" {
  description = "データベース名"
  type        = string
}

variable "db_username" {
  description = "データベースユーザ名"
  type        = string
}

variable "db_password" {
  description = "データベースパスワード"
  type        = string
  sensitive   = true
}

variable "log_group_name" {
  description = "CloudWatch Logsグループ名"
  type        = string
}

variable "domain_name" {
  description = "カスタムドメイン名"
  type        = string
}

variable "route53_zone_id" {
  description = "Route53ホストゾーンID"
  type        = string
}

コードを解説します。

project_name / vpc_id / サブネット関連

variable "project_name" {
  type        = string
}

variable "vpc_id" {
  type        = string
}

variable "public_subnet_ids" {
  type        = list(string)
}

variable "private_subnet_ids" {
  type        = list(string)
}

baseモジュールから受け取るネットワーク情報です。project_nameはリソース名のプレフィックスとして使用します。vpc_idはALBターゲットグループの作成先VPCを指定するために使用します。public_subnet_idsはALBの配置先、private_subnet_idsはFargateタスクの配置先として使用します。

セキュリティグループ関連(api_security_group_id / alb_security_group_id)

variable "api_security_group_id" {
  type        = string
}

variable "alb_security_group_id" {
  type        = string
}

baseモジュールから受け取るセキュリティグループです。api_security_group_idはFargateタスクに適用するセキュリティグループ、alb_security_group_idはALBに適用するセキュリティグループです。

コンテナ関連(ecr_repository_url / container_image_tag)

variable "ecr_repository_url" {
  type        = string
}

variable "container_image_tag" {
  type        = string
  default     = "latest"
}

ECRリポジトリのURLとイメージタグです。ecr_repository_urlはbaseモジュールから受け取り、container_image_tagはデフォルトでlatestを使用します。タスク定義で${ecr_repository_url}:${container_image_tag}の形式でイメージを指定します。

データベース関連(db_host / db_name / db_username / db_password)

variable "db_host" {
  type        = string
}

variable "db_name" {
  type        = string
}

variable "db_username" {
  type        = string
}

variable "db_password" {
  type        = string
  sensitive   = true
}

ECSタスクの環境変数としてコンテナに渡すデータベース接続情報です。db_hostはbaseモジュールのRDSエンドポイントを受け取ります。

sensitiveは、変数の値を出力に表示するかどうかを制御する設定です。db_passwordsensitive = trueを設定することで、terraform planterraform applyの出力にパスワードが表示されることを防ぎます。

domain_name / route53_zone_id

variable "domain_name" {
  type        = string
}

variable "route53_zone_id" {
  type        = string
}

domain_nameはACM証明書の発行(api.<ドメイン名>)とRoute53 Aレコードの作成に使用します。route53_zone_idはbaseモジュールから受け取り、DNS検証レコードとALB向けのAレコードを作成するRoute53ホストゾーンを指定します。

log_group_name

variable "log_group_name" {
  type        = string
}

ECSタスクのログ出力先となるCloudWatch Logsグループ名です。baseモジュールで作成したロググループを受け取り、タスク定義のlogConfigurationで指定します。

main.tf

modules/api/main.tfに以下の内容を記述します。

# ACM証明書(ALB用、東京リージョン)
resource "aws_acm_certificate" "api" {
  domain_name       = "api.${var.domain_name}"
  validation_method = "DNS"

  tags = {
    Name = "${var.project_name}-api-cert"
  }

  lifecycle {
    create_before_destroy = true
  }
}

# ACM証明書のDNS検証レコード
resource "aws_route53_record" "api_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = var.route53_zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

# ACM証明書の検証完了を待機
resource "aws_acm_certificate_validation" "api" {
  certificate_arn         = aws_acm_certificate.api.arn
  validation_record_fqdns = [for record in aws_route53_record.api_cert_validation : record.fqdn]
}

# ALB
resource "aws_lb" "api" {
  name               = "${var.project_name}-api-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [var.alb_security_group_id]
  subnets            = var.public_subnet_ids

  tags = {
    Name = "${var.project_name}-api-alb"
  }
}

# Route53 Aレコード(api.example.com → ALB)
resource "aws_route53_record" "api" {
  zone_id = var.route53_zone_id
  name    = "api.${var.domain_name}"
  type    = "A"

  alias {
    name                   = aws_lb.api.dns_name
    zone_id                = aws_lb.api.zone_id
    evaluate_target_health = true
  }
}

# ALBターゲットグループ
resource "aws_lb_target_group" "api" {
  name        = "${var.project_name}-api-tg"
  port        = 8000
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"  # Fargate の場合は ip を指定

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    matcher             = "200"
  }

  tags = {
    Name = "${var.project_name}-api-tg"
  }
}

# ALBリスナー(HTTPS)
resource "aws_lb_listener" "api_https" {
  load_balancer_arn = aws_lb.api.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.api.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
}

# ALBリスナー(HTTP → HTTPSリダイレクト)
resource "aws_lb_listener" "api_http_redirect" {
  load_balancer_arn = aws_lb.api.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# ECSタスク実行ロール(ECRからのイメージ取得、CloudWatch Logsへの書き込みに必要)
resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.project_name}-ecs-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# ECSクラスタ
resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"

  tags = {
    Name = "${var.project_name}-cluster"
  }
}

# ECSタスク定義
resource "aws_ecs_task_definition" "api" {
  family                   = "${var.project_name}-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"   # 0.25 vCPU
  memory                   = "512"   # 512 MB
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn

  container_definitions = jsonencode([{
    name  = "api"
    image = "${var.ecr_repository_url}:${var.container_image_tag}"
    portMappings = [{
      containerPort = 8000
      protocol      = "tcp"
    }]
    environment = [
      { name = "DB_HOST", value = var.db_host },
      { name = "DB_NAME", value = var.db_name },
      { name = "DB_USER", value = var.db_username },
      { name = "DB_PASSWORD", value = var.db_password },
    ]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = var.log_group_name
        "awslogs-region"        = "ap-northeast-1"
        "awslogs-stream-prefix" = "api"
      }
    }
  }])

  tags = {
    Name = "${var.project_name}-api-task"
  }
}

# ECSサービス
resource "aws_ecs_service" "api" {
  name            = "${var.project_name}-api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = [var.api_security_group_id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = 8000
  }

  tags = {
    Name = "${var.project_name}-api-service"
  }
}

コードを解説します。

ACM証明書とDNS検証

resource "aws_acm_certificate" "api" {
  domain_name       = "api.${var.domain_name}"
  validation_method = "DNS"

ALBでHTTPS通信を行うためのSSL証明書を作成します。domain_nameapi.example.comを指定し、validation_methodDNSにすることで、Route53にDNS検証レコードを自動作成して証明書の所有権を検証します。

resource "aws_route53_record" "api_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => { ... }
  }

ACM証明書のDNS検証に必要なCNAMEレコードをRoute53に作成します。for_eachで検証オプションをループし、証明書が要求するレコードを自動的に作成します。

resource "aws_acm_certificate_validation" "api" {
  certificate_arn         = aws_acm_certificate.api.arn
  validation_record_fqdns = [for record in aws_route53_record.api_cert_validation : record.fqdn]
}

DNS検証レコードが作成された後、ACMが証明書の検証を完了するまで待機します。このリソースが完了すると、証明書がALBで使用可能になります。

ALB

resource "aws_lb" "api" {
  name               = "${var.project_name}-api-alb"
  internal           = false
  load_balancer_type = "application"

インターネット向け(internal = false)のApplication Load Balancerを作成します。applicationタイプを指定することで、HTTP/HTTPSトラフィックをレイヤ7で処理できます。

Route53 Aレコード(ALB用)

resource "aws_route53_record" "api" {
  zone_id = var.route53_zone_id
  name    = "api.${var.domain_name}"
  type    = "A"

  alias {
    name                   = aws_lb.api.dns_name
    zone_id                = aws_lb.api.zone_id

api.example.comをALBに向けるエイリアスレコードを作成します。エイリアスレコードはCNAMEと異なりゾーンの頂点(Zone Apex)にも設定でき、ALBのDNS名に直接解決されます。

ALBターゲットグループ

resource "aws_lb_target_group" "api" {
  name        = "${var.project_name}-api-tg"
  port        = 8000
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    matcher             = "200"
  }

ALBからトラフィックを転送するターゲットグループを作成します。

target_typeは、ターゲットの種類を指定する設定です。ipinstancelambdaの3つから選択でき、Fargateではタスクごとに動的にIPアドレスが割り当てられるためipを指定します。

health_check.pathは、ヘルスチェックのリクエスト先パスを指定する設定です。/healthを指定することで、ALBが30秒間隔でこのエンドポイントにリクエストを送り、ステータスコード200が返ればhealthyと判定します。

ALBリスナー(HTTPS / HTTPリダイレクト)

resource "aws_lb_listener" "api_https" {
  load_balancer_arn = aws_lb.api.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.api.certificate_arn

HTTPSリスナーでは、ポート443でHTTPSトラフィックを受け付け、ターゲットグループにフォワードします。

ssl_policyは、TLS通信で使用する暗号化ポリシーを指定する設定です。ELBSecurityPolicy-TLS13-1-2-2021-06を指定することで、TLS 1.3と1.2をサポートするセキュアなポリシーが適用されます。

certificate_arnは、HTTPS通信に使用するSSL証明書を指定する設定です。同モジュール内で作成・検証済みのACM証明書のARNを設定しています。

resource "aws_lb_listener" "api_http_redirect" {
  load_balancer_arn = aws_lb.api.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }

HTTPリスナーでは、ポート80へのHTTPアクセスをHTTPS(443)に301リダイレクトします。これにより、すべての通信がHTTPSで暗号化されます。

ECSタスク実行ロール

resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.project_name}-ecs-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

ECSタスクがECRからコンテナイメージを取得し、CloudWatch Logsにログを書き込むために必要なIAMロールです。AmazonECSTaskExecutionRolePolicyマネージドポリシーをアタッチすることで、これらの権限が付与されます。

ECSクラスタ

resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"

ECSサービスを配置するための論理的なグループです。クラスタ自体にはコストは発生せず、その中で実行されるタスク(Fargate)に対して課金されます。

ECSタスク定義

resource "aws_ecs_task_definition" "api" {
  family                   = "${var.project_name}-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn

  container_definitions = jsonencode([{
    name  = "api"
    image = "${var.ecr_repository_url}:${var.container_image_tag}"
    environment = [
      { name = "DB_HOST", value = var.db_host },
      { name = "DB_NAME", value = var.db_name },
      { name = "DB_USER", value = var.db_username },
      { name = "DB_PASSWORD", value = var.db_password },
    ]

Fargateで実行するタスクの設定です。

requires_compatibilitiesは、タスクの起動タイプを指定する設定です。["FARGATE"]を指定することで、サーバレスのFargate起動タイプでタスクを実行します。

network_modeは、タスクのネットワークモードを指定する設定です。Fargateではawsvpcが必須で、各タスクに独自のENI(ネットワークインターフェース)が割り当てられます。

cpumemoryは、タスクに割り当てるCPUとメモリのリソース量を指定する設定です。cpu = "256"(0.25 vCPU)、memory = "512"(512 MB)の検証用の最小構成にしています。

container_definitionsでコンテナイメージとポートマッピング、環境変数(データベース接続情報)、ログ設定を定義しています。

ECSサービス

resource "aws_ecs_service" "api" {
  name            = "${var.project_name}-api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = [var.api_security_group_id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = 8000
  }

ECSクラスタ上でタスクを実行・管理するサービスです。

desired_countは、同時に起動するタスクの数を指定する設定です。1にすることで、検証用の最小構成にしています。本番環境では可用性を高めるために2以上に設定します。

launch_typeは、タスクの起動タイプを指定する設定です。FARGATEにすることで、EC2インスタンスの管理が不要になります。

assign_public_ipは、タスクにパブリックIPアドレスを割り当てるかどうかを制御する設定です。falseにすることで、プライベートサブネットに配置し、NATゲートウェイ経由でインターネットにアクセスします。

load_balancerブロックでALBターゲットグループと連携し、コンテナのポート8000にトラフィックを転送します。

📝 Fargateのネットワーク構成について
Fargateタスクはプライベートサブネットに配置しています。プライベートサブネットからECRへのイメージ取得やCloudWatch Logsへのログ送信にはインターネットアクセスが必要なため、baseモジュールでNATゲートウェイを作成し、プライベートサブネットのルートテーブルでNATゲートウェイを経由するよう設定しています。

outputs.tf

modules/api/outputs.tfに以下の内容を記述します。

output "cluster_name" {
  description = "ECSクラスタ名"
  value       = aws_ecs_cluster.main.name
}

output "service_name" {
  description = "ECSサービス名"
  value       = aws_ecs_service.api.name
}

output "alb_dns_name" {
  description = "ALBのDNS名"
  value       = aws_lb.api.dns_name
}

次に、environments/test/main.tfにapiモジュールの呼び出しを追加します。baseモジュールの呼び出しの下に以下を追記してください。

# APIモジュール(ECS/Fargate + ALB + ACM + Route53)
module "api" {
  source = "../../modules/api"

  project_name          = local.project_name
  vpc_id                = module.base.vpc_id
  public_subnet_ids     = module.base.public_subnet_ids
  private_subnet_ids    = module.base.private_subnet_ids
  api_security_group_id = module.base.api_security_group_id
  alb_security_group_id = module.base.alb_security_group_id
  ecr_repository_url    = module.base.ecr_repository_url
  db_host               = module.base.rds_endpoint
  db_name               = local.db_name
  db_username           = local.db_username
  db_password           = var.db_password
  log_group_name        = module.base.cloudwatch_log_group_name
  domain_name           = var.domain_name
  route53_zone_id       = module.base.route53_zone_id
}

10.2 Terraformの再初期化

apiモジュールを追加したため、Terraformを再初期化します。コンテナイメージのPushでapp/api/api/ディレクトリに移動しているため、まずenvironments/test/ディレクトリに戻ります。

cd ../../../environments/test

Terraformを再初期化します。

terraform init

10.3 差分の確認

terraform planで差分を確認します。

terraform plan

以下のような実行結果が表示されます。

Terraform will perform the following actions:

  # module.api.aws_acm_certificate.api will be created
  # module.api.aws_acm_certificate_validation.api will be created
  # module.api.aws_route53_record.api will be created
  # module.api.aws_route53_record.api_cert_validation["api.example.com"] will be created
  # module.api.aws_lb.api will be created
  # module.api.aws_ecs_cluster.main will be created
  # module.api.aws_ecs_service.api will be created
  # module.api.aws_ecs_task_definition.api will be created
  # module.api.aws_iam_role.ecs_task_execution will be created
  # module.api.aws_iam_role_policy_attachment.ecs_task_execution will be created
  # module.api.aws_lb_listener.api_https will be created
  # module.api.aws_lb_listener.api_http_redirect will be created
  # module.api.aws_lb_target_group.api will be created

Plan: 13 to add, 0 to change, 0 to destroy.

ACM証明書、DNS検証レコード、証明書検証、ALB、Route53 Aレコード(ALB用)、ECSクラスタ、ECSサービス、タスク定義、タスク実行ロール、ターゲットグループ、HTTPSリスナー、HTTPリダイレクトリスナーの合計13リソースが作成されることを確認してください。

10.4 AWSへの反映

問題なければterraform applyで反映します。

terraform apply

確認プロンプトが表示されたら、yesと入力して実行します。以下のように、13件のリソースが作成されたことを確認します。

Apply complete! Resources: 13 added, 0 changed, 0 destroyed.
💡 ポイント
ACM証明書のDNS検証には数分かかる場合があります。terraform applyが完了するまでしばらくお待ちください。

10.5 AWSコンソールでの確認

AWSマネジメントコンソールで以下の内容を確認してください。

ACM(Certificate Manager): ACMのダッシュボードを開き、api.<ドメイン名>の証明書が作成されていることを確認します。

  • ステータス が「発行済み」であること

Route53: Route53のダッシュボードでホストゾーンを開き、以下のレコードが追加されていることを確認します。

  • api.<ドメイン名>Aレコード(ALBへのエイリアス)
  • ACM証明書のDNS検証用のCNAMEレコード

EC2 > ロードバランサー: 「test-noteapp-api-alb」が作成されていることを確認します。

  • タイプ が「application」であること
  • スキーム が「internet-facing」であること

EC2 > ロードバランサー > リスナー: ALBのリスナータブを開き、以下の2つのリスナーが設定されていることを確認します。

  • HTTPS:443 — ターゲットグループ「test-noteapp-api-tg」に転送
  • HTTP:80 — HTTPS(443)にリダイレクト

ECS > クラスタ: 「test-noteapp-cluster」が作成されていることを確認します。

ECS > クラスタ > test-noteapp-cluster > サービス: 「test-noteapp-api」サービスが実行中であることを確認します。

ECS > クラスタ > test-noteapp-cluster > タスク: Fargateタスクが1つ「実行中」であることを確認します。

EC2 > ターゲットグループ: 「test-noteapp-api-tg」が作成されていることを確認します。

  • ターゲットタイプ が「ip」であること
  • ターゲットのヘルスステータスが「healthy」であること

💡 ポイント
ECSサービスがタスクを起動し、ターゲットグループのヘルスチェックがhealthyになるまでに数分かかる場合があります。ステータスが「PROVISIONING」や「unhealthy」の場合は、しばらく待ってから再度確認してください。

これらすべてのリソースがAWSコンソールで確認できれば、apiモジュールのデプロイは完了です。

10.6 APIの動作確認

フロントエンドの構築に進む前に、APIが正しく動作しているか確認しておきましょう。カスタムドメインを設定したので、api.<ドメイン名>でアクセスできます。

ヘルスチェックエンドポイントにアクセスして、APIが起動していることを確認します。<ドメイン名>の部分はご自身のドメイン名に置き換えてください。

curl https://api.<ドメイン名>/health

以下のようなレスポンスが返れば、APIは正常に動作しています。

{"status":"healthy"}

次に、ノートを作成してデータベースとの接続も確認します。

curl -X POST https://api.<ドメイン名>/notes -H "Content-Type: application/json" -d '{"title":"Test Note","content":"Hello from Terraform!"}'

以下のようなレスポンスが返れば、ECSからRDSへの接続も正常に動作しています。

{"id":1,"title":"Test Note","content":"Hello from Terraform!","created_at":"...","updated_at":"..."}

ノートの一覧を取得して、登録したデータが正しく保存されていることを確認します。

curl https://api.<ドメイン名>/notes

以下のように、先ほど登録したノートが返されれば成功です。

[{"id":1,"title":"Test Note","content":"Hello from Terraform!","created_at":"...","updated_at":"..."}]
⚠️ エラーが返る場合
ECSタスクの起動直後は、コンテナがまだ起動中の可能性があります。数分待ってから再度試してください。ALBのターゲットグループで、ターゲットのヘルスチェックが「healthy」になっていることをAWSコンソールで確認することもできます。

APIの動作確認ができたら、次のセクションでフロントエンドの構築に進みます。

11. frontendモジュールの作成

apiモジュールでAPIサーバの構築が完了したので、次にフロントエンドの配信基盤を構築します。S3バケットとCloudFrontディストリビューションを管理するモジュールを作成します。

このモジュールで作成されるリソースは以下のとおりです。

種類 リソース名 用途
ACM 証明書 example.com(us-east-1) CloudFrontのHTTPS通信用SSL証明書
S3 バケット test-noteapp-frontend-{アカウントID} フロントエンドの静的ファイルを格納
CloudFront ディストリビューション example.com 静的ファイルをCDNでグローバル配信
CloudFront OAC test-noteapp-oac S3へのアクセスをCloudFront経由に限定
Route53 Aレコード example.com → CloudFront ルートドメインをCloudFrontに紐付け
S3 オブジェクト index.html, style.css, script.js フロントエンドの静的ファイル

11.1 コードの記載

ここでは、フロントエンドの静的ファイルを配信するためのS3バケットと、CDNとしてグローバルにキャッシュ配信するCloudFrontディストリビューションを作成します。Terraformでは、resource "aws_s3_bucket"でS3バケットを、resource "aws_cloudfront_distribution"でCloudFrontを定義します。modules/frontend/ディレクトリの各ファイルにコードを記述していきます。

variables.tf

modules/frontend/variables.tfに以下の内容を記述します。

variable "project_name" {
  description = "プロジェクト名"
  type        = string
}

variable "front_source_dir" {
  description = "フロントエンドソースコードのディレクトリパス"
  type        = string
}

variable "domain_name" {
  description = "カスタムドメイン名"
  type        = string
}

variable "route53_zone_id" {
  description = "Route53ホストゾーンID"
  type        = string
}

コードを解説します。

project_name

variable "project_name" {
  type        = string
}

リソース名のプレフィックスとして使用します。S3バケット名やCloudFrontディストリビューションのタグに${var.project_name}-frontendの形式で付与されます。

front_source_dir

variable "front_source_dir" {
  type        = string
}

フロントエンドのソースコード(HTML/CSS/JS)があるディレクトリパスです。file関数やfilemd5関数でファイルを読み込み、S3にアップロードする際に使用します。

domain_name / route53_zone_id

variable "domain_name" {
  type        = string
}

variable "route53_zone_id" {
  type        = string
}

domain_nameはCloudFrontのカスタムドメイン(aliases)とACM証明書の発行に使用します。route53_zone_idはbaseモジュールから受け取り、ルートドメインをCloudFrontに向けるAレコードの作成に使用します。

main.tf

modules/frontend/main.tfに以下の内容を記述します。

terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      configuration_aliases = [aws.us_east_1]
    }
  }
}

# CloudFront用ACM証明書(us-east-1で作成する必要がある)
resource "aws_acm_certificate" "frontend" {
  provider          = aws.us_east_1
  domain_name       = var.domain_name
  validation_method = "DNS"

  tags = {
    Name = "${var.project_name}-frontend-cert"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "frontend_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.frontend.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = var.route53_zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "frontend" {
  provider                = aws.us_east_1
  certificate_arn         = aws_acm_certificate.frontend.arn
  validation_record_fqdns = [for record in aws_route53_record.frontend_cert_validation : record.fqdn]
}

data "aws_caller_identity" "current" {}

# フロントエンド用S3バケット
resource "aws_s3_bucket" "frontend" {
  bucket = "${var.project_name}-frontend-${data.aws_caller_identity.current.account_id}"

  tags = {
    Name = "${var.project_name}-frontend"
  }
}

# パブリックアクセスのブロック
resource "aws_s3_bucket_public_access_block" "frontend" {
  bucket = aws_s3_bucket.frontend.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Origin Access Control
resource "aws_cloudfront_origin_access_control" "frontend" {
  name                              = "${var.project_name}-frontend-oac"
  description                       = "OAC for frontend S3 bucket"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# CloudFrontディストリビューション
resource "aws_cloudfront_distribution" "frontend" {
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  price_class         = "PriceClass_200"
  aliases             = [var.domain_name]

  origin {
    domain_name              = aws_s3_bucket.frontend.bucket_regional_domain_name
    origin_id                = "S3-${aws_s3_bucket.frontend.id}"
    origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${aws_s3_bucket.frontend.id}"
    viewer_protocol_policy = "redirect-to-https"

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }

    min_ttl     = 0
    default_ttl = 3600
    max_ttl     = 86400
  }

  # 存在しないパスへのアクセス時にindex.htmlを返す
  custom_error_response {
    error_code         = 403
    response_code      = 200
    response_page_path = "/index.html"
  }

  custom_error_response {
    error_code         = 404
    response_code      = 200
    response_page_path = "/index.html"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.frontend.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

  tags = {
    Name = "${var.project_name}-frontend"
  }
}

# Route53レコード(example.com → CloudFront)
resource "aws_route53_record" "frontend" {
  zone_id = var.route53_zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.frontend.domain_name
    zone_id                = aws_cloudfront_distribution.frontend.hosted_zone_id
    evaluate_target_health = false
  }
}

# CloudFrontからのアクセス用バケットポリシー
resource "aws_s3_bucket_policy" "frontend" {
  bucket = aws_s3_bucket.frontend.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudFrontServicePrincipal"
        Effect = "Allow"
        Principal = {
          Service = "cloudfront.amazonaws.com"
        }
        Action   = "s3:GetObject"
        Resource = "${aws_s3_bucket.frontend.arn}/*"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
          }
        }
      }
    ]
  })
}

# index.htmlをS3にアップロード
resource "aws_s3_object" "index_html" {
  bucket       = aws_s3_bucket.frontend.id
  key          = "index.html"
  source       = "${var.front_source_dir}/index.html"
  content_type = "text/html"
  etag         = filemd5("${var.front_source_dir}/index.html")
}

# style.cssをS3にアップロード
resource "aws_s3_object" "style_css" {
  bucket       = aws_s3_bucket.frontend.id
  key          = "style.css"
  source       = "${var.front_source_dir}/style.css"
  content_type = "text/css"
  etag         = filemd5("${var.front_source_dir}/style.css")
}

# script.jsをS3にアップロード(API_URLをカスタムドメインに差し替え)
resource "aws_s3_object" "script_js" {
  bucket       = aws_s3_bucket.frontend.id
  key          = "script.js"
  content      = replace(
    file("${var.front_source_dir}/script.js"),
    "http://localhost:8000",
    "https://api.${var.domain_name}"
  )
  content_type = "application/javascript"
}

コードを解説します。

ACM証明書(us-east-1)

resource "aws_acm_certificate" "frontend" {
  provider          = aws.us_east_1
  domain_name       = var.domain_name
  validation_method = "DNS"

CloudFront用のSSL証明書を作成します。CloudFrontで使用するACM証明書はus-east-1(バージニア北部)リージョンで作成する必要があるため、provider = aws.us_east_1を指定しています。baseモジュールのALB用証明書(東京リージョン)とは別に作成します。

S3バケットとパブリックアクセスブロック

resource "aws_s3_bucket" "frontend" {
  bucket = "${var.project_name}-frontend-${data.aws_caller_identity.current.account_id}"

フロントエンドの静的ファイル(HTML/CSS/JS)を保存するS3バケットです。バケット名にアカウントIDを含めてグローバルで一意にしています。

resource "aws_s3_bucket_public_access_block" "frontend" {
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

S3バケットへの直接アクセスを完全にブロックします。すべての設定をtrueにすることで、パブリックACLやパブリックバケットポリシーが適用されないようにしています。ユーザはCloudFront経由でのみファイルにアクセスできます。

CloudFrontディストリビューション

resource "aws_cloudfront_distribution" "frontend" {
  enabled             = true
  default_root_object = "index.html"
  price_class         = "PriceClass_200"
  aliases             = [var.domain_name]

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.frontend.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

S3バケットのコンテンツをCDNで配信するCloudFrontディストリビューションです。

default_root_objectは、ルートURL(例: https://example.com/)にアクセスした際に返すファイルを指定する設定です。index.htmlを指定することで、ルートアクセス時にトップページが表示されます。

aliasesは、CloudFrontに関連付けるカスタムドメインを指定する設定です。ここではvar.domain_name(例: example.com)を指定しています。

viewer_protocol_policyは、ユーザがHTTPでアクセスした際の動作を制御する設定です。redirect-to-httpsにすることで、HTTPアクセスが自動的にHTTPSにリダイレクトされます。

viewer_certificateでus-east-1のACM証明書を設定してHTTPS通信を有効にし、price_classPriceClass_200にすることでアジア・北米・欧州のエッジロケーションを使用します。

OAC(Origin Access Control)

resource "aws_cloudfront_origin_access_control" "frontend" {
  name                              = "${var.project_name}-frontend-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"

S3へのアクセスをCloudFront経由に限定するためのOAC(Origin Access Control)です。

signing_protocolは、CloudFrontがS3にリクエストする際の署名プロトコルを指定する設定です。sigv4を指定することで、AWS Signature Version 4による署名が使用されます。

signing_behaviorは、CloudFrontがS3へのリクエストに署名を付与するタイミングを制御する設定です。alwaysにすることで、すべてのリクエストに署名が付与され、S3バケットのパブリックアクセスをブロックしたままCloudFrontからのみコンテンツを取得できます。

S3バケットポリシー

resource "aws_s3_bucket_policy" "frontend" {
  policy = jsonencode({
    Statement = [{
      Principal = { Service = "cloudfront.amazonaws.com" }
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.frontend.arn}/*"
      Condition = {
        StringEquals = {
          "AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
        }
      }
    }]
  })

CloudFrontからのみS3オブジェクトのGetObjectを許可するバケットポリシーです。Conditionで特定のCloudFrontディストリビューションのARNを指定することで、他のCloudFrontディストリビューションや直接アクセスからの読み取りを防止しています。

S3オブジェクト(HTML/CSS/JS)

resource "aws_s3_object" "script_js" {
  bucket       = aws_s3_bucket.frontend.id
  key          = "script.js"
  content      = replace(
    file("${var.front_source_dir}/script.js"),
    "http://localhost:8000",
    "https://api.${var.domain_name}"
  )
  content_type = "application/javascript"
}

フロントエンドの静的ファイルをS3にアップロードします。

content_typeは、ファイルのMIMEタイプを指定する設定です。ブラウザがファイルを正しく解釈するために、text/htmltext/cssapplication/javascriptなどを指定します。

etagは、ファイルの変更を検知するためのハッシュ値を指定する設定です。filemd5関数でファイルのMD5ハッシュを計算し、ファイル内容が変わった場合のみS3にアップロードし直します。

script.jsではreplace関数を使用しています。replaceは文字列内の特定の部分を別の文字列に置き換える関数で、ここではhttp://localhost:8000https://api.<ドメイン名>に動的に差し替えています。これにより、ソースコードを手動で修正することなく、デプロイ先のAPI URLが自動的に設定されます。

Route53 Aレコード

resource "aws_route53_record" "frontend" {
  zone_id = var.route53_zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.frontend.domain_name
    zone_id                = aws_cloudfront_distribution.frontend.hosted_zone_id

ルートドメイン(例: example.com)をCloudFrontディストリビューションに向けるエイリアスレコードです。これにより、ユーザはhttps://example.comでフロントエンドにアクセスできるようになります。

outputs.tf

modules/frontend/outputs.tfに以下の内容を記述します。

output "cloudfront_domain_name" {
  description = "CloudFrontドメイン名"
  value       = aws_cloudfront_distribution.frontend.domain_name
}

output "cloudfront_distribution_id" {
  description = "CloudFrontディストリビューションID"
  value       = aws_cloudfront_distribution.frontend.id
}

次に、environments/test/main.tfを更新します。frontendモジュールではCloudFront用のACM証明書をus-east-1で作成する必要があるため、まずalias付きの2つ目のプロバイダを追加します。localsブロックの下に以下を追記してください。

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

CloudFront用のACM証明書はus-east-1(バージニア北部)リージョンで作成する必要があるため、alias付きのプロバイダを定義しています。このプロバイダは、frontendモジュール内でprovider = aws.us_east_1として使用されます。

📝 エイリアスプロバイダとは
Terraformでは通常、provider "aws" { region = "ap-northeast-1" } のように1つのリージョンに対して1つのプロバイダを定義します。しかし、複数のリージョンを同時に扱いたい場合(例: メインは東京リージョン、CloudFront用ACMはバージニア北部)は、alias を付けた2つ目のプロバイダを定義します。エイリアスプロバイダはデフォルトでは使われず、リソースやモジュールで明示的に provider = aws.us_east_1 のように指定したときだけ使われます。モジュールに渡す場合は、後ほど記述する module "frontend" { providers = { aws.us_east_1 = aws.us_east_1 } } のように providers 引数で受け渡す必要があります。マルチリージョン構成では定番のパターンなので、覚えておくと今後の応用でも役立ちます。

続けて、frontendモジュールの呼び出しを追加します。追加したプロバイダ設定の下に以下を追記してください。

# フロントエンドモジュール(S3 + CloudFront)
module "frontend" {
  source = "../../modules/frontend"

  providers = {
    aws           = aws
    aws.us_east_1 = aws.us_east_1
  }

  project_name     = local.project_name
  front_source_dir = "${path.module}/../../app/api/front"
  domain_name      = var.domain_name
  route53_zone_id  = module.base.route53_zone_id
}
📝 path.module とは
path.module はTerraformの組み込み変数で、今コードが書かれているモジュールのディレクトリ(ファイルシステム上のパス) を返します。ここでは environments/test/main.tf に書かれているので、path.moduleenvironments/test/ を指します。${path.module}/../../app/api/front と書くことで、environments/test/ を起点に「2つ上のディレクトリ配下の app/api/front」という意味になり、プロジェクトルートの app/api/front/ ディレクトリを指します。カレントディレクトリに依存しない安全なパス指定方法なので、ファイルや資産を参照する場面では path.module を使うのが定番です。

11.2 Terraformの再初期化

frontendモジュールを追加したため、Terraformを再初期化します。environments/test/ディレクトリにいることを確認してから実行してください。

terraform init

11.3 差分の確認

terraform planで差分を確認します。

terraform plan

以下のような実行結果が表示されます。

Terraform will perform the following actions:

  # module.frontend.aws_acm_certificate.frontend will be created
  # module.frontend.aws_acm_certificate_validation.frontend will be created
  # module.frontend.aws_route53_record.frontend will be created
  # module.frontend.aws_route53_record.frontend_cert_validation["example.com"] will be created
  # module.frontend.aws_cloudfront_distribution.frontend will be created
  # module.frontend.aws_cloudfront_origin_access_control.frontend will be created
  # module.frontend.aws_s3_bucket.frontend will be created
  # module.frontend.aws_s3_bucket_policy.frontend will be created
  # module.frontend.aws_s3_bucket_public_access_block.frontend will be created
  # module.frontend.aws_s3_object.index_html will be created
  # module.frontend.aws_s3_object.style_css will be created
  # module.frontend.aws_s3_object.script_js will be created

Plan: 12 to add, 0 to change, 0 to destroy.

ACM証明書(us-east-1)、DNS検証レコード、証明書検証、Route53 Aレコード(CloudFront用)、S3バケット、パブリックアクセスブロック、OAC(Origin Access Control)、CloudFrontディストリビューション、バケットポリシー、S3オブジェクト(3ファイル)の合計12リソースが作成されることを確認してください。

11.4 AWSへの反映

問題なければterraform applyで反映します。

terraform apply

確認プロンプトが表示されたら、yesと入力して実行します。以下のように、12件のリソースが作成されたことを確認します。

Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
💡 ポイント
CloudFrontディストリビューションの作成には数分かかる場合があります。terraform applyが完了するまでしばらくお待ちください。

11.5 AWSコンソールでの確認

AWSマネジメントコンソールで以下の内容を確認してください。

ACM(Certificate Manager): ACMのダッシュボードを開きます。CloudFront用のACM証明書はus-east-1で作成されるため、リージョンを**バージニア北部(us-east-1)**に切り替えてから確認してください。

  • <ドメイン名> の証明書が作成されていること
  • ステータス が「発行済み」であること

Route53: Route53のダッシュボードでホストゾーンを開き、以下のレコードが追加されていることを確認します。

  • <ドメイン名>Aレコード(CloudFrontへのエイリアス)
  • ACM証明書のDNS検証用のCNAMEレコード

S3: S3のダッシュボードを開き、test-noteapp-frontend-<アカウントID>バケットが作成されていることを確認します。

script.jsを開き「開く」をクリックします。

  • const API_URLのところがhttps://<ドメイン名>となっていること

CloudFront: CloudFrontのダッシュボードを開き、ディストリビューションが作成されていることを確認します。

  • ステータス が「有効」であること
  • オリジン がS3バケットであること
  • デフォルトルートオブジェクト が「index.html」であること

これらすべてのリソースがAWSコンソールで確認できれば、frontendモジュールのデプロイは完了です。

11.6 フロントエンドの動作確認

フロントエンドの静的ファイルは、terraform applyの実行時にS3に自動的にアップロードされています。script.js内のAPI_URLも、Terraformのreplace関数によりhttps://api.<ドメイン名>に自動で差し替えられています。

カスタムドメインを設定したので、ブラウザで https://<ドメイン名> にアクセスしてください。

「ノートアプリ」の画面が表示され、apiモジュールの動作確認で作成した「Test Note」が「ノート一覧」に表示されていれば成功です。

画面からノートの作成・編集・削除ができることも確認してください。

⚠️ 画面が表示されない場合
CloudFrontのデプロイには数分かかる場合があります。しばらく待ってからリトライしてください。
⚠️ ノート一覧が表示されない場合
ブラウザの開発者ツール(F12)でコンソールタブを開き、APIへの接続エラーが出ていないか確認してください。script.js内のAPI_URLhttps://api.<ドメイン名>に正しく差し替えられているかは、S3バケット内のscript.jsをダウンロードして確認できます。CloudFrontのキャッシュが古い場合は、以下のコマンドでキャッシュを無効化してください。ディストリビューションIDはAWSコンソールの「CloudFront > ディストリビューション」から確認できます。
aws cloudfront create-invalidation --distribution-id <CloudFrontのディストリビューションID> --paths "/*"

フロントエンドからAPIを経由してデータの操作ができることを確認できれば、すべてのモジュールの構築と動作確認は完了です。

12. 出力の定義

各モジュールの出力値をまとめて、environments/test/outputs.tfに定義します。すべてのモジュールの構築が完了したので、terraform applyの実行結果にURLやリソースIDを表示することで、デプロイ後の確認を効率化します。

12.1 コードの記載

ここでは、各モジュールの出力値をまとめてterraform applyの実行結果に表示するための出力(output)を定義します。Terraformでは、outputブロックを使い、module.<モジュール名>.<出力名>で各モジュールの値を参照します。

outputs.tf

environments/test/outputs.tfに以下の内容を記述します。

output "api_alb_dns_name" {
  description = "API ALBのDNS名"
  value       = module.api.alb_dns_name
}

output "ecs_cluster_name" {
  description = "ECSクラスタ名"
  value       = module.api.cluster_name
}

output "ecs_service_name" {
  description = "ECSサービス名"
  value       = module.api.service_name
}

output "frontend_url" {
  description = "フロントエンドURL"
  value       = "https://${var.domain_name}"
}

output "api_url" {
  description = "API URL"
  value       = "https://api.${var.domain_name}"
}

output "cloudfront_distribution_id" {
  description = "CloudFrontディストリビューションID"
  value       = module.frontend.cloudfront_distribution_id
}

コードを解説します。

api_alb_dns_name / ecs_cluster_name / ecs_service_name

output "api_alb_dns_name" {
  value       = module.api.alb_dns_name
}

output "ecs_cluster_name" {
  value       = module.api.cluster_name
}

output "ecs_service_name" {
  value       = module.api.service_name
}

apiモジュールから受け取った値を出力します。api_alb_dns_nameはALBのDNS名、ecs_cluster_nameecs_service_nameはCLIでECSサービスを操作する際(タスクの再起動やログの確認など)に必要な値です。

frontend_url / api_url

output "frontend_url" {
  value       = "https://${var.domain_name}"
}

output "api_url" {
  value       = "https://api.${var.domain_name}"
}

フロントエンドとAPIの完全なURLを出力します。terraform applyの実行後にこれらのURLが表示されるため、デプロイ後すぐにアクセス先を確認できます。

cloudfront_distribution_id

output "cloudfront_distribution_id" {
  value       = module.frontend.cloudfront_distribution_id
}

CloudFrontディストリビューションIDを出力します。フロントエンドの更新後にキャッシュを無効化するaws cloudfront create-invalidationコマンドで使用します。

12.2 差分の確認

terraform planで差分を確認します。

terraform plan

以下のような実行結果が表示されます。

Changes to Outputs:
  + api_alb_dns_name           = "test-noteapp-api-alb-xxxxxxxxx.ap-northeast-1.elb.amazonaws.com"
  + api_url                    = "https://api.example.com"
  + ecs_cluster_name           = "test-noteapp-cluster"
  + ecs_service_name           = "test-noteapp-api"
  + cloudfront_distribution_id = "EXXXXXXXXXXXXX"
  + frontend_url               = "https://example.com"

Plan: 0 to add, 0 to change, 0 to destroy.

リソースの変更はなく、出力値のみが追加されることを確認してください。

12.3 AWSへの反映

terraform applyで出力値を反映します。

terraform apply

確認プロンプトが表示されたら、yesと入力して実行します。以下のように、リソースの変更がないことを確認します。

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

13. 不要リソースの削除

ハンズオンが完了したら、課金を防ぐためにすべてのリソースを削除します。

13.1 リモートバックエンド用リソースの除外

📝 なぜこの順番で削除するのか
普通に terraform destroy を実行すると、tfstateの保存先であるS3バケットとDynamoDBテーブル自体を削除しようとするため、以下の2つの問題が発生します。
問題1: S3バケットにはバージョニングが有効になっており、tfstateファイルの履歴(古いバージョン)が残っているため、バケット削除が失敗する
問題2: 仮に削除できたとしても、destroy完了後にTerraformが「処理結果をtfstateに保存しよう」としたときに、保存先自体が消えているためエラーになる

これを避けるために、以下の順序で作業します。

1. terraform state rm でremoteモジュールの3リソースをTerraformの管理対象から外す
2. terraform destroy で残りのアプリケーションリソースを一括削除
3. AWSマネジメントコンソールから手動でS3バケットとDynamoDBテーブルを削除

この順序を守ることで、destroy実行中にtfstateの保存先が消える問題を回避できます。

environments/test/ディレクトリで以下のコマンドを実行し、remoteモジュールの3つのリソースをTerraformのstateから除外します。

S3バケットをstateから除外します。

terraform state rm module.remote.aws_s3_bucket.tfstate

以下のような実行結果が表示されます。

Removed module.remote.aws_s3_bucket.tfstate
Successfully removed 1 resource instance(s).

S3バケットのバージョニング設定をstateから除外します。

terraform state rm module.remote.aws_s3_bucket_versioning.tfstate

以下のような実行結果が表示されます。

Removed module.remote.aws_s3_bucket_versioning.tfstate
Successfully removed 1 resource instance(s).

DynamoDBテーブルをstateから除外します。

terraform state rm module.remote.aws_dynamodb_table.tfstate_lock

以下のような実行結果が表示されます。

Removed module.remote.aws_dynamodb_table.tfstate_lock
Successfully removed 1 resource instance(s).
📝 terraform state rmについて
terraform state rmは、Terraformのstateからリソースの管理情報を除外するコマンドです。AWS上のリソース自体は削除されず、Terraformの管理対象から外れるだけです。これにより、terraform destroyの対象外となり、後から手動で削除できます。

13.2 アプリケーションリソースの削除

stateからリモートバックエンド用リソースを除外したら、terraform destroyでアプリケーションのリソースを一括削除します。

terraform destroy

確認プロンプトが表示されたら、yesと入力して実行します。以下のように、リソースが削除されたことを確認します。

Destroy complete! Resources: 47 destroyed.
💡 ポイント
RDSの削除には数分かかる場合があります。terraform destroyが完了するまでしばらくお待ちください。
📝 ECRリポジトリの削除について
ECRリポジトリにイメージが残っている場合、通常はterraform destroyが失敗します。このハンズオンでは、ECRリポジトリのforce_delete = trueを設定しているため、イメージが残っていても自動で削除されます。

13.3 リモートバックエンド用リソースの手動削除

最後に、stateから除外したリモートバックエンド用のリソースを手動で削除します。

AWSマネジメントコンソールでS3のダッシュボードを開き、noteapp-tfstate-{アカウントID}バケットを選択します。バージョニングが有効なため、先に「バケットを空にする」を実行してします。

その後、バケットを削除してください。

次に、DynamoDBのダッシュボードを開き、noteapp-tfstate-lockテーブルを削除してください。

AWSコンソールで両方のリソースが削除されていることを確認できれば、すべてのリソースの削除は完了です。

14. まとめ

このハンズオンでは、Terraformのモジュールを活用してノートアプリケーションのインフラをAWSにデプロイする方法を体験しました。

  • modules/ディレクトリにbase、api、frontendの3つのモジュールを作成し、変更頻度と責務に応じた再利用可能な構成にした
  • environments/test/ディレクトリから各モジュールを呼び出し、環境ごとにインフラを管理する方法を学んだ
  • S3とDynamoDBによるリモートバックエンドを構成し、tfstateをチームで共有できる仕組みを作った
  • ${local.env}-noteappのプレフィックスにより、複数環境が共存できるリソース命名規則を採用した
  • ECS(Fargate)+ ALBによるコンテナベースのAPIサーバと、S3 + CloudFrontによるフロントエンド配信の構成を構築した
  • ECRにコンテナイメージをPushし、ECSタスク定義から参照してデプロイする流れを体験した
  • カスタムドメインとACM証明書を使って、ALB(api.<ドメイン名>)とCloudFront(<ドメイン名>)にHTTPS通信を設定した
  • ALBのHTTPリスナーでHTTPSリダイレクトを設定し、すべての通信をHTTPSで暗号化する構成にした

15. 次のステップ

これでInfra as Code(Terraform)講座は最後の講座まで完了です。学んだ内容を実際の成果物に落とし込みたい方は、章末尾の応用課題に挑戦してみてください。タスク管理APIを動かすAWSインフラを Terraform でコード化し、再現性のあるデプロイを実現する課題です。

応用課題の課題内容は誰でも閲覧できます。メンターによる成果物レビューや、設計判断の壁打ち(メンタリング)はポートフォリオプランでご提供しています。