🐲

ポートフォリオサイトを ECS Fargate から S3 + CloudFront に移行した話

に公開

はじめに

ポートフォリオサイトを ECS Fargate のコンテナ配信から、S3 + CloudFront の静的配信に移行した話 をまとめます。

1 本目の記事 では、自己紹介サイトを React + Vite + ECS Fargate + Terraform で構築した経緯をまとめました。本記事はその続編で、動いている構成をなぜ・どう変えたのか を扱います。

技術手順より、設計判断の過程を書き残す ことに重点を置いた記事です。

この記事で読み取れること

  • コンテナ配信 → 静的配信への "ダウンサイズ" を選んだ判断軸
  • 既存コードを destroy せず、トグルで状態だけを切り替える設計
  • CloudFront + S3 (OAC) 構成の作り込み (SPA フォールバック含む)
  • 移行直後にハマった AccessDenied の診断プロセス

想定読者

  • 個人開発の AWS 構成を見直したい方
  • インフラの判断軸をどう語るかの参考を探している方
  • S3 + CloudFront を OAC で組むモダンな構成例を探している方

移行のトリガー — なぜ最初から S3 にしなかったか

「静的 SPA なら最初から S3 + CloudFront でよかったのでは?」と思われるかもしれません。実際、ALB の固定費は構築前から認識していました。月 ~$22 が地味に効くこと、自分しか使わないサイトに 24/7 のロードバランサーは過剰なこと。それでも当初は ECS Fargate 構成を選んでいます。

理由は 使ったことのなかったコンテナと CI/CD を、実践的に学びたかった からです。業務では IaC (Terraform / Ansible) を触っていた一方、コンテナと CI/CD は経験が浅かった ため、ここを個人プロジェクトで補強したい、というのが第一の動機でした。

加えて、S3 + CloudFront 構成では学習量が足りない という事情もありました。

  • 静的ファイルを置いてキャッシュさせるだけだと、コンテナのビルド・配置や ECS のローリング更新といった自分が触りたかった領域に届かない
  • IaC を一通り書く題材としても、リソース数が少なくスコープが小さい

ALB のコストは、これらの学習量を確保するための 対価として受け入れていた 形です。

そして当初の学習目的が達成された今、判断軸を切り替える段階に入りました。

  • 学習目的が一段落: コンテナ / ECS / ALB / CI/CD (ECR push + ECS update-service) を一通り経験できた
  • 運用観点への移行: 固定費 ~$22/月 は個人サイトの用途に対して重い
  • destroy/apply 運用の手間: 開発のたびに ALB+ECS を起動・停止し、Cloudflare CNAME も毎回更新する状態

移行後の構成と "残すもの・捨てるもの"

Before / After

Before (v1.0)

After (v2.0)

残すもの・捨てるものの判断

判断は、次にバックエンド付きの構成を作る想定 から逆算しました。今回の S3 + CloudFront だけでは静的配信に閉じるため、動的処理を扱う API サーバを将来的に追加する前提で、残置・撤去を選別しています。

リソース 判断 理由
ALB / ECS Service コードは残す、リソースは作らない バックエンド追加時に再利用
ECS タスク定義 / クラスター / ECR / IAM 実行ロール 完全に残す 同上
CloudFront の ALB origin 定義 削除 バックエンド追加時にビヘイビアとして再追加
デプロイ用 IAM 権限 差し替え ECR/ECS → S3/CloudFront へ

肝は、enable_serving という変数で count = var.enable_serving ? 1 : 0 を切り替える設計を維持したことです。

resource "aws_lb" "main" {
  count = var.enable_serving ? 1 : 0
  # ...
}

resource "aws_ecs_service" "main" {
  count = var.enable_serving ? 1 : 0
  # ...
}

これで バックエンドを追加する際に terraform apply -var enable_serving=true を打つだけで ALB+ECS が復活 する状態を保てます。コードを書き直す手間がない、という地味だけど効く設計です。

技術選定の判断ログ

配信方式: OAC を選定

S3 を CloudFront のoriginにする方法は 3 つ候補がありました。

候補 概要 採否
Public Bucket バケットを public にして直接配信 ✗ セキュリティ面で避けたい
OAI (Origin Access Identity) CloudFront 専用 ID で S3 にアクセス △ 旧推奨方式
OAC (Origin Access Control) OAI の後継、SigV4 で署名付きアクセス ◎ 採用

OAC を選んだ理由:

  • AWS の現行推奨方式。新規構築で OAI を選ぶ理由が見当たらない
  • SourceArn 条件: バケットポリシーで AWS:SourceArn を見ることで、別の CloudFront distribution からのアクセスも防げる
  • S3 を private に保ったまま、CloudFront 経由のみアクセス許可できる

実装は次のような形です。

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

data "aws_iam_policy_document" "frontend_bucket" {
  statement {
    sid       = "AllowCloudFrontServicePrincipalReadOnly"
    effect    = "Allow"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.frontend.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.main.arn]
    }
  }
}

SPA フォールバック: "保険" として入れる判断

React SPA でクライアントサイドルーティング (react-router-dom など) を使っている場合、/about のような直接アクセスで S3 が 404 を返してしまう問題があります。

このサイト自体は単一ページ構成のため必須ではありませんが、入れておいた理由は次の通りです。

  • 将来の保険: ルーティングを後から追加した時に思い出さずに済む
  • 設定コストがゼロに近い: CloudFront の custom_error_response を追加するだけ
  • 入れない理由が見当たらない
custom_error_response {
  error_code            = 403
  response_code         = 200
  response_page_path    = "/index.html"
  error_caching_min_ttl = 60
}

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

注意点: OAC + private S3 では、存在しないオブジェクトに対して 404 ではなく 403 が返ります (オブジェクトの存在を隠蔽するため)。404 だけ設定していると効きません。

Terraform / CI/CD の改修

ファイル単位で見ると次の変更を行いました。

Terraform 側

ファイル 変更
s3.tf (新規) S3 バケット (private) + Public Access Block + OAC + バケットポリシー
cloudfront.tf origin を ALB → S3 に切替、SPA フォールバック追加
iam.tf deploy ロール権限を ECR/ECS → S3 sync + CloudFront invalidation に差し替え
outputs.tf distribution_id とバケット名の output を追加

alb.tf / ecs.tf意図的にノータッチ です。後で再利用するため、コードを残しておきます。

CI/CD (deploy.yml)

CI/CD のフローは ECS デプロイ用から S3 デプロイ用へ全面書き換えとなりました。

Before (旧フロー)

After (新フロー)

新フローの YAML はこちら:

- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v6
  with:
    role-to-assume: ${{ env.AWS_ROLE_ARN }}
    aws-region: ${{ env.AWS_REGION }}
- run: aws s3 sync dist/ s3://$S3_BUCKET/ --delete
- run: |
    aws cloudfront create-invalidation \
      --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
      --paths "/*"

最小権限の原則 を維持しつつ差し替えるため、IAM ポリシーも対称的に書き換えました。

# Before (一部)
{ Sid = "EcrPush",   Action = ["ecr:PutImage", ...] }
{ Sid = "EcsDeploy", Action = ["ecs:UpdateService", ...] }

# After
{ Sid = "S3Sync",               Action = ["s3:PutObject", "s3:DeleteObject", ...] }
{ Sid = "CloudFrontInvalidate", Action = ["cloudfront:CreateInvalidation", ...] }

ECR/ECS は次に復活させる際に同様の対称性で戻す予定なので、権限の引き算と足し算の対応 を意識して書きました。

つまずきと学び — AccessDenied の正体

terraform apply 完了直後にブラウザで https://jishinzerogon.dev を開いたら、画面に 生の S3 エラー XML が表示されました。

<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
</Error>

最初は OAC のバケットポリシー設定ミスを疑いました。しかし、デプロイ前のため S3 バケットは空でした。

原因の整理

挙動を順に追うと、次の流れでした。

  1. ブラウザ → CloudFront に / をリクエスト
  2. CloudFront → S3 に /index.html を取りに行く
  3. S3: ファイルが無いので 403 (AccessDenied) を返す (OAC では 404 ではなく 403 で隠蔽)
  4. CloudFront: custom_error_response で「403 なら /index.html を 200 で返せ」を試みる
  5. CloudFront → S3 に /index.html を再取得 → やはり 403
  6. CloudFront: フォールバック先も取れないため、origin の元のエラーをそのまま返す

AWS のドキュメントによると、カスタムエラーページ自体が取得できない場合、CloudFront は origin の元のエラーをそのまま返す 仕様です。

学んだこと

  • リソース作成とコンテンツ投入のタイミング差 は意識する必要がある。インフラ apply 直後はサイトが "空" の状態
  • CloudFront のフォールバック挙動は 連鎖しない。フォールバック先が取れないと、原エラーがそのまま見える
  • private S3 + OAC 構成で見える 403 は、「権限不足」と「ファイル不在」の両方 を意味することがある (区別がつかない)

修正は単純で、deploy.yml を走らせて dist/ を S3 に同期しただけです。サイトはすぐに復旧しました。

設定ミスを疑った最初の数分が、結果的に CloudFront の挙動を一段深く理解する 機会になりました。

コスト効果と運用変化

コスト

項目 Before (ECS Fargate) After (S3 + CloudFront)
ALB ~$22/月 (常時稼働時) 撤去
ECS Fargate 稼働時間ぶん 停止維持 (バックエンド追加時に復活)
S3 <$0.01/月
CloudFront $0 (Always Free 1TB/月で十分)
合計 (常時稼働ベース) ~$22+ / 月 ~$0.10 / 月

CloudFront の Always Free 枠 (永続) は 月 1TB の転送 + 10M リクエスト までカバーするので、個人ポートフォリオ規模ならほぼ実質無料です。

運用

観点 Before After
開発外の運用 ALB+ECS を destroy / apply で起動停止 常時稼働で何もしない
Cloudflare CNAME 更新 CloudFront 再作成のたびに必要 不要
デプロイ後の確認 ECS タスクの起動待ち S3 sync が即時完了

「触らない」運用 に近づいたのが、体感としてわかりやすい変化です。

まとめ

全体で得たもの

  • 判断の言語化: 「降りる」判断を、コスト・運用・将来拡張の 3 観点で説明できるようになった
  • 既存資産の活かし方: ECS のコードを残し、count トグルで状態だけ管理する設計の手応え
  • CloudFront + S3 (OAC) の作法: SourceArn 条件、SPA フォールバックの 403 含めた挙動

振り返り

ECS Fargate 構成を作る前から「S3 でいい」と判断するのは、自分には難しい段階でした。一度 コンテナで作ってから降りた からこそ、「用途に合った最小構成は何か」を実感できました。作って、運用して、見直す という当たり前のサイクルが、設計判断を鍛える上で価値のあるプロセスでした。

Discussion