0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Terraform】シンプルなNext.jsアプリをAWS ECS Fargateにデプロイ(IP制限まで)

Last updated at Posted at 2025-12-29

はじめに

マルチクラウド対応のTerraformを生成AIを活用して学習してみました。
今回は以下の手順で学習をしました。

①学習のゴールを自身で設定
②ゴールの達成において必要な準備と手順を生成AIに質問
③準備や手順ごとに、最新の公式ドキュメントやチュートリアルを確認

ゴール

  • シンプルなNext.jsアプリをAWS ECS Fargateにデプロイ(terraformの学習なのでアプリの内容は問わない)
  • 特定IPのみアクセス可能なセキュリティ設定とアクセス確認(業務に生かしたいので社内のみの制限などを見越して)
  • リソースの削除(節約)

システム構成図

今回のゴールを満たすシステム構成を生成AIに整理してもらいました

スクリーンショット 2025-12-29 11.32.50.png

ローカルで構築する際も比較のため整理してもらいました

スクリーンショット 2025-12-29 12.16.37.png

サイトにアクセスして画面が表示されるまでの流れ(ローカル環境)

①ブラウザにlocalhost:3000(127.0.0.1:3000)にアクセス

②ブラウザがPCのOSにアクセスをリクエスト

③OSがDockerへリクエストを渡す

④Dockerが3000ポートでリクエストを受け取り、コンテナ内の3000ポートにリクエストを転送

⑤コンテナ内のNext.jsサーバーがリクエストを受け取り、画面の情報をレスポンス

⑥レスポンスが逆の経路で戻り、ブラウザに画面の情報が表示される:Next.js → Docker → OS → ブラウザ

システム構成を考える上での以下の5つのポイント

ポイント1: アクセス方法の違い
項目 ローカル環境 AWS環境
アドレス localhost または 127.0.0.1 パブリックIPまたはドメイン
意味 自分自身のPCを指す特別なアドレス インターネット上の一意のアドレス
アクセス範囲 PC内のみ(外部から完全に遮断) 世界中からアクセス可能
必要なリソース なし インターネットゲートウェイ(IGW)、パブリックIPアドレス

ポイント2: ネットワーク空間の違い
項目 ローカル環境 AWS環境
ネットワーク OSのネットワークスタック VPC(Virtual Private Cloud)
IP範囲 127.0.0.1(固定) 自分で設計(例: 10.0.0.0/16
分離 PC内で閉じている 他のAWSユーザーと物理的に完全分離
必要なリソース なし VPC、サブネット(複数)

ポイント3: 通信経路制御の違い
項目 ローカル環境 AWS環境
経路制御 OSが自動的に処理 ルートテーブルで明示的に設定
デフォルト動作 ループバック(自分自身に返す) 設定しないと通信不可
設定 不要 必須(どこに転送するか指定)
必要なリソース なし ルートテーブル、IGWへのルート設定、サブネットとの関連付け

ポイント4: セキュリティ制御の違い
項目 ローカル環境 AWS環境
保護 OSレベルで自動的に外部から遮断 セキュリティグループで明示的に制御
デフォルト 127.0.0.1は外部からアクセス不可 すべて拒否(ホワイトリスト方式)
アクセス制御 不要 必須(IPアドレス、ポート単位で設定)
必要なリソース なし セキュリティグループ(インバウンド/アウトバウンドルール)

ポイント5: コンテナ実行環境の違い
項目 ローカル環境 AWS環境
実行基盤 Docker Desktop ECS(Elastic Container Service)+ Fargate
イメージ保管 ローカルストレージ ECR(Elastic Container Registry)
ネットワーク設定 docker run -p 3000:3000 タスク定義で設定
管理 手動で起動・停止 自動管理(常時起動、障害時再起動)
必要なリソース なし ECRリポジトリ、ECSクラスター、ECSタスク定義、ECSサービス

サイトにアクセスして画面が表示されるまでの流れ(クラウド環境)

最終的に、以下の流れを実現するような構成をterraformで構築しました。

①ブラウザからパブリックIP:3000にアクセス

②ブラウザがインターネット経由でAWSにリクエストを送信

③インターネットゲートウェイ(IGW)がリクエストを受け取り、VPC内へ転送

④ルートテーブルがリクエストの宛先を確認し、適切なサブネットへルーティング

⑤セキュリティグループがリクエスト元のIPアドレスとポート番号をチェックし、許可されていれば通過させる

⑥サブネット内のECSタスク(コンテナ)の3000ポートにリクエストが到達

⑦ECSタスク内のNext.jsサーバーがリクエストを受け取り、画面の情報をレスポンス

⑧レスポンスが逆の経路で戻り、ブラウザに画面の情報が表示される:
Next.js → ECSタスク → セキュリティグループ → サブネット → ルートテーブル → IGW → インターネット → ブラウザ

使用したAWSサービス

  • ECS Fargate
  • ECR
  • VPC/Subnet/IGW
  • IAM
  • CloudWatch Logs

開発環境

  • macOS(M2、ARM64)
  • Docker Desktop
  • Terraform v1.14.3
  • AWS CLI
  • Node.js 20

アプリの準備

mkdir app
npx create-next-app@latest .
app/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000

CMD HOSTNAME=0.0.0.0 node server.js
docker build -t terraform-study-app .
docker run -p 3000:3000 terraform-study-app

3000にアクセスして、「My First AWS App with Terraform」と表示されていればOK

terraformでawsリソースを構築

初期設定

インストール含め、まずは公式チュートリアルを行いました。

appディレクトリと同じ階層でterraformディレクトリを作成し、プロバイダー設定とterraformの初期化を行います
書き方は公式ドキュメントに記載されています

main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

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

ネットワーク基盤の構築

システム構成図におけるネットワーク基盤(VPC/Subnet/IGW)を構築します。

main.tfに追記します:

main.tf
# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "main-vpc"
  }
}

# パブリックサブネット1(東京AZ-a)
resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "ap-northeast-1a"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-1"
  }
}

# パブリックサブネット2(東京AZ-c)
resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "ap-northeast-1c"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-2"
  }
}

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

  tags = {
    Name = "main-igw"
  }
}

以下を実行し、AWSコンソールでVPC/Subnet/IGWが作成されていることを確認できればOK

terraform plan
terraform apply

通信経路の設定

システム構成図における通信経路(ルートテーブル、Subnetとの関連付け)を構築します。

main.tfに追記します:

main.tf
# ルートテーブル
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  # IGWへ送る
  }

  tags = {
    Name = "public-route-table"
  }
}

# サブネット1にルートテーブルを関連付け
resource "aws_route_table_association" "public_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

# サブネット2にルートテーブルを関連付け
resource "aws_route_table_association" "public_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}
terraform apply

VPCのリソースマップからも関連付けを確認できます

スクリーンショット 2025-12-29 13.28.07.png


セキュリティの設定

IP制限を行います。
以下のコマンドで表示されたIPアドレスに/32をつけてセキュリティグループに設定します。

curl checkip.amazonaws.com

main.tfに追記します:

main.tf
resource "aws_security_group" "ecs_tasks" {
  name        = "ecs-tasks-sg"
  description = "Security group for ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 3000
    to_port     = 3000
    protocol    = "tcp"
    cidr_blocks = ["123.456.789.012/32"]  # ← curl checkip.amazonaws.comの結果
    description = "Allow access from my IP"
  }

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

  tags = {
    Name = "ecs-tasks-sg"
  }
}
terraform apply

EC2のセキュリティグループにリソースが作成されていることを確認できればOK。
インバウンドルールに自身のIP/32:3000が設定されていて、アウトバウンドルールに0.0.0.0/0が設定されているはずです。


コンテナ基盤の構築

アプリケーションの実行基盤を構築します。

ECRリポジトリの作成

まずはDockerイメージのプッシュ先となるECRリポジトリを作成します。

main.tfに追記します:

main.tf
# ECRリポジトリ
resource "aws_ecr_repository" "app" {
  name                 = "nextjs-app"
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = false
  }

  tags = {
    Name = "nextjs-app-repo"
  }
}

output "ecr_repository_url" {
  description = "ECR repository URL"
  value       = aws_ecr_repository.app.repository_url
}
terraform apply

ECS/IAM/CloudWatch Logsの構築

続けてmain.tfに追記します:

main.tf
# CloudWatch Logsグループ
resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/nextjs-app"
  retention_in_days = 7

  tags = {
    Name = "ecs-nextjs-logs"
  }
}

# IAMロール(ECSタスク実行用)
resource "aws_iam_role" "ecs_task_execution_role" {
  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"
      }
    }]
  })

  tags = {
    Name = "ecs-task-execution-role"
  }
}

# AWS管理ポリシーをアタッチ(ECRからイメージ取得など)
resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" {
  role       = aws_iam_role.ecs_task_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# CloudWatch Logs用の権限
resource "aws_iam_role_policy" "ecs_logs" {
  name = "ecs-logs-policy"
  role = aws_iam_role.ecs_task_execution_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ]
      Resource = "arn:aws:logs:ap-northeast-1:*:log-group:/ecs/nextjs-app:*"
    }]
  })
}

# ECSクラスター
resource "aws_ecs_cluster" "main" {
  name = "nextjs-cluster"

  tags = {
    Name = "nextjs-cluster"
  }
}

# ECSタスク定義
resource "aws_ecs_task_definition" "app" {
  family                   = "nextjs-app"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"   # 0.25 vCPU
  memory                   = "512"   # 512 MB
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn

  container_definitions = jsonencode([{
    name      = "nextjs"
    image     = "${aws_ecr_repository.app.repository_url}:latest"
    essential = true

    portMappings = [{
      containerPort = 3000
      protocol      = "tcp"
    }]

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = "/ecs/nextjs-app"
        "awslogs-region"        = "ap-northeast-1"
        "awslogs-stream-prefix" = "ecs"
      }
    }
  }])

  tags = {
    Name = "nextjs-task"
  }
}

# ECSサービス(タスクを常時起動)
resource "aws_ecs_service" "app" {
  name            = "nextjs-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = [aws_subnet.public_1.id, aws_subnet.public_2.id]
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = true
  }

  tags = {
    Name = "nextjs-service"
  }
}

output "ecs_task_public_ip" {
  description = "ECS task public IP (check after task is running)"
  value       = "Check ECS console for the task's public IP"
}

この状態でapplyしてもECRにアプリケーションのイメージが存在しないため、ECSがタスクの起動に失敗します。
次のセクションでDockerイメージをプッシュします。


Dockerイメージのプッシュ

apply成功時に表示されたecr_repository_urlをコピーします。

以下のコマンドでECRにログインします:

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin あなたのECR_URL_のドメイン部分

Dockerfileが存在するディレクトリでビルドします。

:warning:注意:
AWS FargateはデフォルトでAMD64アーキテクチャを使用するため、M1/M2 Macの場合は--platform linux/amd64 を指定する必要があります。

docker build --platform linux/amd64 -t nextjs-app .

イメージのタグ付けし、ECRにpushします:

docker tag nextjs-app:latest あなたのECR_URL:latest
docker push あなたのECR_URL:latest

デプロイとアクセス確認

ECRにイメージがプッシュされると、ECSのタスクが起動し始めます。

タスクのステータスが実行中になったことを確認できたら、ネットワーキングタブからパブリックIPをコピーし、パブリックIP:3000でブラウザからアクセスしてみましょう!

画面が表示されるはずです!

スクリーンショット 2025-12-29 14.03.03.png

同じアドレスでスマホからアクセスすると画面が表示されないことまで確認できればOK
(PCと同じWiFi使っている場合は接続を切りましょう)

お片付け

terraform destroy
terraform show   
The state file is empty. No resources are represented.

おわりに

CloudFormationも少し触っていたことがありますが、terraformの方が書きやすい、見やすい印象でした。
今後はGitHub ActionsがAWSにアクセスするための設定や一連のCI/CDパイプラインの自動化にも活用していきたいです。
余談ですが生成AIは「どう書けばいいか」は教えてくれますが、「なぜそうなるのか」「正しいのか」の確認や理解は自分で行う必要があるので、これまで以上に頭を使う場面が増えたなと感じています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?