SPA公開用のリソースをCloudFormationで作る

インフラ担当の柴田です。

今回はSPA(Single Page Application)をAWSで公開するためのCloudFrontとS3をCloudFormationで作成する方法の紹介です。

今回の構成

今回紹介する構成以下の図のようにS3にSPAのコンテンツを設置して、CloudFront経由で配信するシンプルな構成です。よくある構成ですね。

f:id:a-shibata_fenrir:20210329175823p:plain
構成図

S3だけでもSPAの公開はできますが、殆どの場合は独自ドメインでHTTPSを使って公開することになるのでCloudFrontを経由させます。

また、今回の構成ではS3の静的ウェブサイトホスティングは利用しません。 静的サイトホスティングを有効にするとCloudFrontを迂回したアクセスを拒否するのが難しいためです。

構成のポイント

今回の構成の最大のポイントはテンプレートの下記の記述です。

CloudFrontのカスタムエラーレスポンスを使用して、404エラーの時に/index.htmlを返すようにしている箇所です。

SPAのアプリケーションで例えばメニューを表示した時に、ブラウザのアドレスバーのパスが/menuのようなパスになるとします。しかし、実際には/menuのようなパスにリソースは存在しません。 そのため、ブラウザをリロードしたり対象のパスに直接アクセスすると404エラーになります。

そこで、404エラーが発生したときに/index.htmlを返すことで、SPAアプリケーションでリクエストされたパスを処理してメニューを表示します。

        CustomErrorResponses:
          - ErrorCode: 404 # Return index.html to control the path by SPA.
            ResponsePagePath: "/index.html"
            ResponseCode: 200
            ErrorCachingMinTTL: 30

注意

今回紹介したSPAはルートディレクトリにあるindex.htmlで全て処理をするタイプのSPAようです。 Next.jsのStatic Generation等、サブディレクトリにもindex.htmlを生成するようなSPAには対応できません。

サブディレクトリにもindex.htmlが存在するようなSPAに対応するには、S3の静的ウェブサイトホスティングを使うかLambda@Edgeを使う必要があります。

テンプレート全文

以下が、テンプレートの全文です。 使用するには別途バージニア北部リージョンでACMを利用して証明書の発行が必要です。

AWSTemplateFormatVersion: "2010-09-09"
Description: "S3 and CloudFront"

Parameters:
  ProjectName:
    Description: "Project name"
    Type: "String"
    Default: "spa"
  EnvCode:
    Description: "Environment Code"
    Default: "dev"
    Type: "String"
    AllowedValues:
      - "dev"
      - "stg"
      - "prd"
  SiteName:
    Description: "Site Name"
    Default: "example"
    Type: "String"
  FQDN:
    Description: "FQDN"
    AllowedPattern: "^(\\*\\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$"
    Type: "String"
  ACMArn:
    Description: "ACM Arn in use-east-1"
    AllowedPattern: "arn:aws:acm:us-east-1:.*"
    Type: "String"

Resources:
  # SPAのソースをアップロードするバケット
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "${ProjectName}-${EnvCode}-${SiteName}-site"
  # CloudFrontからのアクセスを許可するポリシーの作成
  BucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties:
      Bucket: !Ref Bucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}"
            Action: "s3:GetObject"
            Resource: !Sub "arn:aws:s3:::${Bucket}/*"
          - Effect: "Allow"
            Principal:
              AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}"
            Action: 
              - "s3:ListBucket"
            Resource: !Sub "arn:aws:s3:::${Bucket}"
  # OAI
  OriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub "${ProjectName}-${EnvCode}-${SiteName}"
  # アクセスログ用のバケット
  AccessLogBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      AccessControl: LogDeliveryWrite
      BucketName: !Sub '${ProjectName}-${EnvCode}-${SiteName}-accesslogs'
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  # S3をオリジンにしたCloudFront
  Distribution:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        Origins:
          - Id: !Ref Bucket
            DomainName: !GetAtt "Bucket.DomainName"
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${OriginAccessIdentity}"
        DefaultCacheBehavior:
          TargetOriginId: !Ref Bucket
          ViewerProtocolPolicy: "redirect-to-https"
          AllowedMethods:
            - "GET"
            - "HEAD"
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none
        DefaultRootObject: "index.html"
        Logging:
          IncludeCookies: false
          Bucket: !GetAtt AccessLogBucket.DomainName
          Prefix: ""
        Comment: !Sub "${AWS::StackName}"
        Aliases:
          - !Ref "FQDN"
        ViewerCertificate:
          AcmCertificateArn: !Ref "ACMArn"
          SslSupportMethod: "sni-only"
        Enabled: true
        CustomErrorResponses:
          - ErrorCode: 404 # 404の時に/index.htmlを返す
            ResponsePagePath: "/index.html"
            ResponseCode: 200
            ErrorCachingMinTTL: 30

Outputs:
  S3Bucket:
    Description: The bucket where the SPA sources are uploaded
    Value: !Ref Bucket
    Export:
      Name: !Sub "${ProjectName}-${EnvCode}-${SiteName}-bucket"