1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DjangoアプリをNext.js + DRF + JWT + AWSに移行してみた #5 Terraform・AWSデプロイ編

1
Last updated at Posted at 2026-05-24

はじめに

前回の記事では JWT トークンを httpOnly Cookie に移行してセキュリティを強化した。

今回はいよいよ AWS へのデプロイを試みた。IaC ツールとして Terraform を使い、フロントエンドは Amplify、バックエンドは ECS Fargate、DBは RDS PostgreSQL という構成で動かす。

今回やったこと:

  • Terraform でフラット構成の infra/ を作成
  • terraform init → plan → apply で 34 リソースを一発構築
  • Docker イメージをビルドして ECR に push
  • ECS Fargate で Django API の起動を確認
  • ハマりポイントを複数踏み抜いて解決
  • terraform destroy で全削除

アーキテクチャ概要

インターネット
    ↓
[Amplify](フロントエンド / Next.js)
    ↓ NEXT_PUBLIC_API_URL
[ALB](Application Load Balancer)
    ↓
[ECS Fargate](バックエンド / Django)
    ↓
[RDS PostgreSQL](プライベートサブネット)

ネットワーク構成は以下のとおり:

  • パブリックサブネット:ALB、NAT Gateway
  • プライベートサブネット:ECS タスク、RDS

ECS はプライベートサブネットに置き、インターネットから直接アクセスできない。外向き通信(ECR からのイメージ pull など)は NAT Gateway 経由。


なぜこの構成にしたか

各コンポーネントの選定理由をまとめておく。

ECS Fargate(EC2 ではなく)

EC2 を使うと OS のパッチ当て・スケーリング設定・AMI 管理が必要になる。Fargate はコンテナイメージを渡すだけで AWS がサーバー管理を担ってくれる。

今回の目的は「ネットワーク・認証・デプロイフローを学ぶ」なので、OS 管理に時間を使わずに済む Fargate を選んだ。実務でも「サーバーレスコンテナ」として Fargate の採用が増えており、覚えておいて損はない。

ECS をプライベートサブネットに置く理由

ECS をパブリックサブネットに置けば NAT Gateway が不要になり $45/月 削減できる。ただしその場合、ECS タスクにパブリック IP が直接つき、インターネットから理論上到達可能になる。

実務の標準パターンは「ALB だけを外に出してバックエンドは隠す」構成。セキュリティグループで防御はできるが、そもそもインターネットに露出させないほうが安全の原則に沿っている。その学習も兼ねてプライベートサブネットに配置した。

NAT Gateway が必要な理由

プライベートサブネットにいる ECS は、外向き通信(ECR からのイメージ pull・SSM からの値取得など)ができない。そのための出口が NAT Gateway。

  • IGW(Internet Gateway):インターネット → VPC への入口
  • NAT Gateway:VPC → インターネットへの出口(プライベートサブネット用)

この役割分担を理解すると VPC の設計がわかりやすくなる。

ALB を挟む理由

ECS タスクは起動・停止のたびに IP アドレスが変わる。ALB がその変動を吸収して「常に同じ URL でアクセスできる」状態を作る。加えて以下の役割も担う:

  • ヘルスチェック:死んだタスクへのルーティングを自動で外す
  • 負荷分散:タスクを複数台に増やしたときに自動で分散
  • HTTPS 終端:ACM 証明書をここにアタッチして HTTPS 化できる

SSM Parameter Store で機密情報を管理する理由

terraform.tfvars に書いた値をそのまま ECS の環境変数にハードコードすると、Terraform の状態ファイル(terraform.tfstate)に平文で残ってしまう

SSM Parameter Store の SecureString は KMS で暗号化されて保存され、ECS タスクの起動時だけ復号される。コードにも state ファイルにも機密値が残らない。

secrets = [
  { name = "SECRET_KEY",  valueFrom = aws_ssm_parameter.django_secret_key.arn },
  { name = "DB_PASSWORD", valueFrom = aws_ssm_parameter.db_password.arn },
]

environment(平文)と secrets(SSM 参照)を使い分けるのが実務パターン。

Amplify(S3+CloudFront ではなく)

S3+CloudFront は静的サイトの王道構成だが、設定項目が多い(バケットポリシー・OAI・CloudFront ディストリビューション・キャッシュ設定など)。

Amplify は GitHub と接続するだけで CI/CD・CDN・HTTPS が自動でセットアップされる。学習の初期段階でフロントエンドのデプロイフローを素早く体験するには合っている。ただし今回遭遇したように、Next.js SSR との組み合わせは追加設定が必要になる場合がある。


Terraform の構成

infra/ にフラット構成で作成した。モノレポの学習用途なのでモジュール分割はせずシンプルに保った。

infra/
├── providers.tf          ← AWS プロバイダー・Terraform バージョン
├── variables.tf          ← 変数定義(パスワード等は sensitive)
├── outputs.tf            ← ALB DNS・ECR URL 等の出力
├── vpc.tf                ← VPC・サブネット・IGW・NAT GW・ルートテーブル
├── ecr.tf                ← ECR リポジトリ
├── rds.tf                ← RDS PostgreSQL
├── alb.tf                ← ALB・ターゲットグループ・リスナー
├── ecs.tf                ← ECS クラスター・タスク定義・サービス・IAM・SSM
├── amplify.tf            ← Amplify アプリ・ブランチ
└── terraform.tfvars      ← 機密値(.gitignore 済み)

providers.tf

terraform {
  required_version = ">= 1.6"

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

provider "aws" {
  region = var.aws_region
}

variables.tf

variable "aws_region" {
  type    = string
  default = "ap-northeast-1"
}

variable "app_name" {
  type    = string
  default = "movielogrecord"
}

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

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

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

sensitive = true をつけると terraform plan の出力やログに値が表示されなくなる。

vpc.tf(抜粋)

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = { Name = "${var.app_name}-vpc" }
}

# パブリックサブネット(ALB 用)
resource "aws_subnet" "public_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${var.aws_region}a"
  map_public_ip_on_launch = true
  tags = { Name = "${var.app_name}-public-a" }
}

# プライベートサブネット(ECS・RDS 用)
resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = "${var.aws_region}a"
  tags = { Name = "${var.app_name}-private-a" }
}

# NAT Gateway(プライベートサブネットからの外向き通信)
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_a.id
  depends_on    = [aws_internet_gateway.main]
}

マルチ AZ にするため ac の2ゾーンにサブネットを作成している。

rds.tf(抜粋)

resource "aws_db_instance" "main" {
  identifier        = "${var.app_name}-db"
  engine            = "postgres"
  engine_version    = "15"
  instance_class    = "db.t3.micro"
  allocated_storage = 20

  db_name  = "movielogrecord"
  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]

  multi_az            = false
  skip_final_snapshot = true
  deletion_protection = false
}

学習用なので multi_az = falseskip_final_snapshot = true(destroy 時にスナップショットを取らない)。

ecs.tf — 機密情報の扱い

パスワードや SECRET_KEY は SSM Parameter Store に保存し、ECS タスクが起動時に参照する構成にした。コードに機密値をハードコードしない。

resource "aws_ssm_parameter" "django_secret_key" {
  name  = "/${var.app_name}/django_secret_key"
  type  = "SecureString"
  value = var.django_secret_key
}

resource "aws_ecs_task_definition" "backend" {
  ...
  container_definitions = jsonencode([{
    name  = "backend"
    image = "${aws_ecr_repository.backend.repository_url}:latest"

    environment = [
      { name = "DEBUG",    value = "False" },
      { name = "DB_HOST",  value = aws_db_instance.main.address },
      { name = "ALLOWED_HOSTS", value = "*" },
    ]

    secrets = [
      { name = "SECRET_KEY",  valueFrom = aws_ssm_parameter.django_secret_key.arn },
      { name = "DB_PASSWORD", valueFrom = aws_ssm_parameter.db_password.arn },
    ]
  }])
}

environment は平文、secrets は SSM から取得という使い分け。SSM から読むために ECS タスク実行ロールに ssm:GetParameter 権限を付与する必要がある。


terraform apply の手順

# terraform.tfvars を作成(.gitignore 済みなので git には入らない)
cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars
db_password       = "your-password"
django_secret_key = "your-django-secret-key"
github_token      = "ghp_xxxxxxxxxxxx"
terraform init    # プロバイダーをダウンロード
terraform plan    # 作成されるリソースを確認
terraform apply -auto-approve  # 実際に作成

約 5〜10 分で 34 リソースが作成される。大半は一瞬だが RDS の起動に 5 分ほどかかる。


Docker イメージの ECR への push

インフラが完成しても、ECR にイメージがなければ ECS タスクは起動できない。

# ECR にログイン
aws ecr get-login-password --region ap-northeast-1 | \
  docker login --username AWS --password-stdin \
  825478277103.dkr.ecr.ap-northeast-1.amazonaws.com

# イメージをビルド(M1/M2 Mac の場合は --platform linux/amd64 が必要)
cd backend
docker build --platform linux/amd64 -t movielogrecord-backend .

# タグ付けして push
docker tag movielogrecord-backend:latest \
  825478277103.dkr.ecr.ap-northeast-1.amazonaws.com/movielogrecord-backend:latest

docker push \
  825478277103.dkr.ecr.ap-northeast-1.amazonaws.com/movielogrecord-backend:latest

M1/M2 Mac では --platform linux/amd64 をつけないと、ECS(x86_64)で動かないイメージができる。

push 後、ECS サービスを強制再デプロイして新イメージを反映:

aws ecs update-service \
  --cluster movielogrecord-cluster \
  --service movielogrecord-backend \
  --force-new-deployment

動作確認

ECS タスクが起動したら ALB 経由で API を叩いてみる:

curl -v http://<ALB-DNS>/api/movies/
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Bearer realm="api"

401 が返れば成功。JWT 認証が機能しており、未認証のリクエストを正しく弾いている。


ハマりポイント

① RDS のパスワードは 8 文字以上

InvalidParameterValue: The parameter MasterUserPassword is not a valid password
because it is shorter than 8 characters.

terraform apply を実行して初めて気づくエラー。AWS の RDS はパスワードに最低 8 文字を要求する。

② ALB ヘルスチェックが Django に弾かれる

ALB がヘルスチェックを送るとき、Host ヘッダーに ECS タスクの内部 IP を使う。Django の ALLOWED_HOSTS にこの IP が含まれていないため 400 が返り、ALB がターゲットを「不健全」と判断してしまった。

さらに、ヘルスチェックのパス(/api/movies/)は認証が必要なので 401 が返る。ALB のデフォルト設定では 200 以外は「不健全」扱いになる。

解決策:2点セットで対応。

# ecs.tf
{ name = "ALLOWED_HOSTS", value = "*" }   # 全ホストを許可(学習用)

# alb.tf
health_check {
  path    = "/api/movies/"
  matcher = "200-401"  # 401 も健全とみなす
}

⚠️ ALLOWED_HOSTS = "*" は本番では使わないこと。 ワイルドカードにすると任意のホストからのリクエストを受け付けてしまい、Host ヘッダーインジェクション攻撃のリスクがある。本番では ALB の DNS 名やカスタムドメインを明示的に指定すること。

③ Amplify の SSR(WEB_COMPUTE)がモノレポと相性が悪い

フロントエンドが httpOnly Cookie のプロキシ API Route(Next.js サーバーサイド機能)を使っているため、Amplify で SSR が必要になる。しかし Amplify Gen 1 の WEB_COMPUTE は Next.js のモノレポ構成(フロントエンドがサブディレクトリにある場合)を自動検出できず、ビルドは成功しても deploy-manifest.json が見つからないというエラーが出た。

CustomerError: Failed to find the deploy-manifest.json file in the build output.

根本原因: @aws-amplify/adapter-nextjs がないと Amplify が期待するファイルが生成されない。

対応方針(次回):

  • A案(簡単): output: 'export' で静的エクスポートに切り替え。認証を localStorage ベースに戻す。
  • B案(本格的): @aws-amplify/adapter-nextjs を導入して SSR のまま動かす。

今回はバックエンドの動作確認が目的だったので、フロントエンドの SSR 問題は次回に持ち越した。

④ ECR の destroy がイメージ残留でエラーになる

RepositoryNotEmptyException: The repository cannot be deleted
because it still contains images

Terraform はデフォルトでイメージが残っている ECR リポジトリを削除できない。force_delete = true を追加することで解決:

resource "aws_ecr_repository" "backend" {
  name         = "${var.app_name}-backend"
  force_delete = true  # イメージが残っていても強制削除
}

コスト感

今回の構成を常時起動した場合の月額目安:

リソース 月額目安
NAT Gateway ~$45(一番高い)
RDS db.t3.micro ~$19
ALB ~$18
ECS Fargate ~$5
合計 ~$87

学習目的で動作確認したらすぐ terraform destroy するのが賢明。コードが残っていれば次回も terraform apply で同じ環境を再現できる。

NAT Gateway はコストが高いが、ECS をプライベートサブネットに置くために必要。学習用に割り切るなら ECS をパブリックサブネットに置いて NAT Gateway をなくすことで $45 削減できる。


まとめ

対応内容 結果
Terraform 34 リソース作成
ECR へのイメージ push
ECS Fargate で Django API 起動 ✅(ALB 経由で 401 確認)
RDS マイグレーション
Amplify フロントエンド SSR ❌ 次回対応

今回一番ハマったのは ALB ヘルスチェックの問題で、ALLOWED_HOSTS の制約とヘルスチェックの成功条件の組み合わせが原因だった。Terraform の宣言的な書き方に慣れると「どのリソースが何に依存しているか」が視覚的に把握しやすく、インフラの全体像が掴みやすいと感じた。

次回は Amplify フロントエンドの問題を解消し、フルスタックでブラウザから動作確認する。

参考リンク

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?