インフラ担当の柴田です。
今回はSPA(Single Page Application)をAWSで公開するためのCloudFrontとS3をCloudFormationで作成する方法の紹介です。
今回の構成
今回紹介する構成以下の図のようにS3にSPAのコンテンツを設置して、CloudFront経由で配信するシンプルな構成です。よくある構成ですね。
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"