ポートフォリオサイトを 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 バケットは空でした。
原因の整理
挙動を順に追うと、次の流れでした。
- ブラウザ → CloudFront に
/をリクエスト - CloudFront → S3 に
/index.htmlを取りに行く - S3: ファイルが無いので 403 (AccessDenied) を返す (OAC では 404 ではなく 403 で隠蔽)
- CloudFront:
custom_error_responseで「403 なら/index.htmlを 200 で返せ」を試みる - CloudFront → S3 に
/index.htmlを再取得 → やはり 403 - 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