CloudFront + S3 で SPA をちゃんと配信する

概要

React / Vue などの SPA(Single Page Application)を本番配信する定番構成が「S3 にビルド成果物を置き、CloudFront から配信する」パターンです。手順自体はシンプルですが、素直に作ると次のような落とし穴にハマります。

  • リロードすると 403 / 404 になる(SPA ルーティングの罠)
  • index.html がキャッシュされ続けてデプロイが反映されない
  • S3 を公開設定にしてしまいセキュリティ的に不安が残る

この記事では、S3 バケットを非公開のまま、OAC(Origin Access Control)で CloudFront からのみアクセスさせ、SPA のルーティングとキャッシュ戦略まで含めて配信する構成をハンズオン形式でまとめます。

環境

  • AWS アカウント(検証用の個人アカウントを想定)
  • AWS CLI v2
  • Node.js 20 以上
  • 配信するもの: 任意の SPA のビルド成果物(dist/ または build/)
  • リージョン: 東京(ap-northeast-1)を基本。ただし後述の理由で ACM 証明書のみ us-east-1

構成図(抽象化したレベル):

ユーザー
  │  HTTPS
  ▼
CloudFront (Distribution)
  │  OAC で署名付きリクエスト
  ▼
S3 (非公開バケット / 静的アセット)

発生した問題

SPA を「とりあえず配信」しようとすると、次の 3 つに必ずぶつかります。

1. リロードで 404 になる

SPA はクライアント側ルーティングです。/users/123 のような URL を直接開くと、S3 には users/123 というオブジェクトが存在しないため 404(バケットポリシー次第では 403)が返ります。トップページからの遷移では動くのに、リロードやURL直打ちだけ壊れる、という厄介な症状になります。

2. デプロイが反映されない

index.html に長い Cache-Control を付けると、新しいバージョンをアップロードしても CloudFront / ブラウザが古い index.html を返し続けます。逆に全ファイルをキャッシュ無効にすると CDN の意味が薄れます。

3. S3 を公開したくない

s3:GetObject を Principal: * で開けてしまう記事も多いですが、バケットを直接インターネットに晒すのは避けたいところです。OAC を使えば非公開のまま CloudFront 経由に限定できます。

原因

  • 404 問題: S3 はあくまでオブジェクトストレージで、SPA のルーティングを知らない。存在しないキーは素直に 404 を返す。
  • キャッシュ問題: ハッシュ付きアセット(app.a1b2c3.js)と index.html を同じキャッシュ戦略で扱っているため。
  • 公開問題: S3 の静的ウェブサイトホスティング機能を使うと、バケットを public にする必要が出てくる。OAC を使えば非公開のまま CloudFront 経由に限定できる。

対応方法

方針は以下の通りです。

  1. S3 バケットは Block Public Access を全部 ON のままにする
  2. CloudFront に OAC を設定し、バケットポリシーで「この Distribution からのみ許可」する
  3. CloudFront の カスタムエラーレスポンスで、403/404 を /index.html(200)にフォールバックさせる(SPA ルーティング対応)
  4. キャッシュは index.html は短命 / ハッシュ付きアセットは長命 に分ける

手順 1: S3 バケット作成とアップロード

# バケット作成(非公開のまま)
aws s3 mb s3://example-spa-bucket --region ap-northeast-1

# Block Public Access を明示的に全 ON
aws s3api put-public-access-block \
  --bucket example-spa-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# ビルド成果物をアップロード
aws s3 sync ./dist s3://example-spa-bucket --delete

手順 2: OAC 付き CloudFront を作る

コンソールでも作れますが、再現性のため CDK(TypeScript)で書きます。ここが一番のキモです。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";

export class SpaHostingStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. 非公開の S3 バケット
    const bucket = new s3.Bucket(this, "SpaBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      removalPolicy: cdk.RemovalPolicy.RETAIN, // 本番は誤削除防止
    });

    // 2. CloudFront + OAC(S3BucketOrigin.withOriginAccessControl が
    //    OAC の作成とバケットポリシー付与を自動でやってくれる)
    const distribution = new cloudfront.Distribution(this, "SpaDistribution", {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
        viewerProtocolPolicy:
          cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
      },
      defaultRootObject: "index.html",

      // 3. SPA ルーティング対応:403/404 を index.html(200) にフォールバック
      errorResponses: [
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: "/index.html",
          ttl: cdk.Duration.seconds(0),
        },
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: "/index.html",
          ttl: cdk.Duration.seconds(0),
        },
      ],
    });

    // 4. デプロイ:アセットは長命キャッシュ、index.html は別途上書き
    new s3deploy.BucketDeployment(this, "DeploySpa", {
      sources: [s3deploy.Source.asset("./dist")],
      destinationBucket: bucket,
      distribution,
      distributionPaths: ["/*"], // デプロイ時に自動 invalidation
    });

    new cdk.CfnOutput(this, "DistributionDomain", {
      value: distribution.distributionDomainName,
    });
  }
}

errorResponses で 403 も拾っているのがポイントです。OAC 構成では存在しないキーに対して S3 が 403 を返すため(s3:ListBucket 権限を渡していない場合)、404 だけ拾うと SPA ルーティングが動きません。

手順 3: index.html のキャッシュだけ短命にする

CDK の BucketDeployment だけだと index.html も含めて一律キャッシュになるので、CI で以下のように上書きするのが実務的です。

# ハッシュ付きアセットは長命(immutable)
aws s3 sync ./dist s3://example-spa-bucket \
  --exclude "index.html" \
  --cache-control "public, max-age=31536000, immutable"

# index.html は常に取り直させる
aws s3 cp ./dist/index.html s3://example-spa-bucket/index.html \
  --cache-control "no-cache, no-store, must-revalidate"

# index.html だけ invalidation(コスト最小)
aws cloudfront create-invalidation \
  --distribution-id XXXXXXXXXXXX \
  --paths "/index.html"

app.a1b2c3.js のようにファイル名にハッシュが入っていれば、内容が変われば URL も変わるため長命キャッシュで安全です。index.html だけ毎回取り直せば、新しいアセットへの参照が確実に配られます。

カスタムドメインを付ける場合

独自ドメイン + HTTPS にするなら ACM 証明書が必要です。CloudFront が参照する証明書は us-east-1(バージニア北部)で発行する必要がある点に注意してください。東京リージョンで作ると CloudFront に紐付けられません。

確認方法

デプロイ後、次を確認します。

# 1. トップページが 200 で返るか
curl -I https://xxxxxxxxxxxxx.cloudfront.net/

# 2. 深いパスを直接叩いてリロード相当を再現(200 + index.html が返れば成功)
curl -sI https://xxxxxxxxxxxxx.cloudfront.net/users/123 | head -n 1
# → HTTP/2 200

# 3. S3 を直接叩いて 403 になる(=公開されていない)ことを確認
curl -I https://example-spa-bucket.s3.ap-northeast-1.amazonaws.com/index.html
# → 403 Forbidden なら OK(CloudFront 経由のみアクセス可)

# 4. キャッシュヘッダの確認
curl -sI https://xxxxxxxxxxxxx.cloudfront.net/index.html | grep -i cache-control
curl -sI https://xxxxxxxxxxxxx.cloudfront.net/assets/app.a1b2c3.js | grep -i cache-control

判定基準:

  • 深いパスが 200 で index.html の内容が返れば SPA ルーティング成功
  • S3 直アクセスが 403 なら非公開が担保されている
  • index.html は no-cache、アセットは max-age=31536000 になっていれば OK

注意点

  • OAC と OAI を混同しない: OAI(Origin Access Identity)は旧方式。新規構築は OAC を使う。SSE-KMS 暗号化との相性も OAC の方が良い。
  • 403 も SPA フォールバックの対象に入れる: OAC 構成では 404 でなく 403 が返るケースがある。両方 errorResponses に入れる。
  • エラーフォールバックの副作用: 403/404 を一律 200 の index.html にすると、本来 404 であるべき API 的な扱いもトップに落ちる。API は別オリジン / 別 Behavior に分離する。
  • invalidation はコストに注意: /* の invalidation は月 1000 パスまで無料だが、毎デプロイで多用すると課金対象になる。ハッシュ付きアセット運用にして index.html だけ invalidate するのが安上がり。
  • 証明書は us-east-1: これは仕様。忘れると詰まる。

まとめ

  • S3 は 非公開のまま、OAC で CloudFront からのみ読ませる
  • SPA のリロード 404 は 403/404 → /index.html(200) のカスタムエラーレスポンスで解決
  • キャッシュは index.html 短命 / ハッシュ付きアセット長命 で分ける
  • カスタムドメインの証明書は us-east-1 で発行

この 4 点を押さえれば、安全で反映の速い SPA 配信基盤が作れます。CDK にしておくと環境の再現とレビューが楽になるので、コンソール手作業より最初から IaC 化するのがおすすめです。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。